Files
5.24BuYu/Assets/MyGame/aa/UI/Game/ChineseChess/ChineseChessMainPanel.cs
T
2026-10-06 01:14:13 +08:00

1176 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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认输
}
/// <summary>
/// GoldConfigRequired Socket 推送数据
/// </summary>
[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;
/// <summary>4.4 已退出的房间 ID:退房后忽略该房间的后续推送(重新进房时由 MarkJoiningRoom 解除)</summary>
private string lastExitedRoomId;
public static Dictionary<string,ChessGameRoom> dic_RoomData=new Dictionary<string, ChessGameRoom>();
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 动画辅助
/// <summary>激活/禁用 Spine(不用时禁用节省性能,用时激活)</summary>
public void ActiveSpine(bool active)
{
if (skeletonGraphic == null) return;
if (skeletonGraphic.gameObject.activeSelf != active)
skeletonGraphic.gameObject.SetActive(active);
}
/// <summary>设置 Spine skin(切换皮肤并重置 slot)</summary>
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();
}
/// <summary>播放 Spine 动画</summary>
public void PlaySpineAnim(string animName, bool loop = true)
{
if (skeletonGraphic == null || string.IsNullOrEmpty(animName)) return;
ActiveSpine(true);
skeletonGraphic.AnimationState.SetAnimation(0, animName, loop);
}
/// <summary>切换到"正在匹配"状态</summary>
public void Spine_ZhengZaiPiPei()
{
SetSpineSkin(skineName_ZhengZaiPiPei);
PlaySpineAnim(animName_Loop, true);
}
/// <summary>切换到"匹配成功"状态:Open → 完成后播 Close → 禁用</summary>
public void Spine_PiPeiChengGong()
{
SetSpineSkin(skineName_PiPeiChengGong);
PlaySpineAnim(animName_Open, false);
// Open 播完后播 Close,然后禁用 Spine
StartCoroutine(DelayedSpineClose());
}
/// <summary>Open 动画播完后播放 Close 动画并禁用 Spine</summary>
private IEnumerator DelayedSpineClose()
{
// 等 Open 动画播完
yield return new WaitForSeconds(0.5f);
PlaySpineAnim(animName_Close, false);
// Close 播完后禁用 Spine
yield return new WaitForSeconds(0.5f);
ActiveSpine(false);
}
/// <summary>切换到"开始游戏"状态(短暂过渡)</summary>
public void Spine_KaiShi()
{
SetSpineSkin(skineName_KaiShi);
PlaySpineAnim(animName_Open, false);
}
/// <summary>切换到"进行中"状态</summary>
public void Spine_JinXingZhong()
{
SetSpineSkin(skineName_JinXingZhong);
PlaySpineAnim(animName_Loop, true);
}
/// <summary>延迟切换到"进行中"状态(等 KaiShi 过渡动画播完)</summary>
private IEnumerator DelayedSpine_JinXingZhong()
{
// 等待开始过渡动画播放完(默认 0.5s)
yield return new WaitForSeconds(0.5f);
Spine_JinXingZhong();
}
/// <summary>KaiShi 动画流程:Open 播完后切 Loop 播一小会,再播 Close 关闭并禁用</summary>
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
/// <summary>
/// 加载头像精灵(优先使用缓存,否则异步下载)
/// </summary>
public static void LoadAvatar(Image target, string avatarUrl, System.Action<Sprite> 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<Sprite> 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<WebSocketResponseBase<ChessGameRoom>>(msg.ToString());
StartGameRoom(va.arguments[0]);
}
/// <summary>
/// 4.3 房间状态完整快照:BeginGame / RoomStateNotice / GetRoomState 返回统一走这里。
/// 用服务端房间对象整体覆盖本地状态(users/state/gold/createUserId/isGameEnded/timeoutSeconds/pendingWinUserId),
/// 不做"人数 ±1"式增量更新。
/// </summary>
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);
}
/// <summary>重新进入指定房间前调用:解除对该房间的"旧房间推送过滤"(建房/进房面板发送请求时用)</summary>
public void MarkJoiningRoom(string roomId)
{
if (!string.IsNullOrEmpty(roomId) && roomId == lastExitedRoomId)
lastExitedRoomId = null;
}
/// <summary>
/// 4.4 清空本地房间状态:gameId、棋盘、双方玩家、准备状态、结算状态
/// </summary>
public void ClearRoomLocalState()
{
if (roomData != null) lastExitedRoomId = roomData.roomId; // 记录,忽略该房间后续推送
ClearChessGame();
roomData = null;
RefreshKickButtonVisible(); // 已退房 → 隐藏踢人按钮
}
private void StartGameRoom(ChessGameRoom chessGameRoom)
{
roomData = chessGameRoom; // 记录房间数据(开象棋对局时要取阵营)
StartHeartbeat(); // 房间心跳(更新活跃时间 + 断线超时检查)
UpdateUserData(chessGameRoom);
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;
}
}
/// <summary>
/// 服务端推送 GoldConfigRequired → 显示金币设置面板(红方配置)
/// data: { targetUserId, room }
/// </summary>
public void OnGoldConfigRequired(string msg)
{
StopWaitCountdown();
var result = JsonConvert.DeserializeObject<WebSocketResponseBase<GoldConfigRequiredData>>(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(); // 房间数据刷新 → 同步踢人按钮显隐
}
/// <summary>
/// 用户数据变化回调(结算/领奖/邮件等触发 LoginHub AssetUpdated → GetUser → 此处)
/// 用账户金币实时校准房间内自己的金币显示(账户金币是权威数据)
/// </summary>
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);
}
/// <summary>
/// 结算后实时刷新房间双方金币:按服务端结果本地预更新(赢家 +底分,输家 -底分)
/// (对方的账户变化客户端收不到推送,只能按底分本地算;
/// 自己的金币随后由 AssetUpdated → OnUserDataChangedHandler 用账户数据校准)
/// winUserId:赢家 userId;空 = 和棋(金币不变)
/// </summary>
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(() =>
{
if (roomData != null)
{
// 4.4 在房间中:必须主动调用退出房间,不能只断开连接
WebSocketSendList.ExitRoomChineseChess(roomData.roomId, MyChessUserId);
ClearRoomLocalState(); // 立即清 gameId/棋盘/双方/准备/结算状态
subUIManager.CloseAll(); // 关结算等子面板
WebSocketSendList.GetRoomListChineseChess(0); // 回大厅重拉列表(响应自动刷新选房界面,0=重新加入大厅通知组)
}
else
{
// 4.4 已在选房界面:关闭象棋面板(OnHide 兜底再退一次房)
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(); // 进面板默认隐藏踢人按钮(防上次残留)
// 回到选房间界面时显示等待中(连带父对象)
SetDengDaiZhongActive(true);
subUIManager.ShowAsync(UIPanel.ZGXQDistrict, args);
// 选房阶段不使用 Spine,禁用
ActiveSpine(false);
}
/// <summary>
/// 显示/隐藏等待中(连带控制父对象链)
/// </summary>
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 象棋对局(普通管理类统一在面板控制)
/// <summary>象棋对局总管理器(普通类)</summary>
private ChessGameManager chessGameManager;
/// <summary>运行时自动生成的棋盘(用于退出时销毁)</summary>
private RectTransform autoChessBoardRect;
/// <summary>
/// 开始一局象棋(双方准备完毕,InitRoomData 里调用)
/// 所有规则/走法本地运算(帧同步),后端只透传数据与结算胜负
/// </summary>
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 (chessBoard == null) CreateDefaultChessBoard();
chessBoard.Init(chessGameManager, myCamp);
}
/// <summary>面板上没拖棋盘控件时,自动生成一个(不依赖任何预制体/资源,定位点也自动建好)</summary>
private void CreateDefaultChessBoard()
{
var go = new GameObject("ChessBoard_Auto", typeof(RectTransform));
var rt = go.GetComponent<RectTransform>();
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<UIChessBoard>();
// 自动生成时:代码自建两个定位点(逻辑 (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<RectTransform>();
ps.SetParent(rt, false);
ps.anchoredPosition = new Vector2(-4f * step, -0.5f * step);
var pe = new GameObject("Point_End", typeof(RectTransform)).GetComponent<RectTransform>();
pe.SetParent(rt, false);
pe.anchoredPosition = new Vector2(-4f * step, 0.5f * step);
chessBoard.point_Start = ps;
chessBoard.point_End = pe;
autoChessBoardRect = rt;
}
/// <summary>本地走子成功 → 帧同步给对方(走 SyncGameData 通道,后端只透传)</summary>
private void OnChessMoveApplied(ChessMoveEventArgs e)
{
if (!e.isLocal) return; // 对方同步过来的不回发
SendChessNetData(new ChessNetData { msgType = 1, move = e.move });
}
/// <summary>对局结束(本地规则判定)→ 上报后端结算 + 弹结果面板</summary>
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);
}
}
/// <summary>悔棋按钮 → 走之前写好的 GameOperation 通道(1求和 2认输 3悔棋),对方确认后双方各悔一步</summary>
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("已发送悔棋请求");
}
/// <summary>同意对方的悔棋请求(UIChineseChessHint 的同意按钮调用):回执已由弹窗发送,这里本地悔一步</summary>
public void AgreeUndoChess()
{
if (chessGameManager == null) return;
chessGameManager.UndoOneStep();
}
/// <summary>收到对方同意悔棋的回执(IsShowHint type=4):本地悔一步</summary>
private void OnUndoAgreed()
{
if (chessGameManager == null) return;
chessGameManager.UndoOneStep();
if (chessBoard != null) chessBoard.ShowToast("对方同意悔棋");
}
#region 对局走棋倒计时(复用原有倒计时字段:waitCountdown / StopWaitCountdown / tmp_WaitTime_Bottom、Top)
/// <summary>每回合倒计时秒数(用服务端房间配置 timeoutSeconds,没配默认 60)</summary>
private int ChessTurnSeconds => roomData != null && roomData.timeoutSeconds > 0 ? roomData.timeoutSeconds : 60;
/// <summary>回合切换 → 重新开始走棋倒计时(当前回合方的信息栏显示)</summary>
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));
}
/// <summary>
/// 走棋倒计时:轮到我 → 显示在下方(超时 AI 自动走一步,不判负不结算);
/// 轮到对方 → 显示在上方(对方客户端超时同样由它的 AI 自动走)
/// </summary>
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 玩家挂起(退到后台)/ 断线 / 房间心跳
/// <summary>挂起判负秒数:挂起最多 120s 超时判输;双方都挂起时,先挂起的判输</summary>
private const float AfkTimeoutSeconds = 120f;
/// <summary>挂起判负毫秒数(北京时间戳比较用)</summary>
private const long AfkTimeoutMs = (long)(AfkTimeoutSeconds * 1000f);
/// <summary>当前北京时间(毫秒,服务端同步)</summary>
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 离线倒计时
/// <summary>是否处于对局中(挂起/断线/重连处理只对进行中的对局生效)</summary>
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;
}
}
/// <summary>
/// 应用切后台/回前台(iOS/Android 都会触发 OnApplicationPause):
/// 退到后台 = 挂起(发 SetPlayerAfk);回前台 = 取消挂起(发 CancelPlayerAfk,本人会收到 BeginGame 恢复同步)
/// </summary>
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);
}
}
/// <summary>PlayerAfkNotice:对方退到后台 → 提示 + 开始 120s 判负监控</summary>
public void OnPlayerAfkNotice(string userId)
{
if (!IsChessGameActive || userId != OpponentChessUserId) return;
isOpponentAfk = true;
opponentAfkStartTimeMs = NowMs;
if (chessBoard != null) chessBoard.ShowToast($"对方已挂起,{AfkTimeoutSeconds:0}秒内未恢复将判负");
StartAfkMonitor();
}
/// <summary>PlayerCancelAfkNotice:对方回前台 → 清除挂起,继续对局</summary>
public void OnPlayerCancelAfkNotice(string userId)
{
if (userId != OpponentChessUserId) return;
isOpponentAfk = false;
StopAfkMonitor();
if (chessBoard != null) chessBoard.ShowToast("对方已恢复");
}
/// <summary>对方挂起监控:挂起满 120s → 对方判输(我在前台时运行;我挂起时冻结,回前台再判)</summary>
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;
}
}
/// <summary>
/// 挂起判负结算:由判定方直接上报 EndGame(对方可能还在后台收不到,不能只靠赢家上报)
/// </summary>
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);
}
/// <summary>Disconnect:对方断线 → 停走棋倒计时 + 上方显示离线倒计时(60s 内未重连由服务端判负,超时后主动查房间状态兜底)</summary>
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)));
}
/// <summary>对方断线倒计时:显示离线状态和剩余秒数;倒计时归零后主动 GetRoomState 校准(房间被重置/已判负时快照分流自动处理)</summary>
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);
}
/// <summary>Reconnect:对方重连 → 停断线倒计时 + 恢复走棋倒计时</summary>
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:房主开局前可踢人,被踢端完整处理)
/// <summary>4.5 被踢出房间 KickedFromRoom({ gameId, userId, reason?, source? }):只处理自己被踢</summary>
public void OnKickedFromRoom(string userId, string reason)
{
if (userId != MyChessUserId) return;
// 停止计时/棋盘/输入 → 清空房间本地状态 → 回大厅重拉列表
ClearRoomLocalState();
subUIManager.CloseAll();
WebSocketSendList.GetRoomListChineseChess(0);
// 优先显示服务端 reason,为空显示默认文案
UIManager.Instance.ShowBoardError(string.IsNullOrEmpty(reason) ? "已被移出房间" : reason);
}
/// <summary>房主点击踢人按钮(开局前):3 秒内二次点击确认踢出(4.5 KickPlayer)</summary>
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);
}
/// <summary>踢人按钮显隐:房主(第一个进房)+ 开局前 + 双方在房间时显示,其余隐藏(房间数据每次刷新时调用)</summary>
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
/// <summary>服务端结算通知(EndGameSuccess/GameEnd)→ 本地对局同步置结束,防止后台方回前台后对局还在跑</summary>
public void MarkChessGameOverByServer()
{
if (chessGameManager != null && !chessGameManager.isGameOver)
chessGameManager.ForceGameOver(chessGameManager.myCamp, silent: true);
}
/// <summary>房间心跳:更新房间活跃时间 + 触发断线超时检查(协议 2.11 / 2.12,每 30s)</summary>
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));
}
/// <summary>
/// 收到 SyncGameData 推送(由 WebSocketManager_Message 的 SyncGameData 分支调用)
/// 兼容 arguments[0] = {"data":"..."} 与 arguments = [gameId, data] 两种回包格式
/// </summary>
public void OnChessNetData(string rawMsg)
{
if (chessGameManager == null) return;
try
{
var resp = JsonConvert.DeserializeObject<WebSocketResponseBase<Newtonsoft.Json.Linq.JToken>>(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);
}
}
/// <summary>处理对方发来的走棋帧(悔棋/认输/求和走 GameOperation 通道,不经过这里)</summary>
private void HandleChessNetData(string dataStr)
{
ChessNetData net;
try { net = JsonConvert.DeserializeObject<ChessNetData>(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);
}
}
/// <summary>清理对局(面板隐藏时调用)</summary>
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()
{
}
/// <summary>
/// 开始等待倒计时
/// </summary>
/// <param name="seconds">总秒数</param>
/// <param name="type">倒计时类型</param>
/// <param name="waitMsg">等待时显示的文字,如"等待玩家进入"</param>
public void StartWaitCountdown(int seconds, CountdownType type = CountdownType.WaitPlayer, string waitMsg = null)
{
if (waitCountdown != null) StopCoroutine(waitCountdown);
waitCountdown = StartCoroutine(WaitCountdownRoutine(seconds, type, waitMsg));
}
/// <summary>
/// 停止等待倒计时
/// </summary>
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;
}
}