/*
======================== 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.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;
/// 是否正在退出倒计时(期间屏蔽帧同步和爆奖特效)
private bool _isLeavingCountdown = false;
private Coroutine _leaveCountdownCoroutine;
[Header("无操作自动退出")]
[Tooltip("无操作总时长(秒),走完自动退出回大厅(<=0 关闭该功能)")]
public float idleExitTotalSeconds = 70f;
[Tooltip("最后多少秒才显示倒计时面板(autoExitGamePanel)并逐秒倒数,之前全程无提示")]
public float idleExitPanelShowSeconds = 10f;
/// 无操作计时是否生效(资源加载完成后才开始计时,OnHide/离房时失效)
private bool _idleExitArmed = false;
/// 最近一次玩家操作的时间(Time.realtimeSinceStartup,含切后台时间)
private float _lastOperationTime = 0f;
/// 警告文本当前显示的剩余秒数(-1=未显示;避免每帧刷新 TMP 文本)
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;
/// 切换房间:重新走一遍加入房间流程(离房 → QuickJoin → 连新房socket → JoinRoom)
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 list_BiaoQingSpine = new List();
[Tooltip("(回退用)文字表情内容列表:优先读 biaoQingConfig 资产,未拖资产时才用这里")]
public List list_BiaoQingZi = new List();
//[Header("我在这")]
public GameObject mySitAnim;
[Tooltip("我在这动画偏移 - 左侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")]
public Vector3 mySitAnimOffsetLeft;
[Tooltip("我在这动画偏移 - 右侧座位(世界坐标,直接加到座位位置上;默认0=保持原地)")]
public Vector3 mySitAnimOffsetRight;
public Dictionary dic_Skill_Toggle = new Dictionary();//开关型技能
[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;
// ⭐ 双倍开火:toggle_Shuang 开启后计时 shuangDuration 秒自动关闭(DaoJiShi 驱动),
// 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 _frozenAnimSpeeds = new Dictionary();
///
/// 冰冻中“受 Timeline 驱动”的 Animator(其所属鱼体内存在 PlayableDirector)。
/// ⭐ 这些 Animator 的动画由 Timeline 驱动,冰冻时绝对不能把 speed 设 0:
/// speed=0 会让 Timeline 的动画输出失效 → 模型瞬间回退到默认姿态。
/// 它们的定格交给 director(根 Playable 速度归零)处理。
///
private readonly HashSet _timelineDrivenAnimators = new HashSet();
/// 冰冻中被定格的 Timeline:记录根 Playable 的原始速度,解冻时还原
private readonly Dictionary _frozenDirectors
= new Dictionary();
// ⭐ 暴走/冰封本地数量:因为暂无扣减 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 list_Sits = new List();
public SitPosData currSitData;
[Header("攻击参数")]
public float attackTime = 0.5f;
[Header("人机参数")]
public float botRotateSpeed = 80f; // 兜底旋转速度(BotConfig 为空时用)
float clickTime = 0;
private Dictionary botFireTimer = new Dictionary();
private Dictionary botLastAngle = new Dictionary();
// bot 旋转广播节流:记录上次广播的角度/时间
private Dictionary botLastSyncAngle = new Dictionary();
private Dictionary botLastSyncTime = new Dictionary();
// bot 技能运行时
private Dictionary botSkills = new Dictionary();
// bot 目标记忆
private Dictionary botTargetGuid = new Dictionary();
// bot 锁定同步节流:记录上次广播的锁定目标 guid(null 表示未锁定),只有目标变化才广播
private Dictionary botLastSyncLockGuid = new Dictionary();
// bot 炮台状态同步节流:记录上次广播的 TurretState,只有状态变化才广播
private Dictionary botLastSyncTurretState = new Dictionary();
// bot 状态计时:开火后持续 Attack 动画,空闲 0.3s 切回 Idle
private Dictionary botIdleTimer = new Dictionary();
// bot 最近的 FrameData_FireBullet(给其他客户端还原技能状态)
private Dictionary botLastFireData = new Dictionary();
// bot 运行时状态(进场金币/倍数档位/行为模式/退出)
private Dictionary botRuntimeStates = new Dictionary();
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;
AudioClip bulletClip;
public List paoBeiList = new List();
public ProgressBar JinDuTiao;
#region UI
bool isfirstShow = true;
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>(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)
{
string roomId = GameManager.Instance.gameData.currRoomData.roomId;
//string roomId = GameManager.Instance.gameData.currRoomData;
foreach (var item in GameManager.Instance.gameData.currRoomData.players)
{
JoinGame(item.position, item.gold, item.diamond, item.cannonMultiple, 0, item.userId, item.cannonId, item.wingsId, item.warGodId);
// ⭐ 优先用后端 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 = v.arguments[0];
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));
}
}
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("获取信息失败");
}
}
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);
}
}
///
/// 刷新暴走/冰封道具数量显示
/// 策略:记录上次服务端数量,只要服务端数量相比上次有变化(增加或减少)就信任服务端
/// (覆盖刮刮卡奖励、其他端使用等场景);服务端数量未变时保留本地扣减值,
/// 避免被服务端旧值覆盖本地扣减
///
private void RefreshSkillPropsCount(List 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();
}
/// 仅更新 UI 文本
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();
UnLoadResources();
// 场景可能未加载或已卸载,做保护避免重复卸载报错
var scene = SceneManager.GetSceneByName(sceneName);
if (scene.IsValid() && scene.isLoaded)
SceneManager.UnloadSceneAsync(sceneName);
// 清除场景加载标记,下次进入可重新加载
Assets.MyGame.Scripts.Net.WebSocketReponseList.ClearLoadedGameScene(sceneName);
WebSocketManager.Instance.Close(SocketType.game);
// ⭐ 座位镜像状态清空,下次进房重新按新房间座位 Setup
SeatMirror.Clear();
// ⭐ 重置本地道具计数,下次进入房间重新从服务端读取
_localBaoZouCount = -1;
_localBingFengCount = -1;
_lastServerBaoZouCount = -1;
_lastServerBingFengCount = -1;
}
#endregion
#region 长按 img_OpenPT 开关炮台面板
/// 已订阅长按事件的座位(换座位/重进房间时先解绑避免重复订阅)
private SitPosData _openPTHookedSit;
/// 缓存的长按处理器(解绑用)
private Action _openPTLongPressHandler;
///
/// 订阅本地座位的 img_OpenPT 长按事件:长按触发 OnOpenPTLongPressTriggered(子类可覆写)。
///
private void HookOpenPTLongPress(SitPosData sit)
{
// 先解绑旧订阅
UnhookOpenPTLongPress();
if (sit == null) return;
_openPTLongPressHandler = () => OnOpenPTLongPressTriggered();
sit.OnOpenPTLongPress += _openPTLongPressHandler;
_openPTHookedSit = sit;
}
///
/// 长按 img_OpenPT 达到阈值触发。
/// Normal 模式:开关炮台面板(subUIManager,与 btn_ChangeTurret 同逻辑)。
/// XJMH 模式:子类覆写为开关 ChangePTPanel。
///
protected virtual void OnOpenPTLongPressTriggered()
{
// 与 btn_ChangeTurret 完全相同的开关逻辑:开着就关,没开就打开
var panel = subUIManager.Get(UIPanel.TurretPanel);
if (panel != null && panel.gameObject.activeSelf)
subUIManager.Hide(UIPanel.TurretPanel);
else
{
subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true));
StartCoroutine(CloseSettingList());
}
}
/// 解绑长按 img_OpenPT 的订阅(面板隐藏/重新订阅前调用)
private void UnhookOpenPTLongPress()
{
if (_openPTHookedSit != null && _openPTLongPressHandler != null)
_openPTHookedSit.OnOpenPTLongPress -= _openPTLongPressHandler;
_openPTHookedSit = null;
_openPTLongPressHandler = null;
}
#endregion
#region 自动攻击开关(SitPosData.toggle_AutoAttack)
/// 已订阅自动攻击关闭事件的座位(换座位/重进房间时先解绑避免重复订阅)
private SitPosData _autoAttackHookedSit;
/// 缓存的关闭处理器(解绑用)
private Action _autoAttackOffHandler;
///
/// 订阅本地座位的自动攻击关闭事件:关闭时广播 Idle 状态给其他客户端
/// (座位本体的 ChangeState(Idle) 已在 SitPosData.OnAutoAttackToggleChanged 里完成)。
///
private void HookAutoAttackOff(SitPosData sit)
{
UnhookAutoAttackOff();
if (sit == null) return;
_autoAttackOffHandler = () => SyncTurretState(TurretState.Idle);
sit.OnAutoAttackOff += _autoAttackOffHandler;
_autoAttackHookedSit = sit;
}
/// 解绑自动攻击关闭事件的订阅(面板隐藏/重新订阅前调用)
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(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(); // 关道具面板时一并收起二层并重置展开状态
});
// ⭐ 大喇叭:开/关弹幕输入面板
if (dalaba != null)
dalaba.onClick.AddListener(() =>
{
if (obj_DaLaBaPanel != null)
obj_DaLaBaPanel.SetActive(!obj_DaLaBaPanel.activeSelf);
});
if (btn_DaLaBaSend != null)
btn_DaLaBaSend.onClick.AddListener(OnClickDaLaBaSend);
if (btn_DaLaBaClose != null)
btn_DaLaBaClose.onClick.AddListener(() =>
{
if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(false);
});
// ⭐ 召唤卡:使用召唤卡召唤Boss
if (zhanhuanka != null)
zhanhuanka.onClick.AddListener(OnClickZhanHuanKa);
// 服务器广播监听
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing);
// ⭐ 战神切换帧同步(changeZhanShen):其他玩家装备/卸下战神,对应座位加载/清除战神模型
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen);
// ⭐ 表情包帧同步(biaoQing):其他玩家发送表情,对应座位显示
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing);
// SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发
EventCenter.Instance.AddEventListener(ConstEventString.FishFrozen, OnFishFrozen);
// ⭐ 召唤卡召唤通知:某玩家使用召唤卡,服务端已创建召唤鱼(仅播提示,实际鱼由 OnFishSpawn 创建)
EventCenter.Instance.AddEventListener(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice);
EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle);
// ⭐ 爆奖结束帧同步(baoJiangEnd):其他玩家爆奖完成,显示最终 BJEndUI 并加分
EventCenter.Instance.AddEventListener(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);
});
}
/// 打开离开游戏确认面板:点确认直接退出房间(无倒计时)
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);
}
}
/// 是否正在离开倒计时
public bool IsLeavingCountdown() => _isLeavingCountdown;
/// 取消离开倒计时
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秒自动回大厅)
///
/// ⭐ 每帧记录玩家操作,超时自动走离房流程回大厅。
/// 操作判定 = 屏幕输入(触摸/鼠标/键盘,含 UI 点击)**或** 自动游玩状态
/// (锁定中自动锁定/开火、自动攻击中——不能只看屏幕点击,详见 IsLocalPlayerAutoPlaying)。
/// 机器人/其他玩家的同步数据不算操作;退出确认面板开着时超时同样生效。
/// 只在资源加载完成后计时(加载慢不会被踢);离房流程进行中不重复触发。
/// 流程:前 idleExitTotalSeconds - idleExitPanelShowSeconds(默认60秒)全程无提示
/// → 最后 idleExitPanelShowSeconds(默认10秒)才弹出倒计时面板(autoExitGamePanel)
/// 逐秒倒数 → 走满 idleExitTotalSeconds(默认70秒)自动退出回大厅;
/// 期间任意操作或点 btn_CloseAutoExitGamePanel → 关闭面板并重新计时。
///
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();
}
/// 显示/刷新无操作退出倒计时面板(仅在剩余秒数变化时刷新文本,避免每帧 GC)
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}秒后自动退出)";
}
}
/// 隐藏无操作退出倒计时面板(有操作/关闭按钮/开始离房/面板隐藏时调用)
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;
}
}
/// btn_CloseAutoExitGamePanel:关闭倒计时面板并从头重新计时
private void OnCloseAutoExitPanel()
{
HideIdleExitWarning();
_lastOperationTime = Time.realtimeSinceStartup;
}
/// 本帧是否有玩家输入:触摸/鼠标按下、按住、松开 或 任意按键
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;
}
///
/// ⭐ 本地玩家是否处于"自动游玩"状态(与 HandleAutoFire 的 needAutoFire 同判定):
/// - 锁定模式:锁定开关开着且有锁定目标(炮台自动锁定/旋转/开火)
/// - 自动攻击模式:toggle_AutoAttack 开着
/// 这些模式下玩家不碰屏幕也在正常游戏,不算无操作。
/// 只判断本地玩家 currSitData,其他玩家/机器人的状态不算。
///
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;
}
/// 开始无操作计时(LoadResources 完成后调用,计时从当前时刻重新起算)
private void ArmIdleAutoExit()
{
_lastOperationTime = Time.realtimeSinceStartup;
_idleExitArmed = true;
}
#endregion
public virtual void OnDestroy()
{
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_BiaoQing, SyncGameData_BiaoQing);
// SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发
EventCenter.Instance.RemoveEventListener(ConstEventString.FishFrozen, OnFishFrozen);
EventCenter.Instance.RemoveEventListener(ConstEventString.SummonCardFishNotice, OnSummonCardFishNotice);
EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle);
EventCenter.Instance.RemoveEventListener(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();
}
///
/// ⭐ 长按锁定键(0.6秒)打开的"锁定设置"面板。
/// 默认 = 通用 SuoDing 面板;XJMH 覆写为原版 PW_SetLock 面板(其白名单通过覆写 IsFishAllowedBySuoDing 提供)。
///
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();
if (lockLongPress == null) lockLongPress = toggle_Lock.gameObject.AddComponent();
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);
});
// ⭐ 双倍开火开关:开启(重新)计时 15 秒 + 满环开始倒计时,关闭停表清环。
// 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();
LoopLaser();
SettingClose();
DaoJiShi();
HandleBotAI();
WebSocketSendList.FlushSyncQueues(Time.deltaTime);
}
float sittingTime = 10;
float currSittingTime;
///
/// 等待10秒未操作关闭设置面板
///
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 资源加载模块
///
/// ⭐ 资源流程令牌(static:新老游戏面板实例共享,GameMainPanel_FishBase 一脉)。
/// 每次 LoadResources / 卸载流程启动时递增并捕获;async 恢复后若令牌已变,
/// 说明期间已有新一轮流程启动(加载中退出、旧面板延迟卸载撞上新面板加载等),
/// 本轮残留流程必须立即终止 —— 否则旧加载流程会在卸载完成后继续
/// EnterRoom/出鱼/置位 isResources,把已退出房间的出鱼系统重新武装,
/// 污染下一个房间的鱼和路线(跨玩法路线混乱的流程层根源)。
///
private static int _resourceFlowToken = 0;
///
/// 外部触发资源加载:由 WebSocketReponseList 在"游戏面板已显示 → 游戏场景加载完成"后调用,
/// 保证加载顺序为 面板(加载页面)→ 场景 → 资源。
///
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;
// ⭐ 加载顺序(配套 AA 分包规划:路线最轻先进 → 鱼 → 音频最重殿后):
// ① 路线(几十KB,进房首屏关键,进度条 0→30%)
// ② 鱼预制体(30→70%)
// ③ 音频(70→80%,Union 全库音频是最重的一步,放最后不再拖住前段进度)
// EnterRoom 位置不动(出鱼系统就绪时序与原逻辑一致,仅资源加载顺序调整)
await MonsterMoveLine.LoadBezier();
if (flow != _resourceFlowToken) return; // ⭐ 期间已退出/切换,本轮作废
SetLoadProgress(0.3f);
await FishResourcesData.LoadFishAssetsData();
if (flow != _resourceFlowToken) return;
SetLoadProgress(0.7f);
await AudioResourcesData.LoadAudioGame();
if (flow != _resourceFlowToken) return;
await Addressables.LoadAssetsAsync("Audio", clip =>
{
if (flow != _resourceFlowToken) return; // 残留流程不再写面板字段
if (clip.name == "PW_Shoot")
bulletClip = clip;
}).Task;
if (flow != _resourceFlowToken) return;
SetLoadProgress(0.8f);
await Task.Delay(100);
if (flow != _resourceFlowToken) return;
CreateFishManager.Instance.EnterRoom(gameType);
CreateFishManager.Instance.isResources = true;
SetLoadProgress(0.9f);
// ⭐ 鱼创建由服务端 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;
}
await Task.Delay(2000);
if (flow != _resourceFlowToken) return; // ⭐ 2秒等待期间退出的情况最多,必须拦
SetLoadProgress(1);
await Task.Delay(300);
if (flow != _resourceFlowToken) return;
// ⭐ 切换房间:合并进度条走完 → 隐藏并销毁;平时隐藏面板自己的进度条
if (_changeRoomBar != null)
ClearChangeRoomBar();
else if (JinDuTiao != null)
JinDuTiao.Hide();
// ⭐ 无操作自动退出:加载完成,从现在开始计无操作时间
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;
await MonsterMoveLine.UnLoadBezier();
if (flow != _resourceFlowToken) return;
// ⭐ 清空子弹池 + 特效池(鱼池已在 UnLoadFishAssetsData 内清理)
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
_resourcesUnloaded = true;
}
/// 资源是否已卸载(避免 OnHide 重复卸载)
private bool _resourcesUnloaded = false;
/// 离房 socket 响应等待信号
private TaskCompletionSource _leaveRoomResponseTcs;
/// 正在离开的游戏面板类型(完成后关闭它切到 home)
private UIPanel _leavingGamePanelType;
///
/// 请求离开房间:发送离房 socket 请求 + 启动进度条卸载资源。
/// 进度条走到 0.9 后等待 socket 返回 LeaveRoomResult,
/// 收到返回后走完剩余进度并切回 home 界面。
///
public void RequestLeaveRoom()
{
if (_leaveRoomResponseTcs != null) return; // 防重复请求
// 先创建等待信号(在发送请求前创建,避免响应太快丢失)
_leaveRoomResponseTcs = new TaskCompletionSource();
// 发送离房请求
WebSocketSendList.LeaveRoom(
GameManager.Instance.gameData.currRoomData.roomId,
currSitData.user);
//CreateFishManager.Instance.fangzhu = false;
// 启动带进度条的卸载流程
BeginLeaveRoomUnload();
}
///
/// 离房卸载流程:进度条到 0.9 后等待 socket 响应,
/// 收到信号后走到 1.0 并切回 home 界面。
/// ⭐ 用 try-finally 保证无论中间是否异常,最终一定会关闭 GamePanel 并切回 Home
///
private async void BeginLeaveRoomUnload()
{
if (_resourcesUnloaded)
{
_leaveRoomResponseTcs = null;
return;
}
// ⭐ 使在途的 LoadResources 立即作废(加载中退出的场景):
// 否则残留加载流程会在卸载完成后继续 EnterRoom/出鱼/置位 isResources,
// 把已退出房间的出鱼系统重新武装,污染下一个房间的鱼和路线。
// 本流程不再加恢复检查:期间不可能有新加载启动(home 在 finally 之后才显示),
// 且必须完整走完(finally 关面板切 home),保证退出流程不被打断。
_resourceFlowToken++;
try
{
if (JinDuTiao != null) JinDuTiao.Show();
SetUnloadProgress(0.2f);
await FishResourcesData.UnLoadFishAssetsData();
SetUnloadProgress(0.4f);
await AudioResourcesData.UnLoadAudioGame();
SetUnloadProgress(0.6f);
await MonsterMoveLine.UnLoadBezier();
SetUnloadProgress(0.8f);
await Task.Delay(100);
CreateFishManager.Instance.ExitRoom();
CreateFishManager.Instance.isResources = false;
// ⭐ 清空子弹池 + 特效池(鱼池已在 UnLoadFishAssetsData 内清理)
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
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);
if (JinDuTiao != null) JinDuTiao.Hide();
}
}
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
{
if (JinDuTiao != null)
{
try { JinDuTiao.Hide(); } catch { }
}
// 进入 home 界面(关闭当前游戏面板)
switch (GameManager.Instance.gameData.currGameType)
{
case GameType.JQQD:
break;
case GameType.defaultPanel:
break;
case GameType.FLZY:
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelFLZY);
break;
case GameType.YYSC:
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelYYSC);
break;
case GameType.ZGXQ:
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelZGXQ);
break;
case GameType.XJMH:
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelXJMH);
break;
case GameType.HHWK:
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelHHWK);
break;
default:
break;
}
UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, GameManager.Instance.gameData.currGameType);
}
}
}
///
/// socket 收到 LeaveRoomResult 后调用,解除 0.9 的等待。
///
public void OnLeaveRoomResultReceived(UIPanel gamePanel)
{
_leavingGamePanelType = gamePanel;
_leaveRoomResponseTcs?.TrySetResult(true);
}
#endregion
#region 切换房间(重新走一遍加入房间流程)
/// 正在切换房间(离房完成后不回大厅,直接重新快速加入新房间)
private bool _isChangeRoom = false;
///
/// 切换房间共享进度条:离房时从旧面板挪到 uiRoot(不随旧面板销毁),
/// 新面板 LoadResources 继续用它,把"退出卸载 + 换房 + 加入加载"合并成一条进度条:
/// 卸载段 0~45% → 网络段(QuickJoin/连socket/JoinRoom) 45~55% → 加载段 55~100%。
/// null 表示非切换房间流程(走面板自己的 JinDuTiao)。
///
private static ProgressBar _changeRoomBar;
/// 卸载段进度写入:切换房间时映射到合并进度条前半段(0~45%),平时直接写面板进度条
private void SetUnloadProgress(float p)
{
if (JinDuTiao == null) return;
JinDuTiao.SetProgress(_isChangeRoom ? p * 0.45f : p);
}
/// 加载段进度写入:切换房间时映射到合并进度条后半段(55~100%),平时直接写面板进度条
private void SetLoadProgress(float p)
{
if (_changeRoomBar != null)
_changeRoomBar.SetProgress(0.55f + p * 0.45f);
else if (JinDuTiao != null)
JinDuTiao.SetProgress(p);
}
/// 写切换房间合并进度条(网络段:QuickJoin返回/JoinRoom已发送)
private static void SetChangeRoomBarProgress(float p)
{
if (_changeRoomBar != null) _changeRoomBar.SetProgress(p);
}
/// 隐藏并销毁切换房间合并进度条(加载完成或切房失败时调用)
private static void ClearChangeRoomBar()
{
if (_changeRoomBar == null) return;
var bar = _changeRoomBar;
_changeRoomBar = null;
try
{
bar.Hide();
if (bar != null) Destroy(bar.gameObject);
}
catch { }
}
///
/// 切换房间入口:复用离房流程(LeaveRoom → 卸载资源 → 关面板),
/// 离房完成后由 BeginLeaveRoomUnload 的 finally 重新走加入房间流程。
///
public void StartChangeRoom()
{
if (_leaveRoomResponseTcs != null) return; // 正在离房/切换中,防重复
_isChangeRoom = true;
// 与点退出一致:离房期间屏蔽帧同步和爆奖特效
_isLeavingCountdown = true;
RequestLeaveRoom();
}
///
/// 重新走加入房间流程(与 UIHome_BY_SelectRoomLevelPanel.OnSelectRoom 快速进入分支完全一致):
/// /api/Room/QuickJoin(同场次同等级,服务器分配新房间)→ 连新房 game socket → JoinRoom(-1) 由服务器分配座位,
/// 之后服务端推送 RoomJoinNotice → 重新创建游戏面板 + 重载场景,完成切换。
/// ⭐ 切房全程共用一条进度条:卸载段0~45%已在离房时走完,这里推进网络段45~55%,
/// 剩余加载段55~100%由新面板 LoadResources 继续。
///
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();
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:列表生成 + 发送 + 广播接收)
/// spine 表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段
private List BiaoQingSpineList =>
biaoQingConfig != null ? biaoQingConfig.list_BiaoQingSpine : list_BiaoQingSpine;
/// 文字表情列表:优先读 biaoQingConfig 配置资产(多场景共用一份),未拖资产时回退面板字段
private List BiaoQingZiList =>
biaoQingConfig != null ? biaoQingConfig.list_BiaoQingZi : list_BiaoQingZi;
/// 表情列表是否已生成(首次打开面板时生成一次)
private bool _biaoQingListBuilt = false;
/// btn_BiaoQing 点击:开/关表情面板(首次打开生成列表)
private void OnBiaoQingButtonClick()
{
if (obj_BiaoQingPanel == null) return;
if (obj_BiaoQingPanel.activeSelf)
{
obj_BiaoQingPanel.SetActive(false);
return;
}
// ⭐ 先激活面板再生成列表/设置文字:TMP 在未激活状态下设置 text 可能不刷新
obj_BiaoQingPanel.SetActive(true);
BuildBiaoQingList();
}
///
/// 生成表情列表(只生成一次),两种形式分开处理:
/// ① spine 表情:遍历 BiaoQingSpineList(优先 biaoQingConfig 资产),每个配置里的每个动画名克隆一个 spine 项
/// (child0=SkeletonGraphic → 换骨骼资源 + 循环播放该动画做列表预览);
/// ② 文字表情:BiaoQingZiList 每条文字克隆一个文字项(child0=TextMeshProUGUI → 设置文字)。
///
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()
: 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()
: null;
if (tmp != null) tmp.text = text;
string zi = text;
var btn = GetBiaoQingItemButton(item);
if (btn != null) btn.onClick.AddListener(() => SendBiaoQing(-1, zi));
}
}
}
/// 取表情项上的按钮(优先根节点,其次子节点)
private Button GetBiaoQingItemButton(GameObject item)
{
var btn = item.GetComponent