using Assets.MyGame.Scripts.Net; using Newtonsoft.Json; using Spine.Unity; using System; using System.Collections; using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; using static Assets.MyGame.Scripts.Net.WebResponses.ChineseChess.ChineseChessGameClassList; public class HintData { public ChessGameRoom roomData; public int hintType; //1求和2认输 } /// /// GoldConfigRequired Socket 推送数据 /// [Serializable] public class GoldConfigRequiredData { public string targetUserId; public ChessGameRoom room; } public enum CountdownType { WaitPlayer, // 等待玩家进入,超时 → 退出到选房 WaitHostStart, // 等待房主开始游戏,超时 → 退出到选房 WaitSetCoin, // 等待设置金额,超时 → 退出到选房 WaitOpponent, // 等待对手操作,超时 → 自动处理 } public class ChineseChessMainPanel : GameMainPanel_Base { public Button btn_QiuHe; public Button btn_RenShu; public Button btn_HuiQi; public Button btn_SheZhi; public Button btn_GuiZe; public Button btn_StartGame; public Button btn_TiRen; // 踢人按钮(拖拽:房主 + 开局前 + 双方在房间时自动显示,其余情况自动隐藏) public Image img_DengDaiZhong; public ChessGameRoom roomData; /// 4.4 已退出的房间 ID:退房后忽略该房间的后续推送(重新进房时由 MarkJoiningRoom 解除) private string lastExitedRoomId; public static Dictionary dic_RoomData=new Dictionary(); public Button btn_FanHuiDaTing; [Header("Bottom")] public TextMeshProUGUI tmp_Name_Bottom; public TextMeshProUGUI tmp_CoinCount_Bottom; public TextMeshProUGUI tmp_WaitTime_Bottom; public Image img_Title_Bottom; [Header("Top")] public TextMeshProUGUI tmp_Name_Top; public TextMeshProUGUI tmp_CoinCount_Top; public TextMeshProUGUI tmp_WaitTime_Top; public Image img_Title_Top; [Header("ChessGame 象棋对局")] public UIChessBoard chessBoard; // 棋盘控件(Inspector 手动拖入;留空则运行时自动生成) private Coroutine waitCountdown; public SkeletonGraphic skeletonGraphic; public string skineName_JinXingZhong; public string skineName_KaiShi; public string skineName_PiPeiChengGong; public string skineName_ZhengZaiPiPei; public string animName_Close; public string animName_Open; public string animName_Loop; #region Spine 动画辅助 /// 激活/禁用 Spine(不用时禁用节省性能,用时激活) public void ActiveSpine(bool active) { if (skeletonGraphic == null) return; if (skeletonGraphic.gameObject.activeSelf != active) skeletonGraphic.gameObject.SetActive(active); } /// 设置 Spine skin(切换皮肤并重置 slot) public void SetSpineSkin(string skinName) { if (skeletonGraphic == null || string.IsNullOrEmpty(skinName)) return; ActiveSpine(true); var skel = skeletonGraphic.Skeleton; if (skel == null) return; skel.SetSkin(skinName); skel.SetSlotsToSetupPose(); skeletonGraphic.LateUpdate(); } /// 播放 Spine 动画 public void PlaySpineAnim(string animName, bool loop = true) { if (skeletonGraphic == null || string.IsNullOrEmpty(animName)) return; ActiveSpine(true); skeletonGraphic.AnimationState.SetAnimation(0, animName, loop); } /// 切换到"正在匹配"状态 public void Spine_ZhengZaiPiPei() { SetSpineSkin(skineName_ZhengZaiPiPei); PlaySpineAnim(animName_Loop, true); } /// 切换到"匹配成功"状态:Open → 完成后播 Close → 禁用 public void Spine_PiPeiChengGong() { SetSpineSkin(skineName_PiPeiChengGong); PlaySpineAnim(animName_Open, false); // Open 播完后播 Close,然后禁用 Spine StartCoroutine(DelayedSpineClose()); } /// Open 动画播完后播放 Close 动画并禁用 Spine private IEnumerator DelayedSpineClose() { // 等 Open 动画播完 yield return new WaitForSeconds(0.5f); PlaySpineAnim(animName_Close, false); // Close 播完后禁用 Spine yield return new WaitForSeconds(0.5f); ActiveSpine(false); } /// 切换到"开始游戏"状态(短暂过渡) public void Spine_KaiShi() { SetSpineSkin(skineName_KaiShi); PlaySpineAnim(animName_Open, false); } /// 切换到"进行中"状态 public void Spine_JinXingZhong() { SetSpineSkin(skineName_JinXingZhong); PlaySpineAnim(animName_Loop, true); } /// 延迟切换到"进行中"状态(等 KaiShi 过渡动画播完) private IEnumerator DelayedSpine_JinXingZhong() { // 等待开始过渡动画播放完(默认 0.5s) yield return new WaitForSeconds(0.5f); Spine_JinXingZhong(); } /// KaiShi 动画流程:Open 播完后切 Loop 播一小会,再播 Close 关闭并禁用 private IEnumerator DelayedKaiShiLoopToClose() { // 等 Open 动画播完 yield return new WaitForSeconds(0.5f); // 切 Loop 动画播一小会 PlaySpineAnim(animName_Loop, true); yield return new WaitForSeconds(1f); // 播 Close 动画并禁用 Spine PlaySpineAnim(animName_Close, false); yield return new WaitForSeconds(0.5f); ActiveSpine(false); } #endregion /// /// 加载头像精灵(优先使用缓存,否则异步下载) /// public static void LoadAvatar(Image target, string avatarUrl, System.Action onLoaded = null) { if (target == null) return; var sprite = GameManager.Instance.GetAvatarSprite(avatarUrl); if (sprite != null) { target.sprite = sprite; onLoaded?.Invoke(sprite); return; } // 缓存未命中,通过 DownSprite 协程异步加载 GameManager.Instance.StartCoroutine(LoadAvatarCoroutine(target, avatarUrl, onLoaded)); } private static IEnumerator LoadAvatarCoroutine(Image target, string avatarUrl, System.Action onLoaded) { // 构造一个临时的 UserBaseInfo 对象来复用 DownSprite 逻辑 var tempInfo = new UserBaseInfo { avatarUrl = avatarUrl }; yield return tempInfo.DownSprite(sprite => { if (sprite != null && target != null) target.sprite = sprite; onLoaded?.Invoke(sprite); }); } public static string FormatGold(long gold) { if (gold >= 100000000) { // 截取两位小数,不四舍五入 long yi = gold / 100000000; long remainder = gold % 100000000; long decimals = remainder * 100 / 100000000; if (decimals > 0) return $"{yi}.{decimals:D2}亿"; return $"{yi}亿"; } if (gold >= 10000) { // 截取两位小数,不四舍五入 long wan = gold / 10000; long remainder = gold % 10000; long decimals = remainder * 100 / 10000; // 两位小数 if (decimals > 0) return $"{wan}.{decimals:D2}万"; return $"{wan}万"; } return gold.ToString(); } public void StartGame(string msg) { var va = JsonConvert.DeserializeObject>(msg.ToString()); StartGameRoom(va.arguments[0]); } /// /// 4.3 房间状态完整快照:BeginGame / RoomStateNotice / GetRoomState 返回统一走这里。 /// 用服务端房间对象整体覆盖本地状态(users/state/gold/createUserId/isGameEnded/timeoutSeconds/pendingWinUserId), /// 不做"人数 ±1"式增量更新。 /// public void ApplyRoomSnapshot(ChessGameRoom room) { if (room == null || string.IsNullOrEmpty(room.roomId)) return; // 4.4 防串房:忽略已退出的旧房间推送 if (!string.IsNullOrEmpty(lastExitedRoomId) && room.roomId == lastExitedRoomId) { Debug.Log($"[Chess] 忽略已退出房间的快照: {room.roomId}"); return; } // 4.4 防串房:本地在 A 房间时忽略 B 房间的推送 if (roomData != null && !string.IsNullOrEmpty(roomData.roomId) && roomData.roomId != room.roomId) { Debug.Log($"[Chess] 忽略其他房间快照: {room.roomId}(当前: {roomData.roomId})"); return; } bool wasActive = IsChessGameActive; roomData = room; // 4.3 整体覆盖 if (wasActive && room.state == 2 && !room.isGameEnded) { // 4.1 断线重连/取消挂起的恢复同步:只刷新双方信息,不重置正在进行的棋局 UpdateUserData(room); return; } if (wasActive) { // 4.1 房间已被服务端重置(双断线)或已结束:清除本地对局,按快照回到对应阶段 ClearChessGame(); } subUIManager.CloseAll(); StartGameRoom(room); } /// 重新进入指定房间前调用:解除对该房间的"旧房间推送过滤"(建房/进房面板发送请求时用) public void MarkJoiningRoom(string roomId) { if (!string.IsNullOrEmpty(roomId) && roomId == lastExitedRoomId) lastExitedRoomId = null; } /// /// 4.4 清空本地房间状态:gameId、棋盘、双方玩家、准备状态、结算状态 /// public void ClearRoomLocalState() { if (roomData != null) lastExitedRoomId = roomData.roomId; // 记录,忽略该房间后续推送 ClearChessGame(); roomData = null; RefreshKickButtonVisible(); // 已退房 → 隐藏踢人按钮 } private void StartGameRoom(ChessGameRoom chessGameRoom) { roomData = chessGameRoom; // 记录房间数据(开象棋对局时要取阵营) StartHeartbeat(); // 房间心跳(更新活跃时间 + 断线超时检查) UpdateUserData(chessGameRoom); if (btn_FanHuiDaTing != null) btn_FanHuiDaTing.gameObject.SetActive(true); // 等待房/选择界面:显示返回大厅 switch (chessGameRoom.users.Count) { case 1: { tmp_Name_Top.text = "等待玩家进入"; tmp_CoinCount_Top.text = ""; img_Title_Top.gameObject.SetActive(false); if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = "等待玩家进入02:00"; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = "等待玩家进入02:00"; StartWaitCountdown(120, CountdownType.WaitPlayer, "等待玩家进入"); // 正在匹配 Spine_ZhengZaiPiPei(); } break; case 2: { StopWaitCountdown(); img_Title_Top.gameObject.SetActive(true); // 判断是否为房主 bool isRoomOwner = chessGameRoom.users.Count > 0 && chessGameRoom.users[0].userId == GameManager.Instance.user.BaseInfo.number; if (isRoomOwner) { //btn_StartGame.gameObject.SetActive(true); btn_StartGame.onClick.RemoveAllListeners(); btn_StartGame.onClick.AddListener(() => { // 房主点击开始 → 发送 StartGame Socket 请求 btn_StartGame.gameObject.SetActive(false); StopWaitCountdown(); WebSocketSendList.StartGameChineseChess(chessGameRoom.roomId, GameManager.Instance.user.BaseInfo.number); }); } else { btn_StartGame.gameObject.SetActive(false); } StartWaitCountdown(60, CountdownType.WaitHostStart, "等待房主开始游戏"); // 匹配成功 Spine_PiPeiChengGong(); } break; default: break; } } /// /// 服务端推送 GoldConfigRequired → 显示金币设置面板(红方配置) /// data: { targetUserId, room } /// public void OnGoldConfigRequired(string msg) { StopWaitCountdown(); var result = JsonConvert.DeserializeObject>(msg); var data = result.arguments[0]; // 更新房间数据 if (data.room != null) { roomData = data.room; UpdateUserData(data.room); } // 判断当前用户是否为红方(targetUserId) bool isRedSide = data.targetUserId == GameManager.Instance.user.BaseInfo.number; if (isRedSide) { // 红方显示金币设置面板 subUIManager.ShowAsync(UIPanel.ZGXQSelectCoin, roomData); StartWaitCountdown(60, CountdownType.WaitSetCoin, "等待设置金额"); } else { // 黑方等待红方设置金币 StartWaitCountdown(60, CountdownType.WaitSetCoin, "等待对方设置金额"); } } public void UpdateUserData(ChessGameRoom chessGameRoom) { foreach (var item in chessGameRoom.users) { if (item.userId == GameManager.Instance.user.BaseInfo.number) { tmp_Name_Bottom.text ="ID:" + item.userId; tmp_CoinCount_Bottom.text = FormatGold(item.gold); LoadAvatar(img_Title_Bottom, item.userAvatar); } else { tmp_Name_Top.text ="ID:" + item.userId; tmp_CoinCount_Top.text = FormatGold(item.gold); LoadAvatar(img_Title_Top, item.userAvatar); } } RefreshKickButtonVisible(); // 房间数据刷新 → 同步踢人按钮显隐 } /// /// 用户数据变化回调(结算/领奖/邮件等触发 LoginHub AssetUpdated → GetUser → 此处) /// 用账户金币实时校准房间内自己的金币显示(账户金币是权威数据) /// private void OnUserDataChangedHandler() { if (roomData?.users == null) return; var assets = GameManager.Instance?.user?.assets; if (assets == null) return; string myId = GameManager.Instance.user.BaseInfo.number; var mine = roomData.users.Find(u => u.userId == myId); if (mine == null || mine.gold == assets.gold) return; mine.gold = assets.gold; if (tmp_CoinCount_Bottom != null) tmp_CoinCount_Bottom.text = FormatGold(mine.gold); } /// /// 结算后实时刷新房间双方金币:按服务端结果本地预更新(赢家 +底分,输家 -底分) /// (对方的账户变化客户端收不到推送,只能按底分本地算; /// 自己的金币随后由 AssetUpdated → OnUserDataChangedHandler 用账户数据校准) /// winUserId:赢家 userId;空 = 和棋(金币不变) /// public void ApplySettlementGold(string winUserId) { if (string.IsNullOrEmpty(winUserId)) return; // 和棋不动金币 if (roomData?.users == null || roomData.users.Count < 2) return; long bet = roomData.gold; for (int i = 0; i < roomData.users.Count; i++) { var u = roomData.users[i]; if (u.userId == winUserId) u.gold += bet; else u.gold = u.gold > bet ? u.gold - bet : 0; } UpdateUserData(roomData); } public override void Awake() { base.Awake(); btn_FanHuiDaTing.onClick.AddListener(() => { // 4.4 返回大厅 = 退出整个象棋界面;在房间中由 OnHide 兜底主动调 ExitRoom 退房(不能只断开连接) UIManager.Instance.Close(UIPanel.MainGamePanelZGXQ); }); btn_GuiZe.onClick.AddListener(() => { subUIManager.ShowAsync(UIPanel.ZGXQIntroduce); }); // 4.5 踢人按钮:房主 + 开局前 + 对方在房间时才显示(显隐由 RefreshKickButtonVisible 控制) if (btn_TiRen != null) { btn_TiRen.onClick.RemoveAllListeners(); btn_TiRen.onClick.AddListener(OnKickButtonClicked); btn_TiRen.gameObject.SetActive(false); // 默认隐藏 } } protected override void OnShow(object args = null) { base.OnShow(args); // 订阅用户数据变化:结算/领奖后 LoginHub AssetUpdated 会触发,实时刷新房间金币 GameManager.Instance.OnUserDataChanged -= OnUserDataChangedHandler; GameManager.Instance.OnUserDataChanged += OnUserDataChangedHandler; RefreshKickButtonVisible(); // 进面板默认隐藏踢人按钮(防上次残留) if (btn_FanHuiDaTing != null) btn_FanHuiDaTing.gameObject.SetActive(true); // 选房/选择界面:显示返回大厅 // 回到选房间界面时显示等待中(连带父对象) SetDengDaiZhongActive(true); subUIManager.ShowAsync(UIPanel.ZGXQDistrict, args); // 选房阶段不使用 Spine,禁用 ActiveSpine(false); } /// /// 显示/隐藏等待中(连带控制父对象链) /// public void SetDengDaiZhongActive(bool active) { if (img_DengDaiZhong != null) { img_DengDaiZhong.gameObject.SetActive(active); } } public void IsShowHint(string id,int type) { // 4.4 只处理当前对手发起的操作请求(旧房间/陌生 userId 忽略) if (roomData == null || id != OpponentChessUserId) return; if (GameManager.Instance.user.BaseInfo.number!=id) { // 悔棋回执:直接处理,不弹窗 if (type == 4) // 对方同意悔棋 → 本地悔一步 { OnUndoAgreed(); return; } if (type == 5) // 对方拒绝悔棋 { if (chessBoard != null) chessBoard.ShowToast("对方拒绝悔棋"); return; } // 1求和 2认输 3悔棋请求 → 走之前的确认弹窗 subUIManager.ShowAsync(UIPanel.ZGXQHint, new HintData { roomData = roomData, hintType = type }); } } public void InitRoomData() { // 游戏已开始,停止所有倒计时 StopWaitCountdown(); // 关闭游戏说明面板(双方都准备完毕,进入进行中) subUIManager.Close(UIPanel.ZGXQGameExplain); // 双方都准备完毕:播放 KaiShi Spine 动画 Open → Loop 一小会 → Close Spine_KaiShi(); StartCoroutine(DelayedKaiShiLoopToClose()); // 初始化象棋对局(普通管理类统一在此控制) StartChessGame(); btn_QiuHe.onClick.RemoveAllListeners(); btn_RenShu.onClick.RemoveAllListeners(); btn_HuiQi.onClick.RemoveAllListeners(); btn_HuiQi.onClick.AddListener(RequestUndoChess); btn_QiuHe.onClick.AddListener(() => { WebSocketSendList.GameOperationChineseChess(roomData.roomId,1,GameManager.Instance.user.BaseInfo.number); }); btn_RenShu.onClick.AddListener(() => { //WebSocketSendList.GameOperationChineseChess(roomData.roomId, 2, GameManager.Instance.user.BaseInfo.number); subUIManager.ShowAsync(UIPanel.ZGXQHint, new HintData { roomData = roomData, hintType = 2 }); }); } #region 象棋对局(普通管理类统一在面板控制) /// 象棋对局总管理器(普通类) private ChessGameManager chessGameManager; /// 运行时自动生成的棋盘(用于退出时销毁) private RectTransform autoChessBoardRect; /// /// 开始一局象棋(双方准备完毕,InitRoomData 里调用) /// 所有规则/走法本地运算(帧同步),后端只透传数据与结算胜负 /// private void StartChessGame() { if (roomData == null || roomData.users == null) return; // 新一局开始:清挂起状态 isMyAfk = false; isOpponentAfk = false; StopAfkMonitor(); // 我的阵营:RedisGameUser.type 1 黑方 2 红方 var me = roomData.users.Find(u => u.userId == GameManager.Instance.user.BaseInfo.number); ChessCamp myCamp = me != null ? (ChessCamp)me.type : ChessCamp.Red; chessGameManager = new ChessGameManager(); chessGameManager.OnMoveApplied += OnChessMoveApplied; chessGameManager.OnGameOver += OnChessGameOver; chessGameManager.OnTurnChanged += OnChessTurnChanged; // 要在 StartGame 前订阅(开局红先会触发一次) chessGameManager.StartGame(myCamp); RefreshKickButtonVisible(); // 对局开始 → 隐藏踢人按钮 if (btn_FanHuiDaTing != null) btn_FanHuiDaTing.gameObject.SetActive(false); // 开局 → 隐藏返回大厅 if (chessBoard == null) CreateDefaultChessBoard(); chessBoard.Init(chessGameManager, myCamp); } /// 面板上没拖棋盘控件时,自动生成一个(不依赖任何预制体/资源,定位点也自动建好) private void CreateDefaultChessBoard() { var go = new GameObject("ChessBoard_Auto", typeof(RectTransform)); var rt = go.GetComponent(); rt.SetParent(transform, false); var prt = (RectTransform)transform; float pw = prt.rect.width > 10f ? prt.rect.width : 1080f; float ph = prt.rect.height > 10f ? prt.rect.height : 1920f; float step = Mathf.Min(pw * 0.96f / 9f, ph * 0.88f / 10.1f); rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f); rt.anchoredPosition = new Vector2(0f, -step * 0.4f); // 稍微下移,避开上方对手信息 rt.sizeDelta = new Vector2(step * 9f, step * 10.1f); chessBoard = go.AddComponent(); // 自动生成时:代码自建两个定位点(逻辑 (0,4) 与 (0,5),网格铺满棋盘区域) // 逻辑 (0,4) = (-4*step, -0.5*step),逻辑 (0,5) = (-4*step, +0.5*step)(棋盘中心为原点) var ps = new GameObject("Point_Start", typeof(RectTransform)).GetComponent(); ps.SetParent(rt, false); ps.anchoredPosition = new Vector2(-4f * step, -0.5f * step); var pe = new GameObject("Point_End", typeof(RectTransform)).GetComponent(); pe.SetParent(rt, false); pe.anchoredPosition = new Vector2(-4f * step, 0.5f * step); chessBoard.point_Start = ps; chessBoard.point_End = pe; autoChessBoardRect = rt; } /// 本地走子成功 → 帧同步给对方(走 SyncGameData 通道,后端只透传) private void OnChessMoveApplied(ChessMoveEventArgs e) { if (!e.isLocal) return; // 对方同步过来的不回发 SendChessNetData(new ChessNetData { msgType = 1, move = e.move }); } /// 对局结束(本地规则判定)→ 上报后端结算 + 弹结果面板 private void OnChessGameOver(ChessCamp winner) { StopWaitCountdown(); // 对局结束,停走棋倒计时 bool iWin = winner == chessGameManager.myCamp; if (iWin) { // 只有赢家上报(避免双端重复),EndGame 第二个参数为胜者 userId WebSocketSendList.EndGameChineseChess(roomData.roomId, GameManager.Instance.user.BaseInfo.number); subUIManager.ShowAsync(UIPanel.ZGXQWin); } else { subUIManager.ShowAsync(UIPanel.ZGXQDefeated); } } /// 悔棋按钮 → 走之前写好的 GameOperation 通道(1求和 2认输 3悔棋),对方确认后双方各悔一步 private void RequestUndoChess() { if (chessGameManager == null || chessGameManager.isGameOver) return; if (chessGameManager.recorder.Count == 0) { if (chessBoard != null) chessBoard.ShowToast("暂无可悔的棋步"); return; } WebSocketSendList.GameOperationChineseChess(roomData.roomId, 3, GameManager.Instance.user.BaseInfo.number); if (chessBoard != null) chessBoard.ShowToast("已发送悔棋请求"); } /// 同意对方的悔棋请求(UIChineseChessHint 的同意按钮调用):回执已由弹窗发送,这里本地悔一步 public void AgreeUndoChess() { if (chessGameManager == null) return; chessGameManager.UndoOneStep(); } /// 收到对方同意悔棋的回执(IsShowHint type=4):本地悔一步 private void OnUndoAgreed() { if (chessGameManager == null) return; chessGameManager.UndoOneStep(); if (chessBoard != null) chessBoard.ShowToast("对方同意悔棋"); } #region 对局走棋倒计时(复用原有倒计时字段:waitCountdown / StopWaitCountdown / tmp_WaitTime_Bottom、Top) /// 每回合倒计时秒数(用服务端房间配置 timeoutSeconds,没配默认 60) private int ChessTurnSeconds => roomData != null && roomData.timeoutSeconds > 0 ? roomData.timeoutSeconds : 60; /// 回合切换 → 重新开始走棋倒计时(当前回合方的信息栏显示) private void OnChessTurnChanged(ChessCamp camp) { if (chessGameManager == null || chessGameManager.isGameOver) return; bool myTurn = camp == chessGameManager.myCamp; if (waitCountdown != null) StopCoroutine(waitCountdown); waitCountdown = StartCoroutine(TurnCountdownRoutine(ChessTurnSeconds, myTurn)); } /// /// 走棋倒计时:轮到我 → 显示在下方(超时 AI 自动走一步,不判负不结算); /// 轮到对方 → 显示在上方(对方客户端超时同样由它的 AI 自动走) /// private IEnumerator TurnCountdownRoutine(int totalSeconds, bool myTurn) { int remaining = totalSeconds; while (remaining >= 0) { string timeStr = $"{remaining / 60:D2}:{remaining % 60:D2}"; if (myTurn) { if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = $"请走棋 {timeStr}"; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = ""; } else { if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = $"对方走棋 {timeStr}"; if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = ""; } yield return new WaitForSeconds(1f); remaining--; } if (myTurn) { // 我方超时 → AI 自动走一步(走完会触发回合切换,倒计时自动重开) if (chessGameManager != null && !chessGameManager.isGameOver) { bool moved = chessGameManager.AutoMove(); if (moved && chessBoard != null) chessBoard.ShowToast("超时,已自动走子"); } } // 对方回合超时:对方客户端同样 AI 自动走一步,我方等它的走子帧 } #endregion #region 玩家挂起(退到后台)/ 断线 / 房间心跳 /// 挂起判负秒数:挂起最多 120s 超时判输;双方都挂起时,先挂起的判输 private const float AfkTimeoutSeconds = 120f; /// 挂起判负毫秒数(北京时间戳比较用) private const long AfkTimeoutMs = (long)(AfkTimeoutSeconds * 1000f); /// 当前北京时间(毫秒,服务端同步) private static long NowMs => BeijingTimeManager.Instance.CurrentTimestampMs; private bool isMyAfk; // 我是否挂起中(退到后台) private long myAfkStartTimeMs; // 我挂起开始时间(北京时间毫秒) private bool isOpponentAfk; // 对方是否挂起中 private long opponentAfkStartTimeMs; // 对方挂起开始时间(北京时间毫秒) private Coroutine afkMonitorCoroutine; private Coroutine heartbeatCoroutine; private Coroutine disconnectCountdownCoroutine; // 对方断线 60s 离线倒计时 /// 是否处于对局中(挂起/断线/重连处理只对进行中的对局生效) public bool IsChessGameActive => chessGameManager != null && !chessGameManager.isGameOver; private string MyChessUserId => GameManager.Instance.user.BaseInfo.number; private string OpponentChessUserId { get { if (roomData?.users == null) return null; var op = roomData.users.Find(u => u.userId != MyChessUserId); return op?.userId; } } /// /// 应用切后台/回前台(iOS/Android 都会触发 OnApplicationPause): /// 退到后台 = 挂起(发 SetPlayerAfk);回前台 = 取消挂起(发 CancelPlayerAfk,本人会收到 BeginGame 恢复同步) /// private void OnApplicationPause(bool pause) { if (roomData == null || !IsChessGameActive) return; // 只对进行中的对局处理 if (pause) { isMyAfk = true; myAfkStartTimeMs = NowMs; StopWaitCountdown(); // 后台期间停走棋倒计时,回前台重新计 WebSocketSendList.SetPlayerAfkChineseChess(roomData.roomId, MyChessUserId); } else if (isMyAfk) { isMyAfk = false; WebSocketSendList.CancelPlayerAfkChineseChess(roomData.roomId, MyChessUserId); // ===== 挂起判定(北京时间,回前台必判,弹对应结果面板): // 谁挂起满 120s 谁输;双方都超时 → 先挂起的输 ===== long now = NowMs; bool iTimedOut = now - myAfkStartTimeMs >= AfkTimeoutMs; bool oppTimedOut = isOpponentAfk && now - opponentAfkStartTimeMs >= AfkTimeoutMs; ChessCamp oppCamp = chessGameManager.myCamp == ChessCamp.Red ? ChessCamp.Black : ChessCamp.Red; if (oppTimedOut && (!iTimedOut || opponentAfkStartTimeMs <= myAfkStartTimeMs)) { JudgeAfkLose(chessGameManager.myCamp); // 对方超时(或双方都超时但对方先挂起)→ 对方输 return; } if (iTimedOut) { JudgeAfkLose(oppCamp); // 我挂起超时 → 我输(本地立即弹失败面板) return; } // 都没超时 → 继续对局(对方挂起监控继续跑) // 4.1 兜底:主动查询房间真实状态,防止服务端已判负/重置但推送在后台丢失 // (快照分流:还在对局 → 只刷新数据;已判负/重置 → 自动清本地回对应界面) if (roomData != null) WebSocketSendList.GetRoomStateChineseChess(roomData.roomId); // 恢复走棋倒计时 OnChessTurnChanged(chessGameManager.currentTurn); } } /// PlayerAfkNotice:对方退到后台 → 提示 + 开始 120s 判负监控 public void OnPlayerAfkNotice(string userId) { if (!IsChessGameActive || userId != OpponentChessUserId) return; isOpponentAfk = true; opponentAfkStartTimeMs = NowMs; if (chessBoard != null) chessBoard.ShowToast($"对方已挂起,{AfkTimeoutSeconds:0}秒内未恢复将判负"); StartAfkMonitor(); } /// PlayerCancelAfkNotice:对方回前台 → 清除挂起,继续对局 public void OnPlayerCancelAfkNotice(string userId) { if (userId != OpponentChessUserId) return; isOpponentAfk = false; StopAfkMonitor(); if (chessBoard != null) chessBoard.ShowToast("对方已恢复"); } /// 对方挂起监控:挂起满 120s → 对方判输(我在前台时运行;我挂起时冻结,回前台再判) private IEnumerator AfkMonitorRoutine() { while (IsChessGameActive && isOpponentAfk) { if (NowMs - opponentAfkStartTimeMs >= AfkTimeoutMs) { JudgeAfkLose(chessGameManager.myCamp); // 对方挂起超时 → 对方输 yield break; } yield return new WaitForSeconds(1f); } } private void StartAfkMonitor() { StopAfkMonitor(); if (IsChessGameActive && isActiveAndEnabled) afkMonitorCoroutine = StartCoroutine(AfkMonitorRoutine()); } private void StopAfkMonitor() { if (afkMonitorCoroutine != null) { StopCoroutine(afkMonitorCoroutine); afkMonitorCoroutine = null; } } /// /// 挂起判负结算:由判定方直接上报 EndGame(对方可能还在后台收不到,不能只靠赢家上报) /// private void JudgeAfkLose(ChessCamp winnerCamp) { if (!IsChessGameActive) return; StopAfkMonitor(); isOpponentAfk = false; isMyAfk = false; StopWaitCountdown(); bool iWin = winnerCamp == chessGameManager.myCamp; string winnerId = iWin ? MyChessUserId : OpponentChessUserId; if (roomData != null && !string.IsNullOrEmpty(winnerId)) WebSocketSendList.EndGameChineseChess(roomData.roomId, winnerId); // EndGame 第二参 = 胜者 userId // 本地对局置结束(静默:不走 OnChessGameOver 的重复上报,上面已上报) chessGameManager.ForceGameOver(winnerCamp, silent: true); subUIManager.ShowAsync(iWin ? UIPanel.ZGXQWin : UIPanel.ZGXQDefeated); } /// Disconnect:对方断线 → 停走棋倒计时 + 上方显示离线倒计时(60s 内未重连由服务端判负,超时后主动查房间状态兜底) public void OnOpponentDisconnected(string userId, int timeoutSeconds) { if (!IsChessGameActive || userId != OpponentChessUserId) return; StopWaitCountdown(); if (disconnectCountdownCoroutine != null) StopCoroutine(disconnectCountdownCoroutine); disconnectCountdownCoroutine = StartCoroutine(DisconnectCountdownRoutine(Mathf.Max(1, timeoutSeconds))); } /// 对方断线倒计时:显示离线状态和剩余秒数;倒计时归零后主动 GetRoomState 校准(房间被重置/已判负时快照分流自动处理) private IEnumerator DisconnectCountdownRoutine(int totalSeconds) { int remaining = totalSeconds; while (remaining >= 0) { if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = $"对方断线 {remaining / 60:D2}:{remaining % 60:D2}"; if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = ""; yield return new WaitForSeconds(1f); remaining--; } disconnectCountdownCoroutine = null; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = ""; // 4.1 超过重连时间:主动查询房间真实状态(服务端超时会推 GameEnd;房间已释放/重置则 RoomStateNotice 快照分流带回大厅) if (IsChessGameActive && roomData != null) WebSocketSendList.GetRoomStateChineseChess(roomData.roomId); } /// Reconnect:对方重连 → 停断线倒计时 + 恢复走棋倒计时 public void OnOpponentReconnected(string userId) { if (!IsChessGameActive || userId != OpponentChessUserId) return; if (disconnectCountdownCoroutine != null) { StopCoroutine(disconnectCountdownCoroutine); disconnectCountdownCoroutine = null; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = ""; } if (chessBoard != null) chessBoard.ShowToast("对方已重连"); OnChessTurnChanged(chessGameManager.currentTurn); } #endregion #region 踢人(4.5:房主开局前可踢人,被踢端完整处理) /// 4.5 被踢出房间 KickedFromRoom({ gameId, userId, reason?, source? }):只处理自己被踢 public void OnKickedFromRoom(string userId, string reason) { if (userId != MyChessUserId) return; // 停止计时/棋盘/输入 → 清空房间本地状态 → 回大厅按当前选中分区重拉列表(不能用 0,会返回全部分区导致列表与 tab 不符) ClearRoomLocalState(); subUIManager.CloseAll(); var districtPanel = subUIManager.Get(UIPanel.ZGXQDistrict); WebSocketSendList.GetRoomListChineseChess(districtPanel != null ? districtPanel.currDistrict : 1); // 优先显示服务端 reason,为空显示默认文案 UIManager.Instance.ShowBoardError(string.IsNullOrEmpty(reason) ? "已被移出房间" : reason); } /// 房主点击踢人按钮(开局前):3 秒内二次点击确认踢出(4.5 KickPlayer) private float lastKickClickTime; private void OnKickButtonClicked() { // 仅房主 + 开局前 + 对方在房间内时可踢 if (roomData == null || roomData.users == null || roomData.users.Count < 2) return; if (IsChessGameActive || roomData.state == 2) return; // 对局中/服务端已开局不可踢 if (roomData.users[0].userId != MyChessUserId) return; // 第一个进房的是房主,只有房主能踢 string oppId = OpponentChessUserId; if (string.IsNullOrEmpty(oppId)) return; if (Time.realtimeSinceStartup - lastKickClickTime > 3f) { lastKickClickTime = Time.realtimeSinceStartup; UIManager.Instance.ShowBoardError("再次点击踢人按钮确认踢出对方"); return; } WebSocketSendList.KickPlayerChineseChess(roomData.roomId, oppId); } /// 踢人按钮显隐:房主(第一个进房)+ 开局前 + 双方在房间时显示,其余隐藏(房间数据每次刷新时调用) private void RefreshKickButtonVisible() { if (btn_TiRen == null) return; bool show = roomData != null && roomData.users != null && roomData.users.Count >= 2 // 对方在房间 && roomData.users[0].userId == MyChessUserId // 第一个进房的是房主 && roomData.state != 2 // 服务端未开局 && !IsChessGameActive; // 本地对局未开始 btn_TiRen.gameObject.SetActive(show); } #endregion /// 服务端结算通知(EndGameSuccess/GameEnd)→ 本地对局同步置结束,防止后台方回前台后对局还在跑 public void MarkChessGameOverByServer() { if (chessGameManager != null && !chessGameManager.isGameOver) chessGameManager.ForceGameOver(chessGameManager.myCamp, silent: true); } /// 房间心跳:更新房间活跃时间 + 触发断线超时检查(协议 2.11 / 2.12,每 30s) private IEnumerator HeartbeatRoutine() { var wait = new WaitForSeconds(30f); while (true) { yield return wait; if (roomData != null && !string.IsNullOrEmpty(roomData.roomId)) { WebSocketSendList.HeartbeatChineseChess(roomData.roomId); WebSocketSendList.CheckTimeoutChineseChess(roomData.roomId); } } } private void StartHeartbeat() { StopHeartbeat(); if (isActiveAndEnabled) heartbeatCoroutine = StartCoroutine(HeartbeatRoutine()); } private void StopHeartbeat() { if (heartbeatCoroutine != null) { StopCoroutine(heartbeatCoroutine); heartbeatCoroutine = null; } } #endregion private void SendChessNetData(ChessNetData data) { if (roomData == null || chessGameManager == null || data == null) return; data.userId = GameManager.Instance.user.BaseInfo.number; WebSocketSendList.SyncGameDataChineseChess(roomData.roomId, JsonConvert.SerializeObject(data)); } /// /// 收到 SyncGameData 推送(由 WebSocketManager_Message 的 SyncGameData 分支调用) /// 兼容 arguments[0] = {"data":"..."} 与 arguments = [gameId, data] 两种回包格式 /// public void OnChessNetData(string rawMsg) { if (chessGameManager == null) return; try { var resp = JsonConvert.DeserializeObject>(rawMsg); if (resp == null || resp.arguments == null) return; foreach (var arg in resp.arguments) { string dataStr = null; if (arg.Type == Newtonsoft.Json.Linq.JTokenType.String && arg.ToString().TrimStart().StartsWith("{")) { dataStr = arg.ToString(); } else if (arg.Type == Newtonsoft.Json.Linq.JTokenType.Object && arg["data"] != null) { dataStr = arg["data"].ToString(); } if (!string.IsNullOrEmpty(dataStr)) HandleChessNetData(dataStr); } } catch (Exception ex) { Debug.LogWarning("[ChineseChess] SyncGameData 解析失败: " + ex.Message); } } /// 处理对方发来的走棋帧(悔棋/认输/求和走 GameOperation 通道,不经过这里) private void HandleChessNetData(string dataStr) { ChessNetData net; try { net = JsonConvert.DeserializeObject(dataStr); } catch { return; } if (net == null || string.IsNullOrEmpty(net.userId)) return; // 4.4 只处理当前对手的走子帧(自己的回声、旧房间、陌生 userId 一并忽略) if (net.userId != OpponentChessUserId) return; if (net.msgType == 1) // 对方走棋 { chessGameManager.ApplyRemoteMove(net.move); } } /// 清理对局(面板隐藏时调用) private void ClearChessGame() { if (chessGameManager != null) { chessGameManager.OnMoveApplied -= OnChessMoveApplied; chessGameManager.OnGameOver -= OnChessGameOver; chessGameManager.OnTurnChanged -= OnChessTurnChanged; chessGameManager = null; } StopWaitCountdown(); // 停走棋倒计时 StopAfkMonitor(); // 停挂起监控 StopHeartbeat(); // 停房间心跳 if (disconnectCountdownCoroutine != null) // 停对方断线倒计时 { StopCoroutine(disconnectCountdownCoroutine); disconnectCountdownCoroutine = null; } isMyAfk = false; isOpponentAfk = false; if (chessBoard != null) chessBoard.Clear(); if (autoChessBoardRect != null) { Destroy(autoChessBoardRect.gameObject); autoChessBoardRect = null; chessBoard = null; } } public void Start() { } /// /// 开始等待倒计时 /// /// 总秒数 /// 倒计时类型 /// 等待时显示的文字,如"等待玩家进入" public void StartWaitCountdown(int seconds, CountdownType type = CountdownType.WaitPlayer, string waitMsg = null) { if (waitCountdown != null) StopCoroutine(waitCountdown); waitCountdown = StartCoroutine(WaitCountdownRoutine(seconds, type, waitMsg)); } /// /// 停止等待倒计时 /// public void StopWaitCountdown() { if (waitCountdown != null) { StopCoroutine(waitCountdown); waitCountdown = null; } if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = ""; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = ""; } private IEnumerator WaitCountdownRoutine(int totalSeconds, CountdownType type, string waitMsg) { int remaining = totalSeconds; while (remaining >= 0) { string timeStr = $"{remaining / 60:D2}:{remaining % 60:D2}"; switch (type) { default: string displayText = string.IsNullOrEmpty(waitMsg) ? timeStr : $"{waitMsg}{timeStr}"; if (tmp_WaitTime_Bottom != null) tmp_WaitTime_Bottom.text = displayText; if (tmp_WaitTime_Top != null) tmp_WaitTime_Top.text = displayText; break; } yield return new WaitForSeconds(1f); remaining--; } switch (type) { default: // 超时:返回选房间界面 subUIManager.CloseAll(); WebSocketSendList.GetRoomListChineseChess(1); break; } } protected override void OnHide() { base.OnHide(); // 4.4 任何关闭路径都确保已退房(连接尚未关闭,能发出去;已退过则 roomData 为空跳过) if (roomData != null) { WebSocketSendList.ExitRoomChineseChess(roomData.roomId, MyChessUserId); ClearRoomLocalState(); } // 退订用户数据变化 GameManager.Instance.OnUserDataChanged -= OnUserDataChangedHandler; // 清理象棋对局 ClearChessGame(); // 退出:播放 Close 动画后禁用 Spine PlaySpineAnim(animName_Close, false); ActiveSpine(false); WebSocketManager.Instance.Close(SocketType.chess); Screen.orientation = ScreenOrientation.LandscapeLeft; } }