Files
5.24BuYu/Assets/MyGame/aa/UI/Game/GameMainPanel_FishBase.cs
T
2026-09-04 23:38:33 +08:00

4346 lines
177 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.UserData_SubClass;
using Assets.MyGame.Scripts.Net.APIResponses.Enum;
using Assets.MyGame.Scripts.Net.WebResponses;
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("游戏场景")]
public string sceneName;
public GameTypeEnum gameType;
[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("表情包")]
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 表情配置:每个元素=一个spine文件+动画名列表,按动画名逐个克隆成表情项")]
public List<BiaoQingSpineData> list_BiaoQingSpine = new List<BiaoQingSpineData>();
[Tooltip("文字表情内容列表,一条文字克隆一个表情项")]
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;
public Toggle toggle_BaoZou;
public TextMeshProUGUI tmp_BaoZouCount;
public Image img_BaoZou;
float frenzyDuration = 5f; // 疯狂状态持续时间
public float currFrenzyTime = 0f;
public Toggle toggle_BingFeng;
public TextMeshProUGUI tmp_BingFengCount;
public Image img_BingFeng;
public GameObject bingFengEffect; // 冰封特效预制体(首次Instantiate,之后激活/隐藏)
private GameObject _bingFengInstance; // 克隆出来的实例
float bingFengDuration = 15f; // 冰封状态持续时间(服务端 FishFrozen 延长 15 秒,客户端写死 15s 倒计时)
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;
// 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();
RefreshGGKCount();
//UIManager.Instance.ShowBoard("太好看了");
var v = JsonConvert.DeserializeObject<WebSocketResponseBase<RoomJoinNotice>>(args.ToString());
if (v.arguments.Count > 0)
{
RoomJoinNotice roomJoinNotice = v.arguments[0];
// ⭐ 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)
{
paoBeiList = 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 == roomJoinNotice.position)
{
currSitData = item;
// ⭐ 长按本地座位的 img_OpenPT 开关炮台面板(与 btn_ChangeTurret 同逻辑)
HookOpenPTLongPress(currSitData);
// ⭐ 本地座位自动攻击关闭时广播 Idle 状态给其他客户端
HookAutoAttackOff(currSitData);
}
else
{
item.btn_TurretAdd.gameObject.SetActive(false);
item.btn_TurretMinus.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;
}
else
{
var targetSit = list_Sits.Find(s => s.posID == 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();
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);
// ⭐ 重置本地道具计数,下次进入房间重新从服务端读取
_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); });
btn_BiaoQing.onClick.AddListener(OnBiaoQingButtonClick);
btn_TuiChu.onClick.AddListener(() =>
{
StartLeaveCountdown();
});
// ⭐ 切换房间:离房后重新走一遍加入房间流程(QuickJoin同场次同等级 → 连新房socket → JoinRoom)
// 各游戏prefab未拖拽该按钮时为null,做保护
if (btn_ChangeRoom != null)
btn_ChangeRoom.onClick.AddListener(StartChangeRoom);
// 服务器广播监听
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);
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;
}
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<SyncGameData>(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle);
EventCenter.Instance.RemoveEventListener<SyncGameData>(ConstEventString.BaoJiangEnd, OnBaoJiangEnd);
// ⭐ 离场清理:子弹池 + 特效池彻底清空,防止内存泄漏
BulletPool.Instance.ClearAll();
EffectPool.Instance.ClearAll();
// ⭐ 清理未完成的 not-self 爆奖 pending(避免悬空引用)
BaoJiangNotSelfHandler.ClearAll();
}
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 = currSitData.posID,
frameType = FrameDataType.changeAutoTarget,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_ChangeAutoTarget { targetGuid = null }
};
WebSocketSendList.SyncGameData(sgd);
}
});
}
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);
});
}
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()
{
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 资源加载模块
public async void LoadResources()
{
// ⭐ 切换房间:隐藏新面板自带的进度条,继续用离房时留下的合并进度条
// (挂在 uiRoot、盖在最上层),避免"退出+加入"显示两条进度条
if (_changeRoomBar != null)
{
if (JinDuTiao != null) JinDuTiao.Hide();
_changeRoomBar.transform.SetAsLastSibling();
}
CreateFishManager.Instance.isResources = false;
// ⭐ 重置卸载标记,下次进入可重新加载
_resourcesUnloaded = false;
await AudioResourcesData.LoadAudioGame();
SetLoadProgress(0.2f);
await MonsterMoveLine.LoadBezier();
SetLoadProgress(0.4f);
await FishResourcesData.LoadFishAssetsData();
SetLoadProgress(0.6f);
await Addressables.LoadAssetsAsync<AudioClip>("Audio", clip =>
{
if (clip.name == "PW_Shoot")
bulletClip = clip;
}).Task;
SetLoadProgress(0.8f);
await Task.Delay(100);
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);
SetLoadProgress(1);
await Task.Delay(300);
// ⭐ 切换房间:合并进度条走完 → 隐藏并销毁;平时隐藏面板自己的进度条
if (_changeRoomBar != null)
ClearChangeRoomBar();
else if (JinDuTiao != null)
JinDuTiao.Hide();
}
public async void UnLoadResources()
{
if (_resourcesUnloaded) return;
CreateFishManager.Instance.ExitRoom();
CreateFishManager.Instance.isResources = false;
await FishResourcesData.UnLoadFishAssetsData();
await AudioResourcesData.UnLoadAudioGame();
await MonsterMoveLine.UnLoadBezier();
// ⭐ 清空子弹池 + 特效池(鱼池已在 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;
}
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>表情列表是否已生成(首次打开面板时生成一次)</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;
}
BuildBiaoQingList();
obj_BiaoQingPanel.SetActive(true);
}
/// <summary>
/// 生成表情列表(只生成一次),两种形式分开处理:
/// ① spine 表情:遍历 list_BiaoQingSpine,每个配置里的每个动画名克隆一个 spine 项
/// (child0=SkeletonGraphic → 换骨骼资源 + 循环播放该动画做列表预览);
/// ② 文字表情:list_BiaoQingZi 每条文字克隆一个文字项(child0=TextMeshProUGUI → 设置文字)。
/// </summary>
private void BuildBiaoQingList()
{
if (_biaoQingListBuilt) return;
_biaoQingListBuilt = true;
// ---- ① spine 表情:一个动画名 = 一项 ----
if (obj_BiaoQingSpineItemPrefab != null && obj_BiaoQingSpineItemParent != null)
{
for (int i = 0; i < list_BiaoQingSpine.Count; i++)
{
var cfg = list_BiaoQingSpine[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 list_BiaoQingZi)
{
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 = 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 = list_BiaoQingSpine 配置下标 → 取骨骼资源交给座位显示
var cfg = (spineIndex >= 0 && spineIndex < list_BiaoQingSpine.Count)
? list_BiaoQingSpine[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)
{
if (currSitData == null || sync.sitPos == currSitData.posID) return; // 本地座位已在发送时显示
var data = FrameDataParser.Parse<FrameData_BiaoQing>(sync.frameData);
if (data == null) return;
ShowBiaoQing(sync.sitPos, data.biaoQingType, data.spineIndex, data.content);
}
#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;
}
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)) 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);
}
}
}
#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;
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;
}
// ⭐ 三发 + 锁定:每发子弹优先瞄准对应目标,目标不足或失效则按默认角度发射
// 必须保证三发子弹都创建,否则金币消耗和后端对不上
if (isSanShe && isLock)
{
// 三发默认角度(无目标时用):左 -20,中 0,右 +20
float[] defaultAngles = { angle - 20, angle, angle + 20 };
for (int i = 0; i < 3; i++)
{
string bulletId = i < bullitIDs.Count ? bullitIDs[i] : bullitIDs[0];
MonsterContral targetMonster = null;
Transform targetHitPos = null;
// 尝试按 GUID 查找目标
if (fireTargetMonsterIDs != null && i < fireTargetMonsterIDs.Count)
{
if (CreateFishManager.Instance.fishDatas_hashCodekey.TryGetValue(fireTargetMonsterIDs[i], out var m)
&& m != null && m.gameObject.activeInHierarchy
// ⭐ 手动锁定的主目标(i==0 且是本地玩家主锁)不受路径进度限制
&& ((i == 0 && sit == currSitData && m == currSitData.monCotl_Lock && currSitData.isManualLock)
|| IsFishPathLockable(m)))
{
targetMonster = m;
targetHitPos = ResolveLockTransform(m);
}
}
if (targetMonster != null && targetHitPos != null)
{
// 朝目标发射
Vector3 targetWorldPos = targetHitPos.position;
Vector2 dir = ((Vector2)targetWorldPos - startPos).normalized;
float targetAngle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f;
CreateBullet(sit, startPos, targetAngle, isDouble, true, bulletId, targetMonster, targetHitPos, GetBulletGold(i));
}
else
{
// 目标不足或失效 → 按默认角度发射(不锁定)
CreateBullet(sit, startPos, defaultAngles[i], isDouble, isLock, bulletId, null, null, GetBulletGold(i));
}
}
}
else if (isSanShe)
{
// ⭐ 安全取值:bullitIDs 不足3个时复用最后一个,避免越界
string SafeBid(int i) => i < bullitIDs.Count ? bullitIDs[i] : bullitIDs[bullitIDs.Count - 1];
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)
{
AudioManager.Instance.PlayFX(bulletClip);
}
}
/// <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)
AudioManager.Instance.PlayFX(bulletClip);
}
/// <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 全部未激活 → 返回 null,调用方应换一条鱼
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 任一激活)
/// </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;
// ⭐ 锚点激活检查: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 路径进度过滤(锁定范围控制)
/// <summary>bot/玩家锁定时,鱼路径进度下限:小于此值表示鱼刚出生,不锁定</summary>
private const float BotLockPathMin = 0.1f;
/// <summary>bot/玩家锁定时,鱼路径进度上限:大于此值表示鱼即将离场,不锁定</summary>
private const float BotLockPathMax = 0.9f;
/// <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>
/// 判断鱼的路径进度是否在可锁定范围内 [BotLockPathMin, BotLockPathMax]
/// 刚出生(< 0.1)或即将离场(> 0.9)的鱼不能锁定,避免锁定后立即丢失目标
/// ⭐ 注意:本函数仅用于自动锁定/bot/三发查找;玩家手动锁定不调用此函数
/// </summary>
private bool IsFishPathLockable(MonsterContral m)
{
if (m == null) return false;
float progress = GetFishPathProgress(m);
return progress >= BotLockPathMin && progress <= BotLockPathMax;
}
#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; // 跳过已选中的
// ⭐ 路径进度过滤:刚出生(< 0.2)或即将离场(> 0.8)的鱼不作为目标
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;
bool isTripleLock = toggle_Three.isOn && toggle_Lock.isOn && currSitData.isLock;
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>();
// ⭐ 记录被点击的碰撞器,用于 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);
}
}
}
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);
pos.z = Camera.main.transform.position.z + 1;
icon.transform.position = pos;
}
private void CheckLockTargetValid()
{
if (!currSitData.isLock) return;
var monster = currSitData.monCotl_Lock;
// ⭐ 目标无效判定:被回收/销毁
// 路径进度检查仅对自动锁定生效;手动锁定不检查(玩家自己锁的鱼离场前一直跟)
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;
}
// ⭐ 检查三发锁定目标:移除已死亡或已离场的额外目标
if (currSitData.tripleLockTargets.Count > 0)
{
bool changed = false;
for (int i = currSitData.tripleLockTargets.Count - 1; i >= 1; i--)
{
var t = currSitData.tripleLockTargets[i];
// 失效判定:被回收/销毁 OR 路径离场
if (t == null || !t.gameObject.activeInHierarchy || !IsFishPathLockable(t))
{
currSitData.tripleLockTargets.RemoveAt(i);
currSitData.tripleLockPositions.RemoveAt(i);
changed = true;
}
}
// 有目标失效 → 清空三发列表,让 RefreshTripleLockIcons 重新找满3个
if (changed)
{
currSitData.HideTripleLockIcons();
}
}
}
private void HandleAutoLock()
{
// ⭐ 退出倒计时期间屏蔽本地玩家自动锁定,其他玩家和机器人不受影响
if (_isLeavingCountdown) return;
// ⭐ 特殊鱼爆奖(钻头/激光)期间禁用自动锁定:锁定目标让位给炮台瞄准方向
if (currSitData != null && currSitData.isDrillActive) return;
// 自动锁定必须在锁定开关开启时才工作
if (!toggle_Lock.isOn) return;
// ⭐ 前提条件:玩家必须先手动锁定一条鱼来激活自动锁定
// 激活后,当锁定的鱼死亡,自动转换到下一个目标
if (!_autoLockActivated) return;
// 已经有锁定目标并且还活着,就不重复找
// ⭐ 手动锁定:只看鱼是否还活着,不检查路径进度(玩家自己锁的鱼离场前一直跟)
// 自动锁定:还要检查路径进度(超出 0.1~0.9 范围视为失效,换下一条)
if (currSitData.isLock &&
currSitData.monCotl_Lock != null &&
currSitData.monCotl_Lock.gameObject.activeInHierarchy &&
(currSitData.isManualLock || IsFishPathLockable(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);
// ⭐ 切换目标瞬间立即更新主锁定图标(鱼还活着就一定显示)
if (currSitData.lockFX != null && target.gameObject.activeInHierarchy)
{
if (!currSitData.lockFX.activeSelf) currSitData.lockFX.SetActive(true);
Vector3 p = lockTr.position;
p.z = Camera.main.transform.position.z + 1;
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 / Boss鱼 / 特殊鱼 为高价值组(按优先级 + 距离最近),
// 黄金鱼 / 普通鱼 为普通组(随机选一只)
// 高价值组有目标时只从高价值组选;高价值组无目标时从普通组随机
MonsterContral best = null;
int bestPri = -1;
float bestSqr = float.MaxValue;
var normalList = new List<MonsterContral>();
foreach (var kv in CreateFishManager.Instance.fishDatas_hashCodekey)
{
MonsterContral m = kv.Value;
if (m == null || !m.gameObject.activeInHierarchy) continue;
// ⭐ 路径进度过滤:刚出生(< 0.2)或即将离场(> 0.8)的鱼不作为自动锁定目标
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
{
normalList.Add(m);
}
}
// 高价值组有目标 → 直接返回最高价值(同价值最近)的目标
if (best != null) return best;
// 高价值组无目标 → 从普通组随机选一只
if (normalList.Count > 0)
return normalList[Random.Range(0, normalList.Count)];
return null;
}
//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 = currSitData.posID,
frameType = FrameDataType.rotateTurrect,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_Rotate { angle = angle }
};
WebSocketSendList.SyncGameData(sgd);
}
private void SyncFire(float angle, bool isSanShe, bool isDouble, bool isLock)
{
var fireData = new FrameData_FireBullet
{
angle = angle,
gold = currSitData.goldNum,
isSanShe = isSanShe,
twoRTruuect = isDouble,
isSuoDing = isLock,
auto = currSitData.IsAutoAttackOn
};
// ⭐ 三发 + 锁定:查找 3 个优先级目标,写入 monsterIDs
if (isSanShe && isLock)
{
var targets = FindFireTargets(3);
if (targets.Count > 0)
fireData.monsterIDs = targets.Select(t => t.monster.guid).ToList();
}
SyncGameData sgd = new SyncGameData
{
sitPos = 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 = 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 = 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 = 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 = 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 = 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 = 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)
{
if (sync.sitPos == currSitData.posID) return;
var sit = list_Sits[sync.sitPos];
// ⭐ 房主不处理机器人旋转广播:房主在 RotateBotToTarget 里已直接旋转本地炮台,
// 再走 SmoothRotate 会和本地直接旋转冲突
if (sit != null && sit.isBot && currSitData == SitPosData.roomOwnerSit) return;
var data = FrameDataParser.Parse<FrameData_Rotate>(sync.frameData);
SmoothRotate(sit, data.angle);
}
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;
float angle = botLastAngle.TryGetValue(sync.userId, out float a) ? 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))
{
FireBullet(sit, angle, fireData.isSanShe, fireData.twoRTruuect,
fireData.isSuoDing, sync.bulletIds, fireData.monsterIDs, bulletGolds);
}
else
{
FireBullet(sit, angle, false, false, false, sync.bulletIds, null, bulletGolds);
}
return;
}
var data = FrameDataParser.Parse<FrameData_FireBullet>(sync.extra.frameData);
// ⭐ 边界检查:防止 sitPos 越界导致 IndexOutOfRangeException
if (sync.extra.sitPos < 0 || sync.extra.sitPos >= list_Sits.Count)
{
Debug.LogWarning($"[SyncGameData_FireBullit] sitPos 越界: {sync.extra.sitPos}");
return;
}
var targetSit = list_Sits[sync.extra.sitPos];
FireBullet(targetSit, data.angle, 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;
if (sync.sitPos < 0 || sync.sitPos >= list_Sits.Count) return;
var sit = list_Sits[sync.sitPos];
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)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeAutoTarget>(sync.frameData);
if (data.targetGuid == null)
{
list_Sits[sync.sitPos].ClearLock();
return;
}
MonsterContral monster = CreateFishManager.Instance.fishDatas_hashCodekey[data.targetGuid[0]];
// ⭐ 统一用 ResolveLockTransform:colliderList 第一个活跃 → lockPoint → transform
// 注:网络同步过来的 colliderIndex 在统一逻辑下不再使用,因为客户端之间
// colliderList 顺序可能不一致,统一用"第一个活跃"更稳定
Transform hitPos = ResolveLockTransform(monster);
list_Sits[sync.sitPos].SetLockTarget(monster, hitPos);
}
public void SyncGameData_RendromDieFish(SyncGameData sync)
{
var data = JsonConvert.DeserializeObject<FrameData_RandomDieFish>(sync.frameData.ToString());
// 用 sync.sitPos 判断是否本地玩家发的(服务器不会丢这个字段)
bool isLocalSender = 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)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_TurretState>(sync.frameData);
var sit = list_Sits[sync.sitPos];
sit.ChangeState(data.state);
}
public void SyncGameData_ChangeTurret(SyncGameData sync)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
list_Sits[sync.sitPos].LoadTurret(data.turretIndex);
}
public void SyncGameData_ChangeWing(SyncGameData sync)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
// turretIndex<=0 = 卸下翅膀;>0 = 加载对应编号翅膀(与战神 changeZhanShen 同一约定)
if (data.turretIndex > 0)
list_Sits[sync.sitPos].LoadWing(data.turretIndex);
else
list_Sits[sync.sitPos].ClearWing();
}
public void SyncGameData_ChangeZhanShen(SyncGameData sync)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeTurret>(sync.frameData);
// turretIndex<=0 = 卸下战神;>0 = 加载对应编号战神(内部会隐藏该座位的普通炮台)
if (data.turretIndex > 0)
list_Sits[sync.sitPos].LoadZhanShen(data.turretIndex);
else
list_Sits[sync.sitPos].ClearZhanShen();
}
public void SyncGameData_ChangeCannonMultiple(SyncGameData sync)
{
if (sync.sitPos == currSitData.posID) return;
var data = FrameDataParser.Parse<FrameData_ChangeCannonMultiple>(sync.frameData);
var sit = list_Sits[sync.sitPos];
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 = 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)
{
foreach (var sit in list_Sits)
{
if (sit.posID != sitNum) 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.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.2)或即将离场(> 0.8)的 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);
sit.consumeGoldNum = paoBeiList[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 = 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;
botLastAngle[sit.user] = angle;
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
List<string> monsterIDs = null;
if (isSanShe && isLock && target != 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 = angle,
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.2)或即将离场(> 0.8)的鱼不作为目标
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.2)或即将离场(> 0.8)的鱼不作为目标
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 = botSit.posID,
frameType = FrameDataType.rotateTurrect,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = new FrameData_Rotate { angle = angle }
};
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 = 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 = 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 = angle,
gold = gold,
isSanShe = isSanShe,
twoRTruuect = isDouble,
isSuoDing = isLock,
};
if (monsterIDs != null && monsterIDs.Count > 0)
fireData.monsterIDs = monsterIDs;
SyncGameData sgd = new SyncGameData
{
sitPos = botSit.posID,
frameType = FrameDataType.fireBullet,
time = BeijingTimeManager.Instance.CurrentTimestampMs,
frameData = fireData
};
WebSocketSendList.SyncGameData(sgd);
}
#endregion
}