using System; using System.Collections; using System.Collections.Generic; using Spine.Unity; using UnityEngine; /// /// 起飞炮台(TurretData 子类):炮台模式 / 起飞模式 两态分离。 /// 【炮台模式(IsFlying=false,默认形态)】就是一门正常炮台:手动瞄准开火/自动开火/锁定瞄准照常, /// 与普通 TurretData 行为完全一致(轻点屏幕只走正常开火流程,不会起飞)。 /// 【起飞模式(IsFlying=true)】点"起飞按钮"进入(按钮逻辑待完善;默认 allowTapTakeoff=false /// 时轻点屏幕不触发起飞):炮台飞到目标点 → 到达后持续向上发射子弹,upFireDuration /// 到时自动降落回原炮台位;起飞时播放特殊 spine 动画(takeoffAnim 播一次,flyLoopAnim 循环衔接); /// 期间炮口固定朝上、状态切换/开火/按住输入均被拦截(见 ChangeState/各开火入口)。 /// 起飞入口:TakeoffToScreenPos(当前轻点路径,受 allowTapTakeoff 控制); /// 将来"起飞按钮"接入后走同一入口即可(或在面板上打开 allowTapTakeoff 恢复轻点起飞)。 /// 交互判定在 GameMainPanel_FishBase.HandleInput(轻点=按下短+几乎没移动;按住扫射不触发起飞)。 /// 挂载:XJMH 座位炮台(xjmhTurretList 中的子对象);入场飞机优先用座位下的完整飞机预制体实例 /// (PW_Plane_等级,含 Body 骨架/尾迹/护盾/免费特效——特效由本类按原版控制),找不到才退回飞机骨架节点 /// (lvptfeiji/lanptfeiji/huangjinfeiji/vipfeiji01)。飞行期间飞机临时挂到炮台下显示、结束还原。 /// 注意:不要在本类定义 Awake/OnEnable,会隐藏基类的同名私有初始化逻辑。 /// public class FlyTurretData : TurretData { [Header("起飞入口")] [Tooltip("允许轻点屏幕起飞(默认关=false:没点起飞按钮就不起飞)。\n将来\"起飞按钮\"逻辑接入后走专门入口,不受本开关限制")] public bool allowTapTakeoff = false; [Header("起飞(点哪飞哪)")] [Tooltip("起飞特殊 spine 动画名(起飞时播一次;为空则直接播飞行循环动画)")] public string takeoffAnim; [Tooltip("飞行/悬停循环 spine 动画名(为空则起飞动画播完后循环自身)")] public string flyLoopAnim; [Tooltip("飞行到目标点的时长(秒)")] public float flyDuration = 0.6f; [Tooltip("飞行插值:0=匀速 1=平滑缓入缓出")] [Range(0f, 1f)] public float flySmooth = 1f; [Tooltip("目标点距画布边缘的最小内边距(画布本地单位),防止炮台飞出屏幕")] public float edgePadding = 60f; [Header("向上发射子弹")] [Tooltip("向上开火间隔(秒)")] public float upFireInterval = 0.4f; [Tooltip("向上开火总时长(秒),<=0 表示一直开火(手动点击别处可改变目标)")] public float upFireDuration = 0f; [Tooltip("到达目标点后延迟多久开始向上开火(秒),通常等起飞动画收尾")] public float upFireDelay = 0f; /// 向上开火回调:由 GameMainPanel_FishBase 注入(走服务端同步链路 SyncFire) [NonSerialized] public Action onUpwardFire; /// 是否处于飞行/悬停状态(含飞回降落途中) public bool IsFlying { get; private set; } private RectTransform _rect; private RectTransform _parentRect; private RectTransform _canvasRect; private Camera _uiCam; private Vector2 _originAnchoredPos; // 首次起飞前的原位(降落飞回这里) private Vector2 _flyFrom; private Vector2 _flyTo; private float _flyTimer; private bool _returning; // true=正在飞回原位降落 private float _fireDelayTimer; private float _fireTimer; private float _fireTotalTimer; private bool _firing; /// 懒加载引用(不用 Awake,避免隐藏基类私有 Awake) private void EnsureRefs() { if (_rect != null) return; _rect = GetComponent(); _parentRect = transform.parent as RectTransform; Canvas canvas = null; if (UIManager.Instance != null && UIManager.Instance.uiRoot != null) canvas = UIManager.Instance.uiRoot.GetComponent(); if (canvas != null) { _canvasRect = canvas.GetComponent(); if (canvas.renderMode != RenderMode.ScreenSpaceOverlay) _uiCam = canvas.worldCamera; } } private void Update() { if (!IsFlying) return; UpdateFly(); UpdateUpFire(); } /// /// 起飞/移动到目标屏幕点(点到哪飞到哪)。 /// 首次调用=进入起飞模式;飞行中再次调用=改目标点;降落中调用=取消降落改飞新目标。 /// ⭐ 轻点起飞总开关:未进入起飞模式时受 allowTapTakeoff 控制——默认 false, /// 即"没点起飞按钮就不起飞"(轻点松开只会走正常 Idle/开火流程);飞行中改目标不受此限制。 /// 返回 false 表示未起飞(开关未开 / 节点不是 UGUI / 画布引用缺失)。 /// public bool TakeoffToScreenPos(Vector2 screenPos) { // ⭐ 默认不响应轻点起飞("起飞先不做",起飞按钮逻辑待完善):进入起飞模式前先过开关。 // 已在飞行=起飞模式内部行为(改目标点),不受开关限制 if (!IsFlying && !allowTapTakeoff) return false; EnsureRefs(); if (_rect == null || _parentRect == null || _canvasRect == null) return false; if (!IsFlying) { _originAnchoredPos = _rect.anchoredPosition; IsFlying = true; PlayTakeoffAnim(); PointUp(); } _returning = false; _flyFrom = _rect.anchoredPosition; _flyTo = ScreenPosToAnchoredPos(screenPos); _flyTimer = 0f; // 改目标瞬间先停火,到达新目标后按延迟恢复 _firing = false; _fireDelayTimer = 0f; return true; } /// 降落:停火并飞回起飞前的原位 public void Land() { if (!IsFlying || _returning) return; _firing = false; _returning = true; _flyFrom = _rect.anchoredPosition; _flyTo = _originAnchoredPos; _flyTimer = 0f; } // ==================== 内部逻辑 ==================== private void UpdateFly() { _flyTimer += Time.deltaTime; float t = flyDuration > 0f ? Mathf.Clamp01(_flyTimer / flyDuration) : 1f; if (flySmooth > 0f) t = Mathf.Lerp(t, t * t * (3f - 2f * t), flySmooth); _rect.anchoredPosition = Vector2.LerpUnclamped(_flyFrom, _flyTo, t); if (_flyTimer < flyDuration) return; _rect.anchoredPosition = _flyTo; if (_returning) { // 落地:恢复待机动画,退出飞行状态 IsFlying = false; _returning = false; RestoreIdleAnim(); } else { StartUpFire(); } } private void StartUpFire() { _firing = true; _fireDelayTimer = upFireDelay; _fireTimer = upFireInterval; // 延迟结束后立即打第一发 _fireTotalTimer = 0f; } private void UpdateUpFire() { if (!_firing) return; // 到达后延迟(等起飞动画收尾) if (_fireDelayTimer > 0f) { _fireDelayTimer -= Time.deltaTime; return; } // 总时长限制(<=0 = 一直开火) if (upFireDuration > 0f) { _fireTotalTimer += Time.deltaTime; if (_fireTotalTimer >= upFireDuration) { Land(); return; } } _fireTimer += Time.deltaTime; if (_fireTimer < upFireInterval) return; _fireTimer = 0f; onUpwardFire?.Invoke(); } /// 屏幕坐标 → 炮台父节点下的 anchoredPosition(限制在画布范围内) private Vector2 ScreenPosToAnchoredPos(Vector2 screenPos) { // 屏幕坐标 → 画布根节点本地坐标(相机解析模式与扇形切换一致) RectTransformUtility.ScreenPointToLocalPointInRectangle( _canvasRect, screenPos, _uiCam, out Vector2 canvasLocal); // 限制在画布内(留边距),防止炮台飞出屏幕 Rect r = _canvasRect.rect; float xMin = r.xMin + edgePadding; float xMax = r.xMax - edgePadding; float yMin = r.yMin + edgePadding; float yMax = r.yMax - edgePadding; if (xMax < xMin) { xMin = xMax = r.center.x; } if (yMax < yMin) { yMin = yMax = r.center.y; } canvasLocal = new Vector2( Mathf.Clamp(canvasLocal.x, xMin, xMax), Mathf.Clamp(canvasLocal.y, yMin, yMax)); // 画布本地 → 世界 → 用“世界增量”换算成父节点下的 anchoredPosition。 // 增量法兼容父节点的翻转/旋转/缩放(上方座位 180° 翻转也能正确落点), // 且不依赖炮台自身的锚点设置。 Vector3 targetWorld = _canvasRect.TransformPoint(canvasLocal); Vector3 deltaLocal = _parentRect.InverseTransformVector(targetWorld - _rect.position); return _rect.anchoredPosition + (Vector2)deltaLocal; } /// 炮口固定朝上:SetRotation 的 angle 约定 0=正上方(aim-90) private void PointUp() { if (setPosData != null) { setPosData.SetRotation(0f); return; } Transform pivot = tra_RotationPivot != null ? tra_RotationPivot : transform.parent; if (pivot != null) pivot.rotation = Quaternion.Euler(0f, 0f, 0f); } /// 起飞动画:takeoffAnim 播一次,flyLoopAnim 循环衔接(为空则循环自身兜底) private void PlayTakeoffAnim() { if (skeletonAnimation == null || skeletonAnimation.AnimationState == null) return; if (!string.IsNullOrEmpty(takeoffAnim)) { skeletonAnimation.AnimationState.SetAnimation(0, takeoffAnim, false); if (!string.IsNullOrEmpty(flyLoopAnim)) skeletonAnimation.AnimationState.AddAnimation(0, flyLoopAnim, true, 0f); else skeletonAnimation.AnimationState.AddAnimation(0, takeoffAnim, true, 0f); } else if (!string.IsNullOrEmpty(flyLoopAnim)) { skeletonAnimation.AnimationState.SetAnimation(0, flyLoopAnim, true); } } /// 落地后恢复待机动画(先 None 再 Idle,避免 currState 相同被基类短路) private void RestoreIdleAnim() { base.ChangeState(TurretState.None); base.ChangeState(TurretState.Idle); } public override void ChangeState(TurretState newState) { // 飞行/悬停期间拦截状态动画(Attack/Idle 切换不打断起飞动画) if (IsFlying) return; if (newState == TurretState.Attack) { // ⭐ XJMH 原版射击动画(Battery:Shoot):**每发** SetAnimation(0,"shot",false)——从头重播(连射时 // 直接打断重放=原版"播放一半直接重新放")、非循环播完停住。 // 基类是"进入状态播一次 + 循环播放",连射不重播 → 这里每次 Attack 都重播。 // 首次进入先走基类同步状态(基类循环版动画同帧被下面重播覆盖,玩家不可见) if (CurrState != TurretState.Attack) base.ChangeState(newState); PlayShotAnim(); return; } base.ChangeState(newState); } /// ⚠ 覆写:XJMH 停火不打断最后一发(原版最后一发播完自动切待机=定格在尾帧,见基类说明) protected override bool KeepPoseOnEmptyIdleAnim => true; /// 同一帧内去重:自动开火"状态切换 + 手动发射"两条入口可能同帧都调本方法 private int _lastShotFrame = -1; /// /// 播放一发射击动画(原版 Battery:Shoot → gunSkeleton SetAnimation(0,"shot",false)): /// ⭐ 每发从头重播(连射时打断当前播放重新开始);非循环,播完停在尾帧等下一发。 /// 手动按住扫射的每发(不经过 ChangeState)由 GameMainPanel 在发射后调用;飞行中不播(原版 isPlaneState 时跳过)。 /// public void PlayShotAnim() { if (IsFlying) return; if (_lastShotFrame == Time.frameCount) return; // 同一帧重复调用只播一次 _lastShotFrame = Time.frameCount; if (skeletonAnimation == null || skeletonAnimation.AnimationState == null) return; string anim = string.IsNullOrEmpty(attackAnim) ? "shot" : attackAnim; if (!HasSpineAnim(skeletonAnimation, anim)) return; skeletonAnimation.AnimationState.SetAnimation(0, anim, false); // 每发从头(替换轨道=打断重放) } // ==================== XJMH 进房入场动画(原版 Battery:EnterAction,进入房间时本地座位播一次) ==================== // 原版链路(PW_Data.InitPlayer 收到自己"大飞机信息"): // GC.BatteryMgr.PlayEnterAction(chairId, PW_Data.EnterAction) —— 首次 false 播完整动画+声音, // 之后 true 只立即摆好。完整流程: // Enter 音效 → 容器(底座)Spine 播 "out" → 炮台本体隐藏、飞机出现 → // 飞机从下方两段升起(EInQuart 0.5s → 停 0.3s → EOutQuart 0.5s)→ // PlaneBack 音效 + 飞机播 "back" → 约 1 秒后飞机隐藏、炮台本体播 "out" 出现。 [Header("XJMH入场动画(原版 EnterAction:飞机从下方升起 → 变回炮台)")] [Tooltip("入场音效 id(原版 PW_Enter,tbaudio 60003);0=不播")] public int enterAudioID = 60003; [Tooltip("飞机回位音效 id(原版 PW_PlaneBack,tbaudio 61106);0=不播")] public int enterBackAudioID = 61106; [Tooltip("飞机入场起点相对停靠位的 Y 偏移(负值=从更下方升起,原版从画面下方飞入)")] public float enterStartOffsetY = -260f; [Tooltip("第一段升起时长(秒),原版 0.5")] public float enterRiseTime = 0.5f; [Tooltip("两段之间的停顿(秒),原版 0.3")] public float enterHoldTime = 0.3f; [Tooltip("第二段升到停靠位时长(秒),原版 0.5")] public float enterBackRiseTime = 0.5f; [Tooltip("飞机 \"back\" 动画开始后,飞机隐藏、炮台出现的延迟(秒),原版 1.0")] public float enterBackHoldTime = 1f; private Coroutine _enterRoutine; private Transform _enterPlaneRoot; // 入场中的飞机节点(PW_Plane_x 完整预制体实例;找不到时=骨架节点兜底) private SkeletonGraphic _enterPlaneSg; // 飞机骨架(临时挂载后播 "stand01"/"back") private bool _enterBoardHidden; // 入场期间 Board 被暂时隐藏(清理时只恢复自己隐藏的) // ⭐ 飞机原始挂载信息:飞机在座位的变体容器节点里(该节点默认未激活,直接显隐看不到), // 入场时临时挂到炮台下显示、结束/清理时按原样还原(父节点/层级/本地变换) private Transform _enterPlaneOrigParent; private int _enterPlaneOrigSibling; private Vector3 _enterPlaneOrigLocalPos; private Quaternion _enterPlaneOrigLocalRot; private Vector3 _enterPlaneOrigLocalScale; /// /// XJMH 进房入场动画(移植原版 Battery:EnterAction 的非立即分支,本地玩家入座时调用一次): /// Enter 音效 → 容器 "out" → 飞机从停靠位下方两段升起(PlaneBack 音效 + "back")→ /// 延迟后飞机隐藏、炮台本体播 "out" 出现。 /// ⭐ 未激活(面板加载中/座位隐藏)时直接返回——SitPosData 会挂起并在激活后补播。 /// 找不到飞机节点时只播炮台出现动画(兜底不报错)。 /// public void PlayEnterAction() { if (!isActiveAndEnabled) return; Debug.Log($"[XJMH入场] {name} 开始播放(Enter音效={enterAudioID} 回位音效={enterBackAudioID})"); if (_enterRoutine != null) StopCoroutine(_enterRoutine); _enterRoutine = StartCoroutine(EnterActionRoutine()); } private IEnumerator EnterActionRoutine() { if (enterAudioID > 0 && AudioManager.Instance != null) AudioManager.Instance.PlayerFX(enterAudioID); // ⭐ 飞机:优先完整飞机预制体实例 PW_Plane_等级(含 Body 骨架/尾迹/护盾/免费特效——由本类控制); // 找不到才退回"骨架节点"(只有骨架、没有特效) _enterPlaneRoot = FindPlaneRoot(); if (_enterPlaneRoot == null) { Debug.LogWarning($"[XJMH入场] {name} 未找到飞机节点(PW_Plane_x 或名字含 feiji),只播炮台出现动画"); PlayTurretOutAnim(); _enterRoutine = null; yield break; } bool isFullPlane = _enterPlaneRoot.name.StartsWith("PW_Plane"); _enterPlaneSg = isFullPlane ? _enterPlaneRoot.GetComponentInChildren(true) : _enterPlaneRoot.GetComponent(); Debug.Log($"[XJMH入场] {name} 飞机={_enterPlaneRoot.name}(完整飞机={isFullPlane} 骨架={(_enterPlaneSg != null ? _enterPlaneSg.name : "无")})"); // ⭐ 飞机临时挂到炮台下并保持停靠位(原挂载点=座位下的变体容器节点,默认未激活,不挂过来显示不了); // 完整飞机:停靠位=炮台的 Aprk 节点(原版 self.aprkPos) AttachPlaneForEnter(); Vector3 home = _enterPlaneRoot.localPosition; Vector3 start = home + new Vector3(0f, enterStartOffsetY, 0f); Vector3 mid = home + new Vector3(0f, enterStartOffsetY * 0.5f, 0f); // ⭐ 原版 EnterAction 第 6 步:底部(容器/底座外壳)Spine 播 "out" 出场 // (PW_Battery.txt:EnterAction → bottomSkeleton.AnimationState:SetAnimation(0,"out",false)) PlayContainerOutAnim(); // 原版:入场时炮台本体隐藏、飞机出现(飞机从下方升上来,"变"回炮台);Board(分板)一并隐藏 if (skeletonAnimation != null) skeletonAnimation.gameObject.SetActive(false); Transform board = transform.Find("Board"); if (board != null) { board.gameObject.SetActive(false); _enterBoardHidden = true; } // ⭐ 飞机特效控制(原版 EnterAction):开 Body(骨架+尾迹)与护盾; // 免费/暴击/加速三类特效一律关(原版只在免费模式玩法里开,prefab 默认全开会一起炸出来) SetPlaneChildActive("Body", true); SetPlaneChildActive("PW_Effect_Defend", true); SetPlaneChildActive("FreeEffect", false); SetPlaneChildActive("FreeEffect_Crit", false); SetPlaneChildActive("FreeEffect_Speedup", false); // 诊断:飞机首次激活(骨架 + 护盾/尾迹粒子首初始化)的真实耗时 var swPlane = System.Diagnostics.Stopwatch.StartNew(); _enterPlaneRoot.gameObject.SetActive(true); Debug.Log($"[XJMH首屏] 入场飞机激活(SetActive) {swPlane.ElapsedMilliseconds}ms"); _enterPlaneRoot.localPosition = start; if (_enterPlaneSg != null && _enterPlaneSg.AnimationState != null) { // 原版飞机常驻飞行循环(PlaneFlyAnim:"stand01" 循环);清残留(对象池/重复入场)后先播它 _enterPlaneSg.AnimationState.ClearTrack(0); if (HasSpineAnim(_enterPlaneSg, "stand01")) _enterPlaneSg.AnimationState.SetAnimation(0, "stand01", true); } // 第一段:升到中途位(原版 EInQuart) yield return RiseTo(_enterPlaneRoot, start, mid, enterRiseTime, true); // 停顿(原版 delay 0.3) if (enterHoldTime > 0f) yield return new WaitForSeconds(enterHoldTime); // 第二段:升到停靠位(原版 EOutQuart) yield return RiseTo(_enterPlaneRoot, mid, home, enterBackRiseTime, false); // 原版 PlaneBack:护盾先收;PlaneBack 音效 + 飞机回位动画(PlaneBackAnim:"back") SetPlaneChildActive("PW_Effect_Defend", false); if (enterBackAudioID > 0 && AudioManager.Instance != null) AudioManager.Instance.PlayerFX(enterBackAudioID); if (_enterPlaneSg != null && _enterPlaneSg.AnimationState != null && HasSpineAnim(_enterPlaneSg, "back")) _enterPlaneSg.AnimationState.SetAnimation(0, "back", false); // 原版:back 动画约 1 秒后飞机本体(Body)隐藏、炮台出现(ShowGun → ShowBoard) if (enterBackHoldTime > 0f) yield return new WaitForSeconds(enterBackHoldTime); SetPlaneChildActive("Body", false); DetachPlaneAfterEnter(); // 飞机隐藏 + 还原父节点/本地变换 _enterPlaneRoot = null; _enterPlaneSg = null; PlayTurretOutAnim(); if (board != null) board.gameObject.SetActive(true); _enterBoardHidden = false; _enterRoutine = null; } /// 协程位移:EInQuart / EOutQuart 缓动(与原版 GC.Action 一致;用 localPosition 兼容完整飞机/骨架节点) private static IEnumerator RiseTo(Transform tr, Vector3 from, Vector3 to, float time, bool easeIn) { if (tr == null) yield break; if (time <= 0f) { tr.localPosition = to; yield break; } float t = 0f; while (t < time) { t += Time.deltaTime; float p = Mathf.Clamp01(t / time); float e = easeIn ? p * p * p * p : 1f - Mathf.Pow(1f - p, 4f); tr.localPosition = Vector3.LerpUnclamped(from, to, e); yield return null; } tr.localPosition = to; } /// 炮台本体出现:恢复显示并播 startingAnimation(预制体配的 "out";为空时兜底 "out") private void PlayTurretOutAnim() { if (skeletonAnimation == null) return; skeletonAnimation.gameObject.SetActive(true); if (skeletonAnimation.AnimationState == null) return; string anim = string.IsNullOrEmpty(skeletonAnimation.startingAnimation) ? "out" : skeletonAnimation.startingAnimation; skeletonAnimation.AnimationState.SetAnimation(0, anim, skeletonAnimation.startingLoop); skeletonAnimation.Update(0.1f); } /// /// 容器(底座/炮台外壳)出场动画:容器 Spine 播 "out"(容器动画为 close/open/out)。 /// 对应原版 EnterAction 第 6 步 bottomSkeleton "out"(battery/Bottom 上的骨架);没有该动画时跳过、不报错。 /// private void PlayContainerOutAnim() { var shell = FindContainerSkeleton(); if (shell == null || shell.AnimationState == null) return; if (!HasSpineAnim(shell, "out")) return; // 没有 out 动画:跳过 shell.AnimationState.SetAnimation(0, "out", false); shell.Update(0.1f); } /// /// 找容器(底座外壳)骨架:① 炮台根节点自带(容器节点布局); /// ② "Bottom" 子节点(原版 PW_Battery 结构,容器骨架在 Bottom 上,根节点没有); /// ③ 兜底:子树里带 "out" 动画、且不是炮口本体/飞机的骨架。 /// private SkeletonGraphic FindContainerSkeleton() { var self = GetComponent(); if (self != null && self != skeletonAnimation && HasSpineAnim(self, "out")) return self; Transform bottom = transform.Find("Bottom"); if (bottom != null) { var sg = bottom.GetComponent(); if (sg != null && sg != skeletonAnimation) return sg; } foreach (var sg in GetComponentsInChildren(true)) { if (sg == null || sg == skeletonAnimation) continue; if (sg.gameObject.name.ToLowerInvariant().Contains("feiji")) continue; // 飞机不算容器 if (HasSpineAnim(sg, "out")) return sg; } return null; } /// 骨架数据里是否有该动画(SkeletonGraphic 已初始化才有效) private static bool HasSpineAnim(SkeletonGraphic sg, string animName) { var data = sg != null && sg.Skeleton != null ? sg.Skeleton.Data : null; return data != null && data.FindAnimation(animName) != null; } /// /// 找入场飞机:① 完整飞机预制体实例(座位下名字以 "PW_Plane_等级" 开头,如 PW_Plane_1——含特效); /// ② 骨架节点兜底(炮台/座位子树内名字含 "feiji" 的 SkeletonGraphic,如 lvptfeiji——只有骨架)。 /// private Transform FindPlaneRoot() { var full = FindFullPlaneRoot(); if (full != null) return full; return FindPlaneSkeletonRect(); } /// 找完整飞机预制体实例:先炮台子树、再座位子树;按当前炮台等级精确匹配,匹配不到用任意一架 private Transform FindFullPlaneRoot() { var t = FindFullPlaneUnder(transform); if (t != null) return t; var sit = GetComponentInParent(); return sit != null ? FindFullPlaneUnder(sit.transform) : null; } private Transform FindFullPlaneUnder(Transform root) { int lv = ParseTurretLevel(); Transform any = null; foreach (var t in root.GetComponentsInChildren(true)) { if (t == null || !t.name.StartsWith("PW_Plane")) continue; if (lv > 0 && t.name.StartsWith("PW_Plane_" + lv)) return t; if (any == null) any = t; } return any; } /// 炮台的飞机停靠位节点(原版 self.aprk = battery:FindChild("Aprk")) private Transform FindAprkNode() { Transform t = transform.Find("Aprk"); if (t != null) return t; foreach (var c in GetComponentsInChildren(true)) if (c.name.ToLowerInvariant().Contains("aprk")) return c; return null; } /// /// 飞机子节点显隐(只对完整飞机 PW_Plane 生效;骨架兜底路径没有这些特效节点,自动跳过): /// Body=飞机本体(骨架+尾迹)、PW_Effect_Defend=护盾、FreeEffect/FreeEffect_Crit/FreeEffect_Speedup=免费模式三类特效。 /// private void SetPlaneChildActive(string childName, bool active) { SetPlaneChildActive(_enterPlaneRoot, childName, active); } private static void SetPlaneChildActive(Transform planeRoot, string childName, bool active) { if (planeRoot == null || !planeRoot.name.StartsWith("PW_Plane")) return; Transform t = planeRoot.Find(childName); if (t != null) t.gameObject.SetActive(active); } // ==================== 入场飞机预热(90% 确认点调用,被加载页遮住) ==================== // XJMH 入场飞机 PW_Plane(骨架 + 41 粒子、2.2MB 预制体):整根激活 = 单帧大尖峰。 // ⚠️ 必须一次性激活,不能分帧:每次 SetActive 都会把所属大 Canvas 标脏 → 每帧一次 // 全量 Rebuild(Unity 官方文档:重建成本 ∝ Canvas 元素数)——分帧 = 把一次大卡 // 变成几十次中卡,总时长反而暴涨(61% 卡好久好久的实测教训)。 // 预热 = 加载页还开着时:一次性激活(缩到 0 不可见)→ 初始化 → 原样还原; // 100% 入场动画再激活它就是"重启用",零首初始化成本。 private Coroutine _prewarmRoutine; private Transform _prewarmPlane; // 预热中的飞机(被打断时还原用) private Transform _prewarmOrigParent; private int _prewarmOrigSibling; private Vector3 _prewarmOrigPos; private Quaternion _prewarmOrigRot; private Vector3 _prewarmOrigScale; private bool _prewarmOrigActive; /// 入场飞机预热(面板在 90% 确认点调用;座位/炮台需已激活)。幂等:重复调用只跑一次。 public void PrewarmEnterPlane() { if (!isActiveAndEnabled) return; if (_prewarmRoutine != null) return; _prewarmRoutine = StartCoroutine(PrewarmPlaneRoutine()); } private IEnumerator PrewarmPlaneRoutine() { Transform plane = FindPlaneRoot(); if (plane == null) { _prewarmRoutine = null; yield break; } _prewarmPlane = plane; _prewarmOrigParent = plane.parent; _prewarmOrigSibling = plane.GetSiblingIndex(); _prewarmOrigPos = plane.localPosition; _prewarmOrigRot = plane.localRotation; _prewarmOrigScale = plane.localScale; _prewarmOrigActive = plane.gameObject.activeSelf; // 容器显隐(与入场动画 EnterActionRoutine 一致):开 Body/护盾、关免费三类 SetPlaneChildActive(plane, "Body", true); SetPlaneChildActive(plane, "PW_Effect_Defend", true); SetPlaneChildActive(plane, "FreeEffect", false); SetPlaneChildActive(plane, "FreeEffect_Crit", false); SetPlaneChildActive(plane, "FreeEffect_Speedup", false); // 临时挂到根节点(激活中)并缩到 0:既能触发初始化,又完全不可见 Transform holder = transform.root; if (holder != null && holder != _prewarmOrigParent) plane.SetParent(holder, true); plane.localScale = Vector3.zero; // ⭐ 一次性整根激活:骨架 + 全部入场粒子在一帧完成首初始化(单帧尖峰,被加载页遮住) LoadingStallWatchdog.Phase = "90 首屏初始化·飞机一次性激活"; var sw = System.Diagnostics.Stopwatch.StartNew(); plane.gameObject.SetActive(true); Debug.Log($"[XJMH入场] 飞机一次性预热激活 {sw.ElapsedMilliseconds}ms"); yield return null; // 骨架首帧网格 yield return null; // 粒子首帧模拟 yield return null; // 网格/贴图上传铺开 FinishPrewarmRestore(); Debug.Log($"[XJMH入场] 飞机预热完成并还原({plane.name})"); _prewarmRoutine = null; // 诊断:预热结束,阶段还给收尾段(后续卡顿归因不错位) LoadingStallWatchdog.Phase = "90~100 收尾"; } /// 预热还原:飞机隐藏并恢复到预热前的挂载/变换/激活状态(协程正常收尾或中途被打断都用它) private void FinishPrewarmRestore() { if (_prewarmPlane == null) return; Transform plane = _prewarmPlane; _prewarmPlane = null; plane.gameObject.SetActive(false); if (_prewarmOrigParent != null && plane.parent != _prewarmOrigParent) { plane.SetParent(_prewarmOrigParent, false); plane.SetSiblingIndex(_prewarmOrigSibling); } plane.localPosition = _prewarmOrigPos; plane.localRotation = _prewarmOrigRot; plane.localScale = _prewarmOrigScale; plane.gameObject.SetActive(_prewarmOrigActive); } /// 预热中途被打断(座位/炮台被禁用):停协程并把飞机还原回原挂载点,避免残留 private void CleanupPrewarm() { if (_prewarmRoutine != null) { StopCoroutine(_prewarmRoutine); _prewarmRoutine = null; } FinishPrewarmRestore(); } /// /// 找飞机骨架:① 炮台子树(飞机挂在炮台下的布局); /// ② 座位子树(当前 XJMH 布局:飞机与炮台平级、挂在座位的变体容器节点里, /// 如 lvptfeiji/lanptfeiji/huangjinfeiji/vipfeiji01)——先按当前炮台等级挑对应飞机,挑不到用座位下任意飞机兜底。 /// private RectTransform FindPlaneSkeletonRect() { var plane = FindPlaneSkeletonUnder(transform, null); if (plane != null) return plane; var sit = GetComponentInParent(); if (sit == null) return null; return FindPlaneSkeletonUnder(sit.transform, GetPlaneKeyword()); } /// 在 root 子树里找名字含 "feiji" 的 SkeletonGraphic;keyword 非空时优先匹配(如 lvpt) private static RectTransform FindPlaneSkeletonUnder(Transform root, string keyword) { RectTransform any = null; foreach (var sg in root.GetComponentsInChildren(true)) { if (sg == null) continue; string n = sg.gameObject.name.ToLowerInvariant(); if (!n.Contains("feiji")) continue; if (string.IsNullOrEmpty(keyword) || n.Contains(keyword)) return sg.rectTransform; if (any == null) any = sg.rectTransform; } return any; } /// 当前炮台等级 → 座位内飞机名关键字(原版 PW_Plane_1~4 = 绿/蓝/金/VIP 机) private string GetPlaneKeyword() { switch (ParseTurretLevel()) { case 1: return "lvpt"; case 2: return "lanpt"; case 3: return "huangjin"; case 4: return "vip"; default: return null; } } /// 炮台等级:优先 turretID(变体容器炮台配了 1~4),否则从名字里取数字(PW_Battery_2 → 2) private int ParseTurretLevel() { if (!string.IsNullOrEmpty(turretID) && int.TryParse(turretID.Trim(), out int id) && id > 0) return id; foreach (char c in name) if (char.IsDigit(c)) return c - '0'; return 0; } /// 入场开始:飞机临时挂到炮台下(保留世界位置/旋转/缩放)——原挂载点(座位下的变体容器节点)默认未激活,直接显隐看不到; /// 完整飞机额外摆到炮台 Aprk 停靠位(原版 aprkPos) private void AttachPlaneForEnter() { _enterPlaneOrigParent = _enterPlaneRoot.parent; _enterPlaneOrigSibling = _enterPlaneRoot.GetSiblingIndex(); _enterPlaneOrigLocalPos = _enterPlaneRoot.localPosition; _enterPlaneOrigLocalRot = _enterPlaneRoot.localRotation; _enterPlaneOrigLocalScale = _enterPlaneRoot.localScale; if (_enterPlaneRoot.parent != transform) _enterPlaneRoot.SetParent(transform, true); // true=保留世界变换,停靠位不跳 if (_enterPlaneRoot.name.StartsWith("PW_Plane")) { Transform aprk = FindAprkNode(); if (aprk != null) _enterPlaneRoot.position = aprk.position; // 停靠位=炮台 Aprk(原版) } } /// 入场结束/清理:飞机隐藏并还原父节点与本地变换,不残留运行时改动 private void DetachPlaneAfterEnter() { if (_enterPlaneRoot == null) return; _enterPlaneRoot.gameObject.SetActive(false); if (_enterPlaneOrigParent != null && _enterPlaneRoot.parent != _enterPlaneOrigParent) { _enterPlaneRoot.SetParent(_enterPlaneOrigParent, false); _enterPlaneRoot.SetSiblingIndex(_enterPlaneOrigSibling); } _enterPlaneRoot.localPosition = _enterPlaneOrigLocalPos; _enterPlaneRoot.localRotation = _enterPlaneOrigLocalRot; _enterPlaneRoot.localScale = _enterPlaneOrigLocalScale; } /// 入场动画清理:停协程 + 飞机隐藏并还原挂载 + Board/炮台本体恢复显示,禁用/回收不残留 private void CleanupEnterAction() { if (_enterRoutine != null) { StopCoroutine(_enterRoutine); _enterRoutine = null; } if (_enterPlaneRoot != null) { DetachPlaneAfterEnter(); _enterPlaneRoot = null; _enterPlaneSg = null; } // 入场中途被打断:恢复被本类暂时隐藏的 Board(只恢复自己隐藏的,不动其他逻辑的显隐) if (_enterBoardHidden) { Transform board = transform.Find("Board"); if (board != null) board.gameObject.SetActive(true); _enterBoardHidden = false; } if (skeletonAnimation != null && !skeletonAnimation.gameObject.activeSelf) skeletonAnimation.gameObject.SetActive(true); } private void OnDisable() { // ⭐ 入场动画清理(停协程、飞机复位隐藏、炮台本体恢复显示):必须在提前 return 之前调用 CleanupEnterAction(); // ⭐ 预热中途被打断:还原临时挂载的飞机,避免残留 CleanupPrewarm(); // 切炮台/座位被禁用时兜底复位 if (!IsFlying) return; IsFlying = false; _returning = false; _firing = false; if (_rect != null) _rect.anchoredPosition = _originAnchoredPos; } }