#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; namespace Continentis.MainGame.Card { /// /// 卡牌成品预览窗口。 /// 通过 CardDataEditor 中的"预览卡牌"按钮打开,或通过菜单 Continentis / Mod Tools / 卡牌预览 进入。 /// 使用 IMGUI 多层合成方式还原游戏内卡牌的大致外观。 /// public class CardPreviewer : EditorWindow { // ───────────────────────────────────────────────────────────────────── // 常量与路径 // ───────────────────────────────────────────────────────────────────── private const string k_SpritesRoot = "Assets/Sprites/MainGame/CardView/GeneralCard"; private const string k_LocaleRoot = "Assets/Mods"; // CSV 列索引(对应 CSV 首行:Key, English, Simplified Chinese, ...) private const int k_ColEN = 1; private const int k_ColCN = 2; // 卡牌宽高比(基于参考图:约 2:3) private const float k_CardAspect = 2f / 3f; // width / height // 卡牌内部各区域相对于卡牌高度的比例(基于参考截图目测) private const float k_ArtTopRatio = 0.09f; // 图片区顶部 private const float k_ArtBotRatio = 0.53f; // 图片区底部(包含类型徽章重叠) private const float k_NameTopRatio = 0.065f; // 名称文字中心 private const float k_TypeBadgeRatio = 0.525f; // 类型徽章中心 private const float k_DescTopRatio = 0.565f; // 描述文字区顶部 private const float k_DescBotRatio = 0.92f; // 描述文字区底部 // ───────────────────────────────────────────────────────────────────── // 状态 // ───────────────────────────────────────────────────────────────────── private CardData _cardData; // 语言切换工具栏(0=English, 1=简体中文) private int _langIndex = 0; private readonly string[] _langLabels = { "English", "简体中文" }; // 加载的精灵层 private Texture2D _texBackground; private Texture2D _texOuterFrame; private Texture2D _texNameFrame; private Texture2D _texTypeBg; private Texture2D _texTypeMain; // 本地化缓存:key → [EN, CN] private Dictionary _localizationCache; private bool _localeLoaded = false; // 样式缓存(延迟初始化,避免 OnEnable 时 GUI skin 未就绪) private GUIStyle _cardNameStyle; private GUIStyle _descStyle; private GUIStyle _typeStyle; private bool _stylesInitialized = false; // 滚动位置(描述文本过长时) private Vector2 _scrollPos; // ───────────────────────────────────────────────────────────────────── // 静态入口 // ───────────────────────────────────────────────────────────────────── [MenuItem("Continentis/Mod Tools/卡牌预览")] public static void OpenEmpty() { GetOrCreateWindow(null); } /// /// 从 CardDataEditor 调用,附带要预览的 CardData。 /// public static void OpenWith(CardData cardData) { GetOrCreateWindow(cardData); } private static CardPreviewer GetOrCreateWindow(CardData cardData) { var window = GetWindow(false, "卡牌预览", true); window.minSize = new Vector2(280, 480); window.maxSize = new Vector2(500, 900); if (cardData != null) window.SetTarget(cardData); return window; } // ───────────────────────────────────────────────────────────────────── // 生命周期 // ───────────────────────────────────────────────────────────────────── private void OnEnable() { LoadLayerSprites(); } private void OnDisable() { // 不主动 Destroy —— AssetDatabase 加载的对象由 Unity 管理生命周期 } // ───────────────────────────────────────────────────────────────────── // 公开接口 // ───────────────────────────────────────────────────────────────────── public void SetTarget(CardData cardData) { _cardData = cardData; _localeLoaded = false; // 切换目标时清空缓存,触发重新扫描 Repaint(); } // ───────────────────────────────────────────────────────────────────── // OnGUI — 主绘制 // ───────────────────────────────────────────────────────────────────── private void OnGUI() { EnsureStylesInitialized(); EnsureLocalizationLoaded(); DrawToolbar(); if (_cardData == null) { DrawEmptyState(); return; } DrawCardPreview(); } // ───────────────────────────────────────────────────────────────────── // 工具栏(语言切换 + 选择 CardData) // ───────────────────────────────────────────────────────────────────── private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); // 语言切换 EditorGUILayout.LabelField("语言:", GUILayout.Width(36)); int newLang = GUILayout.Toolbar(_langIndex, _langLabels, EditorStyles.toolbarButton, GUILayout.Width(160)); if (newLang != _langIndex) { _langIndex = newLang; Repaint(); } GUILayout.FlexibleSpace(); // 手动选择 CardData if (GUILayout.Button("选择 CardData...", EditorStyles.toolbarButton, GUILayout.Width(120))) { ShowCardDataPicker(); } EditorGUILayout.EndHorizontal(); // CardData 对象字段(次行,方便拖拽) EditorGUI.BeginChangeCheck(); var picked = (CardData)EditorGUILayout.ObjectField( "CardData", _cardData, typeof(CardData), false); if (EditorGUI.EndChangeCheck()) SetTarget(picked); } // ───────────────────────────────────────────────────────────────────── // 空状态提示 // ───────────────────────────────────────────────────────────────────── private void DrawEmptyState() { GUILayout.FlexibleSpace(); EditorGUILayout.LabelField( "请将 CardData 拖入上方字段,\n或点击\"选择 CardData...\"按钮", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { fontSize = 12, wordWrap = true }, GUILayout.ExpandWidth(true)); GUILayout.FlexibleSpace(); } // ───────────────────────────────────────────────────────────────────── // 卡牌合成渲染 // ───────────────────────────────────────────────────────────────────── private void DrawCardPreview() { // ── 计算卡牌绘制区域(居中,保持宽高比)────────────────────────── float availW = position.width - 16f; float availH = position.height - 60f; // 减去工具栏高度 float cardW, cardH; if (availW / availH < k_CardAspect) { cardW = availW; cardH = availW / k_CardAspect; } else { cardH = availH; cardW = availH * k_CardAspect; } float offsetX = (availW - cardW) * 0.5f + 8f; float offsetY = 58f; // 工具栏 + ObjectField 高度之和 Rect cardRect = new Rect(offsetX, offsetY, cardW, cardH); // ── 层 1:卡牌深色背景 ──────────────────────────────────────────── if (_texBackground != null) GUI.DrawTexture(cardRect, _texBackground, ScaleMode.StretchToFill, true); else EditorGUI.DrawRect(cardRect, new Color(0.08f, 0.08f, 0.12f)); // ── 层 2:卡牌图片(上半区域,内框范围内)──────────────────────── Rect artRect = RectFromRatios(cardRect, padH: 0.04f, padV: k_ArtTopRatio, widthRatio: 0.92f, heightRatio: k_ArtBotRatio - k_ArtTopRatio); if (_cardData.cardSprite != null) { GUI.DrawTexture(artRect, _cardData.cardSprite.texture, ScaleMode.ScaleToFit, true); } else { EditorGUI.DrawRect(artRect, new Color(0.15f, 0.15f, 0.2f)); GUI.Label(artRect, " 无卡牌图片", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { alignment = TextAnchor.MiddleCenter }); } // ── 层 4:外框 ──────────────────────────────────────────────────── if (_texOuterFrame != null) GUI.DrawTexture(cardRect, _texOuterFrame, ScaleMode.StretchToFill, true); // ── 层 5:名称框背景 ────────────────────────────────────────────── if (_texNameFrame != null) { Rect nameFrameRect = RectFromRatios(cardRect, padH: 0.1f, padV: k_NameTopRatio - 0.036f, widthRatio: 0.80f, heightRatio: 0.072f); GUI.DrawTexture(nameFrameRect, _texNameFrame, ScaleMode.StretchToFill, true); } // ── 层 6:卡牌名称文字 ──────────────────────────────────────────── string cardName = GetLocalizedText(_cardData.displayName); Rect nameTextRect = new Rect( cardRect.x + cardRect.width * 0.1f, cardRect.y + cardRect.height * (k_NameTopRatio - 0.025f), cardRect.width * 0.80f, cardRect.height * 0.055f); GUI.Label(nameTextRect, cardName, _cardNameStyle); // ── 层 7:类型徽章 ──────────────────────────────────────────────── Rect typeBadgeRect = RectFromRatios(cardRect, padH: 0.25f, padV: k_TypeBadgeRatio - 0.028f, widthRatio: 0.50f, heightRatio: 0.056f); if (_texTypeBg != null) GUI.DrawTexture(typeBadgeRect, _texTypeBg, ScaleMode.StretchToFill, true); if (_texTypeMain != null) GUI.DrawTexture(typeBadgeRect, _texTypeMain, ScaleMode.StretchToFill, true); GUI.Label(typeBadgeRect, _cardData.cardType.ToString(), _typeStyle); // ── 层 8:描述文字区(滚动) ────────────────────────────────────── Rect descArea = new Rect( cardRect.x + cardRect.width * 0.06f, cardRect.y + cardRect.height * k_DescTopRatio, cardRect.width * 0.88f, cardRect.height * (k_DescBotRatio - k_DescTopRatio)); string rawFunction = GetLocalizedText(_cardData.functionText); string displayDesc = StripMacros(rawFunction); // 动态调整字号使文字适应区域 _descStyle.fontSize = CalculateFontSize(cardH); // 用 Scroll View 防止超长文字溢出 _scrollPos = GUI.BeginScrollView(descArea, _scrollPos, new Rect(0, 0, descArea.width - 10f, Mathf.Max(descArea.height, _descStyle.CalcHeight(new GUIContent(displayDesc), descArea.width - 14f)))); GUI.Label(new Rect(0, 4f, descArea.width - 14f, descArea.height + 200f), displayDesc, _descStyle); GUI.EndScrollView(); } // ───────────────────────────────────────────────────────────────────── // 资源加载 // ───────────────────────────────────────────────────────────────────── private void LoadLayerSprites() { _texBackground = LoadTex("CardBackground_Default"); _texOuterFrame = LoadTex("CardOuterFrame"); _texNameFrame = LoadTex("CardNameFrame"); _texTypeBg = LoadTex("CardTypeBackground"); _texTypeMain = LoadTex("CardTypeMain"); } private static Texture2D LoadTex(string fileName) { // 优先加载 PNG,再尝试其他扩展名 string path = $"{k_SpritesRoot}/{fileName}.png"; var spr = AssetDatabase.LoadAssetAtPath(path); if (spr != null) return spr.texture; // 降级:直接加载 Texture2D return AssetDatabase.LoadAssetAtPath(path); } // ───────────────────────────────────────────────────────────────────── // 本地化加载(惰性 + 扫描所有 CSV) // ───────────────────────────────────────────────────────────────────── private void EnsureLocalizationLoaded() { if (_localeLoaded) return; _localizationCache = new Dictionary(StringComparer.Ordinal); string[] guids = AssetDatabase.FindAssets("t:TextAsset Localization", new[] { k_LocaleRoot }); foreach (string guid in guids) { string csvPath = AssetDatabase.GUIDToAssetPath(guid); if (!csvPath.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) continue; try { ParseCsv(File.ReadAllText(csvPath)); } catch (Exception e) { Debug.LogWarning($"[CardPreviewer] 解析本地化文件失败:{csvPath}\n{e.Message}"); } } _localeLoaded = true; } /// /// 解析单个 CSV 文件,将 Key → [EN, CN] 写入缓存。 /// 支持 RFC 4180 标准的双引号转义字段。 /// private void ParseCsv(string content) { // 按行拆分 string[] lines = content.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); if (lines.Length < 2) return; // 首行为列头,找到 English / Simplified Chinese 的列索引 string[] headers = SplitCsvRow(lines[0]); int colEN = FindColumnIndex(headers, "English"); int colCN = FindColumnIndex(headers, "Simplified Chinese"); for (int i = 1; i < lines.Length; i++) { if (string.IsNullOrWhiteSpace(lines[i])) continue; string[] cells = SplitCsvRow(lines[i]); if (cells.Length == 0) continue; string key = cells[0].Trim(); if (string.IsNullOrEmpty(key)) continue; string en = colEN >= 0 && colEN < cells.Length ? cells[colEN] : string.Empty; string cn = colCN >= 0 && colCN < cells.Length ? cells[colCN] : string.Empty; _localizationCache[key] = new[] { en, cn }; } } private static int FindColumnIndex(string[] headers, string columnName) { for (int i = 0; i < headers.Length; i++) if (string.Equals(headers[i].Trim(), columnName, StringComparison.OrdinalIgnoreCase)) return i; return -1; } /// /// RFC 4180 兼容的 CSV 行拆分,处理双引号包围的字段及内部双引号转义。 /// private static string[] SplitCsvRow(string line) { var fields = new List(); bool inQuote = false; var current = new System.Text.StringBuilder(); for (int i = 0; i < line.Length; i++) { char c = line[i]; if (inQuote) { if (c == '"') { // 双引号转义 ("") → 单引号字符 if (i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } else { inQuote = false; } } else { current.Append(c); } } else { if (c == '"') { inQuote = true; } else if (c == ',') { fields.Add(current.ToString()); current.Clear(); } else { current.Append(c); } } } fields.Add(current.ToString()); return fields.ToArray(); } // ───────────────────────────────────────────────────────────────────── // 文本处理 // ───────────────────────────────────────────────────────────────────── /// /// 根据当前语言返回本地化文本。若未找到,退回显示 Key 本身。 /// private string GetLocalizedText(string key) { if (string.IsNullOrEmpty(key)) return string.Empty; if (_localizationCache != null && _localizationCache.TryGetValue(key, out string[] texts)) { string text = _langIndex == 0 ? texts[0] : texts[1]; return string.IsNullOrEmpty(text) ? $"[{key}]" : text; } return $"[{key}]"; // 未找到时显示 Key } /// /// 将功能文本中的宏替换为可读形式,用于在预览中近似展示。 /// $Keyword("xxx") → [xxx] /// $Attribute("xxx") → {xxx} /// private static string StripMacros(string text) { if (string.IsNullOrEmpty(text)) return text; text = Regex.Replace(text, @"\$Keyword\(""([^""]+)""\)", m => $"[{m.Groups[1].Value}]"); text = Regex.Replace(text, @"\$Attribute\(""([^""]+)""\)", m => $"{{{m.Groups[1].Value}}}"); return text; } // ───────────────────────────────────────────────────────────────────── // 样式初始化 // ───────────────────────────────────────────────────────────────────── private void EnsureStylesInitialized() { if (_stylesInitialized) return; _cardNameStyle = new GUIStyle(GUI.skin.label) { fontSize = 16, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false, normal = { textColor = Color.white } }; _typeStyle = new GUIStyle(GUI.skin.label) { fontSize = 12, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleCenter, wordWrap = false, // 全部状态一律使用相同颜色,防止 Unity GUI skin 默认的悬停高亮 normal = { textColor = new Color(0.2f, 0.55f, 0.2f) }, hover = { textColor = new Color(0.2f, 0.55f, 0.2f) }, active = { textColor = new Color(0.2f, 0.55f, 0.2f) }, focused = { textColor = new Color(0.2f, 0.55f, 0.2f) } }; _descStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Normal, alignment = TextAnchor.UpperLeft, wordWrap = true, richText = true, normal = { textColor = new Color(0.88f, 0.88f, 0.88f) } }; _stylesInitialized = true; } // ───────────────────────────────────────────────────────────────────── // 工具方法 // ───────────────────────────────────────────────────────────────────── /// /// 在 baseRect 内部按比例偏移创建子矩形。 /// padH / padV 是左右/上方的水平/垂直内边距(相对于卡牌宽/高的比值)。 /// private static Rect RectFromRatios(Rect baseRect, float padH, float padV, float widthRatio, float heightRatio) { return new Rect( baseRect.x + baseRect.width * padH, baseRect.y + baseRect.height * padV, baseRect.width * widthRatio, baseRect.height * heightRatio); } /// /// 根据当前卡牌绘制高度动态计算字号,确保缩小窗口时文字不溢出。 /// private static int CalculateFontSize(float cardHeight) { // 字号随卡牌高度线性缩放,base 400px → 13pt int size = Mathf.RoundToInt(cardHeight * 13f / 400f); return Mathf.Clamp(size, 9, 16); } /// /// 打开 Unity 内置的 CardData 类型选择器(Object Picker)。 /// private void ShowCardDataPicker() { // 用 SearchService 或简单的 AssetDatabase 弹窗 string[] guids = AssetDatabase.FindAssets("t:CardData"); if (guids.Length == 0) { Debug.LogWarning("[CardPreviewer] 项目中未找到任何 CardData 资产。"); return; } // 收集所有 CardData 资产供选择 var items = new List(); var paths = new List(); foreach (string guid in guids) { string p = AssetDatabase.GUIDToAssetPath(guid); items.Add(Path.GetFileNameWithoutExtension(p)); paths.Add(p); } // 弹出简易选择窗口 CardDataPickerWindow.Show(items, paths, selectedPath => { var cd = AssetDatabase.LoadAssetAtPath(selectedPath); SetTarget(cd); }); } // ───────────────────────────────────────────────────────────────────── // 内部:CardData 选择弹窗 // ───────────────────────────────────────────────────────────────────── private class CardDataPickerWindow : EditorWindow { private List _names; private List _paths; private Action _onSelect; private string _search = ""; private Vector2 _scroll; public static void Show(List names, List paths, Action onSelect) { var w = GetWindow(true, "选择 CardData", true); w.minSize = new Vector2(260, 340); w._names = names; w._paths = paths; w._onSelect = onSelect; } private void OnGUI() { GUILayout.BeginHorizontal(EditorStyles.toolbar); _search = GUILayout.TextField(_search, GUI.skin.FindStyle("ToolbarSearchTextField") ?? EditorStyles.textField); if (GUILayout.Button("", GUI.skin.FindStyle("ToolbarSearchCancelButton") ?? EditorStyles.miniButton)) _search = ""; GUILayout.EndHorizontal(); _scroll = EditorGUILayout.BeginScrollView(_scroll); for (int i = 0; i < _names.Count; i++) { if (!string.IsNullOrEmpty(_search) && _names[i].IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0) continue; if (GUILayout.Button(_names[i], EditorStyles.label)) { _onSelect?.Invoke(_paths[i]); Close(); } } EditorGUILayout.EndScrollView(); } } } } #endif