849 lines
39 KiB
C#
849 lines
39 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using Spine.Unity;
|
||
using UnityEngine;
|
||
|
||
/// <summary>
|
||
/// 起飞炮台(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,会隐藏基类的同名私有初始化逻辑。
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <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>
|
||
/// 起飞/移动到目标屏幕点(点到哪飞到哪)。
|
||
/// 首次调用=进入起飞模式;飞行中再次调用=改目标点;降落中调用=取消降落改飞新目标。
|
||
/// ⭐ 轻点起飞总开关:未进入起飞模式时受 allowTapTakeoff 控制——默认 false,
|
||
/// 即"没点起飞按钮就不起飞"(轻点松开只会走正常 Idle/开火流程);飞行中改目标不受此限制。
|
||
/// 返回 false 表示未起飞(开关未开 / 节点不是 UGUI / 画布引用缺失)。
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <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;
|
||
|
||
if (newState == TurretState.Attack)
|
||
{
|
||
// ⭐ XJMH 原版射击动画(Battery:Shoot):**每发** SetAnimation(0,"shot",false)——从头重播(连射时
|
||
// 直接打断重放=原版"播放一半直接重新放")、非循环播完停住。
|
||
// 基类是"进入状态播一次 + 循环播放",连射不重播 → 这里每次 Attack 都重播。
|
||
// 首次进入先走基类同步状态(基类循环版动画同帧被下面重播覆盖,玩家不可见)
|
||
if (CurrState != TurretState.Attack)
|
||
base.ChangeState(newState);
|
||
PlayShotAnim();
|
||
return;
|
||
}
|
||
|
||
base.ChangeState(newState);
|
||
}
|
||
|
||
/// <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); // 每发从头(替换轨道=打断重放)
|
||
}
|
||
|
||
// ==================== 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;
|
||
|
||
/// <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);
|
||
|
||
// ⭐ 飞机:优先完整飞机预制体实例 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<SkeletonGraphic>(true)
|
||
: _enterPlaneRoot.GetComponent<SkeletonGraphic>();
|
||
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);
|
||
|
||
_enterPlaneRoot.gameObject.SetActive(true);
|
||
_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;
|
||
}
|
||
|
||
/// <summary>协程位移:EInQuart / EOutQuart 缓动(与原版 GC.Action 一致;用 localPosition 兼容完整飞机/骨架节点)</summary>
|
||
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;
|
||
}
|
||
|
||
/// <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>
|
||
/// 容器(底座/炮台外壳)出场动画:容器 Spine 播 "out"(容器动画为 close/open/out)。
|
||
/// 对应原版 EnterAction 第 6 步 bottomSkeleton "out"(battery/Bottom 上的骨架);没有该动画时跳过、不报错。
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 找容器(底座外壳)骨架:① 炮台根节点自带(容器节点布局);
|
||
/// ② "Bottom" 子节点(原版 PW_Battery 结构,容器骨架在 Bottom 上,根节点没有);
|
||
/// ③ 兜底:子树里带 "out" 动画、且不是炮口本体/飞机的骨架。
|
||
/// </summary>
|
||
private SkeletonGraphic FindContainerSkeleton()
|
||
{
|
||
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;
|
||
}
|
||
|
||
foreach (var sg in GetComponentsInChildren<SkeletonGraphic>(true))
|
||
{
|
||
if (sg == null || sg == skeletonAnimation) continue;
|
||
if (sg.gameObject.name.ToLowerInvariant().Contains("feiji")) continue; // 飞机不算容器
|
||
if (HasSpineAnim(sg, "out")) return sg;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
|
||
// ==================== 入场飞机预热(进房加载收尾时由面板调用,卡顿被进度条遮住) ====================
|
||
// XJMH 进入房间时"命中"的那架完整飞机 PW_Plane(骨架 + 41 个粒子系统、2.2MB 预制体、52 个节点):
|
||
// 整根激活会让 41 套粒子在同一帧全部首初始化(分配缓冲/上传网格贴图)——单帧几百 ms 主线程尖峰。
|
||
// 这就是"只有星际迷航进房卡、其它玩法不卡"的原因(其他玩法没有这套 XJMH 独有资产)。
|
||
// 预热 = 进度条还在走时把飞机临时挂到激活父节点下、缩到 0 隐藏;骨架 1 帧 + 粒子按每帧 8ms 预算
|
||
// 逐帧点亮完成初始化,再原样还原(玩家全程不可见)。
|
||
|
||
private Coroutine _prewarmRoutine;
|
||
private Transform _prewarmPlane; // 预热中的飞机(被打断时还原用)
|
||
private Transform _prewarmOrigParent;
|
||
private int _prewarmOrigSibling;
|
||
private Vector3 _prewarmOrigPos;
|
||
private Quaternion _prewarmOrigRot;
|
||
private Vector3 _prewarmOrigScale;
|
||
private bool _prewarmOrigActive;
|
||
/// <summary>粒子分帧点亮的帧内计时(Stopwatch 不受 Time.realtimeSinceStartup 帧内不刷新影响)</summary>
|
||
private static readonly System.Diagnostics.Stopwatch _prewarmFrameSw = new System.Diagnostics.Stopwatch();
|
||
|
||
/// <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;
|
||
|
||
// 容器显隐(与入场动画 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);
|
||
|
||
// ⭐ 关键修复("只有 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);
|
||
}
|
||
|
||
// 临时挂到根节点(激活中)并缩到 0:既能触发初始化,又完全不可见
|
||
Transform holder = transform.root;
|
||
if (holder != null && holder != _prewarmOrigParent)
|
||
plane.SetParent(holder, true);
|
||
plane.localScale = Vector3.zero;
|
||
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; // 首帧网格上传铺开
|
||
|
||
FinishPrewarmRestore();
|
||
Debug.Log($"[XJMH入场] 飞机预热完成({plane.name},粒子分帧点亮 {psNodes.Count} 个)");
|
||
_prewarmRoutine = null;
|
||
// 诊断:预热结束,把卡顿监测阶段还给加载段(后续卡顿归因不错位到预热)
|
||
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;
|
||
}
|
||
|
||
/// <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();
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
|
||
/// <summary>入场开始:飞机临时挂到炮台下(保留世界位置/旋转/缩放)——原挂载点(座位下的变体容器节点)默认未激活,直接显隐看不到;
|
||
/// 完整飞机额外摆到炮台 Aprk 停靠位(原版 aprkPos)</summary>
|
||
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(原版)
|
||
}
|
||
}
|
||
|
||
/// <summary>入场结束/清理:飞机隐藏并还原父节点与本地变换,不残留运行时改动</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>入场动画清理:停协程 + 飞机隐藏并还原挂载 + Board/炮台本体恢复显示,禁用/回收不残留</summary>
|
||
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;
|
||
}
|
||
}
|