Files
5.24BuYu/Assets/MyGame/aa/UI/Game/GameMainPanel_FishBase.cs
T
2026-10-17 03:01:15 +08:00

7058 lines
325 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
======================== GameMainPanel_FishBase ========================
【主控制层职责】
负责:输入 → 旋转 → 攻击 → 子弹 → 锁定 → 网络同步 → 广播处理
不负责:动画(TurretData)、发射点(TurretData)、金币(SitPosData)
=======================================================================
① InputModule(输入系统)
├── HandleInput() 主入口
├── 按下(PressDown)
├── 长按(PressHold)
├── 松开(PressUp)
├── CheckTouchIsChangeTurret() 判断是否点到“换炮台按钮”
└── HandleChangeTurretHold() 长按换炮台按钮 → 打开换炮台面板
-----------------------------------------------------------------------
② RotateModule(旋转系统)
├── HandleRotate() 手动旋转入口
├── RotateToTouch() 朝触摸点旋转
├── RotateToLockTarget() 朝锁定目标旋转
├── HandleAutoRotate() 自动开火时的自动旋转
└── AutoSweepRotate() 自动扫射(左右摆动)
-----------------------------------------------------------------------
③ FireModule(攻击系统)
├── TryFire() 长按触发攻击(冷却计时)
├── FireBullet() 发射子弹(普通/三射/双倍/锁定)
├── StartLaser() 激光开始(LaserStart)
├── LoopLaser() 激光持续(LaserLoop)
└── StopLaser() 激光结束(LaserEnd)
-----------------------------------------------------------------------
④ BulletModule(子弹系统)
├── CreateBullet() 创建子弹实例
-----------------------------------------------------------------------
⑤ LockModule(锁定系统)
├── HandleLock() 点击锁定
├── 自动锁定(AutoLock) 可选扩展
├── CancelLock() 取消锁定
├── UpdateLockFX() 锁定特效跟随目标
└── SyncLock() 网络同步锁定状态
-----------------------------------------------------------------------
⑥ NetSyncModule(网络同步)
├── SyncRotate() 同步旋转
├── SyncFire() 同步发射
├── SyncLock() 同步锁定
├── SyncTurretState() 同步激光状态
└── SyncChangeTurret() 同步炮台切换(可选)
-----------------------------------------------------------------------
⑦ NetReceiveModule(服务器广播处理)
├── SyncGameData_Rotate() 广播旋转
├── SyncGameData_FireBullet() 广播发射
├── SyncGameData_ChangeTarget() 广播锁定
├── SyncGameData_ChangeTurret() 广播炮台切换(可选)
└── SyncGameData_LaserState() 广播激光状态(可选)
-----------------------------------------------------------------------
⑧ ResourceModule(资源加载)
├── LoadResources() 加载音频/鱼资源/路线
└── UnLoadResources() 卸载资源
-----------------------------------------------------------------------
⑨ PlayerJoinModule(玩家加入/退出)
├── JoinGame() 玩家加入房间
└── ExitGame() 玩家退出房间
=======================================================================
*/
using Assets.MyGame.Scripts.Net;
using Assets.MyGame.Scripts.Net.API;
using Assets.MyGame.Scripts.Net.API.GameConfig_SubClass;
using Assets.MyGame.Scripts.Net.API.UserData_SubClass;
using Assets.MyGame.Scripts.Net.APIResponses.Enum;
using Assets.MyGame.Scripts.Net.WebResponses;
using Assets.Framework.Tool;
using DG.Tweening;
using Newtonsoft.Json;
using Spine.Unity;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using TMPro;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using Random = UnityEngine.Random;
public class GameMainPanel_FishBase : GameMainPanel_Base
{
[Header("离开游戏倒计时")]
public GameObject obj_DJSPanel;
public TextMeshProUGUI tmp_DJS; //确认(10)
public Button btn_QueRen;
public Button btn_QuXiao;
/// <summary>是否正在退出倒计时(期间屏蔽帧同步和爆奖特效)</summary>
private bool _isLeavingCountdown = false;
private Coroutine _leaveCountdownCoroutine;
[Header("无操作自动退出")]
[Tooltip("无操作总时长(秒),走完自动退出回大厅(<=0 关闭该功能)")]
public float idleExitTotalSeconds = 70f;
[Tooltip("最后多少秒才显示倒计时面板(autoExitGamePanel)并逐秒倒数,之前全程无提示")]
public float idleExitPanelShowSeconds = 10f;
/// <summary>无操作计时是否生效(资源加载完成后才开始计时,OnHide/离房时失效)</summary>
private bool _idleExitArmed = false;
/// <summary>最近一次玩家操作的时间(Time.realtimeSinceStartup,含切后台时间)</summary>
private float _lastOperationTime = 0f;
/// <summary>警告文本当前显示的剩余秒数(-1=未显示;避免每帧刷新 TMP 文本)</summary>
private int _idleWarnShownSeconds = -1;
[Header("游戏场景")]
public string sceneName;
public GameTypeEnum gameType;
[Header("Left")]
public PropConfigData propConfigData; // 道具菜单配置(Assets右键 Create → MyGame → 左侧道具配置)
[Tooltip("鱼雷特效配置(按道具编号选爆炸特效预制体,多场景共用一份 .asset;未配置回退纯逻辑版倒计时爆炸)")]
public TorpedoEffectConfig torpedoEffectConfig;
public Button btn_Prop;
public GameObject obj_PropItemParent;
public GameObject obj_PropPanel;
public GameObject tmp_PropItem;
public GameObject tmp_PropTwoItemParent;
public GameObject tmp_PropTwoPanel;
public GameObject tmp_PropTwoItem;
public Button dalaba;
public Button zhanhuanka;
public Button huojianpao;
[Header("大喇叭弹幕输入")]
public GameObject obj_DaLaBaPanel; // 大喇叭弹幕输入面板
public TMP_InputField tmp_DaLaBaInput; // 弹幕输入框
public Button btn_DaLaBaSend; // 发送按钮
public Button btn_DaLaBaClose; // 关闭按钮
[Header("Right")]
[Header("设置")]
protected bool isOpenSetting;
public Button btn_SettingList;
public Transform SittingListParent;
public Button btn_TuJian;
public Button btn_ChangeTurret;
public Button btn_TuiChu;
public Button btn_Setting;
/// <summary>切换房间:重新走一遍加入房间流程(离房 → QuickJoin → 连新房socket → JoinRoom)</summary>
public Button btn_ChangeRoom;
[Header("表情包")]
[Tooltip("表情包配置资产(一份配置多场景共用;未拖时回退下方面板旧字段)")]
public BiaoQingConfig biaoQingConfig;
public Button btn_BiaoQing; // 打开/关闭表情面板
public GameObject obj_BiaoQingPanel; // 表情面板(开/关)
public GameObject obj_BiaoQingZiItemPrefab; // 文字表情项预制体(child0=TextMeshProUGUI)
public GameObject obj_BiaoQingSpineItemPrefab; // spine表情项预制体(child0=SkeletonGraphic)
public GameObject obj_BiaoQingSpineItemParent; // spine表情项生成的父节点
public GameObject obj_BiaoQingZiItemParent; // 文字表情项生成的父节点
[Tooltip("(回退用)spine 表情配置:优先读 biaoQingConfig 资产,未拖资产时才用这里")]
public List<BiaoQingSpineData> list_BiaoQingSpine = new List<BiaoQingSpineData>();
[Tooltip("(回退用)文字表情内容列表:优先读 biaoQingConfig 资产,未拖资产时才用这里")]
public List<string> list_BiaoQingZi = new List<string>();
//[Header("我在这")]
public GameObject mySitAnim;
[Tooltip("我在这动画偏移 - 左侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")]
public Vector3 mySitAnimOffsetLeft;
[Tooltip("我在这动画偏移 - 右侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")]
public Vector3 mySitAnimOffsetRight;
public Dictionary<string, Toggle> dic_Skill_Toggle = new Dictionary<string, Toggle>();//开关型技能
[Header("Skillbottom")]
// toggle_SkillAuto(自动攻击)已移植到 SitPosData.toggle_AutoAttack:读取 currSitData.IsAutoAttackOn,关闭时座位自己切 Idle + OnAutoAttackOff 广播
public Toggle toggle_Lock;
public Toggle toggle_Three;
public Toggle toggle_Shuang;
[Header("锁定路径进度阈值(0~1,面板可调)")]
[Tooltip("自动锁定/三发锁定/bot 的路径进度下限:鱼刚出生(进度<此值)不作为锁定目标。玩家手动锁定不受此限制")]
[Range(0f, 1f)]
public float lockPathMin = 0.1f;
[Tooltip("自动锁定/三发锁定/bot 的路径进度上限:鱼即将离场(进度>此值)不作为锁定目标。玩家手动锁定不受此限制")]
[Range(0f, 1f)]
public float lockPathMax = 0.9f;
// ⭐ 双倍开火:玩家手动开关的持久模式(同三射/锁定),不自动关闭、不消耗道具。
// child0 的 Image(Filled 360°) 做环形转圈展示(GetShuangFillImage 懒获取):
// 开启后环耗尽一圈从头继续转(循环动画),关闭时停转清环。
float shuangDuration = 15f; // 环转一圈的时长(纯视觉,不控制功能开关)
public float currShuangTime = 0f;
private Image _shuangFillImage;
public Toggle toggle_BaoZou;
public TextMeshProUGUI tmp_BaoZouCount;
public Image img_BaoZou;
[Header("狂暴/冰封持续时间(秒,可在面板手动设置)")]
public float frenzyDuration = 15f; // 疯狂状态持续时间
public float currFrenzyTime = 0f;
public Toggle toggle_BingFeng;
public TextMeshProUGUI tmp_BingFengCount;
public Image img_BingFeng;
public GameObject bingFengEffect; // 冰封特效预制体(首次Instantiate,之后激活/隐藏)
private GameObject _bingFengInstance; // 克隆出来的实例
public float bingFengDuration = 15f; // 冰封状态持续时间(服务端 FishFrozen 延长 15 秒,可在面板调整)
public float currBingFengTime = 0f;
private Dictionary<Component, object> _frozenAnimSpeeds = new Dictionary<Component, object>();
/// <summary>
/// 冰冻中“受 Timeline 驱动”的 Animator(其所属鱼体内存在 PlayableDirector)。
/// ⭐ 这些 Animator 的动画由 Timeline 驱动,冰冻时绝对不能把 speed 设 0:
/// speed=0 会让 Timeline 的动画输出失效 → 模型瞬间回退到默认姿态。
/// 它们的定格交给 director(根 Playable 速度归零)处理。
/// </summary>
private readonly HashSet<Animator> _timelineDrivenAnimators = new HashSet<Animator>();
/// <summary>冰冻中被定格的 Timeline:记录根 Playable 的原始速度,解冻时还原</summary>
private readonly Dictionary<UnityEngine.Playables.PlayableDirector, double> _frozenDirectors
= new Dictionary<UnityEngine.Playables.PlayableDirector, double>();
// ⭐ 暴走/冰封本地数量:因为暂无扣减 API,激活时本地 -1,避免被 OnUserDataChanged 覆盖回服务端旧值
private int _localBaoZouCount = -1; // -1 表示尚未初始化
private int _localBingFengCount = -1;
// 上次从服务端读到的数量:用于判断服务端是否变化(刮刮卡奖励/其他端使用),
// 服务端值变化时信任服务端;未变化时保留本地扣减值
private int _lastServerBaoZouCount = -1;
private int _lastServerBingFengCount = -1;
[Header("Skillleft")]
public Button btn_GuaGuaKa;
public TextMeshProUGUI tmp_GGKCount;
public List<SitPosData> list_Sits = new List<SitPosData>();
public SitPosData currSitData;
[Header("攻击参数")]
public float attackTime = 0.5f;
[Tooltip("远端座位攻击状态看门狗(秒):最后收到某座位的攻击状态/开火消息后,超过这个时间还没有新消息," +
"就判定这个座位已经不在攻击(典型:攻击中直接退出/掉线,永远不会再发待机包)→ 本地收回待机。\n" +
"实际等待时间 = max(本值, attackTime×3),避免开火间隔比看门狗还长的配置被误判")]
public float remoteAttackIdleTimeout = 1.5f;
/// <summary>远端座位最近一次"还在攻击"的心跳时间:收到该座位的攻击状态广播或开火消息时刷新</summary>
private readonly Dictionary<int, float> remoteAttackHeartbeat = new Dictionary<int, float>();
[Header("人机参数")]
public float botRotateSpeed = 80f; // 兜底旋转速度(BotConfig 为空时用)
float clickTime = 0;
private Dictionary<int, float> botFireTimer = new Dictionary<int, float>();
private Dictionary<string, float> botLastAngle = new Dictionary<string, float>();
// bot 旋转广播节流:记录上次广播的角度/时间
private Dictionary<int, float> botLastSyncAngle = new Dictionary<int, float>();
private Dictionary<int, float> botLastSyncTime = new Dictionary<int, float>();
// bot 技能运行时
private Dictionary<int, BotSkillRuntime> botSkills = new Dictionary<int, BotSkillRuntime>();
// bot 目标记忆
private Dictionary<int, string> botTargetGuid = new Dictionary<int, string>();
// bot 锁定同步节流:记录上次广播的锁定目标 guid(null 表示未锁定),只有目标变化才广播
private Dictionary<int, string> botLastSyncLockGuid = new Dictionary<int, string>();
// bot 炮台状态同步节流:记录上次广播的 TurretState,只有状态变化才广播
private Dictionary<int, TurretState> botLastSyncTurretState = new Dictionary<int, TurretState>();
// bot 状态计时:开火后持续 Attack 动画,空闲 0.3s 切回 Idle
private Dictionary<int, float> botIdleTimer = new Dictionary<int, float>();
// bot 最近的 FrameData_FireBullet(给其他客户端还原技能状态)
private Dictionary<string, FrameData_FireBullet> botLastFireData = new Dictionary<string, FrameData_FireBullet>();
// bot 运行时状态(进场金币/倍数档位/行为模式/退出)
private Dictionary<int, BotRuntimeState> botRuntimeStates = new Dictionary<int, BotRuntimeState>();
public GameObject bullet_Test;
public GameObject bulletParent;
float changeTurretTime = 0f;
bool isChangingTurret = false;
[Header("超时自动退出倒计时")]
public GameObject autoExitGamePanel; //倒计时面板
public TextMeshProUGUI tmp_ExitDaoJiShi; //里面显示的是这个 n秒后自动关闭
public Button btn_CloseAutoExitGamePanel; //退出游戏按钮
// ⭐ 起飞炮台(FlyTurretData):轻点空白处触发起飞
// 按下时先走正常开火流程,松开时判定为"轻点"(时长短+几乎没移动)才起飞,
// 保证按住扫射的普通开火完全不受影响
private bool _flyTapPending = false; // 本次按下待判定轻点
private float _flyTapTime; // 按下时刻
private Vector2 _flyTapPos; // 按下位置
private const float flyTapMaxDuration = 0.25f; // 轻点判定:按下到松开最大时长(秒)
private const float flyTapMaxMove = 40f; // 轻点判定:最大移动距离(像素)
int bulletCount = 0;
public GameObject boardCast_Boss;
[Header("创建视频播放器")]
[Tooltip("鱼创建时播放的入场视频控件,由 Boss 预制体的 createVideoClip 指定视频片段。Inspector 拖拽场景中的 VideoPlayer 控件")]
public UnityEngine.Video.VideoPlayer createVideoPlayer;
/// <summary>开火音效(static:全局常驻,切房/重进房直接复用;见 LoadBulletClip 注释)</summary>
static AudioClip bulletClip;
public List<int> paoBeiList = new List<int>();
public ProgressBar JinDuTiao;
#region UI
bool isfirstShow = true;
/// <summary>加载到 100% 是否已完成(进房初始化/入场动画等需在加载完成后执行:玩家可见、位置就绪)</summary>
public bool IsGameLoadCompleted { get; private set; }
/// <summary>进房"首个初始化"(座位/房间信息)已挂起、等加载到 100% 后执行</summary>
private bool _pendingFirstShowInit;
/// <summary>挂起时保存的进房通知(ApplyFirstShowInit 里写入 gameData.roomJoinNotice)</summary>
private RoomJoinNotice _pendingRoomJoinNotice;
protected override void OnShow(object args = null)
{
base.OnShow(args);
// ⭐ 无操作自动退出:加载期间不计时,LoadResources 完成后重新开始计;确保倒计时面板初始隐藏
_idleExitArmed = false;
HideIdleExitWarning();
// ⭐ 资源加载不再在这里启动:由 WebSocketReponseList 在"面板已显示 → 游戏场景加载完成"后
// 调用 BeginLoadResources() 触发,保证加载顺序为 面板 → 场景 → 资源。
// ⭐ 切换房间:继续用离房时留下的合并进度条,先把新面板自带的进度条藏掉,避免出现两条进度条
if (_changeRoomBar != null && JinDuTiao != null)
JinDuTiao.Hide();
RefreshGGKCount();
//UIManager.Instance.ShowBoard("太好看了");
var v = JsonConvert.DeserializeObject<WebSocketResponseBase<RoomJoinNotice>>(args.ToString());
if (v.arguments.Count > 0)
{
RoomJoinNotice roomJoinNotice = v.arguments[0];
// ⭐ 座位镜像:进房第一时间确定本地服务端座位(2/3=上方 → 全桌上下镜像,本地永远看自己在下方)
SeatMirror.Setup(roomJoinNotice.Position);
// ⭐ paoBeiList 每次进入房间都重新根据当前场次+房间等级获取,
// 避免再次进入同一场次的不同等级房间时沿用旧 paoBeiList(例如先高级场后低级场)
{
int t = GameManager.Instance.gameData.currRoomData.gameType;
int l = GameManager.Instance.gameData.currRoomData.roomLevel;
foreach (var item in GameManager.Instance.gameConfig.roomConfigs)
{
if (item.gameType == t && l == item.roomLevel)
{
// ⭐ 炮倍从大到小排列(选择面板显示顺序/切换顺序):倒序拷贝,
// 不动服务端下发的原始配置列表;ApplyBotLevelIndex/SyncIndexByConsume 已适配倒序索引
paoBeiList = item.cannonMultipleList != null
? item.cannonMultipleList.OrderByDescending(x => x).ToList()
: item.cannonMultipleList;
}
}
}
if (isfirstShow)
{
// ⭐ 座位/房间信息初始化推迟到"加载到 100%"后执行(见 ApplyFirstShowInit 与加载完成点):
// 原来在这里立即 JoinGame/摆座位,进房入场动画会在加载期播放——玩家看不到、位置未就绪。
_pendingFirstShowInit = true;
_pendingRoomJoinNotice = roomJoinNotice;
}
else
{
var targetSit = list_Sits.Find(s => s.posID == SeatMirror.ToDisplay(roomJoinNotice.Position));
// ⭐ 优先用后端 isBot 字段判断,兼容 userId.StartsWith("bot_")
bool isBotJoin = roomJoinNotice.IsBot ||
(!string.IsNullOrEmpty(roomJoinNotice.UserId) && roomJoinNotice.UserId.StartsWith("bot_"));
if (isBotJoin)
{
// bot 加入 → 标记 isBot=true (JoinGame 里会自动 SetActive(true) 座位)
if (targetSit != null)
targetSit.isBot = true;
}
else
{
// 真人玩家加入 → 如果座位被bot占用则替换
if (targetSit != null && targetSit.isBot)
{
targetSit.isBot = false;
targetSit.ShowWaiting();
}
// ⭐ 新玩家同步走"主动请求"流程:
// 新玩家资源加载完毕后会主动发 requestSyncFish,
// 房主收到后才广播场上活鱼(避免新玩家还没加载完就收包丢失)
}
JoinGame(roomJoinNotice.Position, roomJoinNotice.Gold, roomJoinNotice.Diamond, roomJoinNotice.CannonMultiple, 0, roomJoinNotice.UserId, roomJoinNotice.CannonId, roomJoinNotice.WingsId, roomJoinNotice.WarGodId);
}
}
else
{
UIManager.Instance.ShowBoardError("获取信息失败");
}
}
/// <summary>
/// 进房首个初始化(座位/房间信息):原 OnShow 里 isfirstShow 的整段逻辑(原样搬入),
/// 现由"加载到 100%"的加载完成点调用——保证入场动画等在玩家可见、位置就绪后才初始化/播放。
/// </summary>
private void ApplyFirstShowInit()
{
RoomJoinNotice roomJoinNotice = _pendingRoomJoinNotice;
if (roomJoinNotice == null) return;
// ⭐ 立即消费标记:本方法在 90% 确认点执行,100% 处的兜底调用不会再重复跑
// (重复执行会把所有座位 JoinGame 两遍:金币/装备/入场标记全部错乱)
_pendingFirstShowInit = false;
string roomId = GameManager.Instance.gameData.currRoomData.roomId;
// 诊断:100% 卡的耗时分解(每个玩家 JoinGame 各多少 ms、合计多少 ms)
var swAll = System.Diagnostics.Stopwatch.StartNew();
var players = GameManager.Instance.gameData.currRoomData.players;
Debug.Log($"[XJMH首屏] 开始初始化:玩家 {players?.Count ?? 0} 名");
foreach (var item in players)
{
var swOne = System.Diagnostics.Stopwatch.StartNew();
JoinGame(item.position, item.gold, item.diamond, item.cannonMultiple, 0, item.userId, item.cannonId, item.wingsId, item.warGodId);
Debug.Log($"[XJMH首屏] JoinGame 座位={item.position} ({item.userName}) 耗时 {swOne.ElapsedMilliseconds}ms");
// ⭐ 优先用后端 isBot 字段,兼容 userId.StartsWith("bot_")
var sit = list_Sits.Find(s => s.posID == item.position);
if (sit != null)
{
bool isBot = item.isBot ||
(!string.IsNullOrEmpty(item.userId) && item.userId.StartsWith("bot_"));
if (isBot) sit.isBot = true;
}
}
GameManager.Instance.gameData.roomJoinNotice = roomJoinNotice;
isfirstShow = false;
foreach (var item in list_Sits)
{
// ⭐ 座位镜像:本地玩家的显示座位 = 服务端座位转显示空间(上方座位翻转后落回下方)
if (item.posID == SeatMirror.ToDisplay(roomJoinNotice.Position))
{
currSitData = item;
// ⭐ 长按本地座位的 img_OpenPT 开关炮台面板(与 btn_ChangeTurret 同逻辑)
HookOpenPTLongPress(currSitData);
// ⭐ 本地座位自动攻击关闭时广播 Idle 状态给其他客户端
HookAutoAttackOff(currSitData);
}
else
{
// ⭐ 非本地座位隐藏加减炮倍:Normal 藏座位按钮(未拖引用为 null 跳过),
// XJMH 藏炮台上的加减倍(TurretData.obj_Add / obj_Down)
item.HideTurretAddMinusUI();
// ⭐ 自动攻击开关与加减倍按钮一样:非本地座位隐藏
if (item.toggle_AutoAttack != null)
item.toggle_AutoAttack.gameObject.SetActive(false);
}
}
//// currSitData.ChangeTurret(1); — 已由 SitPosData.OnEnable 自动加载
if (currSitData.currentTurret != null)
SyncChangeTurret(currSitData.currentTurret.turretNumber);
currSitData.lockFX.transform.GetChild(0).gameObject.SetActive(false);
currSitData.lockFX.transform.GetChild(1).gameObject.SetActive(true);
if (mySitAnim != null && currSitData.turretParent != null)
{
// ⭐ 座位位置 + 左右座位各自的偏移:把"我在这"圆圈摆到座位上面
// (左右座位镜像后需要的偏移不一样,分左/右两套配置,面板上调)
Vector3 sitAnimOffset = IsLeftSeat(currSitData) ? mySitAnimOffsetLeft : mySitAnimOffsetRight;
mySitAnim.transform.position = currSitData.turretParent.position + sitAnimOffset;
// ⭐ 进入游戏圆圈:6 秒后自动消失(点击屏幕立即消失的逻辑在 HandleInput)
StartCoroutine(HideMySitAnimAfter(6f));
}
// 诊断:100% 这一下的总耗时(配合每条 JoinGame 的 ms 看是哪一步重)
Debug.Log($"[XJMH首屏] ApplyFirstShowInit 总耗时 {swAll.ElapsedMilliseconds}ms");
}
/// <summary>
/// XJMH 进房预热(60% 音频段起点调用,进度条还显示着玩家看不到):
/// 把本地座位(座位自带炮台随 Awake 一起初始化)与"入场要用的那架完整飞机"提前点亮——
/// 这些 XJMH 独有资产首次初始化(骨架/粒子/贴图首上传)很重,全堆在 100% 那一帧玩家会看到明显卡顿。
/// ⭐ 按帧拆散(避免"90% 卡死"):先让一帧(进度条先刷新)→ 只留默认炮台、关掉其余 3 把 →
/// 激活座位(1 套骨架) → 等两帧 → 飞机分两帧点亮(Body / 护盾粒子各一帧)。
/// 入场动画有加载完成门卫(IsGameLoadCompleted),预热不会导致动画提前播放。
/// </summary>
private void PrewarmXJMHForLocalSeat()
{
if (!_pendingFirstShowInit || _pendingRoomJoinNotice == null) return;
if (list_Sits == null || list_Sits.Count == 0) return;
var sit = list_Sits.Find(s => s != null && s.posID == SeatMirror.ToDisplay(_pendingRoomJoinNotice.Position));
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) return;
StartCoroutine(PrewarmXJMHCoroutine(sit));
}
private IEnumerator PrewarmXJMHCoroutine(SitPosData sit)
{
// ⭐ 先等一帧:StartCoroutine 会同步执行到第一个 yield —— 座位激活的重活不能和进度条
// 写数字挤在同一帧(玩家看到"卡在 60/61")。先让一帧,进度条先刷新
yield return null;
bool willActivate = sit != null && !sit.gameObject.activeSelf;
// ⭐ 分帧方案(XJMH 进房不卡主线程的关键)—— 把所有"可以错开的初始化"从同一帧剥出去:
// ① 座位外壳(1 帧,只扛座位整层 OnEnable)
// ② 默认炮台 Spine 骨架首初始化(单独 1 帧,见 SitPosData.deferDefaultTurretLightUp)
// ③ 座位外壳粒子 + 默认炮台粒子(本方法先关掉,之后逐帧点亮)
// ④ 入场飞机(FlyTurretData.PrewarmPlaneRoutine:骨架 1 帧 + 41 个粒子逐帧点亮)
List<GameObject> lazyPsNodes = null;
if (willActivate)
{
// 预关全部 4 把炮台(Spine 骨架默认全开,不关的话激活帧一次初始化 4 套)
// ⚠️ 座位已在激活状态时不能动(Awake 不会再跑,关掉就回不来)
if (sit.xjmhTurretList != null)
{
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t != null && t.gameObject.activeSelf) t.gameObject.SetActive(false);
}
}
// 收集"激活后会被点亮"的粒子并先关掉(此刻根未激活,只是置标志、无初始化):
// 座位外壳层(炮台/飞机子树此刻未激活,自动被排除)+ 默认炮台子树(兄弟顺序最靠前=绿色那把)
lazyPsNodes = CollectActiveParticlesUnder(sit.transform);
var defaultTurret = GetDefaultXJMHTurret(sit);
if (defaultTurret != null)
{
var turretPsNodes = CollectActiveParticlesUnder(defaultTurret.transform);
if (turretPsNodes != null)
{
if (lazyPsNodes == null) lazyPsNodes = turretPsNodes;
else lazyPsNodes.AddRange(turretPsNodes);
}
}
// ① 激活座位【外壳】:设 defer 标记 → Awake 只整理炮台列表、不点亮炮台
// (这一帧只扛"座位整层 OnEnable",把炮台骨架首建推到下一帧)
sit.deferDefaultTurretLightUp = true;
LoadingStallWatchdog.Phase = "预热·座位外壳激活帧";
var swSeat = System.Diagnostics.Stopwatch.StartNew();
sit.gameObject.SetActive(true);
Debug.Log($"[XJMH预热] 座位外壳激活 {swSeat.ElapsedMilliseconds}ms");
}
yield return null; // 座位外壳首帧
// ② 单独一帧点亮默认炮台:Spine 骨架首初始化(材质/网格/贴图上传)只花在这一帧
// ⚠️ 判据用"列表里有没有激活的炮台",不能用 currentTurret(可序列化字段,预制体里预置了引用)
if (willActivate && !sit.HasActiveXJMHTurret())
{
LoadingStallWatchdog.Phase = "预热·炮台骨架激活帧";
var swTurret = System.Diagnostics.Stopwatch.StartNew();
sit.ActivateDefaultXJMHTurretForPrewarm();
Debug.Log($"[XJMH预热] 炮台骨架激活 {swTurret.ElapsedMilliseconds}ms");
}
yield return null; // 炮台骨架首帧网格
// ③ 逐帧点亮座位/炮台粒子(每帧约 8ms 预算)
if (lazyPsNodes != null && lazyPsNodes.Count > 0)
{
LoadingStallWatchdog.Phase = "预热·座位/炮台粒子分帧";
yield return ChunkEnableParticles(lazyPsNodes);
}
yield return null;
// ④ 飞机隐藏预热(座位激活后 currentTurret 已就绪 = 入场会用的那把;内部对 41 个粒子分帧)
var turret = sit != null ? sit.currentTurret as FlyTurretData : null;
if (turret != null)
{
LoadingStallWatchdog.Phase = "预热·飞机(骨架+粒子分帧)";
turret.PrewarmEnterPlane();
}
}
/// <summary>XJMH 默认炮台 = 兄弟顺序最靠前的那把(与 SitPosData.NormalizeXJMHTurretList 的排序一致)</summary>
private static TurretData GetDefaultXJMHTurret(SitPosData sit)
{
if (sit == null || sit.xjmhTurretList == null) return null;
TurretData best = null;
int bestIdx = int.MaxValue;
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t == null) continue;
int idx = t.transform.GetSiblingIndex();
if (idx < bestIdx) { bestIdx = idx; best = t; }
}
return best;
}
/// <summary>
/// 收集"根若激活就会被激活"的粒子节点并先把它们关掉(此刻根未激活,SetActive(false) 只置标志、
/// 不触发任何初始化)——配合 ChunkEnableParticles 把这些粒子系统的首初始化逐帧分摊。
/// 容器(父级)关着的不收集;自身默认关闭的粒子也不动。
/// </summary>
private static List<GameObject> CollectActiveParticlesUnder(Transform root)
{
if (root == null) return null;
List<GameObject> list = null;
var systems = root.GetComponentsInChildren<ParticleSystem>(true);
for (int i = 0; i < systems.Length; i++)
{
var ps = systems[i];
if (ps == null || ps.gameObject == root.gameObject) continue;
if (!IsActiveUnderRoot(ps.transform, root)) continue;
ps.gameObject.SetActive(false);
if (list == null) list = new List<GameObject>(16);
list.Add(ps.gameObject);
}
return list;
}
/// <summary>节点在"根若激活"的前提下是否会被激活(检查自身到根之间每级的 activeSelf)</summary>
private static bool IsActiveUnderRoot(Transform node, Transform root)
{
for (var t = node; t != null && t != root; t = t.parent)
if (!t.gameObject.activeSelf) return false;
return true;
}
/// <summary>逐帧分批点亮粒子节点(每帧约 8ms 预算:快机一帧多开、慢机一帧一个)</summary>
private static IEnumerator ChunkEnableParticles(List<GameObject> nodes)
{
const double budgetMs = 8.0;
var frameSw = System.Diagnostics.Stopwatch.StartNew(); // 帧内预算
var oneSw = System.Diagnostics.Stopwatch.StartNew(); // 单个粒子点亮耗时(诊断)
double enabledMs = 0;
int frames = 1;
for (int i = 0; i < nodes.Count; i++)
{
if (nodes[i] == null) continue;
oneSw.Restart();
nodes[i].SetActive(true);
enabledMs += oneSw.Elapsed.TotalMilliseconds;
if (frameSw.Elapsed.TotalMilliseconds >= budgetMs)
{
frameSw.Restart();
frames++;
yield return null;
}
}
yield return null;
Debug.Log($"[XJMH预热] 粒子分帧点亮 {nodes.Count} 个 / {frames} 帧 / 净耗时 {enabledMs:F0}ms(平均 {(nodes.Count > 0 ? enabledMs / nodes.Count : 0):F1}ms/个)");
}
/// <summary>
/// ⭐ 进房早期(0~2%)预关闭"未激活座位"的多余 XJMH 炮台。
/// 原因:XJMH 座位预制体里 4 把炮台(SkeletonGraphic)默认激活,而座位首次 SetActive 时
/// 4 套骨架/材质/贴图会挤在同一帧全部首初始化(单帧尖峰);预关后 Awake 的 InitXJMHTurret
/// 只点亮默认那把 → 激活帧只初始化 1 套。其它玩家/bot 的座位在 100% 入座时才激活,
/// 同样受益(否则每个座位入座都要初始化 4 套)。
/// ⚠️ 只处理"尚未激活"的座位:已激活的座位 Awake 已跑过,会被 Admin 逻辑重新点亮,
/// 动它反而可能把炮台关掉回不来。
/// </summary>
private void PreCloseInactiveSitExtraTurrets()
{
if (list_Sits == null) return;
foreach (var sit in list_Sits)
{
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) continue;
if (sit.gameObject.activeSelf) continue; // 已激活座位不动
// 列表为空时 Awake 不会补开炮台 → 全关会留空座位,跳过
if (sit.xjmhTurretList == null || sit.xjmhTurretList.Count == 0) continue;
for (int i = 0; i < sit.xjmhTurretList.Count; i++)
{
var t = sit.xjmhTurretList[i];
if (t != null && t.gameObject.activeSelf) t.gameObject.SetActive(false);
}
}
}
/// <summary>
/// ⭐ 提前解析 XJMH 炮台/入场飞机的"骨架数据"(在鱼包加载段调用,与鱼包加载并行)。
/// 背景:SkeletonGraphic 首次激活时(Spine 源码 SkeletonGraphic.Initialize → GetSkeletonData)
/// 要解析 .skel/.json + 建图集对象 + 应用融合材质——这是首激活里最重的一块(几十~几百 ms),
/// 而且 GetSkeletonData 的结果按 SkeletonDataAsset 缓存。
/// 这里提前在主线程调用一次把它缓存掉:60% 处的"炮台骨架帧/飞机骨架帧"就只剩建网格/渲染器
/// 这类轻活(源码里 Initialize 其余部分只有 AnimationState/Skeleton 构造 + 网格缓冲分配)。
/// 放在鱼包段执行:本段主线程本来就在解包/反序列化(有加载卡顿),解析耗时混在这里最不显眼。
/// 解析用的是同一份缓存,同一把炮台预制体的所有实例(其它玩家座位)也一并受益。
/// </summary>
private void WarmUpXJMHSkeletonData()
{
if (!_pendingFirstShowInit || _pendingRoomJoinNotice == null) return;
if (list_Sits == null || list_Sits.Count == 0) return;
var sit = list_Sits.Find(s => s != null && s.posID == SeatMirror.ToDisplay(_pendingRoomJoinNotice.Position));
if (sit == null || sit.turretControlMode != TurretControlMode.XJMH) return;
var defaultTurret = GetDefaultXJMHTurret(sit);
if (defaultTurret == null) return;
var sw = System.Diagnostics.Stopwatch.StartNew();
int warmed = 0;
// 默认炮台子树(含挂在它下面的入场飞机实例)里的所有 SkeletonGraphic:逐个把骨架数据解析缓存掉
var graphs = defaultTurret.transform.GetComponentsInChildren<SkeletonGraphic>(true);
for (int i = 0; i < graphs.Length; i++)
{
var sg = graphs[i];
if (sg == null || sg.skeletonDataAsset == null) continue;
try
{
if (sg.skeletonDataAsset.GetSkeletonData(true) != null) warmed++;
}
catch (Exception e)
{
// 解析失败不影响流程:60% 激活时 SkeletonGraphic 会按正常路径自己再解析
Debug.LogWarning($"[XJMH预热] 骨架数据预解析失败(不影响流程): {e.Message}");
}
}
Debug.Log($"[XJMH预热] 骨架数据预解析:{warmed} 个 / 共 {sw.ElapsedMilliseconds}ms");
LoadingStallWatchdog.Phase = "30~60 鱼包"; // 恢复阶段名(诊断用)
}
private void RefreshGGKCount()
{
var user = GameManager.Instance.user;
var props = user?.props;
int ggkCount = 0;
if (props != null)
{
foreach (var p in props)
{
if (p.number == "40005" && p.count > 0)
ggkCount = p.count;
}
}
if (tmp_GGKCount != null)
tmp_GGKCount.text = ggkCount.ToString();
// 同步刷新暴走/冰封本地计数(仅首次或服务端数据变化时同步)
RefreshSkillPropsCount(props);
// 同步更新游戏内金币显示(刮刮卡领奖、邮件领取等都会触发)
// ⭐ 但游戏内金币由房间数据(RoomJoinNotice.gold / SyncPlayerGold)管理,
// user.assets.gold 是账户总金币,进房时后端推送 AssetUpdated 会触发此处,
// 如果用账户总金币覆盖房间金币,会导致显示与后端房间数据不一致。
// 因此游戏内(isGame=true)不覆盖 goldNum,退房后由 SyncPlayerGold 或重新进房时设置。
if (currSitData != null && user?.assets != null && !WebSocketManager.Instance.isGame)
{
currSitData.goldNum = user.assets.gold;
currSitData.SetGoldNumText(user.assets.gold);
}
}
/// <summary>
/// 刷新暴走/冰封道具数量显示
/// 策略:记录上次服务端数量,只要服务端数量相比上次有变化(增加或减少)就信任服务端
/// (覆盖刮刮卡奖励、其他端使用等场景);服务端数量未变时保留本地扣减值,
/// 避免被服务端旧值覆盖本地扣减
/// </summary>
private void RefreshSkillPropsCount(List<BaseProductCount> props)
{
int serverBaoZou = 0;
int serverBingFeng = 0;
if (props != null)
{
foreach (var p in props)
{
if (p.number == "40004" && p.count > 0) serverBaoZou = p.count;
else if (p.number == "40003" && p.count > 0) serverBingFeng = p.count;
}
}
// 首次初始化,或服务端数量相比上次有变化(刮刮卡奖励增加/其他端使用减少)→ 以服务端为准
if (_localBaoZouCount < 0 || serverBaoZou != _lastServerBaoZouCount)
_localBaoZouCount = serverBaoZou;
if (_localBingFengCount < 0 || serverBingFeng != _lastServerBingFengCount)
_localBingFengCount = serverBingFeng;
_lastServerBaoZouCount = serverBaoZou;
_lastServerBingFengCount = serverBingFeng;
UpdateSkillPropsUI();
}
/// <summary>仅更新 UI 文本</summary>
private void UpdateSkillPropsUI()
{
if (tmp_BaoZouCount != null)
tmp_BaoZouCount.text = Mathf.Max(0, _localBaoZouCount).ToString();
if (tmp_BingFengCount != null)
tmp_BingFengCount.text = Mathf.Max(0, _localBingFengCount).ToString();
}
protected override void OnHide()
{
base.OnHide();
// ⭐ 清理炮台切换面板(如果还在显示)
CleanupTurretPanel();
// ⭐ 解绑长按 img_OpenPT 打开炮台面板的订阅
UnhookOpenPTLongPress();
UnhookAutoAttackOff();
// ⭐ 无操作自动退出:面板隐藏即停止计时
_idleExitArmed = false;
HideIdleExitWarning();
// ⭐ 切换房间(同玩法):资源/场景/路线全部复用,不卸载不重载 ——
// 卸载再加载同一个 LZMA 鱼包(219MB 级)要白解压两次,是切房慢的主因。
// 新面板 LoadResources 走切房分支直接 EnterRoom,socket/座位镜像照常重置。
if (_changeRoomBar == null)
{
UnLoadResources();
// 场景可能未加载或已卸载,做保护避免重复卸载报错。
// ⭐ 统一走 WebSocketReponseList.UnloadGameScene:场景是用 Addressables 加载的,
// 必须用 ab 句柄释放,只调 SceneManager.UnloadSceneAsync 会漏掉 ab 包。
var scene = SceneManager.GetSceneByName(sceneName);
if (scene.IsValid() && scene.isLoaded)
Assets.MyGame.Scripts.Net.WebSocketReponseList.UnloadGameScene(sceneName);
// 清除场景加载标记,下次进入可重新加载
Assets.MyGame.Scripts.Net.WebSocketReponseList.ClearLoadedGameScene(sceneName);
}
else
{
Debug.Log("[ChangeRoom] 同玩法切房:复用已加载的场景/鱼/音频/路线,跳过卸载");
}
WebSocketManager.Instance.Close(SocketType.game);
// ⭐ 座位镜像状态清空,下次进房重新按新房间座位 Setup
SeatMirror.Clear();
// ⭐ 重置本地道具计数,下次进入房间重新从服务端读取
_localBaoZouCount = -1;
_localBingFengCount = -1;
_lastServerBaoZouCount = -1;
_lastServerBingFengCount = -1;
}
#endregion
#region 长按 img_OpenPT 开关炮台面板
/// <summary>已订阅长按事件的座位(换座位/重进房间时先解绑避免重复订阅)</summary>
private SitPosData _openPTHookedSit;
/// <summary>缓存的长按处理器(解绑用)</summary>
private Action _openPTLongPressHandler;
/// <summary>
/// 订阅本地座位的 img_OpenPT 长按事件:长按触发 OnOpenPTLongPressTriggered(子类可覆写)。
/// </summary>
private void HookOpenPTLongPress(SitPosData sit)
{
// 先解绑旧订阅
UnhookOpenPTLongPress();
if (sit == null) return;
_openPTLongPressHandler = () => OnOpenPTLongPressTriggered();
sit.OnOpenPTLongPress += _openPTLongPressHandler;
_openPTHookedSit = sit;
}
/// <summary>
/// 长按 img_OpenPT 达到阈值触发。
/// Normal 模式:开关炮台面板(subUIManager,与 btn_ChangeTurret 同逻辑)。
/// XJMH 模式:子类覆写为开关 ChangePTPanel。
/// </summary>
protected virtual void OnOpenPTLongPressTriggered()
{
// 与 btn_ChangeTurret 完全相同的开关逻辑:开着就关,没开就打开
var panel = subUIManager.Get<SubUIBase>(UIPanel.TurretPanel);
if (panel != null && panel.gameObject.activeSelf)
subUIManager.Hide(UIPanel.TurretPanel);
else
{
subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true));
StartCoroutine(CloseSettingList());
}
}
/// <summary>解绑长按 img_OpenPT 的订阅(面板隐藏/重新订阅前调用)</summary>
private void UnhookOpenPTLongPress()
{
if (_openPTHookedSit != null && _openPTLongPressHandler != null)
_openPTHookedSit.OnOpenPTLongPress -= _openPTLongPressHandler;
_openPTHookedSit = null;
_openPTLongPressHandler = null;
}
#endregion
#region 自动攻击开关(SitPosData.toggle_AutoAttack)
/// <summary>已订阅自动攻击关闭事件的座位(换座位/重进房间时先解绑避免重复订阅)</summary>
private SitPosData _autoAttackHookedSit;
/// <summary>缓存的关闭处理器(解绑用)</summary>
private Action _autoAttackOffHandler;
/// <summary>
/// 订阅本地座位的自动攻击关闭事件:关闭时广播 Idle 状态给其他客户端
/// (座位本体的 ChangeState(Idle) 已在 SitPosData.OnAutoAttackToggleChanged 里完成)。
/// </summary>
private void HookAutoAttackOff(SitPosData sit)
{
UnhookAutoAttackOff();
if (sit == null) return;
_autoAttackOffHandler = () => SyncTurretState(TurretState.Idle);
sit.OnAutoAttackOff += _autoAttackOffHandler;
_autoAttackHookedSit = sit;
}
/// <summary>解绑自动攻击关闭事件的订阅(面板隐藏/重新订阅前调用)</summary>
private void UnhookAutoAttackOff()
{
if (_autoAttackHookedSit != null && _autoAttackOffHandler != null)
_autoAttackHookedSit.OnAutoAttackOff -= _autoAttackOffHandler;
_autoAttackHookedSit = null;
_autoAttackOffHandler = null;
}
#endregion
#region 生命周期
private float baseAttackTime;
public override void Awake()
{
base.Awake();
// ⭐ 把面板上拖的 boss 播报预制体交给 SubUIManager:
// 以前这里没赋值 → subUIManager.boardCast_Boss 一直是 null,Boss 出场播报根本不显示。
// 现在 XJMH 用它播 lua 的 PW_Effect_Boss_Appear(见 BossAppearTipXJMH.cs)
if (subUIManager != null)
subUIManager.boardCast_Boss = boardCast_Boss;
GameManager.Instance.OnUserDataChanged += RefreshGGKCount;
baseAttackTime = attackTime;
// ⭐ 各游戏 prefab 按钮配置不同:未拖引用时为null,做保护(空就跳过,不能报错)
if (btn_ChangeTurret != null)
btn_ChangeTurret.onClick.AddListener(() =>
{
var panel = subUIManager.Get<SubUIBase>(UIPanel.TurretPanel);
if (panel != null && panel.gameObject.activeSelf)
subUIManager.Hide(UIPanel.TurretPanel);
else
{
subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true));
StartCoroutine(CloseSettingList());
}
});
if (btn_Setting != null)
btn_Setting.onClick.AddListener(() => {subUIManager.ShowAsync(UIPanel.SettingPanel); });
// ⭐ 各游戏prefab未拖拽表情按钮时为null,做保护(XJMH面板没有该按钮)
if (btn_BiaoQing != null)
btn_BiaoQing.onClick.AddListener(OnBiaoQingButtonClick);
if (btn_TuiChu != null)
btn_TuiChu.onClick.AddListener(() =>
{
StartLeaveCountdown();
});
// ⭐ 切换房间:离房后重新走一遍加入房间流程(QuickJoin同场次同等级 → 连新房socket → JoinRoom)
// 各游戏prefab未拖拽该按钮时为null,做保护
if (btn_ChangeRoom != null)
btn_ChangeRoom.onClick.AddListener(StartChangeRoom);
// ⭐ 无操作自动退出:关闭倒计时面板并重新计时(未拖引用时为null,做保护)
if (btn_CloseAutoExitGamePanel != null)
btn_CloseAutoExitGamePanel.onClick.AddListener(OnCloseAutoExitPanel);
// ⭐ 左侧道具按钮:开启/关闭 obj_PropPanel(一级列表由 PropConfigData 配置构建,
// 点击一级道具在 tmp_PropTwoPanel 展开二级子道具)
if (btn_Prop != null)
btn_Prop.onClick.AddListener(() =>
{
if (obj_PropPanel == null) return;
bool open = !obj_PropPanel.activeSelf;
obj_PropPanel.SetActive(open);
if (open)
{
BuildPropList();
// ⭐ 数量是动态数据:每次打开面板拉最新背包再刷新(刮刮卡等面板内刮出/消耗的道具也能同步)
RefreshPropCounts();
}
else
ClosePropTwo(); // 关道具面板时一并收起二层并重置展开状态
});
// ⭐ 大喇叭:开/关弹幕输入面板
// ⭐ 绑定在 OnShow 里:先 RemoveAllListeners 再 Add,防止面板重复 OnShow 叠加监听器
// (叠加后点一次发送 = 发 N 次 API = 多扣 N 个道具)
if (dalaba != null)
{
dalaba.onClick.RemoveAllListeners();
dalaba.onClick.AddListener(() =>
{
if (obj_DaLaBaPanel != null)
obj_DaLaBaPanel.SetActive(!obj_DaLaBaPanel.activeSelf);
});
}
// 大喇叭正文限长:后端整条广播≤20字,标识占1字,输入框直接限制19字
if (tmp_DaLaBaInput != null)
tmp_DaLaBaInput.characterLimit = APIRequestList.BroadcastMaxContentLength;
if (btn_DaLaBaSend != null)
{
btn_DaLaBaSend.onClick.RemoveAllListeners();
btn_DaLaBaSend.onClick.AddListener(OnClickDaLaBaSend);
}
if (btn_DaLaBaClose != null)
{
btn_DaLaBaClose.onClick.RemoveAllListeners();
btn_DaLaBaClose.onClick.AddListener(() =>
{
if (obj_DaLaBaPanel != null)
obj_DaLaBaPanel.SetActive(false);
});
}
// ⭐ 召唤卡:使用召唤卡召唤Boss(同样防重复绑定)
if (zhanhuanka != null)
{
zhanhuanka.onClick.RemoveAllListeners();
zhanhuanka.onClick.AddListener(OnClickZhanHuanKa);
}
// 服务器广播监听
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate);
EventCenter.Instance.AddEventListener<FireCannonSendData>(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing);
// ⭐ 战神切换帧同步(changeZhanShen):其他玩家装备/卸下战神,对应座位加载/清除战神模型
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen);
// ⭐ 表情包帧同步(biaoQing):其他玩家发送表情,对应座位显示
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing);
// ⭐ 加金币帧同步(addGold):其他玩家刮刮卡等 HTTP 途径获得金币,对应座位加金币刷新显示
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_AddGold, SyncGameData_AddGold);
// ⭐ 刮刮卡领取金币:本地座位加金币并帧同步给房间其他玩家(先减防重复订阅)
GuaGuaLeGPU.OnScratchGoldAwarded -= OnScratchGoldAwarded;
GuaGuaLeGPU.OnScratchGoldAwarded += OnScratchGoldAwarded;
// SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发
EventCenter.Instance.AddEventListener(ConstEventString.FishFrozen, OnFishFrozen);
// ⭐ 召唤卡召唤通知:某玩家使用召唤卡,服务端已创建召唤鱼(仅播提示,实际鱼由 OnFishSpawn 创建)
EventCenter.Instance.AddEventListener<SummonCardFishNoticeData>(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice);
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle);
// ⭐ 爆奖结束帧同步(baoJiangEnd):其他玩家爆奖完成,显示最终 BJEndUI 并加分
EventCenter.Instance.AddEventListener<SyncGameData>(ConstEventString.BaoJiangEnd, OnBaoJiangEnd);
// ⭐ 用户数据变化(背包/金币等)→ 刷新道具数量显示:
// 服务端扣/发道具后推 BackpackUpdated/AssetUpdated → GetUser → UpdataUserData → 此事件。
// 统一覆盖所有道具类型(VIP经验/刮刮卡/鱼雷等异步消耗,点击瞬间服务端还没扣,
// 点击后立即拉背包是竞态拿旧数据,所以靠这条事件链实时刷新)
GameManager.Instance.OnUserDataChanged -= OnUserDataChanged_RefreshProps;
GameManager.Instance.OnUserDataChanged += OnUserDataChanged_RefreshProps;
if (btn_TuJian != null)
btn_TuJian.onClick.AddListener(() =>
{
subUIManager.ShowAsync(UIPanel.GameTuJian);
StartCoroutine(CloseSettingList());
});
if (btn_GuaGuaKa != null)
btn_GuaGuaKa.onClick.AddListener(() =>
{
subUIManager.ShowAsync(UIPanel.GuaGuaKa);
});
}
/// <summary>打开离开游戏确认面板:点确认直接退出房间(无倒计时)</summary>
public void StartLeaveCountdown()
{
if (_isLeavingCountdown) return;
_isLeavingCountdown = true;
if (obj_DJSPanel != null) obj_DJSPanel.SetActive(true);
// ⭐ 无倒计时:确认按钮直接可点
if (btn_QueRen != null) btn_QueRen.interactable = true;
if (tmp_DJS != null) tmp_DJS.text = "确认";
// 绑定按钮事件
if (btn_QueRen != null)
{
btn_QueRen.onClick.RemoveAllListeners();
btn_QueRen.onClick.AddListener(() =>
{
// 确认:关闭确认面板,发送离房请求 + 启动进度条卸载
if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false);
RequestLeaveRoom();
});
}
if (btn_QuXiao != null)
{
btn_QuXiao.onClick.RemoveAllListeners();
btn_QuXiao.onClick.AddListener(CancelLeaveCountdown);
}
}
/// <summary>是否正在离开倒计时</summary>
public bool IsLeavingCountdown() => _isLeavingCountdown;
/// <summary>取消离开倒计时</summary>
public void CancelLeaveCountdown()
{
_isLeavingCountdown = false;
if (_leaveCountdownCoroutine != null)
{
StopCoroutine(_leaveCountdownCoroutine);
_leaveCountdownCoroutine = null;
}
if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false);
}
private IEnumerator LeaveCountdownRoutine(int seconds)
{
int remaining = seconds;
while (remaining > 0)
{
if (tmp_DJS != null) tmp_DJS.text = $"确认({remaining})";
yield return new WaitForSeconds(1f);
remaining--;
}
// 倒计时结束:按钮启用,文本显示"确认"
if (tmp_DJS != null) tmp_DJS.text = "确认";
if (btn_QueRen != null) btn_QueRen.interactable = true;
}
#region 无操作自动退出(60秒静默 → 最后10秒倒计时面板 → 70秒自动回大厅)
/// <summary>
/// ⭐ 每帧记录玩家操作,超时自动走离房流程回大厅。
/// 操作判定 = 屏幕输入(触摸/鼠标/键盘,含 UI 点击)**或** 自动游玩状态
/// (锁定中自动锁定/开火、自动攻击中——不能只看屏幕点击,详见 IsLocalPlayerAutoPlaying)。
/// 机器人/其他玩家的同步数据不算操作;退出确认面板开着时超时同样生效。
/// 只在资源加载完成后计时(加载慢不会被踢);离房流程进行中不重复触发。
/// 流程:前 idleExitTotalSeconds - idleExitPanelShowSeconds(默认60秒)全程无提示
/// → 最后 idleExitPanelShowSeconds(默认10秒)才弹出倒计时面板(autoExitGamePanel)
/// 逐秒倒数 → 走满 idleExitTotalSeconds(默认70秒)自动退出回大厅;
/// 期间任意操作或点 btn_CloseAutoExitGamePanel → 关闭面板并重新计时。
/// </summary>
private void UpdateIdleAutoExit()
{
if (idleExitTotalSeconds <= 0f || idleExitPanelShowSeconds <= 0f)
{
HideIdleExitWarning();
return;
}
// ⭐ 操作判定①:任何触摸/鼠标/键盘输入(含点在 UI 上)
// 操作判定②:锁定/自动攻击中(炮台自动打鱼,玩家不碰屏幕也算在玩)
if (IsAnyOperationThisFrame() || IsLocalPlayerAutoPlaying())
{
_lastOperationTime = Time.realtimeSinceStartup;
// 有操作 → 立即隐藏倒计时面板
HideIdleExitWarning();
}
// 计时未生效(加载中/已隐藏)或已在离房流程中 → 跳过
if (!_idleExitArmed || _leaveRoomResponseTcs != null) return;
float idleElapsed = Time.realtimeSinceStartup - _lastOperationTime;
// 面板弹出时刻:总时长 - 面板显示时长(70 - 10 = 60 秒)
float panelShowAt = idleExitTotalSeconds - idleExitPanelShowSeconds;
if (idleElapsed >= idleExitTotalSeconds)
{
HideIdleExitWarning();
Debug.Log($"[IdleExit] 连续 {idleExitTotalSeconds} 秒无操作,自动退出游戏回大厅");
// 退出确认面板还开着 → 先关掉,再走离房流程
if (_isLeavingCountdown) CancelLeaveCountdown();
RequestLeaveRoom();
return;
}
// 只有最后 idleExitPanelShowSeconds 秒才显示面板;之前保持隐藏
if (idleElapsed >= panelShowAt)
ShowIdleExitWarning(Mathf.CeilToInt(idleExitTotalSeconds - idleElapsed));
else
HideIdleExitWarning();
}
/// <summary>显示/刷新无操作退出倒计时面板(仅在剩余秒数变化时刷新文本,避免每帧 GC)</summary>
private void ShowIdleExitWarning(int remainingSeconds)
{
// 弹出倒计时面板(未拖面板引用时退化为只显示文本)
if (autoExitGamePanel != null)
{
if (!autoExitGamePanel.activeSelf)
autoExitGamePanel.SetActive(true);
}
else if (tmp_ExitDaoJiShi != null && !tmp_ExitDaoJiShi.gameObject.activeSelf)
{
tmp_ExitDaoJiShi.gameObject.SetActive(true);
}
if (tmp_ExitDaoJiShi != null && _idleWarnShownSeconds != remainingSeconds)
{
_idleWarnShownSeconds = remainingSeconds;
tmp_ExitDaoJiShi.text =
$"({remainingSeconds}秒后自动退出)";
}
}
/// <summary>隐藏无操作退出倒计时面板(有操作/关闭按钮/开始离房/面板隐藏时调用)</summary>
private void HideIdleExitWarning()
{
if (autoExitGamePanel != null)
{
if (autoExitGamePanel.activeSelf)
{
autoExitGamePanel.SetActive(false);
_idleWarnShownSeconds = -1;
}
return;
}
if (tmp_ExitDaoJiShi != null && tmp_ExitDaoJiShi.gameObject.activeSelf)
{
tmp_ExitDaoJiShi.gameObject.SetActive(false);
_idleWarnShownSeconds = -1;
}
}
/// <summary>btn_CloseAutoExitGamePanel:关闭倒计时面板并从头重新计时</summary>
private void OnCloseAutoExitPanel()
{
HideIdleExitWarning();
_lastOperationTime = Time.realtimeSinceStartup;
}
/// <summary>本帧是否有玩家输入:触摸/鼠标按下、按住、松开 或 任意按键</summary>
private bool IsAnyOperationThisFrame()
{
var input = GetInput();
if (input.isPressed || input.wasPressedThisFrame || input.wasReleasedThisFrame)
return true;
var keyboard = Keyboard.current;
return keyboard != null && keyboard.anyKey.isPressed;
}
/// <summary>
/// ⭐ 本地玩家是否处于"自动游玩"状态(与 HandleAutoFire 的 needAutoFire 同判定):
/// - 锁定模式:锁定开关开着且有锁定目标(炮台自动锁定/旋转/开火)
/// - 自动攻击模式:toggle_AutoAttack 开着
/// 这些模式下玩家不碰屏幕也在正常游戏,不算无操作。
/// 只判断本地玩家 currSitData,其他玩家/机器人的状态不算。
/// </summary>
private bool IsLocalPlayerAutoPlaying()
{
if (currSitData == null) return false;
// 锁定模式:开关开着 && 有锁定目标(lockFX 激活)
if (toggle_Lock != null && toggle_Lock.isOn
&& currSitData.isLock
&& currSitData.lockFX != null && currSitData.lockFX.gameObject.activeSelf)
return true;
// 自动攻击模式
if (currSitData.IsAutoAttackOn) return true;
return false;
}
/// <summary>开始无操作计时(LoadResources 完成后调用,计时从当前时刻重新起算)</summary>
private void ArmIdleAutoExit()
{
_lastOperationTime = Time.realtimeSinceStartup;
_idleExitArmed = true;
}
#endregion
public override void OnDestroy()
{
// ⭐ 基类释放子面板(图鉴/锁定等 SubUIManager 句柄)的 Addressables 资源
base.OnDestroy();
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate);
EventCenter.Instance.RemoveEventListener<FireCannonSendData>(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing);
// ⭐ 加金币帧同步 + 刮刮卡领取金币订阅随面板销毁解除
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_AddGold, SyncGameData_AddGold);
GuaGuaLeGPU.OnScratchGoldAwarded -= OnScratchGoldAwarded;
// SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发
EventCenter.Instance.RemoveEventListener(ConstEventString.FishFrozen, OnFishFrozen);
EventCenter.Instance.RemoveEventListener<SummonCardFishNoticeData>(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.BaoJiangEnd, OnBaoJiangEnd);
// ⭐ 用户数据变化订阅随面板销毁解除(RefreshGGKCount 此前只 += 未 -=,切场景重复订阅泄漏,一并补上)
GameManager.Instance.OnUserDataChanged -= OnUserDataChanged_RefreshProps;
GameManager.Instance.OnUserDataChanged -= RefreshGGKCount;
// ⭐ 离场清理:子弹池 + 特效池彻底清空,防止内存泄漏
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
// ⭐ 清理未完成的 not-self 爆奖 pending(避免悬空引用)
BaoJiangNotSelfHandler.ClearAll();
// ⭐ 清理冰冻特效实例:Instantiate 无父对象 → 挂在场景根,面板销毁不会带走,
// 冰冻中切房会残留一块冰冻蒙皮,必须手动销毁
if (_bingFengInstance != null)
{
Destroy(_bingFengInstance);
_bingFengInstance = null;
}
_frozenAnimSpeeds.Clear();
// ⭐ 冰冻中面板被销毁(例如切房):把被定格的 Timeline 速度还原,避免残留 0 速导致时间轴永远不动
RestoreFrozenDirectors();
}
/// <summary>
/// ⭐ 长按锁定键(0.6秒)打开的"锁定设置"面板。
/// 默认 = 通用 SuoDing 面板;XJMH 覆写为原版 PW_SetLock 面板(其白名单通过覆写 IsFishAllowedBySuoDing 提供)。
/// </summary>
protected virtual void OpenLockSettingPanel()
{
subUIManager.ShowAsync(UIPanel.GameSuoDing);
}
public virtual void Start()
{
// ⭐ 未拖引用时为null,做保护(空就跳过)
if (btn_SettingList != null)
btn_SettingList.onClick.AddListener(() =>
{
if (isOpenSetting) StartCoroutine(CloseSettingList());
else StartCoroutine(OpenSettingList());
});
if (toggle_Lock != null)
{
toggle_Lock.onValueChanged.AddListener(on =>
{
if (!on)
{
// ⭐ 取消锁定开关 → 重置自动锁定激活状态,下次需重新手动锁定激活
_autoLockActivated = false;
// ⭐ 同时退出"图鉴锁定"模式:下次再开锁定开关 = 正常锁定(不受图鉴勾选约束)
_suoDingMode = false;
// 本地清理锁定
if (currSitData != null)
{
currSitData.ClearLock();
SyncTurretState(TurretState.Idle);
}
// 同步给服务器:取消锁定
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeAutoTarget,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeAutoTarget { targetGuid = null }
};
WebSocketSendList.SyncGameData(sgd);
}
});
// ⭐ 长按锁定 toggle(0.6秒) → 打开单独锁定面板(SuoDing):只打开界面,短按仍是正常开关锁定
var lockLongPress = toggle_Lock.gameObject.GetComponent<ToggleLongPress>();
if (lockLongPress == null) lockLongPress = toggle_Lock.gameObject.AddComponent<ToggleLongPress>();
lockLongPress.onLongPress += () =>
{
OpenLockSettingPanel();
};
}
// ⭐ 未拖引用时为null,做保护(空就跳过)
if (toggle_BaoZou != null)
toggle_BaoZou.onValueChanged.AddListener(on =>
{
if (!on)
{
// ⭐ 打开后只能等倒计时结束自动关闭,不允许玩家手动关闭
// 直接 SetIsOnWithoutNotify(true) 避免递归触发 onValueChanged
toggle_BaoZou.SetIsOnWithoutNotify(true);
return;
}
if (_localBaoZouCount <= 0)
{
toggle_BaoZou.SetIsOnWithoutNotify(false);
return;
}
// 只发请求,效果等 UsePropNotice 回调触发
WebSocketSendList.UseProp(
GameManager.Instance.gameData.currRoomData.roomId,
GameManager.Instance.user.id,
"40004", 0);
});
// toggle_SkillAuto 的关闭监听(切 Idle+广播)已移植到 SitPosData.OnAutoAttackToggleChanged,面板通过 HookAutoAttackOff 订阅广播
// ⭐ 未拖引用时为null,做保护(空就跳过)
if (toggle_BingFeng != null)
toggle_BingFeng.onValueChanged.AddListener(on => {
if (!on)
{
// ⭐ 打开后只能等倒计时结束自动关闭,不允许玩家手动关闭
// 直接 SetIsOnWithoutNotify(true) 避免递归触发 onValueChanged
toggle_BingFeng.SetIsOnWithoutNotify(true);
return;
}
if (_localBingFengCount <= 0)
{
toggle_BingFeng.SetIsOnWithoutNotify(false);
return;
}
// 只发请求,效果等 UsePropNotice 回调触发
WebSocketSendList.UseProp(
GameManager.Instance.gameData.currRoomData.roomId,
GameManager.Instance.user.id,
"40003", 0);
});
// ⭐ 双倍开火开关:持久模式(玩家手动开关,不自动关闭)。
// 开启=满环开始循环转圈(DaoJiShi 驱动,转完一圈从头继续),关闭=停转清环。
// isDouble 不需要同步开关状态:每次开火随 SyncFire 的 twoRTruect 传给服务端,
// 三处开火入口(TryFire/HandleAutoFire/OnFlyTurretUpwardFire)都实时读 isOn
if (toggle_Shuang != null)
{
toggle_Shuang.onValueChanged.AddListener(on =>
{
var fill = GetShuangFillImage();
if (on)
{
currShuangTime = shuangDuration;
if (fill != null) fill.fillAmount = 1f;
}
else
{
currShuangTime = 0f;
if (fill != null) fill.fillAmount = 0f;
}
});
}
}
public bool isFrenzy;
public void SetFrenzy(bool on)
{
bool frenzy = CreateFishManager.Instance.currGameType.isFrenzy;
// 攻击速度翻倍
attackTime = frenzy ? baseAttackTime * 0.5f : baseAttackTime;
// 动画倍速
if (currSitData != null && currSitData.currentTurret != null)
currSitData.currentTurret.SetFrenzy(frenzy);
}
public void OnEnable()
{
}
int ina = 0;
protected virtual void Update()
{
UpdateIdleAutoExit();
HandleAutoRotate();
HandleInput();
CheckLockTargetValid();//锁定目标失效自动清除
HandleAutoFire(); // 自动开火真正发射
HandleLock();
HandleAutoLock(); // ⭐ 先切换锁定目标(切换后图标位置同帧更新)
UpdateLockFX(); // 锁定图标跟随目标
RefreshTripleLockIcons();
// ⭐ 远端攻击状态看门狗:对方攻击中退出/掉线时不会再发待机包,本地按"还有没有攻击心跳"收回待机
UpdateRemoteTurretAttackTimeout();
LoopLaser();
SettingClose();
DaoJiShi();
HandleBotAI();
WebSocketSendList.FlushSyncQueues(Time.deltaTime);
}
float sittingTime = 10;
float currSittingTime;
/// <summary>
/// 等待10秒未操作关闭设置面板
/// </summary>
public void SettingClose()
{
//if (isOpenSetting) StartCoroutine(CloseSettingList());
//else StartCoroutine(OpenSettingList());
if (isOpenSetting)
{
currSittingTime -= Time.deltaTime;
if (currSittingTime <= 0)
{
StartCoroutine(CloseSettingList());
}
}
else
{
currSittingTime = 10;
}
}
#endregion
#region 资源加载模块
/// <summary>
/// ⭐ 资源流程令牌(static:新老游戏面板实例共享,GameMainPanel_FishBase 一脉)。
/// 每次 LoadResources / 卸载流程启动时递增并捕获;async 恢复后若令牌已变,
/// 说明期间已有新一轮流程启动(加载中退出、旧面板延迟卸载撞上新面板加载等),
/// 本轮残留流程必须立即终止 —— 否则旧加载流程会在卸载完成后继续
/// EnterRoom/出鱼/置位 isResources,把已退出房间的出鱼系统重新武装,
/// 污染下一个房间的鱼和路线(跨玩法路线混乱的流程层根源)。
/// </summary>
private static int _resourceFlowToken = 0;
/// <summary>
/// 外部触发资源加载:由 WebSocketReponseList 在"游戏面板已显示 → 游戏场景加载完成"后调用,
/// 保证加载顺序为 面板(加载页面)→ 场景 → 资源。
/// </summary>
public void BeginLoadResources()
{
LoadResources();
}
public async void LoadResources()
{
// ⭐ 捕获本轮令牌:同时使之前所有在途的加载/卸载残留流程作废
int flow = ++_resourceFlowToken;
// ⭐ 切换房间:隐藏新面板自带的进度条,继续用离房时留下的合并进度条
// (挂在 uiRoot、盖在最上层),避免"退出+加入"显示两条进度条
if (_changeRoomBar != null)
{
if (JinDuTiao != null) JinDuTiao.Hide();
_changeRoomBar.transform.SetAsLastSibling();
}
CreateFishManager.Instance.isResources = false;
// ⭐ 重置卸载标记,下次进入可重新加载
_resourcesUnloaded = false;
// ⭐ 切换房间(同玩法):鱼/音频/路线/场景全部复用,跳过全部资源加载直接 EnterRoom ——
// 切房耗时从"卸载+重新解压鱼包(数秒)"缩短到"网络往返+出鱼"。
// bulletClip 为 static,新面板实例直接复用。
bool isChangeRoom = _changeRoomBar != null;
if (isChangeRoom)
{
Debug.Log("[ChangeRoom] 同玩法切房:复用已加载资源,直接进入房间");
SetLoadProgress(0.8f);
}
else
{
// ⭐ 进度分配(单一大包自适应:大包的解压耗时反映在最先碰到它的步骤上):
// 0~2 卸载大厅 UI 资源
// 2~30 路线文本(若路线和鱼资源同在一个大包,包的解压耗时体现在这段,有实时回调)
// 30~60 鱼包(prefab/spine/sprite 内部细分;若包已被路线段拉起则本段快速通过)
// 60~78 玩法音频(同包则快速通过,否则按 PercentComplete 推进)
// 78~84 开火音效
// 84~90 EnterRoom + 初始鱼同步
// 90 ⭐确认点:走到这里时所有资源 await 都已完成
// 90~100 平滑推进收尾(替代原来的 2 秒死等,条一直在动)
SetLoadProgress(0.01f);
LoadingStallWatchdog.Phase = "0~2 卸载大厅UI";
// ⭐ 卸载大厅 UI 资源(进游戏释放 HomeUIRes 内存,回大厅时由离房流程重新预载):
// 大厅主面板此时已被关闭(实例销毁时 SubUIManager.ReleaseAll 已释放子面板句柄),
// 这里清掉 UIManager 侧大厅面板的 prefab 句柄缓存 → HomeUIRes bundle 引用归零真正卸载
UIManager.Instance.ReleaseHomeUI();
SetLoadProgress(0.02f);
// ⭐ 提前后台加载开火音效 PW_Shoot(Audio+PTCBZS,独立小包):
// 与后面的路线/鱼包/音频加载并行跑,78% 处 await 时早已完成,
// 不再把"打开小包 + 标签求交集"的耗时压在 78% 那一帧(进度条卡死点)
_ = LoadBulletClip();
// ⭐ 预关"未激活座位"的多余 XJMH 炮台(4 把 SkeletonGraphic 默认全开):
// 座位首次 SetActive 才做骨架/材质首初始化——预关后激活那一刻只初始化默认那把(1 套);
// 其它玩家/bot 的座位在 100% 入座时才激活,4 套→1 套,大幅削掉入座瞬间的卡顿
PreCloseInactiveSitExtraTurrets();
// 路线:2~30(大包同步解压期间回调不刷新 → 预估进度保证条持续推进)
LoadingStallWatchdog.Phase = "2~30 路线";
StartCreepProgress(0.02f, 0.30f);
await MonsterMoveLine.LoadBezier(p =>
{
if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.02f, 0.30f, p)));
});
if (flow != _resourceFlowToken) return; // ⭐ 期间已退出/切换,本轮作废
// 鱼包:30~60,内部子进度实时上报(预估进度兜底 LZMA 解压阻塞段,不再卡在 30)
LoadingStallWatchdog.Phase = "30~60 鱼包";
StartCreepProgress(0.30f, 0.60f);
var fishLoadTask = FishResourcesData.LoadFishAssetsData(p =>
{
if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.30f, 0.60f, p)));
});
// ⭐ 借鱼包加载这段(主线程本来就在解包/反序列化、有加载卡顿),先把 XJMH 炮台/飞机的
// "骨架数据解析"提前做掉并缓存(SkeletonGraphic 首激活里最重的一块,见方法注释):
// 60% 的炮台/飞机激活帧就只剩建网格/渲染器这类轻活,不再扛解析耗时
WarmUpXJMHSkeletonData();
await fishLoadTask;
if (flow != _resourceFlowToken) return;
// 玩法音频:60~78
LoadingStallWatchdog.Phase = "60~78 音频";
StartCreepProgress(0.60f, 0.78f);
// ⭐ XJMH 预热:方案已定——不在 60% 分帧做(每次 SetActive = 一次全 Canvas Rebuild,
// 分帧摊开总时长暴涨,实测 61% 卡好久好久)。改为 90% 确认点一次性完成
// (见下方"90 首屏初始化"),本处不再调用。骨架数据预解析仍在 30% 段(WarmUpXJMHSkeletonData)。
//PrewarmXJMHForLocalSeat();
await AudioResourcesData.LoadAudioGame(p =>
{
if (flow == _resourceFlowToken) SetLoadProgress(Mathf.Max(_loadProgress, Mathf.Lerp(0.60f, 0.78f, p)));
});
if (flow != _resourceFlowToken) return;
// ⭐ 开火音效 PW_Shoot 在 Default Local Group(Audio+PTCBZS 双标签交集检索):
// 只命中 Default 小包,不把其它玩法的巨型音频包拉进内存;
// 资源在 0~2% 已后台 kick-off 且全局常驻,这里 await 基本瞬时通过(不再把打开包的耗时压在 78% 主线程上)
LoadingStallWatchdog.Phase = "78~84 开火音效";
StartCreepProgress(0.78f, 0.84f);
await LoadBulletClip();
StopCreepProgress();
if (flow != _resourceFlowToken) return;
SetLoadProgress(0.84f);
if (_bulletClipHandle.IsValid() && _bulletClipHandle.Result != null)
{
foreach (var clip in _bulletClipHandle.Result)
{
if (clip.name == "PW_Shoot")
{
bulletClip = clip;
break;
}
}
}
}
LoadingStallWatchdog.Phase = "84~90 EnterRoom/初始鱼";
CreateFishManager.Instance.EnterRoom(gameType);
CreateFishManager.Instance.isResources = true;
SetLoadProgress(0.87f);
// ⭐ 鱼创建由服务端 OnFishSpawn 事件统一下发,客户端无需房主出鱼/请求同步。
// 玩家进入房间后服务端会主动推送 OnFishSpawn(type="init") 初始鱼,
// 重连/取消挂机时服务端会推送 OnFishSpawn(type="sync") 同步当前鱼。
// ⭐ RoomInfo.activeFishes 处理:非首个进房/断线重连/取消挂机时,
// RoomInfo 会携带 activeFishes(当前场上活鱼),用它初始化场景鱼。
// 首个进房时 activeFishes 为 null,由服务端 OnFishSpawn(type="init") 下发。
var activeFishes = GameManager.Instance.gameData.currRoomData?.activeFishes;
if (activeFishes != null && activeFishes.Count > 0)
{
var spawnData = new OnFishSpawnData
{
type = "sync",
timestamp = BeijingTimeManager.Instance.CurrentTimestampMs,
fish = activeFishes
};
CreateFishManager.Instance.OnFishSpawn(spawnData);
// 处理完清空,避免重复处理(如场景重载)
GameManager.Instance.gameData.currRoomData.activeFishes = null;
}
// ⭐ 最终确认玩法 BGM:进入房间时"选房/大厅面板 Hide→ChangeBG(69001) 大厅音乐"
// 可能晚于面板 Awake/OnShow 执行,把玩法 BGM 覆盖成大厅音乐;
// 加载完成这里最终置一次,保证游戏内播放的一定是玩法 BGM
if (GameBgmAudioId > 0 && AudioManager.Instance != null)
AudioManager.Instance.ChangeBG(GameBgmAudioId);
// ⭐ 90% 确认点:走到这里所有资源加载都已完成(上面全是 await 顺序执行)
// ⭐ 首屏初始化(座位/炮台/入场飞机的全部"首次激活")在 90% 一次性完成:
// 进度条还开着 → 单帧大尖峰被加载页遮住 → 90~100 只剩平滑推进 →
// 100% 收掉进度条的瞬间画面完整出现、入场动画立即开播,不再"进游戏卡一下"。
// ⚠️ 不能分帧摊开:每次 SetActive 都会把整个大 Canvas 标脏 → 每帧一次全量
// Rebuild(Unity 官方:重建成本 ∝ Canvas 元素数)→ 总时长暴涨(61% 卡顿教训)
LoadingStallWatchdog.Phase = "90 首屏初始化(座位/炮台一次性)";
SetLoadProgress(0.9f);
if (_pendingFirstShowInit)
{
ApplyFirstShowInit();
// 入场飞机预热:刚激活的座位上 currentTurret 已就绪 → 一次性激活(缩0隐藏)→初始化→还原;
// 100% 入场动画再激活它 = 重启用,零成本。非 XJMH 的炮台不是 FlyTurretData,自动跳过
(currSitData?.currentTurret as FlyTurretData)?.PrewarmEnterPlane();
}
// ⭐ 90→100 平滑收尾:约 1 秒走完,进度条持续在动(替代原 2 秒死等;
// 保留这段缓冲是等出鱼首帧/场景渲染稳定,比原来快 1 秒+)
for (int i = 1; i <= 20; i++)
{
await Task.Delay(50);
if (flow != _resourceFlowToken) return; // ⭐ 收尾期间退出的情况,必须拦
SetLoadProgress(0.9f + 0.1f * (i / 20f));
}
// ⭐ 切换房间:合并进度条走完 → 隐藏并销毁;平时隐藏面板自己的进度条
if (_changeRoomBar != null)
ClearChangeRoomBar();
else if (JinDuTiao != null)
JinDuTiao.Hide();
// ⭐ 加载到 100%(进度条已隐藏):标记完成。首屏初始化已在 90% 完成(见上);
// 这里只做兜底(正常流程标记已消费,不会重复执行)
LoadingStallWatchdog.Phase = "100 入场动画启动";
IsGameLoadCompleted = true;
if (_pendingFirstShowInit)
{
_pendingFirstShowInit = false;
ApplyFirstShowInit();
}
// 加载与首屏初始化完成 → 关闭监测(阶段置空,不再打日志)
LoadingStallWatchdog.Phase = "";
// ⭐ 无操作自动退出:加载完成,从现在开始计无操作时间
ArmIdleAutoExit();
}
public async void UnLoadResources()
{
if (_resourcesUnloaded) return;
// ⭐ 捕获本轮令牌:同时使在途的加载流程作废(OnHide 卸载意味着本房加载不应再继续);
// 已卸载时提前 return 不动令牌,避免误杀新面板正在进行的加载
int flow = ++_resourceFlowToken;
CreateFishManager.Instance.ExitRoom();
CreateFishManager.Instance.isResources = false;
await FishResourcesData.UnLoadFishAssetsData();
if (flow != _resourceFlowToken) return; // ⭐ 期间已有新流程(新面板加载),剩余卸载作废
await AudioResourcesData.UnLoadAudioGame();
if (flow != _resourceFlowToken) return;
// ⭐ 开火音效 PW_Shoot(Audio+PTCBZS)不释放:全局常驻(见 LoadBulletClip 注释,
// 独立小包反复开关只会把打开耗时反复压在进房 78% 那一帧,省不到内存)
TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay); // ⭐ 卸载按需加载的战神(游戏内)
TurretResourcesData.UnloadAllTurretPrefabs(); // ⭐ 卸载按需加载的炮台/翅膀 prefab
await MonsterMoveLine.UnLoadBezier();
if (flow != _resourceFlowToken) return;
// ⭐ 清空子弹池 + 特效池(鱼池已在 UnLoadFishAssetsData 内清理)
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
_resourcesUnloaded = true;
// ⭐ 真正回收内存:Addressables 释放包用的是 AssetBundle.Unload(false) 语义 ——
// 只是断开引用,已加载的贴图/材质/网格/音频要等 UnloadUnusedAssets 才真正销毁。
// iOS 上不调用就会出现"AA 包卸载了但占用内存不降",连续进第二个游戏内存叠加 → jetsam 闪退。
// 用 Task 等待(不依赖本面板协程,OnHide 隐藏后也能跑完)。
await Task.Delay(50); // 等两帧左右让 Destroy 的延迟销毁生效
var unloadOp = Resources.UnloadUnusedAssets();
while (!unloadOp.isDone) await Task.Yield();
System.GC.Collect();
Debug.Log($"[内存] 离房回收完成 | 已分配 {UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / 1048576}MB");
}
/// <summary>资源是否已卸载(避免 OnHide 重复卸载)</summary>
private bool _resourcesUnloaded = false;
/// <summary>
/// ⭐ 开火音效 PW_Shoot 的加载句柄(static:新老面板实例共享)。
/// PW_Shoot 是全局开火音效、每局必用,且各组已是 PackSeparately(每个资源独立小包):
/// 旧实现"每次进房 Release+Load、离房再释放"省不到内存(下一局马上又加载回来),
/// 却把"打开小包 + Audio/PTCBZS 标签求交集"的主线程耗时全压在进房 78% 那一帧(进度条卡死点)。
/// 现在只加载一次、程序生命周期常驻;首次加载在进房 0~2%(后台)就 kick-off,78% 处直接通过。
/// </summary>
private static AsyncOperationHandle<IList<AudioClip>> _bulletClipHandle;
private static bool _bulletClipStarted;
/// <summary>加载开火音效(Audio+PTCBZS 交集,只命中 Default Local Group 里的 PW_Shoot)。
/// 幂等:已启动过则复用同一句柄(通常早已完成,await 直接通过)。</summary>
private static async Task LoadBulletClip()
{
if (!_bulletClipStarted)
{
_bulletClipStarted = true;
_bulletClipHandle = Addressables.LoadAssetsAsync<AudioClip>(new List<string> { "Audio", "PTCBZS" }, null,
Addressables.MergeMode.Intersection);
}
if (!_bulletClipHandle.IsValid()) return;
try { await _bulletClipHandle.Task; }
catch (Exception e) { Debug.LogError($"开火音效加载失败: {e.Message}"); }
}
/// <summary>
/// 后台补卸载游戏资源(切房失败回大厅时调用):
/// 切房流程跳过了卸载(准备复用),失败后资源不再需要,静默补卸防止鱼包常驻内存。
/// </summary>
private static async void UnloadGameResourcesBackground()
{
try
{
await FishResourcesData.UnLoadFishAssetsData();
await AudioResourcesData.UnLoadAudioGame();
// ⭐ 开火音效 PW_Shoot 常驻不释放(见 LoadBulletClip 注释)
TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay);
TurretResourcesData.UnloadAllTurretPrefabs();
await MonsterMoveLine.UnLoadBezier();
Debug.Log("[ChangeRoom] 切房失败,游戏资源已补卸载");
}
catch (Exception e)
{
Debug.LogWarning($"[ChangeRoom] 切房失败补卸载异常(不影响回大厅): {e.Message}");
}
}
/// <summary>离房 socket 响应等待信号</summary>
private TaskCompletionSource<bool> _leaveRoomResponseTcs;
/// <summary>正在离开的游戏面板类型(完成后关闭它切到 home)</summary>
private UIPanel _leavingGamePanelType;
/// <summary>
/// 请求离开房间:发送离房 socket 请求 + 启动进度条卸载资源。
/// 进度条走到 0.9 后等待 socket 返回 LeaveRoomResult,
/// 收到返回后走完剩余进度并切回 home 界面。
/// </summary>
public void RequestLeaveRoom()
{
if (_leaveRoomResponseTcs != null) return; // 防重复请求
// 先创建等待信号(在发送请求前创建,避免响应太快丢失)
_leaveRoomResponseTcs = new TaskCompletionSource<bool>();
// 发送离房请求
WebSocketSendList.LeaveRoom(
GameManager.Instance.gameData.currRoomData.roomId,
currSitData.user);
//CreateFishManager.Instance.fangzhu = false;
// 启动带进度条的卸载流程
BeginLeaveRoomUnload();
}
/// <summary>
/// 离房卸载流程:进度条到 0.9 后等待 socket 响应,
/// 收到信号后走到 1.0 并切回 home 界面。
/// ⭐ 用 try-finally 保证无论中间是否异常,最终一定会关闭 GamePanel 并切回 Home
/// </summary>
private async void BeginLeaveRoomUnload()
{
if (_resourcesUnloaded)
{
_leaveRoomResponseTcs = null;
return;
}
// ⭐ 使在途的 LoadResources 立即作废(加载中退出的场景):
// 否则残留加载流程会在卸载完成后继续 EnterRoom/出鱼/置位 isResources,
// 把已退出房间的出鱼系统重新武装,污染下一个房间的鱼和路线。
// 本流程不再加恢复检查:期间不可能有新加载启动(home 在 finally 之后才显示),
// 且必须完整走完(finally 关面板切 home),保证退出流程不被打断。
_resourceFlowToken++;
try
{
if (JinDuTiao != null) JinDuTiao.Show();
// ⭐ 先退房销毁场上活鱼,再卸载资源(与 UnLoadResources 顺序一致):
// 现在句柄真正会释放、包内资源(贴图/Spine)会被销毁,
// 若先卸载后销毁鱼,残留活鱼会在淡出期间丢失材质花屏
CreateFishManager.Instance.ExitRoom();
CreateFishManager.Instance.isResources = false;
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
if (_isChangeRoom)
{
// ⭐ 同玩法切房:鱼/音频/路线/场景全部复用,跳过卸载 ——
// 卸载再加载同一个 LZMA 鱼包要白解压两次(几秒),是切房慢的主因。
// OnHide(旧面板销毁)同样跳过卸载;新面板 LoadResources 走切房分支直接 EnterRoom。
// 卸载段进度直接置满,合并进度条快进到网络段。
Debug.Log("[ChangeRoom] 同玩法切房:跳过资源卸载,新房间复用已加载资源");
SetUnloadProgress(1f);
}
else
{
SetUnloadProgress(0.2f);
await FishResourcesData.UnLoadFishAssetsData();
SetUnloadProgress(0.4f);
await AudioResourcesData.UnLoadAudioGame();
// ⭐ 开火音效 PW_Shoot(Audio+PTCBZS)不释放:全局常驻(见 LoadBulletClip 注释)
TurretResourcesData.UnloadAllZhanShen(ZhanShenUsage.GamePlay); // ⭐ 卸载按需加载的战神(游戏内)
TurretResourcesData.UnloadAllTurretPrefabs(); // ⭐ 卸载按需加载的炮台/翅膀 prefab
SetUnloadProgress(0.6f);
await MonsterMoveLine.UnLoadBezier();
// ⭐ 真正回收内存:AA 包卸载只是断开引用(Unload(false) 语义),贴图/材质/网格/音频
// 要等 UnloadUnusedAssets 才真正销毁 —— iOS 上不做这步 = 占用不降,
// 连进第二个游戏内存叠加超限 → jetsam 闪退
await Task.Delay(50);
var leaveUnloadOp = Resources.UnloadUnusedAssets();
while (!leaveUnloadOp.isDone) await Task.Yield();
System.GC.Collect();
Debug.Log($"[内存] 离房回收完成 | 已分配 {UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong() / 1048576}MB");
// ⭐ 预载回大厅的主面板 prefab:鱼包已卸载腾出内存,加载大厅包不会叠峰值;
// 后面等待 socket 的时间足够它加载完,finally 里 ShowAsync(HomeMainPanel) 命中缓存秒开。
// (进房时 ReleaseHomeUI 已释放大厅资源,散包后回大厅各子面板首次打开按需单独加载)
UIManager.Instance.PreloadPanel(UIPanel.HomeMainPanel);
SetUnloadProgress(0.8f);
await Task.Delay(100);
}
SetUnloadProgress(0.9f);
// ⭐ 卡在 0.9 等待 socket 返回 LeaveRoomResult
// - socket 已早返回(比卸载快) → 在 0.9 再等 1 秒(美观)后变 1
// - socket 未返回 → 等待 socket,最多 10 秒,超时强制变 1 退出
if (_leaveRoomResponseTcs != null)
{
bool alreadyReceived = _leaveRoomResponseTcs.Task.IsCompleted;
if (alreadyReceived)
{
// socket 比卸载早返回 → 0.9 停留 1 秒
await Task.Delay(1000);
}
else
{
// socket 未返回 → 等待,最多 10 秒
var timeoutTask = Task.Delay(10000);
var done = await Task.WhenAny(_leaveRoomResponseTcs.Task, timeoutTask);
if (done == timeoutTask)
{
// 超时 10 秒 → 强制退出,必须执行和 ExitFishingGame 一致的清理
// 否则 game socket 不关闭、isGame 不复位,导致无法再次进入游戏
Debug.LogWarning("[LeaveRoom] 等待 socket 超时 10 秒,强制退出并清理游戏状态");
try
{
WebSocketManager.Instance.isGame = false;
GameManager.LogGoldFlow("ExitRoom", $"超时强制退出 房间={GameManager.Instance.gameData?.currRoomData?.roomId}");
WebSocketManager.Instance.Close(SocketType.game);
APIRequestList.GetUser((user) =>
{
GameManager.Instance.UpdataUserData(user);
});
}
catch (Exception cleanEx)
{
Debug.LogError($"[LeaveRoom] 超时清理异常: {cleanEx}");
}
}
}
}
// 标记已卸载,OnHide 跳过重复卸载
_resourcesUnloaded = true;
if (_isChangeRoom)
{
// ⭐ 切换房间:进度条停在卸载段末尾(合并进度条45%)不隐藏,
// 由 finally 挪到 uiRoot 后继续给"换房+加入加载段"使用 → 整个切换只有一条进度条
SetUnloadProgress(1);
await Task.Delay(300);
}
else
{
if (JinDuTiao != null) JinDuTiao.SetProgress(1);
await Task.Delay(300);
// ⭐ 不在这里 Hide:进度条保持盖着,等 finally 里大厅 onShown 后才收(原子切换防闪烁)
}
}
catch (Exception ex)
{
Debug.LogError($"[LeaveRoom] 卸载流程异常: {ex}");
}
finally
{
// ⭐ 无论中间是否异常,都必须关闭 GamePanel
// 否则 GamePanel 留在 panels 字典里,下次进入游戏时 Get 返回非 null 导致无法创建新面板
_leaveRoomResponseTcs = null;
if (_isChangeRoom)
{
// ⭐ 切换房间:不回大厅。先把进度条从旧面板挪到 uiRoot(旧面板销毁时不带走它),
// 新面板 LoadResources 继续用它走完"换房+加载段"→ 退出+加入合并为一条进度条;
// 然后关闭当前游戏面板(OnHide 卸载场景/关socket)并重新走加入房间流程
_isChangeRoom = false;
if (JinDuTiao != null)
{
try
{
JinDuTiao.transform.SetParent(UIManager.Instance.uiRoot, false);
JinDuTiao.transform.SetAsLastSibling();
_changeRoomBar = JinDuTiao;
}
catch (Exception barEx)
{
Debug.LogError($"[ChangeRoom] 合并进度条转移异常: {barEx}");
}
}
UIManager.Instance.Close(PanelType);
StartChangeRoomJoin();
}
else
{
// ⭐ 原子切换回大厅:进度条保持盖在最上层不先收 ——
// 旧逻辑 finally 一进来就 Hide 进度条,之后"关游戏面板/卸载场景 → 大厅出现"
// 的过程全部可见,出现大厅与游戏画面交替的闪烁。
// 现在:进度条挪到 uiRoot(避免随游戏面板销毁)→ 关游戏面板(进度条盖着,
// 同时触发 OnHide 卸载场景/资源)→ 大厅 onShown 显示出来后才收掉进度条。
if (JinDuTiao != null)
{
try
{
JinDuTiao.transform.SetParent(UIManager.Instance.uiRoot, false);
JinDuTiao.transform.SetAsLastSibling();
}
catch (Exception barEx)
{
Debug.LogError($"[LeaveRoom] 进度条转移异常: {barEx}");
}
}
UIManager.Instance.Close(PanelType);
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, null, (panel) =>
{
try
{
if (JinDuTiao != null)
{
JinDuTiao.Hide();
Destroy(JinDuTiao.gameObject);
}
}
catch { }
});
// 兜底:3 秒后强制收进度条(防 ShowAsync 加载失败 onShown 不回调导致进度条卡住)
_ = HideBarDelayed(JinDuTiao, 3f);
}
}
}
/// <summary>延迟收掉进度条(Hide+Destroy;静态 Task 不依赖即将销毁的面板协程宿主,可重复调用)</summary>
private static async System.Threading.Tasks.Task HideBarDelayed(ProgressBar bar, float delaySeconds)
{
await System.Threading.Tasks.Task.Delay((int)(delaySeconds * 1000));
try
{
if (bar != null)
{
bar.Hide();
if (bar != null) UnityEngine.Object.Destroy(bar.gameObject);
}
}
catch { }
}
/// <summary>
/// socket 收到 LeaveRoomResult 后调用,解除 0.9 的等待。
/// </summary>
public void OnLeaveRoomResultReceived(UIPanel gamePanel)
{
_leavingGamePanelType = gamePanel;
_leaveRoomResponseTcs?.TrySetResult(true);
}
#endregion
#region 切换房间(重新走一遍加入房间流程)
/// <summary>正在切换房间(离房完成后不回大厅,直接重新快速加入新房间)</summary>
private bool _isChangeRoom = false;
/// <summary>
/// 切换房间共享进度条:离房时从旧面板挪到 uiRoot(不随旧面板销毁),
/// 新面板 LoadResources 继续用它,把"退出卸载 + 换房 + 加入加载"合并成一条进度条:
/// 卸载段 0~45% → 网络段(QuickJoin/连socket/JoinRoom) 45~55% → 加载段 55~100%。
/// null 表示非切换房间流程(走面板自己的 JinDuTiao)。
/// </summary>
private static ProgressBar _changeRoomBar;
/// <summary>卸载段进度写入:切换房间时映射到合并进度条前半段(0~45%),平时直接写面板进度条</summary>
private void SetUnloadProgress(float p)
{
if (JinDuTiao == null) return;
JinDuTiao.SetProgress(_isChangeRoom ? p * 0.45f : p);
}
/// <summary>玩法 BGM 编号(子类覆盖)。加载完成确认点会再置一次,
/// 防止进入房间时"选房/大厅面板 Hide → ChangeBG(69001) 大厅音乐"晚于面板 OnShow 把玩法音乐覆盖掉</summary>
protected virtual int GameBgmAudioId => 0;
/// <summary>加载段进度写入:切换房间时映射到合并进度条后半段(55~100%),平时直接写面板进度条</summary>
private void SetLoadProgress(float p)
{
p = Mathf.Clamp01(p);
// ⭐ 不回退:场景加载阶段的爬升/预估进度可能已把条推到前面,
// 后续固定值(0.01/0.02 等)不能把它拉回去(带上限取大值)
if (p < _loadProgress) p = _loadProgress;
_loadProgress = p;
if (_changeRoomBar != null)
_changeRoomBar.SetProgress(0.55f + p * 0.45f);
else if (JinDuTiao != null)
JinDuTiao.SetProgress(p);
}
/// <summary>
/// ⭐ 第一步(只加载了 UI 面板、游戏场景还在加载中):进度条从 0 缓慢爬升到约 3%。
/// 由 WebSocketReponseList 在"面板显示后、场景加载前"调用;真实资源加载开始时由
/// LoadResources 接管(SetLoadProgress 不回退,固定起点值不会把条拉回去)。
/// </summary>
public void BeginSceneLoadingProgress()
{
SetLoadProgress(0.005f);
// creep 段末 = 0.05(内部上限 = 段末-2% ≈ 3%,给 LoadResources 的接管留空间)
StartCreepProgress(0.005f, 0.05f);
// ⭐ 挂"加载卡顿监测"(诊断):单帧 ≥120ms 打日志+阶段名,直接定位卡主线程的环节
LoadingStallWatchdog.Attach(this);
LoadingStallWatchdog.Phase = "场景加载(0~3%)";
}
// ---- 预估进度(大包 LZMA 同步解压期间主线程阻塞、回调不刷新 → 条卡死像没响应)----
/// <summary>加载进度当前值(真实或预估),预估推进时判断不回退</summary>
private float _loadProgress = 0f;
private Coroutine _creepRoutine;
/// <summary>
/// 预估进度:等待段内按时间指数逼近"段末-2%",真实进度回调以更大值覆盖。
/// 加载完成时直接跳到真实位置,全程条和数字都在动,不会出现"卡住不动"的假死感。
/// </summary>
private void StartCreepProgress(float from, float to)
{
StopCreepProgress();
if (isActiveAndEnabled) _creepRoutine = StartCoroutine(CreepProgressRoutine(from, to));
}
private void StopCreepProgress()
{
if (_creepRoutine != null) { StopCoroutine(_creepRoutine); _creepRoutine = null; }
}
private IEnumerator CreepProgressRoutine(float from, float to)
{
float cap = Mathf.Max(from, to - 0.02f);
float elapsed = 0f;
while (true)
{
yield return new WaitForSeconds(0.1f);
elapsed += 0.1f;
// 指数逼近:前 2.5 秒走完区间约 63%,越接近段末越慢,不会超过 cap
float creep = Mathf.Lerp(from, cap, 1f - Mathf.Exp(-elapsed / 2.5f));
if (creep > _loadProgress) SetLoadProgress(creep);
}
}
/// <summary>写切换房间合并进度条(网络段:QuickJoin返回/JoinRoom已发送)</summary>
private static void SetChangeRoomBarProgress(float p)
{
if (_changeRoomBar != null) _changeRoomBar.SetProgress(p);
}
/// <summary>隐藏并销毁切换房间合并进度条(加载完成或切房失败时调用)</summary>
private static void ClearChangeRoomBar()
{
if (_changeRoomBar == null) return;
var bar = _changeRoomBar;
_changeRoomBar = null;
try
{
bar.Hide();
if (bar != null) Destroy(bar.gameObject);
}
catch { }
}
/// <summary>
/// 切换房间入口:复用离房流程(LeaveRoom → 卸载资源 → 关面板),
/// 离房完成后由 BeginLeaveRoomUnload 的 finally 重新走加入房间流程。
/// </summary>
public void StartChangeRoom()
{
if (_leaveRoomResponseTcs != null) return; // 正在离房/切换中,防重复
_isChangeRoom = true;
// 与点退出一致:离房期间屏蔽帧同步和爆奖特效
_isLeavingCountdown = true;
RequestLeaveRoom();
}
/// <summary>
/// 重新走加入房间流程(与 UIHome_BY_SelectRoomLevelPanel.OnSelectRoom 快速进入分支完全一致):
/// /api/Room/QuickJoin(同场次同等级,服务器分配新房间)→ 连新房 game socket → JoinRoom(-1) 由服务器分配座位,
/// 之后服务端推送 RoomJoinNotice → 重新创建游戏面板 + 重载场景,完成切换。
/// ⭐ 切房全程共用一条进度条:卸载段0~45%已在离房时走完,这里推进网络段45~55%,
/// 剩余加载段55~100%由新面板 LoadResources 继续。
/// </summary>
private void StartChangeRoomJoin()
{
var currRoom = GameManager.Instance.gameData.currRoomData;
int gameType = currRoom != null ? currRoom.gameType : (int)GameManager.Instance.gameData.currGameType;
int level = currRoom != null ? currRoom.roomLevel : 1;
APIRequestList.RoomQuickJoin(gameType, level, (resp) =>
{
if (resp == null || string.IsNullOrEmpty(resp.roomId))
{
// ⭐ 快速加入失败 → 清理合并进度条并回大厅,避免卡在无界面状态
UIManager.Instance.ShowBoardError("切换房间失败,请稍后重试");
ClearChangeRoomBar();
// ⭐ 切房流程跳过了资源卸载(准备复用),失败回大厅后资源不再需要 → 补一次卸载
// 防止鱼包/音频常驻内存直到下次进房
UnloadGameResourcesBackground();
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, GameManager.Instance.gameData.currGameType);
return;
}
// 合并进度条:已拿到新房间(网络段 50%)
SetChangeRoomBarProgress(0.5f);
// 用 QuickJoin 返回的 webSocketUrl 直接连新房 game socket,然后 JoinRoom(-1) 让服务器分配座位
GameManager.Instance.StartGameQuickJoin(resp.webSocketUrl, () =>
{
// 合并进度条:JoinRoom 已发送(网络段 55%,加载段起点)
SetChangeRoomBarProgress(0.55f);
WebSocketSendList.JoinRoom(resp.roomId, GameManager.Instance.user.id, -1);
});
});
}
#endregion
#region 表情包(BiaoQingModule:列表生成 + 发送 + 广播接收)
/// <summary>spine 表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段</summary>
private List<BiaoQingSpineData> BiaoQingSpineList =>
biaoQingConfig != null ? biaoQingConfig.list_BiaoQingSpine : list_BiaoQingSpine;
/// <summary>文字表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段</summary>
private List<string> BiaoQingZiList =>
biaoQingConfig != null ? biaoQingConfig.list_BiaoQingZi : list_BiaoQingZi;
/// <summary>表情列表是否已生成(首次打开面板时生成一次)</summary>
private bool _biaoQingListBuilt = false;
/// <summary>btn_BiaoQing 点击:开/关表情面板(首次打开生成列表)</summary>
private void OnBiaoQingButtonClick()
{
if (obj_BiaoQingPanel == null) return;
if (obj_BiaoQingPanel.activeSelf)
{
obj_BiaoQingPanel.SetActive(false);
return;
}
// ⭐ 先激活面板再生成列表/设置文字:TMP 在未激活状态下设置 text 可能不刷新
obj_BiaoQingPanel.SetActive(true);
BuildBiaoQingList();
}
/// <summary>
/// 生成表情列表(只生成一次),两种形式分开处理:
/// ① spine 表情:遍历 BiaoQingSpineList(优先 biaoQingConfig 资产),每个配置里的每个动画名克隆一个 spine 项
/// (child0=SkeletonGraphic → 换骨骼资源 + 循环播放该动画做列表预览);
/// ② 文字表情:BiaoQingZiList 每条文字克隆一个文字项(child0=TextMeshProUGUI → 设置文字)。
/// </summary>
private void BuildBiaoQingList()
{
if (_biaoQingListBuilt) return;
_biaoQingListBuilt = true;
// ---- ① spine 表情:一个动画名 = 一项 ----
if (obj_BiaoQingSpineItemPrefab != null && obj_BiaoQingSpineItemParent != null)
{
for (int i = 0; i < BiaoQingSpineList.Count; i++)
{
var cfg = BiaoQingSpineList[i];
if (cfg == null || cfg.skeletonDataAsset == null) continue;
foreach (var animName in cfg.animationNames)
{
if (string.IsNullOrEmpty(animName)) continue;
var item = Instantiate(obj_BiaoQingSpineItemPrefab, obj_BiaoQingSpineItemParent.transform);
// child0 = SkeletonGraphic:换骨骼资源 + 循环播放该动画(列表预览)
var sg = item.transform.childCount > 0
? item.transform.GetChild(0).GetComponent<SkeletonGraphic>()
: null;
if (sg != null)
{
sg.skeletonDataAsset = cfg.skeletonDataAsset;
sg.Initialize(false);
sg.AnimationState.SetAnimation(0, animName, true);
}
// 点击发送(闭包捕获配置下标+动画名)
int spineIndex = i;
string anim = animName;
var btn = GetBiaoQingItemButton(item);
if (btn != null) btn.onClick.AddListener(() => SendBiaoQing(spineIndex, anim));
}
}
}
// ---- ② 文字表情:一条文字 = 一项 ----
if (obj_BiaoQingZiItemPrefab != null && obj_BiaoQingZiItemParent != null)
{
foreach (var text in BiaoQingZiList)
{
if (string.IsNullOrEmpty(text)) continue;
var item = Instantiate(obj_BiaoQingZiItemPrefab, obj_BiaoQingZiItemParent.transform);
// child0 = TextMeshProUGUI:设置文字
var tmp = item.transform.childCount > 0
? item.transform.GetChild(0).GetComponent<TextMeshProUGUI>()
: null;
if (tmp != null) tmp.text = text;
string zi = text;
var btn = GetBiaoQingItemButton(item);
if (btn != null) btn.onClick.AddListener(() => SendBiaoQing(-1, zi));
}
}
}
/// <summary>取表情项上的按钮(优先根节点,其次子节点)</summary>
private Button GetBiaoQingItemButton(GameObject item)
{
var btn = item.GetComponent<Button>();
if (btn == null) btn = item.GetComponentInChildren<Button>();
return btn;
}
/// <summary>
/// 发送表情:本地立即在自己座位显示 + 广播 biaoQing 帧给房间其他玩家。
/// spineIndex>=0 = spine表情(content=动画名);spineIndex=-1 = 文字表情(content=文字)。
/// </summary>
private void SendBiaoQing(int spineIndex, string content)
{
if (currSitData == null || string.IsNullOrEmpty(content)) return;
bool isSpine = spineIndex >= 0;
// 发送后关闭表情面板
if (obj_BiaoQingPanel != null) obj_BiaoQingPanel.SetActive(false);
// 本地立即显示
ShowBiaoQing(currSitData.posID,
isSpine ? BiaoQingType.spine : BiaoQingType.zi,
spineIndex, content);
// 广播给其他客户端
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.biaoQing,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_BiaoQing
{
biaoQingType = isSpine ? BiaoQingType.spine : BiaoQingType.zi,
spineIndex = spineIndex,
content = content
}
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>在指定座位显示表情(本地发送与其他客户端广播接收共用)</summary>
private void ShowBiaoQing(int sitPos, BiaoQingType type, int spineIndex, string content)
{
var sit = list_Sits.Find(s => s.posID == sitPos);
if (sit == null) return;
if (type == BiaoQingType.spine)
{
// spineIndex = 表情配置列表下标(biaoQingConfig 资产)→ 取骨骼资源交给座位显示
var cfg = (spineIndex >= 0 && spineIndex < BiaoQingSpineList.Count)
? BiaoQingSpineList[spineIndex]
: null;
if (cfg == null || cfg.skeletonDataAsset == null) return;
sit.ShowBiaoQingSpine(cfg.skeletonDataAsset, content);
}
else
{
sit.ShowBiaoQingZi(content);
}
}
/// <summary>收到其他玩家发送表情的广播(biaoQing):在对应座位显示</summary>
public void SyncGameData_BiaoQing(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (currSitData == null || dispPos == currSitData.posID) return; // 本地座位已在发送时显示
var data = FrameDataParser.Parse<FrameData_BiaoQing>(sync.frameData);
if (data == null) return;
ShowBiaoQing(dispPos, data.biaoQingType, data.spineIndex, data.content);
}
/// <summary>
/// 刮刮卡领取金币回调(GuaGuaLeGPU.OnScratchGoldAwarded):
/// 服务端已把金币加到大厅数据(游戏内房间不推),给本地座位显示层加金币,
/// 并帧同步(addGold)广播给房间其他玩家。
/// </summary>
public void OnScratchGoldAwarded(long gold)
{
if (currSitData == null || gold <= 0) return;
AddGoldToSeat(currSitData, gold, "GuaGuaKa");
// ⭐ 帧同步给房间其他玩家(自己回广播在 SyncGameData_AddGold 里跳过,防止双重加)
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.addGold,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_AddGold { gold = gold, source = "GuaGuaKa" }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>
/// 收到其他玩家的加金币帧(addGold):给对应座位显示层加金币并刷新金币文本。
/// 座位镜像:服务端座位先转显示座位再找场景座位;本地座位跳过(发送时已加)。
/// </summary>
public void SyncGameData_AddGold(SyncGameData sync)
{
var data = FrameDataParser.Parse<FrameData_AddGold>(sync.frameData);
if (data == null || data.gold <= 0) return;
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (currSitData == null || dispPos == currSitData.posID) return; // 本地座位已在 OnScratchGoldAwarded 加过
var sit = list_Sits.Find(s => s != null && s.posID == dispPos);
if (sit == null) return;
AddGoldToSeat(sit, data.gold, string.IsNullOrEmpty(data.source) ? "addGold" : data.source);
}
/// <summary>给座位显示层加金币并刷新金币文本 + 记录金币流水(服务端已加钱,这里纯表现层,与杀鱼 onScoreAdd 同惯例)</summary>
private void AddGoldToSeat(SitPosData sit, long gold, string source)
{
long oldGold = sit.goldNum;
sit.goldNum += gold;
// ⭐ 刷新走 SetGoldNumText(优先 TMP、回退旧版 UGUI Text):
// 原来这里只刷 tMP_GoldNum,而 XJMH 座位拖的是 goldNum_UGUI(tMP_GoldNum 为空)→ 数字不刷新,
// 表现为"XJMH 刮刮卡金币不实时"(FLZY/HHWK 座位用 TMP 所以正常)。与杀鱼/扣费/后端修正路径统一。
sit.SetGoldNumText(sit.goldNum);
// ⭐ 记录金币流水:已加钱(刮刮卡等途径,本地已执行)
GameManager.LogGoldFlowAppliedGain(source,
$"加金币=+{gold} 座位={sit.posID} 修正前金币={oldGold} 剩余金币={sit.goldNum} 房间={GameManager.Instance.gameData?.currRoomData?.roomId}");
}
#endregion
#region 大喇叭/召唤卡(Left道具快捷按钮)
/// <summary>
/// 大喇叭发送:输入弹幕 → /api/Hall/SendBroadcast 全服广播
/// (与背包大喇叭 OnClickFaSong 流程一致),发送成功后清空输入框并关闭面板。
/// </summary>
/// <summary>大喇叭发送中标志:防止回调返回前连点发送按钮导致多发多扣道具</summary>
private bool _isSendingDaLaBa;
/// <summary>大喇叭发送超时协程:APIManager在code!=0时只弹错误不走成功回调,
/// 若只在成功回调复位标志,失败一次(如字数超限)后标志永久卡死,发送按钮失效</summary>
private Coroutine _daLaBaTimeoutRoutine;
/// <summary>发送超时秒数:超过后自动复位防重入标志,允许重试</summary>
private const float DaLaBaTimeoutSeconds = 3f;
private void OnClickDaLaBaSend()
{
// ⭐ 防重入:上次请求回调还没回来时连点直接忽略(多发一次 = 多扣一个大喇叭)
if (_isSendingDaLaBa) return;
string msg = tmp_DaLaBaInput?.text?.Trim();
if (string.IsNullOrEmpty(msg))
{
UIManager.Instance.ShowBoardError("请输入广播内容");
return;
}
// ⭐ 后端整条广播限制20字,标识字符占1字,正文最多19字
if (msg.Length > APIRequestList.BroadcastMaxContentLength)
{
UIManager.Instance.ShowBoardError($"广播内容不能超过{APIRequestList.BroadcastMaxContentLength}个字");
return;
}
_isSendingDaLaBa = true;
// ⭐ 超时兜底:后端拒绝(字数超限等)时没有回调,靠超时复位防重入标志
if (_daLaBaTimeoutRoutine != null) StopCoroutine(_daLaBaTimeoutRoutine);
_daLaBaTimeoutRoutine = StartCoroutine(ResetDaLaBaFlagAfterTimeout());
// ⭐ 只发"标识字符+正文":喇叭图标与打码昵称由接收端(LoginBroadcast)统一补上
APIRequestList.SendBroadcast(APIRequestList.BroadcastMarker + msg, (result) =>
{
// 成功:停掉超时协程并复位标志
if (_daLaBaTimeoutRoutine != null)
{
StopCoroutine(_daLaBaTimeoutRoutine);
_daLaBaTimeoutRoutine = null;
}
_isSendingDaLaBa = false;
// 发送成功:清空输入框并关闭面板(广播内容会通过 LoginBroadcast 全服弹幕显示)
if (tmp_DaLaBaInput != null) tmp_DaLaBaInput.text = "";
if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(false);
// ⭐ 发送成功=服务端已扣大喇叭:拉最新背包(此 API 不推 BackpackUpdated,
// 不主动拉道具数量就不会刷新,得关面板重开才能看到);UpdataUserData 触发
// OnUserDataChanged 实时刷新道具菜单数量
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
});
});
}
/// <summary>超时复位防重入标志:后端返回错误码时APIManager不走成功回调,靠此协程兜底解锁发送按钮</summary>
private IEnumerator ResetDaLaBaFlagAfterTimeout()
{
yield return new WaitForSeconds(DaLaBaTimeoutSeconds);
_isSendingDaLaBa = false;
_daLaBaTimeoutRoutine = null;
}
/// <summary>
/// 使用召唤卡:从背包找到召唤卡道具(type=11)拿道具编号
/// → /api/SummonCard/use 召唤Boss(Boss鱼由服务端 OnFishSpawn 下发给房间所有人)
/// → 显示服务器返回消息 + 拉取最新用户数据同步道具数量。
/// </summary>
private void OnClickZhanHuanKa()
{
var props = GameManager.Instance.user?.props;
if (props != null)
{
foreach (var p in props)
{
if (p.type == (int)PropEnum.召唤卡 && p.count > 0)
{
APIRequestList.UseSummonCard(
new Assets.MyGame.Scripts.Net.APIRequest.Params.SummonCardUseParam { cardNumber = p.number },
(msg) =>
{
UIManager.Instance.ShowBoardError(string.IsNullOrEmpty(msg) ? "使用成功" : msg);
// 刷新用户数据,同步召唤卡剩余数量
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
});
});
return;
}
}
}
UIManager.Instance.ShowBoardError("召唤卡数量不足");
}
#endregion
#region 左侧道具菜单(PropConfigData 一级道具 + 二级展开子道具)
private bool _propListBuilt;
/// <summary>已构建的一级道具项映射(cfg → 克隆项),供数量角标刷新用</summary>
private readonly List<KeyValuePair<PropItemData, GameObject>> _propFirstLayerItems = new List<KeyValuePair<PropItemData, GameObject>>();
/// <summary>
/// 按 PropConfigData 构建一级道具列表(只在首次打开时构建一次)
/// </summary>
private void BuildPropList()
{
if (_propListBuilt) return;
_propListBuilt = true;
if (propConfigData == null || tmp_PropItem == null || obj_PropItemParent == null) return;
for (int i = 0; i < propConfigData.props.Count; i++)
{
var cfg = propConfigData.props[i];
if (cfg == null) continue;
var item = Instantiate(tmp_PropItem, obj_PropItemParent.transform);
// ⭐ 第一层不用倒计时:克隆时隐藏 prefab 自带的 child1 倒计时圈(Filled360 Image,只有二层用)
var cdImg = item.transform.childCount > 1 ? item.transform.GetChild(1).GetComponent<Image>() : null;
if (cdImg != null) cdImg.gameObject.SetActive(false);
// ⭐ 第一层 child0 = 数量 TMP(只显示数字,不显示名字,靠图标辨识)
SetPropItemIcon(item, cfg.icon);
var data = cfg; // 闭包捕获当前项,避免循环变量被覆盖
var btn = GetPropItemButton(item);
if (btn != null) btn.onClick.AddListener(() => ShowPropTwo(data, item));
_propFirstLayerItems.Add(new KeyValuePair<PropItemData, GameObject>(cfg, item));
}
RefreshPropFirstLayerCounts();
}
/// <summary>当前展开二层的一级配置(用于"再点一下同一个一级项关闭二层"的开关切换)</summary>
private PropItemData _currentPropTwoCfg;
/// <summary>道具使用冷却时长(秒):二层使用任意道具后,二层所有道具一起进入 5 秒冷却(一层直达不冷却)</summary>
private float propUseCooldown = 5f;
/// <summary>二层冷却截止时刻(Time.time,全局一个):使用任意二层道具后所有二层道具一起倒计时;列表重建后据此恢复显示</summary>
private float _propTwoCooldownEnd;
/// <summary>
/// 用户数据变化回调(GameManager.OnUserDataChanged):数据已最新,只刷 UI 不重复 GetUser。
/// 服务端扣/发道具后 BackpackUpdated/AssetUpdated → GetUser → UpdataUserData → 这里实时刷新道具数量。
/// </summary>
private void OnUserDataChanged_RefreshProps()
{
RefreshPropCounts(false);
}
/// <summary>
/// 刷新所有道具数量显示:默认先 GetUser 拉最新背包(服务端已扣/已发)再刷一级角标 + 二层列表;
/// fetchUser=false 用于已在 GetUser 回调内的场景(数据已最新,避免重复请求)。
/// </summary>
private void RefreshPropCounts(bool fetchUser = true)
{
if (fetchUser)
{
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
RefreshPropCounts(false);
});
return;
}
RefreshPropFirstLayerCounts();
RebuildPropTwo();
}
/// <summary>刷新一级道具 child0 数量:该类型在道具表里只有 1 个道具(只有第一层、点击直达使用)时直接显示具体数量(为 0 就显示 0);
/// 能展开二层的一级项(道具表里有多个道具)显示空</summary>
private void RefreshPropFirstLayerCounts()
{
foreach (var kv in _propFirstLayerItems)
{
if (kv.Value == null) continue; // 克隆项已被销毁
var twoProps = GetMatchedTwoProps(kv.Key != null ? kv.Key.propType : PropEnum.none);
// ⭐ 只有一层的显示具体数量(数量为 0 就显示 0,不置空);能弹二层的显示空
string text;
if (twoProps.Count == 1)
{
var backend = FindBackendProp(twoProps[0]);
text = backend != null ? Mathf.Max(0, backend.count).ToString() : "0";
}
else
{
text = string.Empty;
}
SetPropItemTextAt(kv.Value, 0, text);
}
}
/// <summary>按类型读取后端道具表(GameManager.gameConfig.propConfig,none = 全部):不读背包,数量为 0 的也显示;未启用(isEnable=false)的剔除</summary>
private List<PropInfConfig> GetMatchedTwoProps(PropEnum type)
{
var matched = new List<PropInfConfig>();
var propConfig = GameManager.Instance?.gameConfig?.propConfig;
if (propConfig == null) return matched;
foreach (var t in propConfig)
{
if (t == null || t.isEnable == false) continue;
if (type != PropEnum.none && t.type != (int)type) continue;
matched.Add(t);
}
return matched;
}
/// <summary>按道具编号在后端背包(user.props)中查找已拥有道具(数量读取与使用判定共用;未拥有返回 null)</summary>
private BaseProductCount FindBackendProp(PropInfConfig t)
{
if (t == null || string.IsNullOrEmpty(t.number)) return null;
var props = GameManager.Instance.user?.props;
if (props == null) return null;
foreach (var p in props)
{
if (p != null && p.number == t.number) return p; // 编号精确匹配
}
return null;
}
/// <summary>关闭二级面板并重置展开状态(点同一个一级项再点一下 / 关闭道具面板时调用)</summary>
private void ClosePropTwo()
{
if (tmp_PropTwoPanel != null) tmp_PropTwoPanel.SetActive(false);
_currentPropTwoCfg = null;
}
/// <summary>道具面板整体缩进去(使用道具后调用):收起 obj_PropPanel 并重置二层展开状态</summary>
private void ClosePropMenu()
{
if (obj_PropPanel != null && obj_PropPanel.activeSelf)
obj_PropPanel.SetActive(false);
ClosePropTwo();
}
/// <summary>重建当前展开的二级列表(道具使用后数量变化时刷新显示,不改变开关状态)</summary>
private void RebuildPropTwo()
{
if (_currentPropTwoCfg == null || tmp_PropTwoPanel == null || !tmp_PropTwoPanel.activeSelf) return;
BuildPropTwoContent(GetMatchedTwoProps(_currentPropTwoCfg.propType));
}
/// <summary>
/// 点击一级道具:按 propType 读取后端道具表(gameConfig.propConfig,不读背包,数量为 0 也显示),
/// 展开到 tmp_PropTwoPanel —— 再点一下同一个一级项关闭;点不同的一级项切换内容并重新对齐 Y。
/// 该类型道具表里只有 1 个道具且拥有数量 &gt; 0 时不弹二层,直接触发使用效果。
/// </summary>
private void ShowPropTwo(PropItemData cfg, GameObject firstItem)
{
if (cfg == null || tmp_PropTwoPanel == null || tmp_PropTwoItem == null || tmp_PropTwoItemParent == null) return;
var matched = GetMatchedTwoProps(cfg.propType);
// ⭐ 该类型在道具表里只有 1 个道具(如刮刮卡/召唤卡):不用弹二层,直接触发使用效果
// (数量不足时同样直达并提示,不弹只有一个"数量0"的二层;一层直达不冷却)
if (matched.Count == 1)
{
var single = FindBackendProp(matched[0]);
if (single != null && single.count > 0)
{
ClosePropTwo(); // 顺带关掉可能开着的其它二层
UsePropEffect(single);
// ⭐ 数量刷新不在这做:异步消耗道具点击瞬间服务端还没扣,立即拉背包是竞态;
// 由 OnUserDataChanged 事件链(服务端扣完 → BackpackUpdated → GetUser)实时刷新
return;
}
// 没拥有/数量为 0:同样不弹二层,直接提示数量不足
ClosePropTwo();
var n = !string.IsNullOrEmpty(matched[0].name) ? matched[0].name : "道具";
UIManager.Instance.ShowBoardError($"[{n}] 数量不足,暂无法使用");
return;
}
// ⭐ 再点一下同一个一级项:关闭二层(点不同的一级项则切换内容)
if (_currentPropTwoCfg == cfg && tmp_PropTwoPanel.activeSelf)
{
ClosePropTwo();
return;
}
_currentPropTwoCfg = cfg;
BuildPropTwoContent(matched);
// ⭐ 二层面板 Y 坐标与被点击的一级项对齐(世界坐标,不同父节点下也正确)
if (firstItem != null)
{
var pp = tmp_PropTwoPanel.transform.position;
pp.y = firstItem.transform.position.y;
tmp_PropTwoPanel.transform.position = pp;
}
tmp_PropTwoPanel.SetActive(true);
}
/// <summary>清空并按 gameConfig 道具表重建 tmp_PropTwoItemParent 下的子道具列表(数量从背包按 number 匹配,0 也显示)</summary>
private void BuildPropTwoContent(List<PropInfConfig> matched)
{
// 清空上次克隆的子道具(模板本身若是该父对象的子节点则跳过)
for (int i = tmp_PropTwoItemParent.transform.childCount - 1; i >= 0; i--)
{
var child = tmp_PropTwoItemParent.transform.GetChild(i).gameObject;
if (child == tmp_PropTwoItem) continue;
Destroy(child);
}
foreach (var t in matched)
{
var item = Instantiate(tmp_PropTwoItem, tmp_PropTwoItemParent.transform);
var backend = FindBackendProp(t);
int count = backend != null ? Mathf.Max(0, backend.count) : 0;
// ⭐ child0 = 名字,child1 = 数量(读 gameConfig 道具表,数量为 0 也显示)
SetPropItemText(item, t.name);
SetPropItemTextAt(item, 1, count.ToString());
SetPropTwoIcon(item, t); // sprite_Icon 缓存下载(与商店面板同惯例)
// ⭐ 冷却:二层全局冷却中则该项 child2 恢复剩余倒计时,否则默认隐藏
ResetPropCooldownUI(item);
var data = t; // 闭包捕获当前项
var btn = GetPropItemButton(item);
if (btn != null) btn.onClick.AddListener(() => UsePropTwoItem(data));
}
// 该类型下道具表里没有任何道具时给出占位提示
if (matched.Count == 0)
{
var empty = Instantiate(tmp_PropTwoItem, tmp_PropTwoItemParent.transform);
SetPropItemText(empty, "暂无道具");
}
}
/// <summary>点击二级道具:二层全体冷却中拦截,数量不足提示,足够则触发使用并让二层所有道具一起进入倒计时</summary>
private void UsePropTwoItem(PropInfConfig t)
{
// ⭐ 二层全体冷却中:点了任何道具都不可使用(child2 倒计时圈还在转)
if (IsPropOnCooldown())
{
UIManager.Instance.ShowBoardError("道具冷却中,请稍候");
return;
}
var backend = FindBackendProp(t);
if (backend == null || backend.count <= 0)
{
var n = t != null && !string.IsNullOrEmpty(t.name) ? t.name : "道具";
UIManager.Instance.ShowBoardError($"[{n}] 数量不足,暂无法使用");
return;
}
StartPropTwoCooldown(); // 使用后二层所有道具一起进入 5 秒冷却
UsePropEffect(backend);
// ⭐ 数量刷新不在这做:道具多为异步消耗(VIP经验要点确认/刮刮卡要刮/鱼雷要等广播),
// 点击瞬间服务端还没扣,立即拉背包是竞态拿旧数据;由 OnUserDataChanged 事件链实时刷新
}
/// <summary>二层是否在冷却中(全局冷却:使用任意二层道具后所有二层道具一起倒计时)</summary>
private bool IsPropOnCooldown()
{
return Time.time < _propTwoCooldownEnd;
}
/// <summary>二层全体进入冷却:记录截止时刻并遍历当前所有二层克隆项启动 child2 倒计时圈</summary>
private void StartPropTwoCooldown()
{
_propTwoCooldownEnd = Time.time + propUseCooldown;
if (tmp_PropTwoItemParent == null) return;
for (int i = 0; i < tmp_PropTwoItemParent.transform.childCount; i++)
{
var child = tmp_PropTwoItemParent.transform.GetChild(i).gameObject;
if (child == tmp_PropTwoItem) continue; // 跳过模板本身
StartCoroutine(PropCooldownRoutine(child));
}
}
/// <summary>克隆项冷却 UI 初始化:child2(filled 360° Image)默认隐藏;二层全局冷却中则恢复剩余倒计时</summary>
private void ResetPropCooldownUI(GameObject item)
{
var cd = item.transform.childCount > 2 ? item.transform.GetChild(2).GetComponent<Image>() : null;
if (cd != null) cd.gameObject.SetActive(false); // 默认隐藏(无冷却时看不到圈)
// 冷却中:从剩余时间恢复倒计时(二层重建/刷新后冷却不断)
if (Time.time < _propTwoCooldownEnd)
StartCoroutine(PropCooldownRoutine(item));
}
/// <summary>
/// 倒计时协程:item child2 的 Image(prefab 里配 Filled/Radial360)fillAmount 从 1 顺时针转到 0,转完隐藏。
/// item 被销毁(二层关闭/重建)时自动退出;全局截止时刻保留,重建后由 ResetPropCooldownUI 恢复剩余时间。
/// </summary>
private IEnumerator PropCooldownRoutine(GameObject item)
{
var cd = item != null && item.transform.childCount > 2
? item.transform.GetChild(2).GetComponent<Image>()
: null;
if (cd == null) yield break; // prefab 的 child2 没配 Image:只挡点击不显示圈
cd.gameObject.SetActive(true);
cd.fillAmount = 1f;
while (item != null && Time.time < _propTwoCooldownEnd)
{
cd.fillAmount = Mathf.Clamp01((_propTwoCooldownEnd - Time.time) / Mathf.Max(0.01f, propUseCooldown));
yield return null;
}
if (item != null)
{
cd.fillAmount = 0f;
cd.gameObject.SetActive(false);
}
}
/// <summary>
/// 点击二级子道具:按后端道具类型(PropEnum)触发使用效果
/// (刮刮卡/大喇叭/召唤卡/VIP经验 有实际流程,装备与碎片类不支持在游戏内使用,给予提示)
/// </summary>
private void UsePropEffect(BaseProductCount p)
{
if (p == null) return;
// ⭐ 使用道具后道具面板缩进去:所有类型统一收起(大喇叭/刮刮卡打开的子面板不被挡住)
ClosePropMenu();
switch ((PropEnum)p.type)
{
case PropEnum.大喇叭:
if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(true);
break;
case PropEnum.召唤卡:
OnClickZhanHuanKa();
break;
case PropEnum.刮刮卡:
// 与 btn_GuaGuaKa 同流程:打开刮刮卡面板
subUIManager.ShowAsync(UIPanel.GuaGuaKa);
break;
case PropEnum.VIP经验:
// 游戏内简化为一次使用1个(背包里支持滑条选数量)
var typeName = string.IsNullOrEmpty(p.name) ? PropEnum.VIP经验.ToString() : p.name;
APIRequestList.UseVipExp(1, (result) =>
{
UIManager.Instance.ShowBoardError(string.IsNullOrEmpty(result) ? "使用成功" : result);
// 刷新用户数据(UpdataUserData 会触发 OnUserDataChanged 实时刷新道具数量)
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
});
});
//UIManager.Instance.ShowTiShi($"确定使用1个{typeName}吗?", () =>
//{
// APIRequestList.UseVipExp(1, (result) =>
// {
// UIManager.Instance.ShowBoardError(string.IsNullOrEmpty(result) ? "使用成功" : result);
// // 刷新用户数据,同步道具数量与VIP经验
// APIRequestList.GetUser((user) =>
// {
// if (user != null)
// GameManager.Instance.UpdataUserData(user);
// RefreshPropFirstLayerCounts(); // 数量变化后同步刷新一级角标
// RebuildPropTwo(); // 二层开着的话同步刷新显示数量
// });
// });
//});
break;
case PropEnum.改名卡:
UIManager.Instance.ShowBoardError("改名卡请到主界面背包中使用");
break;
case PropEnum.鱼雷:
// ⭐ 鱼雷:房间 WebSocket 使用(服务端扣库存+权重随机金币+直接加钱),
// 爆奖表现等 TorpedoResultNotice 房间广播回来(OnTorpedoResult)
var torpedoName = string.IsNullOrEmpty(p.name) ? PropEnum.鱼雷.ToString() : p.name;
WebSocketSendList.UseTorpedo(
GameManager.Instance.gameData.currRoomData.roomId,
GameManager.Instance.user.id,
p.number);
//UIManager.Instance.ShowTiShi($"确定使用1个{torpedoName}吗?", () =>
//{
// WebSocketSendList.UseTorpedo(
// GameManager.Instance.gameData.currRoomData.roomId,
// GameManager.Instance.user.id,
// p.number);
//});
break;
default:
// 装备/碎片等道具没有游戏内使用流程
UIManager.Instance.ShowBoardError($"[{(PropEnum)p.type}] 暂不支持在游戏内使用");
break;
}
}
/// <summary>
/// 召唤卡召唤出的鱼(特殊处理用):被漩涡卷出来直接放到屏幕中央 = 路线 0.5 位置。
/// 之后步骤(漩涡特效等)基于此引用继续处理。
/// </summary>
private MonsterContral _summonFish;
/// <summary>召唤鱼 guid(= 服务端 fishId.ToString()):Notice 比 OnFishSpawn 先到时记录,等鱼建出来再检索</summary>
private string _summonFishGuid;
[Header("召唤卡漩涡特效")]
[Tooltip("召唤鱼漩涡特效预制体(鱼本体先隐藏,特效旋转到指定位置后鱼才激活出现在屏幕中央)")]
public GameObject summonVortexFXPrefab;
[Tooltip("漩涡特效时长(秒):特效转完这一刻激活鱼开始移动(需与特效动画时长一致)")]
public float summonVortexDuration = 1.5f;
[Header("召唤卡出场(旋转放大)")]
[Tooltip("出场时长(秒):原地旋转 + 放大到正常大小的时间")]
public float summonEntranceDuration = 0.5f;
[Tooltip("每轴基准自转圈数(方向随机):三个轴各随机方向转这么多圈")]
public float summonSelfSpinTurns = 2f;
[Tooltip("乱转幅度:各轴额外叠加来回摆动的强度(0=只按基准圈数转)")]
public float summonSpinChaos = 1.5f;
[Tooltip("出场位置偏移(世界坐标):微调漩涡/出场落点")]
public Vector3 summonEntranceTargetOffset = Vector3.zero;
/// <summary>
/// 服务端 SummonCardFishNotice 房间广播回调:某玩家使用召唤卡,服务端已创建召唤鱼。
/// 仅播放召唤提示(飞机场召唤Boss鱼,其他场次召唤黄金鱼),
/// 实际鱼对象由服务端 OnFishSpawn 下发创建,这里不重复生成鱼。
/// ⭐ 特殊处理:检索出这条召唤鱼保存引用,并把开始时间加整体时间的一半
/// (moveTime = 总时长 * 0.5),让鱼直接出现在路线 0.5 位置(屏幕中央),
/// 之后从中间继续向终点游,剩余存活时长自动减半。
/// </summary>
public void OnSummonCardFishNotice(SummonCardFishNoticeData data)
{
if (data == null) return;
string fishName = string.IsNullOrEmpty(data.fishTypeName)
? (data.fishType == 2 ? "Boss鱼" : "黄金鱼")
: data.fishTypeName;
// ⭐ 玩家名打码:保留首尾字、中间显示 ***("我是大帅哥"→"我***哥")
UIManager.Instance.ShowBoardError($"{MaskUserName(data.userName)} 使用召唤卡召唤了一条{fishName}");
// ⭐ 检索召唤鱼:guid = 服务端 fishId.ToString()(与 fishDatas_hashCodekey 的 key 一致)
_summonFishGuid = data.fishId.ToString();
_summonFish = null;
if (TryGetSummonFish(_summonFishGuid, out var monster))
{
ApplySummonFishCenterSpawn(monster);
}
else
{
// OnFishSpawn 还没建出这条鱼(Notice 先到):轮询等鱼建出来再处理
StartCoroutine(WaitSummonFishAndCenterSpawn(_summonFishGuid));
}
}
/// <summary>
/// 玩家名打码:保留首尾字符、中间显示 ***("我是大帅哥"→"我***哥")。
/// 两个字的名字也打码("大帅"→"大***帅");单字/空名原样返回。
/// </summary>
private static string MaskUserName(string name)
{
if (string.IsNullOrEmpty(name)) return name;
name = name.Trim();
if (name.Length <= 1) return name;
return name[0] + "***" + name[name.Length - 1];
}
/// <summary>按 guid 检索召唤鱼(guid = 服务端 fishId.ToString(),OnFishSpawn 建鱼时写入)</summary>
private bool TryGetSummonFish(string guid, out MonsterContral monster)
{
monster = null;
if (string.IsNullOrEmpty(guid)) return false;
var dict = CreateFishManager.Instance?.fishDatas_hashCodekey;
if (dict == null) return false;
if (!dict.TryGetValue(guid, out var m) || m == null) return false;
monster = m;
return true;
}
/// <summary>等待 OnFishSpawn 建出召唤鱼后应用中点出生(Notice 比 OnFishSpawn 先到的时序兜底;guid 变了说明来了新召唤,协程作废)</summary>
private IEnumerator WaitSummonFishAndCenterSpawn(string guid)
{
float timeout = Time.time + 5f; // 最多等 5 秒(服务端 OnFishSpawn 正常几百毫秒内到)
while (Time.time < timeout && _summonFishGuid == guid)
{
if (TryGetSummonFish(guid, out var monster))
{
ApplySummonFishCenterSpawn(monster);
yield break;
}
yield return new WaitForSeconds(0.1f);
}
}
/// <summary>
/// 召唤鱼特殊处理:保存引用 + 漩涡出场流程。
/// 流程:① 鱼本体先未激活(隐藏)→ ② 漩涡特效在指定位置(路线 0.5 = 屏幕中央)播放旋转
/// → ③ 特效时长到了再激活鱼(OnEnable 复位后 ChangeState(move) 自动开始移动)
/// → ④ 激活后重设 moveTime = 总时长*0.5(OnEnable 会把 moveTime 复位成 0,必须在激活后设置)。
/// 未拖 summonVortexFXPrefab 时跳过特效,直接放中间(兜底)。
/// </summary>
private void ApplySummonFishCenterSpawn(MonsterContral monster)
{
_summonFish = monster;
if (monster == null) return;
StartCoroutine(SummonVortexRoutine(monster));
}
/// <summary>漩涡出场协程:鱼先隐藏 → 漩涡在路线 0.5 位置播放 → 激活鱼但先禁用 MonsterContral → 原地旋转+放大出场(终点朝向=0.5 位置的正确朝向)→ 再启用 MonsterContral 继续游动</summary>
private IEnumerator SummonVortexRoutine(MonsterContral monster)
{
var moveLine = monster.monsterData?.moveLine;
// 出场位置 = 路线 0.5 位置(屏幕中央);无路线的 Boss 兜底用屏幕正中
Vector3 spawnPos = GetSummonCenterWorld(monster);
if (moveLine != null && moveLine.moveTime > 0f)
{
// 中点世界坐标:路径上 moveTime = 总时长一半的位置(GetBezierPointJob 按秒取点)
Vector2 center2D = moveLine.GetBezierPointJob(moveLine.moveTime * 0.5f);
spawnPos = new Vector3(center2D.x, center2D.y, spawnPos.z);
}
spawnPos += summonEntranceTargetOffset;
// ① 鱼本体先未激活(漩涡期间不显示、不移动、打不到)
if (monster.gameObject.activeSelf)
monster.gameObject.SetActive(false);
// ② 漩涡特效:在路线 0.5 位置播放,旋转期间鱼保持隐藏
if (summonVortexFXPrefab != null)
{
EffectPool.Instance.Get(summonVortexFXPrefab, spawnPos, Quaternion.identity,
CreateFishManager.Instance.transform.parent); // autoReturn=true:粒子播完自动回池
yield return new WaitForSeconds(summonVortexDuration);
}
// ⭐ 漩涡期间这条鱼被击杀/回收(guid 已被复用成别的鱼)→ 中止出场,避免把回池的鱼又拉出来
if (monster == null || monster.isDead || monster.guid != _summonFishGuid) yield break;
// ③ 出场期间先禁用 MonsterContral 再激活预制体(这些预制体默认是未激活状态):
// 组件禁用时 OnEnable 不会跑 → 不会在出场瞬间按路线瞬移/开始游动,
// 也避免了"激活后再禁用"导致 Boss 的 OnEnable 跑两遍(重复进场音效/随机泳姿)
monster.enabled = false;
monster.gameObject.SetActive(true);
monster.transform.position = spawnPos;
// ④ 用鱼自身的朝向逻辑算出"0.5 位置的正确朝向"(含侧倾/镜像补偿),取出被改写的节点;
// 朝向逻辑用的 uirootRect/mainCamera/_cachedChild 是出生时 OnEnable 缓存的(禁用时不会被清)
MonsterContral.RouteFacingResult facing = monster.GetRouteFacingAt(0.5f);
Vector3 scaleTarget = monster.transform.localScale; // 此刻的缩放就是最终缩放(None 分支的左右镜像也在里面)
monster.transform.localScale = Vector3.zero; // 从 0 放大出来
// ⑤ 旋转 + 放大出场:以"正确朝向"为基准倒退若干圈开始转,转到最后一帧正好落在正确朝向
yield return SummonScaleSpinEntrance(monster, facing, scaleTarget);
// ⑥ 精确归位:朝向/缩放/位置写死成目标值,再启用 MonsterContral 从路线 50% 继续游动
// (OnEnable 会把 moveTime 清零,所以 moveTime 必须在启用之后设置 → OnSummonEntranceEnd 内部处理)
if (monster == null || monster.isDead || !monster.gameObject.activeInHierarchy) yield break;
if (facing.valid && facing.node != null) facing.node.rotation = facing.rotation;
monster.transform.localScale = scaleTarget;
monster.transform.position = spawnPos;
monster.enabled = true;
monster.OnSummonEntranceEnd(0.5f);
}
/// <summary>屏幕正中(画布中心)的世界坐标,z 沿用鱼自身深度</summary>
private Vector3 GetSummonCenterWorld(MonsterContral monster)
{
var uiRootRect = UIManager.Instance.uiRoot.GetComponent<RectTransform>();
Vector3 world = CoordinateTransformation.CanvasToWorld(Vector2.zero, uiRootRect, Camera.main);
world.z = monster.transform.position.z;
return world;
}
/// <summary>
/// 召唤出场:原地各轴乱转 + 放大出来。
/// ⭐ 旋转 = 以"0.5 位置的正确朝向"为基准,叠加一个最终归零的乱转偏移
/// (每轴随机方向转基准圈数 + 额外来回摆动),所以转得再乱,最后一帧也正好是正确朝向。
/// ⭐ 只转朝向逻辑决定的那一个节点(根节点 或 child0):另一个节点保持预制体姿态。
/// </summary>
private IEnumerator SummonScaleSpinEntrance(MonsterContral monster, MonsterContral.RouteFacingResult facing, Vector3 scaleTarget)
{
float duration = Mathf.Max(0.01f, summonEntranceDuration);
float baseTurns = Mathf.Abs(summonSelfSpinTurns) * 360f;
float chaos = Mathf.Max(0f, summonSpinChaos);
// 乱转参数出场期间固定(每轴:随机方向的基准角度 + 随机摆动频率/幅度),避免每帧乱抖
float[] axisDeg = new float[3];
float[] swayFreq = new float[3];
float[] swayDeg = new float[3];
for (int i = 0; i < 3; i++)
{
float dir = Random.value < 0.5f ? -1f : 1f;
axisDeg[i] = dir * baseTurns * Random.Range(0.8f, 1.8f);
swayFreq[i] = Random.Range(1f, 3f); // 来回摆动的次数
swayDeg[i] = chaos * 360f * Random.Range(0.3f, 1f) * (Random.value < 0.5f ? -1f : 1f);
}
float elapsed = 0f;
while (elapsed < duration)
{
// 出场途中被击杀/回收(回池会 SetActive(false))→ 立即中止,交由死亡流程处理
if (monster == null || monster.isDead || !monster.gameObject.activeInHierarchy) yield break;
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
float ease = 1f - Mathf.Pow(1f - t, 3f); // OutCubic:先快后慢,收尾自然
// 放大出来:0 → 最终缩放(含左右镜像的 x 符号)
monster.transform.localScale = Vector3.Lerp(Vector3.zero, scaleTarget, ease);
// 各轴乱转:(基准角度 + 摆动) × (1-ease) → t=1 时偏移正好归零 = 正确朝向
if (facing.valid && facing.node != null)
{
float k = 1f - ease;
float dx = (axisDeg[0] + swayDeg[0] * Mathf.Sin(swayFreq[0] * Mathf.PI * 2f * t)) * k;
float dy = (axisDeg[1] + swayDeg[1] * Mathf.Sin(swayFreq[1] * Mathf.PI * 2f * t)) * k;
float dz = (axisDeg[2] + swayDeg[2] * Mathf.Sin(swayFreq[2] * Mathf.PI * 2f * t)) * k;
facing.node.rotation = facing.rotation * Quaternion.Euler(dx, dy, dz);
}
yield return null;
}
// 收尾精确落在正确朝向 + 最终缩放
if (facing.valid && facing.node != null)
facing.node.rotation = facing.rotation;
monster.transform.localScale = scaleTarget;
}
/// <summary>
/// 服务端 TorpedoResultNotice 房间广播回调:有玩家使用鱼雷成功(服务端已扣库存+加金币)。
/// 所有客户端都播鱼雷爆奖流程(与 Boss 爆奖 RoomKillNotice 全端实例化同惯例):
/// 随机炸死场上小鱼 + 红包分包爆金币 + 分数面板滚分 + 飞回使用者座位结算(isSelf 决定金/银币)。
/// </summary>
public void OnTorpedoResult(TorpedoResultNotice data)
{
if (data == null) return;
// 使用者座位(可能是本地玩家,也可能是其他人)
SitPosData sitData = null;
foreach (var sit in list_Sits)
{
if (sit != null && sit.user == data.userId) { sitData = sit; break; }
}
if (sitData == null)
{
Debug.LogWarning($"[鱼雷] 未找到使用者座位: userId={data.userId}");
return;
}
// JiaFen:结算时金币回加到使用者座位显示(服务端已加钱,这里只是表现层,与杀鱼 onScoreAdd 同惯例)
long gold = data.gold;
var targetSit = sitData;
Action<int> jiaFen = (audioID) =>
{
targetSit.goldNum += gold;
targetSit.SetGoldNumText(targetSit.goldNum);
ShowGoldColumn(targetSit, gold); // 鱼雷结算:使用者座位金币柱
AudioManager.Instance.PlayerFX(audioID);
};
// 鱼雷颜色(后端下发的十六进制色值,特效版用来渲染爆炸特效;解析失败用默认色)
Color torpedoColor = Color.white;
if (!string.IsNullOrEmpty(data.color)) ColorUtility.TryParseHtmlString(data.color, out torpedoColor);
// ⭐ 按鱼雷编号选择特效预制体(torpedoEffectConfig 里配编号→prefab;
// 未配置的编号回退纯逻辑版:内置倒计时直接爆炸)
TorpedoEvent evt = null;
if (torpedoEffectConfig != null && torpedoEffectConfig.effects != null)
{
foreach (var e in torpedoEffectConfig.effects)
{
if (e == null || e.effectPrefab == null || e.propNumber != data.propNumber) continue;
var fxGo = Instantiate(e.effectPrefab);
// prefab 忘挂组件时兜底补挂(必须与动画同一对象,旧版动画事件才调得到)
evt = fxGo.GetComponent<TorpedoEvent>();
if (evt == null) evt = fxGo.AddComponent<TorpedoEvent>();
break;
}
}
if (evt == null)
{
// 回退:纯逻辑版(无特效,内置倒计时→爆炸)
var go = new GameObject("TorpedoEvent");
evt = go.AddComponent<TorpedoEvent>();
}
evt.torpedoColor = torpedoColor;
evt.FireTorpedo(data.gold, sitData.posID, jiaFen);
// 本地玩家使用成功:刷新用户数据(UpdataUserData 会触发 OnUserDataChanged 实时刷新道具数量)
if (data.userId == GameManager.Instance.user.id)
{
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
});
}
}
/// <summary>二级道具图标:优先用 gameConfig 缓存的 sprite_Icon,未缓存则按 imageUrl 下载并回填缓存(与商店面板同惯例,回调时判空防已销毁)</summary>
private void SetPropTwoIcon(GameObject item, PropInfConfig t)
{
if (t == null) return;
var img = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent<Image>() : null;
if (img == null) img = item.GetComponent<Image>();
if (img == null) img = item.GetComponentInChildren<Image>();
if (img == null) return;
if (t.sprite_Icon != null)
{
img.sprite = t.sprite_Icon;
return;
}
if (string.IsNullOrEmpty(t.imageUrl)) return;
WebTool.DownImage(APIManager.baseURL + t.imageUrl, (texture) =>
{
if (item == null || texture == null) return;
t.sprite_Icon = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), Vector2.one * 0.5f);
img.sprite = t.sprite_Icon;
});
}
/// <summary>克隆项文本:优先取 child0 的 TMP,再退回自身/子级查找(与表情列表同惯例)</summary>
private void SetPropItemText(GameObject item, string text)
{
if (string.IsNullOrEmpty(text)) return;
var tmp = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent<TextMeshProUGUI>() : null;
if (tmp == null) tmp = item.GetComponent<TextMeshProUGUI>();
if (tmp == null) tmp = item.GetComponentInChildren<TextMeshProUGUI>();
if (tmp != null) tmp.text = text;
}
/// <summary>克隆项固定子节点文本:按索引取 child{childIndex} 的 TMP(如第一层child0数量、第二层child1数量)</summary>
private void SetPropItemTextAt(GameObject item, int childIndex, string text)
{
var tmp = item.transform.childCount > childIndex
? item.transform.GetChild(childIndex).GetComponent<TextMeshProUGUI>()
: null;
if (tmp != null) tmp.text = text;
}
/// <summary>克隆项图标:优先取 child0 的 Image,再退回自身/子级查找(icon 不填则跳过)</summary>
private void SetPropItemIcon(GameObject item, Sprite icon)
{
if (icon == null) return;
var img = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent<Image>() : null;
if (img == null) img = item.GetComponent<Image>();
if (img == null) img = item.GetComponentInChildren<Image>();
if (img != null) img.sprite = icon;
}
/// <summary>克隆项按钮:自身优先,其次子级查找</summary>
private Button GetPropItemButton(GameObject item)
{
var btn = item.GetComponent<Button>();
if (btn == null) btn = item.GetComponentInChildren<Button>();
return btn;
}
#endregion
#region 输入系统(InputModule)
private bool IsPointerOverUI(Vector2 pos)
{
PointerEventData eventData = new PointerEventData(EventSystem.current);
eventData.position = pos;
List<RaycastResult> results = new List<RaycastResult>();
EventSystem.current.RaycastAll(eventData, results);
return results.Count > 0;
}
public struct CrossPlatformInput
{
public bool isPressed;
public bool wasPressedThisFrame;
public bool wasReleasedThisFrame;
public Vector2 position;
}
private CrossPlatformInput GetInput()
{
CrossPlatformInput input = new CrossPlatformInput();
// ============================
// PC / Editor → 鼠标输入
// ============================
#if UNITY_STANDALONE || UNITY_EDITOR
if (Mouse.current != null)
{
input.isPressed = Mouse.current.leftButton.isPressed;
input.wasPressedThisFrame = Mouse.current.leftButton.wasPressedThisFrame;
input.wasReleasedThisFrame = Mouse.current.leftButton.wasReleasedThisFrame;
input.position = Mouse.current.position.ReadValue();
}
#else
// ============================
// 移动平台 → 触摸输入
// ============================
var touch = Touchscreen.current?.primaryTouch;
if (touch != null)
{
input.isPressed = touch.press.isPressed;
input.wasPressedThisFrame = touch.press.wasPressedThisFrame;
input.wasReleasedThisFrame = touch.press.wasReleasedThisFrame;
input.position = touch.position.ReadValue();
}
#endif
return input;
}
/// <summary>
/// 判断座位在屏幕哪一侧("我在这"偏移用:左右座位镜像后需要的偏移不同)。
/// 本地玩家经 SeatMirror 处理后永远显示在下方座位,所以只有 0(左)/1(右) 两种情况;
/// 有对手座位时按世界 X 比较,单人时按座号兜底。
/// </summary>
private bool IsLeftSeat(SitPosData sit)
{
if (sit == null) return true;
float x = sit.transform.position.x;
for (int i = 0; i < list_Sits.Count; i++)
{
var other = list_Sits[i];
if (other == null || other == sit) continue;
if (other.user == null || !other.gameObject.activeSelf) continue;
return x <= other.transform.position.x;
}
return sit.posID == 0;
}
/// <summary>进入游戏圆圈 mySitAnim:延时自动消失(6 秒没点屏幕也消失;点击屏幕立即消失在 HandleInput)</summary>
private IEnumerator HideMySitAnimAfter(float delay)
{
yield return new WaitForSeconds(delay);
if (mySitAnim != null && mySitAnim.gameObject.activeSelf)
mySitAnim.gameObject.SetActive(false);
}
private void HandleInput()
{
// ⭐ 加载期间 currSitData 尚未赋值(座位信息推迟到加载到 100%)→ 忽略输入,避免空引用
if (currSitData == null) return;
// ⭐ 退出倒计时期间屏蔽本地玩家手动输入(旋转/开火/锁定),其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
CrossPlatformInput input = GetInput();
// ⭐ 记录"开火键是否按住":手动开火是"按下进 Attack、松手回 Idle",
// 自动开火回收状态时要靠它区分"自动开火没开火"和"玩家正按住手动开火"(见 EnsureTurretIdle)
_firePointerDown = input.isPressed;
if (!input.isPressed && !input.wasPressedThisFrame && !input.wasReleasedThisFrame)
return;
if (input.wasPressedThisFrame)
{
// ⭐ UI 点击过滤
if (IsPointerOverUI(input.position))
return;
}
// 按下
if (input.wasPressedThisFrame)
{
isChangingTurret = CheckTouchIsChangeTurret(input.position);
clickTime = attackTime;
if (mySitAnim != null && mySitAnim.gameObject.activeSelf)
mySitAnim.gameObject.SetActive(false);
if (!isChangingTurret)
{
// ⭐ 起飞炮台(FlyTurretData):
// 飞行/悬停中 → 点击空白处=改目标(点到哪飞到哪),立即响应;
// 未起飞 → 仅记录轻点判定,继续走下方正常开火流程(松开时判定轻点是否起飞,
// 是否允许由 FlyTurretData.allowTapTakeoff 控制,默认关=没点起飞按钮不起飞),
// 保证平时按住扫射/自动开火与普通炮台完全一致
if (currSitData.currentTurret is FlyTurretData flyTurret)
{
if (flyTurret.IsFlying)
{
if (flyTurret.TakeoffToScreenPos(input.position))
{
HookFlyTurretUpwardFire(flyTurret);
return;
}
}
else if (!currSitData.isDrillActive)
{
_flyTapPending = true;
_flyTapTime = Time.time;
_flyTapPos = input.position;
}
}
// ⭐ 本地金币校验:没钱不切换 Attack 状态,也不广播,避免无效请求
if (!HasEnoughGoldToFire(toggle_Three.isOn, toggle_Shuang != null && toggle_Shuang.isOn))
return;
// 按下即进入 Attack:短按→射击,松开后回 Idle
currSitData.ChangeState(TurretState.Attack);
SyncTurretState(TurretState.Attack);
}
}
// 长按
if (input.isPressed)
{
if (isChangingTurret)
{
HandleChangeTurretHold();
return;
}
// ⭐ 起飞炮台:飞行/悬停期间手指按住不旋转不开火(炮口固定朝上,子弹自行向上发射)
if (currSitData.currentTurret is FlyTurretData flyHold && flyHold.IsFlying)
return;
// 锁定模式:不手动旋转,交给 HandleAutoRotate + HandleAutoFire
// ⭐ 特殊鱼爆奖期间例外:自动锁定已禁用,玩家点击仍可手动旋转炮台瞄准发射方向
if (toggle_Lock.isOn && !(currSitData != null && currSitData.isDrillActive)) return;
// UI 上不旋转不发射
if (IsPointerOverUI(input.position)) return;
HandleRotate(input.position);
TryFire();
}
// 松开
if (input.wasReleasedThisFrame)
{
if (isChangingTurret)
{
isChangingTurret = false;
changeTurretTime = 0;
CloseChangeTurretUI();
return;
}
// ⭐ 起飞炮台:轻点空白处=起飞(受 allowTapTakeoff 控制,默认关;按下时已正常开火)
// 长按/拖动扫射时此判定不通过,正常走下方 Idle,普通开火完全不受影响
if (_flyTapPending)
{
_flyTapPending = false;
if (currSitData.currentTurret is FlyTurretData flyTap
&& !flyTap.IsFlying
&& Time.time - _flyTapTime <= flyTapMaxDuration
&& Vector2.Distance(input.position, _flyTapPos) <= flyTapMaxMove
&& flyTap.TakeoffToScreenPos(input.position))
{
HookFlyTurretUpwardFire(flyTap);
return; // 起飞:跳过下方 Idle 切换,避免打断起飞动画
}
}
currSitData.ChangeState(TurretState.Idle);
SyncTurretState(TurretState.Idle);
}
}
private void HandleChangeTurretHold()
{
changeTurretTime += Time.deltaTime;
if (changeTurretTime > 1f)
{
// 长按换炮台:已改为按钮打开 TurretPanel
}
}
private bool CheckTouchIsChangeTurret(Vector2 pos)
{
PointerEventData pointerData = new PointerEventData(EventSystem.current);
pointerData.position = pos;
List<RaycastResult> results = new List<RaycastResult>();
EventSystem.current.RaycastAll(pointerData, results);
foreach (var result in results)
{
if (result.gameObject.CompareTag("ChangeTurret"))
{
SitPosData sit = result.gameObject.GetComponentInParent<SitPosData>();
if (sit == currSitData)
return true;
}
}
return false;
}
private void CloseChangeTurretUI()
{
// currSitData.changeTurretList.SetActive(false); — 已废弃
changeTurretTime = 0;
isChangingTurret = false;
}
// ==================== 炮台切换面板清理(子类扩展) ====================
/// <summary>面板隐藏时清理炮台切换面板(子类覆写)。</summary>
protected virtual void CleanupTurretPanel() { }
// ==================== 座位金币柱表现(子类扩展) ====================
/// <summary>
/// 某座位加金币时的表现钩子(基类空实现,XJMH 覆写播金币柱)。
/// 调用点:CreateFishManager 的 onScoreAdd(所有杀鱼/爆奖加分)、OnTorpedoResult 的 jiaFen。
/// </summary>
public virtual void ShowGoldColumn(SitPosData sit, long earn) { }
#endregion
#region 打开关闭右上角设置选项
public virtual IEnumerator OpenSettingList()
{
RectTransform tran = SittingListParent.GetComponent<RectTransform>();
tran.gameObject.SetActive(true);
tran.DOSizeDelta(new Vector2(319, 161), 0.2f);
isOpenSetting = true;
Vector3 v3 = btn_SettingList.transform.localScale;
v3.x = Mathf.Abs(v3.x) * -1;
btn_SettingList.transform.localScale = v3;
yield break;
}
public virtual IEnumerator CloseSettingList()
{
RectTransform tran = SittingListParent.GetComponent<RectTransform>();
Vector3 v3 = btn_SettingList.transform.localScale;
v3.x = Mathf.Abs(v3.x);
btn_SettingList.transform.localScale = v3;
tran.DOSizeDelta(new Vector2(0, 161), 0.2f).OnComplete(() =>
{
tran.gameObject.SetActive(false);
});
isOpenSetting = false;
yield break;
}
#endregion
#region ② 炮台旋转系统(RotateModule)
//private void HandleRotate(Vector2 pos)
//{
// if (toggle_Lock.isOn && currSitData.monCotl_Lock != null)
// {
// RotateToLockTarget(currSitData);
// }
// else
// {
// RotateToTouch(currSitData, pos);
// }
//}
private void HandleRotate(Vector2 pos)
{
// ⭐ 只有真正锁定目标时才自动旋转
if (toggle_Lock.isOn && currSitData.isLock && currSitData.monCotl_Lock != null)
{
RotateToLockTarget(currSitData);
}
else
{
// 锁定开关开着但没有锁定目标 → 允许手动旋转
RotateToTouch(currSitData, pos);
}
}
private void RotateToTouch(SitPosData sit, Vector2 pos)
{
// ⭐ 防御:炮台旋转节点可能尚未初始化(刚进房/切换炮台中)
if (sit == null || sit.tra_CurrTurret == null) return;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
sit.tra_CurrTurret.transform.parent as RectTransform,
pos, null, out Vector2 localPos);
Vector2 dir = localPos - sit.tra_CurrTurret.GetComponent<RectTransform>().anchoredPosition;
float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f;
// 上方座位(2,3)炮台预制体默认朝上,加180°让它朝下打鱼
if (sit.IsFlipped) angle = (angle + 180f) % 360f;
sit.SetRotation(angle);
SyncRotate(angle);
}
private void RotateToLockTarget(SitPosData sit)
{
// ⭐ 防御:锁定点为空或已被销毁(鱼死亡清锁/小白龙切模型销毁锚点)时直接跳过,
// 否则对空引用取 .position 会抛 NRE 中断整个 Update(锁定/开火/清锁全部卡死)
if (sit == null || sit.monCotl_Lock_Position == null) return;
Vector2 screenPos = Camera.main.WorldToScreenPoint(sit.monCotl_Lock_Position.position);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
sit.tra_CurrTurret.transform.parent as RectTransform,
screenPos, null, out Vector2 localPos);
Vector2 dir = localPos - sit.tra_CurrTurret.GetComponent<RectTransform>().anchoredPosition;
// ⭐ 鱼太近时 dir 接近零向量,Atan2 会不稳定导致原地转圈 → 跳过
const float minDistance = 5f;
if (dir.sqrMagnitude < minDistance * minDistance) return;
float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f;
if (sit.IsFlipped) angle = (angle + 180f) % 360f;
sit.SetRotation(angle);
SyncRotate(angle);
}
private void HandleAutoRotate()
{
// ⭐ 加载期间 currSitData 尚未赋值(座位信息推迟到加载到 100%)→ 跳过,避免空引用
if (currSitData == null) return;
// ⭐ 退出倒计时期间屏蔽本地玩家自动旋转,其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
// ⭐ 特殊鱼爆奖(钻头/激光)期间禁用自动旋转:锁定跟随/自动扫射都让位,炮台方向只由玩家点击控制
if (currSitData != null && currSitData.isDrillActive) return;
// ⭐ 起飞炮台:飞行/悬停期间禁用自动旋转(炮口固定朝上)
if (currSitData != null && currSitData.currentTurret is FlyTurretData flyAuto && flyAuto.IsFlying) return;
// 锁定模式优先
if (toggle_Lock.isOn && currSitData.isLock)
{
RotateToLockTarget(currSitData);
return;
}
// 自动开火关闭 → 不自动旋转
if (!currSitData.IsAutoAttackOn) return;
// 自动扫射(左右摆动)
//AutoSweepRotate();
}
private float autoRotateAngle = 0;
private float autoRotateSpeed = 40f;
private float autoRotateMin = -60f;
private float autoRotateMax = 60f;
private int autoRotateDir = 1;
private void AutoSweepRotate()
{
autoRotateAngle += autoRotateSpeed * autoRotateDir * Time.deltaTime;
if (autoRotateAngle > autoRotateMax)
{
autoRotateAngle = autoRotateMax;
autoRotateDir = -1;
}
else if (autoRotateAngle < autoRotateMin)
{
autoRotateAngle = autoRotateMin;
autoRotateDir = 1;
}
// 上方座位加180°,炮台朝下打鱼
float angle = currSitData.IsFlipped ? (autoRotateAngle + 180f) % 360f : autoRotateAngle;
currSitData.SetRotation(angle);
}
/// <summary>
/// 远程玩家/bot 旋转:直接把目标角度赋给炮台,不做任何过渡。
/// 旋转和开炮都改成实时同步,收到广播立即应用。
/// </summary>
private void SmoothRotate(SitPosData sit, float angle)
{
if (sit == null || sit.tra_CurrTurret == null) return;
sit.SetRotation(angle);
}
#endregion
#region Skill系统(SkillModule)
public void DaoJiShi()
{
// 暴走倒计时
if (toggle_BaoZou != null && toggle_BaoZou.isOn && frenzyDuration > 0)
{
if (img_BaoZou != null)
img_BaoZou.fillAmount = Mathf.Clamp01(currFrenzyTime / frenzyDuration);
currFrenzyTime -= Time.deltaTime;
if (currFrenzyTime <= 0)
{
// ⭐ 倒计时结束自动关闭:用 SetIsOnWithoutNotify 避免触发 onValueChanged
// (onValueChanged 里禁止手动关闭会强制设回 true)
// 并清理狂暴状态(攻速/动画/isFrenzy 标志)
toggle_BaoZou.SetIsOnWithoutNotify(false);
currFrenzyTime = 0f;
if (img_BaoZou != null) img_BaoZou.fillAmount = 1f;
CreateFishManager.Instance.currGameType.isFrenzy = false;
SetFrenzy(false);
}
}
// 冰封倒计时
if (toggle_BingFeng != null && toggle_BingFeng.isOn && bingFengDuration > 0)
{
if (img_BingFeng != null)
img_BingFeng.fillAmount = Mathf.Clamp01(currBingFengTime / bingFengDuration);
currBingFengTime -= Time.deltaTime;
if (currBingFengTime <= 0)
{
toggle_BingFeng.SetIsOnWithoutNotify(false);
currBingFengTime = 0f;
if (img_BingFeng != null) img_BingFeng.fillAmount = 1f;
// ⭐ 冰冻结束:本地解冻(服务端不再下发解冻事件,15s 后客户端自动解冻)
// 之前 SyncFreeze(false) 客户端广播已废弃
CreateFishManager.Instance.UnfreezeAllFish();
ResumeAllFishAnimations();
if (_bingFengInstance != null) _bingFengInstance.SetActive(false);
}
}
// ⭐ 双倍开火转圈环:纯展示效果,不是倒计时。
// 双倍是玩家手动开关的持久模式(同三射/锁定),倒计时结束不许关闭功能——
// 环耗尽一圈后从头继续转(循环动画),只有玩家手动关 toggle 才停转清环
// (关闭走 onValueChanged:currShuangTime=0、fill=0)。
// 功能开关由玩家手动控制;三处开火入口实时读 isOn,SyncFire 的 gold 随之×2/×1。
if (toggle_Shuang != null && toggle_Shuang.isOn && shuangDuration > 0)
{
var fill = GetShuangFillImage();
currShuangTime -= Time.deltaTime;
if (currShuangTime <= 0f)
currShuangTime = shuangDuration; // 转完一圈从头继续转
if (fill != null)
fill.fillAmount = Mathf.Clamp01(currShuangTime / shuangDuration);
}
}
/// <summary>toggle_Shuang.child0 的环形填充图(Image Filled 360°),首次调用懒获取并缓存</summary>
private Image GetShuangFillImage()
{
if (_shuangFillImage == null && toggle_Shuang != null && toggle_Shuang.transform.childCount > 0)
_shuangFillImage = toggle_Shuang.transform.GetChild(0).GetComponent<Image>();
return _shuangFillImage;
}
#endregion
#region ③ 攻击系统(FireModule)
private void TryFire()
{
// ⭐ 自动攻击开启时屏蔽手动开火:开火统一交给 HandleAutoFire。
// clickTime/autoFireTimer 是两条独立冷却,同时跑会叠加成双倍射速。
if (currSitData != null && currSitData.IsAutoAttackOn) return;
// ⭐ 特殊鱼炮台(钻头)活动期间禁用普通开火,点击只发射钻头
if (currSitData != null && currSitData.isDrillActive) return;
// ⭐ 起飞炮台:飞行/悬停期间禁用普通开火(此时由悬停逻辑自动向上发射);平时正常开火
if (currSitData != null && currSitData.currentTurret is FlyTurretData flyFire && flyFire.IsFlying) return;
clickTime += Time.deltaTime;
if (clickTime < attackTime) return;
// ⭐ 本地金币校验:没钱直接返回,不发送开炮请求
// clickTime 保持满,金币恢复后立即开火
if (!HasEnoughGoldToFire(toggle_Three.isOn, toggle_Shuang != null && toggle_Shuang.isOn))
return;
// ⭐ 子弹上限校验(先于扣费和状态切换)
if (IsBulletLimitReached(currSitData.user))
{
clickTime = 0; // 重置计时,下次再试
return;
}
clickTime = 0;
float angle = currSitData.currentAngle;
bool isSanShe = toggle_Three.isOn;
bool isDouble = toggle_Shuang.isOn;
bool isLock = toggle_Lock.isOn;
//FireBullet(currSitData, angle, isSanShe, isDouble, isLock);
SyncFire(angle, isSanShe, isDouble, isLock);
// ⭐ XJMH 起飞炮台:手动按住扫射的**每发**也重播射击动画(原版 Battery:Shoot 每发重播)。
// 自动开火路径每发会走 ChangeState(Attack)(内部已重播),这里兜住手动路径(同帧自动去重)
if (currSitData != null && currSitData.currentTurret is FlyTurretData flyShot)
flyShot.PlayShotAnim();
}
// ==================== 起飞炮台(FlyTurretData) ====================
/// <summary>给起飞炮台挂上向上开火回调(每次起飞前刷新,防止切炮台后回调丢失/错位)</summary>
private void HookFlyTurretUpwardFire(FlyTurretData flyTurret)
{
if (flyTurret == null) return;
flyTurret.onUpwardFire = OnFlyTurretUpwardFire;
}
/// <summary>
/// 起飞炮台悬停时的向上开火:angle=0(正上方,aim-90约定)。
/// 校验与普通开火一致(金币/子弹上限/退出倒计时),子弹照常走服务端同步链路。
/// 不广播 TurretState:SyncFire 每 0.4 秒一发已带全部信息,逐发状态广播会刷屏网络。
/// </summary>
private void OnFlyTurretUpwardFire()
{
if (currSitData == null || _isLeavingCountdown) return;
if (currSitData.isDrillActive) return;
if (!HasEnoughGoldToFire(false, toggle_Shuang != null && toggle_Shuang.isOn)) return;
if (IsBulletLimitReached(currSitData.user)) return;
// 0° = 正上方;起飞炮台不支持三连
SyncFire(0f, false, toggle_Shuang != null && toggle_Shuang.isOn, false);
}
/// <summary>战神激光命中延迟(秒):不开实体子弹,开火后延迟此时间直接发 KillFish 给后端(模拟弹道飞行)</summary>
[Tooltip("战神激光命中延迟(秒):不开实体子弹,开火后延迟此时间直接发 KillFish 给后端(模拟弹道飞行)")]
public float laserHitDelay = 0.2f;
[Header("三合一抛物线(正常炮台散射+锁定,上下两颗子弹的弧线)")]
[Tooltip("近距离阈值:目标距离小于此值时弧线鼓出封顶(防止目标太近子弹乱飘)")]
public float sanSheNearDistance = 10f;
[Tooltip("远距离阈值:目标距离大于此值时弧线鼓出保底(很远时仍有弧线感)")]
public float sanSheFarDistance = 35f;
[Tooltip("弧线最高点横向鼓出最大值(目标距离≤近距离阈值时,世界单位)")]
public float sanSheMaxBulge = 6f;
[Tooltip("弧线最高点横向鼓出最小值(目标距离≥远距离阈值时,世界单位)")]
public float sanSheMinBulge = 4f;
[Tooltip("三合一侧弹飞行速度(世界单位/秒,0=用子弹默认速度):子弹默认速度太快全程仅零点几秒,弧线看不清,降速拉长飞行时间")]
public float sanSheSideBulletSpeed = 50f;
private void FireBullet(SitPosData sit, float angle, bool isSanShe, bool isDouble, bool isLock, List<string> bullitIDs,
List<string> fireTargetMonsterIDs = null, List<long> bulletGolds = null)
{
// ⭐ 防御:sit 或 bullitIDs 为空时直接返回,避免空引用
if (sit == null || bullitIDs == null || bullitIDs.Count == 0)
{
Debug.LogWarning("[FireBullet] sit 或 bullitIDs 为空,跳过本次开火");
// ⭐ 服务端这一发没回子弹(拒绝/异常)→ 本地不能再停在 Attack 空转:
// 攻击状态是开火请求发出前就切好的,不回滚就会一直循环攻击动画但一颗子弹都没有
if (sit == currSitData) EnsureTurretIdle();
return;
}
// ⭐ 战神激光模式(锁定中):不创建实体子弹,延迟后直接发 KillFish 给后端
if (sit.currentZhanShenData != null && isLock)
{
FireLaser(sit, isDouble, isSanShe, bullitIDs, fireTargetMonsterIDs, bulletGolds);
return;
}
Vector2 startPos = sit.GetBulletStartPos();
// ⭐ 按索引取该发子弹的服务端权威扣费金额(bullets[i].gold),没有则回退到0(ConsumeGold内部用consumeGoldNum)
long GetBulletGold(int i)
{
if (bulletGolds != null && i < bulletGolds.Count)
return bulletGolds[i];
return 0;
}
// ⭐ 安全取值:bullitIDs 不足3个时复用最后一个,避免越界
string SafeBid(int i) => i < bullitIDs.Count ? bullitIDs[i] : bullitIDs[bullitIDs.Count - 1];
// ⭐ 正常炮台 三发 + 锁定:三合一打一个——三发子弹(-20/0/+20)全部追踪主锁目标(最老版本行为)
// 战神+锁定在上面已提前走 FireLaser(分散打三个),能到这里的一定是正常炮台
// 必须保证三发子弹都创建,否则金币消耗和后端对不上
if (isSanShe && isLock)
{
// 三发出膛角度:右 -20,中 0,左 +20(锁定子弹会追踪转向主锁目标)
float[] defaultAngles = { angle - 20, angle, angle + 20 };
// ⭐ 三合一抛物线:主锁目标有效时,上下两颗子弹走弧线包抄
// 鼓出幅度随目标距离变化:近→大,远→小(最高点离中线越近),
// 用近距离/远距离两个阈值钳制:很近时封顶不乱飘,很远时保底仍有弧线感
bool hasLockTarget = sit.monCotl_Lock != null && sit.monCotl_Lock_Position != null
&& sit.monCotl_Lock_Position.gameObject.activeInHierarchy;
Vector2 toTarget = Vector2.zero;
float targetDist = 0f;
if (hasLockTarget)
{
toTarget = (Vector2)sit.monCotl_Lock_Position.position - startPos;
targetDist = toTarget.magnitude;
hasLockTarget = targetDist > 0.01f;
}
// ⭐ 诊断日志(定位三合一抛物线不生效用,问题解决后删除)
if (sit.user == GameManager.Instance.user.id)
{
float tDiag = Mathf.InverseLerp(sanSheFarDistance, sanSheNearDistance, targetDist);
float bulgeDiag = Mathf.Lerp(sanSheMinBulge, sanSheMaxBulge, tDiag);
Debug.Log($"[三合一诊断] 进入三合一分支: 主锁目标={(sit.monCotl_Lock != null ? sit.monCotl_Lock.name : "无")} " +
$"锁定点={(sit.monCotl_Lock_Position != null ? "有" : "无")} hasLockTarget={hasLockTarget} " +
$"目标距离={targetDist:F2} 鼓出={bulgeDiag:F2} 侧弹速度={sanSheSideBulletSpeed} " +
$"阈值(近{sanSheNearDistance}/远{sanSheFarDistance}) 幅度(小{sanSheMinBulge}/大{sanSheMaxBulge})");
}
for (int i = 0; i < 3; i++)
{
// lockTarget 传 null:CreateBullet 内部兜底会 Lock(sit.monCotl_Lock, sit.monCotl_Lock_Position),
// 三发全部锁定同一条主锁目标;主锁目标为空时按普通子弹发射(不锁定)
var bullet = CreateBullet(sit, startPos, defaultAngles[i], isDouble, true, SafeBid(i), null, null, GetBulletGold(i));
// ⭐ 上下两颗(i=0/2)挂抛物线,中间(i=1)直线:
// 鼓出方向垂直于炮口→目标连线,两颗各往自己外侧鼓,形成弧线包抄汇聚
// 侧弹同时降速(子弹默认100太快,零点几秒飞完,弧线看不清)
if (bullet != null && hasLockTarget && i != 1)
{
// 近距离阈值内→最大鼓出,远距离阈值外→最小鼓出,中间线性过渡
float t = Mathf.InverseLerp(sanSheFarDistance, sanSheNearDistance, targetDist);
float bulge = Mathf.Lerp(sanSheMinBulge, sanSheMaxBulge, t);
Vector2 axisRight = new Vector2(toTarget.y, -toTarget.x).normalized;
bullet.SetParabola(i == 0 ? axisRight : -axisRight, bulge, targetDist, sanSheSideBulletSpeed);
}
}
}
else if (isSanShe)
{
// ⭐ 诊断日志(定位三合一抛物线不生效用,问题解决后删除)
if (sit.user == GameManager.Instance.user.id)
Debug.LogWarning($"[三合一诊断] 走了【散射不锁定】直飞分支: 锁定开关={isLock} → 三发直飞无终点无弧线。" +
$"三合一抛物线需要: 散射+锁定都开 且 锁定到一条鱼");
CreateBullet(sit, startPos, angle - 20, isDouble, isLock, SafeBid(0), null, null, GetBulletGold(0));
CreateBullet(sit, startPos, angle, isDouble, isLock, SafeBid(1), null, null, GetBulletGold(1));
CreateBullet(sit, startPos, angle + 20, isDouble, isLock, SafeBid(2), null, null, GetBulletGold(2));
}
else
{
CreateBullet(sit, startPos, angle, isDouble, isLock, bullitIDs[0], null, null, GetBulletGold(0));
}
if (sit.posID==currSitData.posID)
{
// ⭐ 战神普通开火音效(未锁定):优先读战神配置的 bulletAudio,未配置回退普通炮台音效 bulletClip
var zsData = sit.currentZhanShenData as ZhanShenTurretData;
AudioClip clip = (zsData != null && zsData.bulletAudio != null) ? zsData.bulletAudio : bulletClip;
if (clip != null)
AudioManager.Instance.PlayFX(clip);
}
}
/// <summary>
/// 战神激光开火(锁定中):不创建实体子弹(视觉就是 LineRenderer 激光线),
/// 每发按正常频率走 FireCannon/广播(频率不变),本地扣费后延迟 laserHitDelay 秒
/// 直接把 KillFish 发给后端结算(模拟弹道飞行时间)。
/// 目标:三发=锁定目标列表(与三条激光线一致),单发=主锁定目标。
/// </summary>
private void FireLaser(SitPosData sit, bool isDouble, bool isSanShe, List<string> bullitIDs,
List<string> fireTargetMonsterIDs, List<long> bulletGolds)
{
// ---- 收集激光目标(与激光线一一对应)----
var targets = new List<MonsterContral>();
if (isSanShe)
{
// 三发:优先用开火时随帧同步的目标 GUID
if (fireTargetMonsterIDs != null)
{
foreach (var guid in fireTargetMonsterIDs)
{
if (CreateFishManager.Instance.fishDatas_hashCodekey.TryGetValue(guid, out var m)
&& m != null && !m.isDead && m.gameObject.activeInHierarchy)
targets.Add(m);
}
}
// 兜底:座位上的三发锁定列表
if (targets.Count == 0)
{
foreach (var m in sit.tripleLockTargets)
if (m != null && !m.isDead && m.gameObject.activeInHierarchy)
targets.Add(m);
}
}
// 单发 / 三发兜底:主锁定目标
if (targets.Count == 0 && sit.monCotl_Lock != null
&& !sit.monCotl_Lock.isDead && sit.monCotl_Lock.gameObject.activeInHierarchy)
targets.Add(sit.monCotl_Lock);
// ---- 每发:扣费 + 延迟发 KillFish(无目标也扣费,与子弹打空一致)----
for (int i = 0; i < bullitIDs.Count; i++)
{
long gold = bulletGolds != null && i < bulletGolds.Count ? bulletGolds[i] : 0;
if (!sit.ConsumeGold(isDouble ? 2 : 1, gold))
return; // 没钱,后续不发
MonsterContral target = targets.Count > 0 ? targets[Mathf.Min(i, targets.Count - 1)] : null;
// ⭐ 该发实际扣费炮倍(服务端权威优先,回退本地计算:双倍×2),
// 结算 KillFish 的 paoBei 用 → 双倍激光奖励×2
long useConsume = gold > 0 ? gold : sit.consumeGoldNum * (isDouble ? 2 : 1);
StartCoroutine(LaserHitRoutine(sit, target, bullitIDs[i], useConsume));
}
if (sit.posID == currSitData.posID)
{
// ⭐ 战神激光音效:配置了 laserAudio 的战神,激光音效由 SitPosData 用循环 AudioSource 播放
// (长音频循环,避免逐发播放重叠),此处不再逐发播;
// 未配置 laserAudio 时逐发回退:优先战神 bulletAudio,再退普通炮台 bulletClip
var zsData = sit.currentZhanShenData as ZhanShenTurretData;
if (zsData == null || zsData.laserAudio == null)
{
AudioClip clip = (zsData != null && zsData.bulletAudio != null) ? zsData.bulletAudio : bulletClip;
if (clip != null)
AudioManager.Instance.PlayFX(clip);
}
}
}
/// <summary>
/// 激光命中协程:延迟 laserHitDelay 后结算(不开实体子弹)。
/// 结算逻辑与 BullitMove.AttackFish 一致:本人发 KillFish、bot 由房主发 BotKillFish、本地播 OnHurt。
/// </summary>
private System.Collections.IEnumerator LaserHitRoutine(SitPosData sit, MonsterContral target, string bulletId, long fireGold = 0)
{
if (laserHitDelay > 0f)
yield return new WaitForSeconds(laserHitDelay);
else
yield return null;
// 延迟期间目标死了/失效 → 本发打空(钱已扣,与子弹未命中一致)
if (target == null || target.isDead || !target.gameObject.activeInHierarchy)
yield break;
if (sit.user == GameManager.Instance.user.id)
{
// 真人玩家击杀:直接发后端
// ⭐ paoBei 用该发实际扣费炮倍(双倍=2倍),后端按它结算 → 奖励×2
var v2 = GameManager.Instance.gameData.currRoomData;
WebSocketSendList.KillFish(v2.roomId, sit.user,
target.monsterData.fishTypeConfig.fishNumber, target.guid,
(int)(fireGold > 0 ? fireGold : sit.consumeGoldNum), bulletId, "63215f0afa3f5b25");
}
else if (!string.IsNullOrEmpty(sit.user) && sit.user.StartsWith("bot_"))
{
// 机器人击杀:只有房主客户端发送 BotKillFish
if (SitPosData.roomOwnerSit != null && SitPosData.roomOwnerSit.user == GameManager.Instance.user.id)
{
var v2 = GameManager.Instance.gameData.currRoomData;
WebSocketSendList.BotKillFish(
v2.roomId,
sit.user,
target.monsterData.fishTypeConfig.fishNumber,
target.guid,
sit.consumeGoldNum,
bulletId
);
}
}
target.OnHurt();
}
private bool isLaser = false;
private void StartLaser()
{
isLaser = true;
currSitData.ChangeState(TurretState.LaserStart);
SyncTurretState(TurretState.LaserStart);
}
private void LoopLaser()
{
if (!isLaser) return;
// TODO: 激光持续伤害 / 特效
}
private void StopLaser()
{
isLaser = false;
currSitData.ChangeState(TurretState.LaserEnd);
SyncTurretState(TurretState.LaserEnd);
}
private float autoFireTimer = 0f;
//private void HandleAutoFire()
//{
// if (!currSitData.IsAutoAttackOn) return;
// if (currSitData == null) return;
// autoFireTimer += Time.deltaTime;
// if (autoFireTimer < attackTime) return;
// autoFireTimer = 0f;
// float angle = currSitData.currentAngle;
// bool isSanShe = toggle_Three.isOn;
// bool isDouble = toggle_Shuang.isOn;
// bool isLock = toggle_Lock.isOn;
// FireBullet(currSitData, angle, isSanShe, isDouble, isLock);
// SyncFire(angle, isSanShe, isDouble, isLock);
//}
private void HandleAutoFire()
{
if (currSitData == null) return;
// ⭐ 退出倒计时期间屏蔽本地玩家自动开火,其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
// ⭐ 特殊鱼炮台(钻头)活动期间禁用普通自动开火
if (currSitData.isDrillActive) return;
// ⭐ 起飞炮台:飞行/悬停期间暂停自动开火(此时由悬停逻辑自动向上发射),平时正常自动开火
if (currSitData.currentTurret is FlyTurretData flyAutoFire && flyAutoFire.IsFlying) return;
bool needAutoFire = currSitData.IsAutoAttackOn || (toggle_Lock.isOn && currSitData.isLock && currSitData.lockFX.gameObject.activeSelf);
if (!needAutoFire)
{
// ⭐ 自动开火条件不再满足(关自动攻击/松开锁定目标):把还卡在 Attack 的炮台收回 Idle,
// 否则攻击动画一直循环但一颗子弹都不发射
EnsureTurretIdle();
autoFireTimer = 0f;
return;
}
autoFireTimer += Time.deltaTime;
if (autoFireTimer < attackTime) return;
// ⭐ 本地金币校验:没钱直接返回,不切换攻击状态、不发送开炮请求
// autoFireTimer 保持满,金币恢复后立即开火
if (!HasEnoughGoldToFire(toggle_Three.isOn, toggle_Shuang != null && toggle_Shuang.isOn))
{
EnsureTurretIdle(); // 打不出去就别停在攻击状态
return;
}
// ⭐ 子弹上限校验(先于状态切换和扣费)
if (IsBulletLimitReached(currSitData.user))
{
EnsureTurretIdle(); // 子弹已满打不出去,回收攻击状态(金币恢复/上限解除后下一轮会重新进 Attack)
autoFireTimer = 0f; // 重置计时,下次再试
return;
}
autoFireTimer = 0f;
float angle = currSitData.currentAngle;
bool isSanShe = toggle_Three.isOn;
bool isDouble = toggle_Shuang.isOn;
bool isLock = toggle_Lock.isOn;
// ⭐ 自动攻击也要播放攻击动画
currSitData.ChangeState(TurretState.Attack);
SyncTurretState(TurretState.Attack);
//FireBullet(currSitData, angle, isSanShe, isDouble, isLock);
SyncFire(angle, isSanShe, isDouble, isLock);
}
/// <summary>
/// ⭐ 打不出去时把炮台从 Attack 收回 Idle(只处理 Attack,不碰 LaserStart/LaserLoop)。
/// 背景:攻击状态是在"按下/自动开火"那一刻就切好的(也要先把状态广播出去给其他客户端),
/// 但真正出弹要等服务端 RoomFireNotice 回来。中间任何一环被跳过——自动开火条件不再满足、
/// 金币不足、子弹上限、服务端没回子弹——以前都只 return 不收回状态,炮台就停在 Attack:
/// 攻击动画一直循环、一颗子弹都不发射(远端视角同样跟着卡住,因为状态是同步过去的)。
/// 🔁 只在"当前不是 Idle"时切一次:ChangeState 同状态早退 + 这里的判断,保证不会每帧刷广播。
/// </summary>
private void EnsureTurretIdle()
{
if (currSitData == null) return;
// ⚠ 玩家正按住手动开火时不能抢:手动开火的状态是"按下进 Attack、松手才回 Idle",
// 此时自动开火这一帧不开火(needAutoFire=false)不代表炮台不该是攻击状态。
// 少了这道判断会在按下同一帧把手动 Attack 顶回 Idle → 表现为"根本切不到攻击状态"。
if (IsManualFireActive) return;
// 状态由 OnStateChange 指向的那个 TurretData 接收:战神装备中指向战神(见 SitPosData.LoadZhanShen)
var turret = currSitData.currentZhanShenData != null ? currSitData.currentZhanShenData : currSitData.currentTurret;
if (turret == null || turret.CurrState != TurretState.Attack) return;
currSitData.ChangeState(TurretState.Idle);
SyncTurretState(TurretState.Idle);
}
/// <summary>开火键是否按住(HandleInput 每帧刷新)</summary>
private bool _firePointerDown;
/// <summary>
/// ⭐ 远端攻击状态看门狗:别的座位停在 Attack 上时,靠"这个座位还在不在发攻击消息"判断它是不是真的在攻击。
/// 为什么会卡:攻击状态是同步过来的,而"收回待机"也要等对方发待机包(changeTurretState=Idle)。
/// 对方攻击中直接退出/掉线/被踢 → 既不会再攻击也不会再发待机包 → 本端炮台永远停在攻击动画。
/// 这里按身份判定:开着战神时状态由战神接收(同 EnsureTurretIdle)。
/// 🔁 只改本地表现,不再广播回去(否则多端会互相刷 Idle 包)。
/// </summary>
private void UpdateRemoteTurretAttackTimeout()
{
if (list_Sits == null || list_Sits.Count == 0) return;
float now = Time.time;
// 等待时间至少是开火间隔的 3 倍:配置把攻击间隔调大时看门狗不能比它短,否则会误收回正在攻击的座位
float timeout = Mathf.Max(remoteAttackIdleTimeout, attackTime * 3f);
foreach (var sit in list_Sits)
{
if (sit == null || sit == currSitData) continue; // 本地座位由本地开火/回收逻辑管
var turret = sit.currentZhanShenData != null ? sit.currentZhanShenData : sit.currentTurret;
if (turret == null || turret.CurrState != TurretState.Attack) continue;
float last;
if (!remoteAttackHeartbeat.TryGetValue(sit.posID, out last))
{
// 没收到过心跳(进房间前的残留状态等)→ 给一个完整等待窗口,不立刻收回
remoteAttackHeartbeat[sit.posID] = now;
continue;
}
if (now - last <= timeout) continue;
// 超过等待时间没有任何"还在攻击"的消息 → 这个座位已经不在攻击了,收回待机
sit.ChangeState(TurretState.Idle);
}
}
/// <summary>
/// 手动开火是否正在生效:手动开火只在"自动攻击关 + 锁定关"时才工作
/// (TryFire 开头被这两个开关提前 return 拦掉,锁定模式下手动按住只旋转不开火),
/// 此时 Attack 状态完全由按下/按住驱动,回收逻辑必须让位。
/// </summary>
private bool IsManualFireActive
{
get
{
if (!_firePointerDown) return false;
if (currSitData != null && currSitData.IsAutoAttackOn) return false;
return toggle_Lock == null || !toggle_Lock.isOn;
}
}
#endregion
#region 锁定位置解析(统一工具)
/// <summary>
/// 统一锁定位置解析:colliderList 第一个活跃碰撞器 → lockPoint → monster.transform
/// 所有设置锁定位置的地方都应该调用此方法,避免不同地方用不同优先级导致锁定图标位置和子弹终点不一致。
/// 注意:Unity 序列化字段未赋值时是"假 null",?? 运算符无法识别,必须用 == null 判断。
/// </summary>
/// <summary>
/// 解析锁定锚点 Transform。
/// ⭐ useHitCollider=true(手动锁定):点击碰撞器 → colliderList 激活项 → lockPoint
/// ⭐ useHitCollider=false(自动锁定/三发):直接用 lockPoint → colliderList 激活项 → transform
/// (自动锁定没有点击的碰撞器,优先用固定锁定点更稳定)
/// </summary>
private Transform ResolveLockTransform(MonsterContral monster, out int colliderIndex, bool useHitCollider = false)
{
colliderIndex = -1;
if (monster == null) return null;
// ① 手动锁定:优先用点击的碰撞器(hitCollider)
if (useHitCollider && monster.hitCollider != null && monster.hitCollider.gameObject.activeInHierarchy)
{
// 在 colliderList 里找到对应索引,用于网络同步
if (monster.colliderList != null)
{
for (int i = 0; i < monster.colliderList.Count; i++)
{
if (monster.colliderList[i] == monster.hitCollider)
{
colliderIndex = i;
break;
}
}
}
return monster.hitCollider;
}
// ② 自动锁定:直接用 lockPoint(锁定锚点)
if (!useHitCollider && monster.lockPoint != null && monster.lockPoint.gameObject.activeInHierarchy)
return monster.lockPoint;
// ③ 兜底:colliderList 第一个活跃碰撞器(手动/自动都走)
if (monster.colliderList != null && monster.colliderList.Count > 0)
{
for (int i = 0; i < monster.colliderList.Count; i++)
{
var col = monster.colliderList[i];
if (col != null && col.gameObject.activeInHierarchy)
{
colliderIndex = i;
return col;
}
}
}
// ④ 手动锁定兜底:lockPoint
if (useHitCollider && monster.lockPoint != null && monster.lockPoint.gameObject.activeInHierarchy)
return monster.lockPoint;
// ⑤ ⭐ 区分两种情况:
// - 完全没配锚点(lockPoint/colliderList 都为空,如 XJMH/FLZY 多数 Boss 预制体)
// → 回退用鱼自身 transform,保证自动锁定能锁到这些 Boss
// - 配了锚点但全部未激活(出场效果期间,如嫦娥/小白龙切换片段)
// → 返回 null,调用方应换一条鱼
if (monster.lockPoint == null && (monster.colliderList == null || monster.colliderList.Count == 0))
return monster.transform;
return null;
}
/// <summary>简化重载,不关心 colliderIndex 时用</summary>
private Transform ResolveLockTransform(MonsterContral monster)
{
return ResolveLockTransform(monster, out _, false);
}
/// <summary>
/// ⭐ 判断鱼是否可锁定。
/// - needLockEvent=false 的鱼:一直可锁定(只要 activeInHierarchy)
/// - needLockEvent=true 的鱼:出场/状态切换期间 isLockable=false,等 NotifyLockable() 后才可锁定
/// - 已死亡的鱼(isDead=true,死亡特效播放窗口期)不可锁定
/// - 锚点检查分两种情况:
/// ① 配了锚点(lockPoint 或 colliderList 非空):要求至少一个激活,否则视为出场窗口期不可锁定
/// ② 完全没配锚点(XJMH/FLZY 多数 Boss 预制体):视为一直可锁定,锁定点回退用鱼自身 transform
/// ⭐ 否则这些 Boss 会被永久过滤,表现为"手动能锁、自动锁定永远锁不到Boss"
/// </summary>
private bool IsMonsterLockable(MonsterContral monster)
{
if (monster == null || !monster.gameObject.activeInHierarchy) return false;
// ⭐ 死亡的鱼不可锁定
if (monster.isDead) return false;
// ⭐ 可锁定事件控制:needLockEvent=true 的鱼未触发 NotifyLockable 前不可锁定
if (!monster.isLockable) return false;
// ⭐ 完全没配锚点 → 一直可锁定(锁定点回退 transform,见 ResolveLockTransform ⑤)
bool hasAnchorConfig = monster.lockPoint != null
|| (monster.colliderList != null && monster.colliderList.Count > 0);
if (!hasAnchorConfig) return true;
// ⭐ 配了锚点:要求 lockPoint 或 colliderList 任一激活
if (monster.lockPoint != null && monster.lockPoint.gameObject.activeInHierarchy) return true;
if (monster.colliderList != null)
{
for (int i = 0; i < monster.colliderList.Count; i++)
{
var col = monster.colliderList[i];
if (col != null && col.gameObject.activeInHierarchy) return true;
}
}
return false;
}
#endregion
#region 路径进度过滤(锁定范围控制)
// ⭐ 原 BotLockPathMin/BotLockPathMax 常量已改为面板字段 lockPathMin/lockPathMax(Inspector 可调)
/// <summary>
/// 计算鱼在路径上的进度 [0, 1]。
/// 进度 = moveTime / monsterData.moveLine.moveTime
/// 0 = 刚出生,1 = 即将离场
/// ⭐ isMove=false 的鱼(爆奖Boss等)不走路径,固定在场景中 → 返回中间值 0.5 表示可锁定
/// </summary>
private float GetFishPathProgress(MonsterContral m)
{
if (m == null) return 1f;
// ⭐ isMove=false 的鱼不走路径(如金龙/雷龙等爆奖Boss),一直可用
if (!m.isMove) return 0.5f;
if (m.monsterData == null || m.monsterData.moveLine == null) return 1f;
float total = m.monsterData.moveLine.moveTime;
if (total <= 0f) return 1f;
return Mathf.Clamp01(m.moveTime / total);
}
/// <summary>
/// 判断鱼的路径进度是否在可锁定范围内 [lockPathMin, lockPathMax](面板字段,Inspector 可调)
/// 刚出生或即将离场的鱼不能锁定,避免锁定后立即丢失目标
/// ⭐ 注意:本函数仅用于自动锁定/bot/三发查找;玩家手动锁定不调用此函数
/// </summary>
private bool IsFishPathLockable(MonsterContral m)
{
if (m == null) return false;
float progress = GetFishPathProgress(m);
return progress >= lockPathMin && progress <= lockPathMax;
}
#endregion
#region 多目标查找(TripleLock)
/// <summary>鱼锁定优先级:特殊Boss(5) > Boss鱼(4) > 特殊鱼(3) > 黄金鱼(2) > 普通鱼(1)</summary>
private int GetFishLockPriority(MonsterContral monster)
{
switch (monster.fishenum)
{
case FishTypeEnum.特殊Boss: return 5;
case FishTypeEnum.Boss鱼: return 4;
case FishTypeEnum.特殊鱼: return 3;
case FishTypeEnum.黄金鱼: return 2;
case FishTypeEnum.普通鱼: return 1;
default: return 0;
}
}
/// <summary>
/// 查找 fire 时的多目标(三发锁定用)。
/// 第一个目标 = 主锁 monCotl_Lock(打哪锁哪),剩余从 Boss/特殊Boss/特殊鱼/黄金鱼 按优先级补满
/// </summary>
private List<(MonsterContral monster, Transform hitPos)> FindFireTargets(int maxCount)
{
var result = new List<(MonsterContral monster, Transform hitPos)>();
var selected = new HashSet<string>();
if (CreateFishManager.Instance == null) return result;
// ① 主锁目标排第一(打哪锁哪)
// ⭐ 手动锁定:只看鱼是否还活着;自动锁定:还要满足路径可锁定
if (currSitData.isLock && currSitData.monCotl_Lock != null &&
currSitData.monCotl_Lock.gameObject.activeInHierarchy &&
(currSitData.isManualLock || IsFishPathLockable(currSitData.monCotl_Lock)))
{
Transform pos = currSitData.monCotl_Lock_Position != null && currSitData.monCotl_Lock_Position.gameObject.activeInHierarchy
? currSitData.monCotl_Lock_Position
: currSitData.monCotl_Lock.transform;
result.Add((currSitData.monCotl_Lock, pos));
selected.Add(currSitData.monCotl_Lock.guid);
if (result.Count >= maxCount) return result;
}
// ② 按优先级从分类字典补满剩余位置
// ⭐ 修复:之前漏了 fishDatas_hashCodekey_Small(普通鱼),
// 导致场上只有普通鱼时三发只找到1个目标
var dicts = new Dictionary<string, MonsterContral>[]
{
CreateFishManager.Instance.fishDatas_hashCodekey_TS_Boss,
CreateFishManager.Instance.fishDatas_hashCodekey_Boss,
CreateFishManager.Instance.fishDatas_hashCodekey_TS,
CreateFishManager.Instance.fishDatas_hashCodekey_HJ,
CreateFishManager.Instance.fishDatas_hashCodekey_Small,
};
foreach (var dict in dicts)
{
if (dict == null) continue;
foreach (var kv in dict)
{
MonsterContral m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
if (selected.Contains(m.guid)) continue; // 跳过已选中的
// ⭐ 单独锁定白名单(SuoDing):面板勾选了鱼时,三发目标也只从选中的鱼里补
if (!IsFishAllowedBySuoDing(m)) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的鱼不作为目标
if (!IsFishPathLockable(m)) continue;
// ⭐ 可锁定过滤:出场效果期间/needLockEvent 鱼未就绪 → 跳过换下一条
if (!IsMonsterLockable(m)) continue;
// ⭐ 统一用 ResolveLockTransform:lockPoint → colliderList → (null则不选)
Transform pos = ResolveLockTransform(m);
if (pos == null) continue;
result.Add((m, pos));
selected.Add(m.guid);
if (result.Count >= maxCount) return result;
}
}
return result;
}
#endregion
#region 锁定系统(LockModule)
/// <summary>
/// 刷新三发锁定图标:三发+锁定开启时显示3个锁定圈
/// </summary>
private void RefreshTripleLockIcons()
{
if (currSitData == null) return;
// ⭐ 是否处于"战神+三发+锁定"模式(⭐与有没有锁定目标无关:不再和 isLock 循环依赖,
// 否则三格全空→isLock=false→这里判定退出三发→把锁位全清掉)
bool tripleModeOn = IsZhanShenTripleMode();
if (!tripleModeOn)
{
// 不在三发模式 → 清掉三发锁位,回到单锁逻辑(主锁 monCotl_Lock 保留,继续当单锁用)
currSitData.isScatterLaserOn = false;
if (currSitData.tripleLockTargets.Count > 0)
currSitData.HideTripleLockIcons();
return;
}
// ⭐ 散射标记:三发模式下只要有锁定目标就算散射中(目标不足也要显示左右起点球)
// ⭐ 特殊炮台期间子锁位不受影响,这里照常算
currSitData.isScatterLaserOn = currSitData.HasAnyTripleTarget
|| (currSitData.isLock && currSitData.monCotl_Lock != null);
// 已经有锁位 → 跳过(空位由 CheckLockTargetValid 每帧独立检测补位)
if (currSitData.tripleLockTargets.Count > 0) return;
// ⭐ 特殊炮台(激光蟹/钻头蟹)期间禁止"主锁定"锁鱼 → 不新建三发锁位(含主锁);
// 已有的子锁位不受影响(上面的分支已经保留)
if (currSitData.isDrillActive) return;
var targets = FindFireTargets(3);
if (targets.Count > 0)
currSitData.SetTripleLockTargets(targets);
}
/// <summary>自动锁定是否已激活:激活后当前目标死亡/失效时自动转换到下一个目标。
/// ⭐ 只由两条路径激活:① 手动锁定(受 AutoLockOnManualLock 控制,通用=true;XJMH 覆写 false 不激活);
/// ② 图鉴/锁定面板应用(ApplySuoDingSelection)。</summary>
private bool _autoLockActivated = false;
/// <summary>手动锁定(直接点鱼)后是否激活"自动换目标"。
/// 默认 true(通用玩法:手动锁定后该鱼死亡会继续自动找目标);
/// XJMH 覆写为 false:直接点锁定不自动换目标——锁的鱼死了就空着,等玩家再点;
/// 图鉴锁定面板的自动锁定不受影响(那条路径直接激活)。</summary>
protected virtual bool AutoLockOnManualLock => true;
private void HandleLock()
{
// ⭐ 加载期间 currSitData 尚未赋值(座位信息推迟到加载到 100%)→ 跳过,避免空引用
if (currSitData == null) return;
// ⭐ 退出倒计时期间屏蔽本地玩家手动锁定,其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
// ⭐ 特殊鱼爆奖(钻头/激光)期间禁用手动锁定:点击只用于旋转瞄准/发射方向
if (currSitData != null && currSitData.isDrillActive) return;
CrossPlatformInput input = GetInput();
if (!toggle_Lock.isOn) return;
//if (!input.wasPressedThisFrame && !input.wasReleasedThisFrame) return;
if (!input.wasPressedThisFrame) return;
if (IsPointerOverUI(input.position)) return;
// 射线从相机出发,指向鼠标点击点
Vector3 mousePos = input.position;
mousePos.z = -Camera.main.transform.position.z;
Vector2 mouseWorld = Camera.main.ScreenToWorldPoint(mousePos);
//Vector2 origin = Camera.main.transform.position;
//Vector2 dir = (mouseWorld - origin).normalized;
Vector2 dir = Vector2.zero;
float distance = 10000f;
int fishLayer = LayerMask.GetMask("Fish");
// 获取所有命中(可能有多个)
RaycastHit2D[] hits = Physics2D.RaycastAll(mouseWorld, dir, distance, fishLayer);
if (hits.Length == 0)
{
// 未命中:清除锁定
currSitData.ClearLock();
SyncTurretState(TurretState.Idle);
SyncLock(null);
return;
}
// 按 Z 轴深度选择最前面的(Z 值最大的,因为相机在负 Z,更大 Z 更近)
// 如果您的相机在 Z = -10,物体 Z 范围 0 ~ 10,则 Z 越大越靠近相机
RaycastHit2D bestHit = hits[0];
float bestDepth = bestHit.collider.transform.position.z;
foreach (var hit in hits)
{
float depth = hit.collider.transform.position.z;
if (depth < bestDepth) // 更大 Z → 更远
{
bestDepth = depth;
bestHit = hit;
}
}
// 处理选中的最佳命中
MonsterContral monster = bestHit.collider.GetComponentInParent<MonsterContral>();
// ⭐ 死亡窗口期的鱼不可手动锁定(isDead=true:死亡/爆奖动画播放中;XJMH 多次死亡 Boss
// 非最终死亡恢复后曾卡 true,锁上后子弹被 BullitMove 判 target.isDead 立即 NotLock)。
// ⚠ 不用 IsMonsterLockable:它额外要求 lockPoint/colliderList 配了激活锚点,
// 普通鱼预制体没配这些列表(手动锁定靠点击的碰撞器 hitCollider 本身),用它会把所有鱼都拒掉
if (monster == null || !monster.gameObject.activeInHierarchy || monster.isDead) return;
// ⭐ 记录被点击的碰撞器,用于 ResolveLockTransform 优先级判断
if (monster != null)
monster.hitCollider = bestHit.collider.transform;
// ⭐ 手动锁定:useHitCollider=true → 点击碰撞器 → colliderList 激活项 → lockPoint
int colliderIndex;
Transform lockTr = ResolveLockTransform(monster, out colliderIndex, true);
// ⭐ lockPoint 和 colliderList 全部未激活 → 不锁定这条鱼
if (lockTr == null) return;
// ⭐⭐⭐ 战神 + 三发 + 锁定(散射三个锁定圈):手动点击 = 占主锁位,
// 原来的主锁位自动挤到子锁位,子锁位依次后移,最多锁3个
if (IsZhanShenTripleMode())
{
// 已经在锁位上的鱼 → 不重复加
if (currSitData.IsFishInTripleSlots(monster.guid)) return;
currSitData.PushTripleLockSlot(monster, lockTr);
currSitData.isManualLock = true;
if (AutoLockOnManualLock) _autoLockActivated = true; // XJMH 覆写 false:手动锁定不自动换目标
SyncLock(currSitData.GetTripleLockGuids(), colliderIndex);
return;
}
// ⭐ 标记为手动锁定:不受路径进度(0.1~0.9)限制,鱼离场前一直锁定
currSitData.isManualLock = true;
currSitData.SetLockTarget(monster, lockTr);
SyncLock(new List<string> { monster?.guid }, colliderIndex);
// ⭐ 手动锁定成功 → 激活自动锁定(该鱼死亡后自动转换目标;XJMH 覆写 AutoLockOnManualLock=false 不激活)
if (AutoLockOnManualLock) _autoLockActivated = true;
}
/// <summary>是否处于 战神 + 三发 + 锁定 模式(散射激光 + 3个锁定圈)</summary>
private bool IsZhanShenTripleMode()
{
return currSitData != null
&& currSitData.currentZhanShenData != null
&& toggle_Three != null && toggle_Three.isOn
&& toggle_Lock != null && toggle_Lock.isOn;
}
private void UpdateLockFX()
{
foreach (var sit in list_Sits)
{
// ---- 主锁定图标 ----
if (sit.isLock && sit.monCotl_Lock != null)
{
// ⭐ 切换锁定点(小白龙等切换模型后锁定点失效):
// 当前 monCotl_Lock_Position 失效 → 调 SwitchLockPoint 重新取激活的锚点
if (sit.monCotl_Lock_Position == null || !sit.monCotl_Lock_Position.gameObject.activeInHierarchy)
{
Transform newLockPos = sit.monCotl_Lock.SwitchLockPoint();
if (newLockPos != null)
sit.monCotl_Lock_Position = newLockPos;
}
UpdateSingleLockFX(sit, sit.monCotl_Lock, sit.monCotl_Lock_Position, sit.lockFX);
}
// ---- 三发锁定额外图标(⭐独立于主锁:主锁位空着/主锁被冻结时也要跟着自己的鱼走) ----
// ⭐ tripleLockTargets[0] 是主目标(已在主 lockFX 显示),
// lockFX_Triple[0] 应对应 tripleLockTargets[1],以此类推
for (int i = 0; i < sit.lockFX_Triple.Count; i++)
{
int targetIdx = i + 1; // 跳过索引0(主目标)
if (targetIdx >= sit.tripleLockTargets.Count) break;
var icon = sit.lockFX_Triple[i];
if (icon == null) continue;
var monster = sit.tripleLockTargets[targetIdx];
if (monster == null)
{
if (icon.activeSelf) icon.SetActive(false);
continue;
}
var hitPos = sit.tripleLockPositions[targetIdx];
// ⭐ 同样为三发目标切换锁定点
if (hitPos == null || !hitPos.gameObject.activeInHierarchy)
{
Transform newLockPos = monster.SwitchLockPoint();
if (newLockPos != null)
{
sit.tripleLockPositions[targetIdx] = newLockPos;
hitPos = newLockPos;
}
}
UpdateSingleLockFX(sit, monster, hitPos, icon);
}
}
}
/// <summary>锁定图标 lockFX 固定 z 轴(相机 z=-500、爆奖特效 z=-100,lockFX 在爆奖前面取 -50;图片在子对象上带 z 偏移,太靠近相机会被近平面裁掉)</summary>
private const float LockFXFixedZ = -50f;
private void UpdateSingleLockFX(SitPosData sit, MonsterContral monster, Transform hitPos, GameObject icon)
{
// ⭐ 核心原则:鱼还活着就显示图标,鱼死了才隐藏
if (monster == null || !monster.gameObject.activeInHierarchy)
{
if (icon.activeSelf) icon.SetActive(false);
return;
}
// 鱼还活着 → 确定位置(优先 hitPos,失效则用 monster.transform 兜底)
Vector3 pos;
if (hitPos != null && hitPos.gameObject.activeInHierarchy)
{
pos = hitPos.position;
}
else
{
// hitPos 失效 → 直接用鱼自身位置,不再尝试 ResolveLockTransform(避免二次失败)
pos = monster.transform.position;
}
if (!icon.activeSelf) icon.SetActive(true);
// ⭐ z 轴固定 -50:图片在子对象上带 z 偏移,太靠近相机(如相机前1格)会被近平面裁掉;
// 相机 z=-500、爆奖特效 z=-100,-50 在爆奖前面位置正合适
pos.z = LockFXFixedZ;
icon.transform.position = pos;
}
private void CheckLockTargetValid()
{
// ⭐ 加载期间 currSitData 尚未赋值(座位信息推迟到加载到 100%)→ 跳过,避免空引用
if (currSitData == null) return;
// ⭐⭐⭐ 三发模式:每个锁位完全独立维护 ——
// 有鱼的位:鱼死了/路径离场/被取消勾选 → 只补这一个位,其他位不动
// 空着的位:**每帧持续检测**(有符合条件(含图鉴勾选)的鱼就补上),
// 不等待主锁位的鱼被打死、也不会被封存成"等手动点击"
// (Count 由 SetTripleLockTargets 建立;HideTripleLockIcons 才清成 0)
if (currSitData.tripleLockTargets.Count > 0)
{
// ⭐ 固定3个锁位(三发=最多3个目标)
currSitData.EnsureTripleLockSlots(3);
// ⭐ 记录本次维护前的锁状态:下面"全空"时据此判断是否需要向服务器同步一次取消锁定
// (循环里清理锁位会提前把 isLock/monCotl_Lock 置空,所以必须先取)
bool wasLocked = currSitData.isLock || currSitData.monCotl_Lock != null;
for (int i = currSitData.tripleLockTargets.Count - 1; i >= 0; i--)
{
var t = currSitData.tripleLockTargets[i];
// ⭐ 特殊炮台(激光蟹/钻头蟹)期间:只冻结"主锁位"——不让它锁新鱼/换目标。
// 子锁位(1/2)照常每帧独立检测补位,**不受特殊炮台影响**。
// 主锁位保持原样(鱼死了也不动),等特殊炮台结束后由下面的逻辑自动补上
if (i == 0 && currSitData.isDrillActive) continue;
// ---- 该锁位有鱼:判断是否需要更换 ----
if (t != null && t.gameObject.activeInHierarchy)
{
// ⭐ 玩家手动点选的锁位 → 离场前一直跟,死了才换。
// 手动身份是跟着鱼走的:被挤到子锁位后依然是手动,不会被自动换掉
if (currSitData.IsTripleSlotManual(i)) continue;
// ⭐ 路径离场(超出 0.1~0.9) 或 被 SuoDing 取消勾选 → 只补这一个锁位
if (!IsFishPathLockable(t) || !IsFishAllowedBySuoDing(t))
RefillTripleLockSlot(i);
continue;
}
// ---- 该锁位空着:每帧独立检测补位(补不到就下一帧继续) ----
RefillTripleLockSlot(i);
}
// ⭐ 三个锁位全空 → 本地锁状态复位 + 同步一次服务器,但**保留3个锁位继续检测**
// (不清锁位列表,这样场上再出现符合条件的鱼时会自动补上)
if (!currSitData.HasAnyTripleTarget && wasLocked)
{
currSitData.ResetMainLockKeepTripleSlots();
SyncTurretState(TurretState.Idle);
SyncLock(null); // 同步服务器:取消锁定
}
return;
}
// ⭐ 单锁模式(没有三发锁位时,主锁就是唯一锁定):特殊炮台期间禁止锁定
if (currSitData.isDrillActive) return;
if (!currSitData.isLock) return;
var monster = currSitData.monCotl_Lock;
// ⭐ 目标无效判定:
// ① 被回收/销毁(死了) → 更换目标
// ② 自动锁定 且 路径离场(超出 0.1~0.9) → 更换目标
// ③ 手动锁定 → 不检查路径,玩家自己点的鱼离场前一直跟(死了才换)
bool mainInvalid = monster == null
|| !monster.gameObject.activeInHierarchy
|| (!currSitData.isManualLock && !IsFishPathLockable(monster));
if (mainInvalid)
{
currSitData.ClearLock(); // 清除锁定
SyncTurretState(TurretState.Idle);
SyncLock(null); // 同步服务器
// ⭐⭐⭐ 关键:怪死了 → 动画回到 Idle ⭐⭐⭐
currSitData.ChangeState(TurretState.Idle);
SyncTurretState(TurretState.Idle);
return;
}
}
/// <summary>
/// ⭐ 只补指定索引的三发锁位(锁位独立):该锁位的鱼死了或路径离场,只补这一个,其他锁位不动。
/// slotIndex=0 是主锁位(monCotl_Lock/主 lockFX),1..2 用 lockFX_Triple。
/// 找不到新目标时:主锁位尝试把其他存活锁位提升为主锁,其余锁位只隐藏图标保留占位。
/// </summary>
private void RefillTripleLockSlot(int slotIndex)
{
MonsterContral target = FindOneLockableTargetExceptSlots(slotIndex);
if (target != null)
{
Transform lockTr = ResolveLockTransform(target, out _);
if (lockTr != null)
{
currSitData.SetTripleLockSlot(slotIndex, target, lockTr);
return;
}
}
// 主锁位空缺且没新目标 → 把其他存活锁位提升为主锁,保持三发模式不中断
if (slotIndex == 0 && currSitData.PromoteTripleLockSlotToMain()) return;
currSitData.ClearTripleLockSlot(slotIndex);
}
/// <summary>
/// 找一个可锁定的鱼(排除当前三发锁位里其他锁位已占用的鱼),优先级分层同 FindAutoLockTarget。
/// slotIndex 是要重填的锁位(该锁位自身的旧目标不算占用)。
/// </summary>
private MonsterContral FindOneLockableTargetExceptSlots(int slotIndex)
{
if (CreateFishManager.Instance == null ||
CreateFishManager.Instance.fishDatas_hashCodekey == null)
return null;
// 其他锁位已占用的鱼(避免同一条鱼被锁两次)
var used = new HashSet<string>();
for (int i = 0; i < currSitData.tripleLockTargets.Count; i++)
{
if (i == slotIndex) continue;
var t = currSitData.tripleLockTargets[i];
if (t != null) used.Add(t.guid);
}
Vector3 origin = currSitData.turretWorldPos;
MonsterContral best = null;
int bestPri = -1;
float bestSqr = float.MaxValue;
var fallback = new List<MonsterContral>(); // 黄金鱼/普通鱼层,同层随机
foreach (var kv in CreateFishManager.Instance.fishDatas_hashCodekey)
{
MonsterContral m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
if (used.Contains(m.guid)) continue;
// ⭐ 单独锁定白名单(SuoDing)
if (!IsFishAllowedBySuoDing(m)) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的鱼不作为目标
if (!IsFishPathLockable(m)) continue;
// ⭐ 可锁定过滤:出场效果期间/needLockEvent 鱼未就绪 → 跳过
if (!IsMonsterLockable(m)) continue;
int pri = GetFishLockPriority(m);
if (pri >= 3)
{
float sqr = (m.transform.position - origin).sqrMagnitude;
if (pri > bestPri || (pri == bestPri && sqr < bestSqr))
{
bestPri = pri;
bestSqr = sqr;
best = m;
}
}
else
{
fallback.Add(m);
}
}
if (best != null) return best;
if (fallback.Count > 0) return fallback[Random.Range(0, fallback.Count)];
return null;
}
/// <summary>
/// ⭐ "图鉴锁定"模式标记。区分两种锁定入口:
/// - false(默认):正常锁定 —— 直接点 toggle_Lock 打开,或直接点鱼锁定。**完全不受图鉴勾选约束**。
/// - true:图鉴锁定 —— 长按锁定 toggle 打开图鉴面板、勾选完关闭后进入,只锁勾选的那几种鱼。
/// ⭐ 只有 true 时白名单(selectedSet)才参与判断;关掉锁定开关即退出该模式。
/// </summary>
private bool _suoDingMode = false;
/// <summary>
/// ⭐ 单独锁定白名单(SuoDingPanel):是否允许自动锁定这条鱼。
/// - 非"图鉴锁定"模式 → 不限制(正常锁定想锁什么就锁什么)
/// - 图鉴锁定模式:一个都没勾 → 不限制;勾了至少一条 → 只允许锁勾选的鱼
/// 手动锁定(玩家点击选鱼)不受此限制。
/// ⭐ XJMH 覆写本方法改用原版 PW_SetLock 面板的勾选列表。
/// </summary>
protected virtual bool IsFishAllowedBySuoDing(MonsterContral m)
{
// ⭐ 只有走"长按图鉴"那条路才启用白名单,避免图鉴勾选干扰正常锁定
if (!_suoDingMode) return true;
if (m == null) return false;
var panel = subUIManager.Get<SuoDingPanel>(UIPanel.GameSuoDing);
if (panel == null || !panel.HasSelection) return true;
return panel.IsFishSelected(m.monsterData?.fishTypeConfig?.fishNumber);
}
/// <summary>
/// ⭐ 图鉴/锁定面板关闭时应用勾选结果:
/// 1. 一条都没勾选 → **不开启锁定功能**(锁定开关/图鉴模式/自动锁定全部保持原样,直接返回)
/// 2. 有勾选 → 打开锁定开关 + 激活自动锁定 + 进入"图鉴锁定"模式(之后只锁勾选的鱼)
/// 白名单由面板勾选列表实时同步;自动锁定仍按原有优先级
/// (特殊Boss > Boss鱼 > 特殊鱼 > 黄金鱼)只从选中的鱼里选目标,选中的鱼死光则不锁定。
/// </summary>
public void ApplySuoDingSelection()
{
// ⭐ 一条都没勾选 → 不开启锁定功能:不碰锁定开关、不进图鉴模式、不激活自动锁定
if (!HasSuoDingSelection())
{
_suoDingMode = false;
return;
}
// 打开锁定开关(已打开则不变;关闭时 onValueChanged 的重置逻辑只在 !on 分支,不受影响)
if (toggle_Lock != null && !toggle_Lock.isOn)
toggle_Lock.isOn = true;
// ⭐ 有勾选 → 进入"图鉴锁定"模式:之后的锁定只从勾选的鱼里选目标
_suoDingMode = true;
// ⭐ 面板选完即应用:自动锁定立即开始找目标
_autoLockActivated = true;
// ⭐ 进入图鉴模式后,把当前手动锁定的目标转为自动锁定(交给白名单筛选)
if (currSitData != null && currSitData.isManualLock)
currSitData.isManualLock = false;
}
/// <summary>
/// ⭐ 图鉴/锁定面板当前是否勾选了至少一条鱼(决定关闭面板时是否开启锁定功能)。
/// 通用 = 通用 SuoDing 面板的勾选;XJMH 覆写为原版 PW_SetLock 面板的勾选列表。
/// </summary>
protected virtual bool HasSuoDingSelection()
{
var panel = subUIManager.Get<SuoDingPanel>(UIPanel.GameSuoDing);
return panel != null && panel.HasSelection;
}
private void HandleAutoLock()
{
// ⭐ 加载期间 currSitData 尚未赋值(座位信息推迟到加载到 100%)→ 跳过,避免空引用
if (currSitData == null) return;
// ⭐ 退出倒计时期间屏蔽本地玩家自动锁定,其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
// ⭐ 特殊鱼爆奖(钻头/激光)期间禁用自动锁定:锁定目标让位给炮台瞄准方向
if (currSitData != null && currSitData.isDrillActive) return;
// ⭐ 自动锁定必须在锁定开关开启时才工作
if (!toggle_Lock.isOn) return;
// ⭐ 前提条件:玩家必须先手动锁定一条鱼来激活自动锁定
// 激活后,当锁定的鱼死亡,自动转换到下一个目标
if (!_autoLockActivated) return;
// ⭐⭐⭐ 三发模式:每个锁位由 CheckLockTargetValid 逐位独立维护(死一条只补那一条,
// 空位每帧持续检测),这里不插手
if (currSitData.tripleLockTargets.Count > 0) return;
// ⭐⭐⭐ 换目标规则(当前使用:只有 手动点选 / 走完路线 / 目标死亡 才换):
// ① 手动锁定 → 玩家自己点的鱼,离场前一直跟,死了才换
// ② 自动锁定,路径仍在可锁范围(0.1~0.9) → 保持锁定
// ⭐ 此时场上就算出现更高级的鱼也绝不抢,只有当前这条被打死(或被回收)后才换
// (换的那一刻 FindAutoLockTarget 会按优先级优先选高级鱼)
// ③ 自动锁定,路径离场(超出 0.1~0.9) 或 被 SuoDing 取消勾选 → 落到下面重新找目标
if (currSitData.isLock &&
currSitData.monCotl_Lock != null &&
currSitData.monCotl_Lock.gameObject.activeInHierarchy)
{
// 手动锁定:死了才转换(玩家自己点的鱼,不受白名单限制)
if (currSitData.isManualLock) return;
// 目标活着且还没走完路线 → 保持锁定,不因"出现更高级的鱼"而乱换
if (IsFishPathLockable(currSitData.monCotl_Lock)
&& IsFishAllowedBySuoDing(currSitData.monCotl_Lock))
return;
// 路径离场(超出 0.1~0.9) 或 被取消勾选(不在白名单) → 落到下面重新找目标
}
/* ==================== 立即升级版本(2026-10-06 注释保留,方便回退) ====================
// 目标活着时也做"优先级升级":场上出现更高优先级的鱼 → 立即抢过来锁定
// ⚠ 这个版本会在你没打死当前鱼时就把锁定抢走,表现为"实时切换"
if (currSitData.isLock &&
currSitData.monCotl_Lock != null &&
currSitData.monCotl_Lock.gameObject.activeInHierarchy)
{
if (currSitData.isManualLock) return;
if (IsFishPathLockable(currSitData.monCotl_Lock)
&& IsFishAllowedBySuoDing(currSitData.monCotl_Lock))
{
if (!HasHigherPriorityTarget(currSitData.monCotl_Lock)) return;
}
}
==================== 立即升级版本结束 ==================== */
// 当前锁定目标已死亡/失效 → 自动找下一个目标
MonsterContral target = FindAutoLockTarget();
if (target != null)
{
// ⭐ 自动锁定:useHitCollider=false → lockPoint → colliderList → null
int colliderIndex;
Transform lockTr = ResolveLockTransform(target, out colliderIndex);
// ⭐ lockPoint 和 colliderList 全部未激活(出场效果中) → 不锁定,等下一帧再找
if (lockTr == null) return;
// ⭐ 标记为自动锁定:受路径进度(0.1~0.9)限制
currSitData.isManualLock = false;
currSitData.SetLockTarget(target, lockTr);
SyncLock(new List<string> { target?.guid }, colliderIndex);
// ⭐ 切换日志:输出新目标类型和优先级,方便验证优先级规则(特殊Boss5>Boss4>特殊鱼3>黄金鱼2>普通鱼1)
Debug.Log($"[自动锁定] 切换目标 → {target.gameObject.name} 类型={target.fishenum} 优先级={GetFishLockPriority(target)}");
// ⭐ 切换目标瞬间立即更新主锁定图标(鱼还活着就一定显示)
if (currSitData.lockFX != null && target.gameObject.activeInHierarchy)
{
if (!currSitData.lockFX.activeSelf) currSitData.lockFX.SetActive(true);
// ⭐ z 轴固定 -50(与每帧 UpdateSingleLockFX 一致):在爆奖特效(z=-100)前面
var p = lockTr.position;
p.z = LockFXFixedZ;
currSitData.lockFX.transform.position = p;
}
}
else
{
// 场上没有可锁定目标 → 清除锁定(仅在当前有锁时清除,避免每帧广播)
if (currSitData.isLock)
{
currSitData.ClearLock();
SyncTurretState(TurretState.Idle);
SyncLock(null);
}
}
}
private MonsterContral FindAutoLockTarget()
{
if (CreateFishManager.Instance == null ||
CreateFishManager.Instance.fishDatas_hashCodekey == null ||
CreateFishManager.Instance.fishDatas_hashCodekey.Count == 0)
return null;
// ⭐ 用世界坐标(Z=0平面),和 bot AI 一致,否则距离计算会异常(出现 3.4E+38 之类)
Vector3 origin = currSitData.turretWorldPos;
// ⭐ 规则:严格按优先级分层,高价值到低价值:
// 特殊Boss(5) > Boss鱼(4) > 特殊鱼(3) > 黄金鱼(2) > 普通鱼(1)
// - 特殊Boss/Boss鱼/特殊鱼:同层取距离最近
// - 黄金鱼/普通鱼:各自独立分层,同层随机选一只
// 高层有目标时绝不选低层
MonsterContral best = null;
int bestPri = -1;
float bestSqr = float.MaxValue;
var goldList = new List<MonsterContral>(); // 黄金鱼(2) 单独一层
var smallList = new List<MonsterContral>(); // 普通鱼(1) 单独一层
foreach (var kv in CreateFishManager.Instance.fishDatas_hashCodekey)
{
MonsterContral m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
// ⭐ 单独锁定白名单(SuoDing):面板勾选了鱼时只从选中的鱼里选目标
if (!IsFishAllowedBySuoDing(m)) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的鱼不作为自动锁定目标
if (!IsFishPathLockable(m)) continue;
// ⭐ 可锁定过滤:出场效果期间/needLockEvent 鱼未就绪 → 跳过换下一条
if (!IsMonsterLockable(m)) continue;
int pri = GetFishLockPriority(m);
// 高价值组:特殊Boss(5) / Boss鱼(4) / 特殊鱼(3) → 优先级 + 距离最近
if (pri >= 3)
{
float sqr = (m.transform.position - origin).sqrMagnitude;
if (pri > bestPri || (pri == bestPri && sqr < bestSqr))
{
bestPri = pri;
bestSqr = sqr;
best = m;
}
}
// ⭐ 黄金鱼(2) / 普通鱼(1) 分开收集,不再混在一个列表里
else if (pri == 2)
{
goldList.Add(m);
}
else
{
smallList.Add(m);
}
}
// ① 特殊Boss/Boss鱼/特殊鱼 有目标 → 返回最高价值(同价值最近)的目标
if (best != null) return best;
// ② 没有特殊鱼及以上 → 黄金鱼层随机选一只
if (goldList.Count > 0)
return goldList[Random.Range(0, goldList.Count)];
// ③ 没有黄金鱼 → 普通鱼层随机选一只
if (smallList.Count > 0)
return smallList[Random.Range(0, smallList.Count)];
return null;
}
/// <summary>
/// ⭐ 检查场上是否存在比 current 优先级严格更高的可锁定目标(自动锁定升级用)。
/// 与 FindAutoLockTarget 使用完全相同的过滤条件(活跃/白名单/路径进度/可锁定)。
/// 同优先级不算更高 —— 避免同层"距离最近"目标来回切换导致锁定图标抖动。
/// </summary>
private bool HasHigherPriorityTarget(MonsterContral current)
{
if (current == null || CreateFishManager.Instance == null ||
CreateFishManager.Instance.fishDatas_hashCodekey == null)
return false;
int currentPri = GetFishLockPriority(current);
foreach (var kv in CreateFishManager.Instance.fishDatas_hashCodekey)
{
MonsterContral m = kv.Value;
if (m == null || m == current || !m.gameObject.activeInHierarchy) continue;
// ⭐ 单独锁定白名单(SuoDing):只认选中的鱼,未选中的高优先级鱼不触发升级
if (!IsFishAllowedBySuoDing(m)) continue;
if (!IsFishPathLockable(m)) continue;
if (!IsMonsterLockable(m)) continue;
if (GetFishLockPriority(m) > currentPri) return true;
}
return false;
}
//private void HandleAutoLock()
//{
// if (!currSitData.IsAutoAttackOn) return;
// if (toggle_Lock.isOn) return;
// if (currSitData.isLock) return;
// MonsterContral nearest = CreateFishManager.Instance;
// if (nearest != null)
// {
// currSitData.SetLockTarget(nearest, nearest.transform);
// SyncLock(nearest);
// }
//}
#endregion
#region BulletModule(子弹系统)
public int sitBulletMaxCount = 100;
/// <summary>
/// 检查指定用户是否已达50发子弹上限
/// </summary>
private bool IsBulletLimitReached(string userId)
{
if (bulletParent == null) return false;
int count = 0;
foreach (Transform child in bulletParent.transform)
{
if (child.TryGetComponent<BullitMove>(out var bm) && bm.userid == userId)
count++;
}
if (count >= sitBulletMaxCount)
{
UIManager.Instance.ShowBoardError("子弹已达上限");
return true;
}
return false;
}
/// <summary>
/// 金币不足退出房间弹窗是否可弹出。
/// 弹出后置为 false,仅在金币恢复到能发射最小炮倍时才重置为 true。
/// 不在弹窗关闭时重置——避免点确认后 RequestLeaveRoom 异步等待期间反复弹窗。
/// </summary>
private bool _canShowGoldInsufficientPanel = true;
/// <summary>
/// 获取当前房间最小炮倍(paoBeiList 中的最小值)
/// </summary>
private long GetMinConsumeGold()
{
if (paoBeiList == null || paoBeiList.Count == 0) return 0;
return paoBeiList.Min();
}
/// <summary>
/// 开炮前本地金币校验:三射按 3 发,否则按 1 发计算所需金币。
/// 金币不足则不切换攻击状态、不发送开炮请求,避免无效请求与服务端拒绝。
/// ⭐ 当金币不足以发射最小炮倍时,弹窗询问是否退出房间:
/// 确认 → 走退出房间逻辑;X号 → 关闭面板。本地判断,不发送开炮请求。
/// 通过 _canShowGoldInsufficientPanel bool 保证唯一性:
/// 弹出后设 false,仅在金币恢复到够最小炮倍时才重置 true,
/// 期间无论弹窗是否关闭、RequestLeaveRoom 是否异步等待,都不会再弹。
/// </summary>
private bool HasEnoughGoldToFire(bool isSanShe, bool isDouble = false)
{
if (currSitData == null) return false;
int count = isSanShe ? 3 : 1;
// ⭐ 双倍开火每发按 2 倍炮倍扣费(与 SyncFire 的 unitGold 对齐),
// 否则金币只够单倍时仍发双倍请求,后端扣费失败/扣成负数
long need = currSitData.consumeGoldNum * count * (isDouble ? 2 : 1);
if (currSitData.goldNum >= need)
{
// 金币够当前炮倍:可正常开火,重置弹窗标志
_canShowGoldInsufficientPanel = true;
return true;
}
// ⭐ 金币不够发射(散射按3发/单发按1发)→ 取消锁定,不允许继续锁定攻击
// toggle_Lock.isOn = false 会触发 onValueChanged → ClearLock + 同步服务器,
// 之后 HandleAutoFire 的 needAutoFire 因 toggle_Lock 关闭而变 false,停止自动锁定开火
if (toggle_Lock != null && toggle_Lock.isOn)
{
toggle_Lock.isOn = false;
}
// 金币不足当前炮倍:进一步判断是否连最小炮倍都发射不起
long minConsume = GetMinConsumeGold();
if (minConsume <= 0 || currSitData.goldNum >= minConsume)
{
// 金币够最小炮倍:玩家可降炮倍继续游戏,重置弹窗标志
_canShowGoldInsufficientPanel = true;
return false;
}
// ⭐ 金币不足以发射最小炮倍 → 弹窗询问是否退出房间
// 关键:只在金币恢复后才重置 bool,不在弹窗关闭时重置,
// 避免 RequestLeaveRoom 异步等待期间反复弹窗
if (_canShowGoldInsufficientPanel)
{
_canShowGoldInsufficientPanel = false;
UIManager.Instance.ShowTiShi(
"金币不足,是否退出房间?",
() => RequestLeaveRoom(),
true);
}
return false;
}
private BullitMove CreateBullet(SitPosData sit, Vector2 pos, float angle, bool isDouble, bool isLock, string id,
MonsterContral lockTarget = null, Transform lockHitPos = null, long fireConsumeGold = 0)
{
// ⭐ 战神装备中:子弹用战神的子弹预制体,不用炮台的
GameObject prefab = null;
if (sit.currentZhanShenData != null)
prefab = isDouble ? sit.currentZhanShenData.bulletPrefab_Two : sit.currentZhanShenData.bulletPrefab;
// ⭐ 战神没配子弹/没装备战神 -> 回退炮台子弹(远程座位炮台未加载时补加载)
if (prefab == null)
{
if (sit.currentTurret == null)
{
sit.LoadTurret(sit.currentTurretNumber);
if (sit.currentTurret == null) return null;
}
prefab = isDouble ? sit.currentTurret.bulletPrefab_Two : sit.currentTurret.bulletPrefab;
}
// ⭐ 每座位最多100发子弹(先校验,不扣钱)
int activeBullets = 0;
foreach (Transform child in bulletParent.transform)
{
if (child.TryGetComponent<BullitMove>(out var bm) && bm.userid == sit.user)
activeBullets++;
}
if (activeBullets >= sitBulletMaxCount)
{
UIManager.Instance.ShowBoardError($"[子弹上限] 座位{sit.posID}已达{sitBulletMaxCount}发上限");
return null;
}
if (!sit.ConsumeGold(isDouble ? 2 : 1, fireConsumeGold))
return null;
// ⭐ 从对象池取子弹(池空时内部会 Instantiate)
var bulletGo = BulletPool.Instance.Get(prefab, pos, Quaternion.Euler(0, 0, angle), bulletParent.transform);
if (bulletGo == null) return null;
var bullet = bulletGo.GetComponent<BullitMove>();
// ⭐ 池化复用时必须重新记录起点(Init 会把 vector3_startPosition 对齐到当前位置,z=-400)
// 否则 Update 里 transform.position = vector3_startPosition + ... 会用回收时的旧起点
bullet.Init();
bullet.userid = sit.user;
bullet.playerID = sit.user;
bullet.bulletID = id;
// ⭐ 记录该发实际扣费炮倍(服务端权威金额优先,回退本地计算:双倍×2),
// 击中时 BullitMove.AttackFish 用它作 KillFish 的 paoBei → 双倍炮奖励×2
bullet.fireGold = fireConsumeGold > 0 ? fireConsumeGold : sit.consumeGoldNum * (isDouble ? 2 : 1);
if (isLock)
{
if (lockTarget != null)
bullet.Lock(lockTarget, lockHitPos);
else if (sit.monCotl_Lock != null && sit.monCotl_Lock_Position != null)
bullet.Lock(sit.monCotl_Lock, sit.monCotl_Lock_Position);
// 锁定目标或位置为 null → 不锁定,按普通子弹发射
}
return bullet;
}
#endregion
#region ⑥ 网络同步(NetSyncModule)
private void SyncRotate(float angle)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.rotateTurrect,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
// ⭐ 座位镜像:网络角度统一用"源角度"(下方座位系,0=朝桌面中心)。
// 本地玩家永远显示在下方座位(IsFlipped=false),这里实际是原值;
// 保留转换以兼容未来本地座位可能在上方的情况
frameData = new FrameData_Rotate { angle = SeatMirror.WorldAngleToSource(angle, currSitData.IsFlipped) }
};
WebSocketSendList.SyncGameData(sgd);
}
private void SyncFire(float angle, bool isSanShe, bool isDouble, bool isLock)
{
var fireData = new FrameData_FireBullet
{
// ⭐ 座位镜像:网络角度统一用"源角度"(下方座位系),接收端按显示座位还原
angle = SeatMirror.WorldAngleToSource(angle, currSitData.IsFlipped),
gold = currSitData.goldNum,
isSanShe = isSanShe,
twoRTruuect = isDouble,
isSuoDing = isLock,
auto = currSitData.IsAutoAttackOn
};
// ⭐ 战神 三发 + 锁定:3个锁定目标写入 monsterIDs(FireLaser 分散打三个用)
// ⭐ 优先用当前维护的3个锁位(玩家手动点选的目标) → 打哪锁哪
// 锁位上没有有效目标时才按优先级现找。正常炮台三合一打主锁目标
// (monCotl_Lock 已由 SyncLock/SyncBotLock 同步),不需要 monsterIDs
if (isSanShe && isLock && currSitData.currentZhanShenData != null)
{
var lockedGuids = currSitData.GetTripleLockGuids();
if (lockedGuids.Count > 0)
{
fireData.monsterIDs = lockedGuids;
}
else
{
var targets = FindFireTargets(3);
if (targets.Count > 0)
fireData.monsterIDs = targets.Select(t => t.monster.guid).ToList();
}
}
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.fireBullet,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = fireData
};
//EnqueueSync(sgd);
var roomdata = GameManager.Instance.gameData.currRoomData;
var user = GameManager.Instance.user;
// ⭐ 双倍开火:一发子弹按 2 倍炮倍扣费。FireCannon 后端总扣费 = gold(单价) × count(发数),
// 双倍体现在单价×2(子弹仍是一颗),与本地 ConsumeGold(isDouble?2:1) 对齐,
// 否则后端只扣单倍,本地显示被后端权威数据覆盖 → 消耗没有×2
long unitGold = currSitData.consumeGoldNum * (isDouble ? 2 : 1);
// 三射 = 3 发,否则 = 1 发
int count = isSanShe ? 3 : 1;
// ⭐ bulletIds 由服务端生成并通过 RoomFireNotice 广播回来,客户端不需要传
long timespan = BeijingTimeManager.Instance.CurrentTimestampMs; // 毫秒
long timestamp = timespan / 1000; // 秒级
string sign = WebSocketSendList.MakeFireCannonSign(
roomdata.roomId, user.id, unitGold, count, timestamp, "63215f0afa3f5b25");
// FireCannon:roomId, userId, gold, count, timestamp, sign, extra
WebSocketSendList.FireCannon(
roomdata.roomId,
user.id,
unitGold,
count,
timestamp,
sign,
sgd
);
}
private void SyncLock(List<string> monsterguid, int index = -1)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeAutoTarget,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeAutoTarget
{
targetGuid = monsterguid,
colliderIndex = index,
}
};
WebSocketSendList.SyncGameData(sgd);
}
private void SyncTurretState(TurretState state)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeTurretState,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_TurretState { state = state }
};
WebSocketSendList.SyncGameData(sgd);
}
public void SyncChangeTurret(int turretNumber)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeTurret,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeTurret { turretIndex = turretNumber }
};
WebSocketSendList.SyncGameData(sgd);
}
public void SyncChangeWing(int wingNumber)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeWing,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeTurret { turretIndex = wingNumber }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>广播战神切换(changeZhanShen):其他客户端在对应座位加载/清除战神模型</summary>
public void SyncChangeZhanShen(int zhanShenNumber)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeZhanShen,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeTurret { turretIndex = zhanShenNumber }
};
WebSocketSendList.SyncGameData(sgd);
}
private void SyncFreeze(bool isFreeze)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.freezeFish,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_FreezeFish { isFreeze = isFreeze }
};
WebSocketSendList.SyncGameData(sgd);
}
#endregion
#region ⑦ 服务器广播处理(NetReceiveModule)
public void SyncGameData_Rotate(SyncGameData sync)
{
// ⭐ 座位镜像:网络 sitPos 是服务端空间,先转显示座位再比较/索引
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var sit = list_Sits[dispPos];
// ⭐ 房主不处理机器人旋转广播:房主在 RotateBotToTarget 里已直接旋转本地炮台,
// 再走 SmoothRotate 会和本地直接旋转冲突
if (sit != null && sit.isBot && currSitData == SitPosData.roomOwnerSit) return;
var data = FrameDataParser.Parse<FrameData_Rotate>(sync.frameData);
// ⭐ 座位镜像:网络角度是"源角度"(发送方下方座位系,0=朝桌面中心),
// 按该座位在本端场景的显示位置还原成世界角度:
// 显示座位在下方 → 原样;在上方 → 180−源角度(上下翻转,左右不反)
SmoothRotate(sit, SeatMirror.SourceAngleToWorld(data.angle, sit.IsFlipped));
}
public void SyncGameData_FireBullit(FireCannonSendData sync)
{
// ⭐ 跳过本地玩家(本地玩家开火由 SyncFire → 服务器广播回来后创建子弹,
// 但如果服务器不回广播给开火者,本地玩家需要在 SyncFire 里直接 FireBullet)
// 实际设计:本地玩家也通过广播回来创建子弹,所以不跳过
// ⭐ 从服务端权威数据提取每发子弹扣费金额
// 1. 优先用 bullets 字段(type:3 调用返回值,包含每发子弹的扣费金额)
// 2. fallback 用 sync.gold(RoomFireNotice 广播里的炮倍,服务端权威)
List<long> bulletGolds = null;
if (sync.bullets != null && sync.bullets.Count > 0)
{
bulletGolds = new List<long>(sync.bullets.Count);
foreach (var bg in sync.bullets)
bulletGolds.Add(bg.gold);
}
else if (sync.gold > 0 && sync.bulletIds != null && sync.bulletIds.Count > 0)
{
// RoomFireNotice 没有 bullets 字段,用 sync.gold(服务端权威炮倍)作为每发扣费金额
bulletGolds = new List<long>(sync.bulletIds.Count);
for (int i = 0; i < sync.bulletIds.Count; i++)
bulletGolds.Add(sync.gold);
}
// extra 为空:从 botLastFireData 或默认参数还原(bot 或真人玩家都可能走到这里)
if (sync.extra == null || sync.extra.frameData == null)
{
SitPosData sit = list_Sits.Find(s => s.user == sync.userId);
if (sit == null) return;
// ⭐ 攻击心跳:这个座位刚发了开火消息(bot 的状态广播是节流的,只有开火消息每发都来)
remoteAttackHeartbeat[sit.posID] = Time.time;
// ⭐ 座位镜像:botLastAngle/botLastFireData 缓存的是"源角度"(下方座位系);
// 真人兜底用 sit.currentAngle(本端世界角度,无需转换)
bool angleFromBotMemory = botLastAngle.TryGetValue(sync.userId, out float a);
float angle = angleFromBotMemory ? a : sit.currentAngle;
if (sync.bulletIds == null || sync.bulletIds.Count == 0) return;
// ⭐ 优先用服务器广播的 sync.isBot 标记判断是否机器人
// bot 从 botLastFireData 还原完整技能参数;真人玩家用默认参数
bool isBot = sync.isBot || sit.isBot;
if (isBot && botLastFireData.TryGetValue(sync.userId, out var fireData))
{
// ⭐ 座位镜像:源角度按该 bot 显示座位还原成世界角度再创建子弹
FireBullet(sit, SeatMirror.SourceAngleToWorld(fireData.angle, sit.IsFlipped),
fireData.isSanShe, fireData.twoRTruuect,
fireData.isSuoDing, sync.bulletIds, fireData.monsterIDs, bulletGolds);
}
else if (angleFromBotMemory)
{
// bot 有角度缓存但无完整开火数据 → 同样按源角度转换
FireBullet(sit, SeatMirror.SourceAngleToWorld(angle, sit.IsFlipped),
false, false, false, sync.bulletIds, null, bulletGolds);
}
else
{
FireBullet(sit, angle, false, false, false, sync.bulletIds, null, bulletGolds);
}
return;
}
var data = FrameDataParser.Parse<FrameData_FireBullet>(sync.extra.frameData);
// ⭐ 座位镜像:extra.sitPos 是发送方的服务端座位,转显示座位再索引
// ⭐ 边界检查:防止 sitPos 越界导致 IndexOutOfRangeException
int dispPos = SeatMirror.ToDisplay(sync.extra.sitPos);
if (dispPos < 0 || dispPos >= list_Sits.Count)
{
Debug.LogWarning($"[SyncGameData_FireBullit] sitPos 越界: {sync.extra.sitPos}");
return;
}
var targetSit = list_Sits[dispPos];
// ⭐ 攻击心跳:这个座位刚发了开火消息(真人每发都走这里,等于"还在攻击"的心跳)
remoteAttackHeartbeat[targetSit.posID] = Time.time;
// ⭐⭐ 战神散射(三发+锁定):发送端每发都带 isSanShe/isSuoDing 和3个激光目标GUID(monsterIDs)
// → 其他玩家的座位跟着补齐3个锁位 + 刷新散射标记:3条激光 + 2个散射起点球
// (JGFXLeftParent/JGFXRightParent) 一起显示。
// ⭐ 这条路是"自动凑满3个目标"时唯一的信号(自动补位/自动凑目标不会走锁定同步),
// 只在锁定同步里处理的话,不开手动点鱼别人就看不到散射表现。
// ⚠ 只处理别人的座位:本地座位自己的三发锁位由本地锁定系统维护,不能被网络数据覆盖
if (targetSit != currSitData && targetSit.currentZhanShenData != null)
{
bool scatter = data.isSanShe && data.isSuoDing;
if (targetSit.isScatterLaserOn != scatter)
{
targetSit.isScatterLaserOn = scatter;
// 退出散射:清掉远端多出来的锁位(主锁保留,仍显示1条激光 + 主锁定圈)
if (!scatter) targetSit.HideTripleLockIcons();
}
if (scatter) ApplyTripleLockSlotsFromGuids(targetSit, data.monsterIDs);
}
// ⭐ 座位镜像:网络角度是"源角度"(发送方下方座位系),按该座位显示位置还原成世界角度
FireBullet(targetSit, SeatMirror.SourceAngleToWorld(data.angle, targetSit.IsFlipped),
data.isSanShe, data.twoRTruuect, data.isSuoDing, sync.bulletIds, data.monsterIDs, bulletGolds);
}
/// <summary>
/// 机器人角度同步:接收 SyncFire_ForBot 发出的 SyncGameData(fireBullet),
/// 将角度存入 botLastAngle,供 RoomFireNotice 到达时 SyncGameData_FireBullit 使用
/// </summary>
public void SyncGameData_BotFireAngle(SyncGameData sync)
{
if (sync == null || sync.frameData == null) return;
// ⭐ 座位镜像:bot 的服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos < 0 || dispPos >= list_Sits.Count) return;
var sit = list_Sits[dispPos];
if (sit == null || !sit.isBot) return;
var fireData = FrameDataParser.Parse<FrameData_FireBullet>(sync.frameData);
if (fireData == null) return;
botLastAngle[sit.user] = fireData.angle;
botLastFireData[sit.user] = fireData; // 存储完整 fireData 供 SyncGameData_FireBullit 还原技能状态
}
public void SyncGameData_ChangeTargetMonster(SyncGameData sync)
{
// ⭐ 座位镜像:网络 sitPos 是服务端空间,先转显示座位再比较/索引
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeAutoTarget>(sync.frameData);
var sit = list_Sits[dispPos];
if (data.targetGuid == null || data.targetGuid.Count == 0)
{
sit.ClearLock();
return;
}
// ⭐⭐ 战神三发散射:发送端把3个锁位一起同步过来([0]=主锁目标,[1]/[2]=散射目标)。
// 以前只取了 [0],远端锁位永远只有1个 → 远端激光只有1条,
// 散射左右起点球(JGFXLeftParent/JGFXRightParent)不激活 → 别人的视角看不到两个小球。
// 这里按整份列表补齐远端锁位:远端表现与发送端一致(3条激光 + 2个起点球 + 2个额外锁定圈)。
// 注:统一用 ResolveLockTransform(colliderList 第一个活跃 → lockPoint → transform),
// 网络同步过来的 colliderIndex 不再使用(客户端之间 colliderList 顺序可能不一致)
var targets = new List<(MonsterContral monster, Transform hitPos)>();
foreach (var guid in data.targetGuid)
{
if (string.IsNullOrEmpty(guid)) continue;
if (!CreateFishManager.Instance.fishDatas_hashCodekey.TryGetValue(guid, out var m) || m == null) continue;
targets.Add((m, ResolveLockTransform(m)));
}
if (targets.Count > 1)
{
sit.SetTripleLockTargets(targets);
}
else
{
// 单发锁定:清掉三发锁位(远端不再显示散射起点球/额外激光/额外锁定圈)
sit.HideTripleLockIcons();
if (targets.Count == 1)
sit.SetLockTarget(targets[0].monster, targets[0].hitPos);
else
sit.ClearLock();
}
}
/// <summary>
/// ⭐ 按 guid 列表给座位补齐"三发锁位"(远端散射表现用):
/// 有效目标>1 → SetTripleLockTargets(3条激光 + 2个散射起点球 + 2个额外锁定圈一起出来);
/// 否则清掉三发锁位(该座位按单发显示,主锁不动)。
/// ⚠ 只给其他玩家的座位用:本地座位的三发锁位由本地锁定系统维护,绝不能被网络数据覆盖。
/// </summary>
private void ApplyTripleLockSlotsFromGuids(SitPosData sit, List<string> guids)
{
var targets = new List<(MonsterContral monster, Transform hitPos)>();
if (guids != null)
{
foreach (var guid in guids)
{
if (string.IsNullOrEmpty(guid)) continue;
if (!CreateFishManager.Instance.fishDatas_hashCodekey.TryGetValue(guid, out var m) || m == null) continue;
if (m.isDead || !m.gameObject.activeInHierarchy) continue;
targets.Add((m, ResolveLockTransform(m)));
}
}
if (targets.Count > 1)
sit.SetTripleLockTargets(targets);
else
sit.HideTripleLockIcons();
}
public void SyncGameData_RendromDieFish(SyncGameData sync)
{
var data = JsonConvert.DeserializeObject<FrameData_RandomDieFish>(sync.frameData.ToString());
// 用 sync.sitPos 判断是否本地玩家发的(服务器不会丢这个字段)
// ⭐ 座位镜像:服务端座位转显示座位后再与本地显示座位比较
bool isLocalSender = SeatMirror.ToDisplay(sync.sitPos) == currSitData.posID;
CreateFishManager.Instance.Id_DieListFish_FX(
data.guids,
data.coinNum,
new Vector3(data.x, data.y, data.z),
data.count,
isLocalSender
);
}
public void SyncGameData_TurretState(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_TurretState>(sync.frameData);
var sit = list_Sits[dispPos];
// ⭐ 攻击心跳:真人每次开火都会广播一次 Attack 状态(SyncTurretState(Attack) 在开火循环里),
// 等于"这个座位还在攻击"的心跳。刷新它,看门狗就不会误收回正在攻击的座位。
if (data.state == TurretState.Attack)
remoteAttackHeartbeat[sit.posID] = Time.time;
sit.ChangeState(data.state);
}
public void SyncGameData_ChangeTurret(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
list_Sits[dispPos].LoadTurret(data.turretIndex);
}
public void SyncGameData_ChangeWing(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
// turretIndex<=0 = 卸下翅膀;>0 = 加载对应编号翅膀(与战神 changeZhanShen 同一约定)
if (data.turretIndex > 0)
list_Sits[dispPos].LoadWing(data.turretIndex);
else
list_Sits[dispPos].ClearWing();
}
public void SyncGameData_ChangeZhanShen(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
// turretIndex<=0 = 卸下战神;>0 = 加载对应编号战神(内部会隐藏该座位的普通炮台)
if (data.turretIndex > 0)
list_Sits[dispPos].LoadZhanShen(data.turretIndex);
else
list_Sits[dispPos].ClearZhanShen();
}
public void SyncGameData_ChangeCannonMultiple(SyncGameData sync)
{
// ⭐ 座位镜像:服务端座位转显示座位
int dispPos = SeatMirror.ToDisplay(sync.sitPos);
if (dispPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeCannonMultiple>(sync.frameData);
var sit = list_Sits[dispPos];
sit.consumeGoldNum = data.cannonMultiple;
sit.SetTurretConsumeNumText(data.cannonMultiple);
}
/// <summary>
/// 应用服务端确认的炮倍变更(ChangeGunMultipleNotice)。
/// 本地玩家切换炮倍后,等服务端广播此通知才更新 consumeGoldNum 和 UI,
/// 确保开火扣费时本地炮倍与服务端一致。
/// </summary>
public void ApplyCannonMultiple(string userId, int multiple)
{
var sit = list_Sits.Find(s => s.user == userId);
if (sit == null) return;
sit.consumeGoldNum = multiple;
sit.SyncIndexByConsume(multiple);
sit.SetTurretConsumeNumText(multiple);
}
/// <summary>
/// 爆奖结束帧同步(baoJiangEnd):
/// 其他玩家爆奖流程完成,从 pending 取出对应 not-self 爆奖,
/// 显示最终 BJEndUI(开 tMP_beishu / 关 img_BaoJiangZhong)并加分。
/// 自己触发的爆奖不在这里处理(self 流程在 BJEventBase 内部已自行结束)。
/// </summary>
public void OnBaoJiangEnd(SyncGameData sync)
{
var data = FrameDataParser.Parse<FrameData_BaoJiangEnd>(sync.frameData);
if (data == null)
{
Debug.LogWarning("[GameMainPanel_FishBase] OnBaoJiangEnd: frameData 解析失败");
return;
}
// 自己触发的爆奖不在这里处理(self 流程已自行走完)
if (data.userId == GameManager.Instance.user.id) return;
BaoJiangNotSelfHandler.OnBaoJiangEnd(data.userId, data.fishId, data.beishu, data.score);
}
public void SyncCannonMultiple(int consumeGold)
{
if (currSitData == null) return;
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(currSitData.posID),
frameType = FrameDataType.changeCannonMultiple,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeCannonMultiple { cannonMultiple = consumeGold }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>
/// UsePropNotice 回调:服务器确认道具使用后,激活对应效果
/// </summary>
public void OnUsePropNotice(string userId, string propNumber)
{
switch (propNumber)
{
case "40004": // 暴走 — 个人效果,只有使用者自己生效
// ⭐ 狂暴是个人增益(攻速翻倍+炮台动画),不是全房间效果。
// 服务器会把 UsePropNotice 广播给房间内所有人,必须判断 userId
// 否则别人用狂暴,本地也开了狂暴
if (userId != GameManager.Instance.user.id) break;
_localBaoZouCount--;
UpdateSkillPropsUI();
CreateFishManager.Instance.currGameType.isFrenzy = true;
SetFrenzy(true);
currFrenzyTime = frenzyDuration;
toggle_BaoZou.SetIsOnWithoutNotify(true);
break;
case "40003": // 冰封
{
// ⭐ 新协议:冰冻房间效果由服务端 FishFrozen 事件统一触发,
// UsePropNotice(40003) 只处理个人效果(数量/toggle)
// 房间效果(冻结鱼/暂停动画/冰冻特效)在 OnFishFrozen 中执行
bool isLocalSender = userId == GameManager.Instance.user.id;
if (isLocalSender)
{
_localBingFengCount--;
UpdateSkillPropsUI();
// 倒计时和 toggle 由 OnFishFrozen 统一启动,这里不重复设置
}
break;
}
}
}
/// <summary>
/// 服务端 FishFrozen 事件回调:冰冻道具(40003)生效。
/// 鱼存活时间延长 15 秒,客户端写死 15s 倒计时。
/// 所有客户端都执行(房间效果)。
/// </summary>
public void OnFishFrozen()
{
// 启动 15s 倒计时
currBingFengTime = bingFengDuration;
toggle_BingFeng.SetIsOnWithoutNotify(true);
// 冰冻特效
if (bingFengEffect != null)
{
if (_bingFengInstance == null)
_bingFengInstance = Instantiate(bingFengEffect);
_bingFengInstance.SetActive(true);
}
// 冻结所有鱼 + 暂停动画
CreateFishManager.Instance.FreezeAllFish();
PauseAllFishAnimations();
}
// SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发
// SyncFreeze 已废弃:不再需要客户端广播冰冻状态
#endregion
#region 冰冻动画暂停/恢复
private void PauseAllFishAnimations()
{
// ⭐ 守卫:已暂停时直接返回,避免重复调用覆盖已记录的速度
// (其他客户端会先后被 OnUsePropNotice 和 SyncGameData_FreezeFish 触发两次,
// 第二次会记录 speed=0,解冻时恢复成 0 → 动画永远暂停)
if (_frozenAnimSpeeds.Count > 0 || _frozenDirectors.Count > 0) return;
var root = CreateFishManager.Instance.fishRoot;
if (root == null) return;
_frozenAnimSpeeds.Clear();
_frozenDirectors.Clear();
// ⭐ 先收集“受 Timeline 驱动”的 Animator:它们必须跳过 speed=0 的处理
CollectTimelineDrivenAnimators(root);
foreach (var s in root.GetComponentsInChildren<Spine.Unity.SkeletonAnimation>(true))
{
if (!s.gameObject.activeInHierarchy) continue;
_frozenAnimSpeeds[s] = s.timeScale;
s.timeScale = 0f;
}
foreach (var a in root.GetComponentsInChildren<Animator>(true))
{
if (!a.gameObject.activeInHierarchy) continue;
// ⭐ Timeline 驱动的 Animator 不能设 speed=0:会让 Timeline 动画输出失效 →
// 模型回退到默认姿态(雷霆之主冰冻后显示默认状态的根因)。由下面的 director 定格
if (_timelineDrivenAnimators.Contains(a)) continue;
_frozenAnimSpeeds[a] = a.speed;
a.speed = 0f;
}
foreach (var an in root.GetComponentsInChildren<Animation>(true))
{
if (!an.gameObject.activeInHierarchy || !an.isPlaying) continue;
// ⭐ 记录每个 AnimationState 的原 speed,解冻时恢复原值而非硬编码 1f
var stateSpeeds = new List<float>();
foreach (AnimationState state in an)
{
stateSpeeds.Add(state.speed);
state.speed = 0f;
}
_frozenAnimSpeeds[an] = stateSpeeds;
}
foreach (var pd in root.GetComponentsInChildren<UnityEngine.Playables.PlayableDirector>(true))
{
if (!pd.gameObject.activeInHierarchy || pd.playableAsset == null) continue;
// ⭐ Timeline 鱼/Boss(雷霆之主 4052 等)必须定格在“当前帧”:
// ⛔ 不能用 pd.Pause() —— 暂停会把 Timeline 的动画输出交还给 Animator,
// 模型会瞬间回退到默认姿态(而不是暂停那一刻的姿态),且 Evaluate 也救不回来。
// ✅ 正确做法:保持 director 继续播放,只把根 Playable 的速度设为 0。
// 图仍在每帧求值同一帧 → 姿态定格在暂停瞬间,动画输出始终不脱离 Timeline。
var graph = pd.playableGraph;
if (!graph.IsValid()) continue; // 未播放过/已 Stop(图已销毁)无法定格
int rootPlayableCount = graph.GetRootPlayableCount();
_frozenDirectors[pd] = rootPlayableCount > 0 ? graph.GetRootPlayable(0).GetSpeed() : 1d;
for (int i = 0; i < rootPlayableCount; i++)
graph.GetRootPlayable(i).SetSpeed(0d);
// 立即用定格帧刷新一次(delta=0 只应用姿态,不推进时间)
graph.Evaluate();
}
Debug.Log($"[冰冻] 暂停动画完成:Animator定格={_frozenAnimSpeeds.Count} Timeline定格={_frozenDirectors.Count} " +
$"Timeline驱动Animator(跳过speed=0)={_timelineDrivenAnimators.Count}");
}
/// <summary>
/// 收集“受 Timeline 驱动”的 Animator:所属鱼体(fishRoot 的直接子节点)内存在 PlayableDirector。
/// 冰冻时这些 Animator 不能设 speed=0(见 PauseAllFishAnimations 注释),由 director 定格。
/// </summary>
private void CollectTimelineDrivenAnimators(Transform root)
{
_timelineDrivenAnimators.Clear();
foreach (var pd in root.GetComponentsInChildren<UnityEngine.Playables.PlayableDirector>(true))
{
if (pd.playableAsset == null) continue;
// 找到该 director 所属的鱼节点;挂在 fishRoot 本身的不处理(避免波及全部鱼)
Transform fish = pd.transform;
while (fish != root && fish.parent != null && fish.parent != root)
fish = fish.parent;
if (fish == root || fish.parent != root) continue;
foreach (var a in fish.GetComponentsInChildren<Animator>(true))
_timelineDrivenAnimators.Add(a);
}
}
private void ResumeAllFishAnimations()
{
// ⭐ 先还原被定格的 Timeline(根 Playable 速度恢复),Timeline 从未暂停过,无需 Resume
RestoreFrozenDirectors();
if (_frozenAnimSpeeds.Count == 0) return;
foreach (var kv in _frozenAnimSpeeds)
{
if (kv.Key == null) continue;
switch (kv.Key)
{
case Spine.Unity.SkeletonAnimation s: s.timeScale = (float)kv.Value; break;
case Animator a: a.speed = (float)kv.Value; break;
case Animation an:
// ⭐ 恢复每个 AnimationState 的原 speed
if (kv.Value is List<float> stateSpeeds)
{
int i = 0;
foreach (AnimationState state in an)
{
if (i < stateSpeeds.Count)
state.speed = stateSpeeds[i];
i++;
}
}
break;
}
}
_frozenAnimSpeeds.Clear();
}
/// <summary>
/// 冰冻期间某条鱼被击杀:把这条鱼单独从冰冻定格中解除。
/// ⭐ 死亡必须优先于冰冻 —— 不少鱼的"消失"依赖动画流程往前走:
/// - 雷皇龙/金龙(QL/JL)靠 FLZY_BOSS_AnimatorLeave(Animator 状态退出回调)回池,
/// Animator.speed=0 时状态机不推进 → 状态永不退出 → 鱼打死了也不消失
/// - Timeline 驱动的 Boss 靠 Timeline 信号死亡/离场 → 根 Playable 速度 0 时信号不触发
/// - Spine 鱼靠动画播完/动画事件离场 → timeScale=0 时同样卡死
/// 其他活着的鱼保持冰冻不变。
/// </summary>
public void UnfreezeFishAnimations(MonsterContral monster)
{
if (monster == null) return;
// 1) 恢复该鱼自身的动画速度(取出即从冰冻记录移除,避免解冻时二次恢复)
foreach (var s in monster.GetComponentsInChildren<Spine.Unity.SkeletonAnimation>(true))
{
if (_frozenAnimSpeeds.TryGetValue(s, out var v)) { s.timeScale = (float)v; _frozenAnimSpeeds.Remove(s); }
}
foreach (var a in monster.GetComponentsInChildren<Animator>(true))
{
if (_frozenAnimSpeeds.TryGetValue(a, out var v)) { a.speed = (float)v; _frozenAnimSpeeds.Remove(a); }
}
foreach (var an in monster.GetComponentsInChildren<Animation>(true))
{
if (!_frozenAnimSpeeds.TryGetValue(an, out var v)) continue;
if (v is List<float> stateSpeeds)
{
int i = 0;
foreach (AnimationState state in an)
{
if (i < stateSpeeds.Count)
state.speed = stateSpeeds[i];
i++;
}
}
_frozenAnimSpeeds.Remove(an);
}
// 2) 恢复该鱼体内的 Timeline(根 Playable 速度还原 → 时间轴继续推进,信号才能触发)
foreach (var pd in monster.GetComponentsInChildren<UnityEngine.Playables.PlayableDirector>(true))
{
if (!_frozenDirectors.TryGetValue(pd, out var speed)) continue;
var graph = pd.playableGraph;
if (graph.IsValid())
{
for (int i = 0; i < graph.GetRootPlayableCount(); i++)
graph.GetRootPlayable(i).SetSpeed(speed);
}
_frozenDirectors.Remove(pd);
}
// 3) 脱掉冰壳:死亡/离场动画要能看见(冰壳是鱼的子物体,会跟着鱼一起回收)
monster.SetFreezeEffect(false);
}
/// <summary>
/// 还原冰冻期间被定格的 Timeline:恢复根 Playable 的原始速度(重新开始推进)。
/// </summary>
private void RestoreFrozenDirectors()
{
foreach (var kv in _frozenDirectors)
{
var pd = kv.Key;
if (pd == null) continue;
var graph = pd.playableGraph;
if (!graph.IsValid()) continue;
for (int i = 0; i < graph.GetRootPlayableCount(); i++)
graph.GetRootPlayable(i).SetSpeed(kv.Value);
}
_frozenDirectors.Clear();
_timelineDrivenAnimators.Clear();
}
#endregion
#region 玩家加入/退出(PlayerJoinModule)
public void JoinGame(int sitNum, long goldNum, long diamondNum, long consumeGold, int currentTurretIndex, string user = null, string cannonId = null, string wingId = null, string warGodId = null)
{
// ⭐ 座位镜像:调用方传的是服务端座位号(RoomJoinNotice.Position / players[].position),
// 转成本地视角的显示座位再匹配场景座位(本地在上方时 0↔2、1↔3 对调)
int displayPos = SeatMirror.ToDisplay(sitNum);
foreach (var sit in list_Sits)
{
if (sit.posID != displayPos) continue;
// ⭐ 确保座位 GameObject 本身是激活的(可能被 HandleBotExit 隐藏或初始为 inactive)
if (!sit.gameObject.activeSelf)
sit.gameObject.SetActive(true);
sit.user = user ?? GameManager.Instance.user.id;
sit.goldNum = goldNum;
sit.consumeGoldNum = consumeGold;
// ⭐ 玩家显示信息:数据源为 GameManager.gameData.currRoomData.players
// (RoomChangeResponse 用户列表,RoomInfo 消息下发),按 userId 查询
var playerInfo = GameManager.Instance.gameData?.currRoomData?.players?
.Find(p => p.userId == sit.user);
sit.SetUserInfo(sit.user, playerInfo?.userName, playerInfo?.avatar, playerInfo?.vipLevel ?? 0,
playerInfo?.userNumber, playerInfo?.level ?? 0, playerInfo?.exp ?? 0, playerInfo?.maxExp ?? 0);
// ⭐ 记录金币流水:已加钱(进房设置初始金币,本地已执行)
GameManager.LogGoldFlowAppliedGain("JoinGame", $"座位={sitNum} 初始金币={goldNum} 钻石={diamondNum} 炮台消耗={consumeGold} 房间={GameManager.Instance.gameData?.currRoomData?.roomId}");
// ⭐ 同步 indexConsume 到后端下发的 cannonMultiple 对应档位,避免加减炮倍时从 0 档跳变
sit.SyncIndexByConsume(consumeGold);
// ⭐ 在 ShowPlayer 之前标记 isBot,否则 ShowPlayer 里的 AssignRoomOwner 会把 bot 设为房主
if (!string.IsNullOrEmpty(user) && user.StartsWith("bot_"))
sit.isBot = true;
// bot 进场:记录运行时状态(进场金币 / 初始档位)
if (sit.isBot)
InitBotRuntimeState(sit, goldNum);
sit.SetGoldNumText(goldNum);
// XJMH 模式不显示钻石(tMP_DiamondNum/diamondNum_UGUI 已在 Awake 隐藏)
if (sit.turretControlMode != TurretControlMode.XJMH)
sit.SetDiamondNumText(diamondNum);
sit.SetTurretConsumeNumText(consumeGold);
sit.ShowPlayer();
// XJMH 模式:炮台是座位自带子对象,Awake 已初始化引用,不需要加载
if (sit.turretControlMode != TurretControlMode.XJMH)
{
if (!string.IsNullOrEmpty(cannonId) && int.TryParse(cannonId, out int turretNum))
sit.LoadTurret(turretNum);
else if (sit.isBot)
{
var availableTurrets = sit.GetAvailableTurretNumbers();
if (availableTurrets != null && availableTurrets.Count > 0)
sit.LoadTurret(availableTurrets[0]);
}
if (!string.IsNullOrEmpty(wingId) && int.TryParse(wingId, out int wingNum))
sit.LoadWing(wingNum);
// 战神:有装备就加载(内部会隐藏该座位的普通炮台,炮台/战神二选一)
if (!string.IsNullOrEmpty(warGodId) && int.TryParse(warGodId, out int zsNum) && zsNum > 0)
sit.LoadZhanShen(zsNum);
}
}
}
public void TuiChuHHWK()
{
RequestLeaveRoom();
}
#endregion
#region 人机AI
private void HandleBotAI()
{
if (SitPosData.roomOwnerSit == null || currSitData != SitPosData.roomOwnerSit) return;
// ⭐ enableBot 是总开关(Inspector),优先级最高;cfg.enabled 是子开关
if (!GameManager.Instance.enableBot) return;
var cfg = GameManager.Instance.botConfig;
if (cfg == null || !cfg.enabled) return;
var fishList = CreateFishManager.Instance;
if (fishList == null || fishList.fishDatas_hashCodekey == null) return;
BotBehaviorConfig behavior = GetBotBehavior(cfg);
float rotateSpeed = behavior != null ? behavior.rotateSpeed : botRotateSpeed;
foreach (var sit in list_Sits)
{
if (!sit.isBot || sit.gameObject.activeSelf == false) continue;
if (sit.tra_CurrTurret == null) continue;
// ====== 运行时状态 ======
BotRuntimeState state = GetBotRuntimeState(sit);
if (state == null || state.hasExited) continue;
// ====== 退出检查 ======
if (CheckBotExit(sit, state, cfg)) continue;
Vector3 turretPos = sit.turretWorldPos;
// ====== 技能刷新(底层随机技能) ======
if (cfg != null && cfg.skills != null)
RefreshBotSkills(sit, cfg.skills);
var skill = botSkills.ContainsKey(sit.posID) ? botSkills[sit.posID] : null;
// ====== Boss 检测 → 决策行为模式 ======
List<MonsterContral> bosses = FindBossesInField(sit, fishList, turretPos, cfg);
BotBehaviorMode mode = DecideBotMode(sit, state, bosses, cfg, skill);
state.currentMode = mode;
// ====== 倍数调整(第一次:基于进场金币比率) ======
AdjustBotMultiplier(sit, state, cfg);
// ====== 目标选择 ======
MonsterContral target = GetOrFindBotTarget(sit, fishList, turretPos, skill, mode, bosses, cfg);
if (target == null) continue;
// ====== 锁定技能:走真实锁定系统 ======
bool wantLock = (skill != null && skill.lockActive) || mode == BotBehaviorMode.LockBoss;
if (wantLock)
{
if (sit.monCotl_Lock != target)
{
// ⭐ 统一用 SetLockTarget:内部会设置 isLock / monCotl_Lock / monCotl_Lock_Position / lockFX.SetActive
sit.SetLockTarget(target, ResolveLockTransform(target));
// ⭐ 广播锁定状态给其他客户端(节流:目标 guid 变化才广播)
SyncBotLock(sit, target.guid);
}
}
else
{
if (sit.isLock)
{
// ⭐ 统一用 ClearLock:内部会清空 isLock / monCotl_Lock / lockFX.SetActive(false)
sit.ClearLock();
// ⭐ 广播取消锁定给其他客户端
SyncBotLock(sit, null);
}
}
// ====== 旋转 ======
RotateBotToTarget(sit, target.transform.position, rotateSpeed);
// ====== 倍数再调整(第二次:策略执行后再校验) ======
AdjustBotMultiplier(sit, state, cfg);
// ====== 射击 ======
if (!botFireTimer.ContainsKey(sit.posID))
botFireTimer[sit.posID] = 0f;
botFireTimer[sit.posID] += Time.deltaTime;
float fireInterval = behavior != null
? Random.Range(behavior.fireIntervalMin, behavior.fireIntervalMax)
: attackTime;
if (skill != null && skill.frenzyActive)
fireInterval *= 0.5f;
if (botFireTimer[sit.posID] >= fireInterval)
{
botFireTimer[sit.posID] = 0f;
BotFire(sit, target, skill, mode, cfg, behavior);
}
// 空闲超时切回 Idle 动画
if (!botIdleTimer.ContainsKey(sit.posID))
botIdleTimer[sit.posID] = 0f;
botIdleTimer[sit.posID] += Time.deltaTime;
if (botIdleTimer[sit.posID] > 0.3f)
{
sit.ChangeState(TurretState.Idle);
// ⭐ 广播炮台闲置状态给其他客户端(节流:状态变化才广播)
SyncBotTurretState(sit, TurretState.Idle);
botIdleTimer[sit.posID] = 0f;
}
}
}
/// <summary>
/// bot 进场时初始化运行时状态。
/// 按流程图:进场金币 / 入场限制金币 → 判断倍数等级 → 在对应档位区间随机选一个初始档位。
/// 这样多个 bot 即使进场金币相同(例如都是 1000),初始炮倍也会不一样。
/// </summary>
private void InitBotRuntimeState(SitPosData sit, long entryGold)
{
var cfg = GameManager.Instance.botConfig;
int cfgInitIdx = cfg != null && cfg.multiplier != null ? cfg.multiplier.initialLevelIndex : 0;
// ⭐ 按流程图:识别进场金币是入场限制金币的多少倍 → 判断等级 → 区间内随机选初始档位
int count = GetPaoBeiCount();
int initIdx = CalcBotInitialLevelIndex(entryGold, count, cfg, cfgInitIdx);
var s = new BotRuntimeState
{
entryGold = entryGold,
entryTime = Time.time,
currentLevelIndex = initIdx,
// ⭐ 初始检查时间加随机偏移(0~3s),避免多个 bot 同时换炮倍
nextMultiplierCheckTime = Time.time + 2f + Random.Range(0f, 3f),
nextExitCheckTime = Time.time + 5f,
hasExited = false
};
botRuntimeStates[sit.posID] = s;
// 把初始档位应用到炮台
ApplyBotLevelIndex(sit, initIdx);
}
/// <summary>
/// 按进场金币/入场限制金币的倍数,在对应档位区间内随机选一个初始档位。
/// 倍数越高,初始档位区间越高;区间内随机,保证多个 bot 即使金币相同炮倍也不同。
/// 读不到 roomGoldLimit 或档位不足时,降级用 cfgInitIdx + 随机偏移。
/// </summary>
private int CalcBotInitialLevelIndex(long entryGold, int count, BotConfig cfg, int cfgInitIdx)
{
if (count <= 1) return Mathf.Max(0, cfgInitIdx);
// 配置允许的档位范围 [minIdx, maxIdx]
int minIdx = Mathf.Max(0, cfg != null && cfg.multiplier != null ? cfg.multiplier.minLevelIndex : 0);
int maxIdx = cfg != null && cfg.multiplier != null && cfg.multiplier.maxLevelIndex >= 0
? Mathf.Min(cfg.multiplier.maxLevelIndex, count - 1)
: count - 1;
if (maxIdx < minIdx) maxIdx = minIdx;
if (maxIdx <= minIdx) return minIdx;
// 读取当前房间的入场金币限制
long roomGoldLimit = GetCurrentRoomGoldLimit();
if (roomGoldLimit <= 0 || entryGold <= 0)
{
// 读不到限制 → 在 [minIdx, maxIdx] 全区间随机
return Random.Range(minIdx, maxIdx + 1);
}
// ratio = 进场金币 / 入场限制金币
float ratio = (float)entryGold / roomGoldLimit;
int span = maxIdx - minIdx;
// 根据 ratio 划分三档区间:低(<1) / 中(1~2) / 高(>2)
int lo, hi;
if (ratio < 1f)
{
lo = minIdx;
hi = minIdx + Mathf.Max(0, span / 3);
}
else if (ratio < 2f)
{
lo = minIdx + Mathf.Max(0, span / 3);
hi = minIdx + Mathf.Max(0, span * 2 / 3);
}
else
{
lo = minIdx + Mathf.Max(0, span * 2 / 3);
hi = maxIdx;
}
if (lo > hi) lo = hi;
// 在 [lo, hi] 区间内随机选一个档位
return Random.Range(lo, hi + 1);
}
/// <summary>获取当前房间的入场金币限制(用于 bot 进场按金币倍数判断初始档位)</summary>
private long GetCurrentRoomGoldLimit()
{
var roomData = GameManager.Instance?.gameData?.currRoomData;
if (roomData == null) return 0;
int t = roomData.gameType;
int l = roomData.roomLevel;
var configs = GameManager.Instance?.gameConfig?.roomConfigs;
if (configs != null)
{
foreach (var item in configs)
{
if (item.gameType == t && l == item.roomLevel)
return item.roomGoldLimit;
}
}
return 0;
}
/// <summary>获取 bot 运行时状态</summary>
private BotRuntimeState GetBotRuntimeState(SitPosData sit)
{
if (botRuntimeStates.TryGetValue(sit.posID, out var s)) return s;
// 兜底:未初始化时按当前金币建一份
InitBotRuntimeState(sit, sit.goldNum);
return botRuntimeStates.TryGetValue(sit.posID, out s) ? s : null;
}
/// <summary>查找场内炮台前方的 Boss 列表</summary>
private List<MonsterContral> FindBossesInField(SitPosData sit, CreateFishManager fishList, Vector3 turretPos, BotConfig cfg)
{
var result = new List<MonsterContral>();
if (cfg == null || cfg.bossStrategy == null) return result;
var bossTypes = cfg.bossStrategy.bossFishTypes;
if (bossTypes == null || bossTypes.Length == 0) return result;
foreach (var kv in fishList.fishDatas_hashCodekey)
{
var m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的 Boss 不作为目标
if (!IsFishPathLockable(m)) continue;
int ft = (int)m.fishenum;
bool isBoss = false;
foreach (var bt in bossTypes) { if (bt == ft) { isBoss = true; break; } }
if (!isBoss) continue;
Vector3 fishPos = m.transform.position;
if (sit.IsFlipped && fishPos.y >= turretPos.y) continue;
if (!sit.IsFlipped && fishPos.y <= turretPos.y) continue;
result.Add(m);
}
return result;
}
/// <summary>根据 Boss 存在情况决策行为模式,并覆盖技能开关</summary>
private BotBehaviorMode DecideBotMode(SitPosData sit, BotRuntimeState state, List<MonsterContral> bosses, BotConfig cfg, BotSkillRuntime skill)
{
if (cfg == null || cfg.bossStrategy == null) return BotBehaviorMode.ScatterRandom;
var bs = cfg.bossStrategy;
// 狂暴到期清理
if (state.frenzyActive && Time.time >= state.frenzyEndTime)
state.frenzyActive = false;
if (bosses == null || bosses.Count == 0)
{
// ===== 无Boss → 散射乱射 =====
// 随机选择定点三发或单射:覆盖 skill.tripleActive
if (skill != null)
skill.tripleActive = Random.value < bs.tripleShotProb;
// 无Boss时关闭锁定Boss模式
if (skill != null) skill.lockActive = false;
return bs.scatterWhenNoBoss ? BotBehaviorMode.ScatterRandom : BotBehaviorMode.Idle;
}
// ===== 有Boss =====
if (bosses.Count > bs.scatterModeThreshold)
{
// Boss > 阈值 → 散射模式
if (skill != null) skill.tripleActive = true;
return BotBehaviorMode.ScatterMode;
}
// Boss <= 阈值 → 随机狂暴
if (!state.frenzyActive && Random.value < bs.frenzyProb)
{
state.frenzyActive = true;
state.frenzyEndTime = Time.time + Random.Range(bs.frenzyMinTime, bs.frenzyMaxTime);
}
if (state.frenzyActive && skill != null)
skill.frenzyActive = true;
// 锁定最大倍数Boss
return BotBehaviorMode.LockBoss;
}
/// <summary>倍数升降:当前金币/进场金币 < 阈值降档, > 阈值升档</summary>
private void AdjustBotMultiplier(SitPosData sit, BotRuntimeState state, BotConfig cfg)
{
if (cfg == null || cfg.multiplier == null) return;
var mc = cfg.multiplier;
if (Time.time < state.nextMultiplierCheckTime) return;
// ⭐ 检查间隔加随机抖动(0.5~1.5 倍),避免多个 bot 同时换炮倍
state.nextMultiplierCheckTime = Time.time + mc.checkInterval * Random.Range(0.5f, 1.5f);
float ratio = state.GetGoldRatio(sit.goldNum);
int maxIdx = mc.maxLevelIndex < 0 ? GetPaoBeiCount() - 1 : mc.maxLevelIndex;
int minIdx = Mathf.Max(0, mc.minLevelIndex);
if (ratio < mc.decreaseThreshold && state.currentLevelIndex > minIdx)
{
state.currentLevelIndex = Mathf.Max(minIdx, state.currentLevelIndex - mc.step);
ApplyBotLevelIndex(sit, state.currentLevelIndex);
}
else if (ratio > mc.increaseThreshold && state.currentLevelIndex < maxIdx)
{
state.currentLevelIndex = Mathf.Min(maxIdx, state.currentLevelIndex + mc.step);
ApplyBotLevelIndex(sit, state.currentLevelIndex);
}
}
/// <summary>当前炮倍档位总数(paoBeiList 长度)</summary>
private int GetPaoBeiCount()
{
if (paoBeiList != null && paoBeiList.Count > 0) return paoBeiList.Count;
return 1;
}
/// <summary>
/// 按档位索引设置 bot 炮台消耗。
/// 同时更新底层数据 consumeGoldNum、文本显示,并广播给其他客户端,
/// 保证所有客户端的 bot 炮倍一致(发射子弹扣金币、打中鱼计算奖励都用最新值)。
/// 不走 SyncCannonMultiple(那个用的是本地玩家 currSitData.posID,不能给 bot 用)。
/// </summary>
private void ApplyBotLevelIndex(SitPosData sit, int targetIndex)
{
int count = GetPaoBeiCount();
if (count <= 0 || paoBeiList == null || paoBeiList.Count == 0) return;
targetIndex = Mathf.Clamp(targetIndex, 0, count - 1);
// ⭐ paoBeiList 已改为从大到小排列;bot 档位索引沿用"从小到大"语义
// (BotConfig.initialLevelIndex / 升降档 ±step / 金币比例判断均不变),
// 应用到列表时做镜像映射:0=最小档=列表末尾,count-1=最大档=列表头部
sit.consumeGoldNum = paoBeiList[paoBeiList.Count - 1 - targetIndex];
sit.SetTurretConsumeNumText(sit.consumeGoldNum);
// ⭐ 同步 indexConsume,保持档位索引与 consumeGoldNum 一致
sit.SyncIndexByConsume(sit.consumeGoldNum);
// ⭐ 广播 bot 炮倍变化给其他客户端(用 bot 自己的 sitPos),
// 接收端 SyncGameData_ChangeCannonMultiple 会根据 sitPos 更新对应座位
var sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(sit.posID),
frameType = FrameDataType.changeCannonMultiple,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeCannonMultiple { cannonMultiple = (int)sit.consumeGoldNum }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>退出条件检查:金币>设定 或 金币<设定</summary>
private bool CheckBotExit(SitPosData sit, BotRuntimeState state, BotConfig cfg)
{
if (cfg == null || cfg.exitCondition == null) return false;
var ec = cfg.exitCondition;
if (Time.time < state.nextExitCheckTime) return false;
state.nextExitCheckTime = Time.time + ec.checkInterval;
bool shouldExit = false;
if (ec.exitWhenGoldAbove > 0 && sit.goldNum >= ec.exitWhenGoldAbove) shouldExit = true;
else if (sit.goldNum <= ec.exitWhenGoldBelow) shouldExit = true;
if (!shouldExit) return false;
HandleBotExit(sit, state, cfg);
return true;
}
/// <summary>处理 bot 退出:发离房消息、座位清理、转移房主、保证出鱼</summary>
private void HandleBotExit(SitPosData sit, BotRuntimeState state, BotConfig cfg)
{
state.hasExited = true;
state.currentMode = BotBehaviorMode.Exited;
if (cfg != null && cfg.exitCondition != null && cfg.exitCondition.sendLeaveRoom)
{
string roomId = GameManager.Instance.gameData.currRoomData.roomId;
WebSocketSendList.LeaveRoom(roomId, sit.user);
}
// 清理锁定(统一用 ClearLock,并广播取消锁定给其他客户端)
if (sit.isLock)
{
sit.ClearLock();
SyncBotLock(sit, null);
}
// 清理记忆
botTargetGuid.Remove(sit.posID);
botLastSyncLockGuid.Remove(sit.posID);
botLastSyncTurretState.Remove(sit.posID);
botFireTimer.Remove(sit.posID);
botIdleTimer.Remove(sit.posID);
botSkills.Remove(sit.posID);
botLastAngle.Remove(sit.user);
botLastFireData.Remove(sit.user);
remoteAttackHeartbeat.Remove(sit.posID); // 攻击心跳(看门狗用)
// ⭐ bot 退出后转移房主,否则房主是已退出的 bot 会导致房主显示异常
// 1) 清除 bot 的房主引用(如果它是房主)
// 2) TransferRoomOwner 会把房主交给第一个活跃的非 bot 玩家
// 注:鱼创建已由服务端 OnFishSpawn 统一下发,不再需要 fangzhu 兜底
if (SitPosData.roomOwnerSit == sit)
{
SitPosData.roomOwnerSit = null;
SitPosData.TransferRoomOwner();
}
if (cfg != null && cfg.exitCondition != null && cfg.exitCondition.showWaitingAfterExit)
sit.ShowWaiting();
else
sit.gameObject.SetActive(false);
}
/// <summary>bot 开火执行(从 HandleBotAI 抽出)</summary>
private void BotFire(SitPosData sit, MonsterContral target, BotSkillRuntime skill, BotBehaviorMode mode, BotConfig cfg, BotBehaviorConfig behavior)
{
sit.ChangeState(TurretState.Attack);
// ⭐ 广播炮台攻击状态给其他客户端(节流:状态变化才广播)
SyncBotTurretState(sit, TurretState.Attack);
if (!botIdleTimer.ContainsKey(sit.posID))
botIdleTimer[sit.posID] = 0f;
botIdleTimer[sit.posID] = 0f;
float angle = sit.currentAngle;
// ⭐ 座位镜像:bot 角度缓存统一存"源角度"(下方座位系),与网络帧格式一致
botLastAngle[sit.user] = SeatMirror.WorldAngleToSource(angle, sit.IsFlipped);
bool isSanShe = (skill != null && skill.tripleActive) || mode == BotBehaviorMode.ScatterMode || mode == BotBehaviorMode.ScatterRandom;
bool isDouble = skill != null && skill.doubleActive;
// ⭐ isLock 包含 RandomSweep 锁定跟随子模式,保证子弹追踪锁定目标
bool isLockFollow = mode == BotBehaviorMode.RandomSweep
&& botRuntimeStates.TryGetValue(sit.posID, out var rst) && rst.sweepIsLockFollow;
bool isLock = (skill != null && skill.lockActive) || mode == BotBehaviorMode.LockBoss || isLockFollow;
int count = isSanShe ? 3 : 1;
long gold = isDouble ? sit.consumeGoldNum * 2 : sit.consumeGoldNum;
// 三发+锁定+战神 → 找 N 个目标鱼的 GUID(FireLaser 分散打三个用;正常炮台三合一不需要)
List<string> monsterIDs = null;
if (isSanShe && isLock && target != null && sit.currentZhanShenData != null)
{
monsterIDs = new List<string> { target.guid };
var extraTargets = FindBotLockTargets(sit, target, 2);
foreach (var m in extraTargets) monsterIDs.Add(m.guid);
}
botLastFireData[sit.user] = new FrameData_FireBullet
{
// ⭐ 座位镜像:存"源角度"(下方座位系),房主自己收到广播回放时同样按源角度转换
angle = SeatMirror.WorldAngleToSource(angle, sit.IsFlipped),
gold = gold,
isSanShe = isSanShe,
twoRTruuect = isDouble,
isSuoDing = isLock,
monsterIDs = monsterIDs
};
WebSocketSendList.BotFireCannon(
GameManager.Instance.gameData.currRoomData.roomId,
sit.user, gold, count);
SyncFire_ForBot(sit, angle, gold, isSanShe, isDouble, isLock, monsterIDs);
}
/// <summary>
/// 获取或寻找目标:优先用记忆的目标(还在前方就沿用),死了/不在前方了再重新找
/// </summary>
private MonsterContral GetOrFindBotTarget(SitPosData sit, CreateFishManager fishList, Vector3 turretPos, BotSkillRuntime skill,
BotBehaviorMode mode, List<MonsterContral> bosses, BotConfig cfg)
{
bool isLocked = (skill != null && skill.lockActive) || mode == BotBehaviorMode.LockBoss;
// 1. 检查记忆目标是否还有效
if (botTargetGuid.TryGetValue(sit.posID, out string guid))
{
if (fishList.fishDatas_hashCodekey.TryGetValue(guid, out var m)
&& m != null && m.gameObject.activeInHierarchy)
{
// ⭐ 路径进度过滤:如果记忆目标已经超出可锁定范围(即将离场),清除记忆重新找
if (!IsFishPathLockable(m))
{
botTargetGuid.Remove(sit.posID);
}
else if (isLocked)
{
// 锁定时不检查 inFront,始终跟踪目标
return m;
}
else
{
Vector3 fp = m.transform.position;
bool inFront = sit.IsFlipped ? fp.y < turretPos.y : fp.y > turretPos.y;
if (inFront) return m;
// 不在前方 → 清除记忆
botTargetGuid.Remove(sit.posID);
}
}
else
{
// 目标无效 → 清除记忆
botTargetGuid.Remove(sit.posID);
}
}
// 2. 锁定Boss模式 → 优先最大倍率Boss
if (mode == BotBehaviorMode.LockBoss && bosses != null && bosses.Count > 0
&& cfg != null && cfg.bossStrategy != null && cfg.bossStrategy.preferMaxMultiplierBoss)
{
MonsterContral bossTarget = SelectMaxMultiplierBoss(bosses);
if (bossTarget != null)
{
botTargetGuid[sit.posID] = bossTarget.guid;
return bossTarget;
}
}
// 3. 散射模式 → 在 Boss 中随机选一个(覆盖面广)
if (mode == BotBehaviorMode.ScatterMode && bosses != null && bosses.Count > 0)
{
MonsterContral bossTarget = bosses[Random.Range(0, bosses.Count)];
botTargetGuid[sit.posID] = bossTarget.guid;
return bossTarget;
}
// 4. 重新找目标(Boss > 黄金鱼 > 特殊鱼 > 普通鱼,同类型打最近)
MonsterContral target = SelectBestTarget(sit, fishList, turretPos);
if (target != null)
botTargetGuid[sit.posID] = target.guid;
return target;
}
/// <summary>从 Boss 列表中选倍率(beilv)最大的(跳过已死亡的)</summary>
private MonsterContral SelectMaxMultiplierBoss(List<MonsterContral> bosses)
{
MonsterContral best = null;
int maxBeiLv = int.MinValue;
foreach (var b in bosses)
{
if (b == null || !b.gameObject.activeInHierarchy) continue;
if (b.beilv > maxBeiLv)
{
maxBeiLv = b.beilv;
best = b;
}
}
return best;
}
/// <summary>
/// 按优先级选目标:特殊Boss(5) > Boss(2) > 黄金鱼(3) > 特殊鱼(4) > 普通鱼(1)
/// 同一优先级内选最近的,只选炮台前方的鱼
/// </summary>
private MonsterContral SelectBestTarget(SitPosData sit, CreateFishManager fishList, Vector3 turretPos)
{
MonsterContral best = null;
float bestDist = float.MaxValue;
int bestPri = int.MaxValue;
foreach (var kv in fishList.fishDatas_hashCodekey)
{
var m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的鱼不作为目标
if (!IsFishPathLockable(m)) continue;
Vector3 fishPos = m.transform.position;
if (sit.IsFlipped && fishPos.y >= turretPos.y) continue;
if (!sit.IsFlipped && fishPos.y <= turretPos.y) continue;
float d = (fishPos - turretPos).sqrMagnitude;
int pri = GetFishPriority((int)m.fishenum);
if (pri < bestPri || (pri == bestPri && d < bestDist))
{
bestPri = pri;
bestDist = d;
best = m;
}
}
return best;
}
private int GetFishPriority(int fishType)
{
switch (fishType)
{
case 5: return 0; // 特殊Boss — 最高优先
case 2: return 1; // Boss鱼
case 3: return 2; // 黄金鱼
case 4: return 3; // 特殊鱼
default: return 4; // 普通鱼
}
}
/// <summary>找 bot 三发锁定额外目标(排除已选中的)</summary>
private List<MonsterContral> FindBotLockTargets(SitPosData sit, MonsterContral exclude, int count)
{
var result = new List<MonsterContral>();
if (CreateFishManager.Instance == null) return result;
var fishList = CreateFishManager.Instance;
// ⭐ 用世界坐标(Z=0平面),和 bot AI / 玩家自动锁定一致
Vector3 turretPos = sit.turretWorldPos;
foreach (var kv in fishList.fishDatas_hashCodekey)
{
var m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
if (m == exclude) continue;
// ⭐ 路径进度过滤:刚出生(< 0.1)或即将离场(> 0.9)的鱼不作为目标
if (!IsFishPathLockable(m)) continue;
Vector3 fishPos = m.transform.position;
if (sit.IsFlipped && fishPos.y >= turretPos.y) continue;
if (!sit.IsFlipped && fishPos.y <= turretPos.y) continue;
result.Add(m);
if (result.Count >= count) break;
}
// 按优先级排序取前 count 个
result.Sort((a, b) =>
{
int pa = GetFishPriority((int)a.fishenum);
int pb = GetFishPriority((int)b.fishenum);
if (pa != pb) return pa.CompareTo(pb);
float da = (a.transform.position - turretPos).sqrMagnitude;
float db = (b.transform.position - turretPos).sqrMagnitude;
return da.CompareTo(db);
});
if (result.Count > count)
result.RemoveRange(count, result.Count - count);
return result;
}
/// <summary>根据 aiState 取对应行为参数,cfg 为 null 时返回 null</summary>
private BotBehaviorConfig GetBotBehavior(BotConfig cfg)
{
if (cfg == null) return null;
switch (cfg.aiState)
{
case "idle": return cfg.idleBehavior ?? cfg.activeBehavior;
case "aggressive": return cfg.aggressiveBehavior ?? cfg.activeBehavior;
default: return cfg.activeBehavior;
}
}
/// <summary>技能掷骰子:每隔 skillRefreshInterval 秒独立决定各技能开关</summary>
private void RefreshBotSkills(SitPosData sit, BotSkillConfig skillCfg)
{
if (skillCfg == null) return;
if (!botSkills.TryGetValue(sit.posID, out var s))
{
s = new BotSkillRuntime();
botSkills[sit.posID] = s;
}
if (Time.time < s.nextRefreshTime) return;
s.nextRefreshTime = Time.time + skillCfg.skillRefreshInterval;
// 独立掷骰子:还在持续中就沿用,否则重新掷
s.lockActive = (s.lockActive && Time.time < s.lockEndTime)
|| Random.value < skillCfg.lockProb;
if (s.lockActive && Time.time >= s.lockEndTime)
s.lockEndTime = Time.time + Random.Range(skillCfg.lockMinTime, skillCfg.lockMaxTime);
s.frenzyActive = (s.frenzyActive && Time.time < s.frenzyEndTime)
|| Random.value < skillCfg.frenzyProb;
if (s.frenzyActive && Time.time >= s.frenzyEndTime)
s.frenzyEndTime = Time.time + Random.Range(skillCfg.frenzyMinTime, skillCfg.frenzyMaxTime);
s.tripleActive = (s.tripleActive && Time.time < s.tripleEndTime)
|| Random.value < skillCfg.tripleProb;
if (s.tripleActive && Time.time >= s.tripleEndTime)
s.tripleEndTime = Time.time + Random.Range(skillCfg.tripleMinTime, skillCfg.tripleMaxTime);
s.doubleActive = (s.doubleActive && Time.time < s.doubleEndTime)
|| Random.value < skillCfg.doubleProb;
if (s.doubleActive && Time.time >= s.doubleEndTime)
s.doubleEndTime = Time.time + Random.Range(skillCfg.doubleMinTime, skillCfg.doubleMaxTime);
}
/// <summary>人机炮台旋转(房主端执行)</summary>
private void RotateBotToTarget(SitPosData sit, Vector3 fishWorldPos, float rotateSpeed = -1f)
{
Vector2 screenPos = Camera.main.WorldToScreenPoint(fishWorldPos);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
sit.tra_CurrTurret.transform.parent as RectTransform,
screenPos, null, out Vector2 localPos);
Vector2 dir = localPos - sit.tra_CurrTurret.GetComponent<RectTransform>().anchoredPosition;
// 鱼太近时 dir 接近零向量,Atan2 不稳定 → 跳过
if (dir.sqrMagnitude < 25f) return;
float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f;
if (sit.IsFlipped) angle = (angle + 180f) % 360f;
// Lerp 旋转:自然减速,速度由 rotateSpeed 控制
float current = sit.currentAngle;
float speed = rotateSpeed > 0f ? rotateSpeed : botRotateSpeed;
// t 越大旋转越快,clamp 防过冲
float t = Mathf.Min(speed * 0.15f * Time.deltaTime, 1f);
float smoothed = Mathf.LerpAngle(current, angle, t);
sit.SetRotation(smoothed);
// ⭐ 机器人旋转帧同步:和开炮一样实时广播
// 只保留极小节流(角度>2° 或 0.05s)避免每帧都广播造成网络洪流
float lastAngle = botLastSyncAngle.TryGetValue(sit.posID, out float la) ? la : -999f;
float lastTime = botLastSyncTime.TryGetValue(sit.posID, out float lt) ? lt : 0f;
if (Mathf.Abs(Mathf.DeltaAngle(lastAngle, smoothed)) > 2f || (Time.time - lastTime) > 0.05f)
{
SyncBotRotate(sit, smoothed);
botLastSyncAngle[sit.posID] = smoothed;
botLastSyncTime[sit.posID] = Time.time;
}
}
/// <summary>机器人旋转广播(房主代发,复用 rotateTurrect 帧类型)</summary>
private void SyncBotRotate(SitPosData botSit, float angle)
{
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(botSit.posID),
frameType = FrameDataType.rotateTurrect,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
// ⭐ 座位镜像:房主代算的 bot 角度是世界角度(按 bot 显示座位算的),
// 先转成统一的"源角度"(下方座位系)再广播,接收端按各自显示座位还原
frameData = new FrameData_Rotate { angle = SeatMirror.WorldAngleToSource(angle, botSit.IsFlipped) }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>
/// 机器人锁定状态广播(房主代发,复用 changeAutoTarget 帧类型)。
/// 用 bot 自己的 sitPos,接收端 SyncGameData_ChangeTargetMonster 会根据 sitPos
/// 调用 SetLockTarget/ClearLock 更新对应 bot 座位,从而激活 lockFX 特效和锁定字段。
/// 内置节流:只有目标 guid 变化(包括锁定→取消锁定)才真正广播。
/// </summary>
/// <param name="botSit">bot 座位</param>
/// <param name="targetGuid">锁定目标 guid,null 表示取消锁定</param>
private void SyncBotLock(SitPosData botSit, string targetGuid)
{
// ⭐ 节流:目标 guid 未变化不广播(避免每帧都发)
botLastSyncLockGuid.TryGetValue(botSit.posID, out string lastSynced);
if (lastSynced == targetGuid) return;
botLastSyncLockGuid[botSit.posID] = targetGuid;
List<string> guidList = null;
if (!string.IsNullOrEmpty(targetGuid))
guidList = new List<string> { targetGuid };
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(botSit.posID),
frameType = FrameDataType.changeAutoTarget,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeAutoTarget
{
targetGuid = guidList,
colliderIndex = -1
}
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>
/// 机器人炮台状态广播(房主代发,复用 changeTurretState 帧类型)。
/// 用 bot 自己的 sitPos,接收端 SyncGameData_TurretState 会根据 sitPos
/// 调用 sit.ChangeState(state) 切换炮台动画。
/// 内置节流:只有状态变化才广播(避免空闲时每帧广播 Idle)。
/// 走 LV2 队列(0.5s flush 一次),对 bot 攻击动画可接受。
/// </summary>
private void SyncBotTurretState(SitPosData botSit, TurretState state)
{
if (botSit == null) return;
// ⭐ 节流:状态未变化不广播
if (botLastSyncTurretState.TryGetValue(botSit.posID, out var lastSynced) && lastSynced == state)
return;
botLastSyncTurretState[botSit.posID] = state;
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(botSit.posID),
frameType = FrameDataType.changeTurretState,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_TurretState { state = state }
};
WebSocketSendList.SyncGameData(sgd);
}
/// <summary>人机开火同步(房主代发)</summary>
private void SyncFire_ForBot(SitPosData botSit, float angle, long gold,
bool isSanShe, bool isDouble, bool isLock, List<string> monsterIDs)
{
var fireData = new FrameData_FireBullet
{
// ⭐ 座位镜像:网络角度统一用"源角度"(下方座位系),接收端按显示座位还原
angle = SeatMirror.WorldAngleToSource(angle, botSit.IsFlipped),
gold = gold,
isSanShe = isSanShe,
twoRTruuect = isDouble,
isSuoDing = isLock,
};
if (monsterIDs != null && monsterIDs.Count > 0)
fireData.monsterIDs = monsterIDs;
SyncGameData sgd = new SyncGameData
{
sitPos = SeatMirror.ToServer(botSit.posID),
frameType = FrameDataType.fireBullet,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = fireData
};
WebSocketSendList.SyncGameData(sgd);
}
#endregion
}
/// <summary>
/// ⭐ 加载期卡顿监测(诊断用,挂面板对象、随面板销毁):
/// 加载全程 Update 检查单帧耗时,凡单帧 ≥120ms 就打一条日志并带"当前阶段"——
/// 用于精确定位是哪个环节顶住了主线程(进度条/界面动画卡顿的根因)。
/// 阶段名由 GameMainPanel_FishBase / FlyTurretData 在流程中随时更新;加载完成后置空即静默。
/// 排查完成后可整体删除本类及各 Phase 赋值。
/// </summary>
public class LoadingStallWatchdog : MonoBehaviour
{
/// <summary>当前阶段(由加载流程更新;为空=不监测)</summary>
public static string Phase = "";
private static int _logged;
/// <summary>在面板下挂一个监测器(存在则不重复创建)</summary>
public static void Attach(MonoBehaviour host)
{
if (host == null || host.GetComponentInChildren<LoadingStallWatchdog>(true) != null) return;
var go = new GameObject("LoadingStallWatchdog");
go.transform.SetParent(host.transform, false);
go.AddComponent<LoadingStallWatchdog>();
}
private void Update()
{
if (string.IsNullOrEmpty(Phase)) return;
float dt = Time.unscaledDeltaTime;
if (dt >= 0.12f && _logged < 80)
{
_logged++;
Debug.Log($"[加载卡顿] 「{Phase}」单帧 {dt * 1000f:F0}ms(第 {_logged} 条)");
}
}
}