Files
5.24BuYu/Assets/MyGame/Scripts/Characters/Monster/MonsterContral.cs
T
2026-09-18 08:15:08 +08:00

1285 lines
57 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.
// ============================================================
// 文件:MonsterContral.cs
// 职责:鱼(怪物)实体基类 —— 状态机、路径移动、动画、死亡处理
// 优化点:
// 1. 方向朝向逻辑提取为独立方法 ApplyFacingDirection,消除大量重复 switch
// 2. OnMoveUpdata 按 isyz 分支,各分支内复用 ApplyFacingDirection
// 3. OnDestroy 统一清理三个字典,去除重复判断
// 4. 常量命名使用 PascalCase,符合 C# 规范
// 5. 补充 XML 注释,明确方法职责
// 6. forwordXYZ 空检查改用 string.IsNullOrEmpty
// ============================================================
using Assets.MyGame.Scripts.Net.API.GameConfig_SubClass;
using Assets.MyGame.Scripts.Net.APIResponses.Enum;
using Assets.MyGame.Scripts.Net.WebResponses;
using Spine.Unity;
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using static MonsterMoveLine;
/// <summary>
/// 鱼实体基类:封装状态机(移动/待机)、B样曲线路径跟随(原贝塞尔已切B样条)、动画播放、死亡逻辑。
/// 具体鱼类型(Boss等)通过继承重写虚方法扩展行为。
/// </summary>
public class MonsterContral : MonoBehaviour
{
// ----------------------------------------------------------------
// 常量
// ----------------------------------------------------------------
/// <summary>鱼阵路线 key 前缀</summary>
private const string YzMoveLineName = "鱼阵";
/// <summary>普通鱼路线 key 前缀</summary>
private const string NormalMoveLineName = "曲线";
// ----------------------------------------------------------------
// 公开字段(Inspector 可见 / 外部赋值)
// ----------------------------------------------------------------
/// <summary>
/// 基础倍率
/// </summary>
public int beilv;
/// <summary>本鱼唯一标识(服务器下发的 GUID)</summary>
public string guid;
/// <summary>
/// ⭐ 是否已完成 Init(路线/guid/moveTime 已赋值)。
/// false=池回收后未初始化,OnEnable 不应执行 OnMoveUpdata,避免旧 moveTime/moveLine 误触发销毁
/// true=Init 已调用,可以正常 Update
/// </summary>
protected bool isInitialized = false;
/// <summary>当前鱼使用的路线 ID(Init 时保存,用于房主把活鱼广播给新加入玩家)</summary>
public string lastMoveLineId;
/// <summary>运行时数据(血量、路径、配置等)</summary>
public MonsterDataBase monsterData = new MonsterDataBase(new MonsterHealthBase());
/// <summary>Spine 骨骼动画组件(非 Spine 鱼可为 null)</summary>
public SkeletonAnimation skeleton;
/// <summary>是否使用 Spine 骨骼动画</summary>
public bool isSpine = true;
/// <summary>鱼类型 ID(对应 FishResourcesData 索引)</summary>
public int fishTypeID;
/// <summary>动画状态 → 动画名称 映射</summary>
public Dictionary<CharacterAnimationType, string> dic_AnimKeyAnimName
= new Dictionary<CharacterAnimationType, string>();
/// <summary>当前状态</summary>
public CharacterStateType currState;
/// <summary>血量(目前未使用扣血逻辑,保留供扩展)</summary>
public int HP = 5;
/// <summary>死亡粒子特效 Prefab(为 null 时使用 Manager 默认值)</summary>
public GameObject effect_Die;
/// <summary>死亡金币奖励特效 Prefab</summary>
public GameObject effect_DieAward;
public GameObject currEffect_DieAward;
public Action<GameObject> action_effect_DieAward;
/// <summary>是否处于移动状态(false 时 Update 跳过移动计算)</summary>
public bool isMove = true;
/// <summary>设置冰冻特效激活状态</summary>
public void SetFreezeEffect(bool active)
{
if (bingDongEffect == null)
return;
for (int i = 0; i < bingDongEffect.Count; i++)
{
if (bingDongEffect[i] != null)
bingDongEffect[i].SetActive(active);
}
}
/// <summary>是否为 3D 鱼(影响朝向轴选取)</summary>
public bool is3D = false;
/// <summary>
/// 前进方向轴,可选 "X" / "-X" / "Y" / "-Y" / "Z" / "-Z"。
/// 为空时默认使用 up(2D)或 forward(3D)。
/// ⭐ 运行时不应修改;如需修改请调用 RefreshFacingAxis() 重新解析。
/// </summary>
public string forwordXYZ;
/// <summary>
/// forwordXYZ 预解析后的朝向轴枚举(OnEnable 时解析)。
/// 避免 ApplyFacingDirection 每帧每鱼做 string.IsNullOrEmpty + switch(string)。
/// </summary>
private FacingAxis _facingAxis = FacingAxis.None;
/// <summary>朝向轴枚举:对应 forwordXYZ 的 7 种取值</summary>
private enum FacingAxis
{
None, // "" / null
X,
NegX, // "-X"
Y,
NegY, // "-Y"
Z,
NegZ // "-Z"
}
/// <summary>
/// 将 forwordXYZ 字符串解析为枚举。OnEnable 调用一次,运行时直接用枚举 switch。
/// </summary>
private void RefreshFacingAxis()
{
if (string.IsNullOrEmpty(forwordXYZ))
{
_facingAxis = FacingAxis.None;
return;
}
switch (forwordXYZ)
{
case "X": _facingAxis = FacingAxis.X; break;
case "-X": _facingAxis = FacingAxis.NegX; break;
case "Y": _facingAxis = FacingAxis.Y; break;
case "-Y": _facingAxis = FacingAxis.NegY; break;
case "Z": _facingAxis = FacingAxis.Z; break;
case "-Z": _facingAxis = FacingAxis.NegZ; break;
default: _facingAxis = FacingAxis.None; break;
}
}
/// <summary>
/// 缓存 yzID 前3字符前缀。Init 时调用一次(普通鱼在 base.Init,Boss 在自己 Init)。
/// 避免 UpdateYZMovement 每帧每鱼 yzID.Substring(0,3) 产生字符串 GC 分配。
/// </summary>
protected void SetYzPrefix(string id)
{
_yzPrefix = (!string.IsNullOrEmpty(id) && id.Length >= 3) ? id.Substring(0, 3) : "";
}
/// <summary>鱼类型枚举(缓存,避免反复类型转换)</summary>
public FishTypeEnum fishenum;
/// <summary>
/// 是否为"按路线奇偶翻转X轴朝向"的Boss(Boss本体或BossBase子类,且未配置forwordXYZ自定义朝向轴)。
/// 这类Boss不随路线切线旋转,朝向只由路线方向决定:
/// 路线单数=左→右(X保持预制体正向),路线双数=右→左(X取负镜像)。
/// </summary>
protected bool IsRouteFlipBoss =>
(fishenum == FishTypeEnum.Boss鱼 || this is MonsterControl_BossBase)
&& string.IsNullOrEmpty(forwordXYZ);
/// <summary>
/// ⭐ Boss路线朝向:按路线编号奇偶翻转X轴。
/// 单数路线=左→右 → X保持预制体原始值不变
/// 双数路线=右→左 → X取预制体原始值的负数(镜像)
/// useRouteFlipX=false 时不镜像,X始终保持正向(特殊怪物用)。
/// 以预制体原始X缩放为基准绝对赋值,对象池复用后重新Init不会叠加翻转。
/// 路线编号非数字时不翻转(保持原朝向)。
/// </summary>
protected void ApplyBossRouteFlip(string pathId)
{
if (!int.TryParse(pathId, out int routeNum)) return;
float dirX = (useRouteFlipX && routeNum % 2 == 0) ? -1f : 1f;
var scale = transform.localScale;
scale.x = _prefabScaleX * dirX;
transform.localScale = scale;
}
/// <summary>路径已行进的时间(秒),用于B样曲线插值;可由外部预设偏移实现同步追帧。</summary>
public float moveTime = 0f;
/// <summary>是否为鱼阵成员</summary>
public bool isyz = false;
/// <summary>所属鱼阵 ID(空字符串表示普通鱼)</summary>
public string yzID = "";
// ----------------------------------------------------------------
// ⭐ 缓存优化字段
// ----------------------------------------------------------------
/// <summary>缓存的子物体(朝向控制用),避免每帧 transform.GetChild(0)</summary>
private Transform _cachedChild;
// ----------------------------------------------------------------
// ⭐ 鱼阵 700/701 直线移动缓存
// 同一鱼阵起点相同,Init 时一次性算好世界坐标 + 方向系数,
// UpdateYZMovement 每帧直接用,避免每帧每鱼 CanvasToWorld + 条件判断。
// ----------------------------------------------------------------
private bool _isYzLineMove; // 是否为 700/701 直线阵
private Vector3 _yzStartWorld; // 直线阵起点世界坐标
private float _yzLineDir; // 直线阵方向系数 (-1 向上 / +1 向下)
private bool _yzLineInited; // 直线阵缓存是否已初始化
/// <summary>扇形(700)/圈(701)分布角方向:Init时按轴读生成姿态前方的XY投影,每条鱼各自不同</summary>
private Vector2 _yzMoveDir = Vector2.zero;
/// <summary>
/// yzID 前3字符前缀缓存(Init 时 Substring 一次)。
/// 避免 UpdateYZMovement 每帧每鱼 yzID.Substring(0,3) 产生字符串 GC 分配。
/// </summary>
private string _yzPrefix = "";
// ----------------------------------------------------------------
// 私有字段
// ----------------------------------------------------------------
/// <summary>受击闪烁特效对象(在子物体中查找 "HitEffect")</summary>
private GameObject hurt;
/// <summary>UI 根节点 RectTransform(坐标转换用)</summary>
protected RectTransform uirootRect;
/// <summary>主相机缓存</summary>
protected Camera mainCamera;
public List<Transform> colliderList;
/// <summary>锁定点(colliderList 为空时使用,null 则用自身坐标)</summary>
public Transform lockPoint;
/// <summary>手动锁定时记录被点击的碰撞器,用于锁定锚点优先级(运行时赋值,不序列化)</summary>
[System.NonSerialized]
public Transform hitCollider;
/// <summary>冰冻特效(预制体内子物体列表,null/空则跳过)</summary>
public List<GameObject> bingDongEffect;
[Header("锁定控制(可选)")]
[Tooltip("是否需要锁定事件控制。默认false=一直可锁定;true=出场/切换状态期间不可锁定,需调用 NotifyLockable() 后才可锁定")]
public bool needLockEvent = false;
/// <summary>当前是否可锁定。
/// - needLockEvent=false → 一直 true
/// - needLockEvent=true → 初始 false,等 NotifyLockable() 调用后变 true
/// ⭐ 用于嫦娥(出场→移动两步)和小白龙(多动画片段切换)等特殊鱼
/// </summary>
[System.NonSerialized]
public bool isLockable = true;
/// <summary>
/// ⭐ 鱼是否已死亡(收到服务端击杀通知/离场销毁后为 true)。
/// 死亡后到回收前有特效播放窗口期,此期间:
/// - 子弹(BullitMove)不再命中它(碰撞/锁定追踪都跳过)
/// - 不再被锁定(GameMainPanel_FishBase.IsMonsterLockable 返回 false)
/// OnEnable 池复用时重置为 false。
/// </summary>
[System.NonSerialized]
public bool isDead = false;
/// <summary>可锁定事件:needLockEvent=true 的鱼首次可锁定时触发,GameMainPanel 订阅用于自动锁定</summary>
public event Action<MonsterContral> OnLockable;
/// <summary>
/// 通知此鱼现在可以锁定了。needLockEvent=true 的鱼在出场结束/状态切换完成时调用。
/// </summary>
public void NotifyLockable()
{
if (!isLockable)
{
isLockable = true;
OnLockable?.Invoke(this);
}
}
/// <summary>
/// 切换锁定点(不切换锁定对象)。
/// ⭐ 用于小白龙等切换模型/动画片段后锁定点也随之变化的情况:
/// 重新从 colliderList 找第一个激活的碰撞器,更新为新的锁定锚点。
/// 如果当前没有被锁定则不做任何事。
/// 调用方(GameMainPanel_FishBase)负责更新 monCotl_Lock_Position。
/// </summary>
public Transform SwitchLockPoint()
{
// 优先用 lockPoint(若激活)
if (lockPoint != null && lockPoint.gameObject.activeInHierarchy)
return lockPoint;
// 其次 colliderList 第一个激活的
if (colliderList != null)
{
for (int i = 0; i < colliderList.Count; i++)
{
var col = colliderList[i];
if (col != null && col.gameObject.activeInHierarchy)
return col;
}
}
return null;
}
[Header("创建视频(可选)")]
[Tooltip("鱼创建时播放的入场视频片段,播放结束后才开始移动。为空则无入场视频。播放控件由 GameMainPanel_FishBase 提供")]
public UnityEngine.Video.VideoClip createVideoClip;
/// <summary>createVideoClip 实际播放时长(秒),用于计算 moveTime 偏移。<=0 表示未播放/无视频</summary>
protected float _createVideoDuration = 0f;
/// <summary>当前是否正在播放创建视频(由外部播放器 isPlaying 判断)</summary>
protected bool _isPlayingCreateVideo = false;
/// <summary>是否存活(活跃且未被回收)</summary>
public bool IsAlive => gameObject.activeInHierarchy;
public int rotationPY = 60;
[Tooltip("反面补偿:掉头(跨左右中线)时绕鱼头轴z翻180-z,保持透视方向/背朝向一致。往返路线开;环形路线绕圈会跨中线两次导致跳变闪,环形路线的鱼关掉(全程连续转不翻面)")]
public bool useMirrorCompensate = true;
[Tooltip("Z轴侧倾按左右符号翻转(原始语义):向左→+rotationPY/向右→-rotationPY,纯符号切换无180°翻转。true=启用此模式(忽略反面补偿);false=走useMirrorCompensate的180°反面补偿")]
public bool useRollSignFlip = false;
[Tooltip("Boss路线奇偶翻转X轴:单数路线(左→右)保持正向,双数路线(右→左)X取-1镜像。默认开;特殊怪物不需要镜像时关闭")]
public bool useRouteFlipX = true;
// ================================================================
// Unity 生命周期
// ================================================================
public bool baseMmove;
/// <summary>预制体原始X缩放(Awake缓存):Boss按路线奇偶翻转X轴的基准,池复用后重新Init不会叠加翻转</summary>
private float _prefabScaleX = 1f;
public virtual void Awake()
{
LoadHitEffect();
baseMmove = isMove;
_prefabScaleX = transform.localScale.x;
}
/// <summary>
/// 查找受击特效子物体并强制隐藏。
/// 原为 async void(无 await 实际同步执行),现改为普通方法;
/// Awake 和每次 OnEnable 都会调用,保证池回收后不残留激活状态。
/// </summary>
public void LoadHitEffect()
{
// 查找受击特效子物体并默认隐藏
var hitTra = FindDeepChild(transform, "HitEffect");
if (hitTra != null)
{
hurt = hitTra.gameObject;
hurt.SetActive(false);
}
}
public virtual void OnEnable()
{
// ⭐ 每次 OnEnable 重新查找并强制隐藏受击特效。
// 根因:HurtCoroutine 激活 hurt 后等 0.2s 再关闭,若鱼在这期间被
// 击杀回收(GO 停用 → 协程中断),hurt 会残留激活状态;
// 而 LoadHitEffect 原来只在 Awake 调用一次(池化对象不重跑 Awake),
// 重新生成时不会恢复 → 鱼带着受击特效出场。
// 另外池回收的鱼 transform 可能更换子层级,重新查找更稳妥。
LoadHitEffect();
// ⭐ 用 baseMmove 恢复 isMove:Awake 时记录预制体原始 isMove 状态。
// - 普通鱼预制体 isMove=true → 池回收后恢复 true,正常游动
// (之前被注释导致:冰冻时 FreezeAllFish 设 isMove=false → 鱼回收 →
// 重新生成后残留 false → 鱼永远不动)
// - 爆奖 Boss 预制体 isMove=false → 恢复 false,固定在 (0,0,0) 播放动画
// (金龙/雷龙/玉龙等不走路径,直到爆奖结束销毁)
// - 冰冻中重新激活的鱼,末尾会覆盖为 false,安全
isMove = baseMmove;
// ⭐ 重置可锁定状态:needLockEvent=true 的鱼每次重新生成都需等 NotifyLockable()
// needLockEvent=false 的鱼一直可锁定
isLockable = !needLockEvent;
// ⭐ 重置死亡标记:池回收复用的鱼重新激活时是活鱼
isDead = false;
// ⭐ 重置路线方向缓存:池复用会换路线,需重新由起终点判定
_routeDirXCached = false;
_routeDirX = 0;
fishenum = (FishTypeEnum)monsterData.fishTypeConfig.fishType;
uirootRect = UIManager.Instance.uiRoot.GetComponent<RectTransform>();
mainCamera = Camera.main;
moveTime = 0f;
// ⭐ 重置创建视频状态:池回收的鱼 _isPlayingCreateVideo 可能残留 true,
// 重新生成时重置,由 Init 决定是否重新播放
_createVideoDuration = 0f;
_isPlayingCreateVideo = false;
InitAnimList();
// ⭐ 预解析 forwordXYZ 为枚举,避免 ApplyFacingDirection 每帧每鱼 string switch
RefreshFacingAxis();
// ⭐ 同步缓存到 moveLine,避免 GetBezierPointJob 内每帧每鱼重复查询
// UIManager.Instance.uiRoot.GetComponent<RectTransform>() 和 Camera.main
monsterData.moveLine.cachedCamera = mainCamera;
monsterData.moveLine.cachedUirootRect = uirootRect;
// ⭐ 缓存子物体引用(朝向控制用),避免 ApplyFacingDirection 每帧 transform.GetChild(0)
// 池回收的鱼 transform 可能更换子层级,每次 OnEnable 重新缓存
if (transform.childCount > 0)
{
_cachedChild = transform.GetChild(0);
}
else
{
_cachedChild = null;
}
// ⭐ 2D鱼:重置渲染器缓存(池回收后子层级可能变化)并重新应用sortingOrder层级
_cached2DRenderers = null;
_appliedSortOrder = int.MinValue;
Apply2DOrder();
// ⭐ 池回收的鱼(isInitialized=false)不立即 ChangeState(move),
// 避免 OnMoveUpdata 用旧 moveTime/moveLine 误触发 Id_DieFish_NotFX
// Init 调用后会设置 isInitialized=true 并 ChangeState(move)
if (isInitialized)
{
ChangeState(CharacterStateType.move);
}
// ⭐ 同步冰冻特效状态:池回收的鱼 bingDongEffect 可能残留激活状态,
// 重新生成时会显示白色冰冻蒙皮。根据当前是否冰冻中决定显隐
bool frozen = CreateFishManager.Instance != null && CreateFishManager.Instance.isFrozen;
SetFreezeEffect(frozen);
if (frozen)
{
// 冰冻中重新生成的鱼也要被冻结
isMove = false;
ChangeState(CharacterStateType.idle);
}
}
private void Update()
{
// ⭐ 调试:绘制当前鱼移动路线(Game视图Debug线,每帧重画;详见 DrawMoveLine)
if (drawMoveLine)
DrawMoveLine();
OnUpdate(currState);
}
/// <summary>
/// 销毁时自动从 CreateFishManager 的三个字典中移除自身,避免野指针。
/// </summary>
public virtual void OnDisable()
{
// ⭐ 清掉调试折线缓存:池回收后路线失效,重新 Init 换新路线时会按引用变化重建
_drawLinePoints = null;
_drawLineCurve = null;
if (CreateFishManager.Instance == null) return;
// ⭐ 重置初始化标志:鱼被回收后需要重新 Init 才能正常 Update
isInitialized = false;
}
// ================================================================
// 初始化
// ================================================================
/// <summary>注册动画名称(子类可 override 扩展)。</summary>
public void InitAnimList()
{
dic_AnimKeyAnimName[CharacterAnimationType.move] = "stand2";
}
/// <summary>
/// 初始化路径并启动自动销毁计时。
/// </summary>
/// <param name="pathId">路线编号(与路线 key 前缀拼接)</param>
/// <param name="time">路径持续时间(秒)</param>
/// <param name="isyuzhen">是否为鱼阵成员</param>
/// <param name="yzID">鱼阵 ID(isyuzhen==true 时有效)</param>
public virtual void Init(string pathId, float time, bool isyuzhen = false, string yzID = "")
{
// ⭐ 保存 pathId,用于房主把活鱼广播给新加入玩家
lastMoveLineId = pathId;
// 根据是否为鱼阵选择路线 key
string lineKey = isyuzhen
? YzMoveLineName + pathId
: NormalMoveLineName + pathId;
if (!MonsterMoveLine.dic_BezierCurveDatas.TryGetValue(lineKey, out var bezierData))
{
Debug.LogError($"[MonsterContral] 找不到路线: {lineKey}");
return;
}
monsterData.moveLine.Init(bezierData, time);
isyz = isyuzhen;
this.yzID = yzID;
// ⭐ 缓存 yzID 前3字符前缀,避免 UpdateYZMovement 每帧每鱼 Substring 产生 GC
SetYzPrefix(yzID);
// ⭐ 预计算 700/701 直线阵的世界起点 + 方向系数
// 原逻辑每帧每鱼 CanvasToWorld(start) + 判断 goUp,现 Init 一次性算好
CacheYzLineMoveData(bezierData);
fishenum = (FishTypeEnum)monsterData.fishTypeConfig.fishType;
// isMove=false 的鱼不需要移动路线,x/y 归位到 (0,0);z 不带——用预制体自带的深度
if (!isMove)
transform.position = new Vector3(0, 0, transform.position.z);
// ⭐ Boss朝向:不随路线切线旋转,按路线编号奇偶翻转X轴(单数左→右保持正向/双数右→左翻转)
if (isMove && IsRouteFlipBoss)
ApplyBossRouteFlip(pathId);
// ⭐ 创建视频启动逻辑由子类负责:
// - MonsterControl_BossBase 在自己 Init 里启动 createVideo
// - 普通鱼走父类 Init,但普通鱼一般不配 createVideo
// OnMoveUpdata 会检测 createVideo.isPlaying,视频播放期间不移动
// ⭐ 标记已初始化,并启动 move 状态(OnEnable 里 isInitialized=false 时不会 ChangeState)
isInitialized = true;
ChangeState(CharacterStateType.move);
// 路径结束后自动销毁
//StartCoroutine(yanchi(gameObject, time, isyuzhen));
// ⭐ 使用 OnEnable 缓存的 _cachedChild,避免每帧 transform.GetChild(0) 调用开销
// 兜底:缓存丢失时重新获取一次(理论上不会发生)
Transform child = _cachedChild;
if (child == null)
{
if (transform.childCount == 0) return;
child = transform.GetChild(0);
_cachedChild = child;
}
Vector3 localPos = child.localPosition;
// ⭐ x/y 归零摆正模型;z 深度只在显式配置 depthOffset(≠0)时才覆盖,
// 否则保留预制体上设置的 3D 深度(3D Boss 用,之前被强制归零导致深度无效)
localPos.x = 0f;
if (depthOffset != 0)
localPos.z = depthOffset;
child.localPosition = localPos;
}
public IEnumerator yanchi(GameObject obj, float time, bool isyuzhen)
{
yield return new WaitForSeconds(time);
CreateFishManager.Instance.Id_DieFish_NotFX(guid);
}
// ================================================================
// 状态机
// ================================================================
/// <summary>切换到新状态(先触发旧状态 Exit,再触发新状态 Enter)。</summary>
public void ChangeState(CharacterStateType stateType)
{
OnExit(currState);
currState = stateType;
OnEnter(currState);
}
public void OnEnter(CharacterStateType stateType)
{
switch (stateType)
{
case CharacterStateType.move: OnMoveEnter(); break;
case CharacterStateType.idle: break;
}
}
public void OnUpdate(CharacterStateType stateType)
{
if (!isMove) return;
// ⭐ 池回收的鱼(isInitialized=false)不执行移动逻辑,
// 避免 currState 残留为 move 时用旧 guid/moveLine 误触发 Id_DieFish_NotFX
// Init 调用后会设置 isInitialized=true
if (!isInitialized) return;
switch (stateType)
{
case CharacterStateType.move: OnMoveUpdata(); break;
case CharacterStateType.idle: break;
}
}
public void OnExit(CharacterStateType stateType)
{
// 当前无需处理,保留供子类 override
}
// ================================================================
// 移动逻辑
// ================================================================
public virtual void OnMoveEnter()
{
PlayAnim(CharacterAnimationType.move);
}
public virtual void OnMoveExit() { }
/// <summary>
/// 每帧更新鱼的位置(B样曲线插值)和朝向。
/// 鱼阵(isyz==true)按阵型 ID 前缀分支,普通鱼走统一逻辑。
/// ⭐ 性能优化(零 GC + 缓存):
/// - 曲线求值:静态 Vector2[]/节点向量缓存 + ThreadStatic 临时缓冲,零 GC 分配
/// - 朝向:forwordXYZ 预解析为枚举,避免每帧 string switch
/// - 子物体:OnEnable 缓存 _cachedChild,避免每帧 transform.GetChild(0)
/// - 鱼阵 700/701:Init 预计算起点世界坐标 + 方向系数
/// - yzID 前缀:Init 缓存 _yzPrefix,避免每帧 Substring
/// </summary>
public virtual void OnMoveUpdata()
{
if (!isMove) return; // 冰冻中不计时
// ⭐ 创建视频播放期间不移动、不累计 moveTime
// 视频结束后才开始走路线;视频时长不计入 moveTime(单独累计到 _createVideoDuration)
// 隐藏控件由 loopPointReached 回调负责(OnCreateVideoFinished)
if (_isPlayingCreateVideo)
{
_createVideoDuration += Time.deltaTime;
return;
}
moveTime += Time.deltaTime;
// ⭐ 终点检测:每帧执行(轻量)
if (moveTime >= monsterData.moveLine.moveTime)
{
// ⭐ 路径走完后立即停止移动,防止 Id_DieFish_NotFX 每帧重复触发
// (野鱼 guid 已被覆盖时,Id_DieFish_NotFX 会 NOT_FOUND,但 moveTime 一直 >= moveTime,
// 不停止的话每帧都会调用,产生海量 NOT_FOUND 日志)
isMove = false;
CreateFishManager.Instance.Id_DieFish_NotFX(guid);
return;
}
// ⭐ 每帧执行重计算(已无 GC 压力,分帧会导致鱼体一卡一卡)
if (isyz)
{
UpdateYZMovement();
}
else
{
UpdateNormalMovement();
}
}
/// <summary>
/// 预计算 700/701 直线阵的世界起点 + 方向系数。
/// ⭐ 优化:原 UpdateYZMovement 每帧每鱼 CanvasToWorld(start) + 判断 goUp,
/// 现在 Init 一次性算好,运行时直接用缓存值。
/// 非直线阵(702/704/710 等)标记 _isYzLineMove=false 跳过。
/// </summary>
private void CacheYzLineMoveData(MonsterMoveLine.BezierCurveData bezierData)
{
_isYzLineMove = false;
_yzLineInited = false;
if (!isyz || string.IsNullOrEmpty(_yzPrefix)) return;
if (_yzPrefix != "700" && _yzPrefix != "701") return;
// 世界起点(CanvasToWorld 已在 OnEnable 缓存 uirootRect/mainCamera)
_yzStartWorld = CoordinateTransformation.CanvasToWorld(
bezierData.start, uirootRect, mainCamera);
// 700:根据起点 y 值决定正方向;701:固定向上(dir=-1)
bool goUp = (_yzPrefix == "701") || (bezierData.start.y > 0);
_yzLineDir = goUp ? -1f : 1f;
// ⭐ 分布角方向缓存:按 forwordXYZ 读根当前(=生成端刚设置的)前方向量,取XY平面投影。
// 扇形(700)/圈(701)阵型靠生成端分布角实现:每条鱼朝各自方向辐射展开,
// 移动必须沿各自分布方向;此处姿态尚未被任何朝向逻辑修改,读到的就是分布方向。
// 投影过短(如配X的鱼被生成端Y=-90摆平转出屏幕平面)时回退固定垂直方向(0,±1)
Vector3 fwd;
switch (_facingAxis)
{
case FacingAxis.X: fwd = transform.right; break;
case FacingAxis.NegX: fwd = -transform.right; break;
case FacingAxis.Y: fwd = transform.up; break;
case FacingAxis.NegY: fwd = -transform.up; break;
case FacingAxis.Z: fwd = transform.forward; break;
case FacingAxis.NegZ: fwd = -transform.forward; break;
default: fwd = transform.up; break;
}
Vector2 dir2 = new Vector2(fwd.x, fwd.y);
_yzMoveDir = dir2.sqrMagnitude > 0.25f
? dir2.normalized
: new Vector2(0f, _yzLineDir);
_isYzLineMove = true;
_yzLineInited = true;
}
/// <summary>鱼阵移动:按 yzID 前缀选择运动方程。</summary>
private void UpdateYZMovement()
{
// ⭐ 700/701 直线阵(扇形/圈):使用预计算缓存,零 CanvasToWorld 调用
if (_isYzLineMove && _yzLineInited)
{
// ⭐ 沿各自生成分布角方向辐射移动(Init时缓存,每条鱼各自不同):
// 扇形/圈的阵型靠生成端分布角实现,移动方向必须与之一致;
// _yzMoveDir 在 CacheYzLineMoveData 里按轴读生成姿态前方,投影过短回退(0,±1)
Vector2 lineDir = _yzMoveDir;
Vector2 pos = (Vector2)_yzStartWorld + lineDir * moveTime * monsterData.moveLine.moveTime;
// ⭐ 朝向保持生成姿态(分布角)不转——不调 ApplyFacingDirection:
// 它会覆盖根的分布角,把扇形/圈抹成一列竖队
// ⭐ 保留根节点当前 z(2D 小鱼 z 一直为 0,行为不变)
transform.position = new Vector3(pos.x, pos.y, transform.position.z);
return;
}
// ⭐ 用 Init 时缓存的 _yzPrefix,避免每帧每鱼 yzID.Substring(0,3) GC 分配
if (string.IsNullOrEmpty(_yzPrefix)) return;
switch (_yzPrefix)
{
// 700 / 701:沿各自分布方向辐射移动—— 兜底分支,正常走缓存路径
case "700":
case "701":
{
var startWorld = CoordinateTransformation.CanvasToWorld(
monsterData.moveLine.bezierCurveData.start, uirootRect, mainCamera);
// 700:根据起点 y 值决定正方向;701:固定
bool goUp = (_yzPrefix == "701") || (monsterData.moveLine.bezierCurveData.start.y > 0);
float dir = goUp ? -1f : 1f;
// ⭐ 同缓存分支:沿分布方向移动(缓存值优先,未初始化回退固定垂直),朝向不转
Vector2 lineDir = _yzMoveDir != Vector2.zero ? _yzMoveDir : new Vector2(0f, dir);
Vector2 pos = (Vector2)startWorld + lineDir * moveTime * monsterData.moveLine.moveTime;
// ⭐ 保留根节点当前 z(2D 小鱼 z 一直为 0,行为不变)
transform.position = new Vector3(pos.x, pos.y, transform.position.z);
break;
}
// 702 / 710:B样曲线路径
case "702":
case "7007":
case "710": // 顺序阵:每条路径逐一延迟生成
case "704":
{
Vector2 next = monsterData.moveLine.GetBezierPointJob(moveTime);
if (fishenum != FishTypeEnum.Boss鱼)
ApplyFacingDirection(next);
// ⭐ 保留根节点当前 z(2D 小鱼 z 一直为 0,行为不变)
transform.position = new Vector3(next.x, next.y, transform.position.z);
break;
}
default:
break;
}
}
/// <summary>普通鱼移动:B样曲线路径 + 自动朝向。</summary>
private void UpdateNormalMovement()
{
Vector2 next = monsterData.moveLine.GetBezierPointJob(moveTime);
//if (fishenum != FishTypeEnum.Boss鱼)
ApplyFacingDirection(next);
// ⭐ 保留根节点当前 z:3D Boss 在预制体上配置的深度不被擦掉
// (普通鱼 z 一直是 0,行为不变;next 是 Vector2 直接赋值会把 z 强制归 0)
transform.position = new Vector3(next.x, next.y, transform.position.z);
}
public bool isRotate = true;
public bool isNot1 = false;
/// <summary>
/// 路线水平方向缓存:+1=左→右,-1=右→左,0=纯垂直。
/// 由曲线起终点一次判定(终点在起点右边=左→右),路线终身不变。
/// ⭐ 侧倾符号判断统一用它,替代每帧 dirNorm 分量:曲线中段波动会让 dirNorm 分量
/// 频繁穿0,符号每帧翻转导致侧倾 ±rotationPY 来回闪(±90跳)。
/// </summary>
private int _routeDirX = 0;
/// <summary>_routeDirX 是否已计算(ApplyFacingDirection 首次调用时懒算,OnEnable 重置供池复用)</summary>
private bool _routeDirXCached = false;
/// <summary>
/// 根据 forwordXYZ 字段将物体朝向目标点方向。
/// 为空时 2D 鱼用 up,3D 鱼用 forward。
/// </summary>
/// <param name="nextPos">下一帧目标位置</param>
private void ApplyFacingDirection(Vector2 nextPos)
{
// ⭐ 700/701扇形/圈鱼阵特殊处理:出生时生成端已设好分布角度,Init时已缓存移动方向(_yzMoveDir),
// 之后全程跳过一切旋转/摆位/镜像逻辑——任何路径调用都直接返回,保证阵型姿态绝不被触碰。
// (_isYzLineMove 由 CacheYzLineMoveData 在 Init 内设置,仅 700/701 为 true,出生后立即生效)
if (_isYzLineMove)
return;
// ⭐ 使用 OnEnable 缓存的 _cachedChild,避免每帧 transform.GetChild(0) 调用开销
// 兜底:缓存丢失时重新获取一次(理论上不会发生)
Transform child = _cachedChild;
if (child == null)
{
if (transform.childCount == 0) return;
child = transform.GetChild(0);
_cachedChild = child;
}
Vector3 localPos = child.localPosition;
// ⭐ x/y 归零摆正模型;z 深度只在显式配置 depthOffset(≠0)时才覆盖,
// 否则保留预制体上设置的 3D 深度(3D Boss 用,之前被强制归零导致深度无效)
localPos.x = 0f;
localPos.y = 0f;
if (depthOffset != 0)
localPos.z = depthOffset;
child.localPosition = localPos;
if (!isRotate) return;
Vector2 toNext = nextPos - (Vector2)transform.position;
if (toNext.sqrMagnitude < 0.0001f) return;
Vector2 dirNorm = toNext.normalized; // ⭐ 缓存归一化向量,避免分支内重复计算
// ⭐ 路线左右方向懒缓存:终点在起点右边=左→右(+1)/左边=右→左(-1)/相同=纯垂直(0)
// 由起终点一次判定,路线终身不变——镜像/侧倾符号判断不再受曲线中段波动影响
if (!_routeDirXCached)
{
_routeDirXCached = true;
var bd = monsterData.moveLine.bezierCurveData;
if (bd.end.x > bd.start.x) _routeDirX = 1;
else if (bd.end.x < bd.start.x) _routeDirX = -1;
else _routeDirX = 0;
}
// ⭐ 用预解析的枚举 switch,避免每帧每鱼 string 比较
// 处理未指定朝向轴的情况(默认行为)
if (_facingAxis == FacingAxis.None)
{
// ⭐ Boss不随路线切线旋转(重构前旧逻辑Boss鱼本来就不参与朝向):
// 未配置正方向(forwordXYZ)的Boss,朝向由 ApplyBossRouteFlip 按路线奇偶翻转X轴决定;
// 配置了正方向的Boss走下方轴对齐逻辑,保持原有机制不变
if (IsRouteFlipBoss) return;
// ⭐ 镜像由路线方向一次判定(_routeDirX)+useMirrorCompensate开关(与轴分支统一):
// 横向向左路线才镜像;环形路线起终点相同(_routeDirX==0)天然不镜像——
// 原每帧dirNorm.x滞回(_defaultMirror)在环形/绕圈路线上每圈跨中线两次,
// 镜像来回翻转导致模型闪(±90姿态跳,XJMH飞机类高发)
bool mirrorLeft = useMirrorCompensate && _routeDirX < 0;
if (!is3D)
{
float angle = Mathf.Atan2(toNext.y, toNext.x) * Mathf.Rad2Deg;
transform.rotation = Quaternion.AngleAxis(angle - 90f, Vector3.forward);
// ⭐ 左右镜像(修复"出鱼有的正有的反",不对称鱼往左显示反):
// 贴图头朝上的鱼纯旋转对齐时,往左游=整体转180°=肚皮朝上(反)。
// 往左时叠加X轴镜像:旋转角不变(镜像不改变鱼头方向),
// 只翻回背腹朝向,左右游姿态一致。以预制体基准绝对赋值,池复用不叠加
float mirrorX = mirrorLeft ? -_prefabScaleX : _prefabScaleX;
var s = transform.localScale;
if (!Mathf.Approximately(s.x, mirrorX))
{
s.x = mirrorX;
transform.localScale = s;
}
}
else
{
// ⭐ 连续构造替代 LookRotation(dirNorm, up):朝向接近正上/正下时
// LookRotation 参考系退化(cross(up,forward)≈0),左右轴随dx符号
// 瞬间翻转180°导致鱼抖。此写法与 LookRotation 稳态等价且全程连续
float heading = Mathf.Atan2(dirNorm.y, dirNorm.x) * Mathf.Rad2Deg;
var rot = Quaternion.AngleAxis(heading, Vector3.forward)
* Quaternion.AngleAxis(90f, Vector3.up);
// ⭐ 往左时绕头尾轴再转180°:3D鱼左右近似对称,等效镜像,
// 背始终朝上(与往右一致),避免负缩放的法线翻转副作用
if (mirrorLeft)
rot *= Quaternion.AngleAxis(180f, Vector3.forward);
transform.rotation = rot;
}
return;
}
// 是否为子物体单独控制朝向和深度
if (ischild0)
{
// ⭐ 全部使用缓存 child,不再 transform.GetChild(0)
switch (_facingAxis)
{
case FacingAxis.X:
{
child.right = dirNorm;
var vv = child.eulerAngles;
// ⭐ 侧倾符号由路线方向一次判定(_routeDirX,起终点计算):
// 横向路线 向右→负/向左→正;曲线中段 dy 波动穿0不再翻号(±90闪已除)。
// 纯垂直路线(_routeDirX==0)回退用 dy(垂直游动恒号,稳定)
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y >= 0f;
vv.x = rotationPY * (neg ? -1f : 1f);
child.eulerAngles = vv;
}
break;
case FacingAxis.NegX:
{
child.right = -dirNorm;
var vv = child.eulerAngles;
// ⭐ 同上:符号由路线方向一次判定,垂直路线回退用 dy
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y <= 0f;
vv.x = -rotationPY * (neg ? -1f : 1f);
child.eulerAngles = vv;
}
break;
case FacingAxis.Y:
{
child.up = dirNorm;
var vv = child.eulerAngles;
// ⭐ 同上:符号由路线方向一次判定。
// 横向路线 向左→负/向右→正(与原 dx 判断语义一致且不再受波动影响);
// 纯垂直路线回退用 dy(上游→负/下游→正)
bool neg = _routeDirX != 0 ? _routeDirX < 0 : dirNorm.y >= 0f;
vv.y = rotationPY * (neg ? -1f : 1f);
child.eulerAngles = vv;
}
break;
case FacingAxis.NegY:
{
child.up = -dirNorm;
var vv = child.eulerAngles;
// ⭐ 同上:符号由路线方向一次判定,垂直路线回退用 dy
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y <= 0f;
vv.y = -rotationPY * (neg ? -1f : 1f);
child.eulerAngles = vv;
}
break;
case FacingAxis.Z:
{
// ⭐ 换成旋转角度(不指定forward方向):鱼头朝Z的模型,
// 转向只动X分量 = -heading(唯一随路径变的轴);
// Y=90 固定摆平(鱼头从Z轴转到屏幕平面,初始化锁死);
// Z=rotationPY 绕鱼头轴的姿态偏移(不改鱼头方向)
float heading = Mathf.Atan2(dirNorm.y, dirNorm.x) * Mathf.Rad2Deg;
float rollZ;
if (useRollSignFlip)
{
// ⭐ 原始语义:按路线左右纯符号翻转(向左→+rotationPY/向右→-rotationPY),
// 无180°翻转;_routeDirX 起终点一次判定,终身不变不闪
rollZ = _routeDirX < 0 ? rotationPY : -rotationPY;
}
else
{
// ⭐ 反面补偿模式:由路线方向一次判定(_routeDirX),向左路线才补偿。
// 原 _defaultMirror 每帧滞回,环形路线绕圈跨中线两次会来回翻转
// → 模型闪 + Y欧拉±90跳。环形路线起终点相同(_routeDirX==0)天然不补偿
rollZ = -rotationPY;
if (useMirrorCompensate && _routeDirX < 0) rollZ = 180f - rollZ;
}
child.rotation = Quaternion.Euler(-heading, 90f, rollZ);
}
break;
case FacingAxis.NegZ:
{
// ⭐ 鱼头=-Z:同样X分量转向,基准反向(+180);Z取-rotationPY
float heading = Mathf.Atan2(dirNorm.y, dirNorm.x) * Mathf.Rad2Deg;
float rollZ;
if (useRollSignFlip)
{
// ⭐ 原始语义(与Z case条件相反):向右→+rotationPY/向左→-rotationPY
rollZ = _routeDirX > 0 ? rotationPY : -rotationPY;
}
else
{
// ⭐ 反面补偿模式(与Z case条件相反):向右路线才补偿
rollZ = -rotationPY;
if (useMirrorCompensate && _routeDirX > 0) rollZ = 180f - rollZ;
}
child.rotation = Quaternion.Euler(-heading + 180f, 90f, rollZ);
}
break;
}
}
else
{
// ⭐ 控制父物体自身朝向:与 ischild0 分支同款完整逻辑
// (侧倾符号由方向直接算 + Z/NegZ 连续构造 + 镜像补偿),
// 只是操作对象是 transform 自身——未勾 ischild0 的鱼(如鱼阵普通鱼)
// 同样获得防闪处理,不再只有一行式轴对齐
switch (_facingAxis)
{
case FacingAxis.X:
{
transform.right = dirNorm;
var vv = transform.eulerAngles;
// ⭐ 侧倾符号由路线方向一次判定(_routeDirX,起终点计算),垂直路线回退用 dy
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y >= 0f;
vv.x = rotationPY * (neg ? -1f : 1f);
transform.eulerAngles = vv;
}
break;
case FacingAxis.NegX:
{
transform.right = -dirNorm;
var vv = transform.eulerAngles;
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y <= 0f;
vv.x = -rotationPY * (neg ? -1f : 1f);
transform.eulerAngles = vv;
}
break;
case FacingAxis.Y:
{
transform.up = dirNorm;
var vv = transform.eulerAngles;
// ⭐ 同上:横向路线 向左→负/向右→正,垂直路线回退用 dy
bool neg = _routeDirX != 0 ? _routeDirX < 0 : dirNorm.y >= 0f;
vv.y = rotationPY * (neg ? -1f : 1f);
transform.eulerAngles = vv;
}
break;
case FacingAxis.NegY:
{
transform.up = -dirNorm;
var vv = transform.eulerAngles;
bool neg = _routeDirX != 0 ? _routeDirX > 0 : dirNorm.y <= 0f;
vv.y = -rotationPY * (neg ? -1f : 1f);
transform.eulerAngles = vv;
}
break;
case FacingAxis.Z:
{
// ⭐ 连续构造:转向只动X分量=-heading,Y=90固定摆平,Z=rotationPY
float heading = Mathf.Atan2(dirNorm.y, dirNorm.x) * Mathf.Rad2Deg;
float rollZ;
if (useRollSignFlip)
{
// ⭐ 原始语义:按路线左右纯符号翻转(向左→+rotationPY/向右→-rotationPY)
rollZ = _routeDirX < 0 ? rotationPY : -rotationPY;
}
else
{
// ⭐ 反面补偿模式:向左路线才补偿,环形(_routeDirX==0)不补偿
rollZ = -rotationPY;
if (useMirrorCompensate && _routeDirX < 0) rollZ = 180f - rollZ;
}
transform.rotation = Quaternion.Euler(-heading, 90f, rollZ);
}
break;
case FacingAxis.NegZ:
{
// ⭐ 鱼头=-Z:基准反向(+180);符号条件与Z case相反(向右路线)
float heading = Mathf.Atan2(dirNorm.y, dirNorm.x) * Mathf.Rad2Deg;
float rollZ;
if (useRollSignFlip)
{
rollZ = _routeDirX > 0 ? rotationPY : -rotationPY;
}
else
{
rollZ = -rotationPY;
if (useMirrorCompensate && _routeDirX > 0) rollZ = 180f - rollZ;
}
transform.rotation = Quaternion.Euler(-heading + 180f, 90f, rollZ);
}
break;
}
}
}
public bool ischild0 = false;
public int depthOffset;
// ----------------------------------------------------------------
// ⭐ 2D层级(2D鱼用,与3D鱼的z轴depthOffset深度兼容并存)
// ----------------------------------------------------------------
[Header("2D层级(2D鱼)")]
[Tooltip("是否为2D渲染鱼:用sortingOrder控制层级(3D鱼继续用z轴depthOffset深度,互不影响)")]
public bool is2DFish = false;
[Tooltip("2D鱼的sortingOrder层级值,运行时可调 Set2DOrder(int) 修改")]
public int sortOrder2D = 0;
/// <summary>2D鱼渲染器缓存(设置sortingOrder用,OnEnable重建,避免重复GetComponentsInChildren)</summary>
private Renderer[] _cached2DRenderers;
/// <summary>已应用的sortingOrder值(值未变化时跳过重复设置)</summary>
private int _appliedSortOrder = int.MinValue;
/// <summary>
/// 设置2D层级(sortingOrder)。对鱼身上所有渲染器统一生效,
/// 多渲染器按层级顺序依次 +1 递增,保持鱼内部前后遮挡关系不变。
/// 仅 is2DFish=true 时生效;3D鱼仍走 z 轴 depthOffset,兼容现有逻辑。
/// </summary>
public void Set2DOrder(int order)
{
sortOrder2D = order;
Apply2DOrder();
}
/// <summary>应用sortingOrder到全部子渲染器(值相同则跳过)</summary>
private void Apply2DOrder()
{
if (!is2DFish) return;
if (_appliedSortOrder == sortOrder2D) return;
if (_cached2DRenderers == null)
_cached2DRenderers = GetComponentsInChildren<Renderer>(true);
_appliedSortOrder = sortOrder2D;
for (int i = 0; i < _cached2DRenderers.Length; i++)
{
var r = _cached2DRenderers[i];
if (r != null)
r.sortingOrder = sortOrder2D + i;
}
}
// ================================================================
// ⭐ 绘制路线(调试)
// 把 monsterData.moveLine 的移动曲线(B样条)按时间均分采样成世界坐标折线:
// - Scene 视图:OnDrawGizmos 画 Gizmos 折线(不选中也常显)
// - Game 视图 :Update 每帧画 Debug.DrawLine(需勾选 Game 视图 Gizmos 按钮)
// 折线只在首次绘制时采样一次并缓存(换路线/改段数自动重建),运行时零 GC。
// ================================================================
[Header("绘制路线(调试)")]
[Tooltip("绘制当前鱼的移动路线:Scene视图Gizmos折线 + Game视图Debug线。路线在Init后才有数据,运行时勾选即时生效")]
public bool drawMoveLine = false;
[Tooltip("路线采样段数,越大越平滑(运行时可调,改后自动重建折线)")]
[Range(8, 200)]
public int drawLineSegments = 60;
[Tooltip("路线折线颜色(另固定画绿色起点/红色终点标记)")]
public Color drawLineColor = Color.yellow;
/// <summary>采样后的世界坐标折线缓存(BuildDrawLinePoints 生成,Gizmos 与 Debug.DrawLine 共用)</summary>
private Vector3[] _drawLinePoints;
/// <summary>折线缓存对应的曲线引用(池复用换路线后引用变化→触发重建)</summary>
private MonsterMoveLine.BezierCurveData _drawLineCurve;
/// <summary>
/// 采样当前路线为世界坐标折线。
/// ⭐ 复用 monsterData.moveLine.GetBezierPointJob 按总时间均分采样,
/// 画出来的就是鱼实际游走的路径(含 Canvas→世界 坐标换算,和移动逻辑同一套取点)。
/// </summary>
private void BuildDrawLinePoints(int segments)
{
var line = monsterData.moveLine;
_drawLineCurve = line.bezierCurveData;
// 无路线 / 相机和UI根未缓存(如编辑态未 Init)时无法做 Canvas→世界 换算,跳过
if (line.bezierCurveData == null || line.cachedCamera == null || line.cachedUirootRect == null)
{
_drawLinePoints = null;
return;
}
_drawLinePoints = new Vector3[segments + 1];
for (int i = 0; i <= segments; i++)
{
// t = moveTime * i / segments,GetBezierPointJob 内部归一化后取曲线点
// ⭐ 不关心具体曲线算法(贝塞尔/B样):与鱼移动走同一方法,画啥鱼走啥
Vector2 p = line.GetBezierPointJob(line.moveTime * i / segments);
_drawLinePoints[i] = new Vector3(p.x, p.y, 0f);
}
}
/// <summary>
/// 确保折线缓存可用:换路线(池复用后 bezierCurveData 引用变化)或改采样段数时重建。
/// </summary>
private bool EnsureDrawLinePoints()
{
int segments = Mathf.Max(2, drawLineSegments);
if (_drawLinePoints == null
|| _drawLinePoints.Length != segments + 1
|| _drawLineCurve != monsterData.moveLine.bezierCurveData)
{
BuildDrawLinePoints(segments);
}
return _drawLinePoints != null;
}
/// <summary>
/// Game 视图绘制当前路线(Debug.DrawLine 只保留一帧,由 Update 每帧调用)。
/// </summary>
private void DrawMoveLine()
{
if (!drawMoveLine) return;
if (monsterData.moveLine.bezierCurveData == null) return;
if (!EnsureDrawLinePoints()) return;
// depthTest=false:线不被鱼体遮挡,始终可见
for (int i = 0; i < _drawLinePoints.Length - 1; i++)
Debug.DrawLine(_drawLinePoints[i], _drawLinePoints[i + 1], drawLineColor, 0f, false);
}
#if UNITY_EDITOR
// ⭐ 用 OnDrawGizmos 常显(不用选中就能看,池化的运行时鱼在Hierarchy里难选中);
// drawMoveLine=false 时立即返回,平时零开销
private void OnDrawGizmos()
{
if (!drawMoveLine) return;
if (monsterData.moveLine.bezierCurveData == null) return;
if (!EnsureDrawLinePoints()) return;
Gizmos.color = drawLineColor;
for (int i = 0; i < _drawLinePoints.Length - 1; i++)
Gizmos.DrawLine(_drawLinePoints[i], _drawLinePoints[i + 1]);
// 起点绿 / 终点红,游动方向一目了然
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(_drawLinePoints[0], 0.25f);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(_drawLinePoints[_drawLinePoints.Length - 1], 0.25f);
}
#endif
// ================================================================
// 动画
// ================================================================
/// <summary>播放指定类型动画(需要先在 dic_AnimKeyAnimName 中注册)。</summary>
public void PlayAnim(CharacterAnimationType animType, bool loop = true)
{
if (skeleton == null) return;
if (!dic_AnimKeyAnimName.TryGetValue(animType, out var animName)) return;
skeleton.AnimationState.SetAnimation(0, animName, loop);
}
// ================================================================
// 受击 & 死亡
// ================================================================
/// <summary>触发受击闪烁(0.2s 后自动关闭)。子类可 override 实现受击阶段逻辑(如爆裂金鲨首炮切战损)。</summary>
public virtual void OnHurt()
{
if (hurt != null)
StartCoroutine(HurtCoroutine());
}
private IEnumerator HurtCoroutine()
{
hurt.SetActive(true);
yield return new WaitForSeconds(0.2f);
hurt.SetActive(false);
}
/// <summary>
/// 执行死亡:播放音效并销毁 GameObject。
/// 子类可 override 添加额外逻辑(如 Boss 爆炸动画)。
/// ⭐ 统一设置 isDead=true:死亡后子弹不再命中、不再被锁定。
/// (不调 base 的子类由 CreateFishManager.Ws_DieFish 入口统一标记)
/// </summary>
public virtual void DieFish(RoomKillNotice obj = null)
{
isDead = true;
if (FishResourcesData.fishResourcesDatasDic.TryGetValue(fishTypeID, out var res)
&& res.dieAudio != null && res.dieAudio.Count > 0)
{
AudioManager.Instance.PlayerFX(res.dieAudio[0]);
}
CreateFishManager.Instance.Id_DieFish_NotFX(guid);
//Destroy(gameObject);
}
// ================================================================
// 工具方法
// ================================================================
/// <summary>递归深度查找子物体(广度优先)。</summary>
private Transform FindDeepChild(Transform parent, string name)
{
foreach (Transform child in parent)
{
if (child.name == name) return child;
var found = FindDeepChild(child, name);
if (found != null) return found;
}
return null;
}
}