using Assets.MyGame.Scripts.Net.APIResponses.Enum; using Assets.MyGame.Scripts.Net.WebResponses; using DG.Tweening; using System; using System.Collections; using UnityEngine; /// /// 机械蝎 Boss。 /// 死亡流程:正常打死 → 在死亡位置爆炸 → 范围内普通鱼死亡 → 加分滚动。 /// 通用爆奖模式,Boss 本体走标准 MonsterControl_BossBase.DieFish。 /// /// lua 对应:pw_enemy/PW_Scorpion.txt(本体生命周期)+ pw_effect/PW_Effect.txt DropTenCardEffect(八连抽)。 /// 本体 Spine 状态机(PW_Scorpion.txt):in(出生) → stand(站立,2.2s后) ⇄ attack(随机10~25s,需倒计时>5) /// → out(离场/OnMoveFinish) → 爆炸(OnExplosion)。碰撞体由 MonsterContral 基类驱动。 /// 八连抽(DropTenCardEffect)见 JiXieXieEvent.cs。 /// /// C# 侧已对齐 lua 的 Spine 动画切换(OnMoveEnter 驱动 in→stand + 启动攻击循环 StartAttackCycle;DieFish 驱动 out), /// 全部经 PlaySpineSafe 守卫:动画名缺失或 isSpine=0 时自动跳过(回退原 stand2),绝不抛异常。 /// ⚠️ 当前预制体 PW_Enemy_3000 的 isSpine=0,且 Spine 资源需含 in/stand/attack/out 动画名; /// 美术接入 Spine 骨骼(isSpine=1 + 对应动画名)后此逻辑立即生效。 /// 音效时序(FreeCardBomb/FreeGet 等)已在 JiXieXieEvent.cs 对齐;死亡音效 isSelf 判定见 DieFish。 /// public class JiXieXie_BOSS : MonsterControl_BossBase { // ---- Spine 动画名(需与美术 Spine 资源动画名一致;不一致改这里即可)---- private const string AnimIn = "in"; private const string AnimStand = "stand"; private const string AnimAttack = "attack"; private const string AnimOut = "out"; // ---- 攻击节奏(对齐 lua:随机 10~25s 间隔,attack 持续 2s,出生 2.2s 后转 stand)---- // ⚠️ 攻击循环用 DOTween 递归计时驱动,从 OnMoveEnter 启动(进入 move 状态即触发,不依赖 isMove)。 // 基类 MonsterContral.OnUpdate 对 isMove=false 的固定 Boss 会在分发处直接 return, // 导致 OnMoveUpdata 永不被调用——故攻击计时不能放在 OnMoveUpdata 里。 private Tween _attackTween; private bool _attackRunning; // ================================================================ // 本体特效(预制体 PW_Enemy_3000 里已作为子物体挂好、默认隐藏,由代码按 lua 时序激活) // lua 对应(pw_enemy/PW_Scorpion.txt,原版用 MonentaryEffect 临时创建,time 到就销毁): // PW_Effect_Scorpion_Born → 出场那一帧(dotIndex ≥ showDotIndex)创建,time=3 // PW_Effect_Scorpion_Attack → 进入攻击动作时创建(原版还要求"出场倒计时>5"),time=3.5 // PW_Effect_Scorpion_Out → 到点离场 OnMoveFinish 时创建,time=2(与本类 AutoExit 的 2s 延迟一致) // PW_Effect_Explosion_Scorpion → 被击杀 OnExplosion 时创建,time=1(本类 DieFish) // ⚠️ 四个子物体在 PW_Enemy_3000.prefab 里 m_IsActive=0,不激活就看不到;这里只在对应时机 SetActive。 // ================================================================ [Header("本体特效(留空则按名字自动查找子物体)")] [Tooltip("出场特效 PW_Effect_Scorpion_Born(激活后 3s 自动隐藏,对齐 lua time=3)")] public GameObject bornEffect; [Tooltip("攻击特效 PW_Effect_Scorpion_Attack(激活后 3.5s 自动隐藏,对齐 lua time=3.5)")] public GameObject attackEffect; [Tooltip("离场特效 PW_Effect_Scorpion_Out(激活后一直显示到本体回收,对齐 lua time=2)")] public GameObject outEffect; [Tooltip("被杀爆炸特效 PW_Effect_Explosion_Scorpion(激活后 1s 自动隐藏,对齐 lua time=1)")] public GameObject explosionEffect; private const string BornEffectName = "PW_Effect_Scorpion_Born"; private const string AttackEffectName = "PW_Effect_Scorpion_Attack"; private const string OutEffectName = "PW_Effect_Scorpion_Out"; private const string ExplosionEffectName = "PW_Effect_Explosion_Scorpion"; private const float BornEffectTime = 3f; // lua MonentaryEffect time=3 private const float AttackEffectTime = 3.5f; // lua MonentaryEffect time=3.5 private const float ExplosionEffectTime = 1f; // lua MonentaryEffect time=1 private bool _bornEffectPlayed; // 出场特效只播一次(防 OnMoveEnter 重复触发) private Tween _bornEffectTween; // 出场特效自动隐藏计时 private Tween _attackEffectTween; // 攻击特效自动隐藏计时 // 爆炸特效脱离本体时的原父节点 + 原始局部变换(1s 后挂回时还原,保证对象池复用结构完整) private Tween _explosionEffectTween; private Transform _explosionEffectParent; private Vector3 _explosionEffectLocalPos; private Quaternion _explosionEffectLocalRot; private Vector3 _explosionEffectLocalScale; // ================================================================ // 退场倒计时 UI(对齐 lua pw_view/PW_ScorpionTimerView.txt) // lua:OnCreate → ScorpionCountDown(ratio) 打开视图 + SetTimerText(秒数) + EnterAction() // 每秒 TimerUpdate() 递减;attack 需 scorepionTimerNum > 5 才播表现 // OnMoveFinish / OnExplosion → ExitAction()(从右侧滑出) // ⚠️ 原预制体的文字/背景组件是缺失脚本(原版自定义组件未导入本项目), // 所以这里运行时给 mTextName / mTextTimer 补上 TMP 组件再填字。 // ================================================================ [Header("退场倒计时 UI(留空则不显示倒计时,攻击表现也不受 5 秒门限限制)")] [Tooltip("退场倒计时 UI 预制体(Art/XJMH/pw/pw_prefab/view/PW_ScorpionTimerView)")] public GameObject timerViewPrefab; [Tooltip("倒计时文字字体(留空用 TMP_Settings.defaultFontAsset)")] public TMPro.TMP_FontAsset timerFont; [Tooltip("“退场倒计时”标题字号")] public float timerLabelFontSize = 22f; [Tooltip("倒计时数字字号(MM : SS)")] public float timerValueFontSize = 26f; private const string TimerViewNodeName = "mGONode"; private const string TimerViewLabelName = "mTextName"; private const string TimerViewTextName = "mTextTimer"; private const string TimerViewLabel = "退场倒计时"; // 原版取 GC.Config.DialWeapon.Text12 private const float TimerViewStartX = 0f; // lua EnterAction: localPosition = (0, 50) private const float TimerViewStartY = 50f; private const float TimerViewEnterDeltaX = -250f; // lua EnterAction: localMoveBy(-250, 50, 0.5, EOutBack) private const float TimerViewEnterDeltaY = 50f; private const float TimerViewEnterTime = 0.5f; private const float TimerViewExitDeltaX = 250f; // lua ExitAction: localMoveBy(250, 50, 0.3, EInBack) private const float TimerViewExitDeltaY = 50f; private const float TimerViewExitTime = 0.3f; private const int AttackVisualMinSeconds = 5; // lua:倒计时 > 5 才播攻击动画/特效/音效 private GameObject _timerView; // 倒计时 UI 实例(关闭时立刻置空,销毁交给滑出 tween + DOVirtual 兜底) private RectTransform _timerGONode; // 参与滑入/滑出的节点(mGONode) private TMPro.TextMeshProUGUI _timerText; private int _timerSeconds; // 当前倒计时秒数(= 距离自动离场的剩余秒数) private bool _timerActive; // 倒计时是否生效(决定攻击表现的 5 秒门限) private Tween _timerTickTween; // 每秒递减计时 private Tween _timerMoveTween; // 滑入/滑出动画 private Tween _attackSoundTween; // 攻击音效(动作结束前 1 秒播) // ================================================================ // 受击抖动(对齐 lua Scorpion:OnHit + HitUpdate) // OnHit:置 beHit 标记(防重入);HitUpdate:0.2s 内每帧把 body 的 localPosition // 随机偏移 x=rand(-100,100)*0.0003、y=rand(-50,50)*0.0006,结束后还原。 // C# 侧由 MonsterContral.OnHurt()(子弹命中时调用,virtual 可 override)驱动,不用改公共代码。 // ================================================================ private const string BodyNodeName = "Body"; private const float HitJitterDuration = 0.2f; // lua beHitT >= 0.2 private const float HitJitterScaleX = 0.0003f; // lua math.random(-100,100) * 0.0003 private const float HitJitterScaleY = 0.0006f; // lua math.random(-50,50) * 0.0006 private Transform _bodyTf; // Body 节点(抖动对象,缓存原始 localPosition) private Vector3 _bodyOriginLocalPos; private bool _hitJittering; // 正在抖动(= lua beHit) private Coroutine _hitJitterRoutine; // ---- 自动退出(对齐 lua OnMoveFinish:未被击杀也到点离场)---- // ⚠️ 机械蝎 isMove=false 固定停在 (0,0,0),不走路线、OnMoveUpdata 永不触发路线终点检测, // 且服务端 SyncGameData.remainingSeconds 未接上销毁逻辑 → 不被打死就永远不消失。 // 故用可配置存活计时器兜底:到点播 out(若 Spine 含)后回收。<=0 表示永不离场(纯靠击杀)。 [Header("自动退出:未被击杀时的存活时长(秒),<=0 永不自动离场")] public float bossLifeTime = 30f; private Tween _exitTween; private bool _isExiting; private const float AttackIntervalMin = 10f; private const float AttackIntervalMax = 25f; private const float AttackDuration = 2f; private const float BornToStandDelay = 2.2f; public override void OnEnable() { base.OnEnable(); _attackRunning = false; _bornEffectPlayed = false; _timerSeconds = 0; _timerActive = false; // ⭐ 对象池复用:先解析三个本体特效子物体,再统一复位为隐藏(避免上次残留一直亮着) ResolveBodyEffects(); ResetBodyEffects(); // ⭐ 受击抖动:先还原上一轮残留,再缓存 Body 原始 localPosition StopHitJitter(); ResolveBodyTransform(); // 音效:蝎子语音(PW_TextScorpion) AudioManager.Instance.PlayerFXByName("PW_TextScorpion"); } public override void DieFish(RoomKillNotice obj = null) { StopAttackCycle(); // 死亡即停止攻击循环,避免对已销毁对象触发动画 StopExitTimer(); // 取消自动退出计时,避免击杀回收后定时器再触发一次回收 base.DieFish(obj); // ⭐ 被杀爆炸特效(lua OnExplosion 第一步:爆点创建 PW_Effect_Explosion_Scorpion,time=1) PlayExplosionEffect(); // ⭐ 倒计时 UI 滑出(lua OnExplosion → ScorpionTimer:ExitAction) CloseTimerView(false); // 离场/死亡动画(对齐 lua out;非 Spine 或无 out 动画时自动跳过) PlaySpineSafe(AnimOut, false); // 音效:蝎子死亡 + 死亡语音(PW_ScorpionDie / PW_ScorpionDieVoice) // lua OnExplosion:仅当击杀者为自己(isSelf)才播;本地用户不可用时回退为必播(保留原行为) bool isSelfKill = GameManager.Instance != null && obj != null && obj.userId == GameManager.Instance.user.id; if (isSelfKill || GameManager.Instance == null) { AudioManager.Instance.PlayerFXByName("PW_ScorpionDie"); AudioManager.Instance.PlayerFXByName("PW_ScorpionDieVoice"); } // 标准爆奖事件初始化 action_effect_DieAward = (o) => { // 爆奖预制体已由 CreateFishManager 在 monster.transform.position(死亡位置)创建 Vector3 diePos = transform.position; Debug.Log($"[JiXieXie] action_effect_DieAward: 鱼位置={diePos} 爆奖预制体位置={o.transform.position}"); // 现在回收 Boss 本体 CreateFishManager.Instance.Id_DieFish_NotFX(guid); if (obj == null) return; foreach (var item in CreateFishManager.Instance.currGameType.list_Sits) { if (item.user == obj.userId) { var v = o.GetComponent(); // 先设置死亡位置,再 Init(Init 里会用 explosionCenter 恢复位置) var evt = v as JiXieXieEvent; if (evt != null) { evt.explosionCenter = diePos; Debug.Log($"[JiXieXie] 设置爆炸中心(死亡位置)={diePos}"); } v.Init( obj.gold, // goldCount item.posID, // posID(击杀者座位) obj.skillsGold, // paobei(炮倍) obj.fishScore, // fishScore(底分) obj.beishu, // beishu(总倍数) JiaFen, // action(加分回调) () => { v.obj = obj.param; } // 把服务端爆奖参数交给事件类 ); } } }; } // ================================================================ // 受击抖动(对齐 lua Scorpion:OnHit / HitUpdate) // ================================================================ /// 子弹命中回调(MonsterContral.OnHurt,由 BullitMove 命中鱼时调用,virtual 可直接 override)。 public override void OnHurt() { base.OnHurt(); // 保留基类受击表现(hurt 子物体闪一下) // lua HitUpdate:isOut(离场中)不抖 → 这里死亡/离场中同样跳过 if (isDieFish || _isExiting) return; if (_hitJittering) return; // lua: if self.beHit then return(0.2s 内不重复触发) if (_bodyTf == null) ResolveBodyTransform(); if (_bodyTf == null) return; _hitJitterRoutine = StartCoroutine(HitJitterRoutine()); } /// 0.2s 内每帧随机偏移 Body 的 localPosition,结束后还原(对齐 lua Scorpion:HitUpdate) private IEnumerator HitJitterRoutine() { _hitJittering = true; float t = 0f; while (t < HitJitterDuration) { if (_bodyTf == null) break; t += Time.deltaTime; _bodyTf.localPosition = _bodyOriginLocalPos + new Vector3( UnityEngine.Random.Range(-100f, 100f) * HitJitterScaleX, UnityEngine.Random.Range(-50f, 50f) * HitJitterScaleY, 0f); yield return null; } if (_bodyTf != null) _bodyTf.localPosition = _bodyOriginLocalPos; // 还原(预制体 Body.y=0.74,不能写死 0) _hitJittering = false; _hitJitterRoutine = null; } /// 解析 Body 节点并缓存其原始 localPosition(预制体里 Body 带偏移,还原必须用原值) private void ResolveBodyTransform() { Transform t = transform.Find(BodyNodeName); if (t == null) t = FindChildRecursive(transform, BodyNodeName); _bodyTf = t; if (_bodyTf != null) _bodyOriginLocalPos = _bodyTf.localPosition; else Debug.LogWarning($"[JiXieXie] 找不到 {BodyNodeName} 节点,受击抖动跳过"); } /// 停止受击抖动并把 Body 位置还原(回池/销毁时调用,防抖动残留) private void StopHitJitter() { if (_hitJitterRoutine != null) { StopCoroutine(_hitJitterRoutine); _hitJitterRoutine = null; } if (_hitJittering && _bodyTf != null) _bodyTf.localPosition = _bodyOriginLocalPos; _hitJittering = false; } // ================================================================ // Spine 动画切换(对齐 lua PW_Scorpion.txt 的 SkeletonAnimation 状态机) // 全部经 PlaySpineSafe / HasSpineAnim 守卫:动画名缺失或 isSpine=0 时自动跳过 // (回退原 stand2 或保持现状),绝不抛 Spine ArgumentException。 // ⚠️ 当前预制体 PW_Enemy_3000 的 isSpine=0 且 Spine 资源需含 in/stand/attack/out 动画名; // 美术接入 Spine 骨骼(isSpine=1 + 对应动画名)后此逻辑立即生效。 // ================================================================ /// 出生动画序列:in(一次) → 2.2s 后 stand(循环)。无 in 动画时回退基类 stand2。 /// 同时启动攻击循环(OnMoveEnter 在状态切到 move 时必被调用,不受 isMove 闸门影响)。 public override void OnMoveEnter() { if (HasSpineAnim(AnimIn)) { PlaySpineSafe(AnimIn, false); DOVirtual.DelayedCall(BornToStandDelay, () => PlaySpineSafe(AnimStand, true)); } else { base.OnMoveEnter(); } StartAttackCycle(); StartExitTimer(); // ⭐ 出场特效(lua:出现在路线点那一帧创建 PW_Effect_Scorpion_Born,time=3) if (!_bornEffectPlayed) { _bornEffectPlayed = true; PlayBornEffect(); } // ⭐ 退场倒计时 UI(lua OnCreate → ScorpionCountDown:开视图 + EnterAction + 每秒递减) OpenTimerView(); } /// 对象回收/离场时停止攻击与退出计时,避免对已销毁对象触发(DOTween 回调安全守卫)。 public override void OnDisable() { StopAttackCycle(); StopExitTimer(); ResetBodyEffects(); // ⭐ 回池时把三个本体特效连同计时一起复位,防复用残留 StopHitJitter(); // ⭐ 受击抖动协程 + Body 位置还原 // ⭐ 倒计时 UI:没走 DieFish/AutoExit 的滑出就直接销毁(房间关闭/被动回收也保证不残留) if (_timerView != null) CloseTimerView(true); KillTween(ref _attackSoundTween); base.OnDisable(); } // ================================================================ // 自动退出(对齐 lua OnMoveFinish:未被击杀也到点离场) // ================================================================ /// 启动存活计时:到点触发 AutoExit。bossLifeTime<=0 不自动离场(纯靠击杀)。 private void StartExitTimer() { if (bossLifeTime <= 0f) return; _isExiting = false; if (_exitTween != null && _exitTween.IsActive()) _exitTween.Kill(); _exitTween = DOVirtual.DelayedCall(bossLifeTime, AutoExit); } /// 到点离场:停攻击循环 → 播 out(若 Spine 含)→ 延迟回收本体(同 lua OnMoveFinish→DestroyEnemy)。 private void AutoExit() { if (_isExiting) return; _isExiting = true; StopAttackCycle(); float delay = 0f; if (HasSpineAnim(AnimOut)) { PlaySpineSafe(AnimOut, false); delay = 2f; // 对齐 lua PW_Effect_Scorpion_Out 的 time=2 } // ⭐ 离场特效(lua OnMoveFinish:创建 PW_Effect_Scorpion_Out,time=2,播完 DestroyEnemy) PlayOutEffect(); // ⭐ 倒计时 UI 滑出(lua OnMoveFinish → ScorpionTimer:ExitAction) CloseTimerView(false); DOVirtual.DelayedCall(delay, () => { if (this == null) return; if (CreateFishManager.Instance != null) CreateFishManager.Instance.Id_DieFish_NotFX(guid); }); } private void StopExitTimer() { _isExiting = false; if (_exitTween != null && _exitTween.IsActive()) _exitTween.Kill(); _exitTween = null; } /// 攻击循环:随机 10~25s 间隔播放 attack(一次,2s) 后回 stand(对齐 lua stand⇄attack)。 /// 用 DOTween 递归计时,不依赖 OnMoveUpdata / isMove。 /// lua 原逻辑 attack 还需“出场倒计时>5”才触发,C# 侧无该倒计时阶段,故简化为纯随机间隔。 private void StartAttackCycle() { if (!HasSpineAnim(AnimAttack)) return; if (_attackRunning) return; // 防止 OnMoveEnter 重复触发导致多套循环 _attackRunning = true; ScheduleNextAttack(); } private void ScheduleNextAttack() { if (!_attackRunning) return; float delay = UnityEngine.Random.Range(AttackIntervalMin, AttackIntervalMax); _attackTween = DOVirtual.DelayedCall(delay, PlayAttackOnce); } private void PlayAttackOnce() { if (!_attackRunning) return; // ⭐ 原版(PW_Scorpion.txt):只有"出场倒计时 > 5"时才播 attack 动画 + 攻击特效 + 攻击音效; // 倒计时 ≤5 时只走状态记账(isAttack / actionChangeTime),不做任何表现 if (CanAttackVisual) { PlaySpineSafe(AnimAttack, false); // ⭐ 攻击特效(lua:进入攻击动作时创建 PW_Effect_Scorpion_Attack,time=3.5) PlayAttackEffect(); // 原版:isAttackSound && actionChangeTime <= 1 时补播攻击音效(= 动作结束前 1 秒) KillTween(ref _attackSoundTween); _attackSoundTween = DOVirtual.DelayedCall(Mathf.Max(0f, AttackDuration - 1f), PlayAttackSound); } else { Debug.Log($"[JiXieXie] 退场倒计时 {_timerSeconds}s ≤ {AttackVisualMinSeconds}s:本次攻击不播表现(对齐 lua)"); } _attackTween = DOVirtual.DelayedCall(AttackDuration, () => { if (!_attackRunning) return; PlaySpineSafe(AnimStand, true); ScheduleNextAttack(); }); } /// 是否允许攻击表现:有倒计时时需 > 5 秒(对齐 lua scorepionTimerNum > 5);无倒计时时始终允许 private bool CanAttackVisual => !_timerActive || _timerSeconds > AttackVisualMinSeconds; /// 攻击音效(对齐 lua ScorpionAttack + ScorpionAttackVoice,动作结束前 1 秒播) private void PlayAttackSound() { _attackSoundTween = null; AudioManager.Instance.PlayerFXByName("PW_ScorpionAttack"); AudioManager.Instance.PlayerFXByName("PW_ScorpionAttackVoice"); } private void StopAttackCycle() { _attackRunning = false; if (_attackTween != null && _attackTween.IsActive()) _attackTween.Kill(); _attackTween = null; } // ================================================================ // 本体特效(Born / Attack / Out) // 预制体 PW_Enemy_3000 里三个特效已作为根节点子物体挂好(默认 m_IsActive=0), // 代码只负责在 lua 对应的时机 SetActive + 到点隐藏。 // ================================================================ /// /// 解析三个本体特效子物体:Inspector 没拖时按名字查找(先根节点直接找,再递归忽略大小写兜底)。 /// private void ResolveBodyEffects() { if (bornEffect == null) bornEffect = FindEffectByName(BornEffectName); if (attackEffect == null) attackEffect = FindEffectByName(AttackEffectName); if (outEffect == null) outEffect = FindEffectByName(OutEffectName); if (explosionEffect == null) explosionEffect = FindEffectByName(ExplosionEffectName); if (bornEffect == null && attackEffect == null && outEffect == null && explosionEffect == null) Debug.LogWarning("[JiXieXie] 四个本体特效都没找到" + "(预制体应含 PW_Effect_Scorpion_Born/Attack/Out 与 PW_Effect_Explosion_Scorpion 子物体)"); } /// 按名字查找特效子物体(找不到返回 null) private GameObject FindEffectByName(string effectName) { if (string.IsNullOrEmpty(effectName)) return null; Transform t = transform.Find(effectName); if (t == null) t = FindChildRecursive(transform, effectName); return t != null ? t.gameObject : null; } /// 递归查找子物体(忽略大小写,防改名/挪层级后找不到) private static Transform FindChildRecursive(Transform root, string childName) { if (root == null) return null; for (int i = 0; i < root.childCount; i++) { Transform c = root.GetChild(i); if (string.Equals(c.name, childName, StringComparison.OrdinalIgnoreCase)) return c; Transform r = FindChildRecursive(c, childName); if (r != null) return r; } return null; } /// 出场特效:激活 → BornEffectTime(3s) 后自动隐藏(对齐 lua MonentaryEffect time=3) private void PlayBornEffect() { if (bornEffect == null) return; KillTween(ref _bornEffectTween); bornEffect.SetActive(true); _bornEffectTween = DOVirtual.DelayedCall(BornEffectTime, () => { if (bornEffect != null) bornEffect.SetActive(false); _bornEffectTween = null; }); Debug.Log($"[JiXieXie] 出场特效 {BornEffectName} 播放({BornEffectTime}s 后隐藏)"); } /// 攻击特效:激活 → AttackEffectTime(3.5s) 后自动隐藏(对齐 lua MonentaryEffect time=3.5) private void PlayAttackEffect() { if (attackEffect == null) return; KillTween(ref _attackEffectTween); attackEffect.SetActive(true); _attackEffectTween = DOVirtual.DelayedCall(AttackEffectTime, () => { if (attackEffect != null) attackEffect.SetActive(false); _attackEffectTween = null; }); Debug.Log($"[JiXieXie] 攻击特效 {AttackEffectName} 播放({AttackEffectTime}s 后隐藏)"); } /// 离场特效:激活后一直显示到本体回收(lua Out time=2,与 AutoExit 的 2s 延迟一致) private void PlayOutEffect() { if (outEffect == null) return; outEffect.SetActive(true); Debug.Log($"[JiXieXie] 离场特效 {OutEffectName} 播放"); } /// /// 被杀爆炸特效(lua OnExplosion:在死亡位置创建 PW_Effect_Explosion_Scorpion,time=1)。 /// /// ⚠️ 它是本体的子物体,而本类 DieFish 里 Boss 当帧就会被回收(回池会连带停掉子物体), /// 所以先把特效脱离父节点(保留世界变换)让它能完整播完 1 秒, /// 1 秒后再挂回本体 + 还原预制体原始局部变换 + 隐藏,保证对象池复用时结构完整。 /// 爆炸特效不放进 ResetBodyEffects(),避免回池时把它一起关掉。 /// private void PlayExplosionEffect() { if (explosionEffect == null) return; // 上一次爆炸还没收尾(1s 内又死一次)→ 先收尾,避免层级被越挂越上 KillTween(ref _explosionEffectTween); RestoreExplosionEffectParent(); Transform t = explosionEffect.transform; _explosionEffectParent = t.parent; // 原父节点 = 本体根节点 _explosionEffectLocalPos = t.localPosition; // 预制体原始局部变换,挂回时还原 _explosionEffectLocalRot = t.localRotation; _explosionEffectLocalScale = t.localScale; Transform host = _explosionEffectParent != null ? _explosionEffectParent.parent : null; t.SetParent(host, true); // ⭐ 脱离本体:世界变换不变,回池时不被一起停掉 explosionEffect.SetActive(true); Debug.Log($"[JiXieXie] 被杀爆炸特效 {ExplosionEffectName} 播放({ExplosionEffectTime}s 后回收)"); _explosionEffectTween = DOVirtual.DelayedCall(ExplosionEffectTime, () => { _explosionEffectTween = null; RestoreExplosionEffectParent(); }); } /// 爆炸特效收尾:隐藏 + 挂回本体并还原原始局部变换(计时回调/重复触发前共用) private void RestoreExplosionEffectParent() { if (explosionEffect == null) return; explosionEffect.SetActive(false); Transform t = explosionEffect.transform; if (_explosionEffectParent == null) { // 本体已销毁:脱离出来的实例没人回收了,直接删掉防残留 Destroy(explosionEffect); return; } if (t.parent == _explosionEffectParent) return; t.SetParent(_explosionEffectParent, false); t.localPosition = _explosionEffectLocalPos; t.localRotation = _explosionEffectLocalRot; t.localScale = _explosionEffectLocalScale; } // ================================================================ // 退场倒计时 UI(对齐 lua pw_view/PW_ScorpionTimerView.txt) // ================================================================ /// /// 打开/重建退场倒计时 UI(lua OnCreate → ScorpionCountDown + EnterAction)。 /// 倒计时秒数 = 距离自动离场(bossLifeTime)的剩余秒数;bossLifeTime<=0 时不显示。 /// private void OpenTimerView() { if (timerViewPrefab == null) { Debug.LogWarning("[JiXieXie] timerViewPrefab 未赋值(PW_ScorpionTimerView)," + "退场倒计时不显示(攻击表现也不受 5 秒门限限制)"); return; } if (bossLifeTime <= 0f) { Debug.LogWarning("[JiXieXie] bossLifeTime<=0(不自动离场),退场倒计时不显示"); return; } if (_timerView != null) CloseTimerView(true); // 复用残留先清掉 Transform parent = ResolveTimerUiParent(); if (parent == null) { Debug.LogWarning("[JiXieXie] 找不到 UI 层(subPanelParent / uiRoot),退场倒计时不显示"); return; } _timerView = Instantiate(timerViewPrefab, parent); _timerView.name = "PW_ScorpionTimerView"; Transform node = FindChildRecursive(_timerView.transform, TimerViewNodeName); _timerGONode = node as RectTransform; if (_timerGONode == null) Debug.LogWarning($"[JiXieXie] 倒计时 UI 里找不到 {TimerViewNodeName},滑入/滑出动画跳过"); // ⭐ 原预制体的文字组件是缺失脚本(原版自定义组件未导入本项目),这里运行时补 TMP 再填字 Transform labelTf = FindChildRecursive(_timerView.transform, TimerViewLabelName); Transform textTf = FindChildRecursive(_timerView.transform, TimerViewTextName); EnsureTmpText(labelTf, timerLabelFontSize, TimerViewLabel); _timerText = EnsureTmpText(textTf, timerValueFontSize, null); _timerSeconds = Mathf.CeilToInt(bossLifeTime); _timerActive = true; SetTimerText(_timerSeconds); ScheduleTimerTick(); PlayTimerEnter(); // lua EnterAction Debug.Log($"[JiXieXie] 退场倒计时开始:{_timerSeconds}s(剩余 ≤{AttackVisualMinSeconds}s 时不再攻击)"); } /// /// 关闭倒计时 UI(lua ExitAction:向右滑出)。 /// /// ⭐ 这里是"倒计时整块一定会消失"的唯一保证点,改成了"必定销毁": /// 旧写法把销毁挂在滑出 tween 的 OnComplete 上,而且"已在滑出中就直接 return", /// 只要那根 tween 在 0.3s 内被中途 Kill(滑出期间 Boss 被复用、再次 OpenTimerView、 /// 或任何 KillTween(ref _timerMoveTween)),OnComplete 就永不执行 → /// 倒计时整块永久残留在屏幕上(鱼死了也不消失)。 /// 现在:立刻摘掉字段引用 + 用不挂在任何对象上的 DOVirtual 兜底计时,到点无条件销毁。 /// 顺带修掉"旧 tween 的 OnComplete 误销毁新视图"的隐患(引用改成本地捕获,不再读字段)。 /// private void CloseTimerView(bool instant) { KillTween(ref _timerTickTween); KillTween(ref _attackSoundTween); _timerActive = false; var view = _timerView; if (view == null) { _timerGONode = null; _timerText = null; return; } var node = _timerGONode; // ⭐ 先摘掉所有字段引用:之后不管是复用、回池还是再次开倒计时,都不会再操作这块 UI _timerView = null; _timerGONode = null; _timerText = null; KillTween(ref _timerMoveTween); if (instant || node == null) { Destroy(view); return; } // 滑出动画(lua ExitAction:localMoveBy(250, 50, 0.3, EInBack)) _timerMoveTween = node .DOAnchorPos(node.anchoredPosition + new Vector2(TimerViewExitDeltaX, TimerViewExitDeltaY), TimerViewExitTime) .SetEase(Ease.InBack); // ⭐ 兜底销毁:DOVirtual 不挂在任何对象上,Boss 被禁用/回收/销毁都不影响它,到点无条件销毁 var viewToDestroy = view; DOVirtual.DelayedCall(TimerViewExitTime + 0.1f, () => { if (viewToDestroy != null) Destroy(viewToDestroy); }); } /// 滑入动画(lua EnterAction:先归位 (0,50),再 localMoveBy(-250, 50, 0.5, EOutBack)) private void PlayTimerEnter() { if (_timerGONode == null) return; Vector2 start = new Vector2(TimerViewStartX, TimerViewStartY); _timerGONode.anchoredPosition = start; KillTween(ref _timerMoveTween); _timerMoveTween = _timerGONode .DOAnchorPos(start + new Vector2(TimerViewEnterDeltaX, TimerViewEnterDeltaY), TimerViewEnterTime) .SetEase(Ease.OutBack); } /// 每秒递减(lua Update:deltaTimeNum 累积 ≥1 → TimerUpdate) private void ScheduleTimerTick() { KillTween(ref _timerTickTween); _timerTickTween = DOVirtual.DelayedCall(1f, TimerTick); } private void TimerTick() { _timerTickTween = null; if (!_timerActive || _timerView == null) return; _timerSeconds--; if (_timerSeconds >= 0) SetTimerText(_timerSeconds); if (_timerSeconds > 0) ScheduleTimerTick(); } /// 刷新倒计时文本(lua SetTimerText:≤0 → "00 : 00",否则 "MM : SS" 补零) private void SetTimerText(int seconds) { if (_timerText == null) return; if (seconds <= 0) { _timerText.text = "00 : 00"; return; } int m = seconds / 60; int s = seconds % 60; _timerText.text = (m < 10 ? "0" + m : m.ToString()) + " : " + (s < 10 ? "0" + s : s.ToString()); } /// 给原预制体的文本节点补 TMP 组件(已有 TMP 直接复用),返回该组件 private TMPro.TextMeshProUGUI EnsureTmpText(Transform tf, float fontSize, string text) { if (tf == null) return null; var tmp = tf.GetComponent(); if (tmp == null) { var legacy = tf.GetComponent(); // 兜底:万一节点上是 UGUI Text,先关掉避免叠字 if (legacy != null) legacy.enabled = false; tmp = tf.gameObject.AddComponent(); } // ⭐ 铁律:运行时 AddComponent 的文本必须显式设字体,否则不显示 tmp.font = timerFont != null ? timerFont : TMPro.TMP_Settings.defaultFontAsset; tmp.fontSize = fontSize; tmp.alignment = TMPro.TextAlignmentOptions.Center; tmp.color = Color.white; tmp.raycastTarget = false; if (!string.IsNullOrEmpty(text)) tmp.text = text; return tmp; } /// 倒计时 UI 的父节点:游戏主 UI 面板层 → UIManager.uiRoot 兜底 private Transform ResolveTimerUiParent() { if (CreateFishManager.Instance != null && CreateFishManager.Instance.currGameType != null && CreateFishManager.Instance.currGameType.subPanelParent != null) return CreateFishManager.Instance.currGameType.subPanelParent.parent; if (UIManager.Instance != null) return UIManager.Instance.uiRoot; return null; } /// 复位本体特效:停掉隐藏计时 + 全部隐藏(回池/复用/离场时调用;爆炸特效单独收尾) private void ResetBodyEffects() { KillTween(ref _bornEffectTween); KillTween(ref _attackEffectTween); if (bornEffect != null) bornEffect.SetActive(false); if (attackEffect != null) attackEffect.SetActive(false); if (outEffect != null) outEffect.SetActive(false); } /// 安全停掉一个 DOTween 计时(防止对已销毁对象触发回调) private static void KillTween(ref Tween tween) { if (tween != null && tween.IsActive()) tween.Kill(); tween = null; } /// Spine 动画是否存在(动画名或 Skeleton 缺失时返回 false)。 private bool HasSpineAnim(string name) { if (skeleton == null) return false; var data = skeleton.SkeletonDataAsset?.GetSkeletonData(false); return data != null && data.FindAnimation(name) != null; } /// 安全播放 Spine 动画:动画名不存在 / 非 Spine 时静默跳过,避免 Spine 抛 ArgumentException。 private void PlaySpineSafe(string name, bool loop) { if (skeleton == null) return; var data = skeleton.SkeletonDataAsset?.GetSkeletonData(false); if (data == null || data.FindAnimation(name) == null) return; skeleton.AnimationState.SetAnimation(0, name, loop); } /// 本体被销毁时清理:倒计时 UI 是独立挂 UI 层的对象,必须一起销毁防残留。 private void OnDestroy() { StopAllCoroutines(); StopHitJitter(); KillTween(ref _timerTickTween); KillTween(ref _timerMoveTween); KillTween(ref _attackSoundTween); KillTween(ref _bornEffectTween); KillTween(ref _attackEffectTween); KillTween(ref _explosionEffectTween); if (_timerView != null) { Destroy(_timerView); _timerView = null; _timerGONode = null; _timerText = null; } _timerActive = false; } }