#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Text; using Coffee.UIExtensions; using TMPro; using UnityEditor; using UnityEngine; /// /// 烈焰风暴「结算面板(Settle)」/「奖励框(AwardFrame)」的 UI 化变体生成器。 /// /// 背景:XJMH 的爆奖面板都是「Art 源预制体 → MyGame 变体」两层。Art 源是给 3D 场景用的, /// 直接当 UI 用会有三个问题:粒子不在 Canvas 里渲染、卡面数字没接、没有 BJEndUI 写分数。 /// 同门 Boss(铁拳 / 伽马风暴 / 破坏球 / 幸运轮 …)都在 /// Assets/MyGame/aa/Game/XJMH/Boss/BJ/BJEnd 下留了做好的变体,本脚本就是照抄它们的做法。 /// /// 从已有变体(PW_Effect_Settle_IronFist_Self_XingJiXunHang)反解出来的加工步骤: /// 1. 所有 ParticleSystemRenderer:m_Enabled = 0(改由 UIParticle 渲染) /// 2. 所有 ParticleSystemRenderer:m_SortingLayer = 0(对齐 Default 层) /// 3. 根下新增 UIParticle 子节点:UIParticle 组件 + m_Particles 收全所有粒子 /// + m_Scale3D = (10,10,10) /// ⚠ 这个节点自己的 localScale 在运行时会被 UIParticle 覆盖掉 /// (AutoScalingMode.Transform → UpdateTransformScale() 把它设成 1/parentLossyScale), /// 所以模板里写 0 也只是"随便写";真正决定大小的是「各粒子节点 localScale × m_Scale3D」。 /// 3b. 各粒子节点 localScale 归一化:模板把源里的 30 / 46.176 / 48.395 统一改成 5 /// (配 m_Scale3D=10,等效放大 ≈1.67×,是同门统一口径) /// 4. 根 RectTransform:anchor/pivot = 0.5,sizeDelta = 100×100,位置归零 /// 5. 分数文本(Node/Textparent/Node):字体换 SourceHanSans、spriteAsset 换金币图 /// 6. Textparent 下新增 Node (1) 文本(倍数用),scale 0.7 / 位置 (0,8) / 字号 36 /// 7. 根上挂 BJEndUI:tMP_score = 源里的分数文本,tMP_beishu = 新增的 Node (1) /// /// 奖励框(AwardFrame)另走一条:不碰粒子,只在根上挂 BJImage(tmp_CoinNum = Num 文本)。 /// /// ⚠️ 本脚本不会去改 Art 源预制体(除非你点「就地补齐选中预制体」)。生成的是变体, /// 这样以后美术更新 Art 源,MyGame 变体还能跟着走。 /// public class FlameStrikeVariantBuilder : EditorWindow { // ---------------------------------------------------------------- 路径常量 private const string ArtEffectDir = "Assets/Art/XJMH/pw/pw_prefab/game/effect"; private const string BJEndDir = "Assets/MyGame/aa/Game/XJMH/Boss/BJ/BJEnd"; private const string BJScoreDir = "Assets/MyGame/aa/Game/XJMH/Boss/BJ/BJScore"; /// 变体文件名后缀(与同门 _XingJiXunHang / _XieZi 一致) private const string Suffix = "_LieYanFengBao"; // ---------------------------------------------------------------- 资源 guid(与同门变体完全一致) private const string GoldSpriteGuid = "b5a3b539bfcb41e46ad594b9b14d7732"; private const string SilverSpriteGuid = "e034cf5e78470b44f8a1c4d42d5039d6"; private const string FontGuid = "fdd40eef2e75f6d46b51c289c46fe60b"; // ---------------------------------------------------------------- 模板参考值(从 BJEnd 已有变体抄的) private const float TemplateParticleNodeScale = 5f; // 粒子节点 localScale(源里是 30 / 46.176 / 48.395) private const float TemplateBeishuScale = 0.7f; // 倍数文本 localScale private static readonly Vector2 TemplateBeishuPos = new Vector2(0f, 8f); private static readonly Vector2 TemplateBeishuSize = new Vector2(200f, 50f); private const float TemplateBeishuFontSize = 36f; private static readonly Vector3 TemplateUiParticleScale3D = new Vector3(10f, 10f, 10f); // ---------------------------------------------------------------- 选项 private bool _addUiParticle = true; private bool _disableParticleRenderers = true; private bool _resetSortingLayer = true; private bool _scaleParticleNodes = true; // 模板口径:粒子节点归一化到 5 private bool _proportionalScale = true; // 按比例归一化(保留节点之间的相对大小) private float _particleNodeScale = TemplateParticleNodeScale; private bool _fixRootRect = true; private bool _applyTextStyle = true; private bool _addBjEndUI = true; private bool _createBeishuNode = true; private bool _addBjImage = true; private bool _overwrite; private Vector2 _scroll; [MenuItem("Tools/烈焰风暴/结算面板 & 奖励框 变体生成器")] private static void Open() { var w = GetWindow(false, "烈焰风暴变体", true); w.minSize = new Vector2(460f, 520f); w.Show(); } private void OnGUI() { _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.LabelField("数据源(Art 源预制体)", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "源都在 Assets/Art/XJMH/pw/pw_prefab/game/effect 下:\n" + " · PW_Effect_Settle_FlameStrike_Self → " + BJEndDir + "\n" + " · PW_Effect_Settle_FlameStrike_Other → " + BJEndDir + "\n" + " · PW_Effect_AwardFrame_FlameStrike → " + BJScoreDir + "\n" + "生成的变体名会带后缀 " + Suffix, MessageType.None); EditorGUILayout.Space(); EditorGUILayout.LabelField("加工步骤(对齐 BJEnd 里已有变体)", EditorStyles.boldLabel); _disableParticleRenderers = EditorGUILayout.ToggleLeft( "关闭所有 ParticleSystemRenderer(改由 UIParticle 渲染)", _disableParticleRenderers); _resetSortingLayer = EditorGUILayout.ToggleLeft( "所有粒子 Renderer 的 SortingLayer 归零(Default)", _resetSortingLayer); _addUiParticle = EditorGUILayout.ToggleLeft( "添加 / 复用 UIParticle 节点,并自动收全所有粒子(Scale3D = 10,10,10)", _addUiParticle); _fixRootRect = EditorGUILayout.ToggleLeft( "修正根 RectTransform(anchor/pivot 0.5、sizeDelta 100×100、位置归零)", _fixRootRect); _applyTextStyle = EditorGUILayout.ToggleLeft( "分数文本套用模板样式(SourceHanSans + 金币 spriteAsset)", _applyTextStyle); using (new EditorGUI.IndentLevelScope()) { _scaleParticleNodes = EditorGUILayout.ToggleLeft( $"把各粒子节点的 localScale 归一化到 {TemplateParticleNodeScale}(模板口径)", _scaleParticleNodes); using (new EditorGUI.DisabledScope(!_scaleParticleNodes)) { _proportionalScale = EditorGUILayout.ToggleLeft( "按比例归一化(保留节点之间的相对大小,推荐)", _proportionalScale); _particleNodeScale = EditorGUILayout.FloatField( _proportionalScale ? "主缩放归一化到" : "所有节点统一改成", _particleNodeScale); } EditorGUILayout.HelpBox( "源里各粒子节点的 localScale 是 30(12 个)+ 1(2 个)。模板把 30 归一化成 5," + "再配 m_Scale3D = 10 → 等效 50,与其它 Boss 面板同一口径。\n" + "「按比例」= 30→5、1→0.1667(推荐,不会把原本小的节点放大 5 倍);\n" + "关掉则所有节点都写 " + _particleNodeScale + "。\n" + "⚠ UIParticle 节点自己的 scale 运行时会被强制改成 1/parentLossyScale(AutoScalingMode.Transform)," + "在预制体里调它是没用的,别白费功夫。", MessageType.Info); } EditorGUILayout.Space(); EditorGUILayout.LabelField("挂脚本", EditorStyles.boldLabel); _addBjEndUI = EditorGUILayout.ToggleLeft( "BJEndUI(结算面板:写倍数 + 最终分数)", _addBjEndUI); using (new EditorGUI.IndentLevelScope()) { _createBeishuNode = EditorGUILayout.ToggleLeft( "在 Textparent 下新建 Node (1) 作为倍数文本", _createBeishuNode); } _addBjImage = EditorGUILayout.ToggleLeft( "BJImage(奖励框:写金币数)", _addBjImage); EditorGUILayout.Space(); _overwrite = EditorGUILayout.ToggleLeft("允许覆盖已存在的变体文件", _overwrite); EditorGUILayout.Space(); if (GUILayout.Button("生成:结算面板 Self + Other", GUILayout.Height(30f))) { BuildSettlePanel(true); BuildSettlePanel(false); } if (GUILayout.Button("生成:结算面板 Self", GUILayout.Height(24f))) BuildSettlePanel(true); if (GUILayout.Button("生成:结算面板 Other", GUILayout.Height(24f))) BuildSettlePanel(false); EditorGUILayout.Space(); if (GUILayout.Button("生成:奖励框 AwardFrame", GUILayout.Height(24f))) BuildAwardFrame(); EditorGUILayout.Space(); EditorGUILayout.LabelField("备用", EditorStyles.boldLabel); if (GUILayout.Button("就地补齐选中的预制体(不生成变体,直接改它自己)", GUILayout.Height(24f))) { PatchSelectedInPlace(); } EditorGUILayout.Space(); if (GUILayout.Button("检查资源到位情况", GUILayout.Height(24f))) ReportAssets(); EditorGUILayout.EndScrollView(); } // ================================================================ 生成入口 private void BuildSettlePanel(bool isSelf) { string srcName = isSelf ? "PW_Effect_Settle_FlameStrike_Self" : "PW_Effect_Settle_FlameStrike_Other"; string srcPath = ArtEffectDir + "/" + srcName + ".prefab"; string dstPath = BJEndDir + "/" + srcName + Suffix + ".prefab"; CreateVariant(srcPath, dstPath, root => ModifySettlePanel(root, isSelf)); } private void BuildAwardFrame() { const string srcName = "PW_Effect_AwardFrame_FlameStrike"; string srcPath = ArtEffectDir + "/" + srcName + ".prefab"; string dstPath = BJScoreDir + "/" + srcName + Suffix + ".prefab"; CreateVariant(srcPath, dstPath, ModifyAwardFrame); } // ================================================================ 变体创建 /// 把 Art 源实例化成变体并保存到目标路径(SaveAsPrefabAsset 作用在预制体实例上 → 生成变体)。 private void CreateVariant(string srcPath, string dstPath, Action modify) { var src = AssetDatabase.LoadAssetAtPath(srcPath); if (src == null) { Debug.LogError($"[烈焰风暴变体] 找不到源预制体:{srcPath}"); return; } EnsureFolder(Path.GetDirectoryName(dstPath).Replace('\\', '/')); if (File.Exists(dstPath) && !_overwrite) { Debug.LogError($"[烈焰风暴变体] {dstPath} 已存在。要覆盖请先勾选「允许覆盖已存在的变体文件」," + "或直接在 Project 里改名后再生成。"); return; } var inst = PrefabUtility.InstantiatePrefab(src) as GameObject; if (inst == null) { Debug.LogError($"[烈焰风暴变体] 实例化失败:{srcPath}"); return; } try { inst.name = Path.GetFileNameWithoutExtension(dstPath); modify?.Invoke(inst); PrefabUtility.SaveAsPrefabAsset(inst, dstPath, out bool success); if (!success) { Debug.LogError($"[烈焰风暴变体] 保存失败:{dstPath}"); return; } AssetDatabase.SaveAssets(); var saved = AssetDatabase.LoadAssetAtPath(dstPath); var type = PrefabUtility.GetPrefabAssetType(saved); if (type == PrefabAssetType.Variant) Debug.Log($"[烈焰风暴变体] ✔ 已生成变体:{dstPath}"); else Debug.LogWarning($"[烈焰风暴变体] {dstPath} 已生成,但类型是 {type} 而不是 Variant " + "(说明它没继承 Art 源,请检查)"); if (saved != null) EditorGUIUtility.PingObject(saved); } finally { DestroyImmediate(inst); } } // ================================================================ 结算面板加工 /// 结算面板(Settle_FlameStrike_Self / _Other)的 UI 化 + BJEndUI。 private void ModifySettlePanel(GameObject root, bool isSelf) { var sb = new StringBuilder(); sb.AppendLine($"[烈焰风暴变体] 处理 {(isSelf ? "Self" : "Other")} 结算面板:{root.name}"); // ---- 1/2. 粒子 Renderer:关掉自带渲染 + 层归零 var psList = root.GetComponentsInChildren(true); int rendererCount = 0; foreach (var ps in psList) { var psr = ps.GetComponent(); if (psr == null) continue; if (_disableParticleRenderers) psr.enabled = false; if (_resetSortingLayer) psr.sortingLayerID = 0; rendererCount++; } sb.AppendLine($" 粒子系统 {psList.Length} 个,Renderer 处理 {rendererCount} 个" + $"(enabled={!_disableParticleRenderers}、sortingLayer 归零={_resetSortingLayer})"); // ---- 3. UIParticle 节点 if (_addUiParticle) { bool created = EnsureUiParticle(root, psList, out int particleCount); sb.AppendLine(created ? $" 新建 UIParticle 节点,收粒子 {particleCount} 个,Scale3D={TemplateUiParticleScale3D}" : $" 复用已有 UIParticle 节点,刷新粒子列表 {particleCount} 个,Scale3D={TemplateUiParticleScale3D}"); // 粒子节点缩放归一化:模板把源里的 30 / 46.176 / 48.395 统一改成 5,配 m_Scale3D=10 用。 // ⚠ 这一步与 UIParticle 是新建还是复用无关 —— 真正决定大小的是「粒子节点 scale × m_Scale3D」, // UIParticle 节点自己的 scale 运行时会被 AutoScalingMode.Transform 覆盖,两种情况都要归一化。 if (_scaleParticleNodes) sb.AppendLine(NormalizeParticleNodeScales(psList)); else sb.AppendLine(" 粒子节点缩放:保持源里的原值(未归一化 —— 与其它 Boss 面板口径不一致,请预览确认)"); } // ---- 4. 根 RectTransform if (_fixRootRect) { var rt = root.GetComponent(); if (rt != null) { rt.anchorMin = new Vector2(0.5f, 0.5f); rt.anchorMax = new Vector2(0.5f, 0.5f); rt.pivot = new Vector2(0.5f, 0.5f); rt.sizeDelta = new Vector2(100f, 100f); rt.anchoredPosition = Vector2.zero; rt.localPosition = Vector3.zero; rt.localRotation = Quaternion.identity; sb.AppendLine(" 根 RectTransform:anchor/pivot 0.5、sizeDelta 100×100、位置归零"); } } // ---- 5. 分数文本样式 var scoreTmp = FindScoreTmp(root); if (scoreTmp == null) { Debug.LogError($"[烈焰风暴变体] {root.name} 里找不到分数文本(预期路径 Node/Textparent/Node),BJEndUI 未挂"); } else if (_applyTextStyle) { var font = LoadAsset(FontGuid); var gold = LoadAsset(GoldSpriteGuid); if (font != null) scoreTmp.font = font; if (gold != null) scoreTmp.spriteAsset = gold; sb.AppendLine($" 分数文本 {GetPath(scoreTmp.transform)}:字体=" + $"{(font != null ? font.name : "未改")}、spriteAsset={(gold != null ? gold.name : "未改")}"); } // ---- 6/7. 倍数文本 + BJEndUI if (_addBjEndUI) { if (scoreTmp == null) { sb.AppendLine(" 跳过 BJEndUI(没有分数文本可接)"); } else { TextMeshProUGUI beishuTmp = null; if (_createBeishuNode) { beishuTmp = EnsureBeishuNode(scoreTmp); sb.AppendLine($" 倍数文本:{GetPath(beishuTmp.transform)}(scale {TemplateBeishuScale}," + $"pos {TemplateBeishuPos},字号 {TemplateBeishuFontSize})"); } var endUI = root.GetComponent(); if (endUI == null) endUI = root.AddComponent(); endUI.tMP_score = scoreTmp; endUI.tMP_beishu = beishuTmp; endUI.goldSprite = LoadAsset(GoldSpriteGuid); endUI.SilverSprite = LoadAsset(SilverSpriteGuid); endUI.img_BaoJiangZhong = null; endUI.useScoreAnim = false; endUI.autoDestroyDelay = 5f; sb.AppendLine($" 已挂 BJEndUI(tMP_score={scoreTmp.name}、" + $"tMP_beishu={(beishuTmp != null ? beishuTmp.name : "空")}," + $"gold/SilverSprite 已接,autoDestroyDelay=5)"); } } Debug.Log(sb.ToString()); } // ================================================================ 奖励框加工 /// 奖励框(AwardFrame_FlameStrike):不碰粒子,只挂 BJImage(写金币数)。 private void ModifyAwardFrame(GameObject root) { var sb = new StringBuilder(); sb.AppendLine($"[烈焰风暴变体] 处理奖励框:{root.name}"); if (_fixRootRect) { var rt = root.GetComponent(); if (rt != null) { rt.anchorMin = new Vector2(0.5f, 0.5f); rt.anchorMax = new Vector2(0.5f, 0.5f); rt.pivot = new Vector2(0.5f, 0.5f); rt.sizeDelta = new Vector2(100f, 100f); rt.anchoredPosition = Vector2.zero; rt.localPosition = Vector3.zero; rt.localRotation = Quaternion.identity; sb.AppendLine(" 根 RectTransform:anchor/pivot 0.5、sizeDelta 100×100、位置归零"); } } if (_addBjImage) { var coinTmp = FindCoinNumTmp(root); if (coinTmp == null) { Debug.LogError($"[烈焰风暴变体] {root.name} 里找不到金币数字文本(预期在 Num 节点下),BJImage 未挂"); } else { var bjImage = root.GetComponent(); if (bjImage == null) bjImage = root.AddComponent(); bjImage.tmp_CoinNum = coinTmp; bjImage.goldSprite = LoadAsset(GoldSpriteGuid); bjImage.silverSprite = LoadAsset(SilverSpriteGuid); sb.AppendLine($" 已挂 BJImage(tmp_CoinNum={GetPath(coinTmp.transform)},gold/silverSprite 已接)"); } } Debug.Log(sb.ToString()); } // ================================================================ 就地补齐(改选中预制体本身) private void PatchSelectedInPlace() { var path = AssetDatabase.GetAssetPath(Selection.activeObject); if (string.IsNullOrEmpty(path) || !path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { Debug.LogError("[烈焰风暴变体] 请先在 Project 窗口里选中一个 .prefab 再点这个按钮"); return; } if (PrefabUtility.GetPrefabAssetType(Selection.activeObject) == PrefabAssetType.Variant) { Debug.LogError($"[烈焰风暴变体] {path} 是变体,请直接在变体上改;就地补齐只支持普通预制体"); return; } var contents = PrefabUtility.LoadPrefabContents(path); if (contents == null) return; try { bool isAwardFrame = contents.name.IndexOf("AwardFrame", StringComparison.OrdinalIgnoreCase) >= 0; if (isAwardFrame) ModifyAwardFrame(contents); else ModifySettlePanel(contents, contents.name.IndexOf("Self", StringComparison.OrdinalIgnoreCase) >= 0); PrefabUtility.SaveAsPrefabAsset(contents, path, out bool ok); if (ok) Debug.Log($"[烈焰风暴变体] ✔ 已就地补齐:{path}"); else Debug.LogError($"[烈焰风暴变体] 就地保存失败:{path}"); } finally { PrefabUtility.UnloadPrefabContents(contents); } AssetDatabase.SaveAssets(); } // ================================================================ 资源检查 private void ReportAssets() { var sb = new StringBuilder("[烈焰风暴] 资源到位检查\n"); CheckPrefab(sb, ArtEffectDir + "/PW_Effect_Settle_FlameStrike_Self.prefab"); CheckPrefab(sb, ArtEffectDir + "/PW_Effect_Settle_FlameStrike_Other.prefab"); CheckPrefab(sb, ArtEffectDir + "/PW_Effect_AwardFrame_FlameStrike.prefab"); CheckPrefab(sb, ArtEffectDir + "/PW_Effect_Wheel.prefab"); CheckPrefab(sb, BJEndDir + "/PW_Effect_Wheel_LieYanFengBao.prefab"); CheckPrefab(sb, BJEndDir + "/PW_Effect_Settle_FlameStrike_Self" + Suffix + ".prefab"); CheckPrefab(sb, BJEndDir + "/PW_Effect_Settle_FlameStrike_Other" + Suffix + ".prefab"); CheckPrefab(sb, BJScoreDir + "/PW_Effect_AwardFrame_FlameStrike" + Suffix + ".prefab"); CheckPrefab(sb, "Assets/MyGame/aa/Game/XJMH/Boss/LieYanFengBao/PW_Enemy_115.prefab"); CheckPrefab(sb, "Assets/MyGame/aa/Game/XJMH/Prefabs/Bullit/PW_Bullet_FlameRound.prefab"); CheckPrefab(sb, "Assets/Art/XJMH/pw/pw_spine/pw_spine_plane/pw_spine_plane_lyfb/PW_Spine_Plane_LYFB_SkeletonData.asset"); sb.AppendLine(); sb.AppendLine("音效(tbaudio.json 里应为 69009-69012):"); foreach (var n in new[] { "PW_FlameStrike_Explosion", "PW_FlameStrike_Die", "PW_FlameStrike_WheelExplosion", "PW_FlameStrike_WheelFly" }) { var p = "Assets/Audios/XJMH/" + n + ".ogg"; sb.AppendLine(" " + (File.Exists(p) ? "✔" : "✘") + " " + p); } sb.AppendLine(); sb.AppendLine("挂脚本可用性:"); sb.AppendLine(" " + (LoadAsset(GoldSpriteGuid) != null ? "✔" : "✘") + " 金币 spriteAsset"); sb.AppendLine(" " + (LoadAsset(SilverSpriteGuid) != null ? "✔" : "✘") + " 银币 spriteAsset"); sb.AppendLine(" " + (LoadAsset(FontGuid) != null ? "✔" : "✘") + " SourceHanSans 字体"); Debug.Log(sb.ToString()); } private static void CheckPrefab(StringBuilder sb, string path) { bool exists = File.Exists(path); sb.AppendLine((exists ? "✔ " : "✘ ") + path + (exists ? "" : " ← 缺失")); } // ================================================================ 工具方法 /// 确保根下有一个 UIParticle 节点,并把所有粒子收进 m_Particles。 private static bool EnsureUiParticle(GameObject root, ParticleSystem[] psList, out int particleCount) { var ui = root.GetComponentInChildren(true); bool created = false; if (ui == null) { var go = new GameObject("UIParticle", typeof(RectTransform), typeof(CanvasRenderer), typeof(UIParticle)); var rt = (RectTransform)go.transform; rt.SetParent(root.transform, false); rt.anchorMin = new Vector2(0.5f, 0.5f); rt.anchorMax = new Vector2(0.5f, 0.5f); rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = Vector2.zero; rt.sizeDelta = Vector2.zero; rt.localScale = Vector3.one; // 运行时会被 UIParticle 覆盖成 1/parentLossyScale,这里给 1 只是编辑器里好看 ui = go.GetComponent(); created = true; } // UIParticle 的序列化字段都是 private → 用 SerializedObject 写 var so = new SerializedObject(ui); var arr = so.FindProperty("m_Particles"); if (arr != null) { arr.arraySize = psList.Length; for (int i = 0; i < psList.Length; i++) arr.GetArrayElementAtIndex(i).objectReferenceValue = psList[i]; } var scale3D = so.FindProperty("m_Scale3D"); if (scale3D != null) scale3D.vector3Value = TemplateUiParticleScale3D; so.ApplyModifiedPropertiesWithoutUndo(); particleCount = psList.Length; if (arr == null) Debug.LogWarning("[烈焰风暴变体] 没找到 UIParticle.m_Particles 字段(插件版本可能不同),请手动拖粒子列表"); return created; } /// /// 把各粒子节点的 localScale 归一化到模板口径(源里的 30 → 5,配 m_Scale3D=10 使用)。 /// /// 按比例模式:取出现次数最多的缩放当「主缩放」,factor = 目标 / 主缩放,所有节点乘同一倍率 /// → 30→5、1→0.1667,不会把原本就小的节点放大 5 倍(烈焰风暴源里正好是 12 个 30 + 2 个 1)。 /// 非按比例:所有节点直接写死目标值(模板做法的字面复刻)。 /// private string NormalizeParticleNodeScales(ParticleSystem[] psList) { if (psList == null || psList.Length == 0) return " 粒子节点缩放:没有粒子系统,跳过"; var counts = new Dictionary(); foreach (var ps in psList) { float v = ps.transform.localScale.x; counts.TryGetValue(v, out int c); counts[v] = c + 1; } float dominant = 1f; int best = -1; foreach (var kv in counts) { if (kv.Value > best) { best = kv.Value; dominant = kv.Key; } } if (Mathf.Approximately(dominant, 0f)) dominant = 1f; float factor = _proportionalScale ? _particleNodeScale / dominant : 0f; var sb = new StringBuilder(); sb.Append(_proportionalScale ? $" 粒子节点归一化:主缩放 {dominant}({best}/{psList.Length} 个)→ {_particleNodeScale},倍率 {factor:0.####}" : $" 粒子节点归一化:全部统一写成 {_particleNodeScale}"); int shown = 0; foreach (var ps in psList) { var old = ps.transform.localScale; var nv = _proportionalScale ? old * factor : Vector3.one * _particleNodeScale; ps.transform.localScale = nv; SetConstrainProportionsScale(ps.transform, true); if (shown++ < 16) sb.Append($"\n {GetPath(ps.transform)}: {old.x:0.####} → {nv.x:0.####}"); } return sb.ToString(); } /// 在 Textparent 下确保存在倍数文本节点(模板里叫 Node (1))。 private static TextMeshProUGUI EnsureBeishuNode(TextMeshProUGUI scoreTmp) { var parent = scoreTmp.transform.parent; if (parent == null) { Debug.LogWarning("[烈焰风暴变体] 分数文本没有父节点,倍数文本挂在它自己下面"); parent = scoreTmp.transform; } var exist = parent.Find("Node (1)"); if (exist != null) { var t = exist.GetComponent(); if (t != null) return t; } var go = new GameObject("Node (1)", typeof(RectTransform)); var rt = (RectTransform)go.transform; rt.SetParent(parent, false); rt.anchorMin = new Vector2(0.5f, 0.5f); rt.anchorMax = new Vector2(0.5f, 0.5f); rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = TemplateBeishuPos; rt.sizeDelta = TemplateBeishuSize; rt.localScale = Vector3.one * TemplateBeishuScale; var tmp = go.AddComponent(); tmp.font = scoreTmp.font; // 运行时 AddComponent 的文本必须显式给字体,否则不显示 tmp.spriteAsset = scoreTmp.spriteAsset; tmp.fontSize = TemplateBeishuFontSize; tmp.alignment = TextAlignmentOptions.Center; tmp.color = Color.white; tmp.raycastTarget = false; tmp.text = "225"; // 占位,运行时由 BJEndUI.Init 覆写 return tmp; } /// 分数文本:优先 Node/Textparent/Node,再退到 Textparent 下的第一个 TMP。 private static TextMeshProUGUI FindScoreTmp(GameObject root) { var t = root.transform.Find("Node/Textparent/Node") ?? root.transform.Find("Textparent/Node"); if (t != null) { var tmp = t.GetComponent(); if (tmp != null) return tmp; } var tp = FindDeep(root.transform, "Textparent"); if (tp != null) { var tmp = tp.GetComponentInChildren(true); if (tmp != null) return tmp; } Debug.LogWarning($"[烈焰风暴变体] {root.name}:没找到 Textparent,退用整棵树里第一个 TMP"); return root.GetComponentInChildren(true); } /// 奖励框的金币数字文本(Num 节点下的 TMP)。 private static TextMeshProUGUI FindCoinNumTmp(GameObject root) { var num = FindDeep(root.transform, "Num"); if (num != null) { var tmp = num.GetComponentInChildren(true); if (tmp != null) return tmp; } return root.GetComponentInChildren(true); } private static Transform FindDeep(Transform root, string name) { if (root == null) 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 string GetPath(Transform t) { if (t == null) return "?"; var s = t.name; var p = t.parent; while (p != null) { s = p.name + "/" + s; p = p.parent; } return s; } private static void SetConstrainProportionsScale(Transform t, bool value) { var so = new SerializedObject(t); var p = so.FindProperty("m_ConstrainProportionsScale"); if (p != null) { p.boolValue = value; so.ApplyModifiedPropertiesWithoutUndo(); } } private static T LoadAsset(string guid) where T : UnityEngine.Object { var path = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(path)) { Debug.LogWarning($"[烈焰风暴变体] guid 解析不到资源:{guid}"); return null; } return AssetDatabase.LoadAssetAtPath(path); } private static void EnsureFolder(string folder) { if (AssetDatabase.IsValidFolder(folder)) return; var parts = folder.Split('/'); var cur = parts[0]; for (int i = 1; i < parts.Length; i++) { var next = cur + "/" + parts[i]; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]); cur = next; } } } #endif