725 lines
36 KiB
C#
725 lines
36 KiB
C#
using Assets.MyGame.Scripts.Net.APIResponses.Enum;
|
||
using DG.Tweening;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
|
||
/// <summary>
|
||
/// 烈焰风暴火球弹(PW_Bullet_FlameRound)的飞行 / 反弹 / 命中控制。
|
||
///
|
||
/// 「击杀 + 反弹」框架参考钻头子弹 MyGame/aa/Game/FLZY/TSY/ZuanTou/SpecialBulletMove.cs:
|
||
/// · 撞墙走**法线反弹**(ClosestPoint 求法线 → Vector2.Reflect),反弹后沿法线推回墙外防穿模;
|
||
/// · 反弹冷却 0.05s 防一帧内连触发 / 穿墙;反弹刷新兜底计时(还在弹就永不超时);
|
||
/// · 碰鱼只处理「普通鱼」,同一条鱼只报一次,之后**穿透继续飞**(跟钻头一样,杀完继续打);
|
||
/// · 反弹次数到上限 / 兜底超时 → 收尾爆发,回收。
|
||
///
|
||
/// 表现参数对齐 lua pw_bullet/PW_BulletFlameStrike.txt:
|
||
/// · speed 30;sprite 自转 2400°/s(lua 用第一次算的 dt 缓存四元数,这里同样缓存);
|
||
/// · 反弹时(自己视角)震屏 + 播 PW_FlameStrike_WheelFly(同 1 秒内只播一次);
|
||
/// · 正下方(±90°)撞到上下墙 → 改成 ±30° 斜弹并按 lua 翻转 flag,防止贴着上下边来回弹;
|
||
/// · 碰鱼:命中特效(lua PW_Effect_Hit_Prop6)+ 上报击杀(KillFish,只有击杀者客户端发)+ OnHurt,
|
||
/// 子弹**不消失**(lua Collider() 也只上报、不销毁);
|
||
/// · 收尾(lua Disappear()):PW_FlameStrike_WheelExplosion 音效 + sprite 隐藏 + 切 Explosion 特效
|
||
/// + 0.6s 后回收。
|
||
///
|
||
/// 用法(不改预制体):由 <see cref="LieYanFengBao_BOSS"/> 在 BulletPool.Get 之后运行时 AddComponent,
|
||
/// 并把预制体上原来的 BullitMove 关掉,避免两套移动逻辑打架。
|
||
/// </summary>
|
||
public class FlameRoundBullet : MonoBehaviour
|
||
{
|
||
// ================================================================ Inspector
|
||
[Header("移动")]
|
||
[Tooltip("飞行速度(lua speed=30)")]
|
||
public float moveSpeed = 30f;
|
||
[Tooltip("兜底存活时间(秒):到点还没被反弹上限收尾就强制爆炸。\n" +
|
||
"速度 30 → 8 秒飞约 240 单位(屏幕对角线约 25~28 单位),正常会先被 bounceLimit 收尾")]
|
||
public float lifeTime = 8f;
|
||
[Tooltip("sprite 自转速度(度/秒,lua spinSpeed=2400)")]
|
||
public float spriteSpinSpeed = 2400f;
|
||
|
||
[Header("反弹")]
|
||
[Tooltip("撞墙反弹次数上限,到达后进入收尾爆炸回收(<=0 表示不限次数,只靠 lifeTime 兜底)。\n" +
|
||
"⭐ 每撞一次墙算 1 次 → 4 次大约 2~3.5 秒收尾(钻头蟹 7+5=12 次是三段式,火球弹是单一阶段)")]
|
||
public int bounceLimit = 4;
|
||
[Tooltip("反弹冷却(秒):防同一帧/连续多帧重复触发导致穿墙")]
|
||
public float bounceCooldown = 0.05f;
|
||
[Tooltip("同一面墙的再次反弹冷却(秒):防止卡在墙角里被连续计次,几下就打满反弹上限")]
|
||
public float sameWallCooldown = 0.25f;
|
||
[Tooltip("撞墙反弹是否刷新兜底计时(钻头做法)。关掉=严格按 lifeTime 超时,行为更可预测")]
|
||
public bool refreshLifeOnBounce = false;
|
||
[Tooltip("垂直(正上/正下)撞到上下墙时改成的斜角(lua ±30°),防止贴着上下边来回弹")]
|
||
public float verticalDeflectAngle = 30f;
|
||
[Tooltip("反弹时震屏 + 播 WheelFly 音效(仅自己视角)")]
|
||
public bool bounceFeedback = true;
|
||
[Tooltip("反弹震屏强度(ShortShake2D 的 positionStrength/rotationStrength)")]
|
||
public float bounceShakeStrength = 0.5f;
|
||
[Tooltip("反弹音效最小间隔(秒,lua 用时间戳做的 1 秒节流)")]
|
||
public float bounceSoundInterval = 1f;
|
||
|
||
[Header("命中鱼")]
|
||
[Tooltip("命中特效预制体(lua: PW_Effect_Hit_Prop6)")]
|
||
public GameObject hitEffectPrefab;
|
||
[Tooltip("⭐ 兜底上报开关:仅当创建方没给 onHitFish 回调时才用(走 WebSocketSendList.KillFish)。\n" +
|
||
"对齐钻头蟹时由创建方给回调走本地帧同步杀鱼(Id_DieFish_FX),此时保持 false 避免重复结算")]
|
||
public bool reportKill = false;
|
||
[Tooltip("打详细日志(排查用:每次 Trigger、每次碰鱼、被动回收都会打)")]
|
||
public bool debugLog = true;
|
||
[Tooltip("自动检测鱼(每帧 OverlapCircle 主动扫,不依赖 OnTrigger 事件):\n" +
|
||
"即使触发器因层/标签/帧率(速度30每帧0.6单位)漏触发,也一定能命中并穿透")]
|
||
public bool detectFishByOverlap = true;
|
||
[Tooltip("主动检测的额外半径(在碰撞体半径 + 每帧步长 之上再加的安全余量)")]
|
||
public float overlapExtraRadius = 0.35f;
|
||
[Tooltip("回收时不用 BulletPool、直接 Destroy:\n" +
|
||
"⭐ 关键保险 —— 本项目换炮台/BulletPool.ClearActive() 会销毁所有\"活跃子弹\",\n" +
|
||
"火球弹不属于任何座位,混在池里会被误清;自管理后外部清理一律带不走它")]
|
||
public bool selfManaged = true;
|
||
[Tooltip("墙层(留空自动取 Wall 层,另兼容 Wall 标签)")]
|
||
public LayerMask wallLayer;
|
||
[Tooltip("鱼层(留空自动取 Fish 层,另兼容 Fish 标签)")]
|
||
public LayerMask fishLayer;
|
||
|
||
[Header("节点名 / 收尾")]
|
||
[Tooltip("弹体 sprite 节点名(自转用)")]
|
||
public string spriteNodeName = "sprite";
|
||
[Tooltip("飞行中显示的特效节点名(lua ShowEffect(1)=Glow)")]
|
||
public string glowNodeName = "Glow";
|
||
[Tooltip("收尾时显示的特效节点名(lua ShowEffect(2)=Explosion)")]
|
||
public string explosionNodeName = "Explosion";
|
||
[Tooltip("收尾爆炸后停留多久回池(lua 0.6s)")]
|
||
public float explosionHoldTime = 0.6f;
|
||
|
||
[Header("收尾爆炸炸鱼(对齐钻头蟹 SpecialBulletMove.Explode)")]
|
||
[Tooltip("收尾爆炸的杀鱼半径(世界单位):炸一圈小鱼。<=0 表示不炸鱼")]
|
||
public float explosionRadius = 3f;
|
||
[Tooltip("收尾爆炸是否炸死范围小鱼(对齐钻头蟹 killFishOnFinal)")]
|
||
public bool killFishOnFinal = true;
|
||
[Tooltip("范围炸鱼时是否也各播一次命中特效(钻头蟹不播,靠爆炸特效;默认关,避免同点叠一堆)")]
|
||
public bool explosionSpawnHitEffect = false;
|
||
|
||
[Header("收尾爆奖(BJEndUI)")]
|
||
[Tooltip("⭐ 是否由本发子弹自己出爆奖面板。\n" +
|
||
"默认 **关** —— 4 发子弹各出一份 = 屏幕上叠 4 个面板。\n" +
|
||
"正确做法(对齐钻头蟹):由创建方 Boss 统计所有子弹都收尾后,统一出**一次**面板(走 onFinished 回调)")]
|
||
public bool spawnJackpotOnFinish = false;
|
||
[Tooltip("爆奖结算面板预制体(BJEndUI)。仅 spawnJackpotOnFinish=true 时用")]
|
||
public GameObject bjEndUIPrefab;
|
||
[Tooltip("爆奖倍率(beishu)。<=0 时自动用炮倍 paoBei")]
|
||
public int jackpotBeishu = 0;
|
||
[Tooltip("⭐ 爆奖总分:<0 表示自动累计本发击杀鱼的分值(die_Score) 作兜底;>=0 表示用该注入值(建议由 Boss 从后端 gold 注入)")]
|
||
public long jackpotScore = -1;
|
||
[Tooltip("爆奖面板相对击杀者座位的 Y 偏移(世界单位,座位上方;与其它爆奖一致)")]
|
||
public float bjEndUIOffsetY = 150f;
|
||
|
||
// ================================================================ 创建方注入
|
||
/// <summary>Boss 击杀者 userId(决定谁上报击杀;只有等于本机用户时才发请求)</summary>
|
||
[NonSerialized] public string killerUserId;
|
||
/// <summary>子弹标识(上报用)</summary>
|
||
[NonSerialized] public string bulletId;
|
||
/// <summary>炮倍(上报用,取击杀者座位 consumeGoldNum)</summary>
|
||
[NonSerialized] public int paoBei = 1;
|
||
/// <summary>sourceUID(lua 用它决定 180° 斜弹往左还是往右)</summary>
|
||
[NonSerialized] public int sourceUID;
|
||
/// <summary>是否本机击杀者(震屏/音效门限)</summary>
|
||
[NonSerialized] public bool isSelf;
|
||
/// <summary>碰鱼回调(创建方可追加:加分等),子弹不会因为回调而停止</summary>
|
||
[NonSerialized] public Action<MonsterContral> onHitFish;
|
||
/// <summary>收尾完成回调(回收前触发)</summary>
|
||
[NonSerialized] public Action onFinished;
|
||
|
||
// ================================================================ 内部状态
|
||
private Transform _sprite;
|
||
private GameObject _glow;
|
||
private GameObject _explosion;
|
||
private Rigidbody2D _rb;
|
||
/// <summary>同一个物体上的通用子弹脚本:本脚本接管后它必须保持关闭(开着就会"碰鱼即回池")</summary>
|
||
private BullitMove _bullit;
|
||
|
||
private Vector2 _heading = Vector2.up; // 前进方向(2D 平面),与模型旋转解耦
|
||
private Quaternion _baseRot = Quaternion.identity; // 预制体姿态(去掉 Z 分量后的基准)
|
||
|
||
private float _time; // 兜底计时
|
||
private float _bounceCd; // 反弹冷却
|
||
private float _lastBounceSoundTime = -99f; // 反弹音效节流
|
||
private int _bounceCount;
|
||
private int _flag = 1; // lua 的 flag:180° 斜弹时来回翻
|
||
private bool _finished;
|
||
private Tween _releaseTween;
|
||
|
||
// 主动检测鱼 / 同墙冷却
|
||
private float _baseRadius = 0.5f; // 自身碰撞体半径(Init 里取一次)
|
||
private Collider2D _lastWall; // 上一次反弹的墙
|
||
private float _lastWallTime = -99f;
|
||
private static readonly Collider2D[] _overlapBuf = new Collider2D[32];
|
||
|
||
private readonly HashSet<string> _hitFish = new HashSet<string>();
|
||
private long _accumScore; // 累计击杀分值(收尾爆奖兜底显示用)
|
||
|
||
// ================================================================ 生命周期
|
||
/// <summary>
|
||
/// 初始化并设置前进方向。
|
||
/// heading 语义与钻头 SpecialBulletMove 一致:世界方向;模型只转 Z 轴(保留预制体基准姿态)。
|
||
/// </summary>
|
||
public void Init(Vector2 heading)
|
||
{
|
||
KillReleaseTween();
|
||
|
||
_time = 0f;
|
||
_bounceCd = 0f;
|
||
_bounceCount = 0;
|
||
_flag = 1;
|
||
_finished = false;
|
||
_lastBounceSoundTime = -99f;
|
||
_lastWall = null;
|
||
_lastWallTime = -99f;
|
||
_hitFish.Clear();
|
||
_accumScore = 0;
|
||
|
||
if (wallLayer.value == 0) wallLayer = LayerMask.GetMask("Wall");
|
||
if (fishLayer.value == 0) fishLayer = LayerMask.GetMask("Fish");
|
||
|
||
// ⭐ 铁律:通用子弹脚本开着就"碰鱼即回池",本脚本接管期间必须保证它是关的。
|
||
// 再补一道保险:把 isTriggerFirst 置 false —— 即使它被重新启用,碰撞分支也整段跳过。
|
||
_bullit = GetComponent<BullitMove>();
|
||
if (_bullit != null)
|
||
{
|
||
if (_bullit.enabled)
|
||
Debug.LogWarning("[烈焰风暴火球弹] 检测到 BullitMove 处于开启状态 → 已强制关闭(否则会一碰鱼就回池)");
|
||
_bullit.enabled = false;
|
||
_bullit.isTriggerFirst = false;
|
||
}
|
||
|
||
_rb = GetComponent<Rigidbody2D>();
|
||
if (_rb != null)
|
||
{
|
||
// 位置由脚本每帧直接写,物理只用来收 trigger
|
||
_rb.gravityScale = 0f;
|
||
_rb.velocity = Vector2.zero;
|
||
_rb.angularVelocity = 0f;
|
||
}
|
||
|
||
// 自身碰撞体半径(主动检测鱼时用:半径 + 本帧位移 + 余量)
|
||
var col = GetComponent<Collider2D>();
|
||
_baseRadius = col != null ? Mathf.Max(col.bounds.extents.x, col.bounds.extents.y) : 0.5f;
|
||
|
||
if (_sprite == null) ResolveNodes(); // 池化复用不必每次重找(结构不会变)
|
||
// 视觉复位:上次收尾把 sprite 关掉了,池化复用必须打开(否则第二发看不到弹体)
|
||
if (_sprite != null) _sprite.gameObject.SetActive(true);
|
||
ShowExplosion(false); // 飞行中:Glow 亮、Explosion 灭
|
||
|
||
// 基准姿态 = 当前旋转去掉 Z 分量(本预制体旋转纯 Z,所以实际就是 identity)
|
||
float z0 = transform.eulerAngles.z;
|
||
_baseRot = Quaternion.Inverse(Quaternion.Euler(0f, 0f, z0)) * transform.rotation;
|
||
|
||
// 出膛方向:预制体已被创建方按朝向摆好 → 直接用 transform.up 取方向
|
||
SetHeading(transform.up);
|
||
|
||
Debug.Log($"[烈焰风暴火球弹 v3] 生命周期开始:接管={(_bullit != null ? "已接管(PW_Bullet_FlameRound)" : "⚠ 预制体上没有 BullitMove")} " +
|
||
$"pos={transform.position} dir={_heading} 速度={moveSpeed} 兜底={lifeTime}s " +
|
||
$"反弹上限={bounceLimit} 主动检测={detectFishByOverlap} 自管理={selfManaged} " +
|
||
$"sourceUID={sourceUID} 上报者={killerUserId}");
|
||
|
||
if (_bullit != null && _bullit.enabled)
|
||
Debug.LogWarning("[烈焰风暴火球弹] ⚠ BullitMove 处于开启状态 —— 它一碰鱼就会把子弹回池(表现为\"碰到鱼就消失\")");
|
||
|
||
// ⭐ 兜底时长提醒:屏幕对角线约 25~28 单位,速度 30 时 1 秒 ≈ 一屏多一点。
|
||
// 太短会看起来像"刚打出去就没了",所以只对"短到不合理"的配置告警(默认 8s 不触发)。
|
||
if (lifeTime > 0f && lifeTime < 3f)
|
||
{
|
||
Debug.LogWarning($"[烈焰风暴火球弹] ⚠ lifeTime={lifeTime}s 很短:速度 {moveSpeed} 时只飞约 " +
|
||
$"{lifeTime * moveSpeed:0} 单位(屏幕对角线约 25~28)就会被兜底回收");
|
||
}
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
KillReleaseTween();
|
||
// ⭐ 诊断:正常收尾会先把 _finished 置 true 再回收;没置就走到这里 = **有别的东西**把子弹回收/隐藏了
|
||
if (!_finished && debugLog)
|
||
{
|
||
Debug.LogWarning($"[烈焰风暴火球弹] ⚠ 未收尾就被回收/隐藏 —— 不是本脚本回收的(很可能是 BullitMove 仍开着," +
|
||
$"或外部调了 BulletPool.ClearActive/ClearAll)。已飞 {_time:0.00}s、反弹 {_bounceCount} 次、" +
|
||
$"位置={transform.position}\n调用栈:{new System.Diagnostics.StackTrace(true)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>被真正销毁时的最后一道证据:打印调用栈,一眼看出是谁 Destroy 的</summary>
|
||
private void OnDestroy()
|
||
{
|
||
if (!_finished && debugLog)
|
||
{
|
||
Debug.LogWarning($"[烈焰风暴火球弹] ⚠ 子弹在未收尾状态下被 Destroy(已飞 {_time:0.00}s、反弹 {_bounceCount} 次)\n" +
|
||
$"调用栈:{new System.Diagnostics.StackTrace(true)}");
|
||
}
|
||
}
|
||
|
||
// ================================================================ 每帧
|
||
private void Update()
|
||
{
|
||
if (_finished) return;
|
||
|
||
// 自愈:万一 BullitMove 被别处重新打开 → 立刻关掉(它一碰鱼就会 BulletPool.Return)
|
||
if (_bullit != null && (_bullit.enabled || _bullit.isTriggerFirst))
|
||
{
|
||
_bullit.enabled = false;
|
||
_bullit.isTriggerFirst = false;
|
||
Debug.LogWarning("[烈焰风暴火球弹] BullitMove 被重新打开 → 已强制关闭并封掉它的碰撞分支");
|
||
}
|
||
|
||
_time += Time.deltaTime;
|
||
if (_bounceCd > 0f) _bounceCd -= Time.deltaTime;
|
||
|
||
// 兜底:按 lifeTime 严格超时(refreshLifeOnBounce=true 时撞墙会重置,跟钻头一致)
|
||
if (lifeTime > 0f && _time >= lifeTime)
|
||
{
|
||
Debug.LogWarning($"[烈焰风暴火球弹] 兜底收尾:{lifeTime}s(已反弹 {_bounceCount}/{bounceLimit})pos={transform.position}");
|
||
Finish();
|
||
return;
|
||
}
|
||
|
||
SpriteSpin();
|
||
|
||
// ⭐ 主动命中检测:不依赖 OnTrigger 事件(速度 30 每帧走 0.6 单位,触发器在
|
||
// 层/标签/帧率异常时可能漏触发)→ 每帧扫一次自身周围的鱼,保证"穿透"一定生效
|
||
if (detectFishByOverlap) DetectFishByOverlap();
|
||
|
||
transform.position += (Vector3)(_heading * (moveSpeed * Time.deltaTime));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 每帧主动扫周围的鱼(OverlapCircle)→ 命中走与 Trigger 完全相同的处理(同一条只报一次)。
|
||
/// 半径 = 自身碰撞体半径 + 本帧位移 + 额外余量,避免高速穿模漏检。
|
||
/// </summary>
|
||
private void DetectFishByOverlap()
|
||
{
|
||
float radius = _baseRadius + moveSpeed * Time.deltaTime + Mathf.Max(0f, overlapExtraRadius);
|
||
int n = Physics2D.OverlapCircleNonAlloc(transform.position, radius, _overlapBuf);
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
var c = _overlapBuf[i];
|
||
if (c == null) continue;
|
||
var fish = c.GetComponentInParent<MonsterContral>();
|
||
if (fish == null) continue;
|
||
ProcessFishHit(fish);
|
||
}
|
||
}
|
||
|
||
/// <summary>lua SpriteSpin:弹体 sprite 持续自转(2400°/s)</summary>
|
||
private void SpriteSpin()
|
||
{
|
||
if (_sprite == null || Mathf.Approximately(spriteSpinSpeed, 0f)) return;
|
||
_sprite.localRotation = Quaternion.AngleAxis(spriteSpinSpeed * Time.deltaTime, Vector3.forward)
|
||
* _sprite.localRotation;
|
||
}
|
||
|
||
// ================================================================ 碰撞
|
||
private void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
if (_finished) return;
|
||
|
||
if (debugLog && other != null)
|
||
{
|
||
Debug.Log($"[烈焰风暴火球弹] 收到 Trigger: {other.name} tag={other.tag} " +
|
||
$"layer={LayerMask.LayerToName(other.gameObject.layer)} 已反弹={_bounceCount}");
|
||
}
|
||
|
||
// —— 1. 撞墙:法线反弹 ——
|
||
if (IsWall(other))
|
||
{
|
||
if (_bounceCd > 0f) return; // 冷却中,忽略同一次碰撞的后续帧
|
||
// ⭐ 同一面墙的再次反弹冷却:子弹卡在墙角时会被连续计次,几下就打满反弹上限 → 子弹"无故消失"
|
||
if (_lastWall == other && Time.time - _lastWallTime < sameWallCooldown) return;
|
||
|
||
Bounce(other);
|
||
_bounceCd = bounceCooldown;
|
||
_lastWall = other;
|
||
_lastWallTime = Time.time;
|
||
if (refreshLifeOnBounce) _time = 0f; // 默认关:严格按 lifeTime 超时,行为可预测
|
||
_bounceCount++;
|
||
PlayBounceFeedback();
|
||
|
||
if (debugLog)
|
||
Debug.Log($"[烈焰风暴火球弹] 撞墙反弹 第{_bounceCount}/{bounceLimit} 次 → 新方向 {_heading}");
|
||
|
||
if (bounceLimit > 0 && _bounceCount >= bounceLimit)
|
||
{
|
||
Debug.Log($"[烈焰风暴火球弹] 反弹 {_bounceCount} 次达到上限 → 收尾爆炸");
|
||
Finish();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// —— 2. 碰鱼:上报 + 穿透继续飞(同一条鱼只报一次)——
|
||
if (!IsFish(other)) return;
|
||
ProcessFishHit(other.GetComponentInParent<MonsterContral>());
|
||
}
|
||
|
||
/// <summary>
|
||
/// 命中一条鱼(Trigger、主动 Overlap、收尾爆炸范围杀鱼 共用):
|
||
/// 只处理「普通鱼」→ 同一条只报一次 → 命中特效 + 上报击杀 + OnHurt → **不回池、不销毁**(穿透继续飞)。
|
||
/// </summary>
|
||
/// <param name="spawnHitEffect">是否播命中特效(收尾爆炸时传 false,避免同点叠一堆)</param>
|
||
private void ProcessFishHit(MonsterContral fish, bool spawnHitEffect = true)
|
||
{
|
||
// ⚠ 注意:收尾爆炸范围杀鱼是在 Finish() 里、且必须在 _finished 置 true **之前** 调用
|
||
if (_finished) return;
|
||
if (fish == null || fish.isDead) return;
|
||
if (fish.fishenum != FishTypeEnum.普通鱼) return; // ⭐ 只打小鱼(黄金鱼/特殊鱼/Boss 穿过)
|
||
if (!_hitFish.Add(fish.guid)) return; // ⭐ 同一条鱼只报一次
|
||
|
||
// 累计击杀分值(创建方没注入 jackpotScore 时的兜底显示值;口径与杀鱼一致:炮倍×鱼分)
|
||
if (fish.monsterData != null && fish.monsterData.fishTypeConfig != null)
|
||
_accumScore += (long)paoBei * fish.monsterData.fishTypeConfig.fishScore;
|
||
|
||
if (spawnHitEffect && hitEffectPrefab != null && EffectPool.Instance != null)
|
||
EffectPool.Instance.Get(hitEffectPrefab, transform.position, Quaternion.identity, transform.parent);
|
||
|
||
// ⭐ 杀鱼(对齐钻头蟹 SpecialBulletMove.ProcessHit → 创建方 KillSmallFish):
|
||
// 优先由创建方回调处理(本地帧同步 CreateFishManager.Id_DieFish_FX → 小鱼立刻死、全房间同步);
|
||
// 创建方没给回调时才退回内置 KillFish 上报(reportKill 默认 false,避免和服务端重复结算)
|
||
if (onHitFish != null)
|
||
{
|
||
onHitFish(fish);
|
||
}
|
||
else if (reportKill)
|
||
{
|
||
ReportKill(fish);
|
||
}
|
||
|
||
// 命中表现(lua:EnemyMgr.OnHit;只在这里调一次,ReportKill 里不再重复调)
|
||
fish.OnHurt();
|
||
|
||
// ⚠ 这里绝不能回池 / 不能销毁:lua 火球弹是穿透的(一发能连打多条鱼),
|
||
// 一旦这里消失 = 表现为"子弹碰到鱼就没了"
|
||
if (debugLog)
|
||
Debug.Log($"[烈焰风暴火球弹] 击杀 {fish.guid}(fishenum={fish.fishenum}) 命中特效={spawnHitEffect} " +
|
||
$"杀鱼={(onHitFish != null ? "创建方回调(帧同步)" : (reportKill ? "内置KillFish上报" : "⚠ 无任何杀鱼路径"))} " +
|
||
$"(本发已打第 {_hitFish.Count} 条,反弹 {_bounceCount}/{bounceLimit})");
|
||
}
|
||
|
||
private bool IsWall(Collider2D c)
|
||
{
|
||
if (c == null) return false;
|
||
if (c.CompareTag("Wall")) return true;
|
||
return wallLayer.value != 0 && ((1 << c.gameObject.layer) & wallLayer.value) != 0;
|
||
}
|
||
|
||
private bool IsFish(Collider2D c)
|
||
{
|
||
if (c == null) return false;
|
||
if (c.CompareTag("Fish")) return true;
|
||
return fishLayer.value != 0 && ((1 << c.gameObject.layer) & fishLayer.value) != 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 法线反弹(照搬钻头 SpecialBulletMove.Bounce):
|
||
/// ClosestPoint 求近似法线 → Reflect;另处理 lua 的"正上/正下撞上下墙改斜角"特判;
|
||
/// 最后沿法线把弹体推回墙外,防止高速下一帧跨进墙内造成穿模/连触发。
|
||
/// </summary>
|
||
private void Bounce(Collider2D wall)
|
||
{
|
||
Vector3 worldPos = transform.position;
|
||
float keepZ = worldPos.z; // ⭐ 本项目子弹 z=-400(BullitMove.Init 设的),反弹后必须保留
|
||
Vector2 pos = worldPos;
|
||
Vector2 closest = wall.ClosestPoint(pos);
|
||
Vector2 normal = pos - closest;
|
||
if (normal.sqrMagnitude < 0.0001f)
|
||
normal = pos - (Vector2)wall.bounds.center;
|
||
if (normal.sqrMagnitude < 0.0001f) return;
|
||
normal.Normalize();
|
||
|
||
Vector2 reflected = Vector2.Reflect(_heading, normal);
|
||
if (reflected.sqrMagnitude < 0.0001f) return;
|
||
reflected.Normalize();
|
||
|
||
// lua 特判:撞的是上下墙(法线以 Y 为主)且当前是垂直飞行 → 改成 ±30° 斜弹
|
||
if (Mathf.Abs(normal.y) >= Mathf.Abs(normal.x))
|
||
{
|
||
float curAngle = Mathf.Atan2(_heading.y, _heading.x) * Mathf.Rad2Deg;
|
||
if (Mathf.Abs(Mathf.Abs(curAngle) - 90f) < 1f)
|
||
{
|
||
// lua:180 + (IsOuNumber(sourceUID) and 30 or -30) * flag,之后 flag 取反
|
||
// IsOuNumber = 偶数 → 偶数往 +30 偏,奇数往 -30 偏
|
||
float deflect = ((Mathf.Abs(sourceUID) % 2 == 0) ? verticalDeflectAngle : -verticalDeflectAngle) * _flag;
|
||
float target = curAngle + deflect;
|
||
reflected = new Vector2(Mathf.Cos(target * Mathf.Deg2Rad), Mathf.Sin(target * Mathf.Deg2Rad));
|
||
_flag = -_flag;
|
||
Debug.Log($"[烈焰风暴火球弹] 垂直撞上下墙 → 斜弹 {target:0.0}°(flag={_flag})");
|
||
}
|
||
}
|
||
|
||
SetHeading(reflected);
|
||
|
||
var selfCol = GetComponent<Collider2D>();
|
||
float radius = selfCol != null
|
||
? Mathf.Max(selfCol.bounds.extents.x, selfCol.bounds.extents.y)
|
||
: 0.5f;
|
||
float margin = moveSpeed * Time.deltaTime + 0.05f;
|
||
Vector2 pushed = closest + normal * (radius + margin);
|
||
transform.position = new Vector3(pushed.x, pushed.y, keepZ);
|
||
}
|
||
|
||
/// <summary>设置前进方向:世界 Z 轴旋转叠在基准姿态上</summary>
|
||
private void SetHeading(Vector2 heading)
|
||
{
|
||
_heading = heading.sqrMagnitude < 0.0001f ? Vector2.up : heading.normalized;
|
||
float z = Mathf.Atan2(_heading.y, _heading.x) * Mathf.Rad2Deg - 90f;
|
||
transform.rotation = Quaternion.Euler(0f, 0f, z) * _baseRot;
|
||
}
|
||
|
||
/// <summary>lua:反弹时 isSelf → ShakeScreen(0.5) + 播 PW_FlameStrike_WheelFly(1 秒内只播一次)</summary>
|
||
private void PlayBounceFeedback()
|
||
{
|
||
if (!bounceFeedback || !isSelf) return;
|
||
|
||
if (GameManager.Instance != null && GameManager.Instance.cameraShake != null)
|
||
GameManager.Instance.cameraShake.ShakePresets.ShortShake2D(
|
||
bounceShakeStrength, bounceShakeStrength, 25f, 4);
|
||
|
||
if (Time.unscaledTime - _lastBounceSoundTime >= bounceSoundInterval)
|
||
{
|
||
AudioManager.Instance.PlayerFXByName("PW_FlameStrike_WheelFly");
|
||
_lastBounceSoundTime = Time.unscaledTime;
|
||
}
|
||
}
|
||
|
||
// ================================================================ 击杀上报
|
||
/// <summary>
|
||
/// 上报命中(与普通子弹 BullitMove.AttackFish 完全同一套协议):
|
||
/// 只有击杀者本机才发 KillFish;机器人由房主代发。
|
||
/// </summary>
|
||
private void ReportKill(MonsterContral fish)
|
||
{
|
||
if (!reportKill || fish == null || fish.isDead) return;
|
||
|
||
var gm = GameManager.Instance;
|
||
if (gm == null) return;
|
||
|
||
string selfId = gm.user != null ? gm.user.id : null;
|
||
int paoBeiInt = paoBei > 0 ? paoBei : 1;
|
||
string fishNumber = fish.monsterData != null && fish.monsterData.fishTypeConfig != null
|
||
? fish.monsterData.fishTypeConfig.fishNumber
|
||
: null;
|
||
|
||
bool sent = false;
|
||
if (!string.IsNullOrEmpty(killerUserId) && killerUserId == selfId)
|
||
{
|
||
// 真人玩家击杀:击杀者本机上报(包括火球弹替击杀者继续收鱼)
|
||
var room = gm.gameData != null ? gm.gameData.currRoomData : null;
|
||
if (room != null)
|
||
{
|
||
WebSocketSendList.KillFish(room.roomId, killerUserId, fishNumber, fish.guid,
|
||
paoBeiInt, bulletId, "63215f0afa3f5b25");
|
||
sent = true;
|
||
}
|
||
}
|
||
else if (!string.IsNullOrEmpty(killerUserId) && killerUserId.StartsWith("bot_")
|
||
&& SitPosData.roomOwnerSit != null && SitPosData.roomOwnerSit.user == selfId)
|
||
{
|
||
// 机器人击杀:只有房主客户端代发
|
||
var room = gm.gameData != null ? gm.gameData.currRoomData : null;
|
||
if (room != null)
|
||
{
|
||
WebSocketSendList.BotKillFish(room.roomId, killerUserId, fishNumber, fish.guid,
|
||
paoBeiInt, bulletId);
|
||
sent = true;
|
||
}
|
||
}
|
||
|
||
// 注意:命中表现 fish.OnHurt() 由 ProcessFishHit 统一调用,这里不再重复调
|
||
if (sent)
|
||
Debug.Log($"[烈焰风暴火球弹] 上报击杀 fish={fish.guid} fishNumber={fishNumber} 炮倍={paoBeiInt} 子弹={bulletId}");
|
||
}
|
||
|
||
// ================================================================ 收尾
|
||
/// <summary>
|
||
/// lua Disappear():先炸一圈小鱼 → PW_FlameStrike_WheelExplosion 音效 + sprite 隐藏 + 切 Explosion 特效
|
||
/// → 停留 explosionHoldTime → 回收(默认 Destroy)。
|
||
/// ⭐ 收尾触发点只有两处:反弹次数打满 / lifeTime 兜底超时 —— 日志里会带调用栈,方便排查
|
||
/// "子弹为什么消失"(碰鱼是**绝不**收尾的)。
|
||
/// </summary>
|
||
private void Finish()
|
||
{
|
||
if (_finished) return;
|
||
|
||
if (debugLog)
|
||
Debug.Log($"[烈焰风暴火球弹] 进入收尾:已飞 {_time:0.00}/{lifeTime}s、反弹 {_bounceCount}/{bounceLimit}、" +
|
||
$"已杀 {_hitFish.Count} 条、pos={transform.position}\n调用来源:{new System.Diagnostics.StackTrace(true)}");
|
||
|
||
// ⭐ ① 收尾爆炸先炸一圈小鱼(对齐钻头蟹 SpecialBulletMove.Explode):
|
||
// 必须在 _finished 置 true 之前调用 —— ProcessFishHit 开头会检查 _finished
|
||
KillFishInExplosionRange();
|
||
|
||
_finished = true;
|
||
_heading = Vector2.zero;
|
||
|
||
if (isSelf) AudioManager.Instance.PlayerFXByName("PW_FlameStrike_WheelExplosion");
|
||
|
||
if (_sprite != null) _sprite.gameObject.SetActive(false);
|
||
ShowExplosion(true);
|
||
|
||
// ② 爆炸特效播完后(Release)回收;Release 里会回调 onFinished 交给创建方(Boss 统计)
|
||
if (explosionHoldTime <= 0f)
|
||
{
|
||
Release();
|
||
return;
|
||
}
|
||
_releaseTween = DOVirtual.DelayedCall(explosionHoldTime, Release);
|
||
}
|
||
|
||
// ================================================================ 收尾爆炸范围杀鱼
|
||
/// <summary>
|
||
/// 收尾爆炸炸一圈小鱼(对齐钻头蟹 SpecialBulletMove.Explode):
|
||
/// 以爆炸点为中心 OverlapCircle 扫普通鱼 → 复用 ProcessFishHit(同一条鱼只算一次、累计分值、上报击杀)。
|
||
/// ⭐ 必须在 Finish() 里、_finished 置 true 之前调用。
|
||
/// </summary>
|
||
private void KillFishInExplosionRange()
|
||
{
|
||
if (!killFishOnFinal || explosionRadius <= 0f) return;
|
||
|
||
int n = Physics2D.OverlapCircleNonAlloc(transform.position, explosionRadius, _overlapBuf);
|
||
int killed = 0;
|
||
for (int i = 0; i < n; i++)
|
||
{
|
||
var c = _overlapBuf[i];
|
||
if (c == null) continue;
|
||
var fish = c.GetComponentInParent<MonsterContral>();
|
||
if (fish == null || fish.isDead) continue;
|
||
if (fish.fishenum != FishTypeEnum.普通鱼) continue;
|
||
if (_hitFish.Contains(fish.guid)) continue; // 已经打过的跳过(ProcessFishHit 也会拦,这里提前省一点)
|
||
ProcessFishHit(fish, explosionSpawnHitEffect);
|
||
killed++;
|
||
}
|
||
|
||
if (debugLog)
|
||
Debug.Log($"[烈焰风暴火球弹] 收尾爆炸范围杀鱼:半径={explosionRadius} 扫到={n} 新杀={killed}");
|
||
}
|
||
|
||
// ================================================================ 收尾爆奖
|
||
/// <summary>
|
||
/// 收尾爆奖:在击杀者座位上方生成 BJEndUI 结算面板。
|
||
/// score:jackpotScore >= 0 用注入值(建议 Boss 从后端 gold 注入);否则用本发累计击杀分值(die_Score) 兜底。
|
||
/// beishu:jackpotBeishu > 0 用之;否则用炮倍 paoBei。
|
||
/// isMine:直接用 isSelf(自己击杀=金币样式,他人=银币样式)。
|
||
/// ⭐ 面板是独立 GameObject(挂在座位/bjEffectRoot 下,自带 autoDestroyDelay),子弹回收(Destroy)不影响它。
|
||
/// </summary>
|
||
private void SpawnJackpot()
|
||
{
|
||
if (bjEndUIPrefab == null)
|
||
{
|
||
if (debugLog) Debug.Log("[烈焰风暴火球弹] 收尾爆奖跳过:bjEndUIPrefab 未赋值");
|
||
return;
|
||
}
|
||
|
||
long score = jackpotScore >= 0 ? jackpotScore : _accumScore;
|
||
int beishu = jackpotBeishu > 0 ? jackpotBeishu : (paoBei > 0 ? paoBei : 1);
|
||
|
||
// 定位:击杀者座位上方(与其它爆奖一致);找不到座位回退到子弹当前位置
|
||
Vector3 uiPos = GetKillerSeatPosition() + Vector3.up * bjEndUIOffsetY;
|
||
|
||
var endUI = Instantiate(bjEndUIPrefab, uiPos, Quaternion.identity, GetKillerSeatParent());
|
||
var bj = endUI.GetComponent<BJEndUI>();
|
||
if (bj != null)
|
||
{
|
||
bj.Init(beishu, score, isSelf);
|
||
if (debugLog)
|
||
Debug.Log($"[烈焰风暴火球弹] 收尾爆奖 BJEndUI:score={score} beishu={beishu} isSelf={isSelf} pos={uiPos}");
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[烈焰风暴火球弹] bjEndUIPrefab 上找不到 BJEndUI 组件");
|
||
}
|
||
}
|
||
|
||
/// <summary>击杀者座位世界坐标(找不到回退子弹当前位置)</summary>
|
||
private Vector3 GetKillerSeatPosition()
|
||
{
|
||
var seat = GetKillerSeat();
|
||
return seat != null ? seat.position : transform.position;
|
||
}
|
||
|
||
/// <summary>击杀者座位的 Transform(作为 BJEndUI 父物体,与其它爆奖一致);
|
||
/// 找不到回退 bjEffectRoot;都没有则 null(挂世界根)</summary>
|
||
private Transform GetKillerSeatParent()
|
||
{
|
||
var seat = GetKillerSeat();
|
||
if (seat != null) return seat;
|
||
return CreateFishManager.Instance != null ? CreateFishManager.Instance.bjEffectRoot : null;
|
||
}
|
||
|
||
/// <summary>按 killerUserId 在座位列表里找击杀者座位</summary>
|
||
private Transform GetKillerSeat()
|
||
{
|
||
if (string.IsNullOrEmpty(killerUserId)) return null;
|
||
var mgr = CreateFishManager.Instance;
|
||
if (mgr == null || mgr.currGameType == null || mgr.currGameType.list_Sits == null) return null;
|
||
var sit = mgr.currGameType.list_Sits.Find(s => s.user == killerUserId);
|
||
return sit != null ? sit.transform : null;
|
||
}
|
||
|
||
/// <summary>回收:默认自管理(Destroy),不还给 BulletPool</summary>
|
||
private void Release()
|
||
{
|
||
_releaseTween = null;
|
||
if (this == null) return;
|
||
|
||
// ⭐ 先通知创建方(Boss 靠这个统计"所有火球弹是否都已收尾"→ 全部结束才统一出**一次**爆奖面板)
|
||
onFinished?.Invoke();
|
||
onFinished = null;
|
||
|
||
// 兜底:没接回调、且显式打开开关时,才由本发子弹自己出面板(默认关,避免 4 发叠 4 个面板)
|
||
if (spawnJackpotOnFinish) SpawnJackpot();
|
||
|
||
// ⭐ 默认直接销毁,不走 BulletPool:火球弹不属于任何座位(userid=null),
|
||
// 一旦进了池的"活跃子弹"表,换炮台时的 BulletPool.ClearActive() 会把正在飞的
|
||
// 火球弹一并 Destroy —— 那正是"子弹凭空消失"的经典来源。自管理后与池彻底解耦。
|
||
if (selfManaged || BulletPool.Instance == null)
|
||
Destroy(gameObject);
|
||
else
|
||
BulletPool.Instance.Return(gameObject);
|
||
}
|
||
|
||
private void KillReleaseTween()
|
||
{
|
||
if (_releaseTween != null && _releaseTween.IsActive()) _releaseTween.Kill();
|
||
_releaseTween = null;
|
||
}
|
||
|
||
// ================================================================ 节点
|
||
private void ResolveNodes()
|
||
{
|
||
_sprite = FindDeep(transform, spriteNodeName);
|
||
_glow = FindDeepGo(transform, glowNodeName);
|
||
_explosion = FindDeepGo(transform, explosionNodeName);
|
||
|
||
if (_sprite == null)
|
||
Debug.LogWarning($"[烈焰风暴火球弹] 找不到 sprite 节点「{spriteNodeName}」,自转跳过");
|
||
if (_glow == null && _explosion == null)
|
||
Debug.LogWarning("[烈焰风暴火球弹] 找不到 Glow/Explosion 节点,收尾特效切换跳过");
|
||
}
|
||
|
||
/// <summary>飞行中:Glow 亮、Explosion 灭;收尾:反过来</summary>
|
||
private void ShowExplosion(bool exploded)
|
||
{
|
||
if (_glow != null) _glow.SetActive(!exploded);
|
||
if (_explosion != null) _explosion.SetActive(exploded);
|
||
}
|
||
|
||
private static Transform FindDeep(Transform root, string name)
|
||
{
|
||
if (root == null || string.IsNullOrEmpty(name)) return null;
|
||
if (string.Equals(root.name, name, StringComparison.OrdinalIgnoreCase)) return root;
|
||
for (int i = 0; i < root.childCount; i++)
|
||
{
|
||
var r = FindDeep(root.GetChild(i), name);
|
||
if (r != null) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static GameObject FindDeepGo(Transform root, string name)
|
||
{
|
||
var t = FindDeep(root, name);
|
||
return t != null ? t.gameObject : null;
|
||
}
|
||
}
|