using System; using System.Collections; 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 中的子对象);飞机骨架挂在座位的变体容器节点里 /// (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; base.ChangeState(newState); } // ==================== 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 RectTransform _enterPlane; // 入场中的飞机节点(离场清理复位用) 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); _enterPlane = FindPlaneSkeletonRect(); if (_enterPlane == null) { Debug.LogWarning($"[XJMH入场] {name} 未找到飞机节点(炮台/座位子树内名字含 feiji),只播炮台出现动画"); PlayTurretOutAnim(); _enterRoutine = null; yield break; } _enterPlaneSg = _enterPlane.GetComponent(); // ⭐ 飞机临时挂到炮台下并保持停靠位(原挂载点=座位下的变体容器节点,默认未激活,不挂过来显示不了) AttachPlaneForEnter(); Vector2 home = _enterPlane.anchoredPosition; Vector2 start = home + new Vector2(0f, enterStartOffsetY); Vector2 mid = home + new Vector2(0f, enterStartOffsetY * 0.5f); // ⭐ 原版 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; } _enterPlane.gameObject.SetActive(true); _enterPlane.anchoredPosition = 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(_enterPlane, start, mid, enterRiseTime, true); // 停顿(原版 delay 0.3) if (enterHoldTime > 0f) yield return new WaitForSeconds(enterHoldTime); // 第二段:升到停靠位(原版 EOutQuart) yield return RiseTo(_enterPlane, mid, home, enterBackRiseTime, false); // PlaneBack 音效 + 飞机回位动画(原版 PlaneBack → PlaneBackAnim:"back") 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 秒后飞机隐藏、炮台出现(ShowGun → ShowBoard) if (enterBackHoldTime > 0f) yield return new WaitForSeconds(enterBackHoldTime); DetachPlaneAfterEnter(); // 飞机隐藏 + 还原父节点/本地变换 _enterPlane = null; _enterPlaneSg = null; PlayTurretOutAnim(); if (board != null) board.gameObject.SetActive(true); _enterBoardHidden = false; _enterRoutine = null; } /// 协程位移:EInQuart / EOutQuart 缓动(与原版 GC.Action 一致) private static IEnumerator RiseTo(RectTransform rt, Vector2 from, Vector2 to, float time, bool easeIn) { if (rt == null) yield break; if (time <= 0f) { rt.anchoredPosition = 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); rt.anchoredPosition = Vector2.LerpUnclamped(from, to, e); yield return null; } rt.anchoredPosition = 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; } /// /// 找飞机骨架:① 炮台子树(飞机挂在炮台下的布局); /// ② 座位子树(当前 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; } /// 入场开始:飞机临时挂到炮台下(保留世界位置/旋转/缩放)——原挂载点(座位下的变体容器节点)默认未激活,直接显隐看不到 private void AttachPlaneForEnter() { _enterPlaneOrigParent = _enterPlane.parent; _enterPlaneOrigSibling = _enterPlane.GetSiblingIndex(); _enterPlaneOrigLocalPos = _enterPlane.localPosition; _enterPlaneOrigLocalRot = _enterPlane.localRotation; _enterPlaneOrigLocalScale = _enterPlane.localScale; if (_enterPlane.parent != transform) _enterPlane.SetParent(transform, true); // true=保留世界变换,停靠位不跳 } /// 入场结束/清理:飞机隐藏并还原父节点与本地变换,不残留运行时改动 private void DetachPlaneAfterEnter() { if (_enterPlane == null) return; _enterPlane.gameObject.SetActive(false); if (_enterPlaneOrigParent != null && _enterPlane.parent != _enterPlaneOrigParent) { _enterPlane.SetParent(_enterPlaneOrigParent, false); _enterPlane.SetSiblingIndex(_enterPlaneOrigSibling); } _enterPlane.localPosition = _enterPlaneOrigLocalPos; _enterPlane.localRotation = _enterPlaneOrigLocalRot; _enterPlane.localScale = _enterPlaneOrigLocalScale; } /// 入场动画清理:停协程 + 飞机隐藏并还原挂载 + Board/炮台本体恢复显示,禁用/回收不残留 private void CleanupEnterAction() { if (_enterRoutine != null) { StopCoroutine(_enterRoutine); _enterRoutine = null; } if (_enterPlane != null) { DetachPlaneAfterEnter(); _enterPlane = 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(); // 切炮台/座位被禁用时兜底复位 if (!IsFlying) return; IsFlying = false; _returning = false; _firing = false; if (_rect != null) _rect.anchoredPosition = _originAnchoredPos; } }