using System; using System.Collections; using System.Collections.Generic; using System.Linq; using Spine.Unity; using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; /// 炮台控制模式 public enum TurretControlMode { /// 普通模式:动态克隆炮台,显示金币+钻石,按钮打开面板切换 Normal = 0, /// 星际迷航模式:座位子对象激活/停用,只显示金币,长按炮台弹出扇形切换 XJMH = 1 } public class SitPosData : MonoBehaviour, IPointerDownHandler, IPointerUpHandler { public GameObject spineObj;//他的子对象child0带SkeletonGraphic public GameObject ziObj;//他的子对象child0带textmeshprougui public Button btn_SelectPaoBei; //点击打开关闭选择面板 public GameObject selectPaoBeiPanel; //他的出现效果是从上往下滑动的动画效果 关闭是从下往上滑动的动画效果 所称height变成0和还原那种的时间0.5s public GameObject paobeiItemPrefab;//自己的gameobject上面有image组件和button组件 选择后直接关闭 然后他的getchild0上有个textmeshprougui 5000以下的炮倍是蓝色的 5000-10000是紫色 10000以上是红色的 黄色是选中状态的 public Transform paobeiItemParent; public Sprite redBG; public Sprite purpleBG; public Sprite blueBG; public Sprite yellowBG; [Tooltip("自动攻击开关(由 GameMainPanel 的 toggle_SkillAuto 移植):开着=自动扫射+自动开火;关闭时本地座位炮台切回 Idle 并触发 OnAutoAttackOff")] public Toggle toggle_AutoAttack; /// 自动攻击从开→关时触发一次(GameMainPanel 订阅后广播 Idle 状态给其他客户端,仅本地玩家座位会触发) public event Action OnAutoAttackOff; /// 自动攻击是否开启(toggle 没拖引用时恒为 false,等同未开自动攻击) public bool IsAutoAttackOn => toggle_AutoAttack != null && toggle_AutoAttack.isOn; [Tooltip("长按判定时间(秒):按住 img_OpenPT 超过此时间触发 OnOpenPTLongPress")] public float openPTLongPressTime = 0.5f; public Image img_OpenPT; /// 长按 img_OpenPT 触发一次(GameMainPanel 订阅后用它开关炮台面板,仅本地玩家座位有意义) public event Action OnOpenPTLongPress; private bool _openPTPressing; private float _openPTPressTimer; public bool IsBaoJiangingActive; public Transform obj_BaoJiangZhongPanelPosition; /// 本地玩家的座位引用(由游戏面板初始化时设置) public static SitPosData localPlayerSit; /// 当前房间房主座位(人机请求由房主发送,房主退出后自动继承) public static SitPosData roomOwnerSit; [Tooltip("座位编号 0~3。0,1=下方 2,3=上方")] public int posID; [Tooltip("是否为人机")] public bool isBot = false; /// posID>=2 = 上方,需要翻转(0,1=下方;2,3=上方) public bool IsFlipped => posID >= 2; // ------------------------- // 玩家基础信息 // ------------------------- [Header("玩家信息")] public string user;//玩家唯一ID public TextMeshProUGUI tmp_UserNumber;//ID:玩家编号 public TextMeshProUGUI tmp_UserName;//名字 public TextMeshProUGUI tmp_UserLevel;//LV:等级 public Slider slider_UserLevelEXP;//EXP:经验条 public Image img_Head;//头像 public Image img_Vip; public List sprite_VipList;//这里一共有11个正好对应0-10级vip public List sprite_VipLevelBG; //这里有5个 每两级对应一个0级用最基础的 [Header("金币")] public long goldNum; public long consumeGoldNum; [Tooltip("金币数显示(TMP)。与 goldNum_UGUI 二选一:预制体用哪种组件就拖哪个")] public TextMeshProUGUI tMP_GoldNum; [Tooltip("金币数显示(旧版 UGUI Text)。与 tMP_GoldNum 二选一")] public Text goldNum_UGUI; [Tooltip("钻石数显示(TMP)。与 diamondNum_UGUI 二选一:预制体用哪种组件就拖哪个")] public TextMeshProUGUI tMP_DiamondNum; [Tooltip("钻石数显示(旧版 UGUI Text)。与 tMP_DiamondNum 二选一")] public Text diamondNum_UGUI; [Tooltip("炮倍消耗显示(TMP)。与 turretConsumeNum_UGUI 二选一:预制体用哪种组件就拖哪个")] public TextMeshProUGUI tMP_TurretConsumeNum; [Tooltip("炮倍消耗显示(旧版 UGUI Text)。与 tMP_TurretConsumeNum 二选一")] public Text turretConsumeNum_UGUI; [Header("翅膀")] [Tooltip("翅膀实例化的父节点")] public Transform wingParent; [Header("战神")] [Tooltip("战神实例化的父节点")] public Transform zhanShenParent; [Tooltip("当前装备的战神编号(0=无战神)。炮台/战神显示二选一:有战神时 turretParent 隐藏")] public int currentZhanShenNumber; [Header("特殊鱼炮台")] [Tooltip("特殊鱼炮台实例化的父节点")] public Transform tSYPTParent; [Tooltip("特殊鱼炮台(钻头)是否正在使用:true=期间禁用普通开火,点击只发射钻头")] public bool isDrillActive; [Tooltip("激光锁定:true=激光发射期间炮台锁定方向,SetRotation 不生效(原方向打到爆奖结束)")] public bool isLaserLocked; /// /// 散射激光模式中(GameMainPanel 每帧刷新:三发+锁定+已锁目标)。 /// ⭐ 激活左右起点球父对象用这个标记,不依赖实际锁定点数量(目标不足2个时散射模式开着也要显示起点球) /// [System.NonSerialized] public bool isScatterLaserOn; // ------------------------- // 炮台管理(动态克隆) // ------------------------- [Header("炮台")] [Tooltip("炮台实例化的父节点")] public Transform turretParent; public Transform turretRotate; [Header("炮台控制模式")] [Tooltip("Normal=普通模式(动态克隆炮台,显示金币+钻石,按钮切换); XJMH=星际迷航模式(座位子对象激活/停用,只显示金币,长按扇形切换)")] public TurretControlMode turretControlMode = TurretControlMode.Normal; [Tooltip("炮台锚点位置(相对 turretParent)")] public Vector2 turretAnchoredPos = Vector2.zero; [Tooltip("当前装备的炮台编号")] public int currentTurretNumber; /// /// 当前 XJMH 炮台在 中的索引(-1=未初始化)。 /// ⭐ XJMH 四把炮台的 turretNumber 全是 0(不对接后端、没有编号), /// 炮台身份只能用列表索引表示:切换按钮、帧同步广播/接收统一都用索引。 /// [System.NonSerialized] public int currentTurretIndex = -1; /// 当前运行中的炮台实例 public TurretData currentTurret; /// 当前运行中的战神实例(null=未显示)。炮台/战神显示二选一:有战神时 turretParent 隐藏 [System.NonSerialized] public GameObject currentZhanShen; /// 特殊炮台(钻头/激光)期间切换翅膀的暂存引用:只切换不显示,等 RestoreTurretVisual 恢复显示 private GameObject _pendingShowWing; /// 战神实例上的 TurretData/ZhanShenTurretData(开火时优先取战神的子弹预制体,null=无战神或未挂组件) [System.NonSerialized] public TurretData currentZhanShenData; // ------------------------- // 战神激光(锁定时显示):两种形态 // - laserPrefab 非空 → 预制体形态:JiGuangControl 工具类驱动(克隆激光预制体,方向/长度每帧刷新) // - laserPrefab 为空 → LineRenderer 兜底形态:直接新建 GameObject + LineRenderer // ------------------------- /// LineRenderer 形态激光列表。三发锁定=3条。起点:0=主激光(座位点+selfOffsetJGStart),1/2=散射左右(JGFX父对象位置) private readonly List _zhanShenLasers = new List(); /// 预制体形态激光实例列表(世界空间,由 JiGuangControl.JiGuangCreate 按目标数量重建) private readonly List _laserFx = new List(); /// 激光起点特效实例(UI 层:0=挂 turretRotate 下 selfOffsetJGStart 处,1/2=挂 JGFX 左右父对象下,跟随父节点) private readonly List _laserStartFx = new List(); /// 激光命中特效实例(与 _laserFx 一一对应,世界空间 Z=-50 层,跟随锁定目标) private readonly List _laserHitFx = new List(); /// 激光准备等待剩余秒数:锁定刚建立时读 ZhanShenTurretData.laserFXDelay,>0 期间激光及特效隐藏(等准备动画播完) private float _laserPrepWait; /// 激光等待默认兜底秒数:prefab 未配 laserFXDelay(=0) 时使用——门不再默认敞开,特效显示必须等 SMB 动画事件放行(事件来提前开门,超时强制显示) private const float LaserFXWaitFallback = 2f; /// 上一帧是否有锁定点(检测锁定建立沿,触发准备等待) private bool _laserLockActive; /// 激光循环音效是否正在播放(跟随激光实际显示状态起停,避免每帧重复触发) private bool _laserSoundOn; /// 激光循环音效当前播放速度(1=正常,2=狂暴2倍速),用于边沿比较避免每帧重复调用 private float _laserSoundPitch = 1f; /// 预制体形态激光的基准缩放(重建时取自 laserPrefab.transform.localScale) private Vector3 _laserFxBaseScale = Vector3.one; /// 预制体形态激光的角度偏移:激光预制体自带的 Z 轴旋转(重建时缓存,绕世界 Z 叠加在计算朝向上) private float _laserFxRotZOffset; /// 每条激光的起点缓存(索引与 _laserTargets 对齐,每帧复用避免 GC) private readonly List _laserStarts = new List(); // ------------------------- // XJMH 炮台列表(星际迷航专用) // ------------------------- [Header("XJMH 炮台列表")] [Tooltip("XJMH 模式专用:在 Inspector 中拖入座位下的所有炮台子对象(带 TurretData 组件)。" + "数量不多,建议按炮台编号升序排列,便于扇形切换按索引选择。")] public List xjmhTurretList = new List(); // 状态机入口(GameMainPanel 调用) public Action OnStateChange; // ------------------------- // 炮台旋转 // ------------------------- [Header("炮台旋转")] public Transform tra_CurrTurret; public float currentAngle; [Tooltip("炮口最大偏转角度(度),相对基准方向:下方座位(0,1)基准=朝上0°,上方座位(2,3)基准=朝下180°。" + "超出范围的角度会被钳制回边界(本地玩家/远程同步/bot 全部生效)。<=0 或 >=180 表示不限制")] public float maxRotationAngle = 90f; /// /// 炮台在世界坐标(Z=0平面)的位置缓存。 /// 因为炮台是 UGUI 元素,tra_CurrTurret.position 是 Canvas 坐标,不能直接和鱼的世界坐标比较距离。 /// 这里用和子弹起点(GetBulletStartWorldPos)相同的转换方式,把 UI 坐标转成世界坐标。 /// 由 LoadTurret 加载完炮台后计算并缓存,bot AI/锁定检测距离时用此字段。 /// [System.NonSerialized] public Vector3 turretWorldPos; // ------------------------- // 锁定系统 // ------------------------- [Header("锁定系统")] public MonsterContral monCotl_Lock; public Transform monCotl_Lock_Position; public bool isLock = false; /// /// ⭐ 当前锁定是否为玩家手动锁定。 /// - true:玩家点击锁定的鱼 → 不受路径进度(0.1~0.9)限制,鱼离场前一直锁定 /// - false:自动锁定/三发查找/bot → 受路径进度限制 /// 由 GameMainPanel_FishBase 在 SetLockTarget 前设置 /// [System.NonSerialized] public bool isManualLock = false; public GameObject lockFX; // 主锁定图标(单发/双倍用) public List lockFX_Triple = new List(); // 三发锁定额外2个图标 public List tripleLockTargets = new List(); public List tripleLockPositions = new List(); /// /// ⭐ 该锁位是否由玩家手动点选(索引与 tripleLockTargets 对齐)。 /// true = 玩家自己点的鱼 → 离场前一直跟,死了才换;手动身份会跟着鱼一起移入子锁位。 /// private readonly List tripleSlotManual = new List(); // ------------------------- // UI // ------------------------- [Header("UI")] public Button btn_TurretAdd; public Button btn_TurretMinus; [Header("激光效果父对象")] [Tooltip("散射激光左起点球的父对象:出场/换战神时克隆 ZhanShenTurretData.JGQiuStartPrefab 到它下面,散射激光激活时显示。不拖也没关系:Awake 会自动创建兜底节点")] public Transform JGFXLeftParent; [Tooltip("散射激光右起点球的父对象:出场/换战神时克隆 ZhanShenTurretData.JGQiuStartPrefab 到它下面,散射激光激活时显示。不拖也没关系:Awake 会自动创建兜底节点")] public Transform JGFXRightParent; private void Awake() { if (btn_TurretAdd != null) btn_TurretAdd.onClick.AddListener(() => ChangeTurretAddConsumeNum()); if (btn_TurretMinus != null) btn_TurretMinus.onClick.AddListener(() => ChangeTurretMinusConsumeNum()); // ⭐ 自动攻击开关:默认隐藏,等玩家入座时由 ShowPlayer 按"是否本地座位"决定显隐。 // ⚠️ 以前这里是"没激活就无条件激活"(为修预制体把按钮藏了、点不到的问题), // 但第二个人加入时他的座位才第一次激活、Awake 此刻才执行 → 会把他的"自动"按钮也打开, // 屏幕上出现两个"自动"按钮。改为默认隐藏 + 入座时按本地/他座显隐(本地按钮不会再被藏)。 // 监听照旧:关闭时本地座位炮台切回 Idle(原 GameMainPanel 的 toggle_SkillAuto 逻辑) if (toggle_AutoAttack != null) { if (toggle_AutoAttack.gameObject.activeSelf) toggle_AutoAttack.gameObject.SetActive(false); toggle_AutoAttack.onValueChanged.AddListener(OnAutoAttackToggleChanged); } // ⭐ 炮倍选择面板:按钮开/关;Awake 记录面板原始高度(动画还原目标值,之后会被压到 0) if (btn_SelectPaoBei != null) btn_SelectPaoBei.onClick.AddListener(OnClickSelectPaoBei); EnsurePaoBeiPanelHeight(); // ⭐ 散射起点球父对象兜底:预制体没拖引用就自动创建(挂 beij 下,炮台两侧), // 保证 UpdateJGFXParentsActive 永远有对象可激活,不依赖 Inspector 配置。 // XJMH(星际迷航)没有战神/激光概念,跳过不给它加节点。 if (turretControlMode != TurretControlMode.XJMH) EnsureJGFXParents(); // XJMH 模式:隐藏钻石显示(只显示金币;TMP 与旧版 UGUI Text 哪个拖了藏哪个) if (turretControlMode == TurretControlMode.XJMH) { if (tMP_DiamondNum != null) tMP_DiamondNum.gameObject.SetActive(false); if (diamondNum_UGUI != null) diamondNum_UGUI.gameObject.SetActive(false); } // XJMH 模式:炮台是座位自带子对象,不需要从资源加载,只做初始化 // (整理炮台列表 + 激活默认炮台 = 列表索引 0) if (turretControlMode == TurretControlMode.XJMH) InitXJMHTurret(); } /// /// 自动攻击开关变化:关闭时本地座位炮台立即切回 Idle(自动开火停止)。 /// ⭐ 只对本地玩家座位生效:同一个 toggle 被拖到多个座位时,非本地座位不响应, /// 避免远程座位的状态机被本地点击误切。 /// private void OnAutoAttackToggleChanged(bool on) { if (on) return; if (this != localPlayerSit) return; ChangeState(TurretState.Idle); OnAutoAttackOff?.Invoke(); } /// /// 散射起点球父对象兜底:Inspector 没拖引用时自动创建两个 RectTransform /// (挂在 turretRotate 的父节点 beij 下,炮台左右两侧 ±50),并写回字段。 /// 拖了引用(预制体配置的座位)则什么都不做。 /// private void EnsureJGFXParents() { if (JGFXLeftParent != null && JGFXRightParent != null) return; // 挂载点:turretRotate 的父节点(beij);拿不到就挂座位根 Transform attach = turretRotate != null && turretRotate.parent != null ? turretRotate.parent : transform; if (JGFXLeftParent == null) JGFXLeftParent = CreateJGFXParent(attach, "JGFXLeftParent", -50f); if (JGFXRightParent == null) JGFXRightParent = CreateJGFXParent(attach, "JGFXRightParent", 50f); } /// 创建一个散射起点球父节点(beij 下,x 偏移 ±50、y 100,默认隐藏) private static RectTransform CreateJGFXParent(Transform parent, string name, float xOffset) { GameObject go = new GameObject(name, typeof(RectTransform)); RectTransform rt = (RectTransform)go.transform; rt.SetParent(parent, false); rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f); rt.sizeDelta = new Vector2(44f, 37f); rt.anchoredPosition = new Vector2(xOffset, 100f); go.SetActive(false); return rt; } /// /// ⭐ 预热用:本次座位首次激活时先"只整理炮台列表、不点亮默认炮台"—— /// 把炮台 Spine 骨架的首初始化(几十~上百 ms)拆到下一帧单独做,避免座位激活那一帧 /// 同时扛"座位整层 OnEnable + 炮台骨架首建"两个大活。由 GameMainPanel_FishBase 预热流程设置; /// 任何入座/兜底路径(ShowPlayer)都会强制复位并保证炮台被点亮,不会留空座位。 /// [System.NonSerialized] public bool deferDefaultTurretLightUp; /// /// XJMH 模式:炮台是座位自带子对象,预制体里已摆好,不需要 LoadTurret 加载。 /// ⭐ 默认炮台 = 排序后索引 0 的那把(对应座位 GetChild(0) 的绿炮台)。 /// private void InitXJMHTurret() { if (xjmhTurretList == null || xjmhTurretList.Count == 0) { Debug.LogWarning($"[SitPosData] XJMH 炮台列表未配置,座位 {posID} 无炮台可用"); return; } NormalizeXJMHTurretList(); // ⭐ 预热路径:只整理列表,点亮留给预热协程下一帧调 ActivateDefaultXJMHTurretForPrewarm if (deferDefaultTurretLightUp) return; ApplyXJMHTurret(0); } /// /// 预热续帧:点亮默认炮台(配合 deferDefaultTurretLightUp,把炮台骨架首初始化拆到单独一帧)。 /// ⚠️ "是否已点亮"必须看列表里有没有"激活状态"的炮台——currentTurret 是可序列化字段, /// 预制体里预置了引用,不代表炮台已激活(用它当判据会出现"炮台永远不点亮"的 bug)。 /// public void ActivateDefaultXJMHTurretForPrewarm() { if (turretControlMode != TurretControlMode.XJMH) return; if (HasActiveXJMHTurret()) return; // 已点亮(正常入座路径已做过) deferDefaultTurretLightUp = false; InitXJMHTurret(); } /// 列表里当前是否有"激活状态"的炮台("默认炮台是否已点亮"的正确判据) public bool HasActiveXJMHTurret() { if (xjmhTurretList == null) return false; for (int i = 0; i < xjmhTurretList.Count; i++) if (xjmhTurretList[i] != null && xjmhTurretList[i].gameObject.activeSelf) return true; return false; } /// /// 整理 XJMH 炮台列表:剔除空引用,并按座位下的兄弟顺序排序。 /// ⭐ 排序原因:切换面板的 4 个按钮固定是 绿/蓝/金/VIP(索引 0~3), /// 而座位里这四把炮台的兄弟顺序正好就是 绿/蓝/金/VIP。 /// 不排序的话,Inspector 里拖拽的先后顺序会让按钮和炮台对不上。 /// private void NormalizeXJMHTurretList() { xjmhTurretList.RemoveAll(t => t == null); xjmhTurretList.Sort((a, b) => a.transform.GetSiblingIndex().CompareTo(b.transform.GetSiblingIndex())); } /// /// XJMH:按列表索引激活炮台(只激活目标那把,其余全部停用)。 /// ⭐ 不能再用 turretNumber 匹配:XJMH 四把炮台的 turretNumber 全是 0, /// 按编号匹配会把四把一次全激活,且切换到"同编号"的炮台会被判成无需切换(点按钮没反应)。 /// private void ApplyXJMHTurret(int index) { if (xjmhTurretList == null || index < 0 || index >= xjmhTurretList.Count) return; TurretData target = xjmhTurretList[index]; if (target == null) return; for (int i = 0; i < xjmhTurretList.Count; i++) { var td = xjmhTurretList[i]; if (td != null) td.gameObject.SetActive(i == index); } currentTurretIndex = index; currentTurret = target; currentTurretNumber = target.turretNumber; // XJMH 下恒为 0,仅保持字段同步 tra_CurrTurret = GetTurretRotationPivot(target); EnsureXJMHTurretMuzzle(target, tra_CurrTurret); EnsureXJMHTurretAnim(target, tra_CurrTurret); OnStateChange = target.ChangeState; UpdateTurretWorldPos(); target.PlayAppearAnim(); // 出现动画(各 SkeletonGraphic 的 Spine startingAnimation) Debug.Log($"[SitPosData] XJMH 炮台已切换: 索引{index} ({target.name})"); } /// /// XJMH:修正子弹发射点。 /// ⭐ 预制体里绿/蓝/金三把炮台的 tra_BulletStartPosition 指到了"炮体美术节点" /// (它的 pivot 在炮体盒子底部,相对旋转中心是 -29.8,等于旋转中心后方),子弹会从炮台后面出来。 /// 真正的炮口是旋转节点下那个空节点 Bullit(相对旋转中心 +50,正前方)——VIP 就是这么配的。 /// ⭐ 只在当前指向的是「带 Graphic 的美术节点」时才修正:自己拖进来的炮口标记节点(无 Graphic)不动。 /// private static void EnsureXJMHTurretMuzzle(TurretData turret, Transform pivot) { if (pivot == null) return; // 当前指向的节点没有 Graphic(是炮口标记节点)→ 说明是手动配好的,不动 if (turret.tra_BulletStartPosition != null && turret.tra_BulletStartPosition.GetComponent() == null) return; Transform muzzle = pivot.Find("Bullit"); if (muzzle == null) return; Debug.LogWarning($"[SitPosData] XJMH 炮台 {turret.name} 的 tra_BulletStartPosition 指向了美术节点,已自动改用炮口节点 Bullit"); turret.tra_BulletStartPosition = muzzle; } /// /// XJMH:补上炮台动画组件。 /// ⭐ 预制体里绿/蓝/金三把炮台的 TurretData.skeletonAnimation 都是空的(只有 VIP 拖了), /// 而 TurretData.PlaySkeleton 遇到 null 会直接 return → 这三把炮台动画完全不播。 /// 这里按 VIP 的同一套结构自动补:旋转节点(炮台本体组)下面那个 SkeletonGraphic 就是炮体动画, /// VIP 的 skeletonAnimation 指的正是它。Inspector 里已经拖了的不会被覆盖。 /// private static void EnsureXJMHTurretAnim(TurretData turret, Transform pivot) { if (turret.skeletonAnimation != null || pivot == null) return; turret.skeletonAnimation = pivot.GetComponentInChildren(true); Debug.LogWarning(turret.skeletonAnimation != null ? $"[SitPosData] XJMH 炮台 {turret.name} 的 TurretData.skeletonAnimation 未配置,已自动取旋转节点下的 {turret.skeletonAnimation.name}" : $"[SitPosData] XJMH 炮台 {turret.name} 的 TurretData.skeletonAnimation 未配置,且旋转节点下没找到 SkeletonGraphic,动画不会播放"); } private void OnEnable() { // 默认显示等待界面 ShowWaiting(); } private void OnDisable() { // 入场动画标记复位:座位再次激活(重进房/重新入座)时重新播 _xjmhEnterPlayed = false; _xjmhEnterPending = false; if (localPlayerSit == this) localPlayerSit = null; // 房主退出 → 自动继承给第一个非人机玩家 if (roomOwnerSit == this) { roomOwnerSit = null; TransferRoomOwner(); } // 座位禁用时清理战神激光(世界空间对象,不随座位隐藏) DestroyZhanShenLaser(); // 散射起点父对象藏起来(子对象保留,下次出场/换战神时重建) if (JGFXLeftParent != null) JGFXLeftParent.gameObject.SetActive(false); if (JGFXRightParent != null) JGFXRightParent.gameObject.SetActive(false); } private void Update() { UpdateXJMHEnterPending(); // ⭐ XJMH 入场动画挂起补播(面板/炮台就绪即播,避免加载期播了看不到) UpdateZhanShenLaser(); // ⭐ 长按 img_OpenPT 计时:到达阈值触发一次(提前松手不触发) if (_openPTPressing) { _openPTPressTimer += Time.deltaTime; if (_openPTPressTimer >= openPTLongPressTime) { _openPTPressing = false; _openPTPressTimer = 0f; OnOpenPTLongPress?.Invoke(); } } } // ==================== 长按 img_OpenPT(打开炮台面板) ==================== /// 按下:按点落在 img_OpenPT 的屏幕矩形内才开始长按计时(其他区域不响应) public void OnPointerDown(PointerEventData eventData) { if (!IsPointerOnOpenPT(eventData.position)) return; _openPTPressing = true; _openPTPressTimer = 0f; } /// /// 按点是否落在 img_OpenPT 的屏幕矩形内。 /// ⭐ 用坐标矩形判定,而不是比较射线命中的对象:座位下还挂着昵称/表情/炮台等一堆兄弟 UI, /// 实际被射线命中的往往是它们而不是 img_OpenPT,但它们同属座位层级, /// 按下事件照样会冒泡到本组件,所以只有按坐标判定才可靠。 /// private bool IsPointerOnOpenPT(Vector2 screenPos) { if (img_OpenPT == null) return false; RectTransform rect = img_OpenPT.rectTransform; if (rect == null) return false; Canvas canvas = rect.GetComponentInParent(); Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null; return RectTransformUtility.RectangleContainsScreenPoint(rect, screenPos, cam); } /// 松开:取消长按计时(未达阈值不触发) public void OnPointerUp(PointerEventData eventData) { _openPTPressing = false; _openPTPressTimer = 0f; } // ==================== 战神激光(锁定时显示) ==================== /// /// 战神锁定时显示激光,Z 统一压到 -50 显示层。 /// ⭐ 一个锁定点一条激光:单发锁定=1条,三发锁定=3条(主目标+2个三发目标)。 /// ⭐ 每条激光独立起点:第0条(主锁定)=turretRotate 子对象偏移 selfOffsetJGStart(⭐跟随炮台旋转), /// 第1/2条(散射左右)=JGFXLeftParent/JGFXRightParent 的位置(座位 UI 上的起点球父对象)。 /// ⭐ 使用散射激光时激活 JGFXLeftParent/JGFXRightParent 显示起点球,否则隐藏: /// 本地座位=三发+锁定开着(isScatterLaserOn,面板每帧刷新);远程/bot座位=锁定点>1(三发目标凑满)。 /// ⭐ 两种形态:laserPrefab 非空 → 预制体形态(JiGuangControl 工具类);为空 → LineRenderer 兜底形态。 /// ⭐ 只是视觉表现:打中鱼仍按正常攻击频率由子弹完成(HandleAutoFire 以 attackTime 发射锁定子弹)。 /// 普通炮台没有此效果(currentZhanShenData 为 null 时不显示)。 /// private void UpdateZhanShenLaser() { // 收集所有有效锁定点:主目标 + 三发锁定的额外目标 _laserTargets.Clear(); if (currentZhanShenData != null && isLock) { if (monCotl_Lock_Position != null && monCotl_Lock_Position.gameObject.activeInHierarchy) _laserTargets.Add(monCotl_Lock_Position); // 三发锁定:每个有效目标一条激光(主目标已在上面,这里只补额外的) if (tripleLockTargets.Count > 0) { for (int i = 0; i < tripleLockTargets.Count && i < tripleLockPositions.Count; i++) { var pos = tripleLockPositions[i]; var mon = tripleLockTargets[i]; // 主目标(monCotl_Lock)已在上面加过,跳过避免重复 if (mon == monCotl_Lock) continue; // ⭐ 口径与锁定圈一致:只看是否还在场上(activeInHierarchy)。 // 之前额外用 isDead 过滤,导致死亡动画期间"圈还在、激光少一条" if (mon == null || !mon.gameObject.activeInHierarchy) continue; if (pos == null || !pos.gameObject.activeInHierarchy) continue; _laserTargets.Add(pos); } } } // ⭐ 准备动画等待:锁定刚建立时读 ZhanShenTurretData.laserFXDelay,等待期间激光及全部特效 // (光束/起点球/起点特效/命中特效)先不显示,等战神摆姿势动画播完再出现;锁定丢失后重新锁定会重新等待 // ⭐ 0 视为"未配置"→ 用兜底秒数(原语义 0=锁定立即显示会让特效绕过 SMB 动画事件直接显示) bool lockActive = _laserTargets.Count > 0; if (lockActive && !_laserLockActive) { var zsDelay = currentZhanShenData as ZhanShenTurretData; _laserPrepWait = zsDelay != null && zsDelay.laserFXDelay > 0f ? zsDelay.laserFXDelay : LaserFXWaitFallback; // ⭐ 锁定建立:刷新 FireIndex=1 → Fire0→Fire1 过渡生效,激光动画的 SMB 事件负责开门 currentZhanShenData?.RefreshWarGodFireMode(); } else if (!lockActive && _laserLockActive) { // ⭐ 锁定丢失:刷新 FireIndex=0 → 退回 Fire0 子弹动画(过渡条件 FireIndex==0) currentZhanShenData?.RefreshWarGodFireMode(); } _laserLockActive = lockActive; if (_laserPrepWait > 0f) { _laserPrepWait -= Time.deltaTime; lockActive = false; // 等待期间当作未锁定:激光和特效全部隐藏 } // ⭐ 散射激光使用中就激活左右起点父对象显示起点球: // isScatterLaserOn=三发+锁定开着(面板每帧刷新,本地座位);锁定点>1=三发目标已凑满(远程/bot座位) // 两个条件满足其一即激活——散射模式开着但目标不足时也要显示起点球 UpdateJGFXParentsActive(lockActive && (_laserTargets.Count > 1 || isScatterLaserOn)); // ⭐ 激光循环音效:跟随"激光实际显示"状态起停(激光音效很长,逐发播会重叠,改为循环) UpdateZhanShenLaserSound(lockActive); #if UNITY_EDITOR DebugJGFXState(); #endif // 没有锁定点(或准备动画等待中):隐藏所有激光 if (!lockActive) { HideZhanShenLasers(); return; } // 按配置走对应形态:laserPrefab 非空=预制体形态,为空=LineRenderer 兜底 if (currentZhanShenData.laserPrefab != null) UpdateZhanShenLaserFx(); else UpdateZhanShenLaserLine(); } /// /// 战神 loop 动画开始通知(TurretData 转发:ZhanShenLaserLoopSMB 状态机行为 / 剪辑动画事件): /// 立即放行激光及全部特效显示(提前结束 laserFXDelay 等待;未在等待中则无副作用)。 /// public void NotifyZhanShenLaserLoopAnim() { _laserPrepWait = 0f; } /// /// 激光循环音效:跟随"激光实际显示"状态起停(仅本地座位,且战神配置了 laserAudio 才播)。 /// ⭐ 用 AudioManager.PlayFXLoop 走专用循环 AudioSource——激光音效很长,逐发 PlayOneShot 会重叠; /// 起停走状态边沿,锁定期间不重复触发;激光消失/清战神/座位禁用时由 DestroyZhanShenLaser 停止。 /// ⭐ 狂暴(暴走道具,开火速度翻倍)时同步把激光音效提到 2 倍速。 /// private void UpdateZhanShenLaserSound(bool laserVisible) { var zsData = currentZhanShenData as ZhanShenTurretData; bool shouldPlay = laserVisible && this == localPlayerSit && zsData != null && zsData.laserAudio != null; if (!shouldPlay) { // 激光消失/非本地/未配置音效:停止循环(仅本座位在播时才停,避免误停他人座位) if (_laserSoundOn) { _laserSoundOn = false; AudioManager.Instance.StopFXLoop(); } return; } // ⭐ 狂暴:激光音效同步 2 倍速(与开火速度翻倍一致) float pitch = IsFrenzyNow() ? 2f : 1f; if (_laserSoundOn && Mathf.Approximately(pitch, _laserSoundPitch)) return; // 已在播且速度未变 → 无需处理 _laserSoundOn = true; _laserSoundPitch = pitch; AudioManager.Instance.PlayFXLoop(zsData.laserAudio, pitch); // 已在播同一条时只变速,不重头播 } /// 本地座位当前是否狂暴(开火速度翻倍):狂暴标志由面板 SetFrenzy 维护在炮台/战神数据上 private bool IsFrenzyNow() { if (currentZhanShenData != null && currentZhanShenData.isFrenzy) return true; return currentTurret != null && currentTurret.isFrenzy; } /// 隐藏全部战神激光(两种形态都处理) private void HideZhanShenLasers() { for (int i = 0; i < _zhanShenLasers.Count; i++) { if (_zhanShenLasers[i] != null && _zhanShenLasers[i].gameObject.activeSelf) _zhanShenLasers[i].gameObject.SetActive(false); } for (int i = 0; i < _laserFx.Count; i++) { if (_laserFx[i] != null && _laserFx[i].gameObject.activeSelf) _laserFx[i].gameObject.SetActive(false); } // 起点/命中特效一并隐藏 for (int i = 0; i < _laserStartFx.Count; i++) { if (_laserStartFx[i] != null && _laserStartFx[i].activeSelf) _laserStartFx[i].SetActive(false); } for (int i = 0; i < _laserHitFx.Count; i++) { if (_laserHitFx[i] != null && _laserHitFx[i].activeSelf) _laserHitFx[i].SetActive(false); } } /// /// 预制体形态(laserPrefab 非空):JiGuangControl 工具类驱动。 /// 目标数量变化时全量重建(JiGuangCreate),每帧刷新位置/朝向/长度(JiGuangDirection/JiGuangLength,Z 强制 -50)。 /// ⭐ 每条激光独立起点(多起点重载):0=主锁定(座位点+偏移),1/2=散射左右(JGFX父对象位置)。 /// private void UpdateZhanShenLaserFx() { var zsData = currentZhanShenData; // 收集每条激光的起点(与 _laserTargets 一一对应) _laserStarts.Clear(); for (int i = 0; i < _laserTargets.Count; i++) _laserStarts.Add(GetZhanShenLaserStart(i)); // 目标数量变化时全量重建(锁定建立/解除/三发目标增减时触发),并刷新基准缩放 + 预制体初始旋转偏移 if (_laserFx.Count != _laserTargets.Count) { _laserFxBaseScale = zsData.laserPrefab.transform.localScale; // ⭐ 预制体自带的 Z 轴旋转:在激光预制体上转 Z = 激光平面内旋转(X/Y 不参与,防止甩出平面) _laserFxRotZOffset = zsData.laserPrefab.transform.localEulerAngles.z; JiGuangControl.JiGuangCreate(zsData.laserPrefab, _laserStarts, _laserTargets, _laserFx, null, -50f); // ⭐ 起点/命中特效随激光全量重建(ZhanShenTurretData 上没拖预制体则不生成) RebuildZhanShenLaserPointFx(); } // 起点需每帧同步(座位布局/起点球位置变化);隐藏中的重新激活 for (int i = 0; i < _laserFx.Count; i++) { if (_laserFx[i] == null) continue; if (!_laserFx[i].gameObject.activeSelf) _laserFx[i].gameObject.SetActive(true); if (i < _laserStarts.Count) _laserFx[i].position = _laserStarts[i]; } // 朝向 + 长度(多起点重载:起终点同层 Z=-50;朝向绕世界 Z 叠加预制体 Z 轴旋转偏移) JiGuangControl.JiGuangDirection(_laserStarts, _laserTargets, _laserFx, -50f, _laserFxRotZOffset); JiGuangControl.JiGuangLength(_laserStarts, _laserTargets, _laserFx, _laserFxBaseScale, zsData.laserLengthCorrect, -50f); // ⭐ 起点特效挂 UI 起点节点下(位置由父节点决定自动跟随,无需刷),这里只负责重新激活;命中特效每帧跟随目标 for (int i = 0; i < _laserStartFx.Count; i++) { if (_laserStartFx[i] == null) continue; if (!_laserStartFx[i].activeSelf) _laserStartFx[i].SetActive(true); } for (int i = 0; i < _laserHitFx.Count; i++) { if (_laserHitFx[i] == null) continue; if (!_laserHitFx[i].activeSelf) _laserHitFx[i].SetActive(true); if (i < _laserTargets.Count && _laserTargets[i] != null) { Vector3 hitPos = _laserTargets[i].position; hitPos.z = -50f; _laserHitFx[i].transform.position = hitPos; } } } /// LineRenderer 兜底形态(laserPrefab 为空):按需创建、多余的隐藏。每条激光独立起点 private void UpdateZhanShenLaserLine() { // 需要几条就创建几条(三发=3个 LineRenderer),多余的隐藏 for (int i = 0; i < _laserTargets.Count; i++) { var line = i < _zhanShenLasers.Count ? _zhanShenLasers[i] : CreateZhanShenLaser(i); if (line == null) continue; if (!line.gameObject.activeSelf) line.gameObject.SetActive(true); // 起点:第0条=主锁定(座位点+偏移),1/2=散射左右(JGFX父对象位置),Z=-50 Vector3 start = GetZhanShenLaserStart(i); // 终点:该条锁定鱼的位置(Z=-50,与起点同层) Vector3 end = _laserTargets[i].position; end.z = -50f; line.SetPosition(0, start); line.SetPosition(1, end); } for (int i = _laserTargets.Count; i < _zhanShenLasers.Count; i++) { if (_zhanShenLasers[i] != null && _zhanShenLasers[i].gameObject.activeSelf) _zhanShenLasers[i].gameObject.SetActive(false); } } /// 激光终点缓存列表(每帧复用,避免 GC) private readonly List _laserTargets = new List(); /// /// 创建一条 LineRenderer 形态的战神激光(第 index 条)。 /// 仅 laserPrefab 为空时使用:直接创建一个新 GameObject 并加上 LineRenderer(用 TurretData 上的宽度/颜色)。 /// private LineRenderer CreateZhanShenLaser(int index) { var zsData = currentZhanShenData; GameObject laserGo = new GameObject($"ZhanShenLaser_{index}"); LineRenderer line = laserGo.AddComponent(); line.positionCount = Mathf.Max(line.positionCount, 2); laserGo.transform.SetParent(null); // 世界空间,不随座位/炮台旋转 // 应用 TurretData 上的宽度/颜色/材质 line.startWidth = zsData.laserWidth; line.endWidth = zsData.laserWidth; line.startColor = zsData.laserColor; line.endColor = zsData.laserColor; var shader = Shader.Find("Sprites/Default"); if (shader != null) line.material = new Material(shader) { color = Color.white }; line.useWorldSpace = true; _zhanShenLasers.Add(line); return line; } /// 销毁所有战神激光对象(两种形态都清,清战神/座位禁用时调用) private void DestroyZhanShenLaser() { for (int i = 0; i < _zhanShenLasers.Count; i++) { if (_zhanShenLasers[i] != null) Destroy(_zhanShenLasers[i].gameObject); } _zhanShenLasers.Clear(); for (int i = 0; i < _laserFx.Count; i++) { if (_laserFx[i] != null) Destroy(_laserFx[i].gameObject); } _laserFx.Clear(); // 起点/命中特效一并销毁 ClearZhanShenLaserPointFx(); // 准备动画等待状态复位(下次锁定重新计时) _laserPrepWait = 0f; _laserLockActive = false; // 激光循环音效停止(仅本座位在播时才停,避免误停他人座位) if (_laserSoundOn) { _laserSoundOn = false; AudioManager.Instance.StopFXLoop(); } } // ==================== 激光起点/命中特效(三发+锁定) ==================== /// /// 激光全量重建时同步重建起点/命中特效(数量与激光一一对应)。 /// 起点=UI 层挂起点节点下(0=turretRotate 的 selfOffsetJGStart 处,1/2=JGFX 左右父对象);命中=世界层 Z=-50 跟随锁定目标。 /// ZhanShenTurretData 上对应预制体没拖(为空)时不生成。 /// private void RebuildZhanShenLaserPointFx() { ClearZhanShenLaserPointFx(); var zsDataEx = currentZhanShenData as ZhanShenTurretData; if (zsDataEx == null) return; if (zsDataEx.JGStartFXPrefab == null && zsDataEx.JGHitFXPrefab == null) return; for (int i = 0; i < _laserTargets.Count; i++) { if (_laserTargets[i] == null) continue; // ⭐ 起点特效(UI 层):挂到该条激光的起点节点下,位置由父节点决定(自动跟随,无需每帧刷) // 0=主激光 turretRotate 下 selfOffsetJGStart 处(子对象语义,跟随炮台旋转);1/2=JGFX 左右父对象下 if (zsDataEx.JGStartFXPrefab != null) { Transform startParent = null; Vector3 startLocal = Vector3.zero; if (i == 0 && turretRotate != null) { startParent = turretRotate; startLocal = new Vector3(zsDataEx.selfOffsetJGStart.x, zsDataEx.selfOffsetJGStart.y, 0f); } else if (i == 1 && JGFXLeftParent != null) { startParent = JGFXLeftParent; } else if (i == 2 && JGFXRightParent != null) { startParent = JGFXRightParent; } if (startParent != null) { GameObject startGo = Instantiate(zsDataEx.JGStartFXPrefab, startParent, false); startGo.transform.localPosition = startLocal; _laserStartFx.Add(startGo); } } // 命中特效:克隆到该条激光的终点(锁定目标身上,Z 压 -50 与激光同层) if (zsDataEx.JGHitFXPrefab != null) { Vector3 hitPos = _laserTargets[i].position; hitPos.z = -50f; _laserHitFx.Add(Instantiate(zsDataEx.JGHitFXPrefab, hitPos, Quaternion.identity)); } } } /// 销毁全部起点/命中特效实例(激光重建/销毁时调用) private void ClearZhanShenLaserPointFx() { for (int i = 0; i < _laserStartFx.Count; i++) { if (_laserStartFx[i] != null) Destroy(_laserStartFx[i]); } _laserStartFx.Clear(); for (int i = 0; i < _laserHitFx.Count; i++) { if (_laserHitFx[i] != null) Destroy(_laserHitFx[i]); } _laserHitFx.Clear(); } // ==================== 战神激光起点(每条激光独立起点) ==================== /// /// 战神激光第 index 条的起点(鱼群世界坐标,Z=-50 显示层): /// - 第 0 条(主锁定):turretRotate 子对象偏移(selfOffsetJGStart 是 turretRotate 的本地偏移, /// ⭐ 跟随炮台旋转:炮口转向时起点绕炮台中心跟着转) /// - 第 1 条(散射左):JGFXLeftParent 的位置 /// - 第 2 条(散射右):JGFXRightParent 的位置 /// - 越界/未配置:回退子弹炮口位置(旧版行为) /// private Vector3 GetZhanShenLaserStart(int index) { var zs = currentZhanShenData as ZhanShenTurretData; // 主起点:turretRotate 的子对象偏移(炮台旋转时该点跟着转,TransformPoint 含旋转+缩放) if (index == 0 && zs != null) { Vector3 offset = new Vector3(zs.selfOffsetJGStart.x, zs.selfOffsetJGStart.y, 0f); Vector3 startPoint = turretRotate != null ? turretRotate.TransformPoint(offset) : transform.position + offset; return SeatUIToFishWorld(startPoint, -50f); } // 散射左右起点:座位上左右两个起点球父对象的位置 if (index == 1 && JGFXLeftParent != null) return SeatUIToFishWorld(JGFXLeftParent.position, -50f); if (index == 2 && JGFXRightParent != null) return SeatUIToFishWorld(JGFXRightParent.position, -50f); // 回退:子弹炮口(旧版行为,未配置左右父对象/未挂 ZhanShenTurretData 时) Vector3 fallback = GetBulletStartPos(); fallback.z = -50f; return fallback; } /// /// 座位 UI 上的世界坐标(座位根节点/座位子节点位置)→ 鱼群世界坐标(子弹/激光所在空间)。 /// 与 TurretData.GetBulletStartWorldPos 同一套换算(UI→屏幕→世界),Z 压到指定显示层。 /// private Vector3 SeatUIToFishWorld(Vector3 uiWorldPos, float z) { Canvas canvas = UIManager.Instance != null && UIManager.Instance.uiRoot != null ? UIManager.Instance.uiRoot.GetComponent() : null; Camera cam = Camera.main; if (canvas == null || cam == null) { // 编辑器没跑起来时兜底:直接用 UI 坐标压 Z Vector3 fallback = uiWorldPos; fallback.z = z; return fallback; } Vector3 screenPos = RectTransformUtility.WorldToScreenPoint( canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera, uiWorldPos); float distance = Mathf.Abs(cam.transform.position.z - 0f); Vector3 worldPos = cam.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, distance)); worldPos.z = z; return worldPos; } /// /// 散射激光激活/隐藏左右起点父对象(JGFXLeftParent/JGFXRightParent): /// 使用散射激光(三发+锁定开着,或锁定点>1)时激活显示起点球,关闭/单发时隐藏。 /// private void UpdateJGFXParentsActive(bool scatterActive) { if (JGFXLeftParent != null && JGFXLeftParent.gameObject.activeSelf != scatterActive) JGFXLeftParent.gameObject.SetActive(scatterActive); if (JGFXRightParent != null && JGFXRightParent.gameObject.activeSelf != scatterActive) JGFXRightParent.gameObject.SetActive(scatterActive); } #if UNITY_EDITOR /// 散射起点诊断计时器(编辑器排查用,3秒一条,只在战神+锁定期间打印) private float _jgfxLogTimer = -99f; /// /// 编辑器诊断:锁定期间每3秒打印散射起点球的判定条件。 /// 用来定位「三发+锁定开了但左右父对象不激活」断在哪个环节: /// 三发目标数 / 激光条数 / 散射标记 / 左右父对象引用是否拖了 / 当前激活状态。 /// private void DebugJGFXState() { if (currentZhanShenData == null || !isLock) return; if (Time.unscaledTime - _jgfxLogTimer < 3f) return; _jgfxLogTimer = Time.unscaledTime; Debug.Log($"[SitPosData] 座位{posID} 散射起点诊断: " + $"三发目标数={tripleLockTargets.Count} 激光条数={_laserTargets.Count} " + $"散射标记={isScatterLaserOn} " + $"左父对象={(JGFXLeftParent != null ? "已拖" : "NULL!")} " + $"右父对象={(JGFXRightParent != null ? "已拖" : "NULL!")} " + $"左激活={(JGFXLeftParent != null && JGFXLeftParent.gameObject.activeSelf)} " + $"左子对象数={(JGFXLeftParent != null ? JGFXLeftParent.childCount : -1)}"); } #endif /// /// 出场/换战神时刷新散射激光起点球: /// 先清空 JGFXLeftParent/JGFXRightParent 的旧子对象,再各自克隆一份 /// ZhanShenTurretData.JGQiuStartPrefab(保持预制体原始位置/旋转/缩放)。 /// 没配置 JGQiuStartPrefab/组件就只清空并打警告;父对象默认隐藏,散射激光激活时由 UpdateZhanShenLaser 打开。 /// private void RefreshJGQiuStartFX() { var zs = currentZhanShenData as ZhanShenTurretData; ClearJGFXParents(); if (zs == null) { Debug.LogWarning($"[SitPosData] 战神 #{currentZhanShenNumber} 未挂 ZhanShenTurretData 组件,散射起点球无法创建(座位 {posID})"); return; } if (zs.JGQiuStartPrefab == null) { Debug.LogWarning($"[SitPosData] 战神 #{currentZhanShenNumber} 的 ZhanShenTurretData.JGQiuStartPrefab 未配置,散射起点球无法创建(座位 {posID})——请在战神预制体上拖入起点球预制体"); return; } // 未配置父对象就不克隆(避免起点球落到场景根下),并提示去座位预制体上拖引用 if (JGFXLeftParent == null || JGFXRightParent == null) Debug.LogWarning($"[SitPosData] 座位{posID} 的 JGFXLeftParent/JGFXRightParent 未拖引用——散射起点球无处挂载,请在座位预制体上把左右起点球父对象拖进 SitPosData(座位 {posID} 战神 #{currentZhanShenNumber})"); if (JGFXLeftParent != null) Instantiate(zs.JGQiuStartPrefab, JGFXLeftParent, false); if (JGFXRightParent != null) Instantiate(zs.JGQiuStartPrefab, JGFXRightParent, false); } /// 清空左右起点父对象的子对象并隐藏(清战神/重装起点球前调用) private void ClearJGFXParents() { ClearChildren(JGFXLeftParent); ClearChildren(JGFXRightParent); if (JGFXLeftParent != null) JGFXLeftParent.gameObject.SetActive(false); if (JGFXRightParent != null) JGFXRightParent.gameObject.SetActive(false); } /// 清空一个父节点下的所有子对象 private static void ClearChildren(Transform parent) { if (parent == null) return; for (int i = parent.childCount - 1; i >= 0; i--) Destroy(parent.GetChild(i).gameObject); } #if UNITY_EDITOR /// /// 编辑器可视化:战神主激光起点(turretRotate 子对象偏移 selfOffsetJGStart,⭐ 跟随炮台旋转)。 /// 运行中加载战神后在 Scene 视图可见(旋转炮台时球会绕炮台中心跟着转): /// 黄色小球=turretRotate 位置(偏移基准),洋红线框球=主激光起点,连线=偏移方向。 /// private void OnDrawGizmos() { var zs = currentZhanShenData as ZhanShenTurretData; if (zs == null) return; // 主起点:turretRotate 的本地偏移(跟随旋转,与 GetZhanShenLaserStart(0) 同一套计算) Vector3 offset = new Vector3(zs.selfOffsetJGStart.x, zs.selfOffsetJGStart.y, 0f); Vector3 basePoint = turretRotate != null ? turretRotate.position : transform.position; Vector3 startPoint = turretRotate != null ? turretRotate.TransformPoint(offset) : basePoint + offset; // turretRotate 位置(偏移基准) Gizmos.color = Color.yellow; Gizmos.DrawSphere(basePoint, 8f); // 主激光起点 + 基准点连线(显示偏移关系) Gizmos.color = Color.magenta; Gizmos.DrawWireSphere(startPoint, 20f); Gizmos.DrawLine(basePoint, startPoint); UnityEditor.Handles.Label(startPoint + Vector3.up * 25f, "主激光起点(跟随炮台旋转)"); } #endif /// /// 玩家加入 → 显示玩家 UI(child0),隐藏等待界面(child1)。 /// ⭐ XJMH 座位没有"玩家界面/等待界面"这一对子节点(child0~3 是四把炮台), /// 不能在这里动子对象显隐,否则会把炮台当等待界面切来切去,和切换炮台的激活状态互相打架。 /// public void ShowPlayer() { if (turretControlMode != TurretControlMode.XJMH) { if (transform.childCount > 0) transform.GetChild(0).gameObject.SetActive(true); if (transform.childCount > 1) transform.GetChild(1).gameObject.SetActive(false); } // ⭐ "自动"按钮只属于本地玩家:本地座位显示、其他座位隐藏 // (第二个人加入时他的座位才第一次激活,Awake 已改为默认隐藏,这里按座位归属再定一次) if (toggle_AutoAttack != null) toggle_AutoAttack.gameObject.SetActive(user == GameManager.Instance?.user?.id); // 如果是本地玩家的座位,自动注册并加载装备 if (user == GameManager.Instance?.user?.id) { localPlayerSit = this; // XJMH 模式:炮台是座位自带子对象,Awake 已初始化引用,不需要加载 if (turretControlMode != TurretControlMode.XJMH) { var cannonUsing = GameManager.Instance.user.cannonUsing; if (cannonUsing != null && int.TryParse(cannonUsing.number, out int num)) LoadTurret(num); var wingUsing = GameManager.Instance.user.wingUsing; if (wingUsing != null && int.TryParse(wingUsing.number, out int wingNum)) LoadWing(wingNum); var godWarUsing = GameManager.Instance.user.godWarUsing; if (godWarUsing != null && int.TryParse(godWarUsing.number, out int zsNum)) LoadZhanShen(zsNum); } else { // ⭐ 兜底:预热被中断(激活了座位但还没轮到点亮炮台就退出/销毁)时,入座这里补点亮, // 保证任何时候都不会出现"座位亮着但炮台是空的"(判据:列表里有没有激活的炮台) if (!HasActiveXJMHTurret()) ActivateDefaultXJMHTurretForPrewarm(); // ⭐ XJMH 进入房间入场动画(原版 Battery:EnterAction):仅本地座位播 // (Enter 音效 + 飞机从下方升起变回炮台 + PlaneBack 音效); // 本次激活周期只播一次(OnDisable 复位,重进房重新播),切炮台不受影响 PlayXJMHEnterActionOnce(); } } // 非人机玩家加入时,自动分配为首个房主 if (!isBot) AssignRoomOwner(this); } /// XJMH 本地座位进房入场动画是否已播(本次激活周期只播一次;OnDisable 复位) private bool _xjmhEnterPlayed; /// 已到入座时机但炮台/面板尚未激活(播了看不到)→ 挂起,等激活后 Update 补播 private bool _xjmhEnterPending; /// /// XJMH 本地座位进房入场动画(原版 PW_Data.InitPlayer → BatteryMgr.PlayEnterAction,首次播完整动画+声音): /// 入座时调用一次;面板/炮台未激活时挂起(此时播玩家看不到、协程也起不来)→ Update 补播。 /// private void PlayXJMHEnterActionOnce() { if (_xjmhEnterPlayed || _xjmhEnterPending) return; var turret = currentTurret; if (turret == null && xjmhTurretList != null && xjmhTurretList.Count > 0) turret = xjmhTurretList[0]; // 炮台当前不可播(面板加载中/炮台未激活):挂起等就绪 if (turret == null || !turret.isActiveAndEnabled) { _xjmhEnterPending = true; Debug.Log($"[XJMH入场] 座位 {name} 挂起(炮台未就绪:turret={(turret == null ? "null" : turret.name)})"); return; } _xjmhEnterPlayed = true; Debug.Log($"[XJMH入场] 座位 {name} 触发播放(炮台={turret.name})"); if (turret is FlyTurretData flyTurret) flyTurret.PlayEnterAction(); } /// /// XJMH 入场动画触发/补播检查(Update 每帧): /// ① 兜底触发:本地座位已入座(user=本地)且从未触发过 → 走一次触发(覆盖 ShowPlayer 路径未走到的情况); /// ② 挂起补播:已触发但炮台/面板当时未激活 → 就绪(玩家可见)立即播。 /// private void UpdateXJMHEnterPending() { // ⭐ 加载到 100% 前不触发/不补播(玩家看不到、位置未就绪):等加载完成后 Update 自然触发 var panel = CreateFishManager.Instance?.currGameType as GameMainPanel_FishBase; if (panel == null || !panel.IsGameLoadCompleted) return; // ① 兜底触发(Update 只在座位/面板激活时执行 → 触发时玩家已可见) if (turretControlMode == TurretControlMode.XJMH && !_xjmhEnterPlayed && !_xjmhEnterPending && !string.IsNullOrEmpty(user) && user == GameManager.Instance?.user?.id) { PlayXJMHEnterActionOnce(); } // ② 挂起补播 if (!_xjmhEnterPending || _xjmhEnterPlayed) return; var turret = currentTurret; if (turret == null || !turret.isActiveAndEnabled) return; _xjmhEnterPending = false; _xjmhEnterPlayed = true; Debug.Log($"[XJMH入场] 座位 {name} 补播(炮台={turret.name})"); if (turret is FlyTurretData flyTurret) flyTurret.PlayEnterAction(); } /// /// 玩家退出/等待 → 显示等待界面(child1),隐藏玩家 UI(child0)。 /// ⭐ XJMH 座位没有这一对子节点(child0~3 是四把炮台),跳过(原因见 ShowPlayer)。 /// public void ShowWaiting() { // ⭐ 离座:自动按钮一并隐藏(XJMH 也要处理——空座位不该留"自动"按钮) if (toggle_AutoAttack != null) toggle_AutoAttack.gameObject.SetActive(false); // ⭐ 离座取消入场挂起(重进房时重新播放) _xjmhEnterPending = false; if (turretControlMode == TurretControlMode.XJMH) return; if (transform.childCount > 0) transform.GetChild(0).gameObject.SetActive(false); if (transform.childCount > 1) transform.GetChild(1).gameObject.SetActive(true); } /// /// 设置座位上玩家的显示信息(进房时由 GameMainPanel.JoinGame 调用, /// 数据来自服务端下发的 RoomInfo.players / RoomJoinNotice): /// 玩家ID、昵称、头像、VIP 图标(0~10 级一一对应)、VIP 等级背景(每两级一个)。 /// /// 玩家唯一 ID(用于判断是否本人) /// 玩家昵称(显示在 tmp_UserName) /// 头像名/URL(走 GameManager 头像缓存,找不到回退默认头像) /// VIP 等级 0~10 /// 玩家编号(短ID,显示在 tmp_UserNumber;空则回退显示 userId) /// 玩家等级(显示在 tmp_UserLevel) /// 玩家当前经验(原始值) /// 升级所需总经验(经验条进度 = exp / maxExp) public void SetUserInfo(string userId, string userName, string avatar, int vipLevel, string userNumber = null, int level = 0, long exp = 0, long maxExp = 0) { // 玩家 ID:优先显示后端下发的短编号 UserNumber,没有再用 userId。带 "ID:" 前缀 if (tmp_UserNumber != null) { string displayId = string.IsNullOrEmpty(userNumber) ? userId : userNumber; if (!string.IsNullOrEmpty(displayId)) tmp_UserNumber.text = "ID:" + displayId; } // 昵称:非本人的名字打码(前2后2保留,中间***),自己原样显示 if (tmp_UserName != null && !string.IsNullOrEmpty(userName)) tmp_UserName.text = userId == GameManager.Instance?.user?.id ? userName : MaskUserName(userName); // 等级:带 "LV:" 前缀显示玩家等级 if (tmp_UserLevel != null && level > 0) tmp_UserLevel.text = "LV:" + level; // 经验条:后端下发原始经验值 + 升级所需总经验,换算成 0~1 进度 if (slider_UserLevelEXP != null) slider_UserLevelEXP.value = maxExp > 0 ? Mathf.Clamp01((float)exp / maxExp) : 0f; // 头像:优先 GameManager 头像缓存(Resources/Avatar),查不到用默认头像 if (img_Head != null) { Sprite head = GameManager.Instance.GetAvatarSprite(avatar); if (head != null) img_Head.sprite = head; } // VIP 图标:sprite_VipList 共 11 个正好对应 0~10 级,越界取两端,只激活对应的一个 if (sprite_VipList != null && sprite_VipList.Count > 0) { int vipIndex = Mathf.Clamp(vipLevel, 0, sprite_VipList.Count - 1); for (int i = 0; i < sprite_VipList.Count; i++) { if (sprite_VipList[i] != null && sprite_VipList[i].activeSelf != (i == vipIndex)) sprite_VipList[i].SetActive(i == vipIndex); } } // VIP 等级背景:sprite_VipLevelBG 共 5 个,每两级对应一个(0 级用最基础的),只激活对应的一个 if (sprite_VipLevelBG != null && sprite_VipLevelBG.Count > 0) { int bgIndex = Mathf.Clamp(vipLevel / 2, 0, sprite_VipLevelBG.Count - 1); for (int i = 0; i < sprite_VipLevelBG.Count; i++) { if (sprite_VipLevelBG[i] != null && sprite_VipLevelBG[i].activeSelf != (i == bgIndex)) sprite_VipLevelBG[i].SetActive(i == bgIndex); } } } /// /// 非本人昵称打码:保留前 2 位和后 2 位,中间用 *** 代替。 /// 例:123456789 → 12***89;12345 → 12***45。不足 5 位原样显示。 /// private static string MaskUserName(string name) { if (string.IsNullOrEmpty(name) || name.Length < 5) return name; return name.Substring(0, 1) + "***" + name.Substring(name.Length - 1); } #region 表情包显示(spineObj / ziObj) /// 表情显示时长(秒),超时自动隐藏 public float biaoQingDuration = 3f; /// 正在运行的表情隐藏协程(新表情顶掉旧表情) private Coroutine _biaoQingCoroutine; /// /// 显示 Spine 动画表情:spineObj 的 child0(SkeletonGraphic)换骨骼资源并播放动画, /// biaoQingDuration 秒后自动隐藏。与文字表情互斥(同时只显示一种)。 /// /// spine 骨骼资源 /// 动画名 public void ShowBiaoQingSpine(SkeletonDataAsset asset, string animName) { if (spineObj == null || asset == null || string.IsNullOrEmpty(animName)) return; // child0 = SkeletonGraphic var sg = spineObj.transform.childCount > 0 ? spineObj.transform.GetChild(0).GetComponent() : null; if (sg == null) return; if (ziObj != null) ziObj.SetActive(false); if (_biaoQingCoroutine != null) { StopCoroutine(_biaoQingCoroutine); _biaoQingCoroutine = null; } sg.skeletonDataAsset = asset; sg.Initialize(false); sg.AnimationState.SetAnimation(0, animName, true); spineObj.SetActive(true); _biaoQingCoroutine = StartCoroutine(HideBiaoQingAfter(spineObj)); } /// /// 显示文字表情:先设置文字再激活 ziObj,激活后立即强制刷新两级 ContentSizeFitter /// (child0 的 TMP 自身 + 父物体),避免宽度延迟一帧才更新。 /// biaoQingDuration 秒后自动隐藏。与 spine 表情互斥(同时只显示一种)。 /// /// 文字内容 public void ShowBiaoQingZi(string text) { if (ziObj == null || string.IsNullOrEmpty(text)) return; // child0 = TextMeshProUGUI var tmp = ziObj.transform.childCount > 0 ? ziObj.transform.GetChild(0).GetComponent() : null; if (tmp == null) return; if (spineObj != null) spineObj.SetActive(false); if (_biaoQingCoroutine != null) { StopCoroutine(_biaoQingCoroutine); _biaoQingCoroutine = null; } // ⭐ 先设置字,再激活:TMP 非激活时改字不会向布局系统注册重建,激活后统一处理 tmp.text = text; ziObj.SetActive(true); // ⭐ 激活后立即按"先子后父"顺序强制刷新两级 ContentSizeFitter: // 子级 TMP 先算出新宽度,父级 Fitter 再根据子级新宽度收缩, // 否则布局要等帧末才重建,当帧读到的还是旧宽度 LayoutRebuilder.ForceRebuildLayoutImmediate(tmp.rectTransform); LayoutRebuilder.ForceRebuildLayoutImmediate(ziObj.transform as RectTransform); Canvas.ForceUpdateCanvases(); _biaoQingCoroutine = StartCoroutine(HideBiaoQingAfter(ziObj)); } /// 表情显示 biaoQingDuration 秒后自动隐藏 private IEnumerator HideBiaoQingAfter(GameObject obj) { yield return new WaitForSeconds(biaoQingDuration); if (obj != null) obj.SetActive(false); _biaoQingCoroutine = null; } #endregion /// 房主退出后,将房主身份转移给第一个活跃的非人机玩家 /// 鱼创建已由服务端 OnFishSpawn 统一下发,此处仅维护 roomOwnerSit 标识(用于 bot 控制/AFK 等场景)。 public static void TransferRoomOwner() { var list = FindObjectsOfType(); foreach (var sit in list) { if (!sit.isBot && sit.gameObject.activeInHierarchy && sit != roomOwnerSit) { roomOwnerSit = sit; break; } } } /// 分配房主(首个非人机玩家) /// 鱼创建已由服务端 OnFishSpawn 统一下发,此处仅维护 roomOwnerSit 标识(用于 bot 控制/AFK 等场景)。 public static void AssignRoomOwner(SitPosData sit) { if (roomOwnerSit == null && !sit.isBot) { roomOwnerSit = sit; } } // ==================== 炮台加载 ==================== /// /// 获取炮台的旋转节点。 /// - 若 TurretData.tra_RotationPivot 已赋值 → 用它(精确指定旋转中心) /// - XJMH:自动取"容器里那个炮台本体节点"(见 ) /// - Normal:回退旧逻辑,用炮台父节点(克隆用的中间容器) /// private Transform GetTurretRotationPivot(TurretData turret) { if (turret == null) return null; // ⭐ 已分离旋转(用 turretRotate 旋转)时:tra_CurrTurret 指向 turretRotate 本身。 // 瞄准代码用 tra_CurrTurret.transform.parent(=beij,固定不转)作为参考系, // 上方座位 beij 预设的 180° 翻转与面板 IsFlipped+180 补偿相互抵消, // currentAngle 恢复 aim-90 的统一约定(炮口/子弹/激光方向全部一致)。 // turretRotate.anchoredPosition 与旧 turretParent 同节点同值,瞄准原点不变; // beij 不参与旋转,参考系不会抖动。 if (turretRotate != null) return turretRotate; // 优先用 TurretData 上配置的旋转节点 if (turret.tra_RotationPivot != null) return turret.tra_RotationPivot; // ⭐ XJMH:炮台是"容器"(绿=SkeletonGraphic_Green),容器里除了炮台本体, // 还挂着加减倍按钮(Up)、飞机(lvptfeiji)等其他子节点。 // 转容器本身会把它们一起甩着转;转父节点又等于转整个座位(座位就是 XJMH 炮台的父节点)。 // 真正该转的是容器里那个炮台本体节点 → 自动找。 if (turretControlMode == TurretControlMode.XJMH) { Transform pivot = FindXJMHRotationPivot(turret); return pivot != null ? pivot : turret.transform; } // 回退:用父节点(兼容普通模式旧逻辑) Transform parent = turret.transform.parent; return parent != null ? parent : turret.transform; } /// /// XJMH 炮台的旋转节点 = 炮口所在的、炮台的直接子节点。 /// ⭐ 从炮口 往上走,取"炮台的直接子节点": /// 炮台容器(SkeletonGraphic_Green / huangjinpaotai_1 / SkeletonGraphic_VIP) /// └─ 炮台本体 ← 要转的就是它(lvptpaotai_2 / huangjinpaotai_2 / vippaotai01_2) /// └─ …炮口 tra_BulletStartPosition… /// 已核对预制体:三种炮台都符合,且与 VIP 上手动填的 TurretData.tra_RotationPivot 完全一致。 /// private static Transform FindXJMHRotationPivot(TurretData turret) { Transform muzzle = turret.tra_BulletStartPosition; if (muzzle == null) return null; Transform node = muzzle; while (node.parent != null && node.parent != turret.transform) node = node.parent; return node.parent == turret.transform ? node : null; } /// /// 根据炮台编号加载炮台。 /// Normal 模式:从 Addressables 加载预制体并克隆到 turretParent。 /// XJMH 模式:炮台是座位自带子对象,传入值按 索引解释(XJMH 炮台没有编号)。 /// public void LoadTurret(int turretNumber) { if (turretControlMode == TurretControlMode.XJMH) { SwitchTurretByIndex(turretNumber); return; } LoadTurretNormal(turretNumber); } /// /// 普通模式:从 Addressables 加载预制体并克隆到 turretParent。 /// 位置/旋转/缩放保持预制体原始值,不动。 /// private void LoadTurretNormal(int turretNumber) { // ⭐ 装备战神时切炮台不顶掉战神:战神保持显示并继续接管开火状态机, // 新炮台照常加载到 turretParent(战神装备中它是隐藏的),卸下战神(ClearZhanShen)后自动显示新炮台 bool zhanShenActive = currentZhanShenData != null; // ⭐ 换炮台时只清活跃子弹(清屏),保留池缓存(同 prefab 能复用) // 用 ClearActive 而非 ClearAll:ClearAll 会清池导致永远不复用 BulletPool.Instance.ClearActive(); if (currentTurret != null) { Destroy(currentTurret.gameObject); currentTurret = null; } // ⭐ 换炮台:释放上一个炮台的按需引用(引用归零才真正卸包;同编号不释放) if (currentTurretNumber > 0 && currentTurretNumber != turretNumber) TurretResourcesData.ReleaseTurretPrefab(currentTurretNumber); currentTurretNumber = turretNumber; // ⭐ 散包按需加载:缓存没有时异步取(PT{编号} 标签),完成后实例化 StartCoroutine(LoadTurretRoutine(turretNumber, ++_turretLoadSeq)); } /// 炮台加载序号(防加载期间再次切换的竞态) private int _turretLoadSeq = 0; /// 翅膀加载序号:必须与炮台分开!否则 LoadTurret→LoadWing 连调时翅膀会把炮台协程判为“已切走”而丢弃 private int _wingLoadSeq = 0; /// 当前按需加载的翅膀编号(换翅膀/卸下时释放引用) private int _currentWingNumber = 0; /// 炮台异步按需加载协程:AcquireTurretPrefabAsync 完成后实例化(原同步逻辑后半段) private IEnumerator LoadTurretRoutine(int turretNumber, int seq) { // ⭐ 每次都走 Acquire(缓存命中立即返回并正确 +1 引用,多座位共用同一炮台不会互相误卸) GameObject prefab = null; { var task = TurretResourcesData.AcquireTurretPrefabAsync(turretNumber); while (!task.IsCompleted) yield return null; if (seq != _turretLoadSeq) // 期间又切换了炮台 → 丢弃并归还引用 { TurretResourcesData.ReleaseTurretPrefab(turretNumber); yield break; } prefab = task.Result; } if (prefab == null) { TurretResourcesData.ReleaseTurretPrefab(turretNumber); Debug.LogWarning($"[SitPosData] 炮台预制体未加载: #{turretNumber}(检查 PT{turretNumber} 标签配置)"); yield break; } // 克隆到父节点 GameObject instance = Instantiate(prefab, turretParent, false); instance.SetActive(true); // 固定位置归零,由外部父节点定位 var rt = instance.GetComponent(); if (rt != null) rt.anchoredPosition = turretAnchoredPos; currentTurret = instance.GetComponent(); if (currentTurret == null) { Debug.LogError($"[SitPosData] 预制体上未找到 TurretData 组件: #{turretNumber}"); Destroy(instance); yield break; } currentTurret.turretNumber = turretNumber; // ⭐ 战神装备中判断:异步加载完成后实时读(原方法开头局部变量 zhanShenActive 不能跨协程) bool zhanShenActive = currentZhanShenData != null; // 缓存旋转节点:优先用 TurretData.tra_RotationPivot,否则回退父节点 tra_CurrTurret = GetTurretRotationPivot(currentTurret); // 绑定状态机入口:战神装备中不抢(保持战神接管开火),无战神时才交给炮台 if (!zhanShenActive) OnStateChange = currentTurret.ChangeState; // 无战神时确保炮台可见;战神装备中保持 turretParent 隐藏(卸下时由 ClearZhanShen 恢复显示) if (turretParent != null && !zhanShenActive) turretParent.gameObject.SetActive(true); // ⭐ 缓存炮台在世界坐标(Z=0平面)的位置,bot AI/锁定检测距离时用 UpdateTurretWorldPos(); Debug.Log($"[SitPosData] 炮台已加载(Normal): #{turretNumber}" + (zhanShenActive ? "(战神装备中,炮台待命)" : "")); } /// /// XJMH 模式:按列表索引切换炮台。 /// ⭐ 切换面板的 4 个按钮、帧同步接收端都走这里(XJMH 炮台没有编号,只能用索引)。 /// /// 中的索引 /// 切换成功返回 true;索引越界或列表为空返回 false public bool SwitchTurretByIndex(int index) { if (turretControlMode != TurretControlMode.XJMH) return false; if (xjmhTurretList == null || index < 0 || index >= xjmhTurretList.Count) return false; if (xjmhTurretList[index] == null) return false; // 已经是这把 → 不重复切换(避免无谓清屏子弹);currentTurret 为空时仍需重新应用一次 if (index == currentTurretIndex && currentTurret != null) return true; BulletPool.Instance.ClearActive(); ApplyXJMHTurret(index); return true; } /// /// 获取当前可用的炮台编号列表。 /// Normal 模式:从资源表按类型查询。 /// XJMH 模式:优先从 读取,列表为空时回退扫描座位子对象。 /// public List GetAvailableTurretNumbers() { var result = new List(); if (turretControlMode == TurretControlMode.XJMH) { // 优先用配置的炮台列表 if (xjmhTurretList != null && xjmhTurretList.Count > 0) { foreach (var td in xjmhTurretList) if (td != null) result.Add(td.turretNumber); } else { // 回退:扫描座位子对象 Transform parent = turretParent != null ? turretParent : transform; for (int i = 0; i < parent.childCount; i++) { var td = parent.GetChild(i).GetComponent(); if (td != null) result.Add(td.turretNumber); } } } else { var list = TurretResourcesData.GetTurretDataByType(1); if (list != null) result = list.ConvertAll(d => d.turretNumber); } return result; } /// /// 把炮台 UI 坐标转换成世界坐标(Z=0平面)并缓存到 turretWorldPos。 /// 转换方式和 TurretData.GetBulletStartWorldPos 一致。 /// public void UpdateTurretWorldPos() { if (tra_CurrTurret == null) { turretWorldPos = Vector3.zero; return; } RectTransform rect = tra_CurrTurret.GetComponent(); if (rect != null && UIManager.Instance != null && UIManager.Instance.uiRoot != null) { Canvas canvas = UIManager.Instance.uiRoot.GetComponent(); Camera cam = Camera.main; Vector3 screenPos = RectTransformUtility.WorldToScreenPoint( canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera, rect.position ); float distance = Mathf.Abs(cam.transform.position.z - 0f); Vector3 worldPos = cam.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, distance)); worldPos.z = 0f; turretWorldPos = worldPos; } else { // 非 UI(3D 物体)直接用 position turretWorldPos = tra_CurrTurret.position; } } /// 根据炮台编号字符串加载。 public void LoadTurret(string turretNumberStr) { if (int.TryParse(turretNumberStr, out int num)) LoadTurret(num); } // ==================== 翅膀加载(动态克隆) ==================== /// /// 根据炮台编号,从 Addressables 加载预制体并克隆到 turretParent。 /// 位置/旋转/缩放保持预制体原始值,不动。 /// public void LoadWing(int turretNumber) { // XJMH 模式没有翅膀概念,直接跳过 if (turretControlMode == TurretControlMode.XJMH) return; // turretNumber<=0 = 卸下(与战神同一约定):只清理不加载,不打警告 if (turretNumber <= 0) { // ⭐ 卸下翅膀:释放按需引用 if (_currentWingNumber > 0) { TurretResourcesData.ReleaseTurretPrefab(_currentWingNumber); _currentWingNumber = 0; } ClearWing(); return; } // ⭐ 换翅膀:释放上一个的按需引用(引用归零才真正卸包) if (_currentWingNumber > 0 && _currentWingNumber != turretNumber) TurretResourcesData.ReleaseTurretPrefab(_currentWingNumber); _currentWingNumber = turretNumber; // 先清理已有翅膀 ClearWing(); // ⭐ 散包按需加载(异步):缓存没有时按 PT{编号} 标签取,完成后实例化 StartCoroutine(LoadWingRoutine(turretNumber, ++_wingLoadSeq)); } /// 翅膀异步按需加载协程(模式同炮台:seq 防竞态) private IEnumerator LoadWingRoutine(int turretNumber, int seq) { // ⭐ 每次都走 Acquire(缓存命中立即返回并正确 +1 引用) GameObject prefab = null; { var task = TurretResourcesData.AcquireTurretPrefabAsync(turretNumber); while (!task.IsCompleted) yield return null; if (seq != _wingLoadSeq) { TurretResourcesData.ReleaseTurretPrefab(turretNumber); yield break; } prefab = task.Result; } if (prefab == null) { TurretResourcesData.ReleaseTurretPrefab(turretNumber); Debug.LogWarning($"[SitPosData] 翅膀预制体未加载: #{turretNumber}(检查 PT{turretNumber} 标签配置)"); yield break; } // 克隆到 wingParent,保持预制体原始位置/旋转/缩放 GameObject instance = Instantiate(prefab, wingParent, false); // ⭐ 特殊鱼炮台(钻头/激光)使用中:只切换不显示,等爆奖流程结束由 RestoreTurretVisual 恢复显示 instance.SetActive(!isDrillActive); if (isDrillActive) _pendingShowWing = instance; Debug.Log($"[SitPosData] 翅膀已加载: #{turretNumber}{(isDrillActive ? "(特殊炮台使用中,暂缓显示)" : "")}"); } /// 卸下翅膀:清空 wingParent 下的翅膀模型(保留预设的 Camera,它是用来渲染翅膀到 RawImage 的)。 public void ClearWing() { if (turretControlMode == TurretControlMode.XJMH) return; for (int i = wingParent.childCount - 1; i >= 0; i--) { var child = wingParent.GetChild(i); if (child.GetComponent() != null) continue; // 保留 Camera Destroy(child.gameObject); } } /// 根据炮台编号字符串加载。 public void LoadWing(string turretNumberStr) { if (int.TryParse(turretNumberStr, out int num)) LoadWing(num); } // ==================== 战神加载(动态克隆,同翅膀) ==================== /// /// 根据战神编号,加载游戏内使用的战神模型(ZSYX 标签的那个)。 /// 处理方式和翅膀一样:直接克隆到 zhanShenParent、保持预制体原始位置/旋转/缩放,不再管其他的 /// (渲染由座位预设的 Camera + RawImage 完成,显示节点挂在 turretRotate 下会跟随瞄准旋转)。 /// ⭐ 炮台/战神显示二选一:加载战神后隐藏 turretParent(仅游戏内,切换面板 UI 不受影响)。 /// /// 战神加载序号(防加载期间再次切换的竞态:旧协程完成后丢弃结果) private int _zsLoadSeq = 0; public void LoadZhanShen(int zhanShenNumber) { // XJMH 模式没有战神概念,直接跳过(和翅膀一致) if (turretControlMode == TurretControlMode.XJMH) return; // ⭐ 切换战神:释放上一个的按需资源引用(别的座位还在用同一个战神则只减计数不卸载) int prevNumber = currentZhanShenNumber; ClearZhanShenInstance(); // 先清理已有战神实例(保留预设的 Camera) if (prevNumber > 0 && prevNumber != zhanShenNumber) TurretResourcesData.ReleaseZhanShen(prevNumber, ZhanShenUsage.GamePlay); // ⭐ 异步按需加载(不再阻塞主线程):散包模式下战神不预载,这里现取 StartCoroutine(LoadZhanShenRoutine(zhanShenNumber, ++_zsLoadSeq)); } /// /// 战神异步加载协程:等 AcquireZhanShenAsync 完成后实例化。 /// 加载期间座位被销毁 / 又切换了别的战神 → 丢弃本次结果并归还引用。 /// private IEnumerator LoadZhanShenRoutine(int zhanShenNumber, int seq) { var task = TurretResourcesData.AcquireZhanShenAsync(zhanShenNumber, ZhanShenUsage.GamePlay); while (!task.IsCompleted) yield return null; // 竞态丢弃:期间又切换了战神(seq 变了)→ 归还本次引用 if (seq != _zsLoadSeq) { TurretResourcesData.ReleaseZhanShen(zhanShenNumber, ZhanShenUsage.GamePlay); yield break; } GameObject prefab = task.Result; if (prefab == null) { // 标签未配置(散包未拆):回退全量字典(启动 PT+ZSYX 加载的) prefab = TurretResourcesData.GetZhanShenPrefab(zhanShenNumber, ZhanShenUsage.GamePlay); } if (prefab == null) { TurretResourcesData.ReleaseZhanShen(zhanShenNumber, ZhanShenUsage.GamePlay); Debug.LogWarning($"[SitPosData] 战神使用预制体未加载: #{zhanShenNumber}(检查 ZS{zhanShenNumber}+ZSYX 标签配置)"); yield break; } // 克隆到 zhanShenParent,保持预制体原始位置/旋转/缩放 GameObject instance = Instantiate(prefab, zhanShenParent, false); // ⭐ 特殊鱼炮台(钻头/激光)使用中:只切换不显示,等爆奖流程结束由 RestoreTurretVisual 恢复显示 instance.SetActive(!isDrillActive); // 绑定状态机入口:战神接管开火/待机动画(PlayFire 约定,见 TurretData.PlayWarGodAnimator) // 同时缓存 TurretData:开火时优先用战神的子弹预制体(bulletPrefab/bulletPrefab_Two) var zsData = instance.GetComponent(); // ⭐ 战神初始旋转偏移:从 ZhanShenTurretData 读取(每个战神角度不一样,跟战神预制体配置)。 // 上/下座位视角不一样分开配置(IsFlipped=上方);直接设置欧拉角(覆盖预制体原始旋转);全0=保持默认朝向。 var zsDataEx = zsData as ZhanShenTurretData; if (zsDataEx != null) { Vector3 zsRotateInit = IsFlipped ? zsDataEx.topOffsetZSRotateInit : zsDataEx.buttomOffsetZSRotateInit; if (zsRotateInit != Vector3.zero) instance.transform.localEulerAngles = zsRotateInit; } currentZhanShen = instance; currentZhanShenNumber = zhanShenNumber; currentZhanShenData = zsData; if (zsData != null) { zsData.turretNumber = zhanShenNumber; zsData.setPosData = this; // 注入座位引用:FireIndex 动态切换(锁定=1激光/非锁定=0子弹)需要查锁定状态 OnStateChange = zsData.ChangeState; } // ⭐ 出场/换战神:清空左右起点父对象并重新克隆 JGQiuStartPrefab(当前战神配置的起点球) RefreshJGQiuStartFX(); // ⭐ 炮台/战神显示二选一:显示战神时隐藏普通炮台 if (turretParent != null) turretParent.gameObject.SetActive(false); Debug.Log($"[SitPosData] 战神已加载: #{zhanShenNumber}"); } /// 根据战神编号字符串加载。 public void LoadZhanShen(string zhanShenNumberStr) { if (int.TryParse(zhanShenNumberStr, out int num)) LoadZhanShen(num); } /// 清除战神并恢复普通炮台显示(炮台/战神显示二选一的另一半) public void ClearZhanShen() { // ⭐ 卸下战神:释放按需资源引用(归零才真正卸包) int prevNumber = currentZhanShenNumber; ClearZhanShenInstance(); if (prevNumber > 0) TurretResourcesData.ReleaseZhanShen(prevNumber, ZhanShenUsage.GamePlay); currentZhanShenNumber = 0; // 恢复普通炮台显示(特殊鱼炮台使用中不显示:等 RestoreTurretVisual 统一恢复) if (turretParent != null && !isDrillActive) turretParent.gameObject.SetActive(true); // 状态机入口还给普通炮台(若已加载) if (currentTurret != null) OnStateChange = currentTurret.ChangeState; } /// 只清 zhanShenParent 下的战神实例(保留 Camera),不动炮台显隐 private void ClearZhanShenInstance() { if (zhanShenParent != null) { for (int i = zhanShenParent.childCount - 1; i >= 0; i--) { var child = zhanShenParent.GetChild(i); if (child.GetComponent() != null) continue; // 保留 Camera Destroy(child.gameObject); } } currentZhanShen = null; currentZhanShenData = null; // 战神清了激光也没了 DestroyZhanShenLaser(); // 散射起点球也清掉(下次出场/换战神时重建) ClearJGFXParents(); } /// /// 特殊鱼炮台(钻头/激光)爆奖结束后的统一显示恢复入口(ZuanTouXie_DieEvent 调用)。 /// ⭐ 炮台/战神二选一按"当前装备"恢复,不依赖爆奖前的记录: /// 1. 爆奖期间切换的翅膀(_pendingShowWing) → 恢复显示; /// 2. 有战神(含爆奖期间新切的,currentZhanShen!=null) → 显示战神,普通炮台保持隐藏; /// 3. 无战神 → 显示普通炮台 turretParent。 /// 显示后强制 None→Idle 归位:炮台/战神被隐藏期间 Animator 状态会丢失, /// 若停在 Attack 状态,下次开火 ChangeState(Attack) 会被同状态短路 → 动画定格不播。 /// public void RestoreTurretVisual() { // 挂起的翅膀:恢复显示(引用可能已被 ClearWing/再次切换销毁,Unity 判空天然处理) if (_pendingShowWing != null) { _pendingShowWing.SetActive(true); _pendingShowWing = null; } if (currentZhanShen != null) { // 战神出战:普通炮台保持隐藏(炮台/战神二选一) if (turretParent != null) turretParent.gameObject.SetActive(false); currentZhanShen.SetActive(true); } else if (turretParent != null) { turretParent.gameObject.SetActive(true); } // 动画归位:先 None 再 Idle,强制重进 Idle(避免 Attack 定格) ChangeState(TurretState.None); ChangeState(TurretState.Idle); } // ------------------------- // 切换状态(GameMainPanel 调用) // ------------------------- public void ChangeState(TurretState state) { OnStateChange?.Invoke(state); } // ------------------------- // 炮台旋转(GameMainPanel 调用) // ------------------------- public void SetRotation(float angle) { // ⭐ 激光发射期间锁定方向:忽略旋转请求(激光沿原方向打到爆奖结束) if (isLaserLocked) return; // ⭐ 最大旋转角度约束:炮口只能偏离基准方向(下方座位朝上=0°,上方座位朝下=180°)±maxRotationAngle // - 本地玩家瞄准/远程同步/bot 旋转全部经过这里,统一钳制 // - 起飞炮台飞行期间豁免(炮口固定朝上,PointUp 传 0° 会被上方座位基准 180° 错误钳制) // - maxRotationAngle<=0 或 >=180 表示不限制 if (maxRotationAngle > 0f && maxRotationAngle < 180f && !(currentTurret is FlyTurretData flyTurret && flyTurret.IsFlying)) { float baseAngle = IsFlipped ? 180f : 0f; // DeltaAngle 取 [-180,180] 最短差,正确处理 350°/-10° 等角度回绕 float delta = Mathf.DeltaAngle(baseAngle, angle); delta = Mathf.Clamp(delta, -maxRotationAngle, maxRotationAngle); angle = baseAngle + delta; } currentAngle = angle; // ⭐ 两个分支(Normal / XJMH)分开处理: // - Normal(turretRotate 已配置):旋转只作用于 turretRotate,用【世界旋转】——瞄准参考系是 beij // (tra_CurrTurret 的父节点),currentAngle 已是和 beij 翻转抵消后的统一角度(aim-90), // 直接写世界旋转炮口即对准目标;若用本地旋转,上方座位 beij 的 180° 翻转会叠加进来导致反向。 // - XJMH(星际迷航,turretRotate 未配置):保持旧版行为,直接转 tra_CurrTurret(世界旋转), // 该分支不动。 if (turretRotate != null) turretRotate.rotation = Quaternion.Euler(0, 0, angle); else if (tra_CurrTurret != null) tra_CurrTurret.rotation = Quaternion.Euler(0, 0, angle); } // ------------------------- // 获取发射点(给 GameMainPanel) // ------------------------- public Vector2 GetBulletStartPos() { return currentTurret != null ? currentTurret.GetBulletStartWorldPos() : Vector2.zero; } // ==================== 金币/钻石/炮倍显示(TMP 与旧版 UGUI Text 双支持) ==================== /// 刷新金币显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖) public void SetGoldNumText(long gold) { string text = gold.ToString(); if (tMP_GoldNum != null) tMP_GoldNum.text = text; else if (goldNum_UGUI != null) goldNum_UGUI.text = text; } /// 刷新钻石显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖) public void SetDiamondNumText(long diamond) { string text = diamond.ToString(); if (tMP_DiamondNum != null) tMP_DiamondNum.text = text; else if (diamondNum_UGUI != null) diamondNum_UGUI.text = text; } /// 刷新炮倍消耗显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖) public void SetTurretConsumeNumText(long consume) { string text = consume.ToString(); if (tMP_TurretConsumeNum != null) tMP_TurretConsumeNum.text = text; else if (turretConsumeNum_UGUI != null) turretConsumeNum_UGUI.text = text; } // ==================== 金币消耗 ==================== public bool ConsumeGold(int mul = 1, long fireConsumeGold = 0) { // fireConsumeGold>0: 用服务端返回的该发子弹权威扣费金额 // fireConsumeGold=0: 回退到本地 consumeGoldNum * mul long useConsume = fireConsumeGold > 0 ? fireConsumeGold : consumeGoldNum * mul; long temp = goldNum - useConsume; if (temp < 0) return false; long oldGold = goldNum; goldNum = temp; SetGoldNumText(goldNum); // ⭐ 记录金币流水:已扣钱(开火消耗,本地已执行) GameManager.LogGoldFlowAppliedCost("Fire", $"开火消耗 原有金币={oldGold} 消耗金币=-{useConsume} 剩余金币={goldNum} 座位={posID} 房间={GameManager.Instance.gameData?.currRoomData?.roomId}"); return true; } int indexConsume = 0; /// /// 根据后端下发的炮倍值同步 indexConsume 到 paoBeiList 对应档位, /// 避免加减炮倍时从 0 档开始导致跳变。 /// public void SyncIndexByConsume(long consumeGold) { var list = CreateFishManager.Instance?.currGameType?.paoBeiList; if (list == null || list.Count == 0) return; int idx = list.IndexOf((int)consumeGold); // ⭐ paoBeiList 为从大到小排列:查不到值时兜底到最小档(列表末尾),避免默认落到最大炮倍 indexConsume = idx >= 0 ? idx : list.Count - 1; } public void ChangeTurretAddConsumeNum() { // ⭐ paoBeiList 为从大到小排列:加号=更大的炮倍=更小的索引,到头(最大档)回绕到末尾(最小档) if (indexConsume > 0) indexConsume--; else indexConsume = CreateFishManager.Instance.currGameType.paoBeiList.Count - 1; AudioManager.Instance.PlayerFX(62011); // ⭐ 不立即修改 consumeGoldNum 和 UI,等服务端 ChangeGunMultipleNotice 确认后再应用 // 避免:客户端先切换炮倍 → 开火按新炮倍扣费 → 服务端还没确认按旧炮倍扣费 → 差值 int newConsume = CreateFishManager.Instance.currGameType.paoBeiList[indexConsume]; string roomId = GameManager.Instance.gameData?.currRoomData?.roomId; if (!string.IsNullOrEmpty(roomId)) WebSocketSendList.ChangeGunMultiple(roomId, user, newConsume); } /// /// 非本地座位隐藏加减炮倍 UI(GameMainPanel 进房时对自己以外的座位调用): /// - Normal:隐藏座位上的 btn_TurretAdd / btn_TurretMinus(未拖引用为 null 时跳过) /// - XJMH:加减倍按钮适配在炮台上(TurretData.obj_Add / obj_Down),座位上没有按钮引用, /// 把 XJMH 炮台列表里每把炮台的加减倍都隐藏(全部藏,之后切换炮台新炮台的也不会再显示) /// public void HideTurretAddMinusUI() { if (btn_TurretAdd != null) btn_TurretAdd.gameObject.SetActive(false); if (btn_TurretMinus != null) btn_TurretMinus.gameObject.SetActive(false); if (turretControlMode != TurretControlMode.XJMH) return; if (xjmhTurretList != null && xjmhTurretList.Count > 0) { foreach (var td in xjmhTurretList) HideTurretAddDown(td); } else { // 兜底:列表未配置时扫 turretParent 子对象 Transform parent = turretParent != null ? turretParent : transform; for (int i = 0; i < parent.childCount; i++) HideTurretAddDown(parent.GetChild(i).GetComponent()); } } /// 隐藏一把 XJMH 炮台上的加减倍对象(obj_Add / obj_Down,未拖引用跳过) private static void HideTurretAddDown(TurretData td) { if (td == null) return; if (td.obj_Add != null) td.obj_Add.SetActive(false); if (td.obj_Down != null) td.obj_Down.SetActive(false); } public void ChangeTurretMinusConsumeNum() { // ⭐ paoBeiList 为从大到小排列:减号=更小的炮倍=更大的索引,到尾(最小档)回绕到头部(最大档) if (CreateFishManager.Instance.currGameType.paoBeiList.Count - 1 > indexConsume) indexConsume++; else indexConsume = 0; AudioManager.Instance.PlayerFX(62010); // ⭐ 不立即修改 consumeGoldNum 和 UI,等服务端 ChangeGunMultipleNotice 确认后再应用 // 避免:客户端先切换炮倍 → 开火按新炮倍扣费 → 服务端还没确认按旧炮倍扣费 → 差值 int newConsume = CreateFishManager.Instance.currGameType.paoBeiList[indexConsume]; string roomId = GameManager.Instance.gameData?.currRoomData?.roomId; if (!string.IsNullOrEmpty(roomId)) WebSocketSendList.ChangeGunMultiple(roomId, user, newConsume); } // ==================== 炮倍选择面板 ==================== private Coroutine _paoBeiPanelAnim; /// 炮倍面板展开时的原始高度(Awake 记录,动画在 0 ↔ 该值之间滑动) private float _paoBeiPanelOpenHeight = -1f; /// 记录炮倍面板的原始高度(Awake 时面板还没被动画改过,rect.height 即还原态高度) private void EnsurePaoBeiPanelHeight() { if (_paoBeiPanelOpenHeight >= 0f || selectPaoBeiPanel == null) return; var rt = selectPaoBeiPanel.GetComponent(); if (rt == null) return; _paoBeiPanelOpenHeight = rt.rect.height; if (_paoBeiPanelOpenHeight <= 0f) Debug.LogWarning("[SitPosData] 炮倍面板初始高度为 0,无法记录展开高度,检查 selectPaoBeiPanel 的 RectTransform 配置"); } /// /// 点击炮倍按钮:开关选择面板。 /// ⭐ 只有本地玩家座位可以打开(远程/bot 座位点了没反应,不能替别人换炮倍)。 /// private void OnClickSelectPaoBei() { if (this != localPlayerSit) return; if (selectPaoBeiPanel == null) return; bool opening = !selectPaoBeiPanel.activeSelf; if (opening) RefreshPaoBeiItems(); SetPaoBeiPanelOpen(opening); } /// /// 开/关炮倍面板:height 在 0 ↔ 原始高度之间动画 0.5s。 /// ⭐ 不改面板 pivot/anchoredPosition,滑动方向完全由预制体里配好的 RectTransform 决定。 /// private void SetPaoBeiPanelOpen(bool open) { var rt = selectPaoBeiPanel.GetComponent(); if (rt == null) { selectPaoBeiPanel.SetActive(open); return; } if (_paoBeiPanelAnim != null) { StopCoroutine(_paoBeiPanelAnim); _paoBeiPanelAnim = null; } if (open) { selectPaoBeiPanel.SetActive(true); _paoBeiPanelAnim = StartCoroutine(AnimPaoBeiHeight(rt, rt.rect.height, _paoBeiPanelOpenHeight)); } else { _paoBeiPanelAnim = StartCoroutine(AnimPaoBeiHeight(rt, rt.rect.height, 0f, () => selectPaoBeiPanel.SetActive(false))); } } /// height 动画(0.5s);动画结束回调在收起时用于隐藏面板 private IEnumerator AnimPaoBeiHeight(RectTransform rt, float from, float to, Action onComplete = null) { const float duration = 0.1f; float t = 0f; while (t < duration) { t += Time.deltaTime; rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, Mathf.Lerp(from, to, Mathf.Clamp01(t / duration))); yield return null; } rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, to); onComplete?.Invoke(); _paoBeiPanelAnim = null; } /// /// 按配置 paoBeiList 生成炮倍档位 item: /// child0 的 TextMeshProUGUI 显示炮倍值;背景按值上色(<5000 蓝 / 5000~10000 紫 / >=10000 红),当前档位黄色。 /// 点击 item:换到该档位(走 ChangeGunMultiple 服务端确认协议)并直接关闭面板。 /// private void RefreshPaoBeiItems() { if (paobeiItemPrefab == null || paobeiItemParent == null) return; var list = CreateFishManager.Instance?.currGameType?.paoBeiList; if (list == null || list.Count == 0) { Debug.LogWarning("[SitPosData] paoBeiList 为空,炮倍面板没有可选项"); return; } for (int i = paobeiItemParent.childCount - 1; i >= 0; i--) Destroy(paobeiItemParent.GetChild(i).gameObject); for (int i = 0; i < list.Count; i++) { int index = i; GameObject item = Instantiate(paobeiItemPrefab, paobeiItemParent, false); item.SetActive(true); var tmp = item.transform.childCount > 0 ? item.transform.GetChild(0).GetComponent() : null; if (tmp != null) tmp.text = list[i].ToString(); var img = item.GetComponent(); if (img != null) img.sprite = index == indexConsume ? yellowBG : GetPaoBeiSprite(list[i]); var btn = item.GetComponent