Files
5.24BuYu/Assets/MyGame/aa/Game/XJMH/Boss/BossAppearTipXJMH.cs
T
2026-10-03 17:50:59 +08:00

497 lines
23 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.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UI;
/// <summary>
/// XJMH Boss 出场播报(把 lua 的 EnemyAppearEffect:BossAppear 搬到 C#)。
///
/// ── 原版流程(lua)──
/// Boss OnCreate(= C# 的 MonsterControl_BossBase.Init)→ CreateAppearTip():
/// GC.EffectMgr.CreateEffect("EnemyAppearEffect", "PW_Effect_Boss_Appear",
/// {prefab = self.enemyId, isBoss = true});
/// 各 Boss 再自己补出场语音(TextScorpion / TextShark / TextDragon ...),
/// 而 102/112/113/114/115 这几个没有专属类,走基类 Enemy:CreateAppearTip → 播 AppearTip。
/// 播报本体(pw_effect/PW_Effect.txt → EnemyAppearEffect:BossAppear):
/// Image.sprite = config.appearTip.image ← 专属播报图(handbook/tsfj_*.png)
/// Mul.text = config.appearTip.mul ← "Boss" / "大Boss" / "特殊奖励" / "最高奖励xxxx倍"
/// Name.text = config.appearTip.name ← "xxx入场"
/// MotionNode: (-822,100) →(-568,100) 0.2s EOutSine → 显示粒子 Effect
/// → 停 2.5s → 回 (-822,100) 0.2s EInSine → 销毁
///
/// ── 本项目三个坑(都在这段逻辑里处理掉)──
/// 1. 预制体 PW_Effect_Boss_Appear 的 Mul / Name 在原工程挂的是自定义 Text 组件,该脚本没导入本
/// 项目(历史提交 f93ed7bbc 已把这两个组件删掉)→ 这里运行时补 TextMeshProUGUI。
/// ⚠️ IL2CPP 铁律:运行时 AddComponent 的文本必须显式设 font,否则不显示。
/// 2. 预制体根节点自带 (-822,100) 的 anchoredPosition,而 lua 只动 MotionNode →
/// 生成后必须先把根节点归零,否则位移叠加会跑到屏幕外看不见。
/// 3. 播报图按「专属播报图 → 图鉴图」的顺序尝试加载:部分 Boss 美术还没出专属播报图。
/// 4. 整条播报是纯展示层,所有 Graphic 都要 raycastTarget=false,否则会挡住打鱼点击。
/// </summary>
public class BossAppearTipXJMH : MonoBehaviour
{
// ==================== 预制体节点名(Art/XJMH/pw/pw_prefab/game/effect/PW_Effect_Boss_Appear.prefab)====================
private const string NodeMotion = "MotionNode"; // 参与滑入/滑出的节点
private const string NodeImage = "Image"; // 播报图
private const string NodeFrame = "Frame"; // 底框(lua 不动,保留预制体自带)
private const string NodeName = "Name"; // "xxx入场"
private const string NodeMul = "Mul"; // "Boss"/"大Boss"/"特殊奖励"
private const string NodeEffect = "Effect"; // 粒子(滑入结束才显示)
// ==================== 动画参数(lua EnemyAppearEffect:BossAppear 原值,画布按 1136x640 设计)====================
// lua: motionNode.localPosition = (-822,100) → localMoveTo(-568,100,0.2,EOutSine)
// → delay 2.5 → localMoveTo(-822,100,0.2,EInSine) → 销毁
// 注意:-568 正好是 1136 宽画布的左边缘(-1136/2),也就是"播报贴屏幕左边、滑出后完全移出屏幕"。
private const float LuaCanvasWidth = 1136f;
private const float LuaStartX = -822f;
private const float LuaEndX = -568f;
private const float LuaY = 100f;
private const float TipSlideInTime = 0.2f;
private const float TipHoldTime = 2.5f; // 停留
private const float TipSlideOutTime = 0.2f;
/// <summary>true=按实际画布宽度等比换算(终点贴左边缘,和 lua 的相对位置一致);
/// false=直接用 lua 原值。位置不对时可用 TipStartXOverride / TipEndXOverride 手调。</summary>
public static bool AdaptToCanvasWidth = true;
/// <summary>手动覆盖起点 X(AdaptToCanvasWidth=false 时才用)</summary>
public static float TipStartXOverride = LuaStartX;
/// <summary>手动覆盖终点 X(AdaptToCanvasWidth=false 时才用)</summary>
public static float TipEndXOverride = LuaEndX;
// ==================== 文字样式(取自被删掉的原始自定义 Text 组件)====================
private static readonly Color TipTextColor = new Color(1f, 0.7764706f, 0f, 1f); // 金色
private const float TipMulFontSize = 23f;
private const float TipNameFontSize = 27f;
// ==================== 播报图目录 ====================
private const string HandbookDir = "Assets/Art/XJMH/pw/pw_sprite/handbook/";
// ==================== 运行状态 ====================
private RectTransform _motion;
private Image _image;
private TextMeshProUGUI _nameText;
private TextMeshProUGUI _mulText;
private Transform _effect;
private Sequence _seq;
private AsyncOperationHandle<Sprite> _spriteHandle;
private bool _hasSpriteHandle;
/// <summary>当前是否有播报在播(同一时刻只留一条,避免 Boss 连刷时叠图)</summary>
public static bool IsPlaying { get; private set; }
// ================================================================
// 配置表(对齐 lua pw_config/PW_Config.txt → PW_Config.Enemy[].appearTip)
// ================================================================
/// <summary>单个 Boss 的播报配置</summary>
[Serializable]
public class AppearTipData
{
/// <summary>项目鱼表编号(tbfishresources.json 的 fishNumber,= C# MonsterContral.fishTypeID)。
/// 0 表示只按 bossName 匹配(该 Boss 还没进鱼表)</summary>
public int fishTypeId;
/// <summary>中文名(= 预制体 MonsterControl_BossBase.bossName,也等于 lua PW_Config.Enemy[].name)</summary>
public string bossName;
/// <summary>lua type(PW_Config.Enemy[].type),仅注释用</summary>
public int luaType;
/// <summary>Mul 文本</summary>
public string mul;
/// <summary>Name 文本</summary>
public string title;
/// <summary>播报图地址,按顺序尝试(专用播报图 → 图鉴图兜底)</summary>
public string[] imageAddresses;
/// <summary>出场语音(可空:Boss 自己在 OnEnable 已经播过的不重复播)</summary>
public string soundName;
}
private static readonly AppearTipData[] Tips =
{
// ── 真 Boss:lua 有专属类,出场语音由各自的 *_BOSS.cs OnEnable 负责(这里不重复播)──
new AppearTipData
{
fishTypeId = 12026, bossName = "爆裂金鲨", luaType = 1000, mul = "Boss", title = "爆裂金鲨入场",
imageAddresses = new[] { HandbookDir + "tsfj_njs.png", HandbookDir + "爆裂金鲨.png" },
},
new AppearTipData
{
fishTypeId = 12028, bossName = "机械蝎", luaType = 3000, mul = "大Boss", title = "机械蝎入场",
// ⚠️ 专属播报图 tsfj_xiez.png 项目里还没有 → 自动回退图鉴图 机械蝎.png
imageAddresses = new[] { HandbookDir + "tsfj_xiez.png", HandbookDir + "机械蝎.png" },
},
new AppearTipData
{
fishTypeId = 12029, bossName = "星际巡航", luaType = 4000, mul = "大Boss", title = "星际巡航入场",
imageAddresses = new[] { HandbookDir + "星际巡航.png", HandbookDir + "tsfj_xjxh.png" },
},
new AppearTipData
{
fishTypeId = 12030, bossName = "破坏球", luaType = 5000, mul = "Boss", title = "破坏球入场",
imageAddresses = new[] { HandbookDir + "破坏球.png", HandbookDir + "tsfj_phq.png" },
},
new AppearTipData
{
fishTypeId = 12031, bossName = "幸运轮", luaType = 6000, mul = "Boss", title = "幸运轮",
// 幸运轮.png 本身就是专属播报图(与 tsfj_xyl.png 同一张)
imageAddresses = new[] { HandbookDir + "幸运轮.png", HandbookDir + "tsfj_xyl.png" },
},
// ── 特殊玩法 / 免费道具:lua 无专属类 → 走基类,出场音是 AppearTip ──
new AppearTipData
{
fishTypeId = 12034, bossName = "烈焰风暴", luaType = 115, mul = "特殊奖励", title = "烈焰风暴入场",
imageAddresses = new[] { HandbookDir + "烈焰风暴.png" }, soundName = "PW_AppearTip",
},
new AppearTipData
{
fishTypeId = 12022, bossName = "幸运飞碟", luaType = 114, mul = "最高奖励1500倍", title = "幸运飞碟入场",
imageAddresses = new[] { HandbookDir + "幸运飞碟.png" }, soundName = "PW_AppearTip",
},
new AppearTipData
{
fishTypeId = 12023, bossName = "小丑扑克", luaType = 113, mul = "最高奖励1600倍", title = "小丑扑克入场",
imageAddresses = new[] { HandbookDir + "小丑扑克.png" }, soundName = "PW_AppearTip",
},
new AppearTipData
{
fishTypeId = 12024, bossName = "黑洞飞船", luaType = 112, mul = "特殊奖励", title = "黑洞飞船入场",
imageAddresses = new[] { HandbookDir + "黑洞飞船.png" }, soundName = "PW_AppearTip",
},
new AppearTipData
{
fishTypeId = 12025, bossName = "弹药箱", luaType = 102, mul = "特殊奖励", title = "弹药箱入场",
imageAddresses = new[] { HandbookDir + "弹药箱.png" }, soundName = "PW_AppearTip",
},
// ── 还没进鱼表 / 项目里没有 Boss 预制体,仅按中文名兜底(保留配置,接上即生效)──
new AppearTipData
{
fishTypeId = 0, bossName = "女王龙", luaType = 2000, mul = "大Boss", title = "女王龙入场",
imageAddresses = new[] { HandbookDir + "tsfj_nfl.png", HandbookDir + "女王龙.png" },
},
};
private static Dictionary<int, AppearTipData> _byFishId;
private static Dictionary<string, AppearTipData> _byNameOnly;
private static void EnsureIndex()
{
if (_byFishId != null) return;
_byFishId = new Dictionary<int, AppearTipData>();
// _byNameOnly 只收 fishTypeId<=0 的纯名字行:避免某个预制体的 bossName 被误填
// (例如 7001 存钱罐的 bossName 被填成"小丑扑克")时,在按名字兜底这条路上
// 命中 12023(小丑扑克)的行 —— 有编号的行只走编号匹配。
_byNameOnly = new Dictionary<string, AppearTipData>();
foreach (var t in Tips)
{
if (t.fishTypeId > 0)
{
if (!_byFishId.ContainsKey(t.fishTypeId)) _byFishId.Add(t.fishTypeId, t);
}
else if (!string.IsNullOrEmpty(t.bossName))
{
if (!_byNameOnly.ContainsKey(t.bossName)) _byNameOnly.Add(t.bossName, t);
}
}
}
/// <summary>查配置:先按项目鱼表编号精确命中,再按中文名兜底</summary>
public static bool TryGetData(int fishTypeId, string bossName, out AppearTipData data)
{
EnsureIndex();
if (fishTypeId > 0 && _byFishId.TryGetValue(fishTypeId, out data)) return true;
if (!string.IsNullOrEmpty(bossName) && _byNameOnly.TryGetValue(bossName, out data)) return true;
data = null;
return false;
}
/// <summary>该 Boss 是否有出场播报配置(供调用方决定要不要退回旧播报)</summary>
public static bool HasData(int fishTypeId, string bossName) => TryGetData(fishTypeId, bossName, out _);
// ================================================================
// 对外入口
// ================================================================
/// <summary>
/// 播放一次出场播报。
/// </summary>
/// <param name="prefab">播报预制体(PW_Effect_Boss_Appear,取自面板的 boardCast_Boss 字段)</param>
/// <param name="parent">UI 父节点(子面板层,随 UI 卸载自动清理)</param>
/// <param name="fishTypeId">C# MonsterContral.fishTypeID(项目鱼表编号)</param>
/// <param name="bossName">Boss 中文名(MonsterControl_BossBase.bossName)</param>
/// <param name="font">文本字体(可空 → TMP_Settings.defaultFontAsset = SourceHanSans SDF)</param>
/// <returns>
/// true = 本系统已接管(无论实际有没有播:没配播报的 Boss、或上一条还在播都会返回 true,
/// 这样调用方就不会再退回旧播报导致两条叠着显示);
/// false = 面板上的预制体不是播报版式(缺 MotionNode),调用方可以退回旧播报。
/// </returns>
public static bool TryPlay(GameObject prefab, Transform parent, int fishTypeId, string bossName,
TMP_FontAsset font = null)
{
if (prefab == null || parent == null || !Application.isPlaying) return false;
// 版式判定:播报预制体必须有 MotionNode;否则说明面板上挂的还是旧播报预制体
if (prefab.transform.Find(NodeMotion) == null)
{
Debug.LogWarning($"[BossAppearTip] 预制体 {prefab.name} 里没有 {NodeMotion} 节点," +
"不是 PW_Effect_Boss_Appear 版式,本次不播(可用旧播报兜底)");
return false;
}
if (!TryGetData(fishTypeId, bossName, out var data))
{
// 没配播报的 Boss(如存钱罐 7001 / 机甲雷霆 Z_4030、或 bossName 填错的预制体)
// 直接不出场播报,绝不退回旧播报——否则会拿 bossName 去乱套图鉴图
Debug.Log($"[BossAppearTip] 未配置出场播报,跳过:fishTypeId={fishTypeId} bossName={bossName}");
return true;
}
if (IsPlaying)
{
Debug.Log($"[BossAppearTip] 上一条出场播报还没播完,本次跳过:{bossName}");
return true;
}
var go = Instantiate(prefab, parent);
go.name = "BossAppearTip_" + (string.IsNullOrEmpty(bossName) ? fishTypeId.ToString() : bossName);
var tip = go.GetComponent<BossAppearTipXJMH>();
if (tip == null) tip = go.AddComponent<BossAppearTipXJMH>();
tip.Play(data, font);
return true;
}
// ================================================================
// 播放流程
// ================================================================
/// <summary>本次播放使用的字体(Play 传入,可空 → 用 TMP 默认字体)</summary>
private TMP_FontAsset _tipFont;
private void Play(AppearTipData data, TMP_FontAsset font)
{
IsPlaying = true;
_tipFont = font; // 供 ResolveNodes → EnsureTmpText 使用
// ⭐ 坑2:根节点预制体自带 (-822,100),lua 只动 MotionNode → 先把根归零
var rootRt = transform as RectTransform;
if (rootRt != null) rootRt.anchoredPosition = Vector2.zero;
ResolveNodes();
// ---- 文本(坑1:运行时补 TMP)----
if (_mulText != null && !string.IsNullOrEmpty(data.mul)) _mulText.text = data.mul;
if (_nameText != null && !string.IsNullOrEmpty(data.title)) _nameText.text = data.title;
// ---- 播报图(坑3:按顺序尝试,第一个加载到就用)----
LoadSprite(data.imageAddresses);
// ---- 粒子:滑入结束才显示(lua: frameEffect:SetActive(false) → 滑入结束 SetActive(true))----
if (_effect != null) _effect.gameObject.SetActive(false);
// ---- 动画(lua EnemyAppearEffect:BossAppear)----
if (_motion == null)
{
Debug.LogWarning($"[BossAppearTip] 找不到 {NodeMotion},播报直接销毁");
DestroyTip(0f);
return;
}
_seq?.Kill();
_seq = DOTween.Sequence();
ComputeLayout(out float startX, out float endX);
_motion.anchoredPosition = new Vector2(startX, LuaY);
_seq.Append(_motion.DOAnchorPos(new Vector2(endX, LuaY), TipSlideInTime).SetEase(Ease.OutSine));
_seq.AppendCallback(() => { if (_effect != null) _effect.gameObject.SetActive(true); });
_seq.AppendInterval(TipHoldTime);
_seq.Append(_motion.DOAnchorPos(new Vector2(startX, LuaY), TipSlideOutTime).SetEase(Ease.InSine));
_seq.OnComplete(() => DestroyTip(0f));
// ---- 出场语音 ----
if (!string.IsNullOrEmpty(data.soundName))
AudioManager.Instance.PlayerFXByName(data.soundName);
Debug.Log($"[BossAppearTip] 播报 {data.title}({data.mul})fishTypeId={data.fishTypeId} " +
$"起点/终点=({startX:F0},{LuaY:F0})→({endX:F0},{LuaY:F0})");
}
/// <summary>
/// 算滑入起止 X。
/// lua 的 -822/-568 是按 1136x640 画布设计的,-568 正好是画布左边缘;
/// 本项目画布更宽(UIScene 参考分辨率 1624x750),直接照搬播报会飘在屏幕中间偏左,
/// 所以默认按「画布宽度 / 1136」等比换算,保证终点始终贴屏幕左边缘。
/// </summary>
private void ComputeLayout(out float startX, out float endX)
{
if (!AdaptToCanvasWidth)
{
startX = TipStartXOverride;
endX = TipEndXOverride;
return;
}
float canvasWidth = LuaCanvasWidth;
// 从父节点往上找根 Canvas(播报自己的根节点上挂了 Canvas,不能从自己往上找)
if (transform.parent != null)
{
var canvas = transform.parent.GetComponentInParent<Canvas>();
var root = canvas != null ? canvas.rootCanvas : null;
var rootRt = root != null ? root.transform as RectTransform : null;
if (rootRt != null && rootRt.rect.width > 1f) canvasWidth = rootRt.rect.width;
}
float scale = canvasWidth / LuaCanvasWidth;
endX = -canvasWidth * 0.5f;
startX = endX - (LuaEndX - LuaStartX) * scale; // 滑入距离 254 等比换算
}
/// <summary>取节点引用;Mul / Name 运行时补 TextMeshProUGUI(原自定义 Text 组件未导入本项目)</summary>
private void ResolveNodes()
{
Transform motion = transform.Find(NodeMotion);
if (motion != null)
{
// MotionNode 的 anchor 是 (0.5,0.5),anchoredPosition 即 lua 里的 localPosition
_motion = motion as RectTransform;
var imageTf = motion.Find(NodeImage);
if (imageTf != null)
{
_image = imageTf.GetComponent<Image>();
if (_image != null)
{
_image.raycastTarget = false; // 坑4:不挡打鱼点击
_image.preserveAspect = true; // 各 Boss 播报图比例不同,避免拉伸变形
// 预制体自带的示例图是"女王龙"的播报图,先关掉,等目标图加载好再显示,
// 否则别的 Boss 出场会闪一下女王龙。
_image.enabled = false;
}
}
// Frame(底框):lua 不动它(用预制体自带),只关掉射线检测
var frameTf = motion.Find(NodeFrame);
var frameImg = frameTf != null ? frameTf.GetComponent<Image>() : null;
if (frameImg != null) frameImg.raycastTarget = false;
_effect = motion.Find(NodeEffect);
_nameText = EnsureTmpText(motion, NodeName, TipNameFontSize);
_mulText = EnsureTmpText(motion, NodeMul, TipMulFontSize);
}
else
{
Debug.LogError($"[BossAppearTip] 预制体里找不到 {NodeMotion} 节点");
}
}
/// <summary>
/// 给节点补 TMP 文本(已有就复用)。
/// ⚠️ Mul 节点上挂了一个 Canvas(原工程为粒子/文字排序用),保留不动,TMP 直接加在该节点上。
/// </summary>
private TextMeshProUGUI EnsureTmpText(Transform parent, string nodeName, float fontSize)
{
Transform tf = parent.Find(nodeName);
if (tf == null)
{
Debug.LogWarning($"[BossAppearTip] 预制体里找不到 {nodeName} 节点,文本不显示");
return null;
}
var tmp = tf.GetComponent<TextMeshProUGUI>();
if (tmp == null)
{
// 兜底:万一节点上有残留的 UGUI Text,先关掉避免叠字
var legacy = tf.GetComponent<Text>();
if (legacy != null) legacy.enabled = false;
tmp = tf.gameObject.AddComponent<TextMeshProUGUI>();
}
// ⭐ 铁律:运行时 AddComponent 的文本必须显式设字体,否则不显示
tmp.font = _tipFont != null ? _tipFont : TMP_Settings.defaultFontAsset;
tmp.fontSize = fontSize;
tmp.fontStyle = FontStyles.Bold; // 原组件 m_FontStyle=2
tmp.alignment = TextAlignmentOptions.Center; // 原组件 m_Alignment=4
tmp.color = TipTextColor;
tmp.raycastTarget = false;
return tmp;
}
/// <summary>按顺序尝试加载播报图(Addressables 地址),第一个成功就用</summary>
private async void LoadSprite(string[] addresses)
{
if (_image == null || addresses == null || addresses.Length == 0) return;
for (int i = 0; i < addresses.Length; i++)
{
string address = addresses[i];
if (string.IsNullOrEmpty(address)) continue;
// 地址没注册时 LoadAssetAsync 可能直接抛 InvalidKeyException,所以整段兜起来
AsyncOperationHandle<Sprite> handle = default;
Sprite sprite = null;
try
{
handle = Addressables.LoadAssetAsync<Sprite>(address);
sprite = await handle.Task;
}
catch (Exception e)
{
Debug.LogWarning($"[BossAppearTip] 播报图加载失败,试下一张:{address} → {e.Message}");
if (handle.IsValid()) Addressables.Release(handle);
continue;
}
if (this == null || _image == null)
{
if (handle.IsValid()) Addressables.Release(handle);
return;
}
if (handle.Status == AsyncOperationStatus.Succeeded && sprite != null)
{
_image.sprite = sprite;
_image.enabled = true; // 图到位才显示(避免闪预制体自带的示例图)
_spriteHandle = handle;
_hasSpriteHandle = true;
return;
}
if (handle.IsValid()) Addressables.Release(handle);
Debug.LogWarning($"[BossAppearTip] 播报图不存在,试下一张:{address}");
}
Debug.LogWarning($"[BossAppearTip] 播报图全部加载失败:{string.Join(" / ", addresses)}");
}
private void DestroyTip(float delay)
{
if (delay > 0f) DOVirtual.DelayedCall(delay, () => { if (this != null) Destroy(gameObject); });
else Destroy(gameObject);
}
private void OnDestroy()
{
IsPlaying = false;
_seq?.Kill();
_seq = null;
if (_hasSpriteHandle && _spriteHandle.IsValid()) Addressables.Release(_spriteHandle);
_hasSpriteHandle = false;
_motion = null; _image = null;
_nameText = null; _mulText = null; _effect = null;
}
}