Files
5.24BuYu/Assets/MyGame/Scripts/Characters/Monster/MonsterContral.cs
T
2026-09-10 17:23:20 +08:00

994 lines
40 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>
/// 鱼实体基类:封装状态机(移动/待机)、贝塞尔曲线路径跟随、动画播放、死亡逻辑。
/// 具体鱼类型(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取预制体原始值的负数(镜像)
/// 以预制体原始X缩放为基准绝对赋值,对象池复用后重新Init不会叠加翻转。
/// 路线编号非数字时不翻转(保持原朝向)。
/// </summary>
protected void ApplyBossRouteFlip(string pathId)
{
if (!int.TryParse(pathId, out int routeNum)) return;
float dirX = routeNum % 2 == 1 ? 1f : -1f;
var scale = transform.localScale;
scale.x = _prefabScaleX * dirX;
transform.localScale = scale;
}
/// <summary>路径已行进的时间(秒),用于贝塞尔曲线插值;可由外部预设偏移实现同步追帧。</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>
/// 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;
// ================================================================
// 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;
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()
{
OnUpdate(currState);
}
/// <summary>
/// 销毁时自动从 CreateFishManager 的三个字典中移除自身,避免野指针。
/// </summary>
public virtual void OnDisable()
{
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系扇形阵按起点y值翻转朝向:绕Z轴+180°旋转
// (原为 scale.y 镜像翻转,负缩放有法线翻转/特效异常等副作用;
// rotation 由 Pool.Get 每次重置为本次生成角度,天然无池复用残留)
bool flipY700 = isyuzhen && _yzPrefix == "700" && bezierData.start.y < 0;
if (flipY700)
{
transform.Rotate(Vector3.forward, 180f, Space.Self);
}
// ⭐ 预计算 700/701 直线阵的世界起点 + 方向系数
// 原逻辑每帧每鱼 CanvasToWorld(start) + 判断 goUp,现 Init 一次性算好
CacheYzLineMoveData(bezierData);
// ⭐ 翻转(Z+180°)后 transform.up 反向,700 直线阵移动方向同步取反,
// 保持实际游向与翻转前一致(否则起点y<0的700阵鱼会反向游)
if (flipY700)
_yzLineDir = -_yzLineDir;
fishenum = (FishTypeEnum)monsterData.fishTypeConfig.fishType;
// isMove=false 的鱼不需要移动路线,直接归位到 (0,0,0)
if (!isMove)
transform.position = Vector3.zero;
// ⭐ 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>
/// 每帧更新鱼的位置(贝塞尔曲线插值)和朝向。
/// 鱼阵(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;
_isYzLineMove = true;
_yzLineInited = true;
}
/// <summary>鱼阵移动:按 yzID 前缀选择运动方程。</summary>
private void UpdateYZMovement()
{
// ⭐ 700/701 直线阵:使用预计算缓存,零 CanvasToWorld 调用
if (_isYzLineMove && _yzLineInited)
{
Vector2 pos = _yzStartWorld + (Vector3)(_yzLineDir * transform.up * moveTime
* monsterData.moveLine.moveTime);
// ⭐ 保留根节点当前 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:直线向前移动(沿 up 方向)—— 兜底分支,正常走缓存路径
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 pos = startWorld + (Vector3)(dir * transform.up * moveTime
* monsterData.moveLine.moveTime);
// ⭐ 保留根节点当前 z(2D 小鱼 z 一直为 0,行为不变)
transform.position = new Vector3(pos.x, pos.y, transform.position.z);
break;
}
// 702 / 710:贝塞尔曲线路径
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>普通鱼移动:贝塞尔曲线路径 + 自动朝向。</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>
/// 根据 forwordXYZ 字段将物体朝向目标点方向。
/// 为空时 2D 鱼用 up,3D 鱼用 forward。
/// </summary>
/// <param name="nextPos">下一帧目标位置</param>
private void ApplyFacingDirection(Vector2 nextPos)
{
// ⭐ 使用 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; // ⭐ 缓存归一化向量,避免分支内重复计算
// ⭐ 用预解析的枚举 switch,避免每帧每鱼 string 比较
// 处理未指定朝向轴的情况(默认行为)
if (_facingAxis == FacingAxis.None)
{
// ⭐ Boss不随路线切线旋转(重构前旧逻辑Boss鱼本来就不参与朝向):
// 未配置正方向(forwordXYZ)的Boss,朝向由 ApplyBossRouteFlip 按路线奇偶翻转X轴决定;
// 配置了正方向的Boss走下方轴对齐逻辑,保持原有机制不变
if (IsRouteFlipBoss) return;
if (!is3D)
{
float angle = Mathf.Atan2(toNext.y, toNext.x) * Mathf.Rad2Deg;
transform.rotation = Quaternion.AngleAxis(angle - 90f, Vector3.forward);
}
else
{
transform.rotation = Quaternion.LookRotation(dirNorm, Vector3.up);
}
return;
}
// 是否为子物体单独控制朝向和深度
if (ischild0)
{
// ⭐ 全部使用缓存 child,不再 transform.GetChild(0)
switch (_facingAxis)
{
case FacingAxis.X:
{
child.right = dirNorm;
var vv = child.eulerAngles;
// ⭐ 根据 z 轴角度判断鱼朝向(左/右半边),决定 x 侧倾符号
// child.right=dirNorm 在 2D 平面主要改变 z 轴旋转
// z<180(朝上半边)→负, z>=180(朝下半边)→正
vv.x = rotationPY * (vv.z < 180 ? -1 : 1);
child.eulerAngles = vv;
}
break;
case FacingAxis.NegX:
{
child.right = -dirNorm;
var vv = child.eulerAngles;
vv.x = -rotationPY * (vv.z < 180 ? -1 : 1);
child.eulerAngles = vv;
}
break;
case FacingAxis.Y:
{
child.up = dirNorm;
var vv = child.eulerAngles;
// ⭐ 根据 z 轴角度判断鱼朝向(左/右半边),决定 y 侧倾符号
// child.up=dirNorm 在 2D 平面主要改变 z 轴旋转
// z<180(朝左半边)→负, z>=180(朝右半边)→正
vv.y = rotationPY * (vv.z < 180 ? -1 : 1);
child.eulerAngles = vv;
}
break;
case FacingAxis.NegY:
{
child.up = -dirNorm;
var vv = child.eulerAngles;
vv.y = -rotationPY * (vv.z < 180 ? -1 : 1);
child.eulerAngles = vv;
}
break;
case FacingAxis.Z:
{
child.forward = dirNorm;
var vv = child.eulerAngles;
if (isNot1)
{
vv.z = rotationPY;
}
else
{
vv.z = rotationPY * (vv.y < 180 ? -1 : 1);
}
child.eulerAngles = vv;
}
break;
case FacingAxis.NegZ:
{
child.forward = -dirNorm;
var vv = child.eulerAngles;
vv.z = -rotationPY * (vv.y < 180 ? -1 : 1);
child.eulerAngles = vv;
}
break;
}
}
else
{
// 控制父物体自身朝向
switch (_facingAxis)
{
case FacingAxis.X: transform.right = dirNorm; break;
case FacingAxis.NegX: transform.right = -dirNorm; break;
case FacingAxis.Y: transform.up = dirNorm; break;
case FacingAxis.NegY: transform.up = -dirNorm; break;
case FacingAxis.Z: transform.forward = dirNorm; break;
case FacingAxis.NegZ: transform.forward = -dirNorm; 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;
}
}
// ================================================================
// 动画
// ================================================================
/// <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;
}
}