This commit is contained in:
2026-10-12 19:28:14 +08:00
parent c1c5f05d3f
commit 3ee5643e82
16 changed files with 1259 additions and 93 deletions
@@ -477,7 +477,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
}
/// <summary>
/// XJMH 进房首帧预热(加载 90%→100% 收尾窗口调用,进度条还显示着玩家看不到):
/// XJMH 进房预热(60% 音频段起点调用,进度条还显示着玩家看不到):
/// 把本地座位(座位自带炮台随 Awake 一起初始化)与"入场要用的那架完整飞机"提前点亮——
/// 这些 XJMH 独有资产首次初始化(骨架/粒子/贴图首上传)很重,全堆在 100% 那一帧玩家会看到明显卡顿。
/// ⭐ 按帧拆散(避免"90% 卡死"):先让一帧(进度条先刷新)→ 只留默认炮台、关掉其余 3 把 →
@@ -497,39 +497,169 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
private IEnumerator PrewarmXJMHCoroutine(SitPosData sit)
{
// ⭐ 先等一帧:StartCoroutine 会同步执行到第一个 yield —— 原先座位激活的重活
// 和"90%"写入进度条是同一帧(数字还没刷新、主线程就被占住,看起来"90 卡死")。
// 先让一帧,进度条和收尾循环跑起来再干重活
// ⭐ 先等一帧:StartCoroutine 会同步执行到第一个 yield —— 座位激活的重活不能和进度条
// 写数字挤在同一帧(玩家看到"卡在 60/61")。先让一帧,进度条先刷新
yield return null;
bool willActivate = sit != null && !sit.gameObject.activeSelf;
// ⭐ 只有"座位从未激活、这次预热才激活"时才预关炮台:
// 预制体里 4 把 XJMH 炮台(SkeletonGraphic)默认激活,座位 SetActive 时 4 套骨架/材质/贴图
// 挤在同一帧全部首初始化(单帧尖峰巨大);先关掉后,这一帧只初始化 Awake 点亮的默认那把。
// ⚠️ 座位已在激活状态时不能动(Awake 不会再跑,关掉就回不来)
if (willActivate && sit.xjmhTurretList != null)
// ⭐ 分帧方案(XJMH 进房不卡主线程的关键)—— 把所有"可以错开的初始化"从同一帧剥出去:
// ① 座位外壳(1 帧,只扛座位整层 OnEnable)
// ② 默认炮台 Spine 骨架首初始化(单独 1 帧,见 SitPosData.deferDefaultTurretLightUp)
// ③ 座位外壳粒子 + 默认炮台粒子(本方法先关掉,之后逐帧点亮)
// ④ 入场飞机(FlyTurretData.PrewarmPlaneRoutine:骨架 1 帧 + 41 个粒子逐帧点亮)
List<GameObject> 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 = "预热·座位外壳激活帧";
sit.gameObject.SetActive(true);
}
yield return null; // 座位外壳首帧
// ② 单独一帧点亮默认炮台:Spine 骨架首初始化(材质/网格/贴图上传)只花在这一帧
// ⚠️ 判据用"列表里有没有激活的炮台",不能用 currentTurret(可序列化字段,预制体里预置了引用)
if (willActivate && !sit.HasActiveXJMHTurret())
{
LoadingStallWatchdog.Phase = "预热·炮台骨架激活帧";
sit.ActivateDefaultXJMHTurretForPrewarm();
}
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();
}
}
/// <summary>XJMH 默认炮台 = 兄弟顺序最靠前的那把(与 SitPosData.NormalizeXJMHTurretList 的排序一致)</summary>
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;
}
/// <summary>
/// 收集"根若激活就会被激活"的粒子节点并先把它们关掉(此刻根未激活,SetActive(false) 只置标志、
/// 不触发任何初始化)——配合 ChunkEnableParticles 把这些粒子系统的首初始化逐帧分摊。
/// 容器(父级)关着的不收集;自身默认关闭的粒子也不动。
/// </summary>
private static List<GameObject> CollectActiveParticlesUnder(Transform root)
{
if (root == null) return null;
List<GameObject> list = null;
var systems = root.GetComponentsInChildren<ParticleSystem>(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<GameObject>(16);
list.Add(ps.gameObject);
}
return list;
}
/// <summary>节点在"根若激活"的前提下是否会被激活(检查自身到根之间每级的 activeSelf)</summary>
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;
}
/// <summary>逐帧分批点亮粒子节点(每帧约 8ms 预算:快机一帧多开、慢机一帧一个)</summary>
private static IEnumerator ChunkEnableParticles(List<GameObject> nodes)
{
const double budgetMs = 8.0;
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] != null) nodes[i].SetActive(true);
if (sw.Elapsed.TotalMilliseconds >= budgetMs)
{
sw.Restart();
yield return null;
}
}
yield return null;
}
/// <summary>
/// ⭐ 进房早期(0~2%)预关闭"未激活座位"的多余 XJMH 炮台。
/// 原因:XJMH 座位预制体里 4 把炮台(SkeletonGraphic)默认激活,而座位首次 SetActive 时
/// 4 套骨架/材质/贴图会挤在同一帧全部首初始化(单帧尖峰);预关后 Awake 的 InitXJMHTurret
/// 只点亮默认那把 → 激活帧只初始化 1 套。其它玩家/bot 的座位在 100% 入座时才激活,
/// 同样受益(否则每个座位入座都要初始化 4 套)。
/// ⚠️ 只处理"尚未激活"的座位:已激活的座位 Awake 已跑过,会被 Admin 逻辑重新点亮,
/// 动它反而可能把炮台关掉回不来。
/// </summary>
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);
}
}
// ① 提前激活本地座位:Awake → InitXJMHTurret → 激活默认炮台(首初始化在这里完成,
// 被加载界面遮住)。100% 时 JoinGame 再 SetActive(true) 是幂等的,不影响正常入座流程
if (willActivate)
sit.gameObject.SetActive(true);
// 座位/炮台初始化(骨架首帧网格上传、材质铺开)多留两帧,再点飞机
yield return null;
yield return null;
// ② 飞机隐藏预热(座位激活后 currentTurret 已就绪 = 入场会用的那把)
var turret = sit != null ? sit.currentTurret as FlyTurretData : null;
if (turret != null)
turret.PrewarmEnterPlane();
}
private void RefreshGGKCount()
@@ -1379,6 +1509,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
// 90 ⭐确认点:走到这里时所有资源 await 都已完成
// 90~100 平滑推进收尾(替代原来的 2 秒死等,条一直在动)
SetLoadProgress(0.01f);
LoadingStallWatchdog.Phase = "0~2 卸载大厅UI";
// ⭐ 卸载大厅 UI 资源(进游戏释放 HomeUIRes 内存,回大厅时由离房流程重新预载):
// 大厅主面板此时已被关闭(实例销毁时 SubUIManager.ReleaseAll 已释放子面板句柄),
@@ -1391,7 +1522,13 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
// 不再把"打开小包 + 标签求交集"的耗时压在 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 =>
{
@@ -1400,6 +1537,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
if (flow != _resourceFlowToken) return; // ⭐ 期间已退出/切换,本轮作废
// 鱼包:30~60,内部子进度实时上报(预估进度兜底 LZMA 解压阻塞段,不再卡在 30)
LoadingStallWatchdog.Phase = "30~60 鱼包";
StartCreepProgress(0.30f, 0.60f);
await FishResourcesData.LoadFishAssetsData(p =>
{
@@ -1408,7 +1546,16 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
if (flow != _resourceFlowToken) return;
// 玩法音频:60~78
LoadingStallWatchdog.Phase = "60~78 音频";
StartCreepProgress(0.60f, 0.78f);
// ⭐ XJMH 座位/飞机预热:把"首次实例化"的活提前到加载期做(这些活反正躲不掉,不做就会
// 堆到 100% 入座/入场动画那一帧卡——已确认注释掉它 100% 会卡一下)。
// 放在音频段起点:本段是真实加载耗时段(大量小包 IO,本来就有加载卡顿),混在里面的小帧不显眼。
// 重活已全部按帧拆开:座位外壳 1 帧 / 炮台骨架 1 帧 / 座位·炮台·飞机粒子逐帧点亮(每帧 8ms 预算)。
// 如果你更愿意"加载顺滑、接受 100% 卡一下",把下面这行注释回去即可。
PrewarmXJMHForLocalSeat();
await AudioResourcesData.LoadAudioGame(p =>
{
if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.60f, 0.78f, p)));
@@ -1418,6 +1565,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
// ⭐ 开火音效 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();
@@ -1436,6 +1584,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
}
}
LoadingStallWatchdog.Phase = "84~90 EnterRoom/初始鱼";
CreateFishManager.Instance.EnterRoom(gameType);
CreateFishManager.Instance.isResources = true;
SetLoadProgress(0.87f);
@@ -1468,12 +1617,10 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
AudioManager.Instance.ChangeBG(GameBgmAudioId);
// ⭐ 90% 确认点:走到这里所有资源加载都已完成(上面全是 await 顺序执行)
// (XJMH 座位/飞机预热已挪到 60% 音频段起点——这里是纯净的平滑收尾,不再有重活)
LoadingStallWatchdog.Phase = "90~100 收尾";
SetLoadProgress(0.9f);
// ⭐ XJMH 预热:借 90→100 的收尾窗口(进度条还在走)提前把本地座位/炮台 + 入场飞机点亮。
// 这些 XJMH 独有资产首次初始化(骨架/粒子/贴图)很重,堆到 100% 那帧玩家会看到卡一下
PrewarmXJMHForLocalSeat();
// ⭐ 90→100 平滑收尾:约 1 秒走完,进度条持续在动(替代原 2 秒死等;
// 保留这段缓冲是等出鱼首帧/场景渲染稳定,比原来快 1 秒+)
for (int i = 1; i <= 20; i++)
@@ -1491,6 +1638,7 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
// ⭐ 加载到 100%(进度条已隐藏):标记完成 + 执行挂起的进房初始化(座位/房间信息)。
// 入场动画等表现由初始化链路在此之后触发——玩家可见、位置已就绪
LoadingStallWatchdog.Phase = "100 首屏初始化";
IsGameLoadCompleted = true;
if (_pendingFirstShowInit)
{
@@ -1498,6 +1646,9 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
ApplyFirstShowInit();
}
// 加载与首屏初始化完成 → 关闭监测(阶段置空,不再打日志)
LoadingStallWatchdog.Phase = "";
// ⭐ 无操作自动退出:加载完成,从现在开始计无操作时间
ArmIdleAutoExit();
}
@@ -1868,6 +2019,9 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
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);
@@ -1875,6 +2029,22 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
JinDuTiao.SetProgress(p);
}
/// <summary>
/// ⭐ 第一步(只加载了 UI 面板、游戏场景还在加载中):进度条从 0 缓慢爬升到约 3%。
/// 由 WebSocketReponseList 在"面板显示后、场景加载前"调用;真实资源加载开始时由
/// LoadResources 接管(SetLoadProgress 不回退,固定起点值不会把条拉回去)。
/// </summary>
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 同步解压期间主线程阻塞、回调不刷新 → 条卡死像没响应)----
/// <summary>加载进度当前值(真实或预估),预估推进时判断不回退</summary>
private float _loadProgress = 0f;
@@ -6767,3 +6937,37 @@ public class GameMainPanel_FishBase : GameMainPanel_Base
#endregion
}
/// <summary>
/// ⭐ 加载期卡顿监测(诊断用,挂面板对象、随面板销毁):
/// 加载全程 Update 检查单帧耗时,凡单帧 ≥120ms 就打一条日志并带"当前阶段"——
/// 用于精确定位是哪个环节顶住了主线程(进度条/界面动画卡顿的根因)。
/// 阶段名由 GameMainPanel_FishBase / FlyTurretData 在流程中随时更新;加载完成后置空即静默。
/// 排查完成后可整体删除本类及各 Phase 赋值。
/// </summary>
public class LoadingStallWatchdog : MonoBehaviour
{
/// <summary>当前阶段(由加载流程更新;为空=不监测)</summary>
public static string Phase = "";
private static int _logged;
/// <summary>在面板下挂一个监测器(存在则不重复创建)</summary>
public static void Attach(MonoBehaviour host)
{
if (host == null || host.GetComponentInChildren<LoadingStallWatchdog>(true) != null) return;
var go = new GameObject("LoadingStallWatchdog");
go.transform.SetParent(host.transform, false);
go.AddComponent<LoadingStallWatchdog>();
}
private void Update()
{
if (string.IsNullOrEmpty(Phase)) return;
float dt = Time.unscaledDeltaTime;
if (dt >= 0.12f && _logged < 80)
{
_logged++;
Debug.Log($"[加载卡顿] 「{Phase}」单帧 {dt * 1000f:F0}ms(第 {_logged} 条)");
}
}
}