/* ======================== GameMainPanel_FishBase ======================== 【主控制层职责】 负责:输入 → 旋转 → 攻击 → 子弹 → 锁定 → 网络同步 → 广播处理 不负责:动画(TurretData)、发射点(TurretData)、金币(SitPosData) ======================================================================= ① InputModule(输入系统) ├── HandleInput() 主入口 ├── 按下(PressDown) ├── 长按(PressHold) ├── 松开(PressUp) ├── CheckTouchIsChangeTurret() 判断是否点到“换炮台按钮” └── HandleChangeTurretHold() 长按换炮台按钮 → 打开换炮台面板 ----------------------------------------------------------------------- ② RotateModule(旋转系统) ├── HandleRotate() 手动旋转入口 ├── RotateToTouch() 朝触摸点旋转 ├── RotateToLockTarget() 朝锁定目标旋转 ├── HandleAutoRotate() 自动开火时的自动旋转 └── AutoSweepRotate() 自动扫射(左右摆动) ----------------------------------------------------------------------- ③ FireModule(攻击系统) ├── TryFire() 长按触发攻击(冷却计时) ├── FireBullet() 发射子弹(普通/三射/双倍/锁定) ├── StartLaser() 激光开始(LaserStart) ├── LoopLaser() 激光持续(LaserLoop) └── StopLaser() 激光结束(LaserEnd) ----------------------------------------------------------------------- ④ BulletModule(子弹系统) ├── CreateBullet() 创建子弹实例 ----------------------------------------------------------------------- ⑤ LockModule(锁定系统) ├── HandleLock() 点击锁定 ├── 自动锁定(AutoLock) 可选扩展 ├── CancelLock() 取消锁定 ├── UpdateLockFX() 锁定特效跟随目标 └── SyncLock() 网络同步锁定状态 ----------------------------------------------------------------------- ⑥ NetSyncModule(网络同步) ├── SyncRotate() 同步旋转 ├── SyncFire() 同步发射 ├── SyncLock() 同步锁定 ├── SyncTurretState() 同步激光状态 └── SyncChangeTurret() 同步炮台切换(可选) ----------------------------------------------------------------------- ⑦ NetReceiveModule(服务器广播处理) ├── SyncGameData_Rotate() 广播旋转 ├── SyncGameData_FireBullet() 广播发射 ├── SyncGameData_ChangeTarget() 广播锁定 ├── SyncGameData_ChangeTurret() 广播炮台切换(可选) └── SyncGameData_LaserState() 广播激光状态(可选) ----------------------------------------------------------------------- ⑧ ResourceModule(资源加载) ├── LoadResources() 加载音频/鱼资源/路线 └── UnLoadResources() 卸载资源 ----------------------------------------------------------------------- ⑨ PlayerJoinModule(玩家加入/退出) ├── JoinGame() 玩家加入房间 └── ExitGame() 玩家退出房间 ======================================================================= */ using Assets.MyGame.Scripts.Net; using Assets.MyGame.Scripts.Net.API; using Assets.MyGame.Scripts.Net.API.GameConfig_SubClass; using Assets.MyGame.Scripts.Net.API.UserData_SubClass; using Assets.MyGame.Scripts.Net.APIResponses.Enum; using Assets.MyGame.Scripts.Net.WebResponses; using Assets.Framework.Tool; using DG.Tweening; using Newtonsoft.Json; using Spine.Unity; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using TMPro; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.EventSystems; using UnityEngine.InputSystem; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.Playables; using UnityEngine.SceneManagement; using UnityEngine.UI; using Random = UnityEngine.Random; public class GameMainPanel_FishBase : GameMainPanel_Base { [Header("离开游戏倒计时")] public GameObject obj_DJSPanel; public TextMeshProUGUI tmp_DJS; //确认(10) public Button btn_QueRen; public Button btn_QuXiao; /// 是否正在退出倒计时(期间屏蔽帧同步和爆奖特效) private bool _isLeavingCountdown = false; private Coroutine _leaveCountdownCoroutine; [Header("无操作自动退出")] [Tooltip("无操作总时长(秒),走完自动退出回大厅(<=0 关闭该功能)")] public float idleExitTotalSeconds = 70f; [Tooltip("最后多少秒才显示倒计时面板(autoExitGamePanel)并逐秒倒数,之前全程无提示")] public float idleExitPanelShowSeconds = 10f; /// 无操作计时是否生效(资源加载完成后才开始计时,OnHide/离房时失效) private bool _idleExitArmed = false; /// 最近一次玩家操作的时间(Time.realtimeSinceStartup,含切后台时间) private float _lastOperationTime = 0f; /// 警告文本当前显示的剩余秒数(-1=未显示;避免每帧刷新 TMP 文本) private int _idleWarnShownSeconds = -1; [Header("游戏场景")] public string sceneName; public GameTypeEnum gameType; [Header("Left")] public PropConfigData propConfigData; // 道具菜单配置(Assets右键 Create → MyGame → 左侧道具配置) [Tooltip("鱼雷特效配置(按道具编号选爆炸特效预制体,多场景共用一份 .asset;未配置回退纯逻辑版倒计时爆炸)")] public TorpedoEffectConfig torpedoEffectConfig; public Button btn_Prop; public GameObject obj_PropItemParent; public GameObject obj_PropPanel; public GameObject tmp_PropItem; public GameObject tmp_PropTwoItemParent; public GameObject tmp_PropTwoPanel; public GameObject tmp_PropTwoItem; public Button dalaba; public Button zhanhuanka; public Button huojianpao; [Header("大喇叭弹幕输入")] public GameObject obj_DaLaBaPanel; // 大喇叭弹幕输入面板 public TMP_InputField tmp_DaLaBaInput; // 弹幕输入框 public Button btn_DaLaBaSend; // 发送按钮 public Button btn_DaLaBaClose; // 关闭按钮 [Header("Right")] [Header("设置")] protected bool isOpenSetting; public Button btn_SettingList; public Transform SittingListParent; public Button btn_TuJian; public Button btn_ChangeTurret; public Button btn_TuiChu; public Button btn_Setting; /// 切换房间:重新走一遍加入房间流程(离房 → QuickJoin → 连新房socket → JoinRoom) public Button btn_ChangeRoom; [Header("表情包")] [Tooltip("表情包配置资产(一份配置多场景共用;未拖时回退下方面板旧字段)")] public BiaoQingConfig biaoQingConfig; public Button btn_BiaoQing; // 打开/关闭表情面板 public GameObject obj_BiaoQingPanel; // 表情面板(开/关) public GameObject obj_BiaoQingZiItemPrefab; // 文字表情项预制体(child0=TextMeshProUGUI) public GameObject obj_BiaoQingSpineItemPrefab; // spine表情项预制体(child0=SkeletonGraphic) public GameObject obj_BiaoQingSpineItemParent; // spine表情项生成的父节点 public GameObject obj_BiaoQingZiItemParent; // 文字表情项生成的父节点 [Tooltip("(回退用)spine 表情配置:优先读 biaoQingConfig 资产,未拖资产时才用这里")] public List list_BiaoQingSpine = new List(); [Tooltip("(回退用)文字表情内容列表:优先读 biaoQingConfig 资产,未拖资产时才用这里")] public List list_BiaoQingZi = new List(); //[Header("我在这")] public GameObject mySitAnim; [Tooltip("我在这动画偏移 - 左侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")] public Vector3 mySitAnimOffsetLeft; [Tooltip("我在这动画偏移 - 右侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")] public Vector3 mySitAnimOffsetRight; public Dictionary dic_Skill_Toggle = new Dictionary();//开关型技能 [Header("Skillbottom")] // toggle_SkillAuto(自动攻击)已移植到 SitPosData.toggle_AutoAttack:读取 currSitData.IsAutoAttackOn,关闭时座位自己切 Idle + OnAutoAttackOff 广播 public Toggle toggle_Lock; public Toggle toggle_Three; public Toggle toggle_Shuang; [Header("锁定路径进度阈值(0~1,面板可调)")] [Tooltip("自动锁定/三发锁定/bot 的路径进度下限:鱼刚出生(进度<此值)不作为锁定目标。玩家手动锁定不受此限制")] [Range(0f, 1f)] public float lockPathMin = 0.1f; [Tooltip("自动锁定/三发锁定/bot 的路径进度上限:鱼即将离场(进度>此值)不作为锁定目标。玩家手动锁定不受此限制")] [Range(0f, 1f)] public float lockPathMax = 0.9f; // ⭐ 双倍开火:玩家手动开关的持久模式(同三射/锁定),不自动关闭、不消耗道具。 // child0 的 Image(Filled 360°) 做环形转圈展示(GetShuangFillImage 懒获取): // 开启后环耗尽一圈从头继续转(循环动画),关闭时停转清环。 float shuangDuration = 15f; // 环转一圈的时长(纯视觉,不控制功能开关) public float currShuangTime = 0f; private Image _shuangFillImage; public Toggle toggle_BaoZou; public TextMeshProUGUI tmp_BaoZouCount; public Image img_BaoZou; [Header("狂暴/冰封持续时间(秒,可在面板手动设置)")] public float frenzyDuration = 15f; // 疯狂状态持续时间 public float currFrenzyTime = 0f; public Toggle toggle_BingFeng; public TextMeshProUGUI tmp_BingFengCount; public Image img_BingFeng; public GameObject bingFengEffect; // 冰封特效预制体(首次Instantiate,之后激活/隐藏) private GameObject _bingFengInstance; // 克隆出来的实例 public float bingFengDuration = 15f; // 冰封状态持续时间(服务端 FishFrozen 延长 15 秒,可在面板调整) public float currBingFengTime = 0f; private Dictionary _frozenAnimSpeeds = new Dictionary(); /// /// 冰冻中“受 Timeline 驱动”的 Animator(其所属鱼体内存在 PlayableDirector)。 /// ⭐ 这些 Animator 的动画由 Timeline 驱动,冰冻时绝对不能把 speed 设 0: /// speed=0 会让 Timeline 的动画输出失效 → 模型瞬间回退到默认姿态。 /// 它们的定格交给 director(根 Playable 速度归零)处理。 /// private readonly HashSet _timelineDrivenAnimators = new HashSet(); /// 冰冻中被定格的 Timeline:记录根 Playable 的原始速度,解冻时还原 private readonly Dictionary _frozenDirectors = new Dictionary(); // ⭐ 暴走/冰封本地数量:因为暂无扣减 API,激活时本地 -1,避免被 OnUserDataChanged 覆盖回服务端旧值 private int _localBaoZouCount = -1; // -1 表示尚未初始化 private int _localBingFengCount = -1; // 上次从服务端读到的数量:用于判断服务端是否变化(刮刮卡奖励/其他端使用), // 服务端值变化时信任服务端;未变化时保留本地扣减值 private int _lastServerBaoZouCount = -1; private int _lastServerBingFengCount = -1; [Header("Skillleft")] public Button btn_GuaGuaKa; public TextMeshProUGUI tmp_GGKCount; public List list_Sits = new List(); public SitPosData currSitData; [Header("攻击参数")] public float attackTime = 0.5f; [Header("人机参数")] public float botRotateSpeed = 80f; // 兜底旋转速度(BotConfig 为空时用) float clickTime = 0; private Dictionary botFireTimer = new Dictionary(); private Dictionary botLastAngle = new Dictionary(); // bot 旋转广播节流:记录上次广播的角度/时间 private Dictionary botLastSyncAngle = new Dictionary(); private Dictionary botLastSyncTime = new Dictionary(); // bot 技能运行时 private Dictionary botSkills = new Dictionary(); // bot 目标记忆 private Dictionary botTargetGuid = new Dictionary(); // bot 锁定同步节流:记录上次广播的锁定目标 guid(null 表示未锁定),只有目标变化才广播 private Dictionary botLastSyncLockGuid = new Dictionary(); // bot 炮台状态同步节流:记录上次广播的 TurretState,只有状态变化才广播 private Dictionary botLastSyncTurretState = new Dictionary(); // bot 状态计时:开火后持续 Attack 动画,空闲 0.3s 切回 Idle private Dictionary botIdleTimer = new Dictionary(); // bot 最近的 FrameData_FireBullet(给其他客户端还原技能状态) private Dictionary botLastFireData = new Dictionary(); // bot 运行时状态(进场金币/倍数档位/行为模式/退出) private Dictionary botRuntimeStates = new Dictionary(); public GameObject bullet_Test; public GameObject bulletParent; float changeTurretTime = 0f; bool isChangingTurret = false; [Header("超时自动退出倒计时")] public GameObject autoExitGamePanel; //倒计时面板 public TextMeshProUGUI tmp_ExitDaoJiShi; //里面显示的是这个 n秒后自动关闭 public Button btn_CloseAutoExitGamePanel; //退出游戏按钮 // ⭐ 起飞炮台(FlyTurretData):轻点空白处触发起飞 // 按下时先走正常开火流程,松开时判定为"轻点"(时长短+几乎没移动)才起飞, // 保证按住扫射的普通开火完全不受影响 private bool _flyTapPending = false; // 本次按下待判定轻点 private float _flyTapTime; // 按下时刻 private Vector2 _flyTapPos; // 按下位置 private const float flyTapMaxDuration = 0.25f; // 轻点判定:按下到松开最大时长(秒) private const float flyTapMaxMove = 40f; // 轻点判定:最大移动距离(像素) int bulletCount = 0; public GameObject boardCast_Boss; [Header("创建视频播放器")] [Tooltip("鱼创建时播放的入场视频控件,由 Boss 预制体的 createVideoClip 指定视频片段。Inspector 拖拽场景中的 VideoPlayer 控件")] public UnityEngine.Video.VideoPlayer createVideoPlayer; /// 开火音效(static:切房新面板实例直接复用,避免重复加载;离房时 UnLoadBulletClip 释放并置空) static AudioClip bulletClip; public List paoBeiList = new List(); public ProgressBar JinDuTiao; #region UI bool isfirstShow = true; protected override void OnShow(object args = null) { base.OnShow(args); // ⭐ 无操作自动退出:加载期间不计时,LoadResources 完成后重新开始计;确保倒计时面板初始隐藏 _idleExitArmed = false; HideIdleExitWarning(); // ⭐ 资源加载不再在这里启动:由 WebSocketReponseList 在"面板已显示 → 游戏场景加载完成"后 // 调用 BeginLoadResources() 触发,保证加载顺序为 面板 → 场景 → 资源。 // ⭐ 切换房间:继续用离房时留下的合并进度条,先把新面板自带的进度条藏掉,避免出现两条进度条 if (_changeRoomBar != null && JinDuTiao != null) JinDuTiao.Hide(); RefreshGGKCount(); //UIManager.Instance.ShowBoard("太好看了"); var v = JsonConvert.DeserializeObject>(args.ToString()); if (v.arguments.Count > 0) { RoomJoinNotice roomJoinNotice = v.arguments[0]; // ⭐ 座位镜像:进房第一时间确定本地服务端座位(2/3=上方 → 全桌上下镜像,本地永远看自己在下方) SeatMirror.Setup(roomJoinNotice.Position); // ⭐ paoBeiList 每次进入房间都重新根据当前场次+房间等级获取, // 避免再次进入同一场次的不同等级房间时沿用旧 paoBeiList(例如先高级场后低级场) { int t = GameManager.Instance.gameData.currRoomData.gameType; int l = GameManager.Instance.gameData.currRoomData.roomLevel; foreach (var item in GameManager.Instance.gameConfig.roomConfigs) { if (item.gameType == t && l == item.roomLevel) { // ⭐ 炮倍从大到小排列(选择面板显示顺序/切换顺序):倒序拷贝, // 不动服务端下发的原始配置列表;ApplyBotLevelIndex/SyncIndexByConsume 已适配倒序索引 paoBeiList = item.cannonMultipleList != null ? item.cannonMultipleList.OrderByDescending(x => x).ToList() : item.cannonMultipleList; } } } if (isfirstShow) { string roomId = GameManager.Instance.gameData.currRoomData.roomId; //string roomId = GameManager.Instance.gameData.currRoomData; foreach (var item in GameManager.Instance.gameData.currRoomData.players) { JoinGame(item.position, item.gold, item.diamond, item.cannonMultiple, 0, item.userId, item.cannonId, item.wingsId, item.warGodId); // ⭐ 优先用后端 isBot 字段,兼容 userId.StartsWith("bot_") var sit = list_Sits.Find(s => s.posID == item.position); if (sit != null) { bool isBot = item.isBot || (!string.IsNullOrEmpty(item.userId) && item.userId.StartsWith("bot_")); if (isBot) sit.isBot = true; } } GameManager.Instance.gameData.roomJoinNotice = v.arguments[0]; isfirstShow = false; foreach (var item in list_Sits) { // ⭐ 座位镜像:本地玩家的显示座位 = 服务端座位转显示空间(上方座位翻转后落回下方) if (item.posID == SeatMirror.ToDisplay(roomJoinNotice.Position)) { currSitData = item; // ⭐ 长按本地座位的 img_OpenPT 开关炮台面板(与 btn_ChangeTurret 同逻辑) HookOpenPTLongPress(currSitData); // ⭐ 本地座位自动攻击关闭时广播 Idle 状态给其他客户端 HookAutoAttackOff(currSitData); } else { // ⭐ 非本地座位隐藏加减炮倍:Normal 藏座位按钮(未拖引用为 null 跳过), // XJMH 藏炮台上的加减倍(TurretData.obj_Add / obj_Down) item.HideTurretAddMinusUI(); // ⭐ 自动攻击开关与加减倍按钮一样:非本地座位隐藏 if (item.toggle_AutoAttack != null) item.toggle_AutoAttack.gameObject.SetActive(false); } } //// currSitData.ChangeTurret(1); — 已由 SitPosData.OnEnable 自动加载 if (currSitData.currentTurret != null) SyncChangeTurret(currSitData.currentTurret.turretNumber); currSitData.lockFX.transform.GetChild(0).gameObject.SetActive(false); currSitData.lockFX.transform.GetChild(1).gameObject.SetActive(true); if (mySitAnim != null && currSitData.turretParent != null) { // ⭐ 座位位置 + 左右座位各自的偏移:把"我在这"圆圈摆到座位上面 // (左右座位镜像后需要的偏移不一样,分左/右两套配置,面板上调) Vector3 sitAnimOffset = IsLeftSeat(currSitData) ? mySitAnimOffsetLeft : mySitAnimOffsetRight; mySitAnim.transform.position = currSitData.turretParent.position + sitAnimOffset; // ⭐ 进入游戏圆圈:6 秒后自动消失(点击屏幕立即消失的逻辑在 HandleInput) StartCoroutine(HideMySitAnimAfter(6f)); } } else { var targetSit = list_Sits.Find(s => s.posID == SeatMirror.ToDisplay(roomJoinNotice.Position)); // ⭐ 优先用后端 isBot 字段判断,兼容 userId.StartsWith("bot_") bool isBotJoin = roomJoinNotice.IsBot || (!string.IsNullOrEmpty(roomJoinNotice.UserId) && roomJoinNotice.UserId.StartsWith("bot_")); if (isBotJoin) { // bot 加入 → 标记 isBot=true (JoinGame 里会自动 SetActive(true) 座位) if (targetSit != null) targetSit.isBot = true; } else { // 真人玩家加入 → 如果座位被bot占用则替换 if (targetSit != null && targetSit.isBot) { targetSit.isBot = false; targetSit.ShowWaiting(); } // ⭐ 新玩家同步走"主动请求"流程: // 新玩家资源加载完毕后会主动发 requestSyncFish, // 房主收到后才广播场上活鱼(避免新玩家还没加载完就收包丢失) } JoinGame(roomJoinNotice.Position, roomJoinNotice.Gold, roomJoinNotice.Diamond, roomJoinNotice.CannonMultiple, 0, roomJoinNotice.UserId, roomJoinNotice.CannonId, roomJoinNotice.WingsId, roomJoinNotice.WarGodId); } } else { UIManager.Instance.ShowBoardError("获取信息失败"); } } private void RefreshGGKCount() { var user = GameManager.Instance.user; var props = user?.props; int ggkCount = 0; if (props != null) { foreach (var p in props) { if (p.number == "40005" && p.count > 0) ggkCount = p.count; } } if (tmp_GGKCount != null) tmp_GGKCount.text = ggkCount.ToString(); // 同步刷新暴走/冰封本地计数(仅首次或服务端数据变化时同步) RefreshSkillPropsCount(props); // 同步更新游戏内金币显示(刮刮卡领奖、邮件领取等都会触发) // ⭐ 但游戏内金币由房间数据(RoomJoinNotice.gold / SyncPlayerGold)管理, // user.assets.gold 是账户总金币,进房时后端推送 AssetUpdated 会触发此处, // 如果用账户总金币覆盖房间金币,会导致显示与后端房间数据不一致。 // 因此游戏内(isGame=true)不覆盖 goldNum,退房后由 SyncPlayerGold 或重新进房时设置。 if (currSitData != null && user?.assets != null && !WebSocketManager.Instance.isGame) { currSitData.goldNum = user.assets.gold; currSitData.SetGoldNumText(user.assets.gold); } } /// /// 刷新暴走/冰封道具数量显示 /// 策略:记录上次服务端数量,只要服务端数量相比上次有变化(增加或减少)就信任服务端 /// (覆盖刮刮卡奖励、其他端使用等场景);服务端数量未变时保留本地扣减值, /// 避免被服务端旧值覆盖本地扣减 /// private void RefreshSkillPropsCount(List props) { int serverBaoZou = 0; int serverBingFeng = 0; if (props != null) { foreach (var p in props) { if (p.number == "40004" && p.count > 0) serverBaoZou = p.count; else if (p.number == "40003" && p.count > 0) serverBingFeng = p.count; } } // 首次初始化,或服务端数量相比上次有变化(刮刮卡奖励增加/其他端使用减少)→ 以服务端为准 if (_localBaoZouCount < 0 || serverBaoZou != _lastServerBaoZouCount) _localBaoZouCount = serverBaoZou; if (_localBingFengCount < 0 || serverBingFeng != _lastServerBingFengCount) _localBingFengCount = serverBingFeng; _lastServerBaoZouCount = serverBaoZou; _lastServerBingFengCount = serverBingFeng; UpdateSkillPropsUI(); } /// 仅更新 UI 文本 private void UpdateSkillPropsUI() { if (tmp_BaoZouCount != null) tmp_BaoZouCount.text = Mathf.Max(0, _localBaoZouCount).ToString(); if (tmp_BingFengCount != null) tmp_BingFengCount.text = Mathf.Max(0, _localBingFengCount).ToString(); } protected override void OnHide() { base.OnHide(); // ⭐ 清理炮台切换面板(如果还在显示) CleanupTurretPanel(); // ⭐ 解绑长按 img_OpenPT 打开炮台面板的订阅 UnhookOpenPTLongPress(); UnhookAutoAttackOff(); // ⭐ 无操作自动退出:面板隐藏即停止计时 _idleExitArmed = false; HideIdleExitWarning(); // ⭐ 切换房间(同玩法):资源/场景/路线全部复用,不卸载不重载 —— // 卸载再加载同一个 LZMA 鱼包(219MB 级)要白解压两次,是切房慢的主因。 // 新面板 LoadResources 走切房分支直接 EnterRoom,socket/座位镜像照常重置。 if (_changeRoomBar == null) { UnLoadResources(); // 场景可能未加载或已卸载,做保护避免重复卸载报错 var scene = SceneManager.GetSceneByName(sceneName); if (scene.IsValid() && scene.isLoaded) SceneManager.UnloadSceneAsync(sceneName); // 清除场景加载标记,下次进入可重新加载 Assets.MyGame.Scripts.Net.WebSocketReponseList.ClearLoadedGameScene(sceneName); } else { Debug.Log("[ChangeRoom] 同玩法切房:复用已加载的场景/鱼/音频/路线,跳过卸载"); } WebSocketManager.Instance.Close(SocketType.game); // ⭐ 座位镜像状态清空,下次进房重新按新房间座位 Setup SeatMirror.Clear(); // ⭐ 重置本地道具计数,下次进入房间重新从服务端读取 _localBaoZouCount = -1; _localBingFengCount = -1; _lastServerBaoZouCount = -1; _lastServerBingFengCount = -1; } #endregion #region 长按 img_OpenPT 开关炮台面板 /// 已订阅长按事件的座位(换座位/重进房间时先解绑避免重复订阅) private SitPosData _openPTHookedSit; /// 缓存的长按处理器(解绑用) private Action _openPTLongPressHandler; /// /// 订阅本地座位的 img_OpenPT 长按事件:长按触发 OnOpenPTLongPressTriggered(子类可覆写)。 /// private void HookOpenPTLongPress(SitPosData sit) { // 先解绑旧订阅 UnhookOpenPTLongPress(); if (sit == null) return; _openPTLongPressHandler = () => OnOpenPTLongPressTriggered(); sit.OnOpenPTLongPress += _openPTLongPressHandler; _openPTHookedSit = sit; } /// /// 长按 img_OpenPT 达到阈值触发。 /// Normal 模式:开关炮台面板(subUIManager,与 btn_ChangeTurret 同逻辑)。 /// XJMH 模式:子类覆写为开关 ChangePTPanel。 /// protected virtual void OnOpenPTLongPressTriggered() { // 与 btn_ChangeTurret 完全相同的开关逻辑:开着就关,没开就打开 var panel = subUIManager.Get(UIPanel.TurretPanel); if (panel != null && panel.gameObject.activeSelf) subUIManager.Hide(UIPanel.TurretPanel); else { subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true)); StartCoroutine(CloseSettingList()); } } /// 解绑长按 img_OpenPT 的订阅(面板隐藏/重新订阅前调用) private void UnhookOpenPTLongPress() { if (_openPTHookedSit != null && _openPTLongPressHandler != null) _openPTHookedSit.OnOpenPTLongPress -= _openPTLongPressHandler; _openPTHookedSit = null; _openPTLongPressHandler = null; } #endregion #region 自动攻击开关(SitPosData.toggle_AutoAttack) /// 已订阅自动攻击关闭事件的座位(换座位/重进房间时先解绑避免重复订阅) private SitPosData _autoAttackHookedSit; /// 缓存的关闭处理器(解绑用) private Action _autoAttackOffHandler; /// /// 订阅本地座位的自动攻击关闭事件:关闭时广播 Idle 状态给其他客户端 /// (座位本体的 ChangeState(Idle) 已在 SitPosData.OnAutoAttackToggleChanged 里完成)。 /// private void HookAutoAttackOff(SitPosData sit) { UnhookAutoAttackOff(); if (sit == null) return; _autoAttackOffHandler = () => SyncTurretState(TurretState.Idle); sit.OnAutoAttackOff += _autoAttackOffHandler; _autoAttackHookedSit = sit; } /// 解绑自动攻击关闭事件的订阅(面板隐藏/重新订阅前调用) private void UnhookAutoAttackOff() { if (_autoAttackHookedSit != null && _autoAttackOffHandler != null) _autoAttackHookedSit.OnAutoAttackOff -= _autoAttackOffHandler; _autoAttackHookedSit = null; _autoAttackOffHandler = null; } #endregion #region 生命周期 private float baseAttackTime; public override void Awake() { base.Awake(); // ⭐ 把面板上拖的 boss 播报预制体交给 SubUIManager: // 以前这里没赋值 → subUIManager.boardCast_Boss 一直是 null,Boss 出场播报根本不显示。 // 现在 XJMH 用它播 lua 的 PW_Effect_Boss_Appear(见 BossAppearTipXJMH.cs) if (subUIManager != null) subUIManager.boardCast_Boss = boardCast_Boss; GameManager.Instance.OnUserDataChanged += RefreshGGKCount; baseAttackTime = attackTime; // ⭐ 各游戏 prefab 按钮配置不同:未拖引用时为null,做保护(空就跳过,不能报错) if (btn_ChangeTurret != null) btn_ChangeTurret.onClick.AddListener(() => { var panel = subUIManager.Get(UIPanel.TurretPanel); if (panel != null && panel.gameObject.activeSelf) subUIManager.Hide(UIPanel.TurretPanel); else { subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true)); StartCoroutine(CloseSettingList()); } }); if (btn_Setting != null) btn_Setting.onClick.AddListener(() => {subUIManager.ShowAsync(UIPanel.SettingPanel); }); // ⭐ 各游戏prefab未拖拽表情按钮时为null,做保护(XJMH面板没有该按钮) if (btn_BiaoQing != null) btn_BiaoQing.onClick.AddListener(OnBiaoQingButtonClick); if (btn_TuiChu != null) btn_TuiChu.onClick.AddListener(() => { StartLeaveCountdown(); }); // ⭐ 切换房间:离房后重新走一遍加入房间流程(QuickJoin同场次同等级 → 连新房socket → JoinRoom) // 各游戏prefab未拖拽该按钮时为null,做保护 if (btn_ChangeRoom != null) btn_ChangeRoom.onClick.AddListener(StartChangeRoom); // ⭐ 无操作自动退出:关闭倒计时面板并重新计时(未拖引用时为null,做保护) if (btn_CloseAutoExitGamePanel != null) btn_CloseAutoExitGamePanel.onClick.AddListener(OnCloseAutoExitPanel); // ⭐ 左侧道具按钮:开启/关闭 obj_PropPanel(一级列表由 PropConfigData 配置构建, // 点击一级道具在 tmp_PropTwoPanel 展开二级子道具) if (btn_Prop != null) btn_Prop.onClick.AddListener(() => { if (obj_PropPanel == null) return; bool open = !obj_PropPanel.activeSelf; obj_PropPanel.SetActive(open); if (open) { BuildPropList(); // ⭐ 数量是动态数据:每次打开面板拉最新背包再刷新(刮刮卡等面板内刮出/消耗的道具也能同步) RefreshPropCounts(); } else ClosePropTwo(); // 关道具面板时一并收起二层并重置展开状态 }); // ⭐ 大喇叭:开/关弹幕输入面板 // ⭐ 绑定在 OnShow 里:先 RemoveAllListeners 再 Add,防止面板重复 OnShow 叠加监听器 // (叠加后点一次发送 = 发 N 次 API = 多扣 N 个道具) if (dalaba != null) { dalaba.onClick.RemoveAllListeners(); dalaba.onClick.AddListener(() => { if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(!obj_DaLaBaPanel.activeSelf); }); } // 大喇叭正文限长:后端整条广播≤20字,标识占1字,输入框直接限制19字 if (tmp_DaLaBaInput != null) tmp_DaLaBaInput.characterLimit = APIRequestList.BroadcastMaxContentLength; if (btn_DaLaBaSend != null) { btn_DaLaBaSend.onClick.RemoveAllListeners(); btn_DaLaBaSend.onClick.AddListener(OnClickDaLaBaSend); } if (btn_DaLaBaClose != null) { btn_DaLaBaClose.onClick.RemoveAllListeners(); btn_DaLaBaClose.onClick.AddListener(() => { if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(false); }); } // ⭐ 召唤卡:使用召唤卡召唤Boss(同样防重复绑定) if (zhanhuanka != null) { zhanhuanka.onClick.RemoveAllListeners(); zhanhuanka.onClick.AddListener(OnClickZhanHuanKa); } // 服务器广播监听 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing); // ⭐ 战神切换帧同步(changeZhanShen):其他玩家装备/卸下战神,对应座位加载/清除战神模型 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen); // ⭐ 表情包帧同步(biaoQing):其他玩家发送表情,对应座位显示 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing); // ⭐ 加金币帧同步(addGold):其他玩家刮刮卡等 HTTP 途径获得金币,对应座位加金币刷新显示 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_AddGold, SyncGameData_AddGold); // ⭐ 刮刮卡领取金币:本地座位加金币并帧同步给房间其他玩家(先减防重复订阅) GuaGuaLeGPU.OnScratchGoldAwarded -= OnScratchGoldAwarded; GuaGuaLeGPU.OnScratchGoldAwarded += OnScratchGoldAwarded; // SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发 EventCenter.Instance.AddEventListener(ConstEventString.FishFrozen, OnFishFrozen); // ⭐ 召唤卡召唤通知:某玩家使用召唤卡,服务端已创建召唤鱼(仅播提示,实际鱼由 OnFishSpawn 创建) EventCenter.Instance.AddEventListener(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle); // ⭐ 爆奖结束帧同步(baoJiangEnd):其他玩家爆奖完成,显示最终 BJEndUI 并加分 EventCenter.Instance.AddEventListener(ConstEventString.BaoJiangEnd, OnBaoJiangEnd); // ⭐ 用户数据变化(背包/金币等)→ 刷新道具数量显示: // 服务端扣/发道具后推 BackpackUpdated/AssetUpdated → GetUser → UpdataUserData → 此事件。 // 统一覆盖所有道具类型(VIP经验/刮刮卡/鱼雷等异步消耗,点击瞬间服务端还没扣, // 点击后立即拉背包是竞态拿旧数据,所以靠这条事件链实时刷新) GameManager.Instance.OnUserDataChanged -= OnUserDataChanged_RefreshProps; GameManager.Instance.OnUserDataChanged += OnUserDataChanged_RefreshProps; if (btn_TuJian != null) btn_TuJian.onClick.AddListener(() => { subUIManager.ShowAsync(UIPanel.GameTuJian); StartCoroutine(CloseSettingList()); }); if (btn_GuaGuaKa != null) btn_GuaGuaKa.onClick.AddListener(() => { subUIManager.ShowAsync(UIPanel.GuaGuaKa); }); } /// 打开离开游戏确认面板:点确认直接退出房间(无倒计时) public void StartLeaveCountdown() { if (_isLeavingCountdown) return; _isLeavingCountdown = true; if (obj_DJSPanel != null) obj_DJSPanel.SetActive(true); // ⭐ 无倒计时:确认按钮直接可点 if (btn_QueRen != null) btn_QueRen.interactable = true; if (tmp_DJS != null) tmp_DJS.text = "确认"; // 绑定按钮事件 if (btn_QueRen != null) { btn_QueRen.onClick.RemoveAllListeners(); btn_QueRen.onClick.AddListener(() => { // 确认:关闭确认面板,发送离房请求 + 启动进度条卸载 if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false); RequestLeaveRoom(); }); } if (btn_QuXiao != null) { btn_QuXiao.onClick.RemoveAllListeners(); btn_QuXiao.onClick.AddListener(CancelLeaveCountdown); } } /// 是否正在离开倒计时 public bool IsLeavingCountdown() => _isLeavingCountdown; /// 取消离开倒计时 public void CancelLeaveCountdown() { _isLeavingCountdown = false; if (_leaveCountdownCoroutine != null) { StopCoroutine(_leaveCountdownCoroutine); _leaveCountdownCoroutine = null; } if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false); } private IEnumerator LeaveCountdownRoutine(int seconds) { int remaining = seconds; while (remaining > 0) { if (tmp_DJS != null) tmp_DJS.text = $"确认({remaining})"; yield return new WaitForSeconds(1f); remaining--; } // 倒计时结束:按钮启用,文本显示"确认" if (tmp_DJS != null) tmp_DJS.text = "确认"; if (btn_QueRen != null) btn_QueRen.interactable = true; } #region 无操作自动退出(60秒静默 → 最后10秒倒计时面板 → 70秒自动回大厅) /// /// ⭐ 每帧记录玩家操作,超时自动走离房流程回大厅。 /// 操作判定 = 屏幕输入(触摸/鼠标/键盘,含 UI 点击)**或** 自动游玩状态 /// (锁定中自动锁定/开火、自动攻击中——不能只看屏幕点击,详见 IsLocalPlayerAutoPlaying)。 /// 机器人/其他玩家的同步数据不算操作;退出确认面板开着时超时同样生效。 /// 只在资源加载完成后计时(加载慢不会被踢);离房流程进行中不重复触发。 /// 流程:前 idleExitTotalSeconds - idleExitPanelShowSeconds(默认60秒)全程无提示 /// → 最后 idleExitPanelShowSeconds(默认10秒)才弹出倒计时面板(autoExitGamePanel) /// 逐秒倒数 → 走满 idleExitTotalSeconds(默认70秒)自动退出回大厅; /// 期间任意操作或点 btn_CloseAutoExitGamePanel → 关闭面板并重新计时。 /// private void UpdateIdleAutoExit() { if (idleExitTotalSeconds <= 0f || idleExitPanelShowSeconds <= 0f) { HideIdleExitWarning(); return; } // ⭐ 操作判定①:任何触摸/鼠标/键盘输入(含点在 UI 上) // 操作判定②:锁定/自动攻击中(炮台自动打鱼,玩家不碰屏幕也算在玩) if (IsAnyOperationThisFrame() || IsLocalPlayerAutoPlaying()) { _lastOperationTime = Time.realtimeSinceStartup; // 有操作 → 立即隐藏倒计时面板 HideIdleExitWarning(); } // 计时未生效(加载中/已隐藏)或已在离房流程中 → 跳过 if (!_idleExitArmed || _leaveRoomResponseTcs != null) return; float idleElapsed = Time.realtimeSinceStartup - _lastOperationTime; // 面板弹出时刻:总时长 - 面板显示时长(70 - 10 = 60 秒) float panelShowAt = idleExitTotalSeconds - idleExitPanelShowSeconds; if (idleElapsed >= idleExitTotalSeconds) { HideIdleExitWarning(); Debug.Log($"[IdleExit] 连续 {idleExitTotalSeconds} 秒无操作,自动退出游戏回大厅"); // 退出确认面板还开着 → 先关掉,再走离房流程 if (_isLeavingCountdown) CancelLeaveCountdown(); RequestLeaveRoom(); return; } // 只有最后 idleExitPanelShowSeconds 秒才显示面板;之前保持隐藏 if (idleElapsed >= panelShowAt) ShowIdleExitWarning(Mathf.CeilToInt(idleExitTotalSeconds - idleElapsed)); else HideIdleExitWarning(); } /// 显示/刷新无操作退出倒计时面板(仅在剩余秒数变化时刷新文本,避免每帧 GC) private void ShowIdleExitWarning(int remainingSeconds) { // 弹出倒计时面板(未拖面板引用时退化为只显示文本) if (autoExitGamePanel != null) { if (!autoExitGamePanel.activeSelf) autoExitGamePanel.SetActive(true); } else if (tmp_ExitDaoJiShi != null && !tmp_ExitDaoJiShi.gameObject.activeSelf) { tmp_ExitDaoJiShi.gameObject.SetActive(true); } if (tmp_ExitDaoJiShi != null && _idleWarnShownSeconds != remainingSeconds) { _idleWarnShownSeconds = remainingSeconds; tmp_ExitDaoJiShi.text = $"({remainingSeconds}秒后自动退出)"; } } /// 隐藏无操作退出倒计时面板(有操作/关闭按钮/开始离房/面板隐藏时调用) private void HideIdleExitWarning() { if (autoExitGamePanel != null) { if (autoExitGamePanel.activeSelf) { autoExitGamePanel.SetActive(false); _idleWarnShownSeconds = -1; } return; } if (tmp_ExitDaoJiShi != null && tmp_ExitDaoJiShi.gameObject.activeSelf) { tmp_ExitDaoJiShi.gameObject.SetActive(false); _idleWarnShownSeconds = -1; } } /// btn_CloseAutoExitGamePanel:关闭倒计时面板并从头重新计时 private void OnCloseAutoExitPanel() { HideIdleExitWarning(); _lastOperationTime = Time.realtimeSinceStartup; } /// 本帧是否有玩家输入:触摸/鼠标按下、按住、松开 或 任意按键 private bool IsAnyOperationThisFrame() { var input = GetInput(); if (input.isPressed || input.wasPressedThisFrame || input.wasReleasedThisFrame) return true; var keyboard = Keyboard.current; return keyboard != null && keyboard.anyKey.isPressed; } /// /// ⭐ 本地玩家是否处于"自动游玩"状态(与 HandleAutoFire 的 needAutoFire 同判定): /// - 锁定模式:锁定开关开着且有锁定目标(炮台自动锁定/旋转/开火) /// - 自动攻击模式:toggle_AutoAttack 开着 /// 这些模式下玩家不碰屏幕也在正常游戏,不算无操作。 /// 只判断本地玩家 currSitData,其他玩家/机器人的状态不算。 /// private bool IsLocalPlayerAutoPlaying() { if (currSitData == null) return false; // 锁定模式:开关开着 && 有锁定目标(lockFX 激活) if (toggle_Lock != null && toggle_Lock.isOn && currSitData.isLock && currSitData.lockFX != null && currSitData.lockFX.gameObject.activeSelf) return true; // 自动攻击模式 if (currSitData.IsAutoAttackOn) return true; return false; } /// 开始无操作计时(LoadResources 完成后调用,计时从当前时刻重新起算) private void ArmIdleAutoExit() { _lastOperationTime = Time.realtimeSinceStartup; _idleExitArmed = true; } #endregion public override void OnDestroy() { // ⭐ 基类释放子面板(图鉴/锁定等 SubUIManager 句柄)的 Addressables 资源 base.OnDestroy(); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing); // ⭐ 加金币帧同步 + 刮刮卡领取金币订阅随面板销毁解除 EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_AddGold, SyncGameData_AddGold); GuaGuaLeGPU.OnScratchGoldAwarded -= OnScratchGoldAwarded; // SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发 EventCenter.Instance.RemoveEventListener(ConstEventString.FishFrozen, OnFishFrozen); EventCenter.Instance.RemoveEventListener(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle); EventCenter.Instance.RemoveEventListener(ConstEventString.BaoJiangEnd, OnBaoJiangEnd); // ⭐ 用户数据变化订阅随面板销毁解除(RefreshGGKCount 此前只 += 未 -=,切场景重复订阅泄漏,一并补上) GameManager.Instance.OnUserDataChanged -= OnUserDataChanged_RefreshProps; GameManager.Instance.OnUserDataChanged -= RefreshGGKCount; // ⭐ 离场清理:子弹池 + 特效池彻底清空,防止内存泄漏 BulletPool.Instance.ClearAll(); EffectPool.Instance.ClearAll(); // ⭐ 清理未完成的 not-self 爆奖 pending(避免悬空引用) BaoJiangNotSelfHandler.ClearAll(); // ⭐ 清理冰冻特效实例:Instantiate 无父对象 → 挂在场景根,面板销毁不会带走, // 冰冻中切房会残留一块冰冻蒙皮,必须手动销毁 if (_bingFengInstance != null) { Destroy(_bingFengInstance); _bingFengInstance = null; } _frozenAnimSpeeds.Clear(); // ⭐ 冰冻中面板被销毁(例如切房):把被定格的 Timeline 速度还原,避免残留 0 速导致时间轴永远不动 RestoreFrozenDirectors(); } /// /// ⭐ 长按锁定键(0.6秒)打开的"锁定设置"面板。 /// 默认 = 通用 SuoDing 面板;XJMH 覆写为原版 PW_SetLock 面板(其白名单通过覆写 IsFishAllowedBySuoDing 提供)。 /// protected virtual void OpenLockSettingPanel() { subUIManager.ShowAsync(UIPanel.GameSuoDing); } public virtual void Start() { // ⭐ 未拖引用时为null,做保护(空就跳过) if (btn_SettingList != null) btn_SettingList.onClick.AddListener(() => { if (isOpenSetting) StartCoroutine(CloseSettingList()); else StartCoroutine(OpenSettingList()); }); if (toggle_Lock != null) { toggle_Lock.onValueChanged.AddListener(on => { if (!on) { // ⭐ 取消锁定开关 → 重置自动锁定激活状态,下次需重新手动锁定激活 _autoLockActivated = false; // ⭐ 同时退出"图鉴锁定"模式:下次再开锁定开关 = 正常锁定(不受图鉴勾选约束) _suoDingMode = false; // 本地清理锁定 if (currSitData != null) { currSitData.ClearLock(); SyncTurretState(TurretState.Idle); } // 同步给服务器:取消锁定 SyncGameData sgd = new SyncGameData { sitPos = SeatMirror.ToServer(currSitData.posID), frameType = FrameDataType.changeAutoTarget, time = BeijingTimeManager.Instance.CurrentTimestampMs, frameData = new FrameData_ChangeAutoTarget { targetGuid = null } }; WebSocketSendList.SyncGameData(sgd); } }); // ⭐ 长按锁定 toggle(0.6秒) → 打开单独锁定面板(SuoDing):只打开界面,短按仍是正常开关锁定 var lockLongPress = toggle_Lock.gameObject.GetComponent(); if (lockLongPress == null) lockLongPress = toggle_Lock.gameObject.AddComponent(); lockLongPress.onLongPress += () => { OpenLockSettingPanel(); }; } // ⭐ 未拖引用时为null,做保护(空就跳过) if (toggle_BaoZou != null) toggle_BaoZou.onValueChanged.AddListener(on => { if (!on) { // ⭐ 打开后只能等倒计时结束自动关闭,不允许玩家手动关闭 // 直接 SetIsOnWithoutNotify(true) 避免递归触发 onValueChanged toggle_BaoZou.SetIsOnWithoutNotify(true); return; } if (_localBaoZouCount <= 0) { toggle_BaoZou.SetIsOnWithoutNotify(false); return; } // 只发请求,效果等 UsePropNotice 回调触发 WebSocketSendList.UseProp( GameManager.Instance.gameData.currRoomData.roomId, GameManager.Instance.user.id, "40004", 0); }); // toggle_SkillAuto 的关闭监听(切 Idle+广播)已移植到 SitPosData.OnAutoAttackToggleChanged,面板通过 HookAutoAttackOff 订阅广播 // ⭐ 未拖引用时为null,做保护(空就跳过) if (toggle_BingFeng != null) toggle_BingFeng.onValueChanged.AddListener(on => { if (!on) { // ⭐ 打开后只能等倒计时结束自动关闭,不允许玩家手动关闭 // 直接 SetIsOnWithoutNotify(true) 避免递归触发 onValueChanged toggle_BingFeng.SetIsOnWithoutNotify(true); return; } if (_localBingFengCount <= 0) { toggle_BingFeng.SetIsOnWithoutNotify(false); return; } // 只发请求,效果等 UsePropNotice 回调触发 WebSocketSendList.UseProp( GameManager.Instance.gameData.currRoomData.roomId, GameManager.Instance.user.id, "40003", 0); }); // ⭐ 双倍开火开关:持久模式(玩家手动开关,不自动关闭)。 // 开启=满环开始循环转圈(DaoJiShi 驱动,转完一圈从头继续),关闭=停转清环。 // isDouble 不需要同步开关状态:每次开火随 SyncFire 的 twoRTruect 传给服务端, // 三处开火入口(TryFire/HandleAutoFire/OnFlyTurretUpwardFire)都实时读 isOn if (toggle_Shuang != null) { toggle_Shuang.onValueChanged.AddListener(on => { var fill = GetShuangFillImage(); if (on) { currShuangTime = shuangDuration; if (fill != null) fill.fillAmount = 1f; } else { currShuangTime = 0f; if (fill != null) fill.fillAmount = 0f; } }); } } public bool isFrenzy; public void SetFrenzy(bool on) { bool frenzy = CreateFishManager.Instance.currGameType.isFrenzy; // 攻击速度翻倍 attackTime = frenzy ? baseAttackTime * 0.5f : baseAttackTime; // 动画倍速 if (currSitData != null && currSitData.currentTurret != null) currSitData.currentTurret.SetFrenzy(frenzy); } public void OnEnable() { } int ina = 0; protected virtual void Update() { UpdateIdleAutoExit(); HandleAutoRotate(); HandleInput(); CheckLockTargetValid();//锁定目标失效自动清除 HandleAutoFire(); // 自动开火真正发射 HandleLock(); HandleAutoLock(); // ⭐ 先切换锁定目标(切换后图标位置同帧更新) UpdateLockFX(); // 锁定图标跟随目标 RefreshTripleLockIcons(); LoopLaser(); SettingClose(); DaoJiShi(); HandleBotAI(); WebSocketSendList.FlushSyncQueues(Time.deltaTime); } float sittingTime = 10; float currSittingTime; /// /// 等待10秒未操作关闭设置面板 /// public void SettingClose() { //if (isOpenSetting) StartCoroutine(CloseSettingList()); //else StartCoroutine(OpenSettingList()); if (isOpenSetting) { currSittingTime -= Time.deltaTime; if (currSittingTime <= 0) { StartCoroutine(CloseSettingList()); } } else { currSittingTime = 10; } } #endregion #region 资源加载模块 /// /// ⭐ 资源流程令牌(static:新老游戏面板实例共享,GameMainPanel_FishBase 一脉)。 /// 每次 LoadResources / 卸载流程启动时递增并捕获;async 恢复后若令牌已变, /// 说明期间已有新一轮流程启动(加载中退出、旧面板延迟卸载撞上新面板加载等), /// 本轮残留流程必须立即终止 —— 否则旧加载流程会在卸载完成后继续 /// EnterRoom/出鱼/置位 isResources,把已退出房间的出鱼系统重新武装, /// 污染下一个房间的鱼和路线(跨玩法路线混乱的流程层根源)。 /// private static int _resourceFlowToken = 0; /// /// 外部触发资源加载:由 WebSocketReponseList 在"游戏面板已显示 → 游戏场景加载完成"后调用, /// 保证加载顺序为 面板(加载页面)→ 场景 → 资源。 /// public void BeginLoadResources() { LoadResources(); } public async void LoadResources() { // ⭐ 捕获本轮令牌:同时使之前所有在途的加载/卸载残留流程作废 int flow = ++_resourceFlowToken; // ⭐ 切换房间:隐藏新面板自带的进度条,继续用离房时留下的合并进度条 // (挂在 uiRoot、盖在最上层),避免"退出+加入"显示两条进度条 if (_changeRoomBar != null) { if (JinDuTiao != null) JinDuTiao.Hide(); _changeRoomBar.transform.SetAsLastSibling(); } CreateFishManager.Instance.isResources = false; // ⭐ 重置卸载标记,下次进入可重新加载 _resourcesUnloaded = false; // ⭐ 切换房间(同玩法):鱼/音频/路线/场景全部复用,跳过全部资源加载直接 EnterRoom —— // 切房耗时从"卸载+重新解压鱼包(数秒)"缩短到"网络往返+出鱼"。 // bulletClip 为 static,新面板实例直接复用。 bool isChangeRoom = _changeRoomBar != null; if (isChangeRoom) { Debug.Log("[ChangeRoom] 同玩法切房:复用已加载资源,直接进入房间"); SetLoadProgress(0.8f); } else { // ⭐ 进度分配(单一大包自适应:大包的解压耗时反映在最先碰到它的步骤上): // 0~2 卸载大厅 UI 资源 // 2~30 路线文本(若路线和鱼资源同在一个大包,包的解压耗时体现在这段,有实时回调) // 30~60 鱼包(prefab/spine/sprite 内部细分;若包已被路线段拉起则本段快速通过) // 60~78 玩法音频(同包则快速通过,否则按 PercentComplete 推进) // 78~84 开火音效 // 84~90 EnterRoom + 初始鱼同步 // 90 ⭐确认点:走到这里时所有资源 await 都已完成 // 90~100 平滑推进收尾(替代原来的 2 秒死等,条一直在动) SetLoadProgress(0.01f); // ⭐ 卸载大厅 UI 资源(进游戏释放 HomeUIRes 内存,回大厅时由离房流程重新预载): // 大厅主面板此时已被关闭(实例销毁时 SubUIManager.ReleaseAll 已释放子面板句柄), // 这里清掉 UIManager 侧大厅面板的 prefab 句柄缓存 → HomeUIRes bundle 引用归零真正卸载 UIManager.Instance.ReleaseHomeUI(); SetLoadProgress(0.02f); // 路线:2~30(大包同步解压期间回调不刷新 → 预估进度保证条持续推进) StartCreepProgress(0.02f, 0.30f); await MonsterMoveLine.LoadBezier(p => { if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.02f, 0.30f, p))); }); if (flow != _resourceFlowToken) return; // ⭐ 期间已退出/切换,本轮作废 // 鱼包:30~60,内部子进度实时上报(预估进度兜底 LZMA 解压阻塞段,不再卡在 30) StartCreepProgress(0.30f, 0.60f); await FishResourcesData.LoadFishAssetsData(p => { if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.30f, 0.60f, p))); }); if (flow != _resourceFlowToken) return; // 玩法音频:60~78 StartCreepProgress(0.60f, 0.78f); await AudioResourcesData.LoadAudioGame(p => { if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.60f, 0.78f, p))); }); if (flow != _resourceFlowToken) return; // ⭐ 开火音效 PW_Shoot 在 Default Local Group(Audio+PTCBZS 双标签交集检索): // 只命中 Default 小包,不把其它玩法的巨型音频包拉进内存;句柄由 LoadBulletClip 静态保存,离房释放 StartCreepProgress(0.78f, 0.84f); await LoadBulletClip(); StopCreepProgress(); if (flow != _resourceFlowToken) return; SetLoadProgress(0.84f); if (_bulletClipHandle.IsValid() && _bulletClipHandle.Result != null) { foreach (var clip in _bulletClipHandle.Result) { if (clip.name == "PW_Shoot") { bulletClip = clip; break; } } } } CreateFishManager.Instance.EnterRoom(gameType); CreateFishManager.Instance.isResources = true; SetLoadProgress(0.87f); // ⭐ 鱼创建由服务端 OnFishSpawn 事件统一下发,客户端无需房主出鱼/请求同步。 // 玩家进入房间后服务端会主动推送 OnFishSpawn(type="init") 初始鱼, // 重连/取消挂机时服务端会推送 OnFishSpawn(type="sync") 同步当前鱼。 // ⭐ RoomInfo.activeFishes 处理:非首个进房/断线重连/取消挂机时, // RoomInfo 会携带 activeFishes(当前场上活鱼),用它初始化场景鱼。 // 首个进房时 activeFishes 为 null,由服务端 OnFishSpawn(type="init") 下发。 var activeFishes = GameManager.Instance.gameData.currRoomData?.activeFishes; if (activeFishes != null && activeFishes.Count > 0) { var spawnData = new OnFishSpawnData { type = "sync", timestamp = BeijingTimeManager.Instance.CurrentTimestampMs, fish = activeFishes }; CreateFishManager.Instance.OnFishSpawn(spawnData); // 处理完清空,避免重复处理(如场景重载) GameManager.Instance.gameData.currRoomData.activeFishes = null; } // ⭐ 最终确认玩法 BGM:进入房间时"选房/大厅面板 Hide→ChangeBG(69001) 大厅音乐" // 可能晚于面板 Awake/OnShow 执行,把玩法 BGM 覆盖成大厅音乐; // 加载完成这里最终置一次,保证游戏内播放的一定是玩法 BGM if (GameBgmAudioId > 0 && AudioManager.Instance != null) AudioManager.Instance.ChangeBG(GameBgmAudioId); // ⭐ 90% 确认点:走到这里所有资源加载都已完成(上面全是 await 顺序执行) SetLoadProgress(0.9f); // ⭐ 90→100 平滑收尾:约 1 秒走完,进度条持续在动(替代原 2 秒死等; // 保留这段缓冲是等出鱼首帧/场景渲染稳定,比原来快 1 秒+) for (int i = 1; i <= 20; i++) { await Task.Delay(50); if (flow != _resourceFlowToken) return; // ⭐ 收尾期间退出的情况,必须拦 SetLoadProgress(0.9f + 0.1f * (i / 20f)); } // ⭐ 切换房间:合并进度条走完 → 隐藏并销毁;平时隐藏面板自己的进度条 if (_changeRoomBar != null) ClearChangeRoomBar(); else if (JinDuTiao != null) JinDuTiao.Hide(); // ⭐ 无操作自动退出:加载完成,从现在开始计无操作时间 ArmIdleAutoExit(); } public async void UnLoadResources() { if (_resourcesUnloaded) return; // ⭐ 捕获本轮令牌:同时使在途的加载流程作废(OnHide 卸载意味着本房加载不应再继续); // 已卸载时提前 return 不动令牌,避免误杀新面板正在进行的加载 int flow = ++_resourceFlowToken; CreateFishManager.Instance.ExitRoom(); CreateFishManager.Instance.isResources = false; await FishResourcesData.UnLoadFishAssetsData(); if (flow != _resourceFlowToken) return; // ⭐ 期间已有新流程(新面板加载),剩余卸载作废 await AudioResourcesData.UnLoadAudioGame(); if (flow != _resourceFlowToken) return; UnLoadBulletClip(); // ⭐ 释放开火音效句柄(引用计数归零) TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay); // ⭐ 卸载按需加载的战神(游戏内) TurretResourcesData.UnloadAllTurretPrefabs(); // ⭐ 卸载按需加载的炮台/翅膀 prefab await MonsterMoveLine.UnLoadBezier(); if (flow != _resourceFlowToken) return; // ⭐ 清空子弹池 + 特效池(鱼池已在 UnLoadFishAssetsData 内清理) BulletPool.Instance.ClearAll(); EffectPool.Instance.ClearAll(); _resourcesUnloaded = true; // ⭐ 真正回收内存:Addressables 释放包用的是 AssetBundle.Unload(false) 语义 —— // 只是断开引用,已加载的贴图/材质/网格/音频要等 UnloadUnusedAssets 才真正销毁。 // iOS 上不调用就会出现"AA 包卸载了但占用内存不降",连续进第二个游戏内存叠加 → jetsam 闪退。 // 用 Task 等待(不依赖本面板协程,OnHide 隐藏后也能跑完)。 await Task.Delay(50); // 等两帧左右让 Destroy 的延迟销毁生效 var unloadOp = Resources.UnloadUnusedAssets(); while (!unloadOp.isDone) await Task.Yield(); System.GC.Collect(); Debug.Log($"[内存] 离房回收完成 | 已分配 {UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / 1048576}MB"); } /// 资源是否已卸载(避免 OnHide 重复卸载) private bool _resourcesUnloaded = false; /// /// ⭐ 开火音效 PW_Shoot 的加载句柄(static:新老面板实例共享)。 /// PW_Shoot 在 Default Local Group(Audio+PTCBZS),该包因默认音频常驻本就不会卸载, /// 但句柄仍要显式保存/释放,避免每次进房引用计数 +1 无限累加。 /// private static AsyncOperationHandle> _bulletClipHandle; /// 加载开火音效(Audio+PTCBZS 交集,只命中 Default Local Group 里的 PW_Shoot) private static async Task LoadBulletClip() { // 防御:上一轮句柄未释放时先释放 if (_bulletClipHandle.IsValid()) Addressables.Release(_bulletClipHandle); _bulletClipHandle = Addressables.LoadAssetsAsync(new List { "Audio", "PTCBZS" }, null, Addressables.MergeMode.Intersection); await _bulletClipHandle.Task; } /// 释放开火音效句柄(离房卸载流程调用;static 引用一并置空防悬空) private static void UnLoadBulletClip() { if (_bulletClipHandle.IsValid()) Addressables.Release(_bulletClipHandle); bulletClip = null; } /// /// 后台补卸载游戏资源(切房失败回大厅时调用): /// 切房流程跳过了卸载(准备复用),失败后资源不再需要,静默补卸防止鱼包常驻内存。 /// private static async void UnloadGameResourcesBackground() { try { await FishResourcesData.UnLoadFishAssetsData(); await AudioResourcesData.UnLoadAudioGame(); UnLoadBulletClip(); TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay); TurretResourcesData.UnloadAllTurretPrefabs(); await MonsterMoveLine.UnLoadBezier(); Debug.Log("[ChangeRoom] 切房失败,游戏资源已补卸载"); } catch (Exception e) { Debug.LogWarning($"[ChangeRoom] 切房失败补卸载异常(不影响回大厅): {e.Message}"); } } /// 离房 socket 响应等待信号 private TaskCompletionSource _leaveRoomResponseTcs; /// 正在离开的游戏面板类型(完成后关闭它切到 home) private UIPanel _leavingGamePanelType; /// /// 请求离开房间:发送离房 socket 请求 + 启动进度条卸载资源。 /// 进度条走到 0.9 后等待 socket 返回 LeaveRoomResult, /// 收到返回后走完剩余进度并切回 home 界面。 /// public void RequestLeaveRoom() { if (_leaveRoomResponseTcs != null) return; // 防重复请求 // 先创建等待信号(在发送请求前创建,避免响应太快丢失) _leaveRoomResponseTcs = new TaskCompletionSource(); // 发送离房请求 WebSocketSendList.LeaveRoom( GameManager.Instance.gameData.currRoomData.roomId, currSitData.user); //CreateFishManager.Instance.fangzhu = false; // 启动带进度条的卸载流程 BeginLeaveRoomUnload(); } /// /// 离房卸载流程:进度条到 0.9 后等待 socket 响应, /// 收到信号后走到 1.0 并切回 home 界面。 /// ⭐ 用 try-finally 保证无论中间是否异常,最终一定会关闭 GamePanel 并切回 Home /// private async void BeginLeaveRoomUnload() { if (_resourcesUnloaded) { _leaveRoomResponseTcs = null; return; } // ⭐ 使在途的 LoadResources 立即作废(加载中退出的场景): // 否则残留加载流程会在卸载完成后继续 EnterRoom/出鱼/置位 isResources, // 把已退出房间的出鱼系统重新武装,污染下一个房间的鱼和路线。 // 本流程不再加恢复检查:期间不可能有新加载启动(home 在 finally 之后才显示), // 且必须完整走完(finally 关面板切 home),保证退出流程不被打断。 _resourceFlowToken++; try { if (JinDuTiao != null) JinDuTiao.Show(); // ⭐ 先退房销毁场上活鱼,再卸载资源(与 UnLoadResources 顺序一致): // 现在句柄真正会释放、包内资源(贴图/Spine)会被销毁, // 若先卸载后销毁鱼,残留活鱼会在淡出期间丢失材质花屏 CreateFishManager.Instance.ExitRoom(); CreateFishManager.Instance.isResources = false; BulletPool.Instance.ClearAll(); EffectPool.Instance.ClearAll(); if (_isChangeRoom) { // ⭐ 同玩法切房:鱼/音频/路线/场景全部复用,跳过卸载 —— // 卸载再加载同一个 LZMA 鱼包要白解压两次(几秒),是切房慢的主因。 // OnHide(旧面板销毁)同样跳过卸载;新面板 LoadResources 走切房分支直接 EnterRoom。 // 卸载段进度直接置满,合并进度条快进到网络段。 Debug.Log("[ChangeRoom] 同玩法切房:跳过资源卸载,新房间复用已加载资源"); SetUnloadProgress(1f); } else { SetUnloadProgress(0.2f); await FishResourcesData.UnLoadFishAssetsData(); SetUnloadProgress(0.4f); await AudioResourcesData.UnLoadAudioGame(); UnLoadBulletClip(); // ⭐ 释放开火音效句柄(引用计数归零) TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay); // ⭐ 卸载按需加载的战神(游戏内) TurretResourcesData.UnloadAllTurretPrefabs(); // ⭐ 卸载按需加载的炮台/翅膀 prefab SetUnloadProgress(0.6f); await MonsterMoveLine.UnLoadBezier(); // ⭐ 真正回收内存:AA 包卸载只是断开引用(Unload(false) 语义),贴图/材质/网格/音频 // 要等 UnloadUnusedAssets 才真正销毁 —— iOS 上不做这步 = 占用不降, // 连进第二个游戏内存叠加超限 → jetsam 闪退 await Task.Delay(50); var leaveUnloadOp = Resources.UnloadUnusedAssets(); while (!leaveUnloadOp.isDone) await Task.Yield(); System.GC.Collect(); Debug.Log($"[内存] 离房回收完成 | 已分配 {UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / 1048576}MB"); // ⭐ 预载回大厅的主面板 prefab:鱼包已卸载腾出内存,加载大厅包不会叠峰值; // 后面等待 socket 的时间足够它加载完,finally 里 ShowAsync(HomeMainPanel) 命中缓存秒开。 // (进房时 ReleaseHomeUI 已释放大厅资源,散包后回大厅各子面板首次打开按需单独加载) UIManager.Instance.PreloadPanel(UIPanel.HomeMainPanel); SetUnloadProgress(0.8f); await Task.Delay(100); } SetUnloadProgress(0.9f); // ⭐ 卡在 0.9 等待 socket 返回 LeaveRoomResult // - socket 已早返回(比卸载快) → 在 0.9 再等 1 秒(美观)后变 1 // - socket 未返回 → 等待 socket,最多 10 秒,超时强制变 1 退出 if (_leaveRoomResponseTcs != null) { bool alreadyReceived = _leaveRoomResponseTcs.Task.IsCompleted; if (alreadyReceived) { // socket 比卸载早返回 → 0.9 停留 1 秒 await Task.Delay(1000); } else { // socket 未返回 → 等待,最多 10 秒 var timeoutTask = Task.Delay(10000); var done = await Task.WhenAny(_leaveRoomResponseTcs.Task, timeoutTask); if (done == timeoutTask) { // 超时 10 秒 → 强制退出,必须执行和 ExitFishingGame 一致的清理 // 否则 game socket 不关闭、isGame 不复位,导致无法再次进入游戏 Debug.LogWarning("[LeaveRoom] 等待 socket 超时 10 秒,强制退出并清理游戏状态"); try { WebSocketManager.Instance.isGame = false; GameManager.LogGoldFlow("ExitRoom", $"超时强制退出 房间={GameManager.Instance.gameData?.currRoomData?.roomId}"); WebSocketManager.Instance.Close(SocketType.game); APIRequestList.GetUser((user) => { GameManager.Instance.UpdataUserData(user); }); } catch (Exception cleanEx) { Debug.LogError($"[LeaveRoom] 超时清理异常: {cleanEx}"); } } } } // 标记已卸载,OnHide 跳过重复卸载 _resourcesUnloaded = true; if (_isChangeRoom) { // ⭐ 切换房间:进度条停在卸载段末尾(合并进度条45%)不隐藏, // 由 finally 挪到 uiRoot 后继续给"换房+加入加载段"使用 → 整个切换只有一条进度条 SetUnloadProgress(1); await Task.Delay(300); } else { if (JinDuTiao != null) JinDuTiao.SetProgress(1); await Task.Delay(300); // ⭐ 不在这里 Hide:进度条保持盖着,等 finally 里大厅 onShown 后才收(原子切换防闪烁) } } catch (Exception ex) { Debug.LogError($"[LeaveRoom] 卸载流程异常: {ex}"); } finally { // ⭐ 无论中间是否异常,都必须关闭 GamePanel // 否则 GamePanel 留在 panels 字典里,下次进入游戏时 Get 返回非 null 导致无法创建新面板 _leaveRoomResponseTcs = null; if (_isChangeRoom) { // ⭐ 切换房间:不回大厅。先把进度条从旧面板挪到 uiRoot(旧面板销毁时不带走它), // 新面板 LoadResources 继续用它走完"换房+加载段"→ 退出+加入合并为一条进度条; // 然后关闭当前游戏面板(OnHide 卸载场景/关socket)并重新走加入房间流程 _isChangeRoom = false; if (JinDuTiao != null) { try { JinDuTiao.transform.SetParent(UIManager.Instance.uiRoot, false); JinDuTiao.transform.SetAsLastSibling(); _changeRoomBar = JinDuTiao; } catch (Exception barEx) { Debug.LogError($"[ChangeRoom] 合并进度条转移异常: {barEx}"); } } UIManager.Instance.Close(PanelType); StartChangeRoomJoin(); } else { // ⭐ 原子切换回大厅:进度条保持盖在最上层不先收 —— // 旧逻辑 finally 一进来就 Hide 进度条,之后"关游戏面板/卸载场景 → 大厅出现" // 的过程全部可见,出现大厅与游戏画面交替的闪烁。 // 现在:进度条挪到 uiRoot(避免随游戏面板销毁)→ 关游戏面板(进度条盖着, // 同时触发 OnHide 卸载场景/资源)→ 大厅 onShown 显示出来后才收掉进度条。 if (JinDuTiao != null) { try { JinDuTiao.transform.SetParent(UIManager.Instance.uiRoot, false); JinDuTiao.transform.SetAsLastSibling(); } catch (Exception barEx) { Debug.LogError($"[LeaveRoom] 进度条转移异常: {barEx}"); } } UIManager.Instance.Close(PanelType); UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, null, (panel) => { try { if (JinDuTiao != null) { JinDuTiao.Hide(); Destroy(JinDuTiao.gameObject); } } catch { } }); // 兜底:3 秒后强制收进度条(防 ShowAsync 加载失败 onShown 不回调导致进度条卡住) _ = HideBarDelayed(JinDuTiao, 3f); } } } /// 延迟收掉进度条(Hide+Destroy;静态 Task 不依赖即将销毁的面板协程宿主,可重复调用) private static async System.Threading.Tasks.Task HideBarDelayed(ProgressBar bar, float delaySeconds) { await System.Threading.Tasks.Task.Delay((int)(delaySeconds * 1000)); try { if (bar != null) { bar.Hide(); if (bar != null) UnityEngine.Object.Destroy(bar.gameObject); } } catch { } } /// /// socket 收到 LeaveRoomResult 后调用,解除 0.9 的等待。 /// public void OnLeaveRoomResultReceived(UIPanel gamePanel) { _leavingGamePanelType = gamePanel; _leaveRoomResponseTcs?.TrySetResult(true); } #endregion #region 切换房间(重新走一遍加入房间流程) /// 正在切换房间(离房完成后不回大厅,直接重新快速加入新房间) private bool _isChangeRoom = false; /// /// 切换房间共享进度条:离房时从旧面板挪到 uiRoot(不随旧面板销毁), /// 新面板 LoadResources 继续用它,把"退出卸载 + 换房 + 加入加载"合并成一条进度条: /// 卸载段 0~45% → 网络段(QuickJoin/连socket/JoinRoom) 45~55% → 加载段 55~100%。 /// null 表示非切换房间流程(走面板自己的 JinDuTiao)。 /// private static ProgressBar _changeRoomBar; /// 卸载段进度写入:切换房间时映射到合并进度条前半段(0~45%),平时直接写面板进度条 private void SetUnloadProgress(float p) { if (JinDuTiao == null) return; JinDuTiao.SetProgress(_isChangeRoom ? p * 0.45f : p); } /// 玩法 BGM 编号(子类覆盖)。加载完成确认点会再置一次, /// 防止进入房间时"选房/大厅面板 Hide → ChangeBG(69001) 大厅音乐"晚于面板 OnShow 把玩法音乐覆盖掉 protected virtual int GameBgmAudioId => 0; /// 加载段进度写入:切换房间时映射到合并进度条后半段(55~100%),平时直接写面板进度条 private void SetLoadProgress(float p) { p = Mathf.Clamp01(p); _loadProgress = p; if (_changeRoomBar != null) _changeRoomBar.SetProgress(0.55f + p * 0.45f); else if (JinDuTiao != null) JinDuTiao.SetProgress(p); } // ---- 预估进度(大包 LZMA 同步解压期间主线程阻塞、回调不刷新 → 条卡死像没响应)---- /// 加载进度当前值(真实或预估),预估推进时判断不回退 private float _loadProgress = 0f; private Coroutine _creepRoutine; /// /// 预估进度:等待段内按时间指数逼近"段末-2%",真实进度回调以更大值覆盖。 /// 加载完成时直接跳到真实位置,全程条和数字都在动,不会出现"卡住不动"的假死感。 /// private void StartCreepProgress(float from, float to) { StopCreepProgress(); if (isActiveAndEnabled) _creepRoutine = StartCoroutine(CreepProgressRoutine(from, to)); } private void StopCreepProgress() { if (_creepRoutine != null) { StopCoroutine(_creepRoutine); _creepRoutine = null; } } private IEnumerator CreepProgressRoutine(float from, float to) { float cap = Mathf.Max(from, to - 0.02f); float elapsed = 0f; while (true) { yield return new WaitForSeconds(0.1f); elapsed += 0.1f; // 指数逼近:前 2.5 秒走完区间约 63%,越接近段末越慢,不会超过 cap float creep = Mathf.Lerp(from, cap, 1f - Mathf.Exp(-elapsed / 2.5f)); if (creep > _loadProgress) SetLoadProgress(creep); } } /// 写切换房间合并进度条(网络段:QuickJoin返回/JoinRoom已发送) private static void SetChangeRoomBarProgress(float p) { if (_changeRoomBar != null) _changeRoomBar.SetProgress(p); } /// 隐藏并销毁切换房间合并进度条(加载完成或切房失败时调用) private static void ClearChangeRoomBar() { if (_changeRoomBar == null) return; var bar = _changeRoomBar; _changeRoomBar = null; try { bar.Hide(); if (bar != null) Destroy(bar.gameObject); } catch { } } /// /// 切换房间入口:复用离房流程(LeaveRoom → 卸载资源 → 关面板), /// 离房完成后由 BeginLeaveRoomUnload 的 finally 重新走加入房间流程。 /// public void StartChangeRoom() { if (_leaveRoomResponseTcs != null) return; // 正在离房/切换中,防重复 _isChangeRoom = true; // 与点退出一致:离房期间屏蔽帧同步和爆奖特效 _isLeavingCountdown = true; RequestLeaveRoom(); } /// /// 重新走加入房间流程(与 UIHome_BY_SelectRoomLevelPanel.OnSelectRoom 快速进入分支完全一致): /// /api/Room/QuickJoin(同场次同等级,服务器分配新房间)→ 连新房 game socket → JoinRoom(-1) 由服务器分配座位, /// 之后服务端推送 RoomJoinNotice → 重新创建游戏面板 + 重载场景,完成切换。 /// ⭐ 切房全程共用一条进度条:卸载段0~45%已在离房时走完,这里推进网络段45~55%, /// 剩余加载段55~100%由新面板 LoadResources 继续。 /// private void StartChangeRoomJoin() { var currRoom = GameManager.Instance.gameData.currRoomData; int gameType = currRoom != null ? currRoom.gameType : (int)GameManager.Instance.gameData.currGameType; int level = currRoom != null ? currRoom.roomLevel : 1; APIRequestList.RoomQuickJoin(gameType, level, (resp) => { if (resp == null || string.IsNullOrEmpty(resp.roomId)) { // ⭐ 快速加入失败 → 清理合并进度条并回大厅,避免卡在无界面状态 UIManager.Instance.ShowBoardError("切换房间失败,请稍后重试"); ClearChangeRoomBar(); // ⭐ 切房流程跳过了资源卸载(准备复用),失败回大厅后资源不再需要 → 补一次卸载 // 防止鱼包/音频常驻内存直到下次进房 UnloadGameResourcesBackground(); UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, GameManager.Instance.gameData.currGameType); return; } // 合并进度条:已拿到新房间(网络段 50%) SetChangeRoomBarProgress(0.5f); // 用 QuickJoin 返回的 webSocketUrl 直接连新房 game socket,然后 JoinRoom(-1) 让服务器分配座位 GameManager.Instance.StartGameQuickJoin(resp.webSocketUrl, () => { // 合并进度条:JoinRoom 已发送(网络段 55%,加载段起点) SetChangeRoomBarProgress(0.55f); WebSocketSendList.JoinRoom(resp.roomId, GameManager.Instance.user.id, -1); }); }); } #endregion #region 表情包(BiaoQingModule:列表生成 + 发送 + 广播接收) /// spine 表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段 private List BiaoQingSpineList => biaoQingConfig != null ? biaoQingConfig.list_BiaoQingSpine : list_BiaoQingSpine; /// 文字表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段 private List BiaoQingZiList => biaoQingConfig != null ? biaoQingConfig.list_BiaoQingZi : list_BiaoQingZi; /// 表情列表是否已生成(首次打开面板时生成一次) private bool _biaoQingListBuilt = false; /// btn_BiaoQing 点击:开/关表情面板(首次打开生成列表) private void OnBiaoQingButtonClick() { if (obj_BiaoQingPanel == null) return; if (obj_BiaoQingPanel.activeSelf) { obj_BiaoQingPanel.SetActive(false); return; } // ⭐ 先激活面板再生成列表/设置文字:TMP 在未激活状态下设置 text 可能不刷新 obj_BiaoQingPanel.SetActive(true); BuildBiaoQingList(); } /// /// 生成表情列表(只生成一次),两种形式分开处理: /// ① spine 表情:遍历 BiaoQingSpineList(优先 biaoQingConfig 资产),每个配置里的每个动画名克隆一个 spine 项 /// (child0=SkeletonGraphic → 换骨骼资源 + 循环播放该动画做列表预览); /// ② 文字表情:BiaoQingZiList 每条文字克隆一个文字项(child0=TextMeshProUGUI → 设置文字)。 /// private void BuildBiaoQingList() { if (_biaoQingListBuilt) return; _biaoQingListBuilt = true; // ---- ① spine 表情:一个动画名 = 一项 ---- if (obj_BiaoQingSpineItemPrefab != null && obj_BiaoQingSpineItemParent != null) { for (int i = 0; i < BiaoQingSpineList.Count; i++) { var cfg = BiaoQingSpineList[i]; if (cfg == null || cfg.skeletonDataAsset == null) continue; foreach (var animName in cfg.animationNames) { if (string.IsNullOrEmpty(animName)) continue; var item = Instantiate(obj_BiaoQingSpineItemPrefab, obj_BiaoQingSpineItemParent.transform); // child0 = SkeletonGraphic:换骨骼资源 + 循环播放该动画(列表预览) var sg = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent() : null; if (sg != null) { sg.skeletonDataAsset = cfg.skeletonDataAsset; sg.Initialize(false); sg.AnimationState.SetAnimation(0, animName, true); } // 点击发送(闭包捕获配置下标+动画名) int spineIndex = i; string anim = animName; var btn = GetBiaoQingItemButton(item); if (btn != null) btn.onClick.AddListener(() => SendBiaoQing(spineIndex, anim)); } } } // ---- ② 文字表情:一条文字 = 一项 ---- if (obj_BiaoQingZiItemPrefab != null && obj_BiaoQingZiItemParent != null) { foreach (var text in BiaoQingZiList) { if (string.IsNullOrEmpty(text)) continue; var item = Instantiate(obj_BiaoQingZiItemPrefab, obj_BiaoQingZiItemParent.transform); // child0 = TextMeshProUGUI:设置文字 var tmp = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent() : null; if (tmp != null) tmp.text = text; string zi = text; var btn = GetBiaoQingItemButton(item); if (btn != null) btn.onClick.AddListener(() => SendBiaoQing(-1, zi)); } } } /// 取表情项上的按钮(优先根节点,其次子节点) private Button GetBiaoQingItemButton(GameObject item) { var btn = item.GetComponent