/* ======================== 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; /// 是否正在退出倒计时(期间屏蔽帧同步和爆奖特效) 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; //[Header("我在这")] public GameObject mySitAnim; public Dictionary dic_Skill_Toggle = new Dictionary();//开关型技能 [Header("Skillbottom")] public Toggle toggle_SkillAuto; 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 _frozenAnimSpeeds = new Dictionary(); // ⭐ 暴走/冰封本地数量:因为暂无扣减 API,激活时本地 -1,避免被 OnUserDataChanged 覆盖回服务端旧值 private int _localBaoZouCount = -1; // -1 表示尚未初始化 private int _localBingFengCount = -1; // 上次从服务端读到的数量:用于判断服务端是否变化(刮刮卡奖励/其他端使用), // 服务端值变化时信任服务端;未变化时保留本地扣减值 private int _lastServerBaoZouCount = -1; private int _lastServerBingFengCount = -1; [Header("Skillleft")] public Button btn_GuaGuaKa; public TextMeshProUGUI tmp_GGKCount; public List list_Sits = new List(); public SitPosData currSitData; [Header("攻击参数")] public float attackTime = 0.5f; [Header("人机参数")] public float botRotateSpeed = 80f; // 兜底旋转速度(BotConfig 为空时用) float clickTime = 0; private Dictionary botFireTimer = new Dictionary(); private Dictionary botLastAngle = new Dictionary(); // bot 旋转广播节流:记录上次广播的角度/时间 private Dictionary botLastSyncAngle = new Dictionary(); private Dictionary botLastSyncTime = new Dictionary(); // bot 技能运行时 private Dictionary botSkills = new Dictionary(); // bot 目标记忆 private Dictionary botTargetGuid = new Dictionary(); // bot 锁定同步节流:记录上次广播的锁定目标 guid(null 表示未锁定),只有目标变化才广播 private Dictionary botLastSyncLockGuid = new Dictionary(); // bot 炮台状态同步节流:记录上次广播的 TurretState,只有状态变化才广播 private Dictionary botLastSyncTurretState = new Dictionary(); // bot 状态计时:开火后持续 Attack 动画,空闲 0.3s 切回 Idle private Dictionary botIdleTimer = new Dictionary(); // bot 最近的 FrameData_FireBullet(给其他客户端还原技能状态) private Dictionary botLastFireData = new Dictionary(); // bot 运行时状态(进场金币/倍数档位/行为模式/退出) private Dictionary botRuntimeStates = new Dictionary(); public GameObject bullet_Test; public GameObject bulletParent; float changeTurretTime = 0f; bool isChangingTurret = false; // XJMH 长按炮台扇形切换 [Header("XJMH 炮台长按切换")] [Tooltip("长按炮台触发扇形切换的阈值(秒)")] public float turretLongPressThreshold = 0.5f; /// 是否正在等待长按炮台判定(按下炮台但尚未超过阈值) protected bool _isPendingTurretLongPress = false; /// 炮台扇形切换UI是否已显示 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 paoBeiList = new List(); 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>(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); } 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(); } } /// /// 刷新暴走/冰封道具数量显示 /// 策略:记录上次服务端数量,只要服务端数量相比上次有变化(增加或减少)就信任服务端 /// (覆盖刮刮卡奖励、其他端使用等场景);服务端数量未变时保留本地扣减值, /// 避免被服务端旧值覆盖本地扣减 /// private void RefreshSkillPropsCount(List props) { int serverBaoZou = 0; int serverBingFeng = 0; if (props != null) { foreach (var p in props) { if (p.number == "40004" && p.count > 0) serverBaoZou = p.count; else if (p.number == "40003" && p.count > 0) serverBingFeng = p.count; } } // 首次初始化,或服务端数量相比上次有变化(刮刮卡奖励增加/其他端使用减少)→ 以服务端为准 if (_localBaoZouCount < 0 || serverBaoZou != _lastServerBaoZouCount) _localBaoZouCount = serverBaoZou; if (_localBingFengCount < 0 || serverBingFeng != _lastServerBingFengCount) _localBingFengCount = serverBingFeng; _lastServerBaoZouCount = serverBaoZou; _lastServerBingFengCount = serverBingFeng; UpdateSkillPropsUI(); } /// 仅更新 UI 文本 private void UpdateSkillPropsUI() { if (tmp_BaoZouCount != null) tmp_BaoZouCount.text = Mathf.Max(0, _localBaoZouCount).ToString(); if (tmp_BingFengCount != null) tmp_BingFengCount.text = Mathf.Max(0, _localBingFengCount).ToString(); } protected override void OnHide() { base.OnHide(); // ⭐ 清理 XJMH 扇形切换UI(如果还在显示) CleanupTurretSwitcher(); // ⭐ 解绑长按 img_OpenPT 打开炮台面板的订阅 UnhookOpenPTLongPress(); 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 开关炮台面板 /// 已订阅长按事件的座位(换座位/重进房间时先解绑避免重复订阅) private SitPosData _openPTHookedSit; /// 缓存的长按处理器(解绑用) private Action _openPTLongPressHandler; /// /// 订阅本地座位的 img_OpenPT 长按事件:长按开关炮台面板(subUIManager,与 btn_ChangeTurret 同逻辑)。 /// XJMH 模式没有炮台面板(用扇形切换),不订阅。 /// private void HookOpenPTLongPress(SitPosData sit) { // 先解绑旧订阅 UnhookOpenPTLongPress(); if (sit == null || sit.turretControlMode == TurretControlMode.XJMH) return; _openPTLongPressHandler = () => { // 与 btn_ChangeTurret 完全相同的开关逻辑:开着就关,没开就打开 var panel = subUIManager.Get(UIPanel.TurretPanel); if (panel != null && panel.gameObject.activeSelf) subUIManager.Hide(UIPanel.TurretPanel); else { subUIManager.ShowAsync(UIPanel.TurretPanel, (ItemPanelSelect.Turrect, true)); StartCoroutine(CloseSettingList()); } }; sit.OnOpenPTLongPress += _openPTLongPressHandler; _openPTHookedSit = sit; } /// 解绑长按 img_OpenPT 的订阅(面板隐藏/重新订阅前调用) private void UnhookOpenPTLongPress() { if (_openPTHookedSit != null && _openPTLongPressHandler != null) _openPTHookedSit.OnOpenPTLongPress -= _openPTLongPressHandler; _openPTHookedSit = null; _openPTLongPressHandler = 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(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_TuiChu.onClick.AddListener(() => { StartLeaveCountdown(); }); // 服务器广播监听 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing); // ⭐ 战神切换帧同步(changeZhanShen):其他玩家装备/卸下战神,对应座位加载/清除战神模型 EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen); // SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发 EventCenter.Instance.AddEventListener(ConstEventString.FishFrozen, OnFishFrozen); EventCenter.Instance.AddEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle); // ⭐ 爆奖结束帧同步(baoJiangEnd):其他玩家爆奖完成,显示最终 BJEndUI 并加分 EventCenter.Instance.AddEventListener(ConstEventString.BaoJiangEnd, OnBaoJiangEnd); btn_TuJian.onClick.AddListener(() => { subUIManager.ShowAsync(UIPanel.GameTuJian); StartCoroutine(CloseSettingList()); }); btn_GuaGuaKa.onClick.AddListener(() => { subUIManager.ShowAsync(UIPanel.GuaGuaKa); }); } /// 打开离开游戏确认面板:点确认直接退出房间(无倒计时) public void StartLeaveCountdown() { if (_isLeavingCountdown) return; _isLeavingCountdown = true; if (obj_DJSPanel != null) obj_DJSPanel.SetActive(true); // ⭐ 无倒计时:确认按钮直接可点 if (btn_QueRen != null) btn_QueRen.interactable = true; if (tmp_DJS != null) tmp_DJS.text = "确认"; // 绑定按钮事件 if (btn_QueRen != null) { btn_QueRen.onClick.RemoveAllListeners(); btn_QueRen.onClick.AddListener(() => { // 确认:关闭确认面板,发送离房请求 + 启动进度条卸载 if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false); RequestLeaveRoom(); }); } if (btn_QuXiao != null) { btn_QuXiao.onClick.RemoveAllListeners(); btn_QuXiao.onClick.AddListener(CancelLeaveCountdown); } } /// 是否正在离开倒计时 public bool IsLeavingCountdown() => _isLeavingCountdown; /// 取消离开倒计时 public void CancelLeaveCountdown() { _isLeavingCountdown = false; if (_leaveCountdownCoroutine != null) { StopCoroutine(_leaveCountdownCoroutine); _leaveCountdownCoroutine = null; } if (obj_DJSPanel != null) obj_DJSPanel.SetActive(false); } private IEnumerator LeaveCountdownRoutine(int seconds) { int remaining = seconds; while (remaining > 0) { if (tmp_DJS != null) tmp_DJS.text = $"确认({remaining})"; yield return new WaitForSeconds(1f); remaining--; } // 倒计时结束:按钮启用,文本显示"确认" if (tmp_DJS != null) tmp_DJS.text = "确认"; if (btn_QueRen != null) btn_QueRen.interactable = true; } public virtual void OnDestroy() { EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_Rotate, SyncGameData_Rotate); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_FireBullet, SyncGameData_FireBullit); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTargetMonster, SyncGameData_ChangeTargetMonster); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_RendromDieFish, SyncGameData_RendromDieFish); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_TurretState, SyncGameData_TurretState); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeTurret, SyncGameData_ChangeTurret); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeCannonMultiple, SyncGameData_ChangeCannonMultiple); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeWing, SyncGameData_ChangeWing); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_ChangeZhanShen, SyncGameData_ChangeZhanShen); // SyncGameData_FreezeFish 已废弃:冰冻改由服务端 FishFrozen 事件统一触发 EventCenter.Instance.RemoveEventListener(ConstEventString.FishFrozen, OnFishFrozen); EventCenter.Instance.RemoveEventListener(ConstEventString.SyncGameData_BotFireAngle, SyncGameData_BotFireAngle); EventCenter.Instance.RemoveEventListener(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.onValueChanged.AddListener(on => { if (!on) { // 自动开火关闭 → 立即切回 Idle currSitData.ChangeState(TurretState.Idle); SyncTurretState(TurretState.Idle); } }); 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; /// /// 等待10秒未操作关闭设置面板 /// public void SettingClose() { //if (isOpenSetting) StartCoroutine(CloseSettingList()); //else StartCoroutine(OpenSettingList()); if (isOpenSetting) { currSittingTime -= Time.deltaTime; if (currSittingTime <= 0) { StartCoroutine(CloseSettingList()); } } else { currSittingTime = 10; } } #endregion #region 资源加载模块 public async void LoadResources() { CreateFishManager.Instance.isResources = false; // ⭐ 重置卸载标记,下次进入可重新加载 _resourcesUnloaded = false; await AudioResourcesData.LoadAudioGame(); JinDuTiao.SetProgress(0.2f); await MonsterMoveLine.LoadBezier(); JinDuTiao.SetProgress(0.4f); await FishResourcesData.LoadFishAssetsData(); JinDuTiao.SetProgress(0.6f); await Addressables.LoadAssetsAsync("Audio", clip => { if (clip.name == "PW_Shoot") bulletClip = clip; }).Task; JinDuTiao.SetProgress(0.8f); await Task.Delay(100); CreateFishManager.Instance.EnterRoom(gameType); CreateFishManager.Instance.isResources = true; JinDuTiao.SetProgress(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); JinDuTiao.SetProgress(1); await Task.Delay(300); 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; } /// 资源是否已卸载(避免 OnHide 重复卸载) private bool _resourcesUnloaded = false; /// 离房 socket 响应等待信号 private TaskCompletionSource _leaveRoomResponseTcs; /// 正在离开的游戏面板类型(完成后关闭它切到 home) private UIPanel _leavingGamePanelType; /// /// 请求离开房间:发送离房 socket 请求 + 启动进度条卸载资源。 /// 进度条走到 0.9 后等待 socket 返回 LeaveRoomResult, /// 收到返回后走完剩余进度并切回 home 界面。 /// public void RequestLeaveRoom() { if (_leaveRoomResponseTcs != null) return; // 防重复请求 // 先创建等待信号(在发送请求前创建,避免响应太快丢失) _leaveRoomResponseTcs = new TaskCompletionSource(); // 发送离房请求 WebSocketSendList.LeaveRoom( GameManager.Instance.gameData.currRoomData.roomId, currSitData.user); //CreateFishManager.Instance.fangzhu = false; // 启动带进度条的卸载流程 BeginLeaveRoomUnload(); } /// /// 离房卸载流程:进度条到 0.9 后等待 socket 响应, /// 收到信号后走到 1.0 并切回 home 界面。 /// ⭐ 用 try-finally 保证无论中间是否异常,最终一定会关闭 GamePanel 并切回 Home /// private async void BeginLeaveRoomUnload() { if (_resourcesUnloaded) { _leaveRoomResponseTcs = null; return; } try { if (JinDuTiao != null) JinDuTiao.Show(); if (JinDuTiao != null) JinDuTiao.SetProgress(0.2f); await FishResourcesData.UnLoadFishAssetsData(); if (JinDuTiao != null) JinDuTiao.SetProgress(0.4f); await AudioResourcesData.UnLoadAudioGame(); if (JinDuTiao != null) JinDuTiao.SetProgress(0.6f); await MonsterMoveLine.UnLoadBezier(); if (JinDuTiao != null) JinDuTiao.SetProgress(0.8f); await Task.Delay(100); CreateFishManager.Instance.ExitRoom(); CreateFishManager.Instance.isResources = false; // ⭐ 清空子弹池 + 特效池(鱼池已在 UnLoadFishAssetsData 内清理) BulletPool.Instance.ClearAll(); EffectPool.Instance.ClearAll(); if (JinDuTiao != null) JinDuTiao.SetProgress(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 (JinDuTiao != null) JinDuTiao.SetProgress(1); await Task.Delay(300); if (JinDuTiao != null) JinDuTiao.Hide(); } catch (Exception ex) { Debug.LogError($"[LeaveRoom] 卸载流程异常: {ex}"); } finally { // ⭐ 无论中间是否异常,都必须关闭 GamePanel 并切回 Home // 否则 GamePanel 留在 panels 字典里,下次进入游戏时 Get 返回非 null 导致无法创建新面板 _leaveRoomResponseTcs = null; if (JinDuTiao != null) { try { JinDuTiao.Hide(); } catch { } } // 进入 home 界面(关闭当前游戏面板) switch (GameManager.Instance.gameData.currGameType) { case GameType.JQQD: break; case GameType.defaultPanel: break; case GameType.FLZY: UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelFLZY); break; case GameType.YYSC: UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelYYSC); break; case GameType.ZGXQ: UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelZGXQ); break; case GameType.XJMH: UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelXJMH); break; case GameType.HHWK: UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, UIPanel.MainGamePanelHHWK); break; default: break; } UIManager.Instance.ShowAsync(UIPanel.HomeMainPanel, GameManager.Instance.gameData.currGameType); } } /// /// socket 收到 LeaveRoomResult 后调用,解除 0.9 的等待。 /// public void OnLeaveRoomResultReceived(UIPanel gamePanel) { _leavingGamePanelType = gamePanel; _leaveRoomResponseTcs?.TrySetResult(true); } #endregion #region 输入系统(InputModule) private bool IsPointerOverUI(Vector2 pos) { PointerEventData eventData = new PointerEventData(EventSystem.current); eventData.position = pos; List results = new List(); 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 results = new List(); EventSystem.current.RaycastAll(pointerData, results); foreach (var result in results) { if (result.gameObject.CompareTag("ChangeTurret")) { SitPosData sit = result.gameObject.GetComponentInParent(); if (sit == currSitData) return true; } } return false; } private void CloseChangeTurretUI() { // currSitData.changeTurretList.SetActive(false); — 已废弃 changeTurretTime = 0; isChangingTurret = false; } // ==================== XJMH 炮台扇形切换 ==================== /// /// 检测触摸点是否在当前炮台的 RectTransform 范围内(XJMH 长按切换用)。 /// protected bool IsTouchOnCurrentTurret(Vector2 screenPos) { if (currSitData == null || currSitData.currentTurret == null) return false; RectTransform rt = currSitData.currentTurret.GetComponent(); if (rt == null) return false; Canvas canvas = UIManager.Instance != null && UIManager.Instance.uiRoot != null ? UIManager.Instance.uiRoot.GetComponent() : null; Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null; return RectTransformUtility.RectangleContainsScreenPoint(rt, screenPos, cam); } /// 长按炮台达到阈值后弹出扇形切换UI(子类覆写实现具体UI)。 protected virtual void OnTurretSwitcherShow(Vector2 pressPos) { } /// 扇形切换UI显示期间,手指滑动时更新高亮(子类覆写)。 protected virtual void OnTurretSwitcherUpdate(Vector2 currentPos) { } /// 手指松开,关闭扇形切换UI并根据滑动位置切换炮台(子类覆写)。 protected virtual void OnTurretSwitcherHide(Vector2 releasePos) { } /// 清理扇形切换UI状态(面板隐藏时调用) protected virtual void CleanupTurretSwitcher() { _isTurretSwitcherActive = false; _isPendingTurretLongPress = false; changeTurretTime = 0; } #endregion #region 打开关闭右上角设置选项 public virtual IEnumerator OpenSettingList() { RectTransform tran = SittingListParent.GetComponent(); tran.gameObject.SetActive(true); tran.DOSizeDelta(new Vector2(466, 143), 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(); Vector3 v3 = btn_SettingList.transform.localScale; v3.x = Mathf.Abs(v3.x); btn_SettingList.transform.localScale = v3; tran.DOSizeDelta(new Vector2(0, 143), 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().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().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 (!toggle_SkillAuto.isOn) 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); } /// /// 远程玩家/bot 旋转:直接把目标角度赋给炮台,不做任何过渡。 /// 旋转和开炮都改成实时同步,收到广播立即应用。 /// 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) ==================== /// 给起飞炮台挂上向上开火回调(每次起飞前刷新,防止切炮台后回调丢失/错位) private void HookFlyTurretUpwardFire(FlyTurretData flyTurret) { if (flyTurret == null) return; flyTurret.onUpwardFire = OnFlyTurretUpwardFire; } /// /// 起飞炮台悬停时的向上开火:angle=0(正上方,aim-90约定)。 /// 校验与普通开火一致(金币/子弹上限/退出倒计时),子弹照常走服务端同步链路。 /// 不广播 TurretState:SyncFire 每 0.4 秒一发已带全部信息,逐发状态广播会刷屏网络。 /// 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); } /// 战神激光命中延迟(秒):不开实体子弹,开火后延迟此时间直接发 KillFish 给后端(模拟弹道飞行) [Tooltip("战神激光命中延迟(秒):不开实体子弹,开火后延迟此时间直接发 KillFish 给后端")] public float laserHitDelay = 0.2f; private void FireBullet(SitPosData sit, float angle, bool isSanShe, bool isDouble, bool isLock, List bullitIDs, List fireTargetMonsterIDs = null, List 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); } } /// /// 战神激光开火(锁定中):不创建实体子弹(视觉就是 LineRenderer 激光线), /// 每发按正常频率走 FireCannon/广播(频率不变),本地扣费后延迟 laserHitDelay 秒 /// 直接把 KillFish 发给后端结算(模拟弹道飞行时间)。 /// 目标:三发=锁定目标列表(与三条激光线一致),单发=主锁定目标。 /// private void FireLaser(SitPosData sit, bool isDouble, bool isSanShe, List bullitIDs, List fireTargetMonsterIDs, List bulletGolds) { // ---- 收集激光目标(与激光线一一对应)---- var targets = new List(); 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); } /// /// 激光命中协程:延迟 laserHitDelay 后结算(不开实体子弹)。 /// 结算逻辑与 BullitMove.AttackFish 一致:本人发 KillFish、bot 由房主发 BotKillFish、本地播 OnHurt。 /// 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 (!toggle_SkillAuto.isOn) 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 = toggle_SkillAuto.isOn || (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 锁定位置解析(统一工具) /// /// 统一锁定位置解析:colliderList 第一个活跃碰撞器 → lockPoint → monster.transform /// 所有设置锁定位置的地方都应该调用此方法,避免不同地方用不同优先级导致锁定图标位置和子弹终点不一致。 /// 注意:Unity 序列化字段未赋值时是"假 null",?? 运算符无法识别,必须用 == null 判断。 /// /// /// 解析锁定锚点 Transform。 /// ⭐ useHitCollider=true(手动锁定):点击碰撞器 → colliderList 激活项 → lockPoint /// ⭐ useHitCollider=false(自动锁定/三发):直接用 lockPoint → colliderList 激活项 → transform /// (自动锁定没有点击的碰撞器,优先用固定锁定点更稳定) /// 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; } /// 简化重载,不关心 colliderIndex 时用 private Transform ResolveLockTransform(MonsterContral monster) { return ResolveLockTransform(monster, out _, false); } /// /// ⭐ 判断鱼是否可锁定。 /// - needLockEvent=false 的鱼:一直可锁定(只要 activeInHierarchy) /// - needLockEvent=true 的鱼:出场/状态切换期间 isLockable=false,等 NotifyLockable() 后才可锁定 /// - 已死亡的鱼(isDead=true,死亡特效播放窗口期)不可锁定 /// 同时要求有激活的锁定锚点(lockPoint 或 colliderList 任一激活) /// 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 路径进度过滤(锁定范围控制) /// bot/玩家锁定时,鱼路径进度下限:小于此值表示鱼刚出生,不锁定 private const float BotLockPathMin = 0.1f; /// bot/玩家锁定时,鱼路径进度上限:大于此值表示鱼即将离场,不锁定 private const float BotLockPathMax = 0.9f; /// /// 计算鱼在路径上的进度 [0, 1]。 /// 进度 = moveTime / monsterData.moveLine.moveTime /// 0 = 刚出生,1 = 即将离场 /// ⭐ isMove=false 的鱼(爆奖Boss等)不走路径,固定在场景中 → 返回中间值 0.5 表示可锁定 /// 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); } /// /// 判断鱼的路径进度是否在可锁定范围内 [BotLockPathMin, BotLockPathMax] /// 刚出生(< 0.1)或即将离场(> 0.9)的鱼不能锁定,避免锁定后立即丢失目标 /// ⭐ 注意:本函数仅用于自动锁定/bot/三发查找;玩家手动锁定不调用此函数 /// private bool IsFishPathLockable(MonsterContral m) { if (m == null) return false; float progress = GetFishPathProgress(m); return progress >= BotLockPathMin && progress <= BotLockPathMax; } #endregion #region 多目标查找(TripleLock) /// 鱼锁定优先级:特殊Boss(5) > Boss鱼(4) > 特殊鱼(3) > 黄金鱼(2) > 普通鱼(1) 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; } } /// /// 查找 fire 时的多目标(三发锁定用)。 /// 第一个目标 = 主锁 monCotl_Lock(打哪锁哪),剩余从 Boss/特殊Boss/特殊鱼/黄金鱼 按优先级补满 /// private List<(MonsterContral monster, Transform hitPos)> FindFireTargets(int maxCount) { var result = new List<(MonsterContral monster, Transform hitPos)>(); var selected = new HashSet(); 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[] { 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) /// /// 刷新三发锁定图标:三发+锁定开启时显示3个锁定圈 /// 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); } /// 自动锁定是否已激活:玩家首次手动锁定一条鱼后激活,该鱼死亡后开始自动转换目标 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(); // ⭐ 记录被点击的碰撞器,用于 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 { 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 { 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(); 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 (!toggle_SkillAuto.isOn) 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; /// /// 检查指定用户是否已达50发子弹上限 /// private bool IsBulletLimitReached(string userId) { if (bulletParent == null) return false; int count = 0; foreach (Transform child in bulletParent.transform) { if (child.TryGetComponent(out var bm) && bm.userid == userId) count++; } if (count >= sitBulletMaxCount) { UIManager.Instance.ShowBoardError("子弹已达上限"); return true; } return false; } /// /// 金币不足退出房间弹窗是否可弹出。 /// 弹出后置为 false,仅在金币恢复到能发射最小炮倍时才重置为 true。 /// 不在弹窗关闭时重置——避免点确认后 RequestLeaveRoom 异步等待期间反复弹窗。 /// private bool _canShowGoldInsufficientPanel = true; /// /// 获取当前房间最小炮倍(paoBeiList 中的最小值) /// private long GetMinConsumeGold() { if (paoBeiList == null || paoBeiList.Count == 0) return 0; return paoBeiList.Min(); } /// /// 开炮前本地金币校验:三射按 3 发,否则按 1 发计算所需金币。 /// 金币不足则不切换攻击状态、不发送开炮请求,避免无效请求与服务端拒绝。 /// ⭐ 当金币不足以发射最小炮倍时,弹窗询问是否退出房间: /// 确认 → 走退出房间逻辑;X号 → 关闭面板。本地判断,不发送开炮请求。 /// 通过 _canShowGoldInsufficientPanel bool 保证唯一性: /// 弹出后设 false,仅在金币恢复到够最小炮倍时才重置 true, /// 期间无论弹窗是否关闭、RequestLeaveRoom 是否异步等待,都不会再弹。 /// 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(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(); // ⭐ 池化复用时必须重新记录起点(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 = toggle_SkillAuto.isOn }; // ⭐ 三发 + 锁定:查找 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 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); } /// 广播战神切换(changeZhanShen):其他客户端在对应座位加载/清除战神模型 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(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 bulletGolds = null; if (sync.bullets != null && sync.bullets.Count > 0) { bulletGolds = new List(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(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(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); } /// /// 机器人角度同步:接收 SyncFire_ForBot 发出的 SyncGameData(fireBullet), /// 将角度存入 botLastAngle,供 RoomFireNotice 到达时 SyncGameData_FireBullit 使用 /// 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(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(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(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(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(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(sync.frameData); list_Sits[sync.sitPos].LoadWing(data.turretIndex); } public void SyncGameData_ChangeZhanShen(SyncGameData sync) { if (sync.sitPos == currSitData.posID) return; var data = FrameDataParser.Parse(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(sync.frameData); var sit = list_Sits[sync.sitPos]; sit.consumeGoldNum = data.cannonMultiple; sit.tMP_TurretConsumeNum.text = data.cannonMultiple.ToString(); } /// /// 应用服务端确认的炮倍变更(ChangeGunMultipleNotice)。 /// 本地玩家切换炮倍后,等服务端广播此通知才更新 consumeGoldNum 和 UI, /// 确保开火扣费时本地炮倍与服务端一致。 /// 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(); } /// /// 爆奖结束帧同步(baoJiangEnd): /// 其他玩家爆奖流程完成,从 pending 取出对应 not-self 爆奖, /// 显示最终 BJEndUI(开 tMP_beishu / 关 img_BaoJiangZhong)并加分。 /// 自己触发的爆奖不在这里处理(self 流程在 BJEventBase 内部已自行结束)。 /// public void OnBaoJiangEnd(SyncGameData sync) { var data = FrameDataParser.Parse(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); } /// /// UsePropNotice 回调:服务器确认道具使用后,激活对应效果 /// 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; } } } /// /// 服务端 FishFrozen 事件回调:冰冻道具(40003)生效。 /// 鱼存活时间延长 15 秒,客户端写死 15s 倒计时。 /// 所有客户端都执行(房间效果)。 /// 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(true)) { if (!s.gameObject.activeInHierarchy) continue; _frozenAnimSpeeds[s] = s.timeScale; s.timeScale = 0f; } foreach (var a in root.GetComponentsInChildren(true)) { if (!a.gameObject.activeInHierarchy) continue; _frozenAnimSpeeds[a] = a.speed; a.speed = 0f; } foreach (var an in root.GetComponentsInChildren(true)) { if (!an.gameObject.activeInHierarchy || !an.isPlaying) continue; // ⭐ 记录每个 AnimationState 的原 speed,解冻时恢复原值而非硬编码 1f var stateSpeeds = new List(); foreach (AnimationState state in an) { stateSpeeds.Add(state.speed); state.speed = 0f; } _frozenAnimSpeeds[an] = stateSpeeds; } foreach (var pd in root.GetComponentsInChildren(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 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 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; } } } /// /// bot 进场时初始化运行时状态。 /// 按流程图:进场金币 / 入场限制金币 → 判断倍数等级 → 在对应档位区间随机选一个初始档位。 /// 这样多个 bot 即使进场金币相同(例如都是 1000),初始炮倍也会不一样。 /// 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); } /// /// 按进场金币/入场限制金币的倍数,在对应档位区间内随机选一个初始档位。 /// 倍数越高,初始档位区间越高;区间内随机,保证多个 bot 即使金币相同炮倍也不同。 /// 读不到 roomGoldLimit 或档位不足时,降级用 cfgInitIdx + 随机偏移。 /// 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); } /// 获取当前房间的入场金币限制(用于 bot 进场按金币倍数判断初始档位) 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; } /// 获取 bot 运行时状态 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; } /// 查找场内炮台前方的 Boss 列表 private List FindBossesInField(SitPosData sit, CreateFishManager fishList, Vector3 turretPos, BotConfig cfg) { var result = new List(); 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; } /// 根据 Boss 存在情况决策行为模式,并覆盖技能开关 private BotBehaviorMode DecideBotMode(SitPosData sit, BotRuntimeState state, List 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; } /// 倍数升降:当前金币/进场金币 < 阈值降档, > 阈值升档 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); } } /// 当前炮倍档位总数(paoBeiList 长度) private int GetPaoBeiCount() { if (paoBeiList != null && paoBeiList.Count > 0) return paoBeiList.Count; return 1; } /// /// 按档位索引设置 bot 炮台消耗。 /// 同时更新底层数据 consumeGoldNum、文本显示,并广播给其他客户端, /// 保证所有客户端的 bot 炮倍一致(发射子弹扣金币、打中鱼计算奖励都用最新值)。 /// 不走 SyncCannonMultiple(那个用的是本地玩家 currSitData.posID,不能给 bot 用)。 /// 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); } /// 退出条件检查:金币>设定 或 金币<设定 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; } /// 处理 bot 退出:发离房消息、座位清理、转移房主、保证出鱼 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); } /// bot 开火执行(从 HandleBotAI 抽出) 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 monsterIDs = null; if (isSanShe && isLock && target != null) { monsterIDs = new List { 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); } /// /// 获取或寻找目标:优先用记忆的目标(还在前方就沿用),死了/不在前方了再重新找 /// private MonsterContral GetOrFindBotTarget(SitPosData sit, CreateFishManager fishList, Vector3 turretPos, BotSkillRuntime skill, BotBehaviorMode mode, List 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; } /// 从 Boss 列表中选倍率(beilv)最大的(跳过已死亡的) private MonsterContral SelectMaxMultiplierBoss(List 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; } /// /// 按优先级选目标:特殊Boss(5) > Boss(2) > 黄金鱼(3) > 特殊鱼(4) > 普通鱼(1) /// 同一优先级内选最近的,只选炮台前方的鱼 /// 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; // 普通鱼 } } /// 找 bot 三发锁定额外目标(排除已选中的) private List FindBotLockTargets(SitPosData sit, MonsterContral exclude, int count) { var result = new List(); 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; } /// 根据 aiState 取对应行为参数,cfg 为 null 时返回 null 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; } } /// 技能掷骰子:每隔 skillRefreshInterval 秒独立决定各技能开关 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); } /// 人机炮台旋转(房主端执行) 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().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; } } /// 机器人旋转广播(房主代发,复用 rotateTurrect 帧类型) 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); } /// /// 机器人锁定状态广播(房主代发,复用 changeAutoTarget 帧类型)。 /// 用 bot 自己的 sitPos,接收端 SyncGameData_ChangeTargetMonster 会根据 sitPos /// 调用 SetLockTarget/ClearLock 更新对应 bot 座位,从而激活 lockFX 特效和锁定字段。 /// 内置节流:只有目标 guid 变化(包括锁定→取消锁定)才真正广播。 /// /// bot 座位 /// 锁定目标 guid,null 表示取消锁定 private void SyncBotLock(SitPosData botSit, string targetGuid) { // ⭐ 节流:目标 guid 未变化不广播(避免每帧都发) botLastSyncLockGuid.TryGetValue(botSit.posID, out string lastSynced); if (lastSynced == targetGuid) return; botLastSyncLockGuid[botSit.posID] = targetGuid; List guidList = null; if (!string.IsNullOrEmpty(targetGuid)) guidList = new List { 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); } /// /// 机器人炮台状态广播(房主代发,复用 changeTurretState 帧类型)。 /// 用 bot 自己的 sitPos,接收端 SyncGameData_TurretState 会根据 sitPos /// 调用 sit.ChangeState(state) 切换炮台动画。 /// 内置节流:只有状态变化才广播(避免空闲时每帧广播 Idle)。 /// 走 LV2 队列(0.5s flush 一次),对 bot 攻击动画可接受。 /// 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); } /// 人机开火同步(房主代发) private void SyncFire_ForBot(SitPosData botSit, float angle, long gold, bool isSanShe, bool isDouble, bool isLock, List 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 }