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