using System; using System.Collections.Generic; using System.IO; using System.Linq; using OfficeOpenXml; using ArtResource; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; namespace DesignTools.LimitedTimeEvent { /// /// 限时事件投放配置工具 /// 读取 Docs/config/LimitedTimeEvent.xlsx 的 Series Sheet /// Items字段通过读取 Item.xlsx 中 IType=102 的条目进行选择 /// public class LimitedEventSeriesEditor : BaseDesignToolEditor { private const string LIMITED_TIME_EVENT_EXCEL_NAME = "LimitedTimeEvent.xlsx"; private const string ITEM_EXCEL_NAME = "Item.xlsx"; private const string SERIES_SHEET_NAME = "Jackpot"; private const string ITEM_SHEET_NAME = "Item"; private const string ART_SO_PATH = "Assets/Art_SubModule/Art_SO"; private const int HEADER_ROW = 1; private const int DATA_START_ROW = 3; // 列索引 private int colId = -1; private int colBonusLv = -1; private int colItems = -1; private int colType = -1; private int colProb = -1; private int colNote = -1; private readonly List seriesDataList = new List(); private readonly List limitedEventItems = new List(); private readonly Dictionary limitedEventItemById = new Dictionary(); private readonly Dictionary artTableByName = new Dictionary(StringComparer.OrdinalIgnoreCase); [MenuItem("策划工具/限时事件/限时事件投放")] public static void ShowWindow() { var window = GetWindow("限时事件投放"); window.minSize = window.GetMinWindowSize(); window.Show(); } protected override string GetDocsPathPrefKey() { return "LimitedEventSeriesEditor_DocsPath"; } protected override string GetWindowTitle() { return "限时事件投放配置工具"; } protected override Vector2 GetMinWindowSize() { return new Vector2(1100, 600); } protected override void LoadConfigData() { LoadAllArtTableSO(); LoadLimitedEventItems(); LoadSeriesSheet(); } protected override void DrawDataEditor() { DrawTableHeader(); scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); for (int i = 0; i < seriesDataList.Count; i++) { DrawSeriesRow(seriesDataList[i]); } EditorGUILayout.EndScrollView(); EditorGUILayout.Space(4); DrawAddRemoveButtons(); } protected override void SaveDataToExcel() { SaveSeriesSheet(); } #region 数据加载 /// /// 加载所有ArtTableSO资源 /// private void LoadAllArtTableSO() { artTableByName.Clear(); string[] guids = AssetDatabase.FindAssets("t:ArtTableSO", new[] { ART_SO_PATH }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); ArtTableSO tableSO = AssetDatabase.LoadAssetAtPath(path); if (tableSO == null || string.IsNullOrEmpty(tableSO.TableName)) { continue; } artTableByName[tableSO.TableName] = tableSO; } Debug.Log($"加载了 {artTableByName.Count} 个 ArtTableSO 资源表"); } /// /// 从Item.xlsx加载IType=102的道具 /// private void LoadLimitedEventItems() { limitedEventItems.Clear(); limitedEventItemById.Clear(); string excelPath = GetDocsConfigFilePath(ITEM_EXCEL_NAME); if (!File.Exists(excelPath)) { Debug.LogWarning($"未找到 {ITEM_EXCEL_NAME}: {excelPath}"); return; } using (var package = new ExcelPackage(new FileInfo(excelPath))) { var worksheet = package.Workbook.Worksheets[ITEM_SHEET_NAME]; if (worksheet == null || worksheet.Dimension == null) { Debug.LogWarning($"{ITEM_EXCEL_NAME} 中未找到 Sheet: {ITEM_SHEET_NAME}"); return; } int idCol = -1, nameCol = -1, iTypeCol = -1, resCol = -1; int columnCount = worksheet.Dimension.End.Column; for (int col = 1; col <= columnCount; col++) { string header = worksheet.Cells[HEADER_ROW, col].Text.Trim(); if (header == "Id") idCol = col; else if (header == "Name") nameCol = col; else if (header == "IType") iTypeCol = col; else if (header == "Res") resCol = col; } if (idCol < 0 || nameCol < 0 || iTypeCol < 0) { throw new Exception("Item.xlsx表结构不正确,缺少必要列(Id/Name/IType)"); } int rowCount = worksheet.Dimension.End.Row; for (int row = DATA_START_ROW; row <= rowCount; row++) { string idText = worksheet.Cells[row, idCol].Text.Trim(); if (string.IsNullOrEmpty(idText)) continue; if (!int.TryParse(idText, out int id)) continue; string iTypeText = worksheet.Cells[row, iTypeCol].Text.Trim(); if (!int.TryParse(iTypeText, out int iType)) continue; if (iType == 102) { string name = worksheet.Cells[row, nameCol].Text.Trim(); string res = resCol > 0 ? worksheet.Cells[row, resCol].Text.Trim() : ""; LimitedEventItemInfo itemInfo = new LimitedEventItemInfo { Id = id, Name = name, Res = res, PreviewSprite = GetPreviewSpriteByRes(res) }; limitedEventItems.Add(itemInfo); limitedEventItemById[id] = itemInfo; } } } limitedEventItems.Sort((left, right) => left.Id.CompareTo(right.Id)); Debug.Log($"从Item.xlsx加载了 {limitedEventItems.Count} 个限时事件道具(IType=102)"); } /// /// 加载Series Sheet数据 /// private void LoadSeriesSheet() { seriesDataList.Clear(); string excelPath = GetDocsConfigFilePath(LIMITED_TIME_EVENT_EXCEL_NAME); if (!File.Exists(excelPath)) { throw new FileNotFoundException($"未找到 {LIMITED_TIME_EVENT_EXCEL_NAME}", excelPath); } using (var package = new ExcelPackage(new FileInfo(excelPath))) { var worksheet = package.Workbook.Worksheets[SERIES_SHEET_NAME]; if (worksheet == null) { throw new Exception($"{LIMITED_TIME_EVENT_EXCEL_NAME} 中未找到 Sheet: {SERIES_SHEET_NAME}"); } if (worksheet.Dimension == null) { throw new Exception($"{LIMITED_TIME_EVENT_EXCEL_NAME}/{SERIES_SHEET_NAME} 没有可读取的数据"); } colId = FindColumnIndex(worksheet, "Id"); colBonusLv = FindColumnIndex(worksheet, "BonusLv"); colItems = FindColumnIndex(worksheet, "Items"); colType = FindColumnIndex(worksheet, "Type"); colProb = FindColumnIndex(worksheet, "Prob"); colNote = FindColumnIndex(worksheet, "备注"); if (colId < 0 || colBonusLv < 0 || colItems < 0 || colType < 0 || colProb < 0) { throw new Exception($"{SERIES_SHEET_NAME} 表结构不正确,缺少必要列(Id/BonusLv/Items/Type/Prob)"); } int rowCount = worksheet.Dimension.End.Row; for (int row = DATA_START_ROW; row <= rowCount; row++) { string idText = worksheet.Cells[row, colId].Text.Trim(); if (string.IsNullOrEmpty(idText)) continue; if (!int.TryParse(idText, out int id)) continue; var data = new SeriesRowData { RowIndex = row, Id = id, BonusLv = ParseInt(worksheet.Cells[row, colBonusLv].Text), ItemsJson = worksheet.Cells[row, colItems].Text.Trim(), Type = ParseInt(worksheet.Cells[row, colType].Text), Prob = ParseInt(worksheet.Cells[row, colProb].Text), Note = colNote > 0 ? worksheet.Cells[row, colNote].Text.Trim() : "" }; // 解析Items JSON中的第一个Id data.ParsedItemId = ParseItemIdFromJson(data.ItemsJson); seriesDataList.Add(data); } } Debug.Log($"限时事件投放配置读取完成,共加载 {seriesDataList.Count} 条数据"); } #endregion #region UI绘制 private void DrawTableHeader() { EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Id", EditorStyles.boldLabel, GUILayout.Width(50)); EditorGUILayout.LabelField("BonusLv", EditorStyles.boldLabel, GUILayout.Width(70)); EditorGUILayout.LabelField("Items (限时事件道具)", EditorStyles.boldLabel, GUILayout.Width(350)); EditorGUILayout.LabelField("Type", EditorStyles.boldLabel, GUILayout.Width(60)); EditorGUILayout.LabelField("Prob", EditorStyles.boldLabel, GUILayout.Width(60)); EditorGUILayout.LabelField("备注", EditorStyles.boldLabel, GUILayout.MinWidth(120)); EditorGUILayout.LabelField("", GUILayout.Width(20)); // 删除按钮占位 EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } private void DrawSeriesRow(SeriesRowData data) { EditorGUILayout.BeginHorizontal("box"); // Id data.Id = EditorGUILayout.IntField(data.Id, GUILayout.Width(50)); // BonusLv data.BonusLv = EditorGUILayout.IntField(data.BonusLv, GUILayout.Width(70)); // Items - 使用下拉选择IType=102的道具 DrawItemsField(data); // Type data.Type = EditorGUILayout.IntField(data.Type, GUILayout.Width(60)); // Prob data.Prob = EditorGUILayout.IntField(data.Prob, GUILayout.Width(60)); // 备注 data.Note = EditorGUILayout.TextField(data.Note, GUILayout.MinWidth(120)); // 删除按钮 if (GUILayout.Button("×", GUILayout.Width(20))) { if (EditorUtility.DisplayDialog("确认删除", $"确定删除 Id={data.Id} 的行吗?", "确定", "取消")) { seriesDataList.Remove(data); GUIUtility.ExitGUI(); } } EditorGUILayout.EndHorizontal(); } private void DrawItemsField(SeriesRowData data) { LimitedEventItemInfo itemInfo = GetLimitedEventItemInfo(data.ParsedItemId); bool hasPreview = itemInfo?.PreviewSprite != null && itemInfo.PreviewSprite.texture != null; EditorGUILayout.BeginHorizontal( "box", GUILayout.Width(350), GUILayout.MinHeight(hasPreview ? 68f : 42f)); if (hasPreview) { DrawSpritePreview(itemInfo.PreviewSprite, 48f, string.Empty); } EditorGUILayout.BeginVertical(); if (limitedEventItems.Count > 0) { string buttonText = itemInfo != null ? $"{itemInfo.Id} - {itemInfo.Name}" : data.ParsedItemId > 0 ? $"未找到道具 {data.ParsedItemId}" : "选择限时事件道具"; if (GUILayout.Button(buttonText, GUILayout.Height(22))) { SeriesRowData capturedData = data; LimitedEventItemPickerWindow.Show(limitedEventItems, position, data.ParsedItemId, selectedItem => { capturedData.ParsedItemId = selectedItem.Id; capturedData.ItemsJson = BuildItemsJson(selectedItem.Id); Repaint(); }); } if (itemInfo != null && !string.IsNullOrEmpty(itemInfo.Res)) { EditorGUILayout.LabelField(itemInfo.Res, EditorStyles.miniLabel); } } else { EditorGUILayout.LabelField("未加载到IType=102的道具", EditorStyles.miniLabel); data.ItemsJson = EditorGUILayout.TextField(data.ItemsJson); } EditorGUILayout.LabelField(data.ItemsJson, EditorStyles.miniLabel); EditorGUILayout.EndVertical(); EditorGUILayout.EndHorizontal(); } private void DrawAddRemoveButtons() { EditorGUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); if (GUILayout.Button("+ 添加行", GUILayout.Width(100), GUILayout.Height(25))) { int newId = seriesDataList.Count > 0 ? seriesDataList.Max(x => x.Id) + 1 : 1; int defaultItemId = limitedEventItems.Count > 0 ? limitedEventItems[0].Id : 0; seriesDataList.Add(new SeriesRowData { RowIndex = -1, Id = newId, BonusLv = 1, ItemsJson = defaultItemId > 0 ? $"[{{\"Id\":{defaultItemId},\"Num\":1}}]" : "[]", Type = 0, Prob = 0, Note = "", ParsedItemId = defaultItemId }); } EditorGUILayout.EndHorizontal(); } #endregion #region 数据保存 private void SaveSeriesSheet() { if (seriesDataList.Count == 0) { EditorUtility.DisplayDialog("提示", "当前没有可保存的数据,请先加载配置。", "确定"); return; } string excelPath = GetDocsConfigFilePath(LIMITED_TIME_EVENT_EXCEL_NAME); if (!File.Exists(excelPath)) { EditorUtility.DisplayDialog("错误", $"未找到文件:{excelPath}", "确定"); return; } try { using (var package = new ExcelPackage(new FileInfo(excelPath))) { var worksheet = package.Workbook.Worksheets[SERIES_SHEET_NAME]; if (worksheet == null) { EditorUtility.DisplayDialog("错误", $"未找到 Sheet: {SERIES_SHEET_NAME}", "确定"); return; } // 清除旧数据行(从DATA_START_ROW开始) int oldRowCount = worksheet.Dimension?.End.Row ?? 0; for (int row = DATA_START_ROW; row <= oldRowCount; row++) { for (int col = 1; col <= worksheet.Dimension.End.Column; col++) { worksheet.Cells[row, col].Value = null; } } // 写入新数据 for (int i = 0; i < seriesDataList.Count; i++) { int row = DATA_START_ROW + i; var data = seriesDataList[i]; worksheet.Cells[row, colId].Value = data.Id; worksheet.Cells[row, colBonusLv].Value = data.BonusLv; worksheet.Cells[row, colItems].Value = data.ItemsJson; worksheet.Cells[row, colType].Value = data.Type; worksheet.Cells[row, colProb].Value = data.Prob; if (colNote > 0) { worksheet.Cells[row, colNote].Value = data.Note; } } package.Save(); } EditorUtility.DisplayDialog("成功", "限时事件投放配置已保存到 Excel!", "确定"); Debug.Log("限时事件投放配置保存成功"); } catch (Exception e) { EditorUtility.DisplayDialog("错误", $"保存失败: {e.Message}", "确定"); Debug.LogError($"保存限时事件投放配置失败: {e}"); } } #endregion #region 工具方法 private LimitedEventItemInfo GetLimitedEventItemInfo(int itemId) { if (itemId <= 0) { return null; } limitedEventItemById.TryGetValue(itemId, out LimitedEventItemInfo itemInfo); return itemInfo; } private string BuildItemsJson(int itemId) { return itemId > 0 ? $"[{{\"Id\":{itemId},\"Num\":1}}]" : "[]"; } private Sprite GetPreviewSpriteByRes(string res) { if (!TryParseRes(res, out string tableName, out string artItemName)) { return null; } if (!artTableByName.TryGetValue(tableName, out ArtTableSO table) || table == null) { return null; } ArtItemData item = table.Items.FirstOrDefault(x => x.Name == artItemName); return item?.Sprite; } private static bool TryParseRes(string res, out string tableName, out string artItemName) { tableName = string.Empty; artItemName = string.Empty; if (string.IsNullOrWhiteSpace(res)) { return false; } string[] parts = res.Split(new[] { ',' }, 2, StringSplitOptions.None); if (parts.Length < 2) { return false; } tableName = parts[0].Trim(); artItemName = parts[1].Trim(); return !string.IsNullOrEmpty(tableName) && !string.IsNullOrEmpty(artItemName); } private static void DrawSpritePreview(Sprite sprite, float size, string emptyText) { Rect previewRect = GUILayoutUtility.GetRect(size, size, GUILayout.Width(size), GUILayout.Height(size)); DrawSpritePreview(previewRect, sprite, emptyText); } private static void DrawSpritePreview(Rect previewRect, Sprite sprite, string emptyText) { if (sprite != null && sprite.texture != null) { Rect texCoords = sprite.textureRect; Texture2D tex = sprite.texture; Rect normalizedCoords = new Rect( texCoords.x / tex.width, texCoords.y / tex.height, texCoords.width / tex.width, texCoords.height / tex.height ); EditorGUI.DrawRect(previewRect, new Color(0.4f, 0.4f, 0.4f, 1f)); float aspect = texCoords.height <= 0f ? 1f : texCoords.width / texCoords.height; Rect drawRect = previewRect; if (aspect > 1f) { float newHeight = previewRect.height / aspect; drawRect.y += (previewRect.height - newHeight) * 0.5f; drawRect.height = newHeight; } else { float newWidth = previewRect.width * aspect; drawRect.x += (previewRect.width - newWidth) * 0.5f; drawRect.width = newWidth; } GUI.DrawTextureWithTexCoords(drawRect, tex, normalizedCoords); return; } EditorGUI.DrawRect(previewRect, new Color(0.3f, 0.3f, 0.3f, 1f)); GUIStyle emptyStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) { wordWrap = true, alignment = TextAnchor.MiddleCenter }; GUI.Label(previewRect, emptyText, emptyStyle); } private int FindColumnIndex(ExcelWorksheet worksheet, string columnName) { int columnCount = worksheet.Dimension?.End.Column ?? 0; for (int col = 1; col <= columnCount; col++) { if (string.Equals(worksheet.Cells[HEADER_ROW, col].Text.Trim(), columnName, StringComparison.OrdinalIgnoreCase)) { return col; } } return -1; } private static int ParseInt(string text) { if (int.TryParse(text?.Trim(), out int value)) return value; return 0; } /// /// 从Items的JSON中解析出第一个Id /// 输入格式: [{"Id":100011,"Num":1}] /// private static int ParseItemIdFromJson(string json) { if (string.IsNullOrEmpty(json)) return 0; int idIndex = json.IndexOf("\"Id\"", StringComparison.OrdinalIgnoreCase); if (idIndex < 0) return 0; int colonIndex = json.IndexOf(':', idIndex); if (colonIndex < 0) return 0; int start = colonIndex + 1; while (start < json.Length && char.IsWhiteSpace(json[start])) { start++; } int end = start; while (end < json.Length && (char.IsDigit(json[end]) || json[end] == '-')) { end++; } if (end > start && int.TryParse(json.Substring(start, end - start), out int id)) return id; return 0; } #endregion [Serializable] private class LimitedEventItemInfo { public int Id; public string Name; public string Res; public Sprite PreviewSprite; } private class LimitedEventItemPickerWindow : EditorWindow { private readonly List allItems = new List(); private Action onSelect; private string searchText = string.Empty; private Vector2 scrollPosition; private int currentItemId; private const float IMAGE_SIZE = 84f; private const float ROW_HEIGHT_WITH_PREVIEW = 108f; private const float ROW_HEIGHT_NO_PREVIEW = 54f; public static void Show(List items, Rect ownerWindowRect, int currentItemId, Action onSelect) { if (items == null || items.Count == 0) { EditorUtility.DisplayDialog("提示", "未加载到 IType=102 的道具", "确定"); return; } LimitedEventItemPickerWindow window = CreateInstance(); window.titleContent = new GUIContent("选择限时事件道具"); window.minSize = new Vector2(620, 520); window.allItems.Clear(); window.allItems.AddRange(items.OrderBy(x => x.Id)); window.currentItemId = currentItemId; window.onSelect = onSelect; window.position = GetCenteredRect(ownerWindowRect, window.minSize); window.ShowUtility(); } private static Rect GetCenteredRect(Rect ownerWindowRect, Vector2 windowSize) { float x = ownerWindowRect.x + (ownerWindowRect.width - windowSize.x) * 0.5f; float y = ownerWindowRect.y + (ownerWindowRect.height - windowSize.y) * 0.5f; return new Rect(x, y, windowSize.x, windowSize.y); } private void OnGUI() { DrawToolbar(); EditorGUILayout.Space(4f); DrawItemList(); } private void DrawToolbar() { EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); EditorGUILayout.LabelField("搜索", GUILayout.Width(40)); searchText = EditorGUILayout.TextField(searchText, EditorStyles.toolbarSearchField); if (GUILayout.Button("×", EditorStyles.miniButton, GUILayout.Width(20))) { searchText = string.Empty; GUI.FocusControl(null); } EditorGUILayout.EndHorizontal(); int filteredCount = allItems.Count(MatchSearch); EditorGUILayout.LabelField($"共 {filteredCount} 个限时事件道具,点击即可选择", EditorStyles.miniLabel); EditorGUILayout.EndVertical(); } private void DrawItemList() { List filteredItems = allItems.Where(MatchSearch).ToList(); scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); foreach (LimitedEventItemInfo item in filteredItems) { DrawItemRow(item); } EditorGUILayout.EndScrollView(); } private void DrawItemRow(LimitedEventItemInfo item) { bool isCurrent = item.Id == currentItemId; bool hasPreview = item.PreviewSprite != null && item.PreviewSprite.texture != null; float rowHeight = hasPreview ? ROW_HEIGHT_WITH_PREVIEW : ROW_HEIGHT_NO_PREVIEW; GUI.backgroundColor = isCurrent ? new Color(0.8f, 1f, 0.8f) : Color.white; EditorGUILayout.BeginHorizontal("box", GUILayout.ExpandWidth(true), GUILayout.MinHeight(rowHeight)); if (hasPreview) { Rect imageRect = GUILayoutUtility.GetRect(IMAGE_SIZE, IMAGE_SIZE, GUILayout.Width(IMAGE_SIZE), GUILayout.Height(IMAGE_SIZE)); DrawSpritePreview(imageRect, item.PreviewSprite, string.Empty); } EditorGUILayout.BeginVertical(); GUIStyle nameStyle = new GUIStyle(EditorStyles.boldLabel) { wordWrap = true, alignment = TextAnchor.UpperLeft }; GUILayout.Label($"{item.Id} - {item.Name}", nameStyle); if (!string.IsNullOrEmpty(item.Res)) { GUIStyle resStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) { wordWrap = true, alignment = TextAnchor.UpperLeft }; GUILayout.Label(item.Res, resStyle); } EditorGUILayout.EndVertical(); GUILayout.FlexibleSpace(); if (GUILayout.Button("选择", EditorStyles.miniButton)) { onSelect?.Invoke(item); Close(); } EditorGUILayout.EndHorizontal(); GUI.backgroundColor = Color.white; } private bool MatchSearch(LimitedEventItemInfo item) { if (string.IsNullOrWhiteSpace(searchText)) { return true; } return item.Id.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrEmpty(item.Name) && item.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase)) || (!string.IsNullOrEmpty(item.Res) && item.Res.Contains(searchText, StringComparison.OrdinalIgnoreCase)); } } [Serializable] private class SeriesRowData { public int RowIndex; public int Id; public int BonusLv; public string ItemsJson; public int Type; public int Prob; public string Note; /// /// 从ItemsJson解析出的道具Id,用于下拉选择 /// public int ParsedItemId; } } }