Files
5.24BuYu/Assets/MyGame/aa/Game/XJMH/Boss/LieYanFengBao/FlameRoundBullet.cs
T
2026-09-30 22:24:11 +08:00

725 lines
36 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 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;
}
}