Files
SoulliesOfficial d09b58fd80 架构大更
2026-03-20 11:56:50 -04:00

627 lines
29 KiB
C#
Raw Permalink 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.
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
namespace Continentis.MainGame.Card
{
/// <summary>
/// 卡牌成品预览窗口。
/// 通过 CardDataEditor 中的"预览卡牌"按钮打开,或通过菜单 Continentis / Mod Tools / 卡牌预览 进入。
/// 使用 IMGUI 多层合成方式还原游戏内卡牌的大致外观。
/// </summary>
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<string, string[]> _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);
}
/// <summary>
/// 从 CardDataEditor 调用,附带要预览的 CardData。
/// </summary>
public static void OpenWith(CardData cardData)
{
GetOrCreateWindow(cardData);
}
private static CardPreviewer GetOrCreateWindow(CardData cardData)
{
var window = GetWindow<CardPreviewer>(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<Sprite>(path);
if (spr != null) return spr.texture;
// 降级:直接加载 Texture2D
return AssetDatabase.LoadAssetAtPath<Texture2D>(path);
}
// ─────────────────────────────────────────────────────────────────────
// 本地化加载(惰性 + 扫描所有 CSV
// ─────────────────────────────────────────────────────────────────────
private void EnsureLocalizationLoaded()
{
if (_localeLoaded) return;
_localizationCache = new Dictionary<string, string[]>(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;
}
/// <summary>
/// 解析单个 CSV 文件,将 Key → [EN, CN] 写入缓存。
/// 支持 RFC 4180 标准的双引号转义字段。
/// </summary>
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;
}
/// <summary>
/// RFC 4180 兼容的 CSV 行拆分,处理双引号包围的字段及内部双引号转义。
/// </summary>
private static string[] SplitCsvRow(string line)
{
var fields = new List<string>();
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();
}
// ─────────────────────────────────────────────────────────────────────
// 文本处理
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 根据当前语言返回本地化文本。若未找到,退回显示 Key 本身。
/// </summary>
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
}
/// <summary>
/// 将功能文本中的宏替换为可读形式,用于在预览中近似展示。
/// $Keyword("xxx") → [xxx]
/// $Attribute("xxx") → {xxx}
/// </summary>
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;
}
// ─────────────────────────────────────────────────────────────────────
// 工具方法
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 在 baseRect 内部按比例偏移创建子矩形。
/// padH / padV 是左右/上方的水平/垂直内边距(相对于卡牌宽/高的比值)。
/// </summary>
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);
}
/// <summary>
/// 根据当前卡牌绘制高度动态计算字号,确保缩小窗口时文字不溢出。
/// </summary>
private static int CalculateFontSize(float cardHeight)
{
// 字号随卡牌高度线性缩放base 400px → 13pt
int size = Mathf.RoundToInt(cardHeight * 13f / 400f);
return Mathf.Clamp(size, 9, 16);
}
/// <summary>
/// 打开 Unity 内置的 CardData 类型选择器Object Picker
/// </summary>
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<string>();
var paths = new List<string>();
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<CardData>(selectedPath);
SetTarget(cd);
});
}
// ─────────────────────────────────────────────────────────────────────
// 内部CardData 选择弹窗
// ─────────────────────────────────────────────────────────────────────
private class CardDataPickerWindow : EditorWindow
{
private List<string> _names;
private List<string> _paths;
private Action<string> _onSelect;
private string _search = "";
private Vector2 _scroll;
public static void Show(List<string> names, List<string> paths, Action<string> onSelect)
{
var w = GetWindow<CardDataPickerWindow>(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