// ============================================================ // 文件: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; /// /// 鱼实体基类:封装状态机(移动/待机)、贝塞尔曲线路径跟随、动画播放、死亡逻辑。 /// 具体鱼类型(Boss等)通过继承重写虚方法扩展行为。 /// public class MonsterContral : MonoBehaviour { // ---------------------------------------------------------------- // 常量 // ---------------------------------------------------------------- /// 鱼阵路线 key 前缀 private const string YzMoveLineName = "鱼阵"; /// 普通鱼路线 key 前缀 private const string NormalMoveLineName = "曲线"; // ---------------------------------------------------------------- // 公开字段(Inspector 可见 / 外部赋值) // ---------------------------------------------------------------- /// /// 基础倍率 /// public int beilv; /// 本鱼唯一标识(服务器下发的 GUID) public string guid; /// /// ⭐ 是否已完成 Init(路线/guid/moveTime 已赋值)。 /// false=池回收后未初始化,OnEnable 不应执行 OnMoveUpdata,避免旧 moveTime/moveLine 误触发销毁 /// true=Init 已调用,可以正常 Update /// protected bool isInitialized = false; /// 当前鱼使用的路线 ID(Init 时保存,用于房主把活鱼广播给新加入玩家) public string lastMoveLineId; /// 运行时数据(血量、路径、配置等) public MonsterDataBase monsterData = new MonsterDataBase(new MonsterHealthBase()); /// Spine 骨骼动画组件(非 Spine 鱼可为 null) public SkeletonAnimation skeleton; /// 是否使用 Spine 骨骼动画 public bool isSpine = true; /// 鱼类型 ID(对应 FishResourcesData 索引) public int fishTypeID; /// 动画状态 → 动画名称 映射 public Dictionary dic_AnimKeyAnimName = new Dictionary(); /// 当前状态 public CharacterStateType currState; /// 血量(目前未使用扣血逻辑,保留供扩展) public int HP = 5; /// 死亡粒子特效 Prefab(为 null 时使用 Manager 默认值) public GameObject effect_Die; /// 死亡金币奖励特效 Prefab public GameObject effect_DieAward; public GameObject currEffect_DieAward; public Action action_effect_DieAward; /// 是否处于移动状态(false 时 Update 跳过移动计算) public bool isMove = true; /// 设置冰冻特效激活状态 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); } } /// 是否为 3D 鱼(影响朝向轴选取) public bool is3D = false; /// /// 前进方向轴,可选 "X" / "-X" / "Y" / "-Y" / "Z" / "-Z"。 /// 为空时默认使用 up(2D)或 forward(3D)。 /// ⭐ 运行时不应修改;如需修改请调用 RefreshFacingAxis() 重新解析。 /// public string forwordXYZ; /// /// forwordXYZ 预解析后的朝向轴枚举(OnEnable 时解析)。 /// 避免 ApplyFacingDirection 每帧每鱼做 string.IsNullOrEmpty + switch(string)。 /// private FacingAxis _facingAxis = FacingAxis.None; /// 朝向轴枚举:对应 forwordXYZ 的 7 种取值 private enum FacingAxis { None, // "" / null X, NegX, // "-X" Y, NegY, // "-Y" Z, NegZ // "-Z" } /// /// 将 forwordXYZ 字符串解析为枚举。OnEnable 调用一次,运行时直接用枚举 switch。 /// 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; } } /// /// 缓存 yzID 前3字符前缀。Init 时调用一次(普通鱼在 base.Init,Boss 在自己 Init)。 /// 避免 UpdateYZMovement 每帧每鱼 yzID.Substring(0,3) 产生字符串 GC 分配。 /// protected void SetYzPrefix(string id) { _yzPrefix = (!string.IsNullOrEmpty(id) && id.Length >= 3) ? id.Substring(0, 3) : ""; } /// 鱼类型枚举(缓存,避免反复类型转换) public FishTypeEnum fishenum; /// /// 是否为"按路线奇偶翻转X轴朝向"的Boss(Boss本体或BossBase子类,且未配置forwordXYZ自定义朝向轴)。 /// 这类Boss不随路线切线旋转,朝向只由路线方向决定: /// 路线单数=左→右(X保持预制体正向),路线双数=右→左(X取负镜像)。 /// protected bool IsRouteFlipBoss => (fishenum == FishTypeEnum.Boss鱼 || this is MonsterControl_BossBase) && string.IsNullOrEmpty(forwordXYZ); /// /// ⭐ Boss路线朝向:按路线编号奇偶翻转X轴。 /// 单数路线=左→右 → X保持预制体原始值不变 /// 双数路线=右→左 → X取预制体原始值的负数(镜像) /// 以预制体原始X缩放为基准绝对赋值,对象池复用后重新Init不会叠加翻转。 /// 路线编号非数字时不翻转(保持原朝向)。 /// 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; } /// 路径已行进的时间(秒),用于贝塞尔曲线插值;可由外部预设偏移实现同步追帧。 public float moveTime = 0f; /// 是否为鱼阵成员 public bool isyz = false; /// 所属鱼阵 ID(空字符串表示普通鱼) public string yzID = ""; // ---------------------------------------------------------------- // ⭐ 缓存优化字段 // ---------------------------------------------------------------- /// 缓存的子物体(朝向控制用),避免每帧 transform.GetChild(0) private Transform _cachedChild; // ---------------------------------------------------------------- // ⭐ 鱼阵 700/701 直线移动缓存 // 同一鱼阵起点相同,Init 时一次性算好世界坐标 + 方向系数, // UpdateYZMovement 每帧直接用,避免每帧每鱼 CanvasToWorld + 条件判断。 // ---------------------------------------------------------------- private bool _isYzLineMove; // 是否为 700/701 直线阵 private Vector3 _yzStartWorld; // 直线阵起点世界坐标 private float _yzLineDir; // 直线阵方向系数 (-1 向上 / +1 向下) private bool _yzLineInited; // 直线阵缓存是否已初始化 /// /// yzID 前3字符前缀缓存(Init 时 Substring 一次)。 /// 避免 UpdateYZMovement 每帧每鱼 yzID.Substring(0,3) 产生字符串 GC 分配。 /// private string _yzPrefix = ""; // ---------------------------------------------------------------- // 私有字段 // ---------------------------------------------------------------- /// 受击闪烁特效对象(在子物体中查找 "HitEffect") private GameObject hurt; /// UI 根节点 RectTransform(坐标转换用) protected RectTransform uirootRect; /// 主相机缓存 protected Camera mainCamera; public List colliderList; /// 锁定点(colliderList 为空时使用,null 则用自身坐标) public Transform lockPoint; /// 手动锁定时记录被点击的碰撞器,用于锁定锚点优先级(运行时赋值,不序列化) [System.NonSerialized] public Transform hitCollider; /// 冰冻特效(预制体内子物体列表,null/空则跳过) public List bingDongEffect; [Header("锁定控制(可选)")] [Tooltip("是否需要锁定事件控制。默认false=一直可锁定;true=出场/切换状态期间不可锁定,需调用 NotifyLockable() 后才可锁定")] public bool needLockEvent = false; /// 当前是否可锁定。 /// - needLockEvent=false → 一直 true /// - needLockEvent=true → 初始 false,等 NotifyLockable() 调用后变 true /// ⭐ 用于嫦娥(出场→移动两步)和小白龙(多动画片段切换)等特殊鱼 /// [System.NonSerialized] public bool isLockable = true; /// /// ⭐ 鱼是否已死亡(收到服务端击杀通知/离场销毁后为 true)。 /// 死亡后到回收前有特效播放窗口期,此期间: /// - 子弹(BullitMove)不再命中它(碰撞/锁定追踪都跳过) /// - 不再被锁定(GameMainPanel_FishBase.IsMonsterLockable 返回 false) /// OnEnable 池复用时重置为 false。 /// [System.NonSerialized] public bool isDead = false; /// 可锁定事件:needLockEvent=true 的鱼首次可锁定时触发,GameMainPanel 订阅用于自动锁定 public event Action OnLockable; /// /// 通知此鱼现在可以锁定了。needLockEvent=true 的鱼在出场结束/状态切换完成时调用。 /// public void NotifyLockable() { if (!isLockable) { isLockable = true; OnLockable?.Invoke(this); } } /// /// 切换锁定点(不切换锁定对象)。 /// ⭐ 用于小白龙等切换模型/动画片段后锁定点也随之变化的情况: /// 重新从 colliderList 找第一个激活的碰撞器,更新为新的锁定锚点。 /// 如果当前没有被锁定则不做任何事。 /// 调用方(GameMainPanel_FishBase)负责更新 monCotl_Lock_Position。 /// 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; /// createVideoClip 实际播放时长(秒),用于计算 moveTime 偏移。<=0 表示未播放/无视频 protected float _createVideoDuration = 0f; /// 当前是否正在播放创建视频(由外部播放器 isPlaying 判断) protected bool _isPlayingCreateVideo = false; /// 是否存活(活跃且未被回收) public bool IsAlive => gameObject.activeInHierarchy; public int rotationPY = 60; // ================================================================ // Unity 生命周期 // ================================================================ public bool baseMmove; /// 预制体原始X缩放(Awake缓存):Boss按路线奇偶翻转X轴的基准,池复用后重新Init不会叠加翻转 private float _prefabScaleX = 1f; public virtual void Awake() { LoadHitEffect(); baseMmove = isMove; _prefabScaleX = transform.localScale.x; } /// /// 查找受击特效子物体并强制隐藏。 /// 原为 async void(无 await 实际同步执行),现改为普通方法; /// Awake 和每次 OnEnable 都会调用,保证池回收后不残留激活状态。 /// 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(); mainCamera = Camera.main; moveTime = 0f; // ⭐ 重置创建视频状态:池回收的鱼 _isPlayingCreateVideo 可能残留 true, // 重新生成时重置,由 Init 决定是否重新播放 _createVideoDuration = 0f; _isPlayingCreateVideo = false; InitAnimList(); // ⭐ 预解析 forwordXYZ 为枚举,避免 ApplyFacingDirection 每帧每鱼 string switch RefreshFacingAxis(); // ⭐ 同步缓存到 moveLine,避免 GetBezierPointJob 内每帧每鱼重复查询 // UIManager.Instance.uiRoot.GetComponent() 和 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); } /// /// 销毁时自动从 CreateFishManager 的三个字典中移除自身,避免野指针。 /// public virtual void OnDisable() { if (CreateFishManager.Instance == null) return; // ⭐ 重置初始化标志:鱼被回收后需要重新 Init 才能正常 Update isInitialized = false; } // ================================================================ // 初始化 // ================================================================ /// 注册动画名称(子类可 override 扩展)。 public void InitAnimList() { dic_AnimKeyAnimName[CharacterAnimationType.move] = "stand2"; } /// /// 初始化路径并启动自动销毁计时。 /// /// 路线编号(与路线 key 前缀拼接) /// 路径持续时间(秒) /// 是否为鱼阵成员 /// 鱼阵 ID(isyuzhen==true 时有效) 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); } // ================================================================ // 状态机 // ================================================================ /// 切换到新状态(先触发旧状态 Exit,再触发新状态 Enter)。 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() { } /// /// 每帧更新鱼的位置(贝塞尔曲线插值)和朝向。 /// 鱼阵(isyz==true)按阵型 ID 前缀分支,普通鱼走统一逻辑。 /// ⭐ 性能优化(零 GC + 缓存): /// - 贝塞尔求值:静态 Vector2[] 缓存 + ThreadStatic 临时缓冲,零 GC 分配 /// - 朝向:forwordXYZ 预解析为枚举,避免每帧 string switch /// - 子物体:OnEnable 缓存 _cachedChild,避免每帧 transform.GetChild(0) /// - 鱼阵 700/701:Init 预计算起点世界坐标 + 方向系数 /// - yzID 前缀:Init 缓存 _yzPrefix,避免每帧 Substring /// 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(); } } /// /// 预计算 700/701 直线阵的世界起点 + 方向系数。 /// ⭐ 优化:原 UpdateYZMovement 每帧每鱼 CanvasToWorld(start) + 判断 goUp, /// 现在 Init 一次性算好,运行时直接用缓存值。 /// 非直线阵(702/704/710 等)标记 _isYzLineMove=false 跳过。 /// 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; } /// 鱼阵移动:按 yzID 前缀选择运动方程。 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; } } /// 普通鱼移动:贝塞尔曲线路径 + 自动朝向。 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; /// /// 根据 forwordXYZ 字段将物体朝向目标点方向。 /// 为空时 2D 鱼用 up,3D 鱼用 forward。 /// /// 下一帧目标位置 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; /// 2D鱼渲染器缓存(设置sortingOrder用,OnEnable重建,避免重复GetComponentsInChildren) private Renderer[] _cached2DRenderers; /// 已应用的sortingOrder值(值未变化时跳过重复设置) private int _appliedSortOrder = int.MinValue; /// /// 设置2D层级(sortingOrder)。对鱼身上所有渲染器统一生效, /// 多渲染器按层级顺序依次 +1 递增,保持鱼内部前后遮挡关系不变。 /// 仅 is2DFish=true 时生效;3D鱼仍走 z 轴 depthOffset,兼容现有逻辑。 /// public void Set2DOrder(int order) { sortOrder2D = order; Apply2DOrder(); } /// 应用sortingOrder到全部子渲染器(值相同则跳过) private void Apply2DOrder() { if (!is2DFish) return; if (_appliedSortOrder == sortOrder2D) return; if (_cached2DRenderers == null) _cached2DRenderers = GetComponentsInChildren(true); _appliedSortOrder = sortOrder2D; for (int i = 0; i < _cached2DRenderers.Length; i++) { var r = _cached2DRenderers[i]; if (r != null) r.sortingOrder = sortOrder2D + i; } } // ================================================================ // 动画 // ================================================================ /// 播放指定类型动画(需要先在 dic_AnimKeyAnimName 中注册)。 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); } // ================================================================ // 受击 & 死亡 // ================================================================ /// 触发受击闪烁(0.2s 后自动关闭)。子类可 override 实现受击阶段逻辑(如爆裂金鲨首炮切战损)。 public virtual void OnHurt() { if (hurt != null) StartCoroutine(HurtCoroutine()); } private IEnumerator HurtCoroutine() { hurt.SetActive(true); yield return new WaitForSeconds(0.2f); hurt.SetActive(false); } /// /// 执行死亡:播放音效并销毁 GameObject。 /// 子类可 override 添加额外逻辑(如 Boss 爆炸动画)。 /// ⭐ 统一设置 isDead=true:死亡后子弹不再命中、不再被锁定。 /// (不调 base 的子类由 CreateFishManager.Ws_DieFish 入口统一标记) /// 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); } // ================================================================ // 工具方法 // ================================================================ /// 递归深度查找子物体(广度优先)。 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; } }