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;
}
}
}