Files
5.24BuYu/Assets/MyGame/aa/Game/XJMH/Boss/JiXieXie/JiXieXie_BOSS.cs
T
2026-09-30 05:32:40 +08:00

822 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Assets.MyGame.Scripts.Net.APIResponses.Enum;
using Assets.MyGame.Scripts.Net.WebResponses;
using DG.Tweening;
using System;
using System.Collections;
using UnityEngine;
/// <summary>
/// 机械蝎 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。
/// </summary>
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 实例
private RectTransform _timerGONode; // 参与滑入/滑出的节点(mGONode)
private TMPro.TextMeshProUGUI _timerText;
private int _timerSeconds; // 当前倒计时秒数(= 距离自动离场的剩余秒数)
private bool _timerActive; // 倒计时是否生效(决定攻击表现的 5 秒门限)
private bool _timerExiting; // 正在播滑出动画(此时不要立刻销毁,等回调)
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;
_timerExiting = 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<BJEventBase>();
// 先设置死亡位置,再 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<int>(加分回调)
() => { v.obj = obj.param; } // 把服务端爆奖参数交给事件类
);
}
}
};
}
// ================================================================
// 受击抖动(对齐 lua Scorpion:OnHit / HitUpdate)
// ================================================================
/// <summary>子弹命中回调(MonsterContral.OnHurt,由 BullitMove 命中鱼时调用,virtual 可直接 override)。</summary>
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());
}
/// <summary>0.2s 内每帧随机偏移 Body 的 localPosition,结束后还原(对齐 lua Scorpion:HitUpdate)</summary>
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;
}
/// <summary>解析 Body 节点并缓存其原始 localPosition(预制体里 Body 带偏移,还原必须用原值)</summary>
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} 节点,受击抖动跳过");
}
/// <summary>停止受击抖动并把 Body 位置还原(回池/销毁时调用,防抖动残留)</summary>
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 + 对应动画名)后此逻辑立即生效。
// ================================================================
/// <summary>出生动画序列:in(一次) → 2.2s 后 stand(循环)。无 in 动画时回退基类 stand2。
/// 同时启动攻击循环(OnMoveEnter 在状态切到 move 时必被调用,不受 isMove 闸门影响)。</summary>
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();
}
/// <summary>对象回收/离场时停止攻击与退出计时,避免对已销毁对象触发(DOTween 回调安全守卫)。</summary>
public override void OnDisable()
{
StopAttackCycle();
StopExitTimer();
ResetBodyEffects(); // ⭐ 回池时把三个本体特效连同计时一起复位,防复用残留
StopHitJitter(); // ⭐ 受击抖动协程 + Body 位置还原
// ⭐ 倒计时 UI:若没走 DieFish/AutoExit 的滑出(例如房间关闭被动回收)就立即销毁,防残留
if (_timerView != null && !_timerExiting) CloseTimerView(true);
KillTween(ref _attackSoundTween);
base.OnDisable();
}
// ================================================================
// 自动退出(对齐 lua OnMoveFinish:未被击杀也到点离场)
// ================================================================
/// <summary>启动存活计时:到点触发 AutoExit。bossLifeTime<=0 不自动离场(纯靠击杀)。</summary>
private void StartExitTimer()
{
if (bossLifeTime <= 0f) return;
_isExiting = false;
if (_exitTween != null && _exitTween.IsActive()) _exitTween.Kill();
_exitTween = DOVirtual.DelayedCall(bossLifeTime, AutoExit);
}
/// <summary>到点离场:停攻击循环 → 播 out(若 Spine 含)→ 延迟回收本体(同 lua OnMoveFinish→DestroyEnemy)。</summary>
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;
}
/// <summary>攻击循环:随机 10~25s 间隔播放 attack(一次,2s) 后回 stand(对齐 lua stand⇄attack)。
/// 用 DOTween 递归计时,不依赖 OnMoveUpdata / isMove。
/// lua 原逻辑 attack 还需“出场倒计时>5”才触发,C# 侧无该倒计时阶段,故简化为纯随机间隔。</summary>
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();
});
}
/// <summary>是否允许攻击表现:有倒计时时需 > 5 秒(对齐 lua scorepionTimerNum > 5);无倒计时时始终允许</summary>
private bool CanAttackVisual => !_timerActive || _timerSeconds > AttackVisualMinSeconds;
/// <summary>攻击音效(对齐 lua ScorpionAttack + ScorpionAttackVoice,动作结束前 1 秒播)</summary>
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 + 到点隐藏。
// ================================================================
/// <summary>
/// 解析三个本体特效子物体:Inspector 没拖时按名字查找(先根节点直接找,再递归忽略大小写兜底)。
/// </summary>
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 子物体)");
}
/// <summary>按名字查找特效子物体(找不到返回 null)</summary>
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;
}
/// <summary>递归查找子物体(忽略大小写,防改名/挪层级后找不到)</summary>
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;
}
/// <summary>出场特效:激活 → BornEffectTime(3s) 后自动隐藏(对齐 lua MonentaryEffect time=3)</summary>
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 后隐藏)");
}
/// <summary>攻击特效:激活 → AttackEffectTime(3.5s) 后自动隐藏(对齐 lua MonentaryEffect time=3.5)</summary>
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 后隐藏)");
}
/// <summary>离场特效:激活后一直显示到本体回收(lua Out time=2,与 AutoExit 的 2s 延迟一致)</summary>
private void PlayOutEffect()
{
if (outEffect == null) return;
outEffect.SetActive(true);
Debug.Log($"[JiXieXie] 离场特效 {OutEffectName} 播放");
}
/// <summary>
/// 被杀爆炸特效(lua OnExplosion:在死亡位置创建 PW_Effect_Explosion_Scorpion,time=1)。
///
/// ⚠️ 它是本体的子物体,而本类 DieFish 里 Boss 当帧就会被回收(回池会连带停掉子物体),
/// 所以先把特效脱离父节点(保留世界变换)让它能完整播完 1 秒,
/// 1 秒后再挂回本体 + 还原预制体原始局部变换 + 隐藏,保证对象池复用时结构完整。
/// 爆炸特效不放进 ResetBodyEffects(),避免回池时把它一起关掉。
/// </summary>
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();
});
}
/// <summary>爆炸特效收尾:隐藏 + 挂回本体并还原原始局部变换(计时回调/重复触发前共用)</summary>
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)
// ================================================================
/// <summary>
/// 打开/重建退场倒计时 UI(lua OnCreate → ScorpionCountDown + EnterAction)。
/// 倒计时秒数 = 距离自动离场(bossLifeTime)的剩余秒数;bossLifeTime<=0 时不显示。
/// </summary>
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;
_timerExiting = false;
SetTimerText(_timerSeconds);
ScheduleTimerTick();
PlayTimerEnter(); // lua EnterAction
Debug.Log($"[JiXieXie] 退场倒计时开始:{_timerSeconds}s(剩余 ≤{AttackVisualMinSeconds}s 时不再攻击)");
}
/// <summary>关闭倒计时 UI(lua ExitAction:向右滑出);instant=true 时立即销毁</summary>
private void CloseTimerView(bool instant)
{
KillTween(ref _timerTickTween);
KillTween(ref _attackSoundTween);
_timerActive = false;
if (_timerView == null)
{
_timerGONode = null;
_timerText = null;
return;
}
if (instant || _timerGONode == null)
{
KillTween(ref _timerMoveTween);
Destroy(_timerView);
_timerView = null; _timerGONode = null; _timerText = null;
_timerExiting = false;
return;
}
if (_timerExiting) return; // 已在滑出中
_timerExiting = true;
KillTween(ref _timerMoveTween);
_timerMoveTween = _timerGONode
.DOAnchorPos(_timerGONode.anchoredPosition + new Vector2(TimerViewExitDeltaX, TimerViewExitDeltaY), TimerViewExitTime)
.SetEase(Ease.InBack)
.OnComplete(() =>
{
_timerMoveTween = null;
if (_timerView != null) Destroy(_timerView);
_timerView = null; _timerGONode = null; _timerText = null;
_timerExiting = false;
});
}
/// <summary>滑入动画(lua EnterAction:先归位 (0,50),再 localMoveBy(-250, 50, 0.5, EOutBack))</summary>
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);
}
/// <summary>每秒递减(lua Update:deltaTimeNum 累积 ≥1 → TimerUpdate)</summary>
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();
}
/// <summary>刷新倒计时文本(lua SetTimerText:≤0 → "00 : 00",否则 "MM : SS" 补零)</summary>
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());
}
/// <summary>给原预制体的文本节点补 TMP 组件(已有 TMP 直接复用),返回该组件</summary>
private TMPro.TextMeshProUGUI EnsureTmpText(Transform tf, float fontSize, string text)
{
if (tf == null) return null;
var tmp = tf.GetComponent<TMPro.TextMeshProUGUI>();
if (tmp == null)
{
var legacy = tf.GetComponent<UnityEngine.UI.Text>(); // 兜底:万一节点上是 UGUI Text,先关掉避免叠字
if (legacy != null) legacy.enabled = false;
tmp = tf.gameObject.AddComponent<TMPro.TextMeshProUGUI>();
}
// ⭐ 铁律:运行时 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;
}
/// <summary>倒计时 UI 的父节点:游戏主 UI 面板层 → UIManager.uiRoot 兜底</summary>
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;
}
/// <summary>复位本体特效:停掉隐藏计时 + 全部隐藏(回池/复用/离场时调用;爆炸特效单独收尾)</summary>
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);
}
/// <summary>安全停掉一个 DOTween 计时(防止对已销毁对象触发回调)</summary>
private static void KillTween(ref Tween tween)
{
if (tween != null && tween.IsActive()) tween.Kill();
tween = null;
}
/// <summary>Spine 动画是否存在(动画名或 Skeleton 缺失时返回 false)。</summary>
private bool HasSpineAnim(string name)
{
if (skeleton == null) return false;
var data = skeleton.SkeletonDataAsset?.GetSkeletonData(false);
return data != null && data.FindAnimation(name) != null;
}
/// <summary>安全播放 Spine 动画:动画名不存在 / 非 Spine 时静默跳过,避免 Spine 抛 ArgumentException。</summary>
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);
}
/// <summary>本体被销毁时清理:倒计时 UI 是独立挂 UI 层的对象,必须一起销毁防残留。</summary>
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;
}
}