Files
5.24BuYu/Assets/MyGame/Scripts/Turret/SitPosData.cs
T
2026-10-03 17:50:59 +08:00

2378 lines
106 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>炮台控制模式</summary>
public enum TurretControlMode
{
/// <summary>普通模式:动态克隆炮台,显示金币+钻石,按钮打开面板切换</summary>
Normal = 0,
/// <summary>星际迷航模式:座位子对象激活/停用,只显示金币,长按炮台弹出扇形切换</summary>
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;
/// <summary>自动攻击从开→关时触发一次(GameMainPanel 订阅后广播 Idle 状态给其他客户端,仅本地玩家座位会触发)</summary>
public event Action OnAutoAttackOff;
/// <summary>自动攻击是否开启(toggle 没拖引用时恒为 false,等同未开自动攻击)</summary>
public bool IsAutoAttackOn => toggle_AutoAttack != null && toggle_AutoAttack.isOn;
[Tooltip("长按判定时间(秒):按住 img_OpenPT 超过此时间触发 OnOpenPTLongPress")]
public float openPTLongPressTime = 0.5f;
public Image img_OpenPT;
/// <summary>长按 img_OpenPT 触发一次(GameMainPanel 订阅后用它开关炮台面板,仅本地玩家座位有意义)</summary>
public event Action OnOpenPTLongPress;
private bool _openPTPressing;
private float _openPTPressTimer;
public bool IsBaoJiangingActive;
public Transform obj_BaoJiangZhongPanelPosition;
/// <summary>本地玩家的座位引用(由游戏面板初始化时设置)</summary>
public static SitPosData localPlayerSit;
/// <summary>当前房间房主座位(人机请求由房主发送,房主退出后自动继承)</summary>
public static SitPosData roomOwnerSit;
[Tooltip("座位编号 0~3。0,1=下方 2,3=上方")]
public int posID;
[Tooltip("是否为人机")]
public bool isBot = false;
/// <summary>posID>=2 = 上方,需要翻转(0,1=下方;2,3=上方)</summary>
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<GameObject> sprite_VipList;//这里一共有11个正好对应0-10级vip
public List<GameObject> 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;
/// <summary>
/// 散射激光模式中(GameMainPanel 每帧刷新:三发+锁定+已锁目标)。
/// ⭐ 激活左右起点球父对象用这个标记,不依赖实际锁定点数量(目标不足2个时散射模式开着也要显示起点球)
/// </summary>
[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;
/// <summary>
/// 当前 XJMH 炮台在 <see cref="xjmhTurretList"/> 中的索引(-1=未初始化)。
/// ⭐ XJMH 四把炮台的 turretNumber 全是 0(不对接后端、没有编号),
/// 炮台身份只能用列表索引表示:切换按钮、帧同步广播/接收统一都用索引。
/// </summary>
[System.NonSerialized]
public int currentTurretIndex = -1;
/// <summary>当前运行中的炮台实例</summary>
public TurretData currentTurret;
/// <summary>当前运行中的战神实例(null=未显示)。炮台/战神显示二选一:有战神时 turretParent 隐藏</summary>
[System.NonSerialized]
public GameObject currentZhanShen;
/// <summary>特殊炮台(钻头/激光)期间切换翅膀的暂存引用:只切换不显示,等 RestoreTurretVisual 恢复显示</summary>
private GameObject _pendingShowWing;
/// <summary>战神实例上的 TurretData/ZhanShenTurretData(开火时优先取战神的子弹预制体,null=无战神或未挂组件)</summary>
[System.NonSerialized]
public TurretData currentZhanShenData;
// -------------------------
// 战神激光(锁定时显示):两种形态
// - laserPrefab 非空 → 预制体形态:JiGuangControl 工具类驱动(克隆激光预制体,方向/长度每帧刷新)
// - laserPrefab 为空 → LineRenderer 兜底形态:直接新建 GameObject + LineRenderer
// -------------------------
/// <summary>LineRenderer 形态激光列表。三发锁定=3条。起点:0=主激光(座位点+selfOffsetJGStart),1/2=散射左右(JGFX父对象位置)</summary>
private readonly List<LineRenderer> _zhanShenLasers = new List<LineRenderer>();
/// <summary>预制体形态激光实例列表(世界空间,由 JiGuangControl.JiGuangCreate 按目标数量重建)</summary>
private readonly List<Transform> _laserFx = new List<Transform>();
/// <summary>激光起点特效实例(UI 层:0=挂 turretRotate 下 selfOffsetJGStart 处,1/2=挂 JGFX 左右父对象下,跟随父节点)</summary>
private readonly List<GameObject> _laserStartFx = new List<GameObject>();
/// <summary>激光命中特效实例(与 _laserFx 一一对应,世界空间 Z=-50 层,跟随锁定目标)</summary>
private readonly List<GameObject> _laserHitFx = new List<GameObject>();
/// <summary>激光准备等待剩余秒数:锁定刚建立时读 ZhanShenTurretData.laserFXDelay,>0 期间激光及特效隐藏(等准备动画播完)</summary>
private float _laserPrepWait;
/// <summary>激光等待默认兜底秒数:prefab 未配 laserFXDelay(=0) 时使用——门不再默认敞开,特效显示必须等 SMB 动画事件放行(事件来提前开门,超时强制显示)</summary>
private const float LaserFXWaitFallback = 2f;
/// <summary>上一帧是否有锁定点(检测锁定建立沿,触发准备等待)</summary>
private bool _laserLockActive;
/// <summary>激光循环音效是否正在播放(跟随激光实际显示状态起停,避免每帧重复触发)</summary>
private bool _laserSoundOn;
/// <summary>激光循环音效当前播放速度(1=正常,2=狂暴2倍速),用于边沿比较避免每帧重复调用</summary>
private float _laserSoundPitch = 1f;
/// <summary>预制体形态激光的基准缩放(重建时取自 laserPrefab.transform.localScale)</summary>
private Vector3 _laserFxBaseScale = Vector3.one;
/// <summary>预制体形态激光的角度偏移:激光预制体自带的 Z 轴旋转(重建时缓存,绕世界 Z 叠加在计算朝向上)</summary>
private float _laserFxRotZOffset;
/// <summary>每条激光的起点缓存(索引与 _laserTargets 对齐,每帧复用避免 GC)</summary>
private readonly List<Vector3> _laserStarts = new List<Vector3>();
// -------------------------
// XJMH 炮台列表(星际迷航专用)
// -------------------------
[Header("XJMH 炮台列表")]
[Tooltip("XJMH 模式专用:在 Inspector 中拖入座位下的所有炮台子对象(带 TurretData 组件)。" +
"数量不多,建议按炮台编号升序排列,便于扇形切换按索引选择。")]
public List<TurretData> xjmhTurretList = new List<TurretData>();
// 状态机入口(GameMainPanel 调用)
public Action<TurretState> OnStateChange;
// -------------------------
// 炮台旋转
// -------------------------
[Header("炮台旋转")]
public Transform tra_CurrTurret;
public float currentAngle;
[Tooltip("炮口最大偏转角度(度),相对基准方向:下方座位(0,1)基准=朝上0°,上方座位(2,3)基准=朝下180°。" +
"超出范围的角度会被钳制回边界(本地玩家/远程同步/bot 全部生效)。<=0 或 >=180 表示不限制")]
public float maxRotationAngle = 90f;
/// <summary>
/// 炮台在世界坐标(Z=0平面)的位置缓存。
/// 因为炮台是 UGUI 元素,tra_CurrTurret.position 是 Canvas 坐标,不能直接和鱼的世界坐标比较距离。
/// 这里用和子弹起点(GetBulletStartWorldPos)相同的转换方式,把 UI 坐标转成世界坐标。
/// 由 LoadTurret 加载完炮台后计算并缓存,bot AI/锁定检测距离时用此字段。
/// </summary>
[System.NonSerialized]
public Vector3 turretWorldPos;
// -------------------------
// 锁定系统
// -------------------------
[Header("锁定系统")]
public MonsterContral monCotl_Lock;
public Transform monCotl_Lock_Position;
public bool isLock = false;
/// <summary>
/// ⭐ 当前锁定是否为玩家手动锁定。
/// - true:玩家点击锁定的鱼 → 不受路径进度(0.1~0.9)限制,鱼离场前一直锁定
/// - false:自动锁定/三发查找/bot → 受路径进度限制
/// 由 GameMainPanel_FishBase 在 SetLockTarget 前设置
/// </summary>
[System.NonSerialized]
public bool isManualLock = false;
public GameObject lockFX; // 主锁定图标(单发/双倍用)
public List<GameObject> lockFX_Triple = new List<GameObject>(); // 三发锁定额外2个图标
public List<MonsterContral> tripleLockTargets = new List<MonsterContral>();
public List<Transform> tripleLockPositions = new List<Transform>();
/// <summary>
/// ⭐ 三发锁位是否处于"空置等手动点击"状态(索引与 tripleLockTargets 对齐)。
/// true = 该锁位打死后自动检测没找到鱼 → 保持空着,不再自动补位,必须玩家手动点击才锁。
/// </summary>
private readonly List<bool> tripleSlotIdle = new List<bool>();
/// <summary>
/// ⭐ 该锁位是否由玩家手动点选(索引与 tripleLockTargets 对齐)。
/// true = 玩家自己点的鱼 → 离场前一直跟,死了才换;手动身份会跟着鱼一起移入子锁位。
/// </summary>
private readonly List<bool> tripleSlotManual = new List<bool>();
// -------------------------
// 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());
// ⭐ 自动攻击开关:关闭时本地座位炮台切回 Idle(原 GameMainPanel 的 toggle_SkillAuto 监听逻辑)
if (toggle_AutoAttack != null)
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();
}
/// <summary>
/// 自动攻击开关变化:关闭时本地座位炮台立即切回 Idle(自动开火停止)。
/// ⭐ 只对本地玩家座位生效:同一个 toggle 被拖到多个座位时,非本地座位不响应,
/// 避免远程座位的状态机被本地点击误切。
/// </summary>
private void OnAutoAttackToggleChanged(bool on)
{
if (on) return;
if (this != localPlayerSit) return;
ChangeState(TurretState.Idle);
OnAutoAttackOff?.Invoke();
}
/// <summary>
/// 散射起点球父对象兜底:Inspector 没拖引用时自动创建两个 RectTransform
/// (挂在 turretRotate 的父节点 beij 下,炮台左右两侧 ±50),并写回字段。
/// 拖了引用(预制体配置的座位)则什么都不做。
/// </summary>
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);
}
/// <summary>创建一个散射起点球父节点(beij 下,x 偏移 ±50、y 100,默认隐藏)</summary>
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;
}
/// <summary>
/// XJMH 模式:炮台是座位自带子对象,预制体里已摆好,不需要 LoadTurret 加载。
/// ⭐ 默认炮台 = 排序后索引 0 的那把(对应座位 GetChild(0) 的绿炮台)。
/// </summary>
private void InitXJMHTurret()
{
if (xjmhTurretList == null || xjmhTurretList.Count == 0)
{
Debug.LogWarning($"[SitPosData] XJMH 炮台列表未配置,座位 {posID} 无炮台可用");
return;
}
NormalizeXJMHTurretList();
ApplyXJMHTurret(0);
}
/// <summary>
/// 整理 XJMH 炮台列表:剔除空引用,并按座位下的兄弟顺序排序。
/// ⭐ 排序原因:切换面板的 4 个按钮固定是 绿/蓝/金/VIP(索引 0~3),
/// 而座位里这四把炮台的兄弟顺序正好就是 绿/蓝/金/VIP。
/// 不排序的话,Inspector 里拖拽的先后顺序会让按钮和炮台对不上。
/// </summary>
private void NormalizeXJMHTurretList()
{
xjmhTurretList.RemoveAll(t => t == null);
xjmhTurretList.Sort((a, b) =>
a.transform.GetSiblingIndex().CompareTo(b.transform.GetSiblingIndex()));
}
/// <summary>
/// XJMH:按列表索引激活炮台(只激活目标那把,其余全部停用)。
/// ⭐ 不能再用 turretNumber 匹配:XJMH 四把炮台的 turretNumber 全是 0,
/// 按编号匹配会把四把一次全激活,且切换到"同编号"的炮台会被判成无需切换(点按钮没反应)。
/// </summary>
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})");
}
/// <summary>
/// XJMH:修正子弹发射点。
/// ⭐ 预制体里绿/蓝/金三把炮台的 tra_BulletStartPosition 指到了"炮体美术节点"
/// (它的 pivot 在炮体盒子底部,相对旋转中心是 -29.8,等于旋转中心后方),子弹会从炮台后面出来。
/// 真正的炮口是旋转节点下那个空节点 Bullit(相对旋转中心 +50,正前方)——VIP 就是这么配的。
/// ⭐ 只在当前指向的是「带 Graphic 的美术节点」时才修正:自己拖进来的炮口标记节点(无 Graphic)不动。
/// </summary>
private static void EnsureXJMHTurretMuzzle(TurretData turret, Transform pivot)
{
if (pivot == null) return;
// 当前指向的节点没有 Graphic(是炮口标记节点)→ 说明是手动配好的,不动
if (turret.tra_BulletStartPosition != null
&& turret.tra_BulletStartPosition.GetComponent<Graphic>() == null) return;
Transform muzzle = pivot.Find("Bullit");
if (muzzle == null) return;
Debug.LogWarning($"[SitPosData] XJMH 炮台 {turret.name} 的 tra_BulletStartPosition 指向了美术节点,已自动改用炮口节点 Bullit");
turret.tra_BulletStartPosition = muzzle;
}
/// <summary>
/// XJMH:补上炮台动画组件。
/// ⭐ 预制体里绿/蓝/金三把炮台的 TurretData.skeletonAnimation 都是空的(只有 VIP 拖了),
/// 而 TurretData.PlaySkeleton 遇到 null 会直接 return → 这三把炮台动画完全不播。
/// 这里按 VIP 的同一套结构自动补:旋转节点(炮台本体组)下面那个 SkeletonGraphic 就是炮体动画,
/// VIP 的 skeletonAnimation 指的正是它。Inspector 里已经拖了的不会被覆盖。
/// </summary>
private static void EnsureXJMHTurretAnim(TurretData turret, Transform pivot)
{
if (turret.skeletonAnimation != null || pivot == null) return;
turret.skeletonAnimation = pivot.GetComponentInChildren<SkeletonGraphic>(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()
{
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()
{
UpdateZhanShenLaser();
// ⭐ 长按 img_OpenPT 计时:到达阈值触发一次(提前松手不触发)
if (_openPTPressing)
{
_openPTPressTimer += Time.deltaTime;
if (_openPTPressTimer >= openPTLongPressTime)
{
_openPTPressing = false;
_openPTPressTimer = 0f;
OnOpenPTLongPress?.Invoke();
}
}
}
// ==================== 长按 img_OpenPT(打开炮台面板) ====================
/// <summary>按下:按点落在 img_OpenPT 的屏幕矩形内才开始长按计时(其他区域不响应)</summary>
public void OnPointerDown(PointerEventData eventData)
{
if (!IsPointerOnOpenPT(eventData.position)) return;
_openPTPressing = true;
_openPTPressTimer = 0f;
}
/// <summary>
/// 按点是否落在 img_OpenPT 的屏幕矩形内。
/// ⭐ 用坐标矩形判定,而不是比较射线命中的对象:座位下还挂着昵称/表情/炮台等一堆兄弟 UI,
/// 实际被射线命中的往往是它们而不是 img_OpenPT,但它们同属座位层级,
/// 按下事件照样会冒泡到本组件,所以只有按坐标判定才可靠。
/// </summary>
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<Canvas>();
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay
? canvas.worldCamera
: null;
return RectTransformUtility.RectangleContainsScreenPoint(rect, screenPos, cam);
}
/// <summary>松开:取消长按计时(未达阈值不触发)</summary>
public void OnPointerUp(PointerEventData eventData)
{
_openPTPressing = false;
_openPTPressTimer = 0f;
}
// ==================== 战神激光(锁定时显示) ====================
/// <summary>
/// 战神锁定时显示激光,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 时不显示)。
/// </summary>
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();
}
/// <summary>
/// 战神 loop 动画开始通知(TurretData 转发:ZhanShenLaserLoopSMB 状态机行为 / 剪辑动画事件):
/// 立即放行激光及全部特效显示(提前结束 laserFXDelay 等待;未在等待中则无副作用)。
/// </summary>
public void NotifyZhanShenLaserLoopAnim()
{
_laserPrepWait = 0f;
}
/// <summary>
/// 激光循环音效:跟随"激光实际显示"状态起停(仅本地座位,且战神配置了 laserAudio 才播)。
/// ⭐ 用 AudioManager.PlayFXLoop 走专用循环 AudioSource——激光音效很长,逐发 PlayOneShot 会重叠;
/// 起停走状态边沿,锁定期间不重复触发;激光消失/清战神/座位禁用时由 DestroyZhanShenLaser 停止。
/// ⭐ 狂暴(暴走道具,开火速度翻倍)时同步把激光音效提到 2 倍速。
/// </summary>
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); // 已在播同一条时只变速,不重头播
}
/// <summary>本地座位当前是否狂暴(开火速度翻倍):狂暴标志由面板 SetFrenzy 维护在炮台/战神数据上</summary>
private bool IsFrenzyNow()
{
if (currentZhanShenData != null && currentZhanShenData.isFrenzy) return true;
return currentTurret != null && currentTurret.isFrenzy;
}
/// <summary>隐藏全部战神激光(两种形态都处理)</summary>
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);
}
}
/// <summary>
/// 预制体形态(laserPrefab 非空):JiGuangControl 工具类驱动。
/// 目标数量变化时全量重建(JiGuangCreate),每帧刷新位置/朝向/长度(JiGuangDirection/JiGuangLength,Z 强制 -50)。
/// ⭐ 每条激光独立起点(多起点重载):0=主锁定(座位点+偏移),1/2=散射左右(JGFX父对象位置)。
/// </summary>
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;
}
}
}
/// <summary>LineRenderer 兜底形态(laserPrefab 为空):按需创建、多余的隐藏。每条激光独立起点</summary>
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);
}
}
/// <summary>激光终点缓存列表(每帧复用,避免 GC)</summary>
private readonly List<Transform> _laserTargets = new List<Transform>();
/// <summary>
/// 创建一条 LineRenderer 形态的战神激光(第 index 条)。
/// 仅 laserPrefab 为空时使用:直接创建一个新 GameObject 并加上 LineRenderer(用 TurretData 上的宽度/颜色)。
/// </summary>
private LineRenderer CreateZhanShenLaser(int index)
{
var zsData = currentZhanShenData;
GameObject laserGo = new GameObject($"ZhanShenLaser_{index}");
LineRenderer line = laserGo.AddComponent<LineRenderer>();
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;
}
/// <summary>销毁所有战神激光对象(两种形态都清,清战神/座位禁用时调用)</summary>
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();
}
}
// ==================== 激光起点/命中特效(三发+锁定) ====================
/// <summary>
/// 激光全量重建时同步重建起点/命中特效(数量与激光一一对应)。
/// 起点=UI 层挂起点节点下(0=turretRotate 的 selfOffsetJGStart 处,1/2=JGFX 左右父对象);命中=世界层 Z=-50 跟随锁定目标。
/// ZhanShenTurretData 上对应预制体没拖(为空)时不生成。
/// </summary>
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));
}
}
}
/// <summary>销毁全部起点/命中特效实例(激光重建/销毁时调用)</summary>
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();
}
// ==================== 战神激光起点(每条激光独立起点) ====================
/// <summary>
/// 战神激光第 index 条的起点(鱼群世界坐标,Z=-50 显示层):
/// - 第 0 条(主锁定):turretRotate 子对象偏移(selfOffsetJGStart 是 turretRotate 的本地偏移,
/// ⭐ 跟随炮台旋转:炮口转向时起点绕炮台中心跟着转)
/// - 第 1 条(散射左):JGFXLeftParent 的位置
/// - 第 2 条(散射右):JGFXRightParent 的位置
/// - 越界/未配置:回退子弹炮口位置(旧版行为)
/// </summary>
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;
}
/// <summary>
/// 座位 UI 上的世界坐标(座位根节点/座位子节点位置)→ 鱼群世界坐标(子弹/激光所在空间)。
/// 与 TurretData.GetBulletStartWorldPos 同一套换算(UI→屏幕→世界),Z 压到指定显示层。
/// </summary>
private Vector3 SeatUIToFishWorld(Vector3 uiWorldPos, float z)
{
Canvas canvas = UIManager.Instance != null && UIManager.Instance.uiRoot != null
? UIManager.Instance.uiRoot.GetComponent<Canvas>()
: 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;
}
/// <summary>
/// 散射激光激活/隐藏左右起点父对象(JGFXLeftParent/JGFXRightParent):
/// 使用散射激光(三发+锁定开着,或锁定点>1)时激活显示起点球,关闭/单发时隐藏。
/// </summary>
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
/// <summary>散射起点诊断计时器(编辑器排查用,3秒一条,只在战神+锁定期间打印)</summary>
private float _jgfxLogTimer = -99f;
/// <summary>
/// 编辑器诊断:锁定期间每3秒打印散射起点球的判定条件。
/// 用来定位「三发+锁定开了但左右父对象不激活」断在哪个环节:
/// 三发目标数 / 激光条数 / 散射标记 / 左右父对象引用是否拖了 / 当前激活状态。
/// </summary>
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
/// <summary>
/// 出场/换战神时刷新散射激光起点球:
/// 先清空 JGFXLeftParent/JGFXRightParent 的旧子对象,再各自克隆一份
/// ZhanShenTurretData.JGQiuStartPrefab(保持预制体原始位置/旋转/缩放)。
/// 没配置 JGQiuStartPrefab/组件就只清空并打警告;父对象默认隐藏,散射激光激活时由 UpdateZhanShenLaser 打开。
/// </summary>
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);
}
/// <summary>清空左右起点父对象的子对象并隐藏(清战神/重装起点球前调用)</summary>
private void ClearJGFXParents()
{
ClearChildren(JGFXLeftParent);
ClearChildren(JGFXRightParent);
if (JGFXLeftParent != null)
JGFXLeftParent.gameObject.SetActive(false);
if (JGFXRightParent != null)
JGFXRightParent.gameObject.SetActive(false);
}
/// <summary>清空一个父节点下的所有子对象</summary>
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
/// <summary>
/// 编辑器可视化:战神主激光起点(turretRotate 子对象偏移 selfOffsetJGStart,⭐ 跟随炮台旋转)。
/// 运行中加载战神后在 Scene 视图可见(旋转炮台时球会绕炮台中心跟着转):
/// 黄色小球=turretRotate 位置(偏移基准),洋红线框球=主激光起点,连线=偏移方向。
/// </summary>
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
/// <summary>
/// 玩家加入 → 显示玩家 UI(child0),隐藏等待界面(child1)。
/// ⭐ XJMH 座位没有"玩家界面/等待界面"这一对子节点(child0~3 是四把炮台),
/// 不能在这里动子对象显隐,否则会把炮台当等待界面切来切去,和切换炮台的激活状态互相打架。
/// </summary>
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);
}
// 如果是本地玩家的座位,自动注册并加载装备
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);
}
}
// 非人机玩家加入时,自动分配为首个房主
if (!isBot)
AssignRoomOwner(this);
}
/// <summary>
/// 玩家退出/等待 → 显示等待界面(child1),隐藏玩家 UI(child0)。
/// ⭐ XJMH 座位没有这一对子节点(child0~3 是四把炮台),跳过(原因见 ShowPlayer)。
/// </summary>
public void ShowWaiting()
{
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);
}
/// <summary>
/// 设置座位上玩家的显示信息(进房时由 GameMainPanel.JoinGame 调用,
/// 数据来自服务端下发的 RoomInfo.players / RoomJoinNotice):
/// 玩家ID、昵称、头像、VIP 图标(0~10 级一一对应)、VIP 等级背景(每两级一个)。
/// </summary>
/// <param name="userId">玩家唯一 ID(用于判断是否本人)</param>
/// <param name="userName">玩家昵称(显示在 tmp_UserName)</param>
/// <param name="avatar">头像名/URL(走 GameManager 头像缓存,找不到回退默认头像)</param>
/// <param name="vipLevel">VIP 等级 0~10</param>
/// <param name="userNumber">玩家编号(短ID,显示在 tmp_UserNumber;空则回退显示 userId)</param>
/// <param name="level">玩家等级(显示在 tmp_UserLevel)</param>
/// <param name="exp">玩家当前经验(原始值)</param>
/// <param name="maxExp">升级所需总经验(经验条进度 = exp / maxExp)</param>
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);
}
}
}
/// <summary>
/// 非本人昵称打码:保留前 2 位和后 2 位,中间用 *** 代替。
/// 例:123456789 → 12***89;12345 → 12***45。不足 5 位原样显示。
/// </summary>
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)
/// <summary>表情显示时长(秒),超时自动隐藏</summary>
public float biaoQingDuration = 3f;
/// <summary>正在运行的表情隐藏协程(新表情顶掉旧表情)</summary>
private Coroutine _biaoQingCoroutine;
/// <summary>
/// 显示 Spine 动画表情:spineObj 的 child0(SkeletonGraphic)换骨骼资源并播放动画,
/// biaoQingDuration 秒后自动隐藏。与文字表情互斥(同时只显示一种)。
/// </summary>
/// <param name="asset">spine 骨骼资源</param>
/// <param name="animName">动画名</param>
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<SkeletonGraphic>()
: 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));
}
/// <summary>
/// 显示文字表情:先设置文字再激活 ziObj,激活后立即强制刷新两级 ContentSizeFitter
/// (child0 的 TMP 自身 + 父物体),避免宽度延迟一帧才更新。
/// biaoQingDuration 秒后自动隐藏。与 spine 表情互斥(同时只显示一种)。
/// </summary>
/// <param name="text">文字内容</param>
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<TextMeshProUGUI>()
: 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));
}
/// <summary>表情显示 biaoQingDuration 秒后自动隐藏</summary>
private IEnumerator HideBiaoQingAfter(GameObject obj)
{
yield return new WaitForSeconds(biaoQingDuration);
if (obj != null) obj.SetActive(false);
_biaoQingCoroutine = null;
}
#endregion
/// <summary>房主退出后,将房主身份转移给第一个活跃的非人机玩家</summary>
/// <remarks>鱼创建已由服务端 OnFishSpawn 统一下发,此处仅维护 roomOwnerSit 标识(用于 bot 控制/AFK 等场景)。</remarks>
public static void TransferRoomOwner()
{
var list = FindObjectsOfType<SitPosData>();
foreach (var sit in list)
{
if (!sit.isBot && sit.gameObject.activeInHierarchy && sit != roomOwnerSit)
{
roomOwnerSit = sit;
break;
}
}
}
/// <summary>分配房主(首个非人机玩家)</summary>
/// <remarks>鱼创建已由服务端 OnFishSpawn 统一下发,此处仅维护 roomOwnerSit 标识(用于 bot 控制/AFK 等场景)。</remarks>
public static void AssignRoomOwner(SitPosData sit)
{
if (roomOwnerSit == null && !sit.isBot)
{
roomOwnerSit = sit;
}
}
// ==================== 炮台加载 ====================
/// <summary>
/// 获取炮台的旋转节点。
/// - 若 TurretData.tra_RotationPivot 已赋值 → 用它(精确指定旋转中心)
/// - XJMH:自动取"容器里那个炮台本体节点"(见 <see cref="FindXJMHRotationPivot"/>)
/// - Normal:回退旧逻辑,用炮台父节点(克隆用的中间容器)
/// </summary>
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;
}
/// <summary>
/// XJMH 炮台的旋转节点 = 炮口所在的、炮台的直接子节点。
/// ⭐ 从炮口 <see cref="TurretData.tra_BulletStartPosition"/> 往上走,取"炮台的直接子节点":
/// 炮台容器(SkeletonGraphic_Green / huangjinpaotai_1 / SkeletonGraphic_VIP)
/// └─ 炮台本体 ← 要转的就是它(lvptpaotai_2 / huangjinpaotai_2 / vippaotai01_2)
/// └─ …炮口 tra_BulletStartPosition…
/// 已核对预制体:三种炮台都符合,且与 VIP 上手动填的 TurretData.tra_RotationPivot 完全一致。
/// </summary>
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;
}
/// <summary>
/// 根据炮台编号加载炮台。
/// Normal 模式:从 Addressables 加载预制体并克隆到 turretParent。
/// XJMH 模式:炮台是座位自带子对象,传入值按 <see cref="xjmhTurretList"/> 索引解释(XJMH 炮台没有编号)。
/// </summary>
public void LoadTurret(int turretNumber)
{
if (turretControlMode == TurretControlMode.XJMH)
{
SwitchTurretByIndex(turretNumber);
return;
}
LoadTurretNormal(turretNumber);
}
/// <summary>
/// 普通模式:从 Addressables 加载预制体并克隆到 turretParent。
/// 位置/旋转/缩放保持预制体原始值,不动。
/// </summary>
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;
}
currentTurretNumber = turretNumber;
// 从 TurretResourcesData 拿预制体
GameObject prefab = TurretResourcesData.GetTurretPrefab(turretNumber);
if (prefab == null)
{
Debug.LogWarning($"[SitPosData] 炮台预制体未加载: #{turretNumber}");
return;
}
// 克隆到父节点
GameObject instance = Instantiate(prefab, turretParent, false);
instance.SetActive(true);
// 固定位置归零,由外部父节点定位
var rt = instance.GetComponent<RectTransform>();
if (rt != null)
rt.anchoredPosition = turretAnchoredPos;
currentTurret = instance.GetComponent<TurretData>();
if (currentTurret == null)
{
Debug.LogError($"[SitPosData] 预制体上未找到 TurretData 组件: #{turretNumber}");
Destroy(instance);
return;
}
currentTurret.turretNumber = turretNumber;
// 缓存旋转节点:优先用 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 ? "(战神装备中,炮台待命)" : ""));
}
/// <summary>
/// XJMH 模式:按列表索引切换炮台。
/// ⭐ 切换面板的 4 个按钮、帧同步接收端都走这里(XJMH 炮台没有编号,只能用索引)。
/// </summary>
/// <param name="index"><see cref="xjmhTurretList"/> 中的索引</param>
/// <returns>切换成功返回 true;索引越界或列表为空返回 false</returns>
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;
}
/// <summary>
/// 获取当前可用的炮台编号列表。
/// Normal 模式:从资源表按类型查询。
/// XJMH 模式:优先从 <see cref="xjmhTurretList"/> 读取,列表为空时回退扫描座位子对象。
/// </summary>
public List<int> GetAvailableTurretNumbers()
{
var result = new List<int>();
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<TurretData>();
if (td != null)
result.Add(td.turretNumber);
}
}
}
else
{
var list = TurretResourcesData.GetTurretDataByType(1);
if (list != null)
result = list.ConvertAll(d => d.turretNumber);
}
return result;
}
/// <summary>
/// 把炮台 UI 坐标转换成世界坐标(Z=0平面)并缓存到 turretWorldPos。
/// 转换方式和 TurretData.GetBulletStartWorldPos 一致。
/// </summary>
public void UpdateTurretWorldPos()
{
if (tra_CurrTurret == null)
{
turretWorldPos = Vector3.zero;
return;
}
RectTransform rect = tra_CurrTurret.GetComponent<RectTransform>();
if (rect != null && UIManager.Instance != null && UIManager.Instance.uiRoot != null)
{
Canvas canvas = UIManager.Instance.uiRoot.GetComponent<Canvas>();
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;
}
}
/// <summary>根据炮台编号字符串加载。</summary>
public void LoadTurret(string turretNumberStr)
{
if (int.TryParse(turretNumberStr, out int num))
LoadTurret(num);
}
// ==================== 翅膀加载(动态克隆) ====================
/// <summary>
/// 根据炮台编号,从 Addressables 加载预制体并克隆到 turretParent。
/// 位置/旋转/缩放保持预制体原始值,不动。
/// </summary>
public void LoadWing(int turretNumber)
{
// XJMH 模式没有翅膀概念,直接跳过
if (turretControlMode == TurretControlMode.XJMH) return;
// turretNumber<=0 = 卸下(与战神同一约定):只清理不加载,不打警告
if (turretNumber <= 0)
{
ClearWing();
return;
}
// 先清理已有翅膀
ClearWing();
// 从 TurretResourcesData 拿预制体
GameObject prefab = TurretResourcesData.GetTurretPrefab(turretNumber);
if (prefab == null)
{
Debug.LogWarning($"[SitPosData] 翅膀预制体未加载: #{turretNumber}");
return;
}
// 克隆到 wingParent,保持预制体原始位置/旋转/缩放
GameObject instance = Instantiate(prefab, wingParent, false);
// ⭐ 特殊鱼炮台(钻头/激光)使用中:只切换不显示,等爆奖流程结束由 RestoreTurretVisual 恢复显示
instance.SetActive(!isDrillActive);
if (isDrillActive) _pendingShowWing = instance;
Debug.Log($"[SitPosData] 翅膀已加载: #{turretNumber}{(isDrillActive ? "(特殊炮台使用中,暂缓显示)" : "")}");
}
/// <summary>卸下翅膀:清空 wingParent 下的翅膀模型(保留预设的 Camera,它是用来渲染翅膀到 RawImage 的)。</summary>
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<Camera>() != null)
continue; // 保留 Camera
Destroy(child.gameObject);
}
}
/// <summary>根据炮台编号字符串加载。</summary>
public void LoadWing(string turretNumberStr)
{
if (int.TryParse(turretNumberStr, out int num))
LoadWing(num);
}
// ==================== 战神加载(动态克隆,同翅膀) ====================
/// <summary>
/// 根据战神编号,加载游戏内使用的战神模型(ZSYX 标签的那个)。
/// 处理方式和翅膀一样:直接克隆到 zhanShenParent、保持预制体原始位置/旋转/缩放,不再管其他的
/// (渲染由座位预设的 Camera + RawImage 完成,显示节点挂在 turretRotate 下会跟随瞄准旋转)。
/// ⭐ 炮台/战神显示二选一:加载战神后隐藏 turretParent(仅游戏内,切换面板 UI 不受影响)。
/// </summary>
public void LoadZhanShen(int zhanShenNumber)
{
// XJMH 模式没有战神概念,直接跳过(和翅膀一致)
if (turretControlMode == TurretControlMode.XJMH) return;
// 先清理已有战神(保留预设的 Camera,它是用来渲染战神到 RawImage 的)
ClearZhanShenInstance();
// 从 TurretResourcesData 拿游戏内使用的战神预制体
GameObject prefab = TurretResourcesData.GetZhanShenPrefab(zhanShenNumber, ZhanShenUsage.GamePlay);
if (prefab == null)
{
Debug.LogWarning($"[SitPosData] 战神使用预制体未加载: #{zhanShenNumber}(检查 ZSYX 标签配置)");
return;
}
// 克隆到 zhanShenParent,保持预制体原始位置/旋转/缩放
GameObject instance = Instantiate(prefab, zhanShenParent, false);
// ⭐ 特殊鱼炮台(钻头/激光)使用中:只切换不显示,等爆奖流程结束由 RestoreTurretVisual 恢复显示
instance.SetActive(!isDrillActive);
// 绑定状态机入口:战神接管开火/待机动画(PlayFire 约定,见 TurretData.PlayWarGodAnimator)
// 同时缓存 TurretData:开火时优先用战神的子弹预制体(bulletPrefab/bulletPrefab_Two)
var zsData = instance.GetComponent<TurretData>();
// ⭐ 战神初始旋转偏移:从 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}");
}
/// <summary>根据战神编号字符串加载。</summary>
public void LoadZhanShen(string zhanShenNumberStr)
{
if (int.TryParse(zhanShenNumberStr, out int num))
LoadZhanShen(num);
}
/// <summary>清除战神并恢复普通炮台显示(炮台/战神显示二选一的另一半)</summary>
public void ClearZhanShen()
{
ClearZhanShenInstance();
currentZhanShenNumber = 0;
// 恢复普通炮台显示(特殊鱼炮台使用中不显示:等 RestoreTurretVisual 统一恢复)
if (turretParent != null && !isDrillActive)
turretParent.gameObject.SetActive(true);
// 状态机入口还给普通炮台(若已加载)
if (currentTurret != null)
OnStateChange = currentTurret.ChangeState;
}
/// <summary>只清 zhanShenParent 下的战神实例(保留 Camera),不动炮台显隐</summary>
private void ClearZhanShenInstance()
{
if (zhanShenParent != null)
{
for (int i = zhanShenParent.childCount - 1; i >= 0; i--)
{
var child = zhanShenParent.GetChild(i);
if (child.GetComponent<Camera>() != null)
continue; // 保留 Camera
Destroy(child.gameObject);
}
}
currentZhanShen = null;
currentZhanShenData = null;
// 战神清了激光也没了
DestroyZhanShenLaser();
// 散射起点球也清掉(下次出场/换战神时重建)
ClearJGFXParents();
}
/// <summary>
/// 特殊鱼炮台(钻头/激光)爆奖结束后的统一显示恢复入口(ZuanTouXie_DieEvent 调用)。
/// ⭐ 炮台/战神二选一按"当前装备"恢复,不依赖爆奖前的记录:
/// 1. 爆奖期间切换的翅膀(_pendingShowWing) → 恢复显示;
/// 2. 有战神(含爆奖期间新切的,currentZhanShen!=null) → 显示战神,普通炮台保持隐藏;
/// 3. 无战神 → 显示普通炮台 turretParent。
/// 显示后强制 None→Idle 归位:炮台/战神被隐藏期间 Animator 状态会丢失,
/// 若停在 Attack 状态,下次开火 ChangeState(Attack) 会被同状态短路 → 动画定格不播。
/// </summary>
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 双支持) ====================
/// <summary>刷新金币显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖)</summary>
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;
}
/// <summary>刷新钻石显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖)</summary>
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;
}
/// <summary>刷新炮倍消耗显示:优先 TMP,回退旧版 UGUI Text(两个字段在预制体上二选一拖)</summary>
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;
/// <summary>
/// 根据后端下发的炮倍值同步 indexConsume 到 paoBeiList 对应档位,
/// 避免加减炮倍时从 0 档开始导致跳变。
/// </summary>
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);
}
/// <summary>
/// 非本地座位隐藏加减炮倍 UI(GameMainPanel 进房时对自己以外的座位调用):
/// - Normal:隐藏座位上的 btn_TurretAdd / btn_TurretMinus(未拖引用为 null 时跳过)
/// - XJMH:加减倍按钮适配在炮台上(TurretData.obj_Add / obj_Down),座位上没有按钮引用,
/// 把 XJMH 炮台列表里每把炮台的加减倍都隐藏(全部藏,之后切换炮台新炮台的也不会再显示)
/// </summary>
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<TurretData>());
}
}
/// <summary>隐藏一把 XJMH 炮台上的加减倍对象(obj_Add / obj_Down,未拖引用跳过)</summary>
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;
/// <summary>炮倍面板展开时的原始高度(Awake 记录,动画在 0 ↔ 该值之间滑动)</summary>
private float _paoBeiPanelOpenHeight = -1f;
/// <summary>记录炮倍面板的原始高度(Awake 时面板还没被动画改过,rect.height 即还原态高度)</summary>
private void EnsurePaoBeiPanelHeight()
{
if (_paoBeiPanelOpenHeight >= 0f || selectPaoBeiPanel == null) return;
var rt = selectPaoBeiPanel.GetComponent<RectTransform>();
if (rt == null) return;
_paoBeiPanelOpenHeight = rt.rect.height;
if (_paoBeiPanelOpenHeight <= 0f)
Debug.LogWarning("[SitPosData] 炮倍面板初始高度为 0,无法记录展开高度,检查 selectPaoBeiPanel 的 RectTransform 配置");
}
/// <summary>
/// 点击炮倍按钮:开关选择面板。
/// ⭐ 只有本地玩家座位可以打开(远程/bot 座位点了没反应,不能替别人换炮倍)。
/// </summary>
private void OnClickSelectPaoBei()
{
if (this != localPlayerSit) return;
if (selectPaoBeiPanel == null) return;
bool opening = !selectPaoBeiPanel.activeSelf;
if (opening) RefreshPaoBeiItems();
SetPaoBeiPanelOpen(opening);
}
/// <summary>
/// 开/关炮倍面板:height 在 0 ↔ 原始高度之间动画 0.5s。
/// ⭐ 不改面板 pivot/anchoredPosition,滑动方向完全由预制体里配好的 RectTransform 决定。
/// </summary>
private void SetPaoBeiPanelOpen(bool open)
{
var rt = selectPaoBeiPanel.GetComponent<RectTransform>();
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)));
}
}
/// <summary>height 动画(0.5s);动画结束回调在收起时用于隐藏面板</summary>
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;
}
/// <summary>
/// 按配置 paoBeiList 生成炮倍档位 item:
/// child0 的 TextMeshProUGUI 显示炮倍值;背景按值上色(&lt;5000 蓝 / 5000~10000 紫 / &gt;=10000 红),当前档位黄色。
/// 点击 item:换到该档位(走 ChangeGunMultiple 服务端确认协议)并直接关闭面板。
/// </summary>
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<TextMeshProUGUI>() : null;
if (tmp != null)
tmp.text = list[i].ToString();
var img = item.GetComponent<Image>();
if (img != null)
img.sprite = index == indexConsume ? yellowBG : GetPaoBeiSprite(list[i]);
var btn = item.GetComponent<Button>();
if (btn != null)
btn.onClick.AddListener(() =>
{
if (this != localPlayerSit) return; // 只有自己座位可以换炮倍
SelectPaoBeiByIndex(index);
SetPaoBeiPanelOpen(false); // 选择后直接关闭
});
}
}
/// <summary>炮倍档位背景色:5000 以下蓝,5000~10000 紫,10000 以上红(黄色留给选中态)</summary>
private Sprite GetPaoBeiSprite(int value)
{
if (value >= 10000) return redBG;
if (value >= 5000) return purpleBG;
return blueBG;
}
/// <summary>
/// 选择面板点选档位:本地记录索引并发起 ChangeGunMultiple 请求,
/// consumeGoldNum 和 UI 等服务端 ChangeGunMultipleNotice 确认后再应用(与加减按钮同一协议,防止扣费差值)。
/// </summary>
public void SelectPaoBeiByIndex(int index)
{
var list = CreateFishManager.Instance?.currGameType?.paoBeiList;
if (list == null || index < 0 || index >= list.Count) return;
indexConsume = index;
AudioManager.Instance.PlayerFX(62011);
int newConsume = list[index];
string roomId = GameManager.Instance.gameData?.currRoomData?.roomId;
if (!string.IsNullOrEmpty(roomId))
WebSocketSendList.ChangeGunMultiple(roomId, user, newConsume);
}
// ==================== 锁定系统 ====================
public void SetLockTarget(MonsterContral monster, Transform hitPos)
{
monCotl_Lock = monster;
monCotl_Lock_Position = hitPos;
isLock = monster != null;
lockFX.SetActive(isLock);
// 非三发锁定 → 隐藏额外图标
if (!isLock) HideTripleLockIcons();
}
/// <summary>
/// 设置三发锁定目标(3个目标分别追踪)
/// </summary>
public void SetTripleLockTargets(List<(MonsterContral monster, Transform hitPos)> targets)
{
if (targets == null || targets.Count == 0)
{
HideTripleLockIcons();
return;
}
// 确保 lockFX_Triple 有2个克隆的图标(+主图标=3个)
EnsureTripleLockIcons();
// ⭐ 固定3个锁位(不足的留空占位,下一帧由锁定系统自动尝试补一次)
EnsureTripleLockSlots(3);
for (int i = 0; i < 3; i++)
{
bool has = i < targets.Count;
tripleLockTargets[i] = has ? targets[i].monster : null;
tripleLockPositions[i] = has ? targets[i].hitPos : null;
// ⭐ 自动建位:允许自动补位(补不到会转成"空置等手动点击"),不算玩家手动
tripleSlotIdle[i] = false;
tripleSlotManual[i] = false;
}
monCotl_Lock = targets[0].monster;
monCotl_Lock_Position = targets[0].hitPos;
isLock = targets[0].monster != null;
RefreshTripleIconVisibility();
}
private void EnsureTripleLockIcons()
{
while (lockFX_Triple.Count < 2 && lockFX != null)
{
var clone = GameObject.Instantiate(lockFX, lockFX.transform.parent);
clone.name = lockFX.name + "_Triple" + (lockFX_Triple.Count + 1);
clone.transform.localScale = lockFX.transform.localScale * 0.5f;
lockFX_Triple.Add(clone);
}
}
/// <summary>把锁位列表补齐到 count 个(用 null 占位,保证三个列表索引对齐)</summary>
public void EnsureTripleLockSlots(int count)
{
while (tripleLockTargets.Count < count)
{
tripleLockTargets.Add(null);
tripleLockPositions.Add(null);
tripleSlotIdle.Add(false);
tripleSlotManual.Add(false);
}
}
public void HideTripleLockIcons()
{
foreach (var icon in lockFX_Triple)
{
if (icon != null)
icon.SetActive(false);
}
tripleLockTargets.Clear();
tripleLockPositions.Clear();
tripleSlotIdle.Clear();
tripleSlotManual.Clear();
}
// ==================== 三发锁位(每个锁位独立维护) ====================
// ⭐ index=0 是主锁位(monCotl_Lock + 主 lockFX),1..2 对应 lockFX_Triple[index-1]
/// <summary>按每个锁位是否有鱼刷新3个锁定圈显示(有目标才有圈)</summary>
private void RefreshTripleIconVisibility()
{
if (lockFX != null)
lockFX.SetActive(tripleLockTargets.Count > 0 && tripleLockTargets[0] != null);
for (int i = 0; i < lockFX_Triple.Count; i++)
{
if (lockFX_Triple[i] == null) continue;
int slot = i + 1;
lockFX_Triple[i].SetActive(slot < tripleLockTargets.Count && tripleLockTargets[slot] != null);
}
}
/// <summary>替换单个三发锁位(自动补位用:锁位独立,只补这一个,其他锁位不动)</summary>
public void SetTripleLockSlot(int index, MonsterContral monster, Transform hitPos)
{
if (index < 0) return;
EnsureTripleLockSlots(index + 1);
tripleLockTargets[index] = monster;
tripleLockPositions[index] = hitPos;
tripleSlotIdle[index] = false;
tripleSlotManual[index] = false; // 自动补的位不算玩家手动
if (index == 0)
{
monCotl_Lock = monster;
monCotl_Lock_Position = hitPos;
isLock = monster != null;
}
RefreshTripleIconVisibility();
}
/// <summary>
/// 清空单个三发锁位:该位打死后自动检测没找到鱼 → 保留占位、隐藏该位图标,
/// 并标记"空置等手动点击"(不再自动补位,必须玩家点击才锁)。
/// </summary>
public void ClearTripleLockSlot(int index)
{
if (index < 0 || index >= tripleLockTargets.Count) return;
tripleLockTargets[index] = null;
tripleLockPositions[index] = null;
tripleSlotIdle[index] = true;
tripleSlotManual[index] = false;
if (index == 0)
{
monCotl_Lock = null;
monCotl_Lock_Position = null;
isLock = false;
}
RefreshTripleIconVisibility();
}
/// <summary>该锁位是否处于"空置等手动点击"状态(不再自动补位)</summary>
public bool IsTripleSlotIdle(int index)
{
return index >= 0 && index < tripleSlotIdle.Count && tripleSlotIdle[index];
}
/// <summary>
/// 该锁位是否是玩家手动点选的鱼(手动身份跟着鱼一起移入子锁位)。
/// true → 离场前一直跟,只有鱼死了才换。
/// </summary>
public bool IsTripleSlotManual(int index)
{
return index >= 0 && index < tripleSlotManual.Count && tripleSlotManual[index];
}
/// <summary>三个锁位是否还有任意一个有效目标(鱼还活着)</summary>
public bool HasAnyTripleTarget
{
get
{
foreach (var m in tripleLockTargets)
if (m != null && m.gameObject.activeInHierarchy) return true;
return false;
}
}
/// <summary>当前三发锁位上有效的鱼 guid 列表(开火同步 monsterIDs 用)</summary>
public List<string> GetTripleLockGuids()
{
var list = new List<string>();
foreach (var m in tripleLockTargets)
if (m != null && !m.isDead && m.gameObject.activeInHierarchy)
list.Add(m.guid);
return list;
}
/// <summary>该 guid 的鱼是否已经在某个三发锁位上</summary>
public bool IsFishInTripleSlots(string guid)
{
if (string.IsNullOrEmpty(guid)) return false;
foreach (var m in tripleLockTargets)
if (m != null && m.guid == guid) return true;
return false;
}
/// <summary>
/// ⭐ 手动点击锁鱼(战神+三发+锁定):新点的鱼占主锁位,原来锁位上的鱼依次排到子锁位,
/// 最多保留3个(超过时最旧的被挤掉)。
/// 手动身份跟着鱼一起转移(移入子锁位后依然是手动锁定,离场前不会被自动换掉);
/// 挤位前会先"紧凑化",跳过空位/死鱼,避免有空位时误丢掉还活着的锁定目标。
/// </summary>
public void PushTripleLockSlot(MonsterContral monster, Transform hitPos)
{
EnsureTripleLockIcons();
// ① 收集现有锁位上还活着的鱼(跳过空位和死鱼),按 新→旧 顺序,最多留2个
var keepMon = new List<MonsterContral>();
var keepPos = new List<Transform>();
var keepManual = new List<bool>();
for (int i = 0; i < tripleLockTargets.Count && keepMon.Count < 2; i++)
{
var m = tripleLockTargets[i];
if (m == null || !m.gameObject.activeInHierarchy) continue;
if (m == monster) continue; // 同一条鱼不重复占位
keepMon.Add(m);
keepPos.Add(i < tripleLockPositions.Count ? tripleLockPositions[i] : null);
keepManual.Add(i < tripleSlotManual.Count && tripleSlotManual[i]);
}
// ② 新点的鱼占主锁位,其余依次排到子锁位
EnsureTripleLockSlots(3);
tripleLockTargets[0] = monster;
tripleLockPositions[0] = hitPos;
tripleSlotIdle[0] = false;
tripleSlotManual[0] = true; // 玩家手动点的
for (int i = 1; i < 3; i++)
{
int k = i - 1;
bool has = k < keepMon.Count;
tripleLockTargets[i] = has ? keepMon[k] : null;
tripleLockPositions[i] = has ? keepPos[k] : null;
// ⭐ 空位标记"等手动点击":玩家在自己点选,空位不再自动补
tripleSlotIdle[i] = !has;
tripleSlotManual[i] = has && keepManual[k];
}
monCotl_Lock = monster;
monCotl_Lock_Position = hitPos;
isLock = true;
RefreshTripleIconVisibility();
}
/// <summary>
/// 主锁位空缺时,把其他存活的锁位提升为主锁,避免主锁死掉导致整个三发模式中断。
/// 提升后原锁位留空并允许自动补一次。成功返回 true。
/// </summary>
public bool PromoteTripleLockSlotToMain()
{
for (int i = 1; i < tripleLockTargets.Count; i++)
{
var mon = tripleLockTargets[i];
if (mon == null || !mon.gameObject.activeInHierarchy) continue;
Transform pos = i < tripleLockPositions.Count ? tripleLockPositions[i] : null;
tripleLockTargets[0] = mon;
tripleLockPositions[0] = pos;
tripleLockTargets[i] = null;
tripleLockPositions[i] = null;
if (i < tripleSlotIdle.Count)
{
tripleSlotIdle[0] = tripleSlotIdle[i];
tripleSlotIdle[i] = false; // 让出的位置允许自动补一次
}
if (i < tripleSlotManual.Count)
{
tripleSlotManual[0] = tripleSlotManual[i]; // 手动身份跟着鱼提升
tripleSlotManual[i] = false;
}
monCotl_Lock = mon;
monCotl_Lock_Position = pos;
isLock = true;
RefreshTripleIconVisibility();
return true;
}
return false;
}
public void ClearLock()
{
monCotl_Lock = null;
monCotl_Lock_Position = null;
isLock = false;
// ⭐ 清除锁定时重置手动锁定标记
isManualLock = false;
lockFX.SetActive(false);
HideTripleLockIcons();
ChangeState(TurretState.Idle);
}
}