Files
5.24BuYu/Assets/MyGame/aa/UI/Game/GameMainPanel_FishBase.cs
T
2026-09-28 20:19:27 +08:00

5646 lines
245 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.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;
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;
// ⭐ 双倍开火: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<Component, object> _frozenAnimSpeeds = new Dictionary<Component, object>();
// ⭐ 暴走/冰封本地数量:因为暂无扣减 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;
[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; //退出游戏按钮
// XJMH 长按炮台扇形切换
[Header("XJMH 炮台长按切换")]
[Tooltip("长按炮台触发扇形切换的阈值(秒)")]
public float turretLongPressThreshold = 0.5f;
/// <summary>是否正在等待长按炮台判定(按下炮台但尚未超过阈值)</summary>
protected bool _isPendingTurretLongPress = false;
/// <summary>炮台扇形切换UI是否已显示</summary>
protected bool _isTurretSwitcherActive = false;
// ⭐ 起飞炮台(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<int> paoBeiList = new List<int>();
public ProgressBar JinDuTiao;
#region UI
bool isfirstShow = true;
protected override void OnShow(object args = null)
{
base.OnShow(args);
// ⭐ 无操作自动退出:加载期间不计时,LoadResources 完成后重新开始计;确保倒计时面板初始隐藏
_idleExitArmed = false;
HideIdleExitWarning();
LoadResources();
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)
{
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)
{
mySitAnim.transform.position = currSitData.turretParent.position;
// ⭐ 进入游戏圆圈: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;
if (currSitData.tMP_GoldNum != null)
currSitData.tMP_GoldNum.text = user.assets.gold.ToString();
}
}
/// <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();
// ⭐ 清理 XJMH 扇形切换UI(如果还在显示)
CleanupTurretSwitcher();
// ⭐ 解绑长按 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 开关炮台面板
/// <summary>已订阅长按事件的座位(换座位/重进房间时先解绑避免重复订阅)</summary>
private SitPosData _openPTHookedSit;
/// <summary>缓存的长按处理器(解绑用)</summary>
private Action _openPTLongPressHandler;
/// <summary>
/// 订阅本地座位的 img_OpenPT 长按事件:长按开关炮台面板(subUIManager,与 btn_ChangeTurret 同逻辑)。
/// XJMH 模式没有炮台面板(用扇形切换),不订阅。
/// </summary>
private void HookOpenPTLongPress(SitPosData sit)
{
// 先解绑旧订阅
UnhookOpenPTLongPress();
if (sit == null || sit.turretControlMode == TurretControlMode.XJMH) return;
_openPTLongPressHandler = () =>
{
// 与 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());
}
};
sit.OnOpenPTLongPress += _openPTLongPressHandler;
_openPTHookedSit = sit;
}
/// <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();
GameManager.Instance.OnUserDataChanged += RefreshGGKCount;
baseAttackTime = attackTime;
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());
}
});
btn_Setting.onClick.AddListener(() => {subUIManager.ShowAsync(UIPanel.SettingPanel); });
// ⭐ 各游戏prefab未拖拽表情按钮时为null,做保护(XJMH面板没有该按钮)
if (btn_BiaoQing != null)
btn_BiaoQing.onClick.AddListener(OnBiaoQingButtonClick);
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<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);
// 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);
btn_TuJian.onClick.AddListener(() =>
{
subUIManager.ShowAsync(UIPanel.GameTuJian);
StartCoroutine(CloseSettingList());
});
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 virtual void 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);
// 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);
// ⭐ 离场清理:子弹池 + 特效池彻底清空,防止内存泄漏
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
// ⭐ 清理未完成的 not-self 爆奖 pending(避免悬空引用)
BaoJiangNotSelfHandler.ClearAll();
// ⭐ 清理冰冻特效实例:Instantiate 无父对象 → 挂在场景根,面板销毁不会带走,
// 冰冻中切房会残留一块冰冻蒙皮,必须手动销毁
if (_bingFengInstance != null)
{
Destroy(_bingFengInstance);
_bingFengInstance = null;
}
_frozenAnimSpeeds.Clear();
}
public virtual void Start()
{
btn_SettingList.onClick.AddListener(() =>
{
if (isOpenSetting) StartCoroutine(CloseSettingList());
else StartCoroutine(OpenSettingList());
});
if (toggle_Lock != null)
{
toggle_Lock.onValueChanged.AddListener(on =>
{
if (!on)
{
// ⭐ 取消锁定开关 → 重置自动锁定激活状态,下次需重新手动锁定激活
_autoLockActivated = 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 += () =>
{
subUIManager.ShowAsync(UIPanel.GameSuoDing);
};
}
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 订阅广播
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;
/// <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;
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;
await AudioResourcesData.LoadAudioGame();
if (flow != _resourceFlowToken) return; // ⭐ 期间已退出/切换,本轮作废
SetLoadProgress(0.2f);
await MonsterMoveLine.LoadBezier();
if (flow != _resourceFlowToken) return;
SetLoadProgress(0.4f);
await FishResourcesData.LoadFishAssetsData();
if (flow != _resourceFlowToken) return;
SetLoadProgress(0.6f);
await Addressables.LoadAssetsAsync<AudioClip>("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;
}
/// <summary>资源是否已卸载(避免 OnHide 重复卸载)</summary>
private bool _resourcesUnloaded = false;
/// <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();
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);
}
}
}
/// <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>加载段进度写入:切换房间时映射到合并进度条后半段(55~100%),平时直接写面板进度条</summary>
private void SetLoadProgress(float p)
{
if (_changeRoomBar != null)
_changeRoomBar.SetProgress(0.55f + p * 0.45f);
else if (JinDuTiao != null)
JinDuTiao.SetProgress(p);
}
/// <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();
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);
}
#endregion
#region 大喇叭/召唤卡(Left道具快捷按钮)
/// <summary>
/// 大喇叭发送:输入弹幕 → /api/Hall/SendBroadcast 全服广播
/// (与背包大喇叭 OnClickFaSong 流程一致),发送成功后清空输入框并关闭面板。
/// </summary>
private void OnClickDaLaBaSend()
{
string msg = tmp_DaLaBaInput?.text?.Trim();
if (string.IsNullOrEmpty(msg))
{
UIManager.Instance.ShowBoardError("请输入广播内容");
return;
}
APIRequestList.SendBroadcast("广播:" + msg, (result) =>
{
// 发送成功:清空输入框并关闭面板(广播内容会通过 LoginBroadcast 全服弹幕显示)
if (tmp_DaLaBaInput != null) tmp_DaLaBaInput.text = "";
if (obj_DaLaBaPanel != null) obj_DaLaBaPanel.SetActive(false);
});
}
/// <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>
/// 刷新所有道具数量显示:默认先 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>重建当前展开的二级列表(道具使用后数量变化时刷新显示,不改变开关状态)</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);
RefreshPropCounts(); // ⭐ 使用后拉最新背包,刷新所有道具数量显示
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);
RefreshPropCounts(); // ⭐ 使用后拉最新背包,刷新所有道具数量显示
}
/// <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;
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);
// 刷新用户数据,同步道具数量与VIP经验
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
RefreshPropCounts(false); // 数据已最新:刷新一级角标 + 二层数量显示
});
});
//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>
/// 服务端 SummonCardFishNotice 房间广播回调:某玩家使用召唤卡,服务端已创建召唤鱼。
/// 仅播放召唤提示(飞机场召唤Boss鱼,其他场次召唤黄金鱼),
/// 实际鱼对象由服务端 OnFishSpawn 下发创建,这里不重复生成鱼。
/// </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($"{data.userName} 使用召唤卡召唤了一条{fishName}");
}
/// <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;
if (targetSit.tMP_GoldNum != null)
targetSit.tMP_GoldNum.text = targetSit.goldNum.ToString();
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);
// 本地玩家使用成功:刷新用户数据(鱼雷库存已扣)+ 道具菜单数量
if (data.userId == GameManager.Instance.user.id)
{
APIRequestList.GetUser((user) =>
{
if (user != null)
GameManager.Instance.UpdataUserData(user);
RefreshPropCounts(false); // 数据已最新:刷新一级角标 + 二层数量显示
});
}
}
/// <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>进入游戏圆圈 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()
{
// ⭐ 退出倒计时期间屏蔽本地玩家手动输入(旋转/开火/锁定),其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
CrossPlatformInput input = GetInput();
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);
// XJMH 模式:检测是否按在当前炮台上(长按触发扇形切换)
_isPendingTurretLongPress = !isChangingTurret
&& currSitData.turretControlMode == TurretControlMode.XJMH
&& IsTouchOnCurrentTurret(input.position);
if (!isChangingTurret)
{
// ⭐ 起飞炮台(FlyTurretData):
// 飞行/悬停中 → 点击空白处=改目标(点到哪飞到哪),立即响应;
// 未起飞 → 仅记录轻点判定,继续走下方正常开火流程(松开时判定轻点才起飞),
// 保证平时按住扫射/自动开火与普通炮台完全一致
if (currSitData.currentTurret is FlyTurretData flyTurret)
{
if (flyTurret.IsFlying)
{
if (!_isPendingTurretLongPress && flyTurret.TakeoffToScreenPos(input.position))
{
HookFlyTurretUpwardFire(flyTurret);
return;
}
}
else if (!_isPendingTurretLongPress && !currSitData.isDrillActive)
{
_flyTapPending = true;
_flyTapTime = Time.time;
_flyTapPos = input.position;
}
}
// ⭐ 本地金币校验:没钱不切换 Attack 状态,也不广播,避免无效请求
if (!HasEnoughGoldToFire(toggle_Three.isOn))
return;
// 按下即进入 Attack:短按→射击,长按超过阈值后切回 Idle 弹扇形
currSitData.ChangeState(TurretState.Attack);
SyncTurretState(TurretState.Attack);
}
}
// 长按
if (input.isPressed)
{
// XJMH 长按炮台判定:超过阈值后弹出扇形切换UI
if (_isPendingTurretLongPress && !_isTurretSwitcherActive)
{
changeTurretTime += Time.deltaTime;
if (changeTurretTime >= turretLongPressThreshold)
{
_isPendingTurretLongPress = false;
_isTurretSwitcherActive = true;
// 扇形切换弹出时切回 Idle,停止射击动画
currSitData.ChangeState(TurretState.Idle);
SyncTurretState(TurretState.Idle);
OnTurretSwitcherShow(input.position);
}
}
if (_isTurretSwitcherActive)
{
OnTurretSwitcherUpdate(input.position);
return;
}
if (isChangingTurret)
{
HandleChangeTurretHold();
return;
}
// XJMH 待判定长按:不旋转(手指在炮台上旋转无意义),但仍允许开火(短按=射击)
if (_isPendingTurretLongPress)
{
TryFire();
return;
}
// ⭐ 起飞炮台:飞行/悬停期间手指按住不旋转不开火(炮口固定朝上,子弹自行向上发射)
if (currSitData.currentTurret is FlyTurretData flyHold && flyHold.IsFlying)
return;
// 锁定模式:不手动旋转,交给 HandleAutoRotate + HandleAutoFire
// ⭐ 特殊鱼爆奖期间例外:自动锁定已禁用,玩家点击仍可手动旋转炮台瞄准发射方向
if (toggle_Lock.isOn && !(currSitData != null && currSitData.isDrillActive))
{
// ⭐ 自动攻击开启时例外:按住屏幕(非UI)额外走 TryFire 手动开火,
// 与 HandleAutoFire 的自动开火叠加 = 双倍射速(clickTime/autoFireTimer 两条独立冷却)
if (currSitData != null && currSitData.IsAutoAttackOn && !IsPointerOverUI(input.position))
TryFire();
return;
}
// UI 上不旋转不发射
if (IsPointerOverUI(input.position)) return;
HandleRotate(input.position);
TryFire();
}
// 松开
if (input.wasReleasedThisFrame)
{
// XJMH 扇形切换释放:根据滑动位置选择炮台
if (_isTurretSwitcherActive)
{
_isTurretSwitcherActive = false;
_isPendingTurretLongPress = false;
changeTurretTime = 0;
OnTurretSwitcherHide(input.position);
return;
}
// XJMH 短按炮台释放(未触发长按)→ 正常回到 Idle
if (_isPendingTurretLongPress)
{
_isPendingTurretLongPress = false;
changeTurretTime = 0;
}
if (isChangingTurret)
{
isChangingTurret = false;
changeTurretTime = 0;
CloseChangeTurretUI();
return;
}
// ⭐ 起飞炮台:轻点空白处=起飞(按下时已正常开火,松开时判定轻点→触发起飞)
// 长按/拖动扫射时此判定不通过,正常走下方 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;
}
// ==================== XJMH 炮台扇形切换 ====================
/// <summary>
/// 检测触摸点是否在当前炮台的 RectTransform 范围内(XJMH 长按切换用)。
/// </summary>
protected bool IsTouchOnCurrentTurret(Vector2 screenPos)
{
if (currSitData == null || currSitData.currentTurret == null) return false;
RectTransform rt = currSitData.currentTurret.GetComponent<RectTransform>();
if (rt == null) return false;
Canvas canvas = UIManager.Instance != null && UIManager.Instance.uiRoot != null
? UIManager.Instance.uiRoot.GetComponent<Canvas>()
: null;
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
return RectTransformUtility.RectangleContainsScreenPoint(rt, screenPos, cam);
}
/// <summary>长按炮台达到阈值后弹出扇形切换UI(子类覆写实现具体UI)。</summary>
protected virtual void OnTurretSwitcherShow(Vector2 pressPos) { }
/// <summary>扇形切换UI显示期间,手指滑动时更新高亮(子类覆写)。</summary>
protected virtual void OnTurretSwitcherUpdate(Vector2 currentPos) { }
/// <summary>手指松开,关闭扇形切换UI并根据滑动位置切换炮台(子类覆写)。</summary>
protected virtual void OnTurretSwitcherHide(Vector2 releasePos) { }
/// <summary>清理扇形切换UI状态(面板隐藏时调用)</summary>
protected virtual void CleanupTurretSwitcher()
{
_isTurretSwitcherActive = false;
_isPendingTurretLongPress = false;
changeTurretTime = 0;
}
#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()
{
// ⭐ 退出倒计时期间屏蔽本地玩家自动旋转,其他玩家和机器人不受影响
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);
}
}
// ⭐ 双倍开火倒计时:child0 的环形填充图(Image Filled 360°)随时间耗尽,
// 15 秒后自动关闭。SetIsOnWithoutNotify 不触发 onValueChanged(避免重置计时逻辑);
// 关闭后三处开火入口读 isOn=false,后续 SyncFire 的 twoRTruect 自动恢复单倍
if (toggle_Shuang != null && toggle_Shuang.isOn && shuangDuration > 0)
{
var fill = GetShuangFillImage();
if (fill != null)
fill.fillAmount = Mathf.Clamp01(currShuangTime / shuangDuration);
currShuangTime -= Time.deltaTime;
if (currShuangTime <= 0)
{
toggle_Shuang.SetIsOnWithoutNotify(false);
currShuangTime = 0f;
if (fill != null) fill.fillAmount = 0f;
}
}
}
/// <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()
{
// ⭐ 特殊鱼炮台(钻头)活动期间禁用普通开火,点击只发射钻头
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))
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);
}
// ==================== 起飞炮台(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)) 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 为空,跳过本次开火");
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;
StartCoroutine(LaserHitRoutine(sit, target, bullitIDs[i]));
}
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)
{
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)
{
// 真人玩家击杀:直接发后端
var v2 = GameManager.Instance.gameData.currRoomData;
WebSocketSendList.KillFish(v2.roomId, sit.user,
target.monsterData.fishTypeConfig.fishNumber, target.guid,
(int)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)
{
autoFireTimer = 0f;
return;
}
autoFireTimer += Time.deltaTime;
if (autoFireTimer < attackTime) return;
// ⭐ 本地金币校验:没钱直接返回,不切换攻击状态、不发送开炮请求
// autoFireTimer 保持满,金币恢复后立即开火
if (!HasEnoughGoldToFire(toggle_Three.isOn))
return;
// ⭐ 子弹上限校验(先于状态切换和扣费)
if (IsBulletLimitReached(currSitData.user))
{
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);
}
#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;
// ⭐ 散射激光模式标记(座位用它激活左右起点球父对象):战神+三发+锁定开着+有锁定目标就算散射中,
// 不依赖实际凑满几个目标(目标不足时散射模式开着也要显示起点球)
// ⭐ 正常炮台三合一打一个,不算散射激光模式(不显示左右起点球)
currSitData.isScatterLaserOn = toggle_Three != null && toggle_Three.isOn
&& toggle_Lock != null && toggle_Lock.isOn
&& currSitData.isLock
&& currSitData.currentZhanShenData != null;
// ⭐ 三发锁定圈(3个圈)只在战神模式显示;正常炮台三合一只有主锁圈(1个)
bool isTripleLock = currSitData.isScatterLaserOn;
if (!isTripleLock)
{
currSitData.HideTripleLockIcons();
return;
}
// 已经有三发目标 → 跳过
if (currSitData.tripleLockTargets.Count > 0) return;
var targets = FindFireTargets(3);
if (targets.Count > 0)
currSitData.SetTripleLockTargets(targets);
}
/// <summary>自动锁定是否已激活:玩家首次手动锁定一条鱼后激活,该鱼死亡后开始自动转换目标</summary>
private bool _autoLockActivated = false;
private void HandleLock()
{
// ⭐ 退出倒计时期间屏蔽本地玩家手动锁定,其他玩家和机器人不受影响
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;
// ⭐ 标记为手动锁定:不受路径进度(0.1~0.9)限制,鱼离场前一直锁定
currSitData.isManualLock = true;
currSitData.SetLockTarget(monster, lockTr);
SyncLock(new List<string> { monster?.guid }, colliderIndex);
// ⭐ 手动锁定成功 → 激活自动锁定(该鱼死亡后自动转换目标)
_autoLockActivated = true;
}
private void UpdateLockFX()
{
foreach (var sit in list_Sits)
{
if (!sit.isLock || sit.monCotl_Lock == null) continue;
// ⭐ 切换锁定点(小白龙等切换模型后锁定点失效):
// 当前 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];
var monster = sit.tripleLockTargets[targetIdx];
var hitPos = sit.tripleLockPositions[targetIdx];
if (icon == null || monster == null) continue;
// ⭐ 同样为三发目标切换锁定点
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()
{
// ⭐⭐⭐ 三发模式:每个锁位独立维护 —— 鱼死了 或 路径离场时,只补那一个锁位,其他锁位不动
// (Count 由 SetTripleLockTargets 建立;HideTripleLockIcons 才清成 0)
if (currSitData.tripleLockTargets.Count > 0)
{
// ⭐ 补齐到3个锁位(三发=最多3个目标):某位暂时没鱼可补就留空占位,下一帧继续尝试
while (currSitData.tripleLockTargets.Count < 3)
{
currSitData.tripleLockTargets.Add(null);
currSitData.tripleLockPositions.Add(null);
}
for (int i = currSitData.tripleLockTargets.Count - 1; i >= 0; i--)
{
var t = currSitData.tripleLockTargets[i];
// ⭐ 鱼死了(被回收/销毁) → 补这一个锁位
if (t == null || !t.gameObject.activeInHierarchy)
{
RefillTripleLockSlot(i);
continue;
}
// ⭐ 主锁位是玩家手动锁的 → 离场前一直跟,不自动更换
if (i == 0 && currSitData.isManualLock) continue;
// ⭐ 自动锁定的锁位:路径离场(超出 0.1~0.9) 或 被 SuoDing 取消勾选
// → 只补这一个锁位,其他锁位不动
if (!IsFishPathLockable(t) || !IsFishAllowedBySuoDing(t))
RefillTripleLockSlot(i);
}
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>
/// ⭐ 单独锁定白名单(SuoDingPanel):是否允许自动锁定这条鱼。
/// - 面板从未打开过(不存在) 或 一个都没勾 → 不限制(走原有优先级规则)
/// - 勾选了至少一条 → 只允许锁定勾选的鱼(固定锁定这几个选中的鱼)
/// 手动锁定(玩家点击选鱼)不受此限制。
/// </summary>
private bool IsFishAllowedBySuoDing(MonsterContral m)
{
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>
/// ⭐ SuoDing 面板关闭时应用勾选结果(由 SuoDingPanel.OnHide 回调):
/// 1. 打开锁定开关(toggle_Lock)
/// 2. 直接激活自动锁定(跳过"先手动锁定一条鱼"的前提)
/// 3. 当前手动锁定的目标转为自动锁定 → 立即受白名单约束
/// 白名单(selectedSet)由面板 Toggle 实时同步;之后的自动锁定仍按原有优先级
/// (特殊Boss > Boss鱼 > 特殊鱼 > 黄金鱼)只从选中的鱼里选目标,选中的鱼死光则不锁定。
/// </summary>
public void ApplySuoDingSelection()
{
// 打开锁定开关(已打开则不变;关闭时 onValueChanged 的重置逻辑只在 !on 分支,不受影响)
if (toggle_Lock != null && !toggle_Lock.isOn)
toggle_Lock.isOn = true;
// ⭐ 面板选完即应用:自动锁定立即开始找目标
_autoLockActivated = true;
// ⭐ 当前若是手动锁定 → 转为自动锁定:
// 在勾选白名单里 → 继续锁(无缝);不在白名单/即将离场 → HandleAutoLock 立即换白名单里的目标
if (currSitData != null && currSitData.isManualLock)
currSitData.isManualLock = false;
}
private void HandleAutoLock()
{
// ⭐ 退出倒计时期间屏蔽本地玩家自动锁定,其他玩家和机器人不受影响
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) → 保持锁定;
// 但场上出现严格更高优先级的鱼时立即切换过去
// ③ 自动锁定,路径离场(超出 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))
{
// 当前目标仍有效且在白名单内 → 没有更高优先级目标就保持锁定,有就落到下面切换
if (!HasHigherPriorityTarget(currSitData.monCotl_Lock)) return;
}
// 当前目标离场(超出 0.1~0.9) 或 被取消勾选(不在白名单) → 落到下面重新找目标
}
/* ==================== 不立即抢版本(2026-09-28 注释保留,方便回退) ====================
// ⭐⭐⭐ 换目标规则:
// ① 手动锁定 → 玩家自己点的鱼,离场前一直跟,死了才换
// ② 自动锁定,路径仍在可锁范围(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;
// 路径离场 / 不在白名单 → 重新找目标
}
==================== 不立即抢版本结束 ==================== */
// 当前锁定目标已死亡/失效 → 自动找下一个目标
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)
{
if (currSitData == null) return false;
int count = isSanShe ? 3 : 1;
long need = currSitData.consumeGoldNum * count;
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;
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 分散打三个用)
// 正常炮台三合一打主锁目标(monCotl_Lock 已由 SyncLock/SyncBotLock 同步),不需要 monsterIDs
if (isSanShe && isLock && currSitData.currentZhanShenData != null)
{
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;
// 三射 = 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, currSitData.consumeGoldNum, count, timestamp, "63215f0afa3f5b25");
// FireCannon:roomId, userId, gold, count, timestamp, sign, extra
WebSocketSendList.FireCannon(
roomdata.roomId,
user.id,
currSitData.consumeGoldNum,
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;
// ⭐ 座位镜像: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];
// ⭐ 座位镜像:网络角度是"源角度"(发送方下方座位系),按该座位显示位置还原成世界角度
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);
if (data.targetGuid == null)
{
list_Sits[dispPos].ClearLock();
return;
}
MonsterContral monster = CreateFishManager.Instance.fishDatas_hashCodekey[data.targetGuid[0]];
// ⭐ 统一用 ResolveLockTransform:colliderList 第一个活跃 → lockPoint → transform
// 注:网络同步过来的 colliderIndex 在统一逻辑下不再使用,因为客户端之间
// colliderList 顺序可能不一致,统一用"第一个活跃"更稳定
Transform hitPos = ResolveLockTransform(monster);
list_Sits[dispPos].SetLockTarget(monster, hitPos);
}
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];
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.tMP_TurretConsumeNum.text = data.cannonMultiple.ToString();
}
/// <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);
if (sit.tMP_TurretConsumeNum != null)
sit.tMP_TurretConsumeNum.text = multiple.ToString();
}
/// <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) return;
var root = CreateFishManager.Instance.fishRoot;
if (root == null) return;
_frozenAnimSpeeds.Clear();
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;
_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.state != PlayState.Playing) continue;
_frozenAnimSpeeds[pd] = 1f;
pd.Pause();
}
}
private void ResumeAllFishAnimations()
{
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;
case UnityEngine.Playables.PlayableDirector pd: pd.Resume(); break;
}
}
_frozenAnimSpeeds.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.tMP_GoldNum.text = goldNum.ToString();
// XJMH 模式不显示钻石,tMP_DiamondNum 已隐藏
if (sit.turretControlMode != TurretControlMode.XJMH && sit.tMP_DiamondNum != null)
sit.tMP_DiamondNum.text = diamondNum.ToString();
sit.tMP_TurretConsumeNum.text = consumeGold.ToString();
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];
if (sit.tMP_TurretConsumeNum != null)
sit.tMP_TurretConsumeNum.text = sit.consumeGoldNum.ToString();
// ⭐ 同步 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);
// ⭐ 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
}