/*
======================== 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;
[Tooltip("远端座位攻击状态看门狗(秒):最后收到某座位的攻击状态/开火消息后,超过这个时间还没有新消息," +
"就判定这个座位已经不在攻击(典型:攻击中直接退出/掉线,永远不会再发待机包)→ 本地收回待机。\n" +
"实际等待时间 = max(本值, attackTime×3),避免开火间隔比看门狗还长的配置被误判")]
public float remoteAttackIdleTimeout = 1.5f;
/// 远端座位最近一次"还在攻击"的心跳时间:收到该座位的攻击状态广播或开火消息时刷新
private readonly Dictionary remoteAttackHeartbeat = new Dictionary();
[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:全局常驻,切房/重进房直接复用;见 LoadBulletClip 注释)
static AudioClip bulletClip;
public List paoBeiList = new List();
public ProgressBar JinDuTiao;
#region UI
bool isfirstShow = true;
/// 加载到 100% 是否已完成(进房初始化/入场动画等需在加载完成后执行:玩家可见、位置就绪)
public bool IsGameLoadCompleted { get; private set; }
/// 进房"首个初始化"(座位/房间信息)已挂起、等加载到 100% 后执行
private bool _pendingFirstShowInit;
/// 挂起时保存的进房通知(ApplyFirstShowInit 里写入 gameData.roomJoinNotice)
private RoomJoinNotice _pendingRoomJoinNotice;
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)
{
// ⭐ 座位/房间信息初始化推迟到"加载到 100%"后执行(见 ApplyFirstShowInit 与加载完成点):
// 原来在这里立即 JoinGame/摆座位,进房入场动画会在加载期播放——玩家看不到、位置未就绪。
_pendingFirstShowInit = true;
_pendingRoomJoinNotice = roomJoinNotice;
}
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("获取信息失败");
}
}
///
/// 进房首个初始化(座位/房间信息):原 OnShow 里 isfirstShow 的整段逻辑(原样搬入),
/// 现由"加载到 100%"的加载完成点调用——保证入场动画等在玩家可见、位置就绪后才初始化/播放。
///
private void ApplyFirstShowInit()
{
RoomJoinNotice roomJoinNotice = _pendingRoomJoinNotice;
if (roomJoinNotice == null) return;
// ⭐ 立即消费标记:本方法在 90% 确认点执行,100% 处的兜底调用不会再重复跑
// (重复执行会把所有座位 JoinGame 两遍:金币/装备/入场标记全部错乱)
_pendingFirstShowInit = false;
string roomId = GameManager.Instance.gameData.currRoomData.roomId;
// 诊断:100% 卡的耗时分解(每个玩家 JoinGame 各多少 ms、合计多少 ms)
var swAll = System.Diagnostics.Stopwatch.StartNew();
var players = GameManager.Instance.gameData.currRoomData.players;
Debug.Log($"[XJMH首屏] 开始初始化:玩家 {players?.Count ?? 0} 名");
foreach (var item in players)
{
var swOne = System.Diagnostics.Stopwatch.StartNew();
JoinGame(item.position, item.gold, item.diamond, item.cannonMultiple, 0, item.userId, item.cannonId, item.wingsId, item.warGodId);
Debug.Log($"[XJMH首屏] JoinGame 座位={item.position} ({item.userName}) 耗时 {swOne.ElapsedMilliseconds}ms");
// ⭐ 优先用后端 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 = roomJoinNotice;
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));
}
// 诊断:100% 这一下的总耗时(配合每条 JoinGame 的 ms 看是哪一步重)
Debug.Log($"[XJMH首屏] ApplyFirstShowInit 总耗时 {swAll.ElapsedMilliseconds}ms");
}
///
/// XJMH 进房预热(60% 音频段起点调用,进度条还显示着玩家看不到):
/// 把本地座位(座位自带炮台随 Awake 一起初始化)与"入场要用的那架完整飞机"提前点亮——
/// 这些 XJMH 独有资产首次初始化(骨架/粒子/贴图首上传)很重,全堆在 100% 那一帧玩家会看到明显卡顿。
/// ⭐ 按帧拆散(避免"90% 卡死"):先让一帧(进度条先刷新)→ 只留默认炮台、关掉其余 3 把 →
/// 激活座位(1 套骨架) → 等两帧 → 飞机分两帧点亮(Body / 护盾粒子各一帧)。
/// 入场动画有加载完成门卫(IsGameLoadCompleted),预热不会导致动画提前播放。
///
private void PrewarmXJMHForLocalSeat()
{
if (!_pendingFirstShowInit || _pendingRoomJoinNotice == null) return;
if (list_Sits == null || list_Sits.Count == 0) return;
var sit = list_Sits.Find(s => s != null && s.posID == SeatMirror.ToDisplay(_pendingRoomJoinNotice.Position));
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) return;
StartCoroutine(PrewarmXJMHCoroutine(sit));
}
private IEnumerator PrewarmXJMHCoroutine(SitPosData sit)
{
// ⭐ 先等一帧:StartCoroutine 会同步执行到第一个 yield —— 座位激活的重活不能和进度条
// 写数字挤在同一帧(玩家看到"卡在 60/61")。先让一帧,进度条先刷新
yield return null;
bool willActivate = sit != null && !sit.gameObject.activeSelf;
// ⭐ 分帧方案(XJMH 进房不卡主线程的关键)—— 把所有"可以错开的初始化"从同一帧剥出去:
// ① 座位外壳(1 帧,只扛座位整层 OnEnable)
// ② 默认炮台 Spine 骨架首初始化(单独 1 帧,见 SitPosData.deferDefaultTurretLightUp)
// ③ 座位外壳粒子 + 默认炮台粒子(本方法先关掉,之后逐帧点亮)
// ④ 入场飞机(FlyTurretData.PrewarmPlaneRoutine:骨架 1 帧 + 41 个粒子逐帧点亮)
List lazyPsNodes = null;
if (willActivate)
{
// 预关全部 4 把炮台(Spine 骨架默认全开,不关的话激活帧一次初始化 4 套)
// ⚠️ 座位已在激活状态时不能动(Awake 不会再跑,关掉就回不来)
if (sit.xjmhTurretList != null)
{
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t != null && t.gameObject.activeSelf) t.gameObject.SetActive(false);
}
}
// 收集"激活后会被点亮"的粒子并先关掉(此刻根未激活,只是置标志、无初始化):
// 座位外壳层(炮台/飞机子树此刻未激活,自动被排除)+ 默认炮台子树(兄弟顺序最靠前=绿色那把)
lazyPsNodes = CollectActiveParticlesUnder(sit.transform);
var defaultTurret = GetDefaultXJMHTurret(sit);
if (defaultTurret != null)
{
var turretPsNodes = CollectActiveParticlesUnder(defaultTurret.transform);
if (turretPsNodes != null)
{
if (lazyPsNodes == null) lazyPsNodes = turretPsNodes;
else lazyPsNodes.AddRange(turretPsNodes);
}
}
// ① 激活座位【外壳】:设 defer 标记 → Awake 只整理炮台列表、不点亮炮台
// (这一帧只扛"座位整层 OnEnable",把炮台骨架首建推到下一帧)
sit.deferDefaultTurretLightUp = true;
LoadingStallWatchdog.Phase = "预热·座位外壳激活帧";
var swSeat = System.Diagnostics.Stopwatch.StartNew();
sit.gameObject.SetActive(true);
Debug.Log($"[XJMH预热] 座位外壳激活 {swSeat.ElapsedMilliseconds}ms");
}
yield return null; // 座位外壳首帧
// ② 单独一帧点亮默认炮台:Spine 骨架首初始化(材质/网格/贴图上传)只花在这一帧
// ⚠️ 判据用"列表里有没有激活的炮台",不能用 currentTurret(可序列化字段,预制体里预置了引用)
if (willActivate && !sit.HasActiveXJMHTurret())
{
LoadingStallWatchdog.Phase = "预热·炮台骨架激活帧";
var swTurret = System.Diagnostics.Stopwatch.StartNew();
sit.ActivateDefaultXJMHTurretForPrewarm();
Debug.Log($"[XJMH预热] 炮台骨架激活 {swTurret.ElapsedMilliseconds}ms");
}
yield return null; // 炮台骨架首帧网格
// ③ 逐帧点亮座位/炮台粒子(每帧约 8ms 预算)
if (lazyPsNodes != null && lazyPsNodes.Count > 0)
{
LoadingStallWatchdog.Phase = "预热·座位/炮台粒子分帧";
yield return ChunkEnableParticles(lazyPsNodes);
}
yield return null;
// ④ 飞机隐藏预热(座位激活后 currentTurret 已就绪 = 入场会用的那把;内部对 41 个粒子分帧)
var turret = sit != null ? sit.currentTurret as FlyTurretData : null;
if (turret != null)
{
LoadingStallWatchdog.Phase = "预热·飞机(骨架+粒子分帧)";
turret.PrewarmEnterPlane();
}
}
/// XJMH 默认炮台 = 兄弟顺序最靠前的那把(与 SitPosData.NormalizeXJMHTurretList 的排序一致)
private static TurretData GetDefaultXJMHTurret(SitPosData sit)
{
if (sit == null || sit.xjmhTurretList == null) return null;
TurretData best = null;
int bestIdx = int.MaxValue;
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t == null) continue;
int idx = t.transform.GetSiblingIndex();
if (idx < bestIdx) { bestIdx = idx; best = t; }
}
return best;
}
///
/// 收集"根若激活就会被激活"的粒子节点并先把它们关掉(此刻根未激活,SetActive(false) 只置标志、
/// 不触发任何初始化)——配合 ChunkEnableParticles 把这些粒子系统的首初始化逐帧分摊。
/// 容器(父级)关着的不收集;自身默认关闭的粒子也不动。
///
private static List CollectActiveParticlesUnder(Transform root)
{
if (root == null) return null;
List list = null;
var systems = root.GetComponentsInChildren(true);
for (int i = 0; i < systems.Length; i++)
{
var ps = systems[i];
if (ps == null || ps.gameObject == root.gameObject) continue;
if (!IsActiveUnderRoot(ps.transform, root)) continue;
ps.gameObject.SetActive(false);
if (list == null) list = new List(16);
list.Add(ps.gameObject);
}
return list;
}
/// 节点在"根若激活"的前提下是否会被激活(检查自身到根之间每级的 activeSelf)
private static bool IsActiveUnderRoot(Transform node, Transform root)
{
for (var t = node; t != null && t != root; t = t.parent)
if (!t.gameObject.activeSelf) return false;
return true;
}
/// 逐帧分批点亮粒子节点(每帧约 8ms 预算:快机一帧多开、慢机一帧一个)
private static IEnumerator ChunkEnableParticles(List nodes)
{
const double budgetMs = 8.0;
var frameSw = System.Diagnostics.Stopwatch.StartNew(); // 帧内预算
var oneSw = System.Diagnostics.Stopwatch.StartNew(); // 单个粒子点亮耗时(诊断)
double enabledMs = 0;
int frames = 1;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] == null) continue;
oneSw.Restart();
nodes[i].SetActive(true);
enabledMs += oneSw.Elapsed.TotalMilliseconds;
if (frameSw.Elapsed.TotalMilliseconds >= budgetMs)
{
frameSw.Restart();
frames++;
yield return null;
}
}
yield return null;
Debug.Log($"[XJMH预热] 粒子分帧点亮 {nodes.Count} 个 / {frames} 帧 / 净耗时 {enabledMs:F0}ms(平均 {(nodes.Count > 0 ? enabledMs / nodes.Count : 0):F1}ms/个)");
}
///
/// ⭐ 进房早期(0~2%)预关闭"未激活座位"的多余 XJMH 炮台。
/// 原因:XJMH 座位预制体里 4 把炮台(SkeletonGraphic)默认激活,而座位首次 SetActive 时
/// 4 套骨架/材质/贴图会挤在同一帧全部首初始化(单帧尖峰);预关后 Awake 的 InitXJMHTurret
/// 只点亮默认那把 → 激活帧只初始化 1 套。其它玩家/bot 的座位在 100% 入座时才激活,
/// 同样受益(否则每个座位入座都要初始化 4 套)。
/// ⚠️ 只处理"尚未激活"的座位:已激活的座位 Awake 已跑过,会被 Admin 逻辑重新点亮,
/// 动它反而可能把炮台关掉回不来。
///
private void PreCloseInactiveSitExtraTurrets()
{
if (list_Sits == null) return;
foreach (var sit in list_Sits)
{
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) continue;
if (sit.gameObject.activeSelf) continue; // 已激活座位不动
// 列表为空时 Awake 不会补开炮台 → 全关会留空座位,跳过
if (sit.xjmhTurretList == null || sit.xjmhTurretList.Count == 0) continue;
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t != null && t.gameObject.activeSelf) t.gameObject.SetActive(false);
}
}
}
///
/// ⭐ 提前解析 XJMH 炮台/入场飞机的"骨架数据"(在鱼包加载段调用,与鱼包加载并行)。
/// 背景:SkeletonGraphic 首次激活时(Spine 源码 SkeletonGraphic.Initialize → GetSkeletonData)
/// 要解析 .skel/.json + 建图集对象 + 应用融合材质——这是首激活里最重的一块(几十~几百 ms),
/// 而且 GetSkeletonData 的结果按 SkeletonDataAsset 缓存。
/// 这里提前在主线程调用一次把它缓存掉:60% 处的"炮台骨架帧/飞机骨架帧"就只剩建网格/渲染器
/// 这类轻活(源码里 Initialize 其余部分只有 AnimationState/Skeleton 构造 + 网格缓冲分配)。
/// 放在鱼包段执行:本段主线程本来就在解包/反序列化(有加载卡顿),解析耗时混在这里最不显眼。
/// 解析用的是同一份缓存,同一把炮台预制体的所有实例(其它玩家座位)也一并受益。
///
private void WarmUpXJMHSkeletonData()
{
if (!_pendingFirstShowInit || _pendingRoomJoinNotice == null) return;
if (list_Sits == null || list_Sits.Count == 0) return;
var sit = list_Sits.Find(s => s != null && s.posID == SeatMirror.ToDisplay(_pendingRoomJoinNotice.Position));
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) return;
var defaultTurret = GetDefaultXJMHTurret(sit);
if (defaultTurret == null) return;
var sw = System.Diagnostics.Stopwatch.StartNew();
int warmed = 0;
// 默认炮台子树(含挂在它下面的入场飞机实例)里的所有 SkeletonGraphic:逐个把骨架数据解析缓存掉
var graphs = defaultTurret.transform.GetComponentsInChildren(true);
for (int i = 0; i < graphs.Length; i++)
{
var sg = graphs[i];
if (sg == null || sg.skeletonDataAsset == null) continue;
try
{
if (sg.skeletonDataAsset.GetSkeletonData(true) != null) warmed++;
}
catch (Exception e)
{
// 解析失败不影响流程:60% 激活时 SkeletonGraphic 会按正常路径自己再解析
Debug.LogWarning($"[XJMH预热] 骨架数据预解析失败(不影响流程): {e.Message}");
}
}
Debug.Log($"[XJMH预热] 骨架数据预解析:{warmed} 个 / 共 {sw.ElapsedMilliseconds}ms");
LoadingStallWatchdog.Phase = "30~60 鱼包"; // 恢复阶段名(诊断用)
}
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();
// 场景可能未加载或已卸载,做保护避免重复卸载报错。
// ⭐ 统一走 WebSocketReponseList.UnloadGameScene:场景是用 Addressables 加载的,
// 必须用 ab 句柄释放,只调 SceneManager.UnloadSceneAsync 会漏掉 ab 包。
var scene = SceneManager.GetSceneByName(sceneName);
if (scene.IsValid() && scene.isLoaded)
Assets.MyGame.Scripts.Net.WebSocketReponseList.UnloadGameScene(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();
// ⭐ 远端攻击状态看门狗:对方攻击中退出/掉线时不会再发待机包,本地按"还有没有攻击心跳"收回待机
UpdateRemoteTurretAttackTimeout();
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);
LoadingStallWatchdog.Phase = "0~2 卸载大厅UI";
// ⭐ 卸载大厅 UI 资源(进游戏释放 HomeUIRes 内存,回大厅时由离房流程重新预载):
// 大厅主面板此时已被关闭(实例销毁时 SubUIManager.ReleaseAll 已释放子面板句柄),
// 这里清掉 UIManager 侧大厅面板的 prefab 句柄缓存 → HomeUIRes bundle 引用归零真正卸载
UIManager.Instance.ReleaseHomeUI();
SetLoadProgress(0.02f);
// ⭐ 提前后台加载开火音效 PW_Shoot(Audio+PTCBZS,独立小包):
// 与后面的路线/鱼包/音频加载并行跑,78% 处 await 时早已完成,
// 不再把"打开小包 + 标签求交集"的耗时压在 78% 那一帧(进度条卡死点)
_ = LoadBulletClip();
// ⭐ 预关"未激活座位"的多余 XJMH 炮台(4 把 SkeletonGraphic 默认全开):
// 座位首次 SetActive 才做骨架/材质首初始化——预关后激活那一刻只初始化默认那把(1 套);
// 其它玩家/bot 的座位在 100% 入座时才激活,4 套→1 套,大幅削掉入座瞬间的卡顿
PreCloseInactiveSitExtraTurrets();
// 路线:2~30(大包同步解压期间回调不刷新 → 预估进度保证条持续推进)
LoadingStallWatchdog.Phase = "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)
LoadingStallWatchdog.Phase = "30~60 鱼包";
StartCreepProgress(0.30f, 0.60f);
var fishLoadTask = FishResourcesData.LoadFishAssetsData(p =>
{
if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.30f, 0.60f, p)));
});
// ⭐ 借鱼包加载这段(主线程本来就在解包/反序列化、有加载卡顿),先把 XJMH 炮台/飞机的
// "骨架数据解析"提前做掉并缓存(SkeletonGraphic 首激活里最重的一块,见方法注释):
// 60% 的炮台/飞机激活帧就只剩建网格/渲染器这类轻活,不再扛解析耗时
WarmUpXJMHSkeletonData();
await fishLoadTask;
if (flow != _resourceFlowToken) return;
// 玩法音频:60~78
LoadingStallWatchdog.Phase = "60~78 音频";
StartCreepProgress(0.60f, 0.78f);
// ⭐ XJMH 预热:方案已定——不在 60% 分帧做(每次 SetActive = 一次全 Canvas Rebuild,
// 分帧摊开总时长暴涨,实测 61% 卡好久好久)。改为 90% 确认点一次性完成
// (见下方"90 首屏初始化"),本处不再调用。骨架数据预解析仍在 30% 段(WarmUpXJMHSkeletonData)。
//PrewarmXJMHForLocalSeat();
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 小包,不把其它玩法的巨型音频包拉进内存;
// 资源在 0~2% 已后台 kick-off 且全局常驻,这里 await 基本瞬时通过(不再把打开包的耗时压在 78% 主线程上)
LoadingStallWatchdog.Phase = "78~84 开火音效";
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;
}
}
}
}
LoadingStallWatchdog.Phase = "84~90 EnterRoom/初始鱼";
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 顺序执行)
// ⭐ 首屏初始化(座位/炮台/入场飞机的全部"首次激活")在 90% 一次性完成:
// 进度条还开着 → 单帧大尖峰被加载页遮住 → 90~100 只剩平滑推进 →
// 100% 收掉进度条的瞬间画面完整出现、入场动画立即开播,不再"进游戏卡一下"。
// ⚠️ 不能分帧摊开:每次 SetActive 都会把整个大 Canvas 标脏 → 每帧一次全量
// Rebuild(Unity 官方:重建成本 ∝ Canvas 元素数)→ 总时长暴涨(61% 卡顿教训)
LoadingStallWatchdog.Phase = "90 首屏初始化(座位/炮台一次性)";
SetLoadProgress(0.9f);
if (_pendingFirstShowInit)
{
ApplyFirstShowInit();
// 入场飞机预热:刚激活的座位上 currentTurret 已就绪 → 一次性激活(缩0隐藏)→初始化→还原;
// 100% 入场动画再激活它 = 重启用,零成本。非 XJMH 的炮台不是 FlyTurretData,自动跳过
(currSitData?.currentTurret as FlyTurretData)?.PrewarmEnterPlane();
}
// ⭐ 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();
// ⭐ 加载到 100%(进度条已隐藏):标记完成。首屏初始化已在 90% 完成(见上);
// 这里只做兜底(正常流程标记已消费,不会重复执行)
LoadingStallWatchdog.Phase = "100 入场动画启动";
IsGameLoadCompleted = true;
if (_pendingFirstShowInit)
{
_pendingFirstShowInit = false;
ApplyFirstShowInit();
}
// 加载与首屏初始化完成 → 关闭监测(阶段置空,不再打日志)
LoadingStallWatchdog.Phase = "";
// ⭐ 无操作自动退出:加载完成,从现在开始计无操作时间
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;
// ⭐ 开火音效 PW_Shoot(Audio+PTCBZS)不释放:全局常驻(见 LoadBulletClip 注释,
// 独立小包反复开关只会把打开耗时反复压在进房 78% 那一帧,省不到内存)
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 是全局开火音效、每局必用,且各组已是 PackSeparately(每个资源独立小包):
/// 旧实现"每次进房 Release+Load、离房再释放"省不到内存(下一局马上又加载回来),
/// 却把"打开小包 + Audio/PTCBZS 标签求交集"的主线程耗时全压在进房 78% 那一帧(进度条卡死点)。
/// 现在只加载一次、程序生命周期常驻;首次加载在进房 0~2%(后台)就 kick-off,78% 处直接通过。
///
private static AsyncOperationHandle> _bulletClipHandle;
private static bool _bulletClipStarted;
/// 加载开火音效(Audio+PTCBZS 交集,只命中 Default Local Group 里的 PW_Shoot)。
/// 幂等:已启动过则复用同一句柄(通常早已完成,await 直接通过)。
private static async Task LoadBulletClip()
{
if (!_bulletClipStarted)
{
_bulletClipStarted = true;
_bulletClipHandle = Addressables.LoadAssetsAsync(new List { "Audio", "PTCBZS" }, null,
Addressables.MergeMode.Intersection);
}
if (!_bulletClipHandle.IsValid()) return;
try { await _bulletClipHandle.Task; }
catch (Exception e) { Debug.LogError($"开火音效加载失败: {e.Message}"); }
}
///
/// 后台补卸载游戏资源(切房失败回大厅时调用):
/// 切房流程跳过了卸载(准备复用),失败后资源不再需要,静默补卸防止鱼包常驻内存。
///
private static async void UnloadGameResourcesBackground()
{
try
{
await FishResourcesData.UnLoadFishAssetsData();
await AudioResourcesData.UnLoadAudioGame();
// ⭐ 开火音效 PW_Shoot 常驻不释放(见 LoadBulletClip 注释)
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();
// ⭐ 开火音效 PW_Shoot(Audio+PTCBZS)不释放:全局常驻(见 LoadBulletClip 注释)
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);
// ⭐ 不回退:场景加载阶段的爬升/预估进度可能已把条推到前面,
// 后续固定值(0.01/0.02 等)不能把它拉回去(带上限取大值)
if (p < _loadProgress) p = _loadProgress;
_loadProgress = p;
if (_changeRoomBar != null)
_changeRoomBar.SetProgress(0.55f + p * 0.45f);
else if (JinDuTiao != null)
JinDuTiao.SetProgress(p);
}
///
/// ⭐ 第一步(只加载了 UI 面板、游戏场景还在加载中):进度条从 0 缓慢爬升到约 3%。
/// 由 WebSocketReponseList 在"面板显示后、场景加载前"调用;真实资源加载开始时由
/// LoadResources 接管(SetLoadProgress 不回退,固定起点值不会把条拉回去)。
///
public void BeginSceneLoadingProgress()
{
SetLoadProgress(0.005f);
// creep 段末 = 0.05(内部上限 = 段末-2% ≈ 3%,给 LoadResources 的接管留空间)
StartCreepProgress(0.005f, 0.05f);
// ⭐ 挂"加载卡顿监测"(诊断):单帧 ≥120ms 打日志+阶段名,直接定位卡主线程的环节
LoadingStallWatchdog.Attach(this);
LoadingStallWatchdog.Phase = "场景加载(0~3%)";
}
// ---- 预估进度(大包 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