Files
5.24BuYu/Assets/MyGame/Scripts/Turret/FlyTurretData.cs
T

849 lines
39 KiB
C#
Raw Normal View History

2026-09-02 16:27:35 +08:00
using System;
2026-10-11 21:57:58 +08:00
using System.Collections;
2026-10-12 19:28:14 +08:00
using System.Collections.Generic;
2026-10-11 21:57:58 +08:00
using Spine.Unity;
2026-09-02 16:27:35 +08:00
using UnityEngine;
/// <summary>
2026-10-11 18:30:19 +08:00
/// 起飞炮台(TurretData 子类):炮台模式 / 起飞模式 两态分离。
/// 【炮台模式(IsFlying=false,默认形态)】就是一门正常炮台:手动瞄准开火/自动开火/锁定瞄准照常,
/// 与普通 TurretData 行为完全一致(轻点屏幕只走正常开火流程,不会起飞)。
/// 【起飞模式(IsFlying=true)】点"起飞按钮"进入(按钮逻辑待完善;默认 allowTapTakeoff=false
/// 时轻点屏幕不触发起飞):炮台飞到目标点 → 到达后持续向上发射子弹,upFireDuration
/// 到时自动降落回原炮台位;起飞时播放特殊 spine 动画(takeoffAnim 播一次,flyLoopAnim 循环衔接);
/// 期间炮口固定朝上、状态切换/开火/按住输入均被拦截(见 ChangeState/各开火入口)。
/// 起飞入口:TakeoffToScreenPos(当前轻点路径,受 allowTapTakeoff 控制);
/// 将来"起飞按钮"接入后走同一入口即可(或在面板上打开 allowTapTakeoff 恢复轻点起飞)。
2026-09-02 16:27:35 +08:00
/// 交互判定在 GameMainPanel_FishBase.HandleInput(轻点=按下短+几乎没移动;按住扫射不触发起飞)。
2026-10-12 00:37:28 +08:00
/// 挂载:XJMH 座位炮台(xjmhTurretList 中的子对象);入场飞机优先用座位下的完整飞机预制体实例
/// (PW_Plane_等级,含 Body 骨架/尾迹/护盾/免费特效——特效由本类按原版控制),找不到才退回飞机骨架节点
/// (lvptfeiji/lanptfeiji/huangjinfeiji/vipfeiji01)。飞行期间飞机临时挂到炮台下显示、结束还原。
2026-09-02 16:27:35 +08:00
/// 注意:不要在本类定义 Awake/OnEnable,会隐藏基类的同名私有初始化逻辑。
/// </summary>
public class FlyTurretData : TurretData
{
2026-10-11 18:30:19 +08:00
[Header("起飞入口")]
[Tooltip("允许轻点屏幕起飞(默认关=false:没点起飞按钮就不起飞)。\n将来\"起飞按钮\"逻辑接入后走专门入口,不受本开关限制")]
public bool allowTapTakeoff = false;
2026-09-02 16:27:35 +08:00
[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;
/// <summary>向上开火回调:由 GameMainPanel_FishBase 注入(走服务端同步链路 SyncFire)</summary>
[NonSerialized] public Action onUpwardFire;
/// <summary>是否处于飞行/悬停状态(含飞回降落途中)</summary>
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;
/// <summary>懒加载引用(不用 Awake,避免隐藏基类私有 Awake)</summary>
private void EnsureRefs()
{
if (_rect != null) return;
_rect = GetComponent<RectTransform>();
_parentRect = transform.parent as RectTransform;
Canvas canvas = null;
if (UIManager.Instance != null && UIManager.Instance.uiRoot != null)
canvas = UIManager.Instance.uiRoot.GetComponent<Canvas>();
if (canvas != null)
{
_canvasRect = canvas.GetComponent<RectTransform>();
if (canvas.renderMode != RenderMode.ScreenSpaceOverlay)
_uiCam = canvas.worldCamera;
}
}
private void Update()
{
if (!IsFlying) return;
UpdateFly();
UpdateUpFire();
}
/// <summary>
/// 起飞/移动到目标屏幕点(点到哪飞到哪)。
2026-10-11 18:30:19 +08:00
/// 首次调用=进入起飞模式;飞行中再次调用=改目标点;降落中调用=取消降落改飞新目标。
/// ⭐ 轻点起飞总开关:未进入起飞模式时受 allowTapTakeoff 控制——默认 false,
/// 即"没点起飞按钮就不起飞"(轻点松开只会走正常 Idle/开火流程);飞行中改目标不受此限制。
/// 返回 false 表示未起飞(开关未开 / 节点不是 UGUI / 画布引用缺失)。
2026-09-02 16:27:35 +08:00
/// </summary>
public bool TakeoffToScreenPos(Vector2 screenPos)
{
2026-10-11 18:30:19 +08:00
// ⭐ 默认不响应轻点起飞("起飞先不做",起飞按钮逻辑待完善):进入起飞模式前先过开关。
// 已在飞行=起飞模式内部行为(改目标点),不受开关限制
if (!IsFlying && !allowTapTakeoff) return false;
2026-09-02 16:27:35 +08:00
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;
}
/// <summary>降落:停火并飞回起飞前的原位</summary>
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();
}
/// <summary>屏幕坐标 → 炮台父节点下的 anchoredPosition(限制在画布范围内)</summary>
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;
}
/// <summary>炮口固定朝上:SetRotation 的 angle 约定 0=正上方(aim-90)</summary>
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);
}
/// <summary>起飞动画:takeoffAnim 播一次,flyLoopAnim 循环衔接(为空则循环自身兜底)</summary>
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);
}
}
/// <summary>落地后恢复待机动画(先 None 再 Idle,避免 currState 相同被基类短路)</summary>
private void RestoreIdleAnim()
{
base.ChangeState(TurretState.None);
base.ChangeState(TurretState.Idle);
}
public override void ChangeState(TurretState newState)
{
// 飞行/悬停期间拦截状态动画(Attack/Idle 切换不打断起飞动画)
if (IsFlying) return;
2026-10-12 16:03:34 +08:00
if (newState == TurretState.Attack)
{
// ⭐ XJMH 原版射击动画(Battery:Shoot):**每发** SetAnimation(0,"shot",false)——从头重播(连射时
// 直接打断重放=原版"播放一半直接重新放")、非循环播完停住。
// 基类是"进入状态播一次 + 循环播放",连射不重播 → 这里每次 Attack 都重播。
// 首次进入先走基类同步状态(基类循环版动画同帧被下面重播覆盖,玩家不可见)
if (CurrState != TurretState.Attack)
base.ChangeState(newState);
PlayShotAnim();
return;
}
2026-09-02 16:27:35 +08:00
base.ChangeState(newState);
}
2026-10-12 16:03:34 +08:00
/// <summary>⚠ 覆写:XJMH 停火不打断最后一发(原版最后一发播完自动切待机=定格在尾帧,见基类说明)</summary>
protected override bool KeepPoseOnEmptyIdleAnim => true;
/// <summary>同一帧内去重:自动开火"状态切换 + 手动发射"两条入口可能同帧都调本方法</summary>
private int _lastShotFrame = -1;
/// <summary>
/// 播放一发射击动画(原版 Battery:Shoot → gunSkeleton SetAnimation(0,"shot",false)):
/// ⭐ 每发从头重播(连射时打断当前播放重新开始);非循环,播完停在尾帧等下一发。
/// 手动按住扫射的每发(不经过 ChangeState)由 GameMainPanel 在发射后调用;飞行中不播(原版 isPlaneState 时跳过)。
/// </summary>
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); // 每发从头(替换轨道=打断重放)
}
2026-10-11 21:57:58 +08:00
// ==================== 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;
2026-10-12 00:37:28 +08:00
private Transform _enterPlaneRoot; // 入场中的飞机节点(PW_Plane_x 完整预制体实例;找不到时=骨架节点兜底)
2026-10-11 22:29:54 +08:00
private SkeletonGraphic _enterPlaneSg; // 飞机骨架(临时挂载后播 "stand01"/"back")
private bool _enterBoardHidden; // 入场期间 Board 被暂时隐藏(清理时只恢复自己隐藏的)
// ⭐ 飞机原始挂载信息:飞机在座位的变体容器节点里(该节点默认未激活,直接显隐看不到),
// 入场时临时挂到炮台下显示、结束/清理时按原样还原(父节点/层级/本地变换)
private Transform _enterPlaneOrigParent;
private int _enterPlaneOrigSibling;
private Vector3 _enterPlaneOrigLocalPos;
private Quaternion _enterPlaneOrigLocalRot;
private Vector3 _enterPlaneOrigLocalScale;
2026-10-11 21:57:58 +08:00
/// <summary>
/// XJMH 进房入场动画(移植原版 Battery:EnterAction 的非立即分支,本地玩家入座时调用一次):
/// Enter 音效 → 容器 "out" → 飞机从停靠位下方两段升起(PlaneBack 音效 + "back")→
/// 延迟后飞机隐藏、炮台本体播 "out" 出现。
/// ⭐ 未激活(面板加载中/座位隐藏)时直接返回——SitPosData 会挂起并在激活后补播。
/// 找不到飞机节点时只播炮台出现动画(兜底不报错)。
/// </summary>
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);
2026-10-12 00:37:28 +08:00
// ⭐ 飞机:优先完整飞机预制体实例 PW_Plane_等级(含 Body 骨架/尾迹/护盾/免费特效——由本类控制);
// 找不到才退回"骨架节点"(只有骨架、没有特效)
_enterPlaneRoot = FindPlaneRoot();
if (_enterPlaneRoot == null)
2026-10-11 21:57:58 +08:00
{
2026-10-12 00:37:28 +08:00
Debug.LogWarning($"[XJMH入场] {name} 未找到飞机节点(PW_Plane_x 或名字含 feiji),只播炮台出现动画");
2026-10-11 21:57:58 +08:00
PlayTurretOutAnim();
_enterRoutine = null;
yield break;
}
2026-10-12 00:37:28 +08:00
bool isFullPlane = _enterPlaneRoot.name.StartsWith("PW_Plane");
_enterPlaneSg = isFullPlane
? _enterPlaneRoot.GetComponentInChildren<SkeletonGraphic>(true)
: _enterPlaneRoot.GetComponent<SkeletonGraphic>();
Debug.Log($"[XJMH入场] {name} 飞机={_enterPlaneRoot.name}(完整飞机={isFullPlane} 骨架={(_enterPlaneSg != null ? _enterPlaneSg.name : "无")})");
2026-10-11 22:29:54 +08:00
2026-10-12 00:37:28 +08:00
// ⭐ 飞机临时挂到炮台下并保持停靠位(原挂载点=座位下的变体容器节点,默认未激活,不挂过来显示不了);
// 完整飞机:停靠位=炮台的 Aprk 节点(原版 self.aprkPos)
2026-10-11 22:29:54 +08:00
AttachPlaneForEnter();
2026-10-12 00:37:28 +08:00
Vector3 home = _enterPlaneRoot.localPosition;
Vector3 start = home + new Vector3(0f, enterStartOffsetY, 0f);
Vector3 mid = home + new Vector3(0f, enterStartOffsetY * 0.5f, 0f);
2026-10-11 21:57:58 +08:00
// ⭐ 原版 EnterAction 第 6 步:底部(容器/底座外壳)Spine 播 "out" 出场
// (PW_Battery.txt:EnterAction → bottomSkeleton.AnimationState:SetAnimation(0,"out",false))
PlayContainerOutAnim();
2026-10-11 22:29:54 +08:00
// 原版:入场时炮台本体隐藏、飞机出现(飞机从下方升上来,"变"回炮台);Board(分板)一并隐藏
2026-10-11 21:57:58 +08:00
if (skeletonAnimation != null)
skeletonAnimation.gameObject.SetActive(false);
2026-10-11 22:29:54 +08:00
Transform board = transform.Find("Board");
if (board != null) { board.gameObject.SetActive(false); _enterBoardHidden = true; }
2026-10-12 00:37:28 +08:00
// ⭐ 飞机特效控制(原版 EnterAction):开 Body(骨架+尾迹)与护盾;
// 免费/暴击/加速三类特效一律关(原版只在免费模式玩法里开,prefab 默认全开会一起炸出来)
SetPlaneChildActive("Body", true);
SetPlaneChildActive("PW_Effect_Defend", true);
SetPlaneChildActive("FreeEffect", false);
SetPlaneChildActive("FreeEffect_Crit", false);
SetPlaneChildActive("FreeEffect_Speedup", false);
_enterPlaneRoot.gameObject.SetActive(true);
_enterPlaneRoot.localPosition = start;
2026-10-11 22:29:54 +08:00
if (_enterPlaneSg != null && _enterPlaneSg.AnimationState != null)
{
// 原版飞机常驻飞行循环(PlaneFlyAnim:"stand01" 循环);清残留(对象池/重复入场)后先播它
_enterPlaneSg.AnimationState.ClearTrack(0);
if (HasSpineAnim(_enterPlaneSg, "stand01"))
_enterPlaneSg.AnimationState.SetAnimation(0, "stand01", true);
}
2026-10-11 21:57:58 +08:00
// 第一段:升到中途位(原版 EInQuart)
2026-10-12 00:37:28 +08:00
yield return RiseTo(_enterPlaneRoot, start, mid, enterRiseTime, true);
2026-10-11 21:57:58 +08:00
// 停顿(原版 delay 0.3)
if (enterHoldTime > 0f) yield return new WaitForSeconds(enterHoldTime);
// 第二段:升到停靠位(原版 EOutQuart)
2026-10-12 00:37:28 +08:00
yield return RiseTo(_enterPlaneRoot, mid, home, enterBackRiseTime, false);
2026-10-11 21:57:58 +08:00
2026-10-12 00:37:28 +08:00
// 原版 PlaneBack:护盾先收;PlaneBack 音效 + 飞机回位动画(PlaneBackAnim:"back")
SetPlaneChildActive("PW_Effect_Defend", false);
2026-10-11 21:57:58 +08:00
if (enterBackAudioID > 0 && AudioManager.Instance != null)
AudioManager.Instance.PlayerFX(enterBackAudioID);
2026-10-11 22:29:54 +08:00
if (_enterPlaneSg != null && _enterPlaneSg.AnimationState != null && HasSpineAnim(_enterPlaneSg, "back"))
_enterPlaneSg.AnimationState.SetAnimation(0, "back", false);
2026-10-11 21:57:58 +08:00
2026-10-12 00:37:28 +08:00
// 原版:back 动画约 1 秒后飞机本体(Body)隐藏、炮台出现(ShowGun → ShowBoard)
2026-10-11 21:57:58 +08:00
if (enterBackHoldTime > 0f) yield return new WaitForSeconds(enterBackHoldTime);
2026-10-12 00:37:28 +08:00
SetPlaneChildActive("Body", false);
2026-10-11 22:29:54 +08:00
DetachPlaneAfterEnter(); // 飞机隐藏 + 还原父节点/本地变换
2026-10-12 00:37:28 +08:00
_enterPlaneRoot = null;
2026-10-11 22:29:54 +08:00
_enterPlaneSg = null;
PlayTurretOutAnim();
if (board != null) board.gameObject.SetActive(true);
_enterBoardHidden = false;
2026-10-11 21:57:58 +08:00
_enterRoutine = null;
}
2026-10-12 00:37:28 +08:00
/// <summary>协程位移:EInQuart / EOutQuart 缓动(与原版 GC.Action 一致;用 localPosition 兼容完整飞机/骨架节点)</summary>
private static IEnumerator RiseTo(Transform tr, Vector3 from, Vector3 to, float time, bool easeIn)
2026-10-11 21:57:58 +08:00
{
2026-10-12 00:37:28 +08:00
if (tr == null) yield break;
if (time <= 0f) { tr.localPosition = to; yield break; }
2026-10-11 21:57:58 +08:00
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);
2026-10-12 00:37:28 +08:00
tr.localPosition = Vector3.LerpUnclamped(from, to, e);
2026-10-11 21:57:58 +08:00
yield return null;
}
2026-10-12 00:37:28 +08:00
tr.localPosition = to;
2026-10-11 21:57:58 +08:00
}
/// <summary>炮台本体出现:恢复显示并播 startingAnimation(预制体配的 "out";为空时兜底 "out")</summary>
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);
}
/// <summary>
2026-10-11 22:29:54 +08:00
/// 容器(底座/炮台外壳)出场动画:容器 Spine 播 "out"(容器动画为 close/open/out)。
/// 对应原版 EnterAction 第 6 步 bottomSkeleton "out"(battery/Bottom 上的骨架);没有该动画时跳过、不报错。
2026-10-11 21:57:58 +08:00
/// </summary>
private void PlayContainerOutAnim()
{
2026-10-11 22:29:54 +08:00
var shell = FindContainerSkeleton();
2026-10-11 21:57:58 +08:00
if (shell == null || shell.AnimationState == null) return;
2026-10-11 22:29:54 +08:00
if (!HasSpineAnim(shell, "out")) return; // 没有 out 动画:跳过
2026-10-11 21:57:58 +08:00
shell.AnimationState.SetAnimation(0, "out", false);
shell.Update(0.1f);
}
2026-10-11 22:29:54 +08:00
/// <summary>
/// 找容器(底座外壳)骨架:① 炮台根节点自带(容器节点布局);
/// ② "Bottom" 子节点(原版 PW_Battery 结构,容器骨架在 Bottom 上,根节点没有);
/// ③ 兜底:子树里带 "out" 动画、且不是炮口本体/飞机的骨架。
/// </summary>
private SkeletonGraphic FindContainerSkeleton()
2026-10-11 21:57:58 +08:00
{
2026-10-11 22:29:54 +08:00
var self = GetComponent<SkeletonGraphic>();
if (self != null && self != skeletonAnimation && HasSpineAnim(self, "out")) return self;
Transform bottom = transform.Find("Bottom");
if (bottom != null)
{
var sg = bottom.GetComponent<SkeletonGraphic>();
if (sg != null && sg != skeletonAnimation) return sg;
}
2026-10-11 21:57:58 +08:00
foreach (var sg in GetComponentsInChildren<SkeletonGraphic>(true))
{
2026-10-11 22:29:54 +08:00
if (sg == null || sg == skeletonAnimation) continue;
if (sg.gameObject.name.ToLowerInvariant().Contains("feiji")) continue; // 飞机不算容器
if (HasSpineAnim(sg, "out")) return sg;
2026-10-11 21:57:58 +08:00
}
return null;
}
2026-10-11 22:29:54 +08:00
/// <summary>骨架数据里是否有该动画(SkeletonGraphic 已初始化才有效)</summary>
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;
}
2026-10-12 00:37:28 +08:00
/// <summary>
/// 找入场飞机:① 完整飞机预制体实例(座位下名字以 "PW_Plane_等级" 开头,如 PW_Plane_1——含特效);
/// ② 骨架节点兜底(炮台/座位子树内名字含 "feiji" 的 SkeletonGraphic,如 lvptfeiji——只有骨架)。
/// </summary>
private Transform FindPlaneRoot()
{
var full = FindFullPlaneRoot();
if (full != null) return full;
return FindPlaneSkeletonRect();
}
/// <summary>找完整飞机预制体实例:先炮台子树、再座位子树;按当前炮台等级精确匹配,匹配不到用任意一架</summary>
private Transform FindFullPlaneRoot()
{
var t = FindFullPlaneUnder(transform);
if (t != null) return t;
var sit = GetComponentInParent<SitPosData>();
return sit != null ? FindFullPlaneUnder(sit.transform) : null;
}
private Transform FindFullPlaneUnder(Transform root)
{
int lv = ParseTurretLevel();
Transform any = null;
foreach (var t in root.GetComponentsInChildren<Transform>(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;
}
/// <summary>炮台的飞机停靠位节点(原版 self.aprk = battery:FindChild("Aprk"))</summary>
private Transform FindAprkNode()
{
Transform t = transform.Find("Aprk");
if (t != null) return t;
foreach (var c in GetComponentsInChildren<Transform>(true))
if (c.name.ToLowerInvariant().Contains("aprk")) return c;
return null;
}
/// <summary>
/// 飞机子节点显隐(只对完整飞机 PW_Plane 生效;骨架兜底路径没有这些特效节点,自动跳过):
/// Body=飞机本体(骨架+尾迹)、PW_Effect_Defend=护盾、FreeEffect/FreeEffect_Crit/FreeEffect_Speedup=免费模式三类特效。
/// </summary>
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);
}
// ==================== 入场飞机预热(进房加载收尾时由面板调用,卡顿被进度条遮住) ====================
2026-10-12 19:28:14 +08:00
// XJMH 进入房间时"命中"的那架完整飞机 PW_Plane(骨架 + 41 个粒子系统、2.2MB 预制体、52 个节点):
// 整根激活会让 41 套粒子在同一帧全部首初始化(分配缓冲/上传网格贴图)——单帧几百 ms 主线程尖峰。
// 这就是"只有星际迷航进房卡、其它玩法不卡"的原因(其他玩法没有这套 XJMH 独有资产)。
// 预热 = 进度条还在走时把飞机临时挂到激活父节点下、缩到 0 隐藏;骨架 1 帧 + 粒子按每帧 8ms 预算
// 逐帧点亮完成初始化,再原样还原(玩家全程不可见)。
2026-10-12 00:37:28 +08:00
private Coroutine _prewarmRoutine;
private Transform _prewarmPlane; // 预热中的飞机(被打断时还原用)
private Transform _prewarmOrigParent;
private int _prewarmOrigSibling;
private Vector3 _prewarmOrigPos;
private Quaternion _prewarmOrigRot;
private Vector3 _prewarmOrigScale;
private bool _prewarmOrigActive;
2026-10-12 19:28:14 +08:00
/// <summary>粒子分帧点亮的帧内计时(Stopwatch 不受 Time.realtimeSinceStartup 帧内不刷新影响)</summary>
private static readonly System.Diagnostics.Stopwatch _prewarmFrameSw = new System.Diagnostics.Stopwatch();
2026-10-12 00:37:28 +08:00
/// <summary>入场飞机预热(面板在加载收尾阶段调用;座位/炮台需已激活)。幂等:重复调用只跑一次。</summary>
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;
2026-10-12 19:28:14 +08:00
// 容器显隐(与入场动画 EnterActionRoutine 一致):开 Body/护盾、关免费三类
2026-10-12 00:37:28 +08:00
SetPlaneChildActive(plane, "Body", true);
2026-10-12 19:28:14 +08:00
SetPlaneChildActive(plane, "PW_Effect_Defend", true);
2026-10-12 00:37:28 +08:00
SetPlaneChildActive(plane, "FreeEffect", false);
SetPlaneChildActive(plane, "FreeEffect_Crit", false);
SetPlaneChildActive(plane, "FreeEffect_Speedup", false);
2026-10-12 19:28:14 +08:00
// ⭐ 关键修复("只有 XJMH 进房卡主线程"的根因):
// 整根 SetActive(true) 会让 41 个粒子系统在同一帧全部首初始化(单帧几百 ms)。
// 先把"会被激活的"粒子节点逐个关掉(飞机根此刻未激活,只是置标志、不做初始化),
// 等飞机激活(这一帧只初始化骨架等非粒子部分)后,按"每帧约 8ms 预算"逐帧点亮——
// 设备快一帧多开几个、设备慢一帧只开一个,单帧尖峰摊平到十几帧,不再顶住主线程。
// ⚠️ 只动"容器开着"的粒子(Body/护盾);免费三类容器已关、默认关着的粒子都不动,保持原状。
var psNodes = new List<GameObject>(64);
foreach (var ps in plane.GetComponentsInChildren<ParticleSystem>(true))
{
if (ps == null || ps.gameObject == plane.gameObject) continue;
if (!IsActiveUnder(ps.transform, plane)) continue; // 容器关着的(免费三类等)不预热
ps.gameObject.SetActive(false);
psNodes.Add(ps.gameObject);
}
2026-10-12 00:37:28 +08:00
// 临时挂到根节点(激活中)并缩到 0:既能触发初始化,又完全不可见
Transform holder = transform.root;
if (holder != null && holder != _prewarmOrigParent)
plane.SetParent(holder, true);
plane.localScale = Vector3.zero;
2026-10-12 19:28:14 +08:00
LoadingStallWatchdog.Phase = "预热·飞机骨架激活帧";
plane.gameObject.SetActive(true); // 这一帧只初始化骨架等非粒子部分
yield return null; // 骨架首帧网格
// ⭐ 逐帧分批点亮粒子(帧内时间预算自适应:快机多开、慢机少开)
LoadingStallWatchdog.Phase = "预热·飞机粒子分帧";
const double frameBudgetMs = 8.0;
_prewarmFrameSw.Restart();
for (int i = 0; i < psNodes.Count; i++)
{
if (psNodes[i] == null) continue;
psNodes[i].SetActive(true);
if (_prewarmFrameSw.Elapsed.TotalMilliseconds >= frameBudgetMs)
{
_prewarmFrameSw.Restart();
yield return null;
}
}
yield return null; // 最后一批首帧模拟
yield return null; // 首帧网格上传铺开
2026-10-12 00:37:28 +08:00
FinishPrewarmRestore();
2026-10-12 19:28:14 +08:00
Debug.Log($"[XJMH入场] 飞机预热完成({plane.name},粒子分帧点亮 {psNodes.Count} 个)");
2026-10-12 00:37:28 +08:00
_prewarmRoutine = null;
2026-10-12 19:28:14 +08:00
// 诊断:预热结束,把卡顿监测阶段还给加载段(后续卡顿归因不错位到预热)
LoadingStallWatchdog.Phase = "60~78 音频加载";
}
/// <summary>节点在"飞机根若激活"的前提下是否会被激活(检查自身到飞机根之间每级的 activeSelf)</summary>
private static bool IsActiveUnder(Transform node, Transform stopRoot)
{
for (var t = node; t != null && t != stopRoot; t = t.parent)
if (!t.gameObject.activeSelf) return false;
return true;
2026-10-12 00:37:28 +08:00
}
/// <summary>预热还原:飞机隐藏并恢复到预热前的挂载/变换/激活状态(协程正常收尾或中途被打断都用它)</summary>
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);
}
/// <summary>预热中途被打断(座位/炮台被禁用):停协程并把飞机还原回原挂载点,避免残留</summary>
private void CleanupPrewarm()
{
if (_prewarmRoutine != null) { StopCoroutine(_prewarmRoutine); _prewarmRoutine = null; }
FinishPrewarmRestore();
}
2026-10-11 22:29:54 +08:00
/// <summary>
/// 找飞机骨架:① 炮台子树(飞机挂在炮台下的布局);
/// ② 座位子树(当前 XJMH 布局:飞机与炮台平级、挂在座位的变体容器节点里,
/// 如 lvptfeiji/lanptfeiji/huangjinfeiji/vipfeiji01)——先按当前炮台等级挑对应飞机,挑不到用座位下任意飞机兜底。
/// </summary>
private RectTransform FindPlaneSkeletonRect()
{
var plane = FindPlaneSkeletonUnder(transform, null);
if (plane != null) return plane;
var sit = GetComponentInParent<SitPosData>();
if (sit == null) return null;
return FindPlaneSkeletonUnder(sit.transform, GetPlaneKeyword());
}
/// <summary>在 root 子树里找名字含 "feiji" 的 SkeletonGraphic;keyword 非空时优先匹配(如 lvpt)</summary>
private static RectTransform FindPlaneSkeletonUnder(Transform root, string keyword)
{
RectTransform any = null;
foreach (var sg in root.GetComponentsInChildren<SkeletonGraphic>(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;
}
/// <summary>当前炮台等级 → 座位内飞机名关键字(原版 PW_Plane_1~4 = 绿/蓝/金/VIP 机)</summary>
private string GetPlaneKeyword()
{
switch (ParseTurretLevel())
{
case 1: return "lvpt";
case 2: return "lanpt";
case 3: return "huangjin";
case 4: return "vip";
default: return null;
}
}
/// <summary>炮台等级:优先 turretID(变体容器炮台配了 1~4),否则从名字里取数字(PW_Battery_2 → 2)</summary>
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;
}
2026-10-12 00:37:28 +08:00
/// <summary>入场开始:飞机临时挂到炮台下(保留世界位置/旋转/缩放)——原挂载点(座位下的变体容器节点)默认未激活,直接显隐看不到;
/// 完整飞机额外摆到炮台 Aprk 停靠位(原版 aprkPos)</summary>
2026-10-11 22:29:54 +08:00
private void AttachPlaneForEnter()
{
2026-10-12 00:37:28 +08:00
_enterPlaneOrigParent = _enterPlaneRoot.parent;
_enterPlaneOrigSibling = _enterPlaneRoot.GetSiblingIndex();
_enterPlaneOrigLocalPos = _enterPlaneRoot.localPosition;
_enterPlaneOrigLocalRot = _enterPlaneRoot.localRotation;
_enterPlaneOrigLocalScale = _enterPlaneRoot.localScale;
2026-10-11 22:29:54 +08:00
2026-10-12 00:37:28 +08:00
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(原版)
}
2026-10-11 22:29:54 +08:00
}
/// <summary>入场结束/清理:飞机隐藏并还原父节点与本地变换,不残留运行时改动</summary>
private void DetachPlaneAfterEnter()
{
2026-10-12 00:37:28 +08:00
if (_enterPlaneRoot == null) return;
2026-10-11 22:29:54 +08:00
2026-10-12 00:37:28 +08:00
_enterPlaneRoot.gameObject.SetActive(false);
if (_enterPlaneOrigParent != null && _enterPlaneRoot.parent != _enterPlaneOrigParent)
2026-10-11 22:29:54 +08:00
{
2026-10-12 00:37:28 +08:00
_enterPlaneRoot.SetParent(_enterPlaneOrigParent, false);
_enterPlaneRoot.SetSiblingIndex(_enterPlaneOrigSibling);
2026-10-11 22:29:54 +08:00
}
2026-10-12 00:37:28 +08:00
_enterPlaneRoot.localPosition = _enterPlaneOrigLocalPos;
_enterPlaneRoot.localRotation = _enterPlaneOrigLocalRot;
_enterPlaneRoot.localScale = _enterPlaneOrigLocalScale;
2026-10-11 22:29:54 +08:00
}
/// <summary>入场动画清理:停协程 + 飞机隐藏并还原挂载 + Board/炮台本体恢复显示,禁用/回收不残留</summary>
2026-10-11 21:57:58 +08:00
private void CleanupEnterAction()
{
if (_enterRoutine != null) { StopCoroutine(_enterRoutine); _enterRoutine = null; }
2026-10-12 00:37:28 +08:00
if (_enterPlaneRoot != null)
2026-10-11 21:57:58 +08:00
{
2026-10-11 22:29:54 +08:00
DetachPlaneAfterEnter();
2026-10-12 00:37:28 +08:00
_enterPlaneRoot = null;
2026-10-11 22:29:54 +08:00
_enterPlaneSg = null;
}
// 入场中途被打断:恢复被本类暂时隐藏的 Board(只恢复自己隐藏的,不动其他逻辑的显隐)
if (_enterBoardHidden)
{
Transform board = transform.Find("Board");
if (board != null) board.gameObject.SetActive(true);
_enterBoardHidden = false;
2026-10-11 21:57:58 +08:00
}
if (skeletonAnimation != null && !skeletonAnimation.gameObject.activeSelf)
skeletonAnimation.gameObject.SetActive(true);
}
2026-09-02 16:27:35 +08:00
private void OnDisable()
{
2026-10-11 21:57:58 +08:00
// ⭐ 入场动画清理(停协程、飞机复位隐藏、炮台本体恢复显示):必须在提前 return 之前调用
2026-10-11 18:20:05 +08:00
CleanupEnterAction();
2026-10-12 00:37:28 +08:00
// ⭐ 预热中途被打断:还原临时挂载的飞机,避免残留
CleanupPrewarm();
2026-10-11 18:20:05 +08:00
// 切炮台/座位被禁用时兜底复位
2026-09-02 16:27:35 +08:00
if (!IsFlying) return;
IsFlying = false;
_returning = false;
_firing = false;
if (_rect != null)
_rect.anchoredPosition = _originAnchoredPos;
}
}