// ============================================================ // 文件:CreateFishManager.cs // 职责:捕鱼游戏 —— 鱼的生成 / 销毁 / 鱼阵管理核心管理器 // 优化点: // 1. 消除 Update 中重复创建 List、字典查找性能问题 // 2. 统一鱼阵路线选取逻辑,减少冗余代码 // 3. FishSpawnTimerJob shouldSpawn 逻辑修正(原逻辑只在 ==0 时计时,现保持一致) // 4. 增加必要的空检查与防御性编程 // 5. 清理无用注释与废弃代码块 // 6. 方法职责单一化拆分 // ============================================================ using Assets.MyGame.Scripts.Characters.Monster; using Assets.MyGame.Scripts.Net; using Assets.MyGame.Scripts.Net.API; using Assets.MyGame.Scripts.Net.API.GameConfig_SubClass; using Assets.MyGame.Scripts.Net.APIResponses.Enum; using Assets.MyGame.Scripts.Net.APIResponses.GameConfig_SubClass; using Assets.MyGame.Scripts.Net.WebResponses; using cfg; using Newtonsoft.Json; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using TMPro; using Unity.Collections; using Unity.Jobs; using UnityEditor; using UnityEngine; using UnityEngine.Serialization; using Random = UnityEngine.Random; /// /// 捕鱼游戏核心管理器:负责鱼的定时生成、网络同步、阵型调度、死亡特效。 /// 继承 MonoSingleton 保证全局唯一。 /// public class CreateFishManager : MonoSingleton { // ---------------------------------------------------------------- // 外部依赖 // ---------------------------------------------------------------- /// 当前游戏全局配置(通过 GameManager 获取) public GameConfig M_GameConfig => GameManager.Instance.gameConfig; // ---------------------------------------------------------------- // 配置缓存(空间换时间,避免 List.Find O(n) 查找) // ---------------------------------------------------------------- /// roomFishConfigs 按游戏类型索引 private Dictionary roomFishConfig; /// 每个游戏类型下 fishNumber → RoomFishDetailConfig private Dictionary> createDic; /// fishNumber → FishTypeConfig(基础配置缓存) private Dictionary fishDatas; /// fishArrayNumber → FishArrayConfig(鱼阵配置缓存) private Dictionary fishArrayConfigDic; // ---------------------------------------------------------------- // UI 引用 // ---------------------------------------------------------------- /// 当前游戏面板(用于坐标转换、金币飞行等) public GameMainPanel_FishBase currGameType; // ---------------------------------------------------------------- // 死亡特效 Prefab(Inspector 赋值) // ---------------------------------------------------------------- public GameObject dieFXPrefab; // 基础死亡特效 public GameObject dieFXPrefabAward; // 普通鱼金币奖励特效 public GameObject dieFXPrefabAwardTmp; // Boss 分数浮动文本 public GameObject dieFXPrefabAwardTmp_AddScore; // ---------------------------------------------------------------- // 生成系统运行时数据 // ---------------------------------------------------------------- /// 鱼的父节点(保持场景层级整洁) public Transform fishRoot; /// 鱼的父节点(保持场景层级整洁) public Transform poolRoot; /// /// 非 UI 爆奖特效统一父节点(挂在场景根节点,不挂 UI 节点下)。 /// 仅装载那些"原本挂在 UIManager.uiRoot 下(不会随房间退出自动销毁)" /// 或"原本无父对象(世界空间特效)"的爆奖特效: /// - BJEndUI 系列(BJEventBase/GSL/FLXJ 的 EndBJ) /// - DieEffect_TSY / EffectZuanTouDie 的 baoJiangEffectPrefab /// - HHWK_SDY_DieEvent 的 explosionPrefab /// - HHWK_ZDX_DieEvent 的 bombPrefab / explosionPrefab /// - Boss鱼/特殊Boss isToUI=false 分支的 currEffect_DieAward /// UI 爆奖(挂在 subPanelParent/subPanelParent.parent 下的金币飞、分数版等) /// 不挂这里,由 UIManager 卸载游戏主面板时自动清理。 /// ⭐ 本节点不能删除:ExitRoom 只清空子物体; /// 意外被删除/为 null 时取值会自动在场景根节点重建(不建到 UI 节点)。 /// [FormerlySerializedAs("bjEffectRoot")] [SerializeField] private Transform _bjEffectRoot; /// 爆奖特效统一父节点:为 null(意外删除)时自动在场景根节点重建 public Transform bjEffectRoot { get { if (_bjEffectRoot == null) EnsureBJEffectRoot(); return _bjEffectRoot; } } /// 当前房间可生成鱼列表(与 NativeArray 下标对齐) public List spawnList; /// 当前房间 fishNumber → RoomFishDetailConfig public Dictionary spawnDic; public bool isCreateFish; //是否可以创建鱼 public bool isFrozen = false; //是否冰冻中(冻结出鱼+所有鱼定格) [Header("调试:手动出鱼")] [Tooltip("按 M 键生成的鱼编号(配置 fishNumber)")] public string debugSpawnFishNumber = "11043"; public bool isleft = true; [Tooltip("按M键批量模式:激活=本地在所有普通鱼路线上各生成一条(不走服务端);不激活=走服务端出一条")] public bool debugSpawnAllLines = false; private Dictionary frozenFishStates = new Dictionary(); private Dictionary frozenFishIsMoves = new Dictionary(); /// 按鱼类型分类的生成配置(Boss / 普通 / 黄金…) private Dictionary> fishTypeConfig; // ------ 鱼阵路线相关 ------ /// arrayNumber → 该阵型所有可用生成配置 private Dictionary> yz_FishConfig_Line; /// 列表下标 → arrayNumber(用于随机取阵型) private Dictionary yz_FishConfig_Line_Dic; /// arrayNumber → 列表下标(反向索引) private Dictionary yz_FishConfig_Line_Dic1; /// 二维列表,与字典保持同步(供随机无重复取阵型用) private List> yz_FishConfig_Line_List; // ------ Job System NativeArray ------ private NativeArray timers; // 每条鱼的累计计时 private NativeArray frequencies; // 每条鱼的生成间隔 private NativeArray shouldSpawn; // 1=本帧应尝试生成,0=冷却中 // ---------------------------------------------------------------- // 运行时鱼实例字典(guid 为 key) // ---------------------------------------------------------------- /// 全部存活鱼(包含 Boss / 小鱼 / 黄金鱼) public Dictionary fishDatas_hashCodekey = new Dictionary(); /// 普通鱼 public Dictionary fishDatas_hashCodekey_Small = new Dictionary(); /// Boss 鱼 public Dictionary fishDatas_hashCodekey_Boss = new Dictionary(); /// 黄金 public Dictionary fishDatas_hashCodekey_HJ = new Dictionary(); /// 特殊鱼(非 Boss) public Dictionary fishDatas_hashCodekey_TS = new Dictionary(); /// 特殊 Boss public Dictionary fishDatas_hashCodekey_TS_Boss = new Dictionary(); #if UNITY_EDITOR // ⭐ 编辑器调试 List:与上方字典同步,方便在 Inspector 查看当前场上活鱼 public List fishList_All = new List(); public List fishList_Small = new List(); public List fishList_Boss = new List(); public List fishList_HJ = new List(); public List fishList_TS = new List(); public List fishList_TS_Boss = new List(); #endif // ------ 各类计时器 ------ private RoomFishProbabilityConfig tsbossCreateData; // 特殊Boss 创建数据 private float tsbossTimer = 999f; // 特殊Boss 刷新倒计时(秒) private int tsbossCount; // 本轮同时启用特殊Boss鱼数量 private int tsbossQuanZhongCount; // 本轮同时启用特殊Boss鱼数量 private List tsbossRecentList = new List(); // 最近出现过的特殊Boss(cool down) private int tsbossRecentMax = 3; // N次内不重复出现 private RoomFishProbabilityConfig tsCreateData; // 特殊鱼 创建数据 private float tsTimer = 999f; // 特殊鱼 刷新倒计时(秒) private int tsCount; // 本轮同时启用特殊鱼数量 private int tsQuanZhongCount; // 本轮同时启用特殊鱼数量 private RoomFishProbabilityConfig bossCreateData; // Boss 创建数据 private float bossTimer = 999f; // Boss 刷新倒计时(秒) private int bossCount; // 本轮同时启用Boss鱼数量 private int bossQuanZhongCount; // 本轮同时启用Boss鱼数量 private List bossRecentList = new List(); // 最近出现过的Boss(cool down) private int bossRecentMax = 3; // N次内不重复出现 /// Boss鱼路线奇偶切换标记:true=下次选奇数路线,false=下次选偶数路线 private bool bossNextPathOdd = true; private RoomFishProbabilityConfig yzCreateData; // 鱼阵创建数据 private float yzTimer = 999f; // 鱼阵刷新倒计时(秒) private int yzCount; // 本轮同时激活阵型数量 private int yz_LineCount = 0; // 场景内鱼阵路线总数 private int yzQuanZhongCount = 0; // 场景内鱼阵路线总数 private RoomFishProbabilityConfig ptCreateData; // 普通鱼创建数据 private float ptTimer = 999f; // 普通鱼批量启用倒计时(秒) private int ptCount; // 本轮同时启用普通鱼数量 private int ptQuanZhongCount; // 本轮同时启用普通鱼数量 private RoomFishProbabilityConfig hjCreateData; // 普通鱼创建数据 private float hjTimer = 999f; // 黄金鱼批量启用倒计时(秒) private int hjCount; // 本轮同时启用黄金鱼数量 private int hjQuanZhongCount; // 本轮同时启用黄金鱼数量 // fangzhu 字段已删除:鱼创建改由服务端 OnFishSpawn 统一下发,客户端无需房主出鱼 public bool isResources = false; /// /// 鱼阵实例全局计数器,每次 SpawnFishArray 递增。 /// 用于生成全局唯一 guid,防止 pathList 重复 pathId 或同 fishId 多次推送导致 guid 冲突。 /// guid 格式: baseGuid_arraySeq_i (如 16501_3_1),arraySeq 每次鱼阵递增,确保唯一。 /// private int _fishArraySeq = 0; // ================================================================ // 懒加载属性 // ================================================================ /// /// 鱼基础配置字典(fishNumber → FishTypeConfig),首次访问时构建。 /// public Dictionary FishDatas { get { if (fishDatas != null) return fishDatas; fishDatas = new Dictionary(M_GameConfig.fishTypeConfigs.Count); foreach (var item in M_GameConfig.fishTypeConfigs) fishDatas[item.fishNumber] = item; return fishDatas; } } /// /// 鱼阵配置字典(fishArrayNumber → FishArrayConfig),首次访问时构建。 /// public Dictionary FishArrayDatas { get { if (fishArrayConfigDic != null) return fishArrayConfigDic; fishArrayConfigDic = new Dictionary(M_GameConfig.fishArrayConfigs.Count); foreach (var item in M_GameConfig.fishArrayConfigs) fishArrayConfigDic[item.fishArrayNumber] = item; return fishArrayConfigDic; } } /// /// 房间鱼配置(按游戏类型),首次访问时触发懒加载初始化。 /// public Dictionary RoomFishConfig { get { EnsureInit(); return roomFishConfig; } } /// /// 游戏类型 → (fishNumber → RoomFishDetailConfig),首次访问时触发懒加载。 /// public Dictionary> CreateDic { get { EnsureInit(); return createDic; } } #region 生命周期 // ================================================================ // Unity 生命周期 // ================================================================ protected override void Awake() { base.Awake(); yzCreateData = new RoomFishProbabilityConfig { countRange = new List { 2, 3 }, firstFishCountRange = new List { 2, 3 }, roomLevel = 3, fishType = 0, maxCountRange = new List { 10, 20 }, timeRange = new List { 3, 4 }, type = 2 }; // 确保 FishRoot 存在 if (fishRoot == null) fishRoot = new GameObject("FishRoot").transform; if (poolRoot == null) poolRoot = new GameObject("poolRoot").transform; // ⭐ 鱼创建改由服务端 OnFishSpawn 事件统一下发,客户端不再监听 createFish 帧同步 EventCenter.Instance.AddEventListener( ConstEventString.OnFishSpawn, OnFishSpawn); } // ================================================================ // Update:冰冻时不做任何事(鱼创建由服务端 OnFishSpawn 驱动) // ================================================================ private void Update() { // 鱼创建由服务端 OnFishSpawn 事件驱动,客户端 Update 不再出鱼 // (保留 isFrozen 字段供 FreezeAllFish/UnfreezeAllFish 使用) // ⭐ 调试用:按 M 键发送 HTTP 请求到服务端 AdminApi 出鱼 // roomId 从 gameData.currRoomData 读取,fishNumber 从 Inspector 配置读取 if (UnityEngine.InputSystem.Keyboard.current != null && UnityEngine.InputSystem.Keyboard.current.mKey.wasPressedThisFrame) { if (string.IsNullOrEmpty(debugSpawnFishNumber)) { Debug.LogWarning("[CreateFishManager] 未配置 debugSpawnFishNumber,请在 Inspector 设置鱼编号"); return; } string roomId = GameManager.Instance.gameData?.currRoomData?.roomId; if (string.IsNullOrEmpty(roomId)) { Debug.LogWarning("[CreateFishManager] 当前未在房间中(roomId 为空),无法出鱼"); return; } // ⭐ debugSpawnAllLines 激活:本地在所有普通鱼路线("曲线"+id)上各生成一条(不走服务端); // 不激活:走服务端 AdminApi 出一条(现状) if (debugSpawnAllLines) { int spawned = 0; foreach (var key in MonsterMoveLine.dic_BezierCurveDatas.Keys) { if (key == null || !key.StartsWith("曲线")) continue; if (!int.TryParse(key.Substring(2), out int rid)) continue; if (ManualSpawn(debugSpawnFishNumber, rid, 30)) spawned++; } Debug.Log($"[CreateFishManager] 调试批量出鱼(所有路线): fishNumber={debugSpawnFishNumber}, 共生成{spawned}条"); } else { APIManager.TempSpawnFish(roomId, debugSpawnFishNumber,1,30, isleft?1:2); } } } private void OnDestroy() { EventCenter.Instance.RemoveEventListener( ConstEventString.OnFishSpawn, OnFishSpawn); // 释放 NativeArray,避免内存泄漏 DisposeNativeArrays(); } #endregion #region 初始化 public void InitChuYu() { // 随机初始化计时器,避免所有类型同帧触发 yzCount = Random.Range(yzCreateData.countRange[0], yzCreateData.countRange[1]); ptCount = Random.Range(ptCreateData.countRange[0], ptCreateData.countRange[1]); hjCount = Random.Range(hjCreateData.countRange[0], hjCreateData.countRange[1]); bossCount = Random.Range(bossCreateData.countRange[0], bossCreateData.countRange[1]); tsbossCount = Random.Range(tsbossCreateData.countRange[0], tsbossCreateData.countRange[1]); tsCount = Random.Range(tsCreateData.countRange[0], tsCreateData.countRange[1]); yzTimer = 0.5f; ptTimer = 0.5f; hjTimer = 0.5f; tsTimer = 1f; bossTimer = 5f; tsbossTimer = 5f; } /// /// 懒加载初始化:只执行一次,构建全部配置缓存字典。 /// private void EnsureInit() { if (roomFishConfig != null) return; FishYZ.Init(); roomFishConfig = new Dictionary(); createDic = new Dictionary>(); var list = GameManager.Instance.gameConfig.roomFishConfigs; if (list == null || list.Count == 0) return; // 构建 fishArray / fishType 字典缓存 BuildConfigCache(); foreach (var item in list) { var gameType = (GameTypeEnum)item.gameType; roomFishConfig[gameType] = item; var fishDic = new Dictionary(); createDic[gameType] = fishDic; // 普通鱼列表 if (item.normalFishList != null) foreach (var fish in item.normalFishList) fishDic[fish.fishNumber] = fish; // 等级鱼列表(同 fishNumber 会覆盖普通鱼,实现换皮) if (item.roomFishLevelList != null) foreach (var fish in item.roomFishLevelList) fishDic[fish.fishNumber] = fish; } } /// /// 构建高频查询的字典缓存(fishArrayConfigs / fishTypeConfigs)。 /// private void BuildConfigCache() { fishArrayConfigDic = GameManager.Instance.gameConfig.fishArrayConfigs .ToDictionary(a => a.fishArrayNumber, a => a); fishDatas = GameManager.Instance.gameConfig.fishTypeConfigs .ToDictionary(f => f.fishNumber, f => f); } #endregion #region 进入房间加载 /// 进入房间:初始化生成系统并绑定游戏面板。 public void EnterRoom(GameTypeEnum gameType) { // ⭐ 复位残留冰冻状态:冰冻中切房时旧面板倒计时未走完就销毁, // 不会触发 UnfreezeAllFish → isFrozen 残留 true → 新房间 OnFishSpawn 全部被拦,不出鱼 ResetFreezeState(); InitSpawnSystem(gameType); BindCurrentGamePanel(); EnsureBJEffectRoot(); // ⭐ 回放进房加载期间收到的出鱼事件(服务端进房瞬间推的 init/sync 初始鱼) FlushPendingFishSpawns(); } /// /// 创建爆奖特效统一父节点(挂在场景根节点,不挂 UI 节点下)。 /// 已存在则不重复创建。所有爆奖相关 Instantiate 都应使用此节点作为父对象。 /// private void EnsureBJEffectRoot() { if (_bjEffectRoot != null) return; // ⭐ 挂场景根节点(不挂 UIManager.uiRoot):世界空间爆奖特效直接用世界坐标摆放 var go = new GameObject("bjEffectRoot"); _bjEffectRoot = go.transform; } /// 根据当前 GameType 绑定对应 UI 面板到 currGameType。 private void BindCurrentGamePanel() { switch (GameManager.Instance.gameData.currGameType) { case GameType.FLZY: currGameType = UIManager.Instance.Get(UIPanel.MainGamePanelFLZY); break; case GameType.YYSC: currGameType = UIManager.Instance.Get(UIPanel.MainGamePanelYYSC); break; case GameType.XJMH: currGameType = UIManager.Instance.Get(UIPanel.MainGamePanelXJMH); break; case GameType.HHWK: currGameType = UIManager.Instance.Get(UIPanel.MainGamePanelHHWK); break; default: break; } } /// /// 进入房间时初始化生成系统:加载当前房间配置、分配 NativeArray。 /// public async void InitSpawnSystem(GameTypeEnum gameType) { EnsureInit(); // 初始化分类容器 fishTypeConfig = new Dictionary>(); yz_FishConfig_Line = new Dictionary>(); yz_FishConfig_Line_Dic = new Dictionary(); yz_FishConfig_Line_Dic1 = new Dictionary(); yz_FishConfig_Line_List = new List>(); spawnList = new List(); spawnDic = new Dictionary(); var config = RoomFishConfig[gameType]; // ---- 处理普通鱼列表 ---- if (config.normalFishList != null) { foreach (var item in config.normalFishList) { if (!spawnDic.ContainsKey(item.fishNumber)) { spawnDic[item.fishNumber] = item; spawnList.Add(item); } if (item.type == 1) { // 单体鱼:按鱼类型分组 AddToFishTypeConfig(item); } else if (item.type == 2) { // 鱼阵:按 arrayNumber 分组 TryAddToArrayLine(item); } } } // ---- 处理等级鱼列表(仅当前房间等级)---- int currRoomLevel = GameManager.Instance.gameData.currRoomData.roomLevel; if (config.roomFishLevelList != null) { foreach (var item in config.roomFishProbabilityList) { if (item.roomLevel != currRoomLevel) continue; switch (item.type) { case 1: switch ((FishTypeEnum)item.fishType) { case FishTypeEnum.普通鱼: ptCreateData = item; break; case FishTypeEnum.Boss鱼: bossCreateData = item; break; case FishTypeEnum.黄金鱼: hjCreateData = item; break; case FishTypeEnum.特殊鱼: tsCreateData = item; break; case FishTypeEnum.特殊Boss: tsbossCreateData = item; break; default: break; } break; case 2: //yzTimer=item. yzCreateData = item; break; default: break; } } foreach (var item in config.roomFishLevelList) { if (item.roomLevel != currRoomLevel) continue; if (item.type == 2) { spawnDic[item.fishNumber] = item; spawnList.Add(item); yzQuanZhongCount += item.fishWeight; TryAddToArrayLine(item); } else { AddToFishTypeConfig(item); // 等级鱼也加入 spawnDic / spawnList spawnDic[item.fishNumber] = item; spawnList.Add(item); fishDatas.TryGetValue(item.fishNumber, out var ft); switch ((FishTypeEnum)ft.fishType) { case FishTypeEnum.普通鱼: ptQuanZhongCount += item.fishWeight; break; case FishTypeEnum.Boss鱼: bossQuanZhongCount += item.fishWeight; break; case FishTypeEnum.黄金鱼: hjQuanZhongCount += item.fishWeight; break; case FishTypeEnum.特殊鱼: tsQuanZhongCount += item.fishWeight; break; case FishTypeEnum.特殊Boss: tsbossQuanZhongCount += item.fishWeight; break; default: break; } } } } // ---- 分配 Job System NativeArray ---- int count = spawnList.Count; DisposeNativeArrays(); // 防止重复进房泄漏 timers = new NativeArray(count, Allocator.Persistent); frequencies = new NativeArray(count, Allocator.Persistent); shouldSpawn = new NativeArray(count, Allocator.Persistent); for (int i = 0; i < count; i++) { timers[i] = 0f; frequencies[i] = spawnList[i].fishFrequency; shouldSpawn[i] = 1; // 初始全部允许生成 } // ---- 统计场景内鱼阵路线总数 ---- yz_LineCount = 0; foreach (var key in MonsterMoveLine.dic_BezierCurveDatas.Keys) { if (key.StartsWith("鱼阵")) yz_LineCount++; } InitChuYu(); } /// /// 将单体鱼配置按 FishTypeEnum 加入分类字典。 /// private void AddToFishTypeConfig(RoomFishDetailConfig item) { if (!FishDatas.TryGetValue(item.fishNumber, out var ft)) return; var typeEnum = (FishTypeEnum)ft.fishType; if (!fishTypeConfig.ContainsKey(typeEnum)) fishTypeConfig[typeEnum] = new List(); fishTypeConfig[typeEnum].Add(item); } /// /// 将鱼阵配置按 arrayNumber 加入路线字典(使用缓存替代 List.Find)。 /// private void TryAddToArrayLine(RoomFishDetailConfig item) { if (!fishArrayConfigDic.TryGetValue(item.fishNumber, out var yz)) return; if (!yz_FishConfig_Line.ContainsKey(yz.arrayNumber)) { yz_FishConfig_Line[yz.arrayNumber] = new List(); yz_FishConfig_Line_List.Add(new List()); int newIdx = yz_FishConfig_Line_List.Count - 1; yz_FishConfig_Line_Dic[newIdx] = yz.arrayNumber; yz_FishConfig_Line_Dic1[yz.arrayNumber] = newIdx; } yz_FishConfig_Line[yz.arrayNumber].Add(item); yz_FishConfig_Line_List[yz_FishConfig_Line_Dic1[yz.arrayNumber]].Add(item); } #endregion #region OnFishSpawn 服务端出鱼接收处理 // ⭐ 鱼创建改由服务端 OnFishSpawn 事件统一下发 // 客户端 BroadCastCreateFish / TickXxxSpawn / SendCreateFishRequest / // CreateInitialFishes / BroadcastExistingFishes / OnRequestSyncFish / CreateFish // 全部删除,改为接收 OnFishSpawnData 后调用 SpawnFishMainThread 生成鱼实例 // // type: // "init" - 首次出鱼(玩家进入房间后服务端推送初始鱼) // "spawn" - 定时出鱼(游戏中持续刷怪) // "sync" - 重连/取消挂机同步(把当前场上活鱼推送给回归玩家) /// /// 接收服务端 OnFishSpawn 事件,生成鱼实例。 /// 遍历 data.fish 列表,对每条鱼: /// 1. guid 去重(已有此 guid 的鱼跳过,防 sync 模式重复 Spawn) /// 2. 构造 FrameData_CreateFish 复用 SpawnFishMainThread 生成 /// 3. 启动 expireAt 过期自动移除协程 /// public void OnFishSpawn(OnFishSpawnData data) { if (data == null || data.fish == null || data.fish.Count == 0) return; if (isFrozen) return; // 冰冻中不创建鱼 // ⭐ 修复:生成系统未就绪(进房加载中/退房清理后 spawnDic==null)时不再丢弃事件, // 先入队,EnterRoom 完成后回放。服务端在进房瞬间就会推 OnFishSpawn(init/sync)初始鱼, // 而此时客户端还在 LoadResources 加载(数秒),直接 return 会把初始鱼全部丢掉 → 进房不出鱼。 if (spawnDic == null) { if (_pendingFishSpawns == null) _pendingFishSpawns = new List(); _pendingFishSpawns.Add(data); Debug.Log($"[CreateFishManager] 生成系统未就绪,出鱼事件入队等待回放: type={data.type}, count={data.fish.Count}, queued={_pendingFishSpawns.Count}"); return; } // ⭐ 分帧出鱼:普通鱼/黄金鱼分批生成(每帧5条),其他类型立即全出 // 避免一次出大量普通鱼导致单帧卡顿 var batchedQueue = new List(data.fish.Count); bool isSync = data.type == "sync"; foreach (var item in data.fish) { if (item == null) continue; // 判断是否需要分帧:普通鱼(1)和黄金鱼(3)且非鱼阵 → 分帧 bool shouldBatch = ShouldBatchSpawn(item); if (shouldBatch) { batchedQueue.Add(item); } else { // Boss/特殊鱼/鱼阵等:立即生成 TrySpawnSingleFish(item, isSync); } } // 分帧组:启动协程逐帧生成 if (batchedQueue.Count > 0) { StartCoroutine(SpawnFishBatchedCoroutine(batchedQueue, isSync)); } NetLog.Log($"[CreateFishManager] OnFishSpawn type={data.type}, count={data.fish.Count}, batched={batchedQueue.Count}"); } /// 生成系统未就绪期间收到的出鱼事件队列(EnterRoom 完成后回放) private List _pendingFishSpawns; /// /// 回放进房加载期间收到的出鱼事件(服务端在进房瞬间推的 init/sync 初始鱼)。 /// 在 EnterRoom 末尾调用:此时配置/资源/面板都已就绪。 /// private void FlushPendingFishSpawns() { if (_pendingFishSpawns == null || _pendingFishSpawns.Count == 0) return; var queue = _pendingFishSpawns; _pendingFishSpawns = null; // 先置空,回放期间新到事件走正常路径 Debug.Log($"[CreateFishManager] 回放加载期间出鱼事件: {queue.Count} 批"); foreach (var data in queue) { try { OnFishSpawn(data); } catch (System.Exception e) { Debug.LogWarning($"[CreateFishManager] 回放出鱼事件失败: {e.Message}"); } } } /// /// 判断该鱼是否需要分帧生成。 /// 普通鱼(1)和黄金鱼(3)且非鱼阵 → 分帧;其他(Boss/特殊鱼/特殊Boss/鱼阵) → 立即出。 /// private bool ShouldBatchSpawn(OnFishSpawnFishItem item) { // 鱼阵一次性全出(fishArrayNumber 非空表示鱼阵) if (!string.IsNullOrEmpty(item.fishArrayNumber)) return false; // 通过 fishNumber 查 FishTypeConfig 判断鱼类型 if (string.IsNullOrEmpty(item.fishNumber)) return false; if (FishDatas != null && FishDatas.TryGetValue(item.fishNumber, out var cfg)) { int ft = cfg.fishType; // 普通鱼=1, 黄金鱼=3 → 分帧 return ft == (int)FishTypeEnum.普通鱼 || ft == (int)FishTypeEnum.黄金鱼; } // 查不到配置,默认分帧(保守处理,避免未知类型一次大量生成) return true; } /// /// 分帧出鱼协程:每帧生成 batchSize 条,避免单帧卡顿。 /// private IEnumerator SpawnFishBatchedCoroutine(List queue, bool isSync) { const int batchSize = 5; // 每帧生成5条 int index = 0; while (index < queue.Count) { // 冰冻中持续等待解冻,避免冰冻期间继续生成鱼 while (isFrozen) yield return null; int end = Mathf.Min(index + batchSize, queue.Count); for (int i = index; i < end; i++) { TrySpawnSingleFish(queue[i], isSync); } index = end; yield return null; // 等待下一帧 } } /// /// 尝试生成单条鱼(含去重检查)。返回是否成功生成。 /// private bool TrySpawnSingleFish(OnFishSpawnFishItem item, bool isSync = false) { if (item == null) return false; NetLog.Log("差值:" + (BeijingTimeManager.Instance.CurrentTimestampMs - item.createdAt) / 1000); NetLog.Log("差值:" + (BeijingTimeManager.Instance.CurrentTimestampMs - item.createdAt)); NetLog.Log("差值:" + BeijingTimeManager.Instance.CurrentTimestampMs + "创建\n" + item.createdAt); // ⭐ 鱼阵时 fishNumber 为空,用 fishArrayNumber 作为配置 key; // 单体鱼时 fishNumber 非空,直接用 fishNumber string cfgKey = string.IsNullOrEmpty(item.fishNumber) ? item.fishArrayNumber : item.fishNumber; if (string.IsNullOrEmpty(cfgKey)) return false; // guid 用服务端 fishId string guid = item.fishId.ToString(); // guid 去重:已有此 guid 的鱼跳过 // ⭐ 鱼阵鱼的 guid 是 baseGuid_pathId_i 格式,字典里没有 baseGuid 本身, // 所以要额外检查是否有以 baseGuid+"_" 开头的 key,防止 sync 模式重复出鱼 // 导致 guid 冲突覆盖(旧鱼变野鱼无法销毁) // ⭐ 优化:用 foreach 代替 LINQ Any(StartsWith),消除 Lambda 闭包和迭代器 GC if (!string.IsNullOrEmpty(guid) && fishDatas_hashCodekey != null) { if (fishDatas_hashCodekey.ContainsKey(guid)) return false; string guidPrefix = guid + "_"; bool hasPrefixMatch = false; foreach (var key in fishDatas_hashCodekey.Keys) { if (key != null && key.StartsWith(guidPrefix)) { hasPrefixMatch = true; break; } } if (hasPrefixMatch) return false; } // 构造 FrameData_CreateFish 复用 SpawnFishMainThread var createFishData = new FrameData_CreateFish { fishNumber = cfgKey, guid = guid, moveLineId = item.routeId.ToString() }; // ⭐ 根据 type 决定 moveTime: // - spawn/init:新鱼,传当前时间 → moveTime≈0,鱼从曲线起点开始游 // (避免服务端/客户端时间差或事件排队回放延迟导致 moveTime>0, // 鱼凭空出现在曲线中间甚至已过路径末端) // - sync:同步已有鱼,传 createdAt → moveTime=时间差,鱼出现在路径正确位置 long spawnTime = isSync ? item.createdAt : BeijingTimeManager.Instance.CurrentTimestampMs; SpawnFishMainThread(createFishData, spawnTime); return true; } /// /// 鱼过期自动移除协程。 /// remainingSeconds 秒后调用 Id_DieFish_NotFX 销毁鱼(无特效)。 /// private IEnumerator ExpireFishCoroutine(string guid, int remainingSeconds) { yield return new WaitForSeconds(remainingSeconds); Id_DieFish_NotFX(guid); } /// /// 手动出鱼(测试/调试用,不走服务端协议)。 /// 用当前时间作为 spawnTime → moveTime≈0,鱼从曲线起点开始游。 /// /// 鱼编号(配置 fishNumber) /// 路径编号,<=0 时随机取一条 /// 鱼存活秒数,<=0 时默认 30 秒 /// 生成成功返回 true,失败返回 false public bool ManualSpawn(string fishNumber, int routeId = 0, int remainingSeconds = 30) { if (isFrozen) { Debug.LogWarning("[CreateFishManager] 冰冻中,手动出鱼失败"); return false; } if (spawnDic == null) { Debug.LogWarning("[CreateFishManager] spawnDic 未初始化,手动出鱼失败"); return false; } if (string.IsNullOrEmpty(fishNumber)) { Debug.LogWarning("[CreateFishManager] fishNumber 为空,手动出鱼失败"); return false; } if (!spawnDic.ContainsKey(fishNumber)) { Debug.LogWarning($"[CreateFishManager] 找不到鱼配置: {fishNumber},手动出鱼失败"); return false; } // routeId<=0 时随机取一条路线(1~4 范围,按现有路线命名规则) if (routeId <= 0) routeId = Random.Range(1, 5); // 生成唯一 guid(用手动出鱼前缀 + 时间戳,避免与服务端 fishId 冲突) string guid = "manual_" + BeijingTimeManager.Instance.CurrentTimestampMs + "_" + Random.Range(1000, 9999); var createFishData = new FrameData_CreateFish { fishNumber = fishNumber, guid = guid, moveLineId = routeId.ToString() }; // 用当前时间 → moveTime≈0,鱼从曲线起点开始游 SpawnFishMainThread(createFishData, BeijingTimeManager.Instance.CurrentTimestampMs); //if (remainingSeconds > 0) // StartCoroutine(ExpireFishCoroutine(guid, remainingSeconds)); Debug.Log($"[CreateFishManager] 手动出鱼: fishNumber={fishNumber}, routeId={routeId}, guid={guid}"); return true; } #endregion #region 旧客户端出鱼链路(已删除) // 以下方法已删除,鱼创建改由服务端 OnFishSpawn 事件下发: // - BroadCastCreateFish / TickBossSpawn / TickTSBossSpawn / TickFishArraySpawn // - TickPTFishSpawn / TickHJFishSpawn / TickTSFishSpawn // - ProcessSpawnRequests / SendCreateFishRequest // - CreateInitialFishes / BroadcastExistingFishes / OnRequestSyncFish // - CreateFish(SyncGameData) // 保留方法:SpawnFishMainThread / OnlyFish / SpawnFishArray 等(被 OnFishSpawn 调用) #endregion #region 鱼生成核心逻辑 // ================================================================ // 旧定时 Tick 方法(已删除,鱼创建改由服务端 OnFishSpawn 事件下发) // ================================================================ // TickBossSpawn / TickTSBossSpawn / TickFishArraySpawn / TickPTFishSpawn // TickHJFishSpawn / TickTSFishSpawn / buchongfu / ProcessSpawnRequests // SendCreateFishRequest / CreateInitialFishes / BroadcastExistingFishes // OnRequestSyncFish / CreateFish(SyncGameData) // —— 全部删除,改由 OnFishSpawn(OnFishSpawnData) 接收服务端出鱼事件 —— // ================================================================ // 单体鱼 / 鱼阵生成(被 OnFishSpawn 调用) // ================================================================ /// /// 主线程实例化鱼对象(单体鱼 / 鱼阵分发)。 /// private void SpawnFishMainThread(FrameData_CreateFish createFishData, long time) { // ⭐ spawnDic 可能在场景卸载时被置 null,这里再保护一次 if (spawnDic == null) return; if (!spawnDic.TryGetValue(createFishData.fishNumber, out var cfg)) { Debug.LogWarning($"[CreateFishManager] 找不到 spawnDic 配置: {createFishData.fishNumber}"); return; } if (cfg.type == 1) { // 单体鱼 OnlyFish(createFishData, time); } else if (cfg.type == 2) { // 鱼阵 SpawnFishArray(createFishData, cfg.fishNumber, time); } } // ================================================================ // 单体鱼生成 // ================================================================ /// /// 生成一条普通/Boss/黄金鱼并初始化路径。 /// /// 成功返回 true,配置缺失返回 false。 private bool OnlyFish(FrameData_CreateFish createFishData, long time) { if (!fishDatas.TryGetValue(createFishData.fishNumber, out var fishType)) { Debug.LogError($"[CreateFishManager] 找不到 fishTypeConfig: {createFishData.fishNumber}"); return false; } //var prefab = FishResourcesData.GetFishPrefab(int.Parse(fishType.fishNumber)); //var fish = Instantiate(prefab, new Vector3(9999, 9999, 0), prefab.transform.rotation, fishRoot); try { var fish = Pool.Instance.Get(int.Parse(createFishData.fishNumber), new Vector3(9999, 9999, 0), Quaternion.identity, fishRoot); if (fish == null) { Debug.LogError($"[CreateFishManager] Pool.Get 返回 null,跳过此鱼: {createFishData.fishNumber}"); return false; } var mc = fish.GetComponent() ?? fish.AddComponent(); mc.monsterData.fishTypeConfig = FishDatas[createFishData.fishNumber]; mc.guid = createFishData.guid; // ⭐ moveTime 计算:当前时间 - 创建时间(秒) float elapsedSec = (BeijingTimeManager.Instance.CurrentTimestampMs - time) / 1000f; // ⭐ 创建视频时长抵扣: // - 配置了 createVideoClip 且 elapsedSec < 视频时长:视频还没播完 // → moveTime=0(视频继续播),OnMoveUpdata 会跳过移动直到视频结束 // - 配置了 createVideoClip 且 elapsedSec >= 视频时长:视频已播完 // → moveTime = elapsedSec - 视频时长(视频结束后才开始累计路线时间) // - 未配置 createVideoClip:moveTime = elapsedSec(原逻辑) if (mc.createVideoClip != null) { float videoDuration = (float)mc.createVideoClip.length; if (elapsedSec < videoDuration) { // 视频还没播完,继续播放(Init 里会启动 createVideoPlayer) mc.moveTime = 0f; } else { // 视频已播完,扣除视频时长,moveTime 为视频结束后已走的路线时间 mc.moveTime = elapsedSec - videoDuration; } } else { mc.moveTime = elapsedSec; } mc.fishenum = (FishTypeEnum)fishType.fishType; // 按鱼类型放入对应字典 RegisterFishInstance(mc, fishType.fishType); mc.Init(createFishData.moveLineId, spawnDic[createFishData.fishNumber].times); Debug.Log($"[CreateFishManager] 生成鱼:{fishType.fishName}"); } catch (Exception ex) { Debug.LogError(createFishData.fishNumber); Debug.LogError(ex.Message); } return true; } /// 将鱼实例注册到全局字典及对应分类字典。 private void RegisterFishInstance(MonsterContral mc, int fishTypeId) { // ⭐ 检测 guid 冲突:如果 guid 已存在,说明有重复出鱼(可能是 sync 模式重发), // 旧鱼会被覆盖变成野鱼(无法通过 Id_DieFish_NotFX 销毁) if (fishDatas_hashCodekey.TryGetValue(mc.guid, out var existing) && existing != null && existing != mc) { Debug.LogWarning($"[CreateFishManager] guid 冲突! guid={mc.guid}, 旧鱼={existing.gameObject.name} 被新鱼={mc.gameObject.name} 覆盖"); GameManager.LogFishFlow("COLLISION", mc.guid, $"旧鱼={existing.gameObject.name}(instId={existing.GetInstanceID()}) 被新鱼={mc.gameObject.name}(instId={mc.GetInstanceID()}) 覆盖,旧鱼强制回收", mc.GetInstanceID()); // ⭐ 旧鱼强制回收:被覆盖的旧鱼 GameObject 还活着但字典里已找不到, // 会变成野鱼永远停在屏幕上。这里主动回收旧鱼,避免泄漏 existing.isMove = false; // 停止移动,防止 OnMoveUpdata 继续跑 Pool.Instance.Return(existing.gameObject); } fishDatas_hashCodekey[mc.guid] = mc; // ⭐ 记录创建日志:包含 fishNumber/isyz/yzID/instId/dictCount,用于排查鱼阵鱼无法销毁 GameManager.LogFishFlow("CREATE", mc.guid, $"fishNumber={mc.monsterData?.fishTypeConfig?.fishNumber} type={fishTypeId} isyz={mc.isyz} yzID={mc.yzID} dictCount={fishDatas_hashCodekey.Count}", mc.GetInstanceID()); switch (fishTypeId) { case 1: // 普通鱼 fishDatas_hashCodekey_Small[mc.guid] = mc; break; case 2: // Boss fishDatas_hashCodekey_Boss[mc.guid] = mc; break; case 3: // 特殊鱼 / 黄金鱼 fishDatas_hashCodekey_HJ[mc.guid] = mc; break; case 4: fishDatas_hashCodekey_TS[mc.guid] = mc; break; case 5: fishDatas_hashCodekey_TS_Boss[mc.guid] = mc; break; } #if UNITY_EDITOR // ⭐ 同步到调试 List,方便 Inspector 查看 if (!fishList_All.Contains(mc)) fishList_All.Add(mc); switch (fishTypeId) { case 1: if (!fishList_Small.Contains(mc)) fishList_Small.Add(mc); break; case 2: if (!fishList_Boss.Contains(mc)) fishList_Boss.Add(mc); break; case 3: if (!fishList_HJ.Contains(mc)) fishList_HJ.Add(mc); break; case 4: if (!fishList_TS.Contains(mc)) fishList_TS.Add(mc); break; case 5: if (!fishList_TS_Boss.Contains(mc)) fishList_TS_Boss.Add(mc); break; } #endif } #endregion #region 鱼阵 // ================================================================ // 鱼阵生成(按 arrayNumber 前缀分发到对应阵型协程/方法) // ================================================================ /// 根据鱼阵配置分发到对应的生成逻辑。 private void SpawnFishArray(FrameData_CreateFish createFishData, string fishNumber, long time) { if (!fishArrayConfigDic.TryGetValue(fishNumber, out var yz)) { Debug.LogError($"[CreateFishManager] 找不到 fishArrayConfig: {fishNumber}"); return; } if (!fishDatas.TryGetValue(yz.fishNumber, out var fishType)) { Debug.LogError($"[CreateFishManager] 鱼阵 fishType 缺失: {yz.fishNumber}"); return; } if (!FishYZ.dicfishYZ.TryGetValue(yz.arrayNumber, out var pathList) || pathList.Count == 0) { Debug.LogWarning($"[CreateFishManager] 鱼阵路线为空: {yz.arrayNumber}"); return; } // ⭐ 日志:记录鱼阵入口,排查 pathList 重复导致 guid 冲突 GameManager.LogFishFlow("ARRAY_ENTER", createFishData.guid, $"arrayNumber={yz.arrayNumber} fishNumber={yz.fishNumber} fishCount={yz.fishCount} pathList=[{string.Join(",", pathList)}] dictCount={fishDatas_hashCodekey.Count}"); // ⭐ 递增鱼阵实例计数器,用于生成全局唯一 guid // 防止 pathList 中 pathId 重复,或同 fishId 多次推送导致 guid 冲突 int arraySeq = ++_fishArraySeq; var prefab = FishResourcesData.GetFishPrefab(int.Parse(fishType.fishNumber)); var pos = new Vector3(9999, 9999, 0); string prefix = yz.arrayNumber.Length >= 3 ? yz.arrayNumber.Substring(0, 3) : yz.arrayNumber; switch (prefix) { case "700": // 扇形阵:逐条延迟生成 StartCoroutine(IeShanXing(createFishData, yz, pos, prefab, pathList, time, yz.fishCount, arraySeq)); break; case "701": // 环形阵:无延迟;同一鱼阵内重复同一条线(同一出生点)时延迟 1s 再发射 StartCoroutine(IeQuan(createFishData, yz, pos, prefab, pathList, time, yz.fishCount, arraySeq)); break; case "702": case "7007": case "710": // 顺序阵:每条路径逐一延迟生成 case "704": //int chosen710 = (prefix == "710") // ? pathList[Random.Range(0, pathList.Count)] // : -1; StartCoroutine(YZ7007(createFishData, yz, pos, prefab, time)); //foreach (int pathId in pathList) //{ // int actualPath = (prefix == "710") ? chosen710 : pathId; // StartCoroutine(YZ7007(createFishData, yz, pos, prefab, actualPath, time, yz.fishCount)); //} break; default: Debug.LogWarning($"[CreateFishManager] 未知鱼阵前缀: {prefix}"); break; } } // ================================================================ // 鱼阵生成协程 / 方法 // ================================================================ /// 扇形阵:按路线列表逐条延迟 1s 生成一扇。 private IEnumerator IeShanXing(FrameData_CreateFish createFishData, FishArrayConfig yz, Vector3 pos, GameObject prefab, List pathList, long time, int count, int arraySeq) { for (int lineIndex = 0; lineIndex < pathList.Count; lineIndex++) { if (!isResources) { yield break; } ShanXing(createFishData, yz, pos, prefab, pathList[lineIndex], count, time, arraySeq, lineIndex); yield return new WaitForSeconds(1f); } } private void ShanXing(FrameData_CreateFish createFishData, FishArrayConfig yz, Vector3 pos, GameObject prefab, int pathId, int count, long time, int arraySeq, int lineIndex) { var cfg = spawnDic[createFishData.fishNumber]; // ⭐ 上下基准角(与原始方向判断相反取正):起点 y>0→+90°/y<0→-90° // (与MonsterContral.Init同key规则"鱼阵"+pathId查曲线) float baseAngle = -90f; if (MonsterMoveLine.dic_BezierCurveDatas.TryGetValue("鱼阵" + pathId, out var bd700)) baseAngle = bd700.start.y > 0f ? 90f : -90f; for (int i = 1; i <= 17; i++) { // 交替正负角度,形成扇形分布(从基准角向两侧±5°~±85°展开) float angle = i * 5f * (i % 2 == 0 ? -1f : 1f); // ⭐ 方向判断与原始flipY700一致:路线起点start.y<0的阵绕鱼头轴翻面180° // (鱼头方向不变,肚背朝向翻转);原始写法为 transform.Rotate(Z,180,Space.Self) float flipZ = (bd700 != null && bd700.start.y < 0f) ? 180f : 0f; // ⭐ 分布角度=基准角+偏移,绕世界Z轴左乘叠加(屏幕平面内展开); // Y=-90固定摆平,Z是朝前的轴(翻面阵+180) var rotation = Quaternion.AngleAxis(baseAngle + angle, Vector3.forward) * Quaternion.Euler(prefab.transform.eulerAngles.x, -90f, prefab.transform.eulerAngles.z + flipZ); //var fish = Instantiate(prefab, pos, rotation, fishRoot); var fish = Pool.Instance.Get(int.Parse(yz.fishNumber), pos, rotation, fishRoot); var mc = fish.GetComponent(); mc.monsterData.fishTypeConfig = FishDatas[yz.fishNumber]; // ⭐ guid 用 arraySeq + lineIndex 替代 pathId:arraySeq 每次鱼阵递增,lineIndex 为线列表下标, // 两者组合全局唯一,防止 pathList 重复 pathId(如[1,1,1,1,1])导致 guid 冲突旧鱼被回收 mc.guid = createFishData.guid + "_" + arraySeq + "_" + lineIndex + "_" + i; RegisterFishInstance(mc, FishDatas[yz.fishNumber].fishType); mc.moveTime = (BeijingTimeManager.Instance.CurrentTimestampMs - time) / 1000f; mc.Init(pathId.ToString(), cfg.times, true, yz.arrayNumber); // ⭐ 鱼阵调试日志:输出每条子鱼生成后的旋转与缩放(排查"有的正有的反") Debug.Log($"[鱼阵调试] 阵={yz.arrayNumber}(扇形700) pathId={pathId} 第{i}/{16}条 guid={mc.guid} " + $"rotation={fish.transform.eulerAngles.ToString("F1")} localScale={fish.transform.localScale.ToString("F3")}"); } } /// /// 环形阵:按路线列表遍历生成圈。 /// 正常无延迟(同帧出);⭐ 同一鱼阵内出现重复的同一条线(同一出生点)时, /// 延迟 1s 再发射,避免两圈叠在出生点。 /// private IEnumerator IeQuan(FrameData_CreateFish createFishData, FishArrayConfig yz, Vector3 pos, GameObject prefab, List pathList, long time, int count, int arraySeq) { var usedLines = new HashSet(); for (int lineIndex = 0; lineIndex < pathList.Count; lineIndex++) { if (!isResources) { yield break; } int pathId = pathList[lineIndex]; // ⭐ 该线本次鱼阵已出现过(同一出生点):延迟 1s 再发射 if (!usedLines.Add(pathId)) yield return new WaitForSeconds(1f); Quan(createFishData, yz, pos, prefab, pathId, count, time, arraySeq, lineIndex); } } /// /// 环形阵:在同一路径上生成 count 条鱼,角度均匀分布(360/count)。 /// private void Quan(FrameData_CreateFish createFishData, FishArrayConfig yz, Vector3 pos, GameObject prefab, int pathId, int count, long time, int arraySeq, int lineIndex) { if (count <= 0) return; var cfg = spawnDic[createFishData.fishNumber]; for (int i = 0; i < count; i++) { float angle = (360f / count) * i; // ⭐ 分布角度=基准90°+偏移(上下的鱼阵,中间点从90°起算,沿圈360°均分); // 绕世界Z轴左乘叠加(屏幕平面内展开);Y=-90固定摆平,Z是朝前的轴不动 var rotation = Quaternion.AngleAxis(90f + angle, Vector3.forward) * Quaternion.Euler(prefab.transform.eulerAngles.x, -90f, prefab.transform.eulerAngles.z); //var fish = Instantiate(prefab, pos, rotation, fishRoot); var fish = Pool.Instance.Get(int.Parse(yz.fishNumber), pos, rotation, fishRoot); var mc = fish.GetComponent(); mc.monsterData.fishTypeConfig = FishDatas[yz.fishNumber]; // ⭐ guid 用 arraySeq + lineIndex 替代 pathId:arraySeq 每次鱼阵递增,lineIndex 为线列表下标, // 两者组合全局唯一,防止 pathList 重复 pathId(如[1,1,1,1,1])导致 guid 冲突旧鱼被回收 mc.guid = createFishData.guid + "_" + arraySeq + "_" + lineIndex + "_" + i; RegisterFishInstance(mc, FishDatas[yz.fishNumber].fishType); mc.moveTime = (BeijingTimeManager.Instance.CurrentTimestampMs - time) / 1000f; mc.Init(pathId.ToString(), cfg.times, true, yz.arrayNumber); // ⭐ 鱼阵调试日志:输出每条子鱼生成后的旋转与缩放(排查"有的正有的反") Debug.Log($"[鱼阵调试] 阵={yz.arrayNumber}(环形701) pathId={pathId} 第{i + 1}/{count}条 guid={mc.guid} " + $"rotation={fish.transform.eulerAngles.ToString("F1")} localScale={fish.transform.localScale.ToString("F3")}"); } } /// /// 顺序阵:每隔 1s 生成一条鱼,共生成 count 条。 /// private IEnumerator YZ7007(FrameData_CreateFish createFishData, FishArrayConfig yz, Vector3 pos, GameObject prefab, long time) { if (!spawnDic.TryGetValue(createFishData.fishNumber, out var cfg)) { Debug.LogWarning($"[CreateFishManager] YZ7007 找不到 spawnDic 配置: {createFishData.fishNumber}"); yield break; } // ⭐ 7007 鱼阵:路线从 FishYZ.dicfishYZ[arrayNumber] 取第 0 个 // (数量由后端控制,客户端只读路线列表第 0 个) if (!FishYZ.dicfishYZ.TryGetValue(yz.arrayNumber, out var yzPathList) || yzPathList.Count == 0) { Debug.LogWarning($"[CreateFishManager] YZ7007 找不到鱼阵路线: {yz.arrayNumber}"); yield break; } int yzPathId = yzPathList[0]; for (int i = 0; i < yz.fishCount; i++) { if (!isResources) { yield break; } //var fish = Instantiate(prefab, pos, prefab.transform.rotation, fishRoot); var fish = Pool.Instance.Get(int.Parse(yz.fishNumber), pos, Quaternion.identity, fishRoot); if (fish == null) { Debug.LogWarning($"[CreateFishManager] YZ7007 Pool.Get 返回 null,跳过: {yz.fishNumber}"); yield return new WaitForSeconds(1f); continue; } var mc = fish.GetComponent() ?? fish.AddComponent(); mc.monsterData.fishTypeConfig = FishDatas[yz.fishNumber]; mc.guid = createFishData.guid + i; RegisterFishInstance(mc, FishDatas[yz.fishNumber].fishType); // 每条鱼额外减去已等待秒数,使路径起点与帧数同步 mc.moveTime = ((BeijingTimeManager.Instance.CurrentTimestampMs - time) / 1000f) - i; // ⭐ 路线用 FishYZ 取到的第 0 个,yzID 用 arrayNumber mc.Init(yzPathId.ToString(), cfg.times, true, yz.arrayNumber); // ⭐ 鱼阵调试日志:输出每条子鱼生成后的旋转与缩放(排查"有的正有的反") Debug.Log($"[鱼阵调试] 阵={yz.arrayNumber}(顺序阵) pathId={yzPathId} 第{i + 1}/{yz.fishCount}条 guid={mc.guid} " + $"rotation={fish.transform.eulerAngles.ToString("F1")} localScale={fish.transform.localScale.ToString("F3")}"); yield return new WaitForSeconds(1f); } } #endregion #region 结束游戏退出房间 /// 退出房间:关闭生成系统。 public void ExitRoom() { CloseSpawnSystem(); } /// /// 关闭生成系统:清理列表,销毁场景内所有鱼对象。 /// public async void CloseSpawnSystem() { //await Task.Delay(2000); // ⭐ 退房复位冰冻状态:冰冻中退房时旧面板倒计时未走完就销毁, // 不会触发 UnfreezeAllFish → isFrozen 残留 true → 污染下一个房间不出鱼 ResetFreezeState(); spawnList = null; spawnDic = null; // ⭐ 退房清理排队中的出鱼事件,防止旧房间残留鱼泄进下一个房间 if (_pendingFishSpawns != null) _pendingFishSpawns.Clear(); yz_FishConfig_Line_Dic = null; yz_FishConfig_Line = null; yz_FishConfig_Line_Dic1 = null; if (fishRoot == null) return; // ⭐ 收集要销毁的对象列表,避免遍历时字典被修改或对象已销毁导致 MissingReferenceException var fishToDestroy = new List(); foreach (var item in fishDatas_hashCodekey) { if (item.Value != null && item.Value.gameObject != null) { fishToDestroy.Add(item.Value.gameObject); } } foreach (var go in fishToDestroy) { if (go != null) Destroy(go); } fishDatas_hashCodekey.Clear(); fishDatas_hashCodekey_Boss.Clear(); fishDatas_hashCodekey_Small.Clear(); #if UNITY_EDITOR // ⭐ 清理调试 List fishList_All.Clear(); fishList_Small.Clear(); fishList_Boss.Clear(); fishList_HJ.Clear(); fishList_TS.Clear(); fishList_TS_Boss.Clear(); #endif foreach (Transform item in fishRoot) { Destroy(item.gameObject); } // ⭐ 不销毁 bjEffectRoot 本身:只清空其下的爆奖特效子物体 // (Boss 分数版/加分弹出/BJEndUI/特殊爆奖等), // 防止退出房间时残留未结束的爆奖协程/动画造成的内存泄漏 if (_bjEffectRoot != null) { for (int i = _bjEffectRoot.childCount - 1; i >= 0; i--) Destroy(_bjEffectRoot.GetChild(i).gameObject); } // ⭐ 清理特效池:bjEffectRoot 下的特效子物体销毁后池内对象失效,清空避免复用已销毁对象 EffectPool.Instance.ClearAll(); //for (int i = fishDatas_hashCodekey.childCount - 1; i >= 0; i--) // Destroy(fishRoot.GetChild(i).gameObject); } /// 释放 NativeArray(避免 Unity 内存泄漏警告)。 private void DisposeNativeArrays() { if (timers.IsCreated) timers.Dispose(); if (frequencies.IsCreated) frequencies.Dispose(); if (shouldSpawn.IsCreated) shouldSpawn.Dispose(); } #endregion #region Tool // ================================================================ // 随机工具方法 // ================================================================ // ⭐ 静态复用 Random 实例,避免每次调用 new System.Random() 产生 GC private static readonly System.Random _sharedRand = new System.Random(); // ⭐ 预分配结果 List,避免调用方反复 new List private static readonly List _reusableIntResult = new List(64); /// /// 从 [min, max) 区间随机取 count 个不重复整数。 /// 若 min==max 返回空列表。 /// ⭐ 优化:复用静态 Random + 预分配 List,零 GC 分配。 /// 注意:返回的 List 是内部复用对象,调用方用完前不要再次调用本方法。 /// public static List RandomUniqueNumbers(int count, int min, int max) { _reusableIntResult.Clear(); if (min >= max) { Debug.LogWarning("[CreateFishManager] RandomUniqueNumbers: min >= max,返回空列表"); return _reusableIntResult; } count = Mathf.Min(count, max - min); // 不能超过范围 // ⭐ 用 HashSet 去重,但复用静态实例避免每次 new _reusableIntSet.Clear(); while (_reusableIntSet.Count < count) _reusableIntSet.Add(_sharedRand.Next(min, max)); foreach (var v in _reusableIntSet) _reusableIntResult.Add(v); return _reusableIntResult; } private static readonly HashSet _reusableIntSet = new HashSet(64); // ⭐ 预分配 string 结果 List,避免每次调用 new List private static readonly List _reusableStrResult = new List(64); // ⭐ 预分配 key 缓冲,避免 keyCollection.ToList() 产生 GC private static readonly List _reusableKeyBuf = new List(128); // ⭐ 预分配索引 HashSet,避免每次 new HashSet private static readonly HashSet _reusableIdxSet = new HashSet(64); // ⭐ 缓存 uiRoot.Canvas,避免每次杀鱼特效都 GetComponent()(5处复用) private static Canvas _cachedUiCanvas; private static Canvas GetUiCanvas() { if (_cachedUiCanvas == null && UIManager.Instance != null && UIManager.Instance.uiRoot != null) _cachedUiCanvas = UIManager.Instance.uiRoot.GetComponent(); return _cachedUiCanvas; } /// /// 从 KeyCollection 中随机取 count 个不重复的 string key。 /// ⭐ 优化:复用静态 Random + 预分配缓冲,零 GC 分配。 /// 注意:返回的 List 是内部复用对象,调用方用完前不要再次调用本方法。 /// public static List RandomUniqueNumbers(int count, int min, int max, Dictionary>.KeyCollection keyCollection) { _reusableStrResult.Clear(); if (min >= max) return _reusableStrResult; count = Mathf.Min(count, max - min); // ⭐ 用 foreach 拷贝到复用缓冲,避免 keyCollection.ToList() 的 GC _reusableKeyBuf.Clear(); foreach (var k in keyCollection) _reusableKeyBuf.Add(k); _reusableIdxSet.Clear(); while (_reusableIdxSet.Count < count) { int idx = _sharedRand.Next(min, max); if (_reusableIdxSet.Add(idx)) _reusableStrResult.Add(_reusableKeyBuf[idx]); } return _reusableStrResult; } #endregion #region 杀鱼 // ================================================================ // 杀鱼接口 // ================================================================ /// /// 批量杀死多条鱼(带特效 + 不足数量用纯金币补齐)。 /// /// 实际场上能找到的鱼 guid 列表(可能少于 count) /// 红包拆分后的每份金额,长度应 >= count /// 金币最终飞回的世界坐标(一般是炮台位置) /// 理论上要“死”的总数量(鱼 + 纯金币) public void Id_DieListFish_FX(List guids, List coinNum, Vector3 v3Point, int count, bool isLocal = false) { if (coinNum == null || coinNum.Count == 0) return; // 1. 先处理"真的有鱼"的部分 int realCount = Mathf.Min(guids.Count, coinNum.Count, count); for (int i = 0; i < realCount; i++) { Id_DieFish_FX(guids[i], coinNum[i], v3Point, isLocal); } // 2. 不足的部分,用"屏幕随机位置的金币特效"补齐 int needExtra = count - realCount; if (needExtra <= 0) return; // 防御:最多用 coinNum 里剩下的 int maxExtra = Mathf.Min(needExtra, coinNum.Count - realCount); for (int i = 0; i < maxExtra; i++) { var red = coinNum[realCount + i]; SpawnRandomCoinFX(red, v3Point, isLocal); } } /// /// 随机选取 count 条小鱼/黄金鱼,通知服务器同步死亡,并返回 guid 列表。 /// /// 击杀者座位 ID。Boss 爆奖连锁杀鱼时传 Boss 击杀者座位 /// (含人机);-1 表示用本地 currSitData.posID(普通玩家直接击杀场景). public List SyncGameData_SendRendromDieFishID( int count, List coinNum, Vector3 v3Point, int killerSitPos = -1) { var guids = new List(count); // ⭐ 优化:用 Fisher-Yates 洗牌索引代替 Keys.ToList()+RemoveAt,零 GC // 将 keys 拷贝到复用缓冲 _reusableKeyBuf.Clear(); foreach (var k in fishDatas_hashCodekey_Small.Keys) _reusableKeyBuf.Add(k); int keyCount = _reusableKeyBuf.Count; if (keyCount <= count) { // 若存活鱼不足 count,全部选取 for (int i = 0; i < keyCount; i++) guids.Add(_reusableKeyBuf[i]); } else { // ⭐ Fisher-Yates 洗牌:只洗前 count 个位置,避免全量洗牌 for (int i = 0; i < count; i++) { int j = Random.Range(i, keyCount); // 交换 i 和 j var tmp = _reusableKeyBuf[i]; _reusableKeyBuf[i] = _reusableKeyBuf[j]; _reusableKeyBuf[j] = tmp; guids.Add(_reusableKeyBuf[i]); } } // ⭐ sitPos 用击杀者座位(含人机);房主代人机击杀 Boss 时,这里传人机座位, // 否则所有客户端都会判定为房主击杀 → 房主自己看金色(错) // ⭐ 座位镜像:网络帧里的 sitPos 必须是服务端座位号,显示座位转回服务端空间 int posID = SeatMirror.ToServer(killerSitPos >= 0 ? killerSitPos : currGameType.currSitData.posID); var sgd = new SyncGameData { sitPos = posID, frameType = FrameDataType.randomDieFish, time = BeijingTimeManager.Instance.CurrentTimestampMs, frameData = new FrameData_RandomDieFish { x = v3Point.x, y = v3Point.y, z = v3Point.z, guids = guids, coinNum = coinNum, count = count } }; WebSocketSendList.SyncGameData(sgd); return guids; } /// /// 发送"随机爆金币不死鱼"帧:guids 为空列表,所有客户端只播屏幕随机位置的金币特效, /// 场上鱼不死亡(Id_DieListFish_FX 中 guids 为空 → 全部走 SpawnRandomCoinFX)。 /// /// 红包分包记录(每条对应一个金币特效) /// 爆奖中心(金币特效的基准位置) /// 击杀者座位 ID(-1 = 本地座位) public void SyncGameData_SendRandomCoinFX(List coinNum, Vector3 v3Point, int killerSitPos = -1) { if (coinNum == null || coinNum.Count == 0) return; // ⭐ 座位镜像:网络帧里的 sitPos 必须是服务端座位号,显示座位转回服务端空间 int posID = SeatMirror.ToServer(killerSitPos >= 0 ? killerSitPos : currGameType.currSitData.posID); var sgd = new SyncGameData { sitPos = posID, frameType = FrameDataType.randomDieFish, time = BeijingTimeManager.Instance.CurrentTimestampMs, frameData = new FrameData_RandomDieFish { x = v3Point.x, y = v3Point.y, z = v3Point.z, guids = new List(), // ⭐ 空 = 不关联场上鱼,金币全走 SpawnRandomCoinFX coinNum = coinNum, count = coinNum.Count } }; WebSocketSendList.SyncGameData(sgd); } /// /// 清除所有座位对此鱼的锁定引用(鱼死亡/离场时调用,避免炮台继续对着死鱼/已回收鱼空转)。 /// private void ClearLockOnFish(string fishId) { if (currGameType == null || currGameType.list_Sits == null) return; foreach (var sit in currGameType.list_Sits) { if (sit.monCotl_Lock != null && sit.monCotl_Lock.guid == fishId) { // ⭐ 必须完整清锁(ClearLock 置 isLock=false/隐藏锁定圈/重置 isManualLock/炮台回Idle), // 不能只清两个引用:isLock 残留 true 会让 GameMainPanel_FishBase.HandleAutoRotate // 对空 monCotl_Lock_Position 取 .position 每帧抛 NRE, // 排在其后的 CheckLockTargetValid/HandleAutoLock 永远执行不到 → 锁定系统卡死 sit.ClearLock(); } } } /// /// 鱼离场 /// public void Id_DieFish_NotFX(string fishId) { if (!fishDatas_hashCodekey.TryGetValue(fishId, out var monster)) { #if UNITY_EDITOR // ⭐ 优化:调试日志用 #if UNITY_EDITOR 包裹,Release 包零 GC // 原代码 Where().Take().ToList() + string.Join + $"" 每次 NotFound 都产生大量 GC Debug.LogWarning($"[CreateFishManager] Id_DieFish_NotFX 找不到鱼: fishId={fishId}, 字典数量={fishDatas_hashCodekey.Count}"); string baseGuid = fishId.Contains("_") ? fishId.Split('_')[0] : fishId; int matchCount = 0; foreach (var k in fishDatas_hashCodekey.Keys) { if (k.StartsWith(baseGuid)) { matchCount++; if (matchCount <= 10) GameManager.LogFishFlow("NOT_FOUND", fishId, $"similarKey={k}"); } } if (matchCount == 0) GameManager.LogFishFlow("NOT_FOUND", fishId, "(无相似key)"); #else GameManager.LogFishFlow("NOT_FOUND", fishId, fishDatas_hashCodekey.Count.ToString()); #endif return; } // ⭐ 标记死亡:离场回收的鱼同样不可被命中/锁定(回池 OnEnable 会重置) monster.isDead = true; // ⭐ 清除座位锁定引用:炮台不再对着已离场的鱼 ClearLockOnFish(fishId); Pool.Instance.Return(monster.gameObject); switch (monster.fishenum) { case FishTypeEnum.普通鱼: fishDatas_hashCodekey_Small.Remove(fishId); break; case FishTypeEnum.Boss鱼: fishDatas_hashCodekey_Boss.Remove(fishId); break; case FishTypeEnum.黄金鱼: fishDatas_hashCodekey_HJ.Remove(fishId); break; case FishTypeEnum.特殊鱼: fishDatas_hashCodekey_TS.Remove(fishId); break; case FishTypeEnum.特殊Boss: fishDatas_hashCodekey_TS_Boss.Remove(fishId); break; default: break; } fishDatas_hashCodekey.Remove(fishId); // ⭐ 记录成功回收日志 GameManager.LogFishFlow("REMOVE", fishId, $"fishNumber={monster.monsterData?.fishTypeConfig?.fishNumber} isyz={monster.isyz} yzID={monster.yzID} dictCount={fishDatas_hashCodekey.Count}", monster.GetInstanceID()); #if UNITY_EDITOR // ⭐ 同步移除调试 List fishList_All.Remove(monster); switch (monster.fishenum) { case FishTypeEnum.普通鱼: fishList_Small.Remove(monster); break; case FishTypeEnum.Boss鱼: fishList_Boss.Remove(monster); break; case FishTypeEnum.黄金鱼: fishList_HJ.Remove(monster); break; case FishTypeEnum.特殊鱼: fishList_TS.Remove(monster); break; case FishTypeEnum.特殊Boss: fishList_TS_Boss.Remove(monster); break; } #endif } /// /// 按 ID 杀死一条鱼(带特效 + 金币飞出动画)。 /// public void Id_DieFish_FX(string fishId, RedPacketRecord coinNum, Vector3 v3Point, bool isLocal = false) { if (!fishDatas_hashCodekey.TryGetValue(fishId, out var monster)) { // ⭐ 记录找不到鱼日志(带特效击杀路径) GameManager.LogFishFlow("NOT_FOUND", fishId, $"路径=Id_DieFish_FX dictCount={fishDatas_hashCodekey.Count}"); return; } // ⭐ 记录带特效击杀日志 GameManager.LogFishFlow("KILL_FX", fishId, $"fishNumber={monster.monsterData?.fishTypeConfig?.fishNumber} isyz={monster.isyz} yzID={monster.yzID} dictCount={fishDatas_hashCodekey.Count}", monster.GetInstanceID()); // ⭐ 统一标记死亡:死亡后子弹不再命中、不再被锁定 monster.isDead = true; // ⭐ 清除座位锁定引用:炮台不再对着死亡的鱼 ClearLockOnFish(fishId); // 👁 调试:记录每架掉金币飞机的实际坐标,便于对比"其他位置掉金币"来源 Debug.Log($"[掉金币坐标] fishId={fishId} 飞机坐标={monster.transform.position} 金币飞向={v3Point} isLocal={isLocal} 调用栈={System.Environment.StackTrace}"); if (false) { AudioManager.Instance.PlayerFX(60004); } // 死亡粒子特效(池化,粒子播完自动回池) EffectPool.Instance.Get( monster.effect_Die != null ? monster.effect_Die : dieFXPrefab, monster.transform.position, Quaternion.identity, transform.parent); // 非 Boss 鱼:金币飞向炮台 if (monster.fishenum != FishTypeEnum.Boss鱼 && monster.fishenum != FishTypeEnum.特殊Boss) { // ⭐ 池化:动画事件(coinEvent/ScoreNumScrollEvent)播完手动回池 var award = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAward, monster.transform.position, Quaternion.identity, currGameType.subPanelParent.parent, autoReturn: false); award.GetComponent().anchoredPosition = CoordinateTransformation.WorldPos_ToCanvas( monster.transform.position, GetUiCanvas()); var coinScript = award.GetComponent(); if (coinScript != null) { // 普通爆奖(BJBase 体系) coinScript.Init((int)coinNum.Amount, v3Point); coinScript.isBoss = true; coinScript.isLocalCoin = isLocal; coinScript.SetCoinCount(); } else { // 特殊鱼爆奖(DieEffect_TSY 体系:小鱼/Boss 模式互通) var tsy = award.GetComponent(); if (tsy != null) { // ⭐ 闪电起点=鱼死亡位置(3D 世界坐标)。award 挂 UI 画布下,其 position 是画布坐标(≈屏幕中心)不可用 tsy.startPoint = monster.transform.position; tsy.isLocalCoin = isLocal; tsy.monster = monster; tsy.coinCount = coinNum.Amount; if (tsy.baoJiangMode == TSYBaoJiangMode.Boss爆奖) { var sit = currGameType.currSitData; tsy.InitBossAward((int)coinNum.Amount, sit != null ? sit.posID : 0, sit != null ? (int)sit.consumeGoldNum : 1, monster.monsterData.fishTypeConfig.fishScore, 1, null); } else { // ⭐ 小鱼模式也调 Init:注入参数 + 缓存 _targetSit(座位)+ JiaFen, // 否则依赖 Init 的效果(ShanDian/ZuanTouXie 等查座位/结算)触发不到 var sit = currGameType.currSitData; tsy.Init(coinNum.Amount, sit != null ? sit.posID : 0, sit != null ? sit.consumeGoldNum : 1, monster.monsterData.fishTypeConfig.fishScore, 1, null); tsy.startPoint = monster.transform.position; // Init 的 ApplySeatOffset 会挪位置,重注入 tsy.monster = monster; tsy.coinCount = coinNum.Amount; } tsy.SetCoinCount(); } } } //MonsterDieList.Instance.SmallHuangYiHuang( // monster.transform.position, // monster.monsterData.fishTypeConfig.fishNumber, 1000); monster.DieFish(); fishDatas_hashCodekey.Remove(fishId); #if UNITY_EDITOR // ⭐ 同步移除调试 List fishList_All.Remove(monster); switch (monster.fishenum) { case FishTypeEnum.普通鱼: fishList_Small.Remove(monster); break; case FishTypeEnum.Boss鱼: fishList_Boss.Remove(monster); break; case FishTypeEnum.黄金鱼: fishList_HJ.Remove(monster); break; case FishTypeEnum.特殊鱼: fishList_TS.Remove(monster); break; case FishTypeEnum.特殊Boss: fishList_TS_Boss.Remove(monster); break; } #endif } /// /// 不依赖鱼,只在屏幕内随机一个位置生成金币特效,然后飞回指定点。 /// public void SpawnRandomCoinFX(RedPacketRecord coinNum, Vector3 targetWorldPos, bool isLocal = false) { // 1. 随机一个屏幕坐标 Vector2 screenPos = new Vector2( UnityEngine.Random.Range(0f, Screen.width), UnityEngine.Random.Range(0f, Screen.height)); // 2. 屏幕坐标 → Canvas 坐标(跟你原来金币飞行逻辑保持一致) Vector2 canvasPos = CoordinateTransformation.ScreenPoint_ToCanvas( screenPos, GetUiCanvas()); // 3. 实例化金币特效(池化,动画事件播完手动回池) var award = EffectPool.Instance.Get( dieFXPrefabAward, targetWorldPos, Quaternion.identity, currGameType.subPanelParent.parent, autoReturn: false); var rect = award.GetComponent(); rect.anchoredPosition = canvasPos; // 4. 设置 CoinScript 的终点为 targetWorldPos var coinScript = award.GetComponent(); coinScript.Init((int)coinNum.Amount, targetWorldPos); coinScript.isBoss = true; coinScript.isLocalCoin = isLocal; coinScript.SetCoinCount(); } /// /// 收到服务器 WebSocket 杀鱼通知,处理金币加分、特效、音效。 /// public void Ws_DieFish(string msg) { var killFish = JsonConvert.DeserializeObject>(msg) .arguments[0]; NetLog.Log($"[CreateFishManager] 杀鱼延迟:{DateTimeOffset.Now.ToUnixTimeSeconds() - killFish.timespan}s"); SitPosData sitData = null; //击杀玩家座位数据 foreach (var item in currGameType.list_Sits) { if (item.user == killFish.userId) { //item.lockFX.gameObject.SetActive(false); sitData = item; } } // ⭐ 死鱼清除所有座位的锁定引用(原逻辑只清击杀者座位,其他座位锁同一条鱼时不清) ClearLockOnFish(killFish.fishId); if (!fishDatas_hashCodekey.TryGetValue(killFish.fishId, out var monster)) return; // ⭐ 统一标记死亡:死亡后子弹不再命中、不再被锁定(含不调 base.DieFish 的 Boss 子类) monster.isDead = true; // 死亡粒子特效(池化,粒子播完自动回池) EffectPool.Instance.Get( monster.effect_Die != null ? monster.effect_Die : dieFXPrefab, monster.transform.position, Quaternion.identity, transform.parent); long goldToAdd = killFish.gold; // 立即复制需要的数据 string fishId = killFish.fishId; // 如果需要的话 Action onScoreAdd = (audioID) => { long oldGold = sitData.goldNum; // 记录加金币前的值 sitData.goldNum += goldToAdd; // 使用副本,不再依赖 killFish 对象 sitData.tMP_GoldNum.text = sitData.goldNum.ToString(); AudioManager.Instance.PlayerFX(audioID); // ⭐ 记录金币流水:已加钱(杀鱼获得,本地已执行) GameManager.LogGoldFlowAppliedGain("KillFish", $"击中鱼原有金币={oldGold} 增加金币=+{goldToAdd} 剩余金币={sitData.goldNum} 鱼ID={fishId} 座位={sitData.posID} 房间={GameManager.Instance.gameData?.currRoomData?.roomId}"); }; //// 加分回调(在金币/加分面板飞完后执行) //Action onScoreAdd = (audioID) => //{ // var sitList = GetCurrentPanelSitList(); // sitData.goldNum += killFish.gold; // sitData.tMP_GoldNum.text = sitData.goldNum.ToString(); // AudioManager.Instance.PlayerFX(audioID); //}; bool dieFishCalled = false; // Boss case 内部已调用 DieFish,底部跳过 switch (monster.fishenum) { case FishTypeEnum.普通鱼: { // 普通鱼:金币飞向炮台(池化,动画事件播完手动回池) var award = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAward, monster.transform.position, Quaternion.identity, currGameType.subPanelParent.parent, autoReturn: false); try { award.GetComponent().anchoredPosition = CoordinateTransformation.WorldPos_ToCanvas( monster.transform.position, GetUiCanvas()); } catch { award.transform.position = monster.transform.position; } var coinScript = award.GetComponent(); coinScript.Init(killFish.gold, sitData.transform.position, killFish.userId, sitData.posID, killFish.param); coinScript.isLocalCoin = sitData.user == GameManager.Instance.user.id; coinScript.isBoss = false; coinScript.JiaFen = onScoreAdd; coinScript.SetCoinCount(); break; } case FishTypeEnum.Boss鱼: { // ⭐ 防御:配置了 Boss鱼 类型但预制体没挂 MonsterControl_BossBase(新鱼/配错) // -> 按普通鱼金币流程处理,避免 InvalidCastException 崩掉杀鱼消息 var bossCtrl = monster as MonsterControl_BossBase; if (bossCtrl == null) { Debug.LogWarning($"[Ws_DieFish] Boss鱼 未挂 MonsterControl_BossBase: fishId={killFish.fishId} 实际脚本={monster.GetType().Name},按普通鱼流程结算"); var award = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAward, monster.transform.position, Quaternion.identity, currGameType.subPanelParent.parent, autoReturn: false); try { award.GetComponent().anchoredPosition = CoordinateTransformation.WorldPos_ToCanvas(monster.transform.position, GetUiCanvas()); } catch { award.transform.position = monster.transform.position; } var coinScript = award.GetComponent(); coinScript.Init(killFish.gold, sitData.transform.position, killFish.userId, sitData.posID, killFish.param); coinScript.isLocalCoin = sitData.user == GameManager.Instance.user.id; coinScript.isBoss = false; coinScript.JiaFen = onScoreAdd; coinScript.SetCoinCount(); monster.DieFish(killFish); dieFishCalled = true; break; } // Boss 鱼:显示浮动分数文本 // ⭐ not-self 分流:非自己击杀(含机器人)且开启新模式 → 走 not-self 流程 // 不克隆 effect_DieAward,在 killer 座位显示 img_BaoJiangZhong, // 等待帧同步 baoJiangEnd(机器人由房主延迟 5~10 秒代发)后显示最终 BJEndUI + 加分 bool isSelfKill_Boss = sitData.user == GameManager.Instance.user.id; if (GameManager.Instance.useNotSelfBaoJiangMode && !isSelfKill_Boss && monster.effect_DieAward != null && BaoJiangNotSelfHandler.ShowInProgress( sitData, monster.effect_DieAward, killFish.fishId, killFish.userId, killFish.beishu, gold: killFish.gold, onScoreAdd: onScoreAdd)) { // Boss 死亡仍走 DieFish(处理死亡音效/震屏等),但不播爆奖特效 monster.DieFish(killFish); dieFishCalled = true; break; } bossCtrl.v3 = sitData.transform.position; bossCtrl.JiaFen = WrapJiaFenWithBaoJiangEnd(onScoreAdd, killFish, sitData, isSelfKill_Boss); bossCtrl.isLocalCoin = sitData.user == GameManager.Instance.user.id; // ⭐ 金/银区分 bossCtrl.killerSitPos = sitData.posID; // ⭐ 爆奖杀小鱼用击杀者座位(含人机) monster.DieFish(killFish); // Boss 重写 action_effect_DieAward,捕获上面的 JiaFen dieFishCalled = true; if (bossCtrl.isToUI) { // ⭐ 池化:UI 空间 Boss 爆奖,动画事件播完手动回池 // ⭐ 不设置位置:位置/大小用爆奖预制体里配置好的(z 也用预制体自带的) bossCtrl.currEffect_DieAward = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp, currGameType.subPanelParent.parent, autoReturn: false); } else { // isToUI=false:世界空间特效,无 UI 父对象 → 挂 bjEffectRoot 统一管理(池化) bossCtrl.currEffect_DieAward = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp, bjEffectRoot, autoReturn: false); } bossCtrl.action_effect_DieAward?.Invoke(bossCtrl.currEffect_DieAward); } break; case FishTypeEnum.黄金鱼: { // 黄金鱼:金币飞向炮台(池化,动画事件播完手动回池) var award = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAward, monster.transform.position, Quaternion.identity, currGameType.subPanelParent.parent, autoReturn: false); try { award.GetComponent().anchoredPosition = CoordinateTransformation.WorldPos_ToCanvas( monster.transform.position, GetUiCanvas()); } catch { award.transform.position = monster.transform.position; } var coinScript = award.GetComponent(); coinScript.Init(killFish.gold, sitData.transform.position, killFish.userId, sitData.posID, killFish.param); coinScript.isLocalCoin = sitData.user == GameManager.Instance.user.id; coinScript.isBoss = false; coinScript.JiaFen = onScoreAdd; coinScript.SetCoinCount(); } break; case FishTypeEnum.特殊鱼: { // ⭐ not-self + Boss爆奖模式分流:不克隆 effect_DieAward,只读取预制体上的 BJEndUI // 在 killer 座位 obj_BaoJiangZhongPanelPosition 显示 img_BaoJiangZhong(关 tMP_beishu) // 等待帧同步 baoJiangEnd 后再走最终 BJEndUI 效果 + 加分 // 开关:GameManager.useNotSelfBaoJiangMode(false=旧模式,所有玩家走相同流程) // ⚠️ 排除重写了 SetCoinCount 的子类(如炸弹蟹 HHWK_ZDX_DieEvent): // 这些鱼有特殊爆炸/范围杀鱼玩法,必须走原流程,不能跳过 SetCoinCount bool isSelfKill_TSY = sitData.user == GameManager.Instance.user.id; if (GameManager.Instance.useNotSelfBaoJiangMode && !isSelfKill_TSY && monster.effect_DieAward != null) { var tsyPrefab = monster.effect_DieAward.GetComponent(); if (tsyPrefab != null && tsyPrefab.baoJiangMode == TSYBaoJiangMode.Boss爆奖) { // 检查子类是否重写了 SetCoinCount(炸弹蟹等有特殊爆炸玩法的鱼不走 not-self 分流) var setCoinMethod = tsyPrefab.GetType().GetMethod("SetCoinCount"); bool hasCustomSetCoinCount = setCoinMethod != null && setCoinMethod.DeclaringType != typeof(DieEffect_TSY); if (!hasCustomSetCoinCount && BaoJiangNotSelfHandler.ShowInProgress( sitData, monster.effect_DieAward, killFish.fishId, killFish.userId, killFish.beishu, gold: killFish.gold, onScoreAdd: onScoreAdd)) { break; } } } // 特殊鱼:金币飞向炮台(池化,动画事件播完手动回池) // ⭐ Get 不设置位置:DieEffect_TSY(ZDX 炸弹蟹等)爆奖完全用预制体里配置好的位置(含 y 轴); // 只有 BJBase 金币特效按鱼死亡位置摆放(startPoint 依赖) var award = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAward, currGameType.subPanelParent.parent, autoReturn: false); var coinScript = award.GetComponent(); if (coinScript != null) { // 普通爆奖(BJBase 体系):摆到鱼死亡位置(画布坐标),startPoint 依赖 try { award.GetComponent().anchoredPosition = CoordinateTransformation.WorldPos_ToCanvas( monster.transform.position, GetUiCanvas()); } catch { award.transform.position = monster.transform.position; } coinScript.startPoint = award.transform.position; coinScript.Init(killFish.gold, sitData.transform.position, killFish.userId, sitData.posID, killFish.param); coinScript.isLocalCoin = sitData.user == GameManager.Instance.user.id; coinScript.isBoss = false; coinScript.JiaFen = onScoreAdd; coinScript.SetCoinCount(); } else { // 特殊鱼爆奖(DieEffect_TSY 体系:小鱼/Boss 模式互通) var tsy = award.GetComponent(); if (tsy != null) { // ⭐ 闪电起点=鱼死亡位置(3D 世界坐标)。award 挂 UI 画布下,其 position 是画布坐标(≈屏幕中心)不可用 tsy.startPoint = monster.transform.position; tsy.isLocalCoin = sitData.user == GameManager.Instance.user.id; tsy.monster = monster; tsy.coinCount = killFish.gold; // 统一注入击杀者座位信息(posID/paoBei 供 GetSeatPosition/范围杀鱼金币计算使用) tsy.posID = sitData.posID; tsy.paoBei = (int)sitData.consumeGoldNum; // 倍数:服务端返回的爆奖倍数(小鱼模式 BJEndUI 结算显示用;未下发时保持默认 1) tsy.bJbeishu = killFish.beishu > 0 ? killFish.beishu : 1; if (tsy.baoJiangMode == TSYBaoJiangMode.Boss爆奖) { tsy.InitBossAward(killFish.gold, sitData.posID, (int)sitData.consumeGoldNum, monster.monsterData.fishTypeConfig.fishScore, 1, onScoreAdd); } else { // ⭐ 小鱼模式也调 Init:注入参数 + 缓存 _targetSit(座位)+ JiaFen tsy.Init(killFish.gold, sitData.posID, (int)sitData.consumeGoldNum, monster.monsterData.fishTypeConfig.fishScore, killFish.beishu > 0 ? killFish.beishu : 1, onScoreAdd); tsy.JiaFenEx = onScoreAdd; // Init 的 ApplySeatOffset 会挪位置/清状态,重注入小鱼模式专用字段 tsy.startPoint = monster.transform.position; tsy.isLocalCoin = sitData.user == GameManager.Instance.user.id; tsy.monster = monster; tsy.coinCount = killFish.gold; tsy.posID = sitData.posID; tsy.paoBei = (int)sitData.consumeGoldNum; tsy.bJbeishu = killFish.beishu > 0 ? killFish.beishu : 1; } tsy.SetCoinCount(); } } // // Boss 鱼:显示浮动分数文本 // var bossCtrl = (MonsterControl_BossBase)monster; // bossCtrl.v3 = sitData.transform.position; // if (bossCtrl.isToUI) // { // bossCtrl.currEffect_DieAward = Instantiate( //monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp, //currGameType.subPanelParent.parent); // } // else // { // bossCtrl.currEffect_DieAward = Instantiate( //monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp); // } // bossCtrl.action_effect_DieAward?.Invoke(bossCtrl.currEffect_DieAward); // bossCtrl.JiaFen = onScoreAdd; } break; case FishTypeEnum.特殊Boss: { // Boss 鱼:显示浮动分数文本 var bossCtrl = (MonsterControl_BossBase)monster; // ⭐ not-self 分流:非自己击杀(含机器人)且开启新模式 → 走 not-self 流程 bool isSelfKill_TBoss = sitData.user == GameManager.Instance.user.id; if (GameManager.Instance.useNotSelfBaoJiangMode && !isSelfKill_TBoss && monster.effect_DieAward != null && BaoJiangNotSelfHandler.ShowInProgress( sitData, monster.effect_DieAward, killFish.fishId, killFish.userId, killFish.beishu, gold: killFish.gold, onScoreAdd: onScoreAdd)) { // Boss 死亡仍走 DieFish(处理死亡音效/震屏等),但不播爆奖特效 monster.DieFish(killFish); dieFishCalled = true; break; } bossCtrl.v3 = sitData.transform.position; bossCtrl.JiaFen = WrapJiaFenWithBaoJiangEnd(onScoreAdd, killFish, sitData, isSelfKill_TBoss); bossCtrl.isLocalCoin = sitData.user == GameManager.Instance.user.id; // ⭐ 金/银区分 bossCtrl.killerSitPos = sitData.posID; // ⭐ 爆奖杀小鱼用击杀者座位(含人机) monster.DieFish(killFish); // Boss 重写 action_effect_DieAward dieFishCalled = true; if (bossCtrl.isToUI) { // ⭐ 池化:UI 空间 Boss 爆奖,动画事件播完手动回池 // ⭐ 不设置位置:位置/大小用爆奖预制体里配置好的(z 也用预制体自带的) bossCtrl.currEffect_DieAward = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp, currGameType.subPanelParent.parent, autoReturn: false); } else { // isToUI=false:世界空间特效,无 UI 父对象 → 挂 bjEffectRoot 统一管理(池化) bossCtrl.currEffect_DieAward = EffectPool.Instance.Get( monster.effect_DieAward != null ? monster.effect_DieAward : dieFXPrefabAwardTmp, bjEffectRoot, autoReturn: false); } bossCtrl.action_effect_DieAward?.Invoke(bossCtrl.currEffect_DieAward); } break; default: break; } if (!dieFishCalled) monster.DieFish(killFish); MonsterDieList.Instance.SmallHuangYiHuang( monster.transform.position, monster.monsterData.fishTypeConfig.fishNumber, 1000); } #endregion // ================================================================ // 工具:包装 JiaFen 回调,self 流程加分完成后发送 baoJiangEnd 帧同步 // ================================================================ /// /// 包装 onScoreAdd:先执行原加分,再在 self+not-self 模式开启时发送 baoJiangEnd 帧同步。 /// 这样无论子类走 OnScoreEnd(雷龙)还是直接调 JiaFen(金龙),都能触发帧同步。 /// BJEventBase.Init 会把这个包装再包一层 `(p) => action?.Invoke(p)`,调用链不变。 /// private System.Action WrapJiaFenWithBaoJiangEnd(System.Action onScoreAdd, RoomKillNotice killFish, SitPosData sitData, bool isSelfKill) { return (p) => { // 1. 执行原加分逻辑 onScoreAdd?.Invoke(p); // 2. self 流程:本地玩家击杀且开启 not-self 模式时,发送 baoJiangEnd 帧同步 // (机器人击杀由房主延迟代发,不在这里发) if (isSelfKill && GameManager.Instance != null && GameManager.Instance.useNotSelfBaoJiangMode) { BaoJiangNotSelfHandler.SendBaoJiangEnd( killFish.userId, killFish.fishId, killFish.beishu, killFish.gold, sitData.posID); } }; } // ================================================================ // 工具:获取当前面板座位列表 // ================================================================ /// /// 根据当前游戏类型返回对应 UI 面板的座位列表。 /// private List GetCurrentPanelSitList() { switch (GameManager.Instance.gameData.currGameType) { case GameType.defaultPanel: return UIManager.Instance.Get(UIPanel.MainGamePanelDefault).list_Sits; case GameType.FLZY: return UIManager.Instance.Get(UIPanel.MainGamePanelFLZY).list_Sits; case GameType.YYSC: return UIManager.Instance.Get(UIPanel.MainGamePanelYYSC).list_Sits; case GameType.XJMH: return UIManager.Instance.Get(UIPanel.MainGamePanelXJMH).list_Sits; case GameType.HHWK: return UIManager.Instance.Get(UIPanel.MainGamePanelHHWK).list_Sits; default: return null; } } #region 冰冻技能 /// /// 复位冰冻状态(退房/进房时调用): /// 清除 isFrozen 与冰冻记录,并停掉所有出鱼协程。 /// ⭐ 若不复位:冰冻中切换房间时,旧面板的 15s 解冻倒计时随面板一起销毁, /// 永远不会调用 UnfreezeAllFish → isFrozen 残留 true → /// 新房间 OnFishSpawn 第一行 `if (isFrozen) return;` 把服务端所有出鱼全部拦掉 → 新房间不出鱼。 /// ⭐ StopAllCoroutines:分帧出鱼协程里的 `while (isFrozen) yield return null;` 会一直挂起, /// 解冻复位后会继续把旧房间排队的鱼生成到新房间,必须一并停掉。 /// public void ResetFreezeState() { isFrozen = false; frozenFishStates.Clear(); frozenFishIsMoves.Clear(); StopAllCoroutines(); } /// /// 冰冻:记录所有鱼当前状态并强制切到 Idle(暂停移动+动画) /// public void FreezeAllFish() { // ⭐ 守卫:已冻结时直接返回,避免重复调用覆盖已记录的状态 // (其他客户端会先后被 OnUsePropNotice 和 SyncGameData_FreezeFish 触发两次) if (isFrozen) return; isFrozen = true; // ⭐ 两个字典必须同步清空,否则 frozenFishIsMoves 残留旧数据 // 会导致下次解冻时用旧值覆盖 isMove 但不恢复 currState frozenFishStates.Clear(); frozenFishIsMoves.Clear(); // 只遍历全量字典 fishDatas_hashCodekey(已包含所有鱼),不重复遍历子字典 if (fishDatas_hashCodekey != null) { foreach (var kv in fishDatas_hashCodekey) { MonsterContral m = kv.Value; if (m == null || !m.gameObject.activeInHierarchy) continue; // 记录原状态 frozenFishStates[m.guid] = m.currState; frozenFishIsMoves[m.guid] = m.isMove; // 定格 m.isMove = false; m.ChangeState(CharacterStateType.idle); m.SetFreezeEffect(true); } } } /// /// 解冻:恢复鱼到冰冻前状态 /// public void UnfreezeAllFish() { isFrozen = false; if (fishDatas_hashCodekey != null) { foreach (var kv in fishDatas_hashCodekey) { MonsterContral m = kv.Value; if (m == null || !m.gameObject.activeInHierarchy) continue; m.SetFreezeEffect(false); if (frozenFishStates.TryGetValue(m.guid, out var prevState)) { // 被本次冰冻冻结的鱼:恢复冰冻前的 isMove 和 currState m.isMove = frozenFishIsMoves.TryGetValue(m.guid, out var prevIsMove) ? prevIsMove : m.baseMmove; m.ChangeState(prevState); } else { // ⭐ 不在冰冻记录里的鱼(冰冻期间新激活的,OnEnable 把 isMove 设成了 false) // 用 baseMmove 恢复预制体默认状态,避免解冻后永远不动 m.isMove = m.baseMmove; if (m.baseMmove) m.ChangeState(CharacterStateType.move); } } } // ⭐ 两个字典同步清空 frozenFishStates.Clear(); frozenFishIsMoves.Clear(); } #endregion } #if UNITY_EDITOR [CustomEditor(typeof(CreateFishManager))] public class CreateFishManagerEditor : Editor { private static readonly string[] FishNames = { "电鳗","吞噬鲲","玲珑宝塔","聚宝盆","金龙","神龙转盘","炸弹蟹","青龙","蟠桃树","激光蟹","钻头蟹","荧光水母","远古龙龟","深海灯笼鱼","深渊鳄鱼","禄存星君","苍穹之主","雷霆之主","人鱼公主","霸王蟹","哥斯拉" }; private static readonly string[] FishIds = { "11023","11024","11025","11026","11027","11028","11029","11030","11031","11032","11033","11034","11035","11036","11037","11038","11039","11040","11041","11042","11043" }; public override void OnInspectorGUI() { base.OnInspectorGUI(); var mgr = (CreateFishManager)target; int index = Array.IndexOf(FishIds, mgr.debugSpawnFishNumber); if (index < 0) index = 0; EditorGUILayout.Space(); EditorGUI.BeginChangeCheck(); int newIndex = EditorGUILayout.Popup("调试出鱼(下拉选择)", index, FishNames); if (EditorGUI.EndChangeCheck() && newIndex != index) { // 下拉框只负责给 debugSpawnFishNumber 赋值,出鱼逻辑只读这个字段 Undo.RecordObject(mgr, "Change Fish ID"); mgr.debugSpawnFishNumber = FishIds[newIndex]; EditorUtility.SetDirty(mgr); } } } #endif