using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using ArtResource; using DesignTools.Common; using OfficeOpenXml; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; namespace DesignTools.Friends { /// /// 好友馈赠宝箱奖励配置工具 /// 读取 Docs/config/FriendTreasure.xlsx 的 Chest Sheet /// Items 的道具从 Item.xlsx 获取,支持查看图片、编辑数量与概率、增删行 /// public class FriendTreasureChestEditor : BaseDesignToolEditor { private const string FRIEND_TREASURE_EXCEL_NAME = "FriendTreasure.xlsx"; private const string FRIEND_TREASURE_SHEET_NAME = "Chest"; private const string ITEM_EXCEL_NAME = "Item.xlsx"; private const string ITEM_SHEET_NAME = "Item"; private const string ART_SO_PATH = "Assets/Art_SubModule/Art_SO"; private const int COL_ID = 1; private const int COL_ITEMS = 2; private const int COL_PROB = 3; private const int DATA_START_ROW = 3; private const int PROB_TOTAL = 1000; private readonly List chestRewardList = new List(); private readonly Dictionary itemDict = new Dictionary(); private readonly List allArtTables = new List(); private readonly List allItemTypes = new List(); [MenuItem("策划工具/好友/好友馈赠/宝箱奖励及概率")] public static void ShowWindow() { var window = GetWindow("宝箱奖励及概率"); window.minSize = window.GetMinWindowSize(); window.Show(); } protected override string GetDocsPathPrefKey() { return "FriendTreasureChestEditor_DocsPath"; } protected override string GetWindowTitle() { return "好友馈赠宝箱奖励及概率"; } protected override Vector2 GetMinWindowSize() { return new Vector2(1200, 680); } protected override void LoadConfigData() { LoadAllArtTableSO(); LoadItemData(); LoadChestData(); } protected override void DrawDataEditor() { DrawTipsAndToolbar(); EditorGUILayout.Space(6); DrawChestRewardTable(); } protected override void SaveDataToExcel() { SaveToExcel(); } private void DrawTipsAndToolbar() { EditorGUILayout.BeginVertical("box"); // EditorGUILayout.HelpBox("保存时只会写回 FriendTreasure.xlsx 的 Chest Sheet,不会改动其它 Sheet。", MessageType.Info); // EditorGUILayout.HelpBox("新增只能追加到最后;删除任意一行后,后续 Id 会自动前移并从 1 开始重新编号。", MessageType.Info); int totalProb = chestRewardList.Sum(x => x.Prob); float totalPercent = totalProb / 10f; MessageType messageType = totalProb == PROB_TOTAL ? MessageType.Info : MessageType.Error; EditorGUILayout.HelpBox($"当前总概率:{totalProb}/{PROB_TOTAL}({totalPercent:F1}%)", messageType); EditorGUILayout.BeginHorizontal(); GUI.backgroundColor = Color.green; if (GUILayout.Button("+ 新增一行", GUILayout.Width(120), GUILayout.Height(28))) { AddNewRow(); } GUI.backgroundColor = Color.white; GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } private void DrawChestRewardTable() { EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Id", EditorStyles.boldLabel, GUILayout.Width(50)); EditorGUILayout.LabelField("道具类型", EditorStyles.boldLabel, GUILayout.Width(130)); EditorGUILayout.LabelField("奖励道具(可输ID / 搜索)", EditorStyles.boldLabel, GUILayout.Width(470)); EditorGUILayout.LabelField("数量", EditorStyles.boldLabel, GUILayout.Width(80)); EditorGUILayout.LabelField("概率", EditorStyles.boldLabel, GUILayout.Width(80)); EditorGUILayout.LabelField("百分比", EditorStyles.boldLabel, GUILayout.Width(90)); EditorGUILayout.LabelField("预览", EditorStyles.boldLabel, GUILayout.Width(70)); EditorGUILayout.LabelField("操作", EditorStyles.boldLabel, GUILayout.Width(70)); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(4); scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); for (int i = 0; i < chestRewardList.Count; i++) { FriendTreasureChestRowData rowData = chestRewardList[i]; DrawSingleRow(i, rowData); EditorGUILayout.Space(4); } EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); } private void DrawSingleRow(int index, FriendTreasureChestRowData rowData) { EditorGUILayout.BeginVertical("box"); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(rowData.Id.ToString(), GUILayout.Width(50)); DrawItemSelector(rowData); rowData.Num = EditorGUILayout.IntField(rowData.Num, GUILayout.Width(80)); rowData.Prob = EditorGUILayout.IntField(rowData.Prob, GUILayout.Width(80)); EditorGUILayout.LabelField(GetPercentText(rowData.Prob), GUILayout.Width(90)); DrawItemPreview(rowData.RewardItemId); GUI.backgroundColor = new Color(1f, 0.55f, 0.55f); if (GUILayout.Button("删除", GUILayout.Width(60), GUILayout.Height(22))) { RemoveRow(index); GUI.backgroundColor = Color.white; GUIUtility.ExitGUI(); } GUI.backgroundColor = Color.white; EditorGUILayout.EndHorizontal(); if (itemDict.TryGetValue(rowData.RewardItemId, out FriendTreasureItemData itemData)) { EditorGUILayout.BeginHorizontal(); GUILayout.Space(54); EditorGUILayout.LabelField($"当前道具:{itemData.Name} ({itemData.Id})", EditorStyles.miniLabel); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndVertical(); } private void DrawItemSelector(FriendTreasureChestRowData rowData) { EnsureInputItemId(rowData); int currentType = GetCurrentItemType(rowData.RewardItemId); if (currentType < 0 && allItemTypes.Count > 0) { currentType = allItemTypes[0]; } int currentTypeIndex = Mathf.Max(0, allItemTypes.IndexOf(currentType)); string[] typeOptions = allItemTypes.Select(GetItemTypeDisplayName).ToArray(); if (typeOptions.Length == 0) { EditorGUILayout.LabelField("无可用类型", GUILayout.Width(130)); EditorGUILayout.LabelField("无可用道具", GUILayout.Width(470)); return; } int newTypeIndex = EditorGUILayout.Popup(currentTypeIndex, typeOptions, GUILayout.Width(130)); int selectedType = allItemTypes[newTypeIndex]; List itemsOfType = itemDict.Values .Where(x => x.IType == selectedType) .OrderBy(x => x.Id) .ToList(); if (itemsOfType.Count == 0) { EditorGUILayout.LabelField("该类型无道具", GUILayout.Width(470)); return; } EditorGUILayout.BeginVertical(GUILayout.Width(470)); EditorGUILayout.BeginHorizontal(); int newInputItemId = EditorGUILayout.IntField(rowData.InputItemId, GUILayout.Width(90)); if (newInputItemId != rowData.InputItemId) { rowData.InputItemId = newInputItemId; if (itemDict.ContainsKey(newInputItemId)) { SetRowItem(rowData, newInputItemId); currentType = GetCurrentItemType(rowData.RewardItemId); selectedType = currentType; itemsOfType = itemDict.Values .Where(x => x.IType == selectedType) .OrderBy(x => x.Id) .ToList(); } } GUI.backgroundColor = new Color(0.75f, 0.9f, 1f); if (GUILayout.Button("查找", GUILayout.Width(55), GUILayout.Height(20))) { EditorApplication.delayCall += () => OpenItemPickerWindow(rowData); GUI.backgroundColor = Color.white; GUIUtility.ExitGUI(); } GUI.backgroundColor = Color.white; if (itemDict.TryGetValue(rowData.InputItemId, out FriendTreasureItemData inputItemData)) { EditorGUILayout.LabelField($"{inputItemData.Name} ({inputItemData.Id})", EditorStyles.miniLabel, GUILayout.Width(300)); } else { Color oldColor = GUI.color; GUI.color = new Color(1f, 0.35f, 0.35f); EditorGUILayout.LabelField("未找到该 ItemId", EditorStyles.miniLabel, GUILayout.Width(300)); GUI.color = oldColor; } EditorGUILayout.EndHorizontal(); int currentItemIndex = itemsOfType.FindIndex(x => x.Id == rowData.RewardItemId); if (currentItemIndex < 0) { currentItemIndex = 0; SetRowItem(rowData, itemsOfType[0].Id); } string[] itemOptions = itemsOfType .Select(x => $"{x.Name} ({x.Id})") .ToArray(); int newItemIndex = EditorGUILayout.Popup(currentItemIndex, itemOptions, GUILayout.Width(460)); SetRowItem(rowData, itemsOfType[newItemIndex].Id); EditorGUILayout.EndVertical(); } private void EnsureInputItemId(FriendTreasureChestRowData rowData) { if (rowData.InputItemId <= 0) { rowData.InputItemId = rowData.RewardItemId; } } private void SetRowItem(FriendTreasureChestRowData rowData, int itemId) { rowData.RewardItemId = itemId; rowData.InputItemId = itemId; } private void OpenItemPickerWindow(FriendTreasureChestRowData rowData) { int currentType = GetCurrentItemType(rowData.RewardItemId); DesignToolItemPickerWindow.ShowWindow( itemDict.Values .Select(x => new DesignToolItemPickerItem { Id = x.Id, Name = x.Name, IType = x.IType }) .ToList(), allItemTypes, ITEM_TYPE_NAMES, currentType, rowData.RewardItemId, position, selectedItemId => { SetRowItem(rowData, selectedItemId); Repaint(); }); } private void AddNewRow() { int defaultItemId = itemDict.Count > 0 ? itemDict.Values.OrderBy(x => x.Id).First().Id : 0; chestRewardList.Add(new FriendTreasureChestRowData { Id = chestRewardList.Count + 1, RewardItemId = defaultItemId, InputItemId = defaultItemId, Num = 1, Prob = 0 }); } private void RemoveRow(int index) { if (index < 0 || index >= chestRewardList.Count) { return; } chestRewardList.RemoveAt(index); ReassignIds(); } private void ReassignIds() { for (int i = 0; i < chestRewardList.Count; i++) { chestRewardList[i].Id = i + 1; } } private void LoadAllArtTableSO() { allArtTables.Clear(); string[] guids = AssetDatabase.FindAssets("t:ArtTableSO", new[] { ART_SO_PATH }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); ArtTableSO artTable = AssetDatabase.LoadAssetAtPath(path); if (artTable != null) { allArtTables.Add(artTable); } } Debug.Log($"加载了 {allArtTables.Count} 个 ArtTableSO 资源"); } private void LoadItemData() { itemDict.Clear(); allItemTypes.Clear(); string itemExcelPath = GetDocsConfigFilePath(ITEM_EXCEL_NAME); if (!File.Exists(itemExcelPath)) { throw new FileNotFoundException($"未找到 {ITEM_EXCEL_NAME}", itemExcelPath); } using (var package = new ExcelPackage(new FileInfo(itemExcelPath))) { var worksheet = package.Workbook.Worksheets[ITEM_SHEET_NAME]; if (worksheet == null) { throw new Exception($"{ITEM_EXCEL_NAME} 中未找到 Sheet: {ITEM_SHEET_NAME}"); } int idCol = -1; int nameCol = -1; int iTypeCol = -1; int resCol = -1; int columnCount = worksheet.Dimension?.Columns ?? 0; for (int col = 1; col <= columnCount; col++) { string header = worksheet.Cells[1, col].Text; 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?.Rows ?? 0; 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 name = worksheet.Cells[row, nameCol].Text.Trim(); string iTypeText = worksheet.Cells[row, iTypeCol].Text.Trim(); string res = resCol > 0 ? worksheet.Cells[row, resCol].Text.Trim() : string.Empty; if (!int.TryParse(iTypeText, out int iType)) { continue; } itemDict[id] = new FriendTreasureItemData { Id = id, Name = name, IType = iType, Res = res }; } } foreach (int itemType in itemDict.Values.Select(x => x.IType).Distinct().OrderBy(x => x)) { allItemTypes.Add(itemType); } Debug.Log($"加载了 {itemDict.Count} 个 Item 数据"); } private void LoadChestData() { chestRewardList.Clear(); string excelPath = GetDocsConfigFilePath(FRIEND_TREASURE_EXCEL_NAME); if (!File.Exists(excelPath)) { throw new FileNotFoundException($"未找到 {FRIEND_TREASURE_EXCEL_NAME}", excelPath); } using (var package = new ExcelPackage(new FileInfo(excelPath))) { var worksheet = package.Workbook.Worksheets[FRIEND_TREASURE_SHEET_NAME]; if (worksheet == null) { throw new Exception($"未找到 Sheet: {FRIEND_TREASURE_SHEET_NAME}"); } int rowCount = worksheet.Dimension?.Rows ?? 0; for (int row = DATA_START_ROW; row <= rowCount; row++) { string idText = worksheet.Cells[row, COL_ID].Text.Trim(); if (string.IsNullOrEmpty(idText)) { continue; } if (!int.TryParse(idText, out int id)) { continue; } string itemsText = worksheet.Cells[row, COL_ITEMS].Text.Trim(); int prob = ParseInt(worksheet.Cells[row, COL_PROB].Text.Trim()); if (!TryParseItems(itemsText, out int itemId, out int num)) { Debug.LogWarning($"{FRIEND_TREASURE_EXCEL_NAME}/{FRIEND_TREASURE_SHEET_NAME} 第 {row} 行 Items 格式异常,已使用默认值"); itemId = itemDict.Count > 0 ? itemDict.Keys.Min() : 0; num = 1; } chestRewardList.Add(new FriendTreasureChestRowData { Id = id, RewardItemId = itemId, InputItemId = itemId, Num = num, Prob = prob, RowIndex = row }); } } ReassignIds(); } private void SaveToExcel() { ReassignIds(); if (!ValidateBeforeSave(out string errorMessage)) { EditorUtility.DisplayDialog("校验失败", errorMessage, "确定"); return; } try { string excelPath = GetDocsConfigFilePath(FRIEND_TREASURE_EXCEL_NAME); using (var package = new ExcelPackage(new FileInfo(excelPath))) { var worksheet = package.Workbook.Worksheets[FRIEND_TREASURE_SHEET_NAME]; if (worksheet == null) { EditorUtility.DisplayDialog("错误", $"未找到 Sheet: {FRIEND_TREASURE_SHEET_NAME}", "确定"); return; } int rowCount = worksheet.Dimension?.Rows ?? 0; for (int i = 0; i < chestRewardList.Count; i++) { FriendTreasureChestRowData rowData = chestRewardList[i]; int row = DATA_START_ROW + i; worksheet.Cells[row, COL_ID].Value = rowData.Id; worksheet.Cells[row, COL_ITEMS].Value = BuildItemsJson(rowData.RewardItemId, rowData.Num); worksheet.Cells[row, COL_PROB].Value = rowData.Prob; } for (int row = DATA_START_ROW + chestRewardList.Count; row <= rowCount; row++) { worksheet.Cells[row, COL_ID].Value = null; worksheet.Cells[row, COL_ITEMS].Value = null; worksheet.Cells[row, COL_PROB].Value = null; } package.Save(); } EditorUtility.DisplayDialog("成功", "宝箱奖励及概率已保存到 Chest Sheet!", "确定"); Debug.Log("宝箱奖励及概率已保存"); } catch (Exception e) { EditorUtility.DisplayDialog("错误", $"保存失败: {e.Message}", "确定"); Debug.LogError($"保存宝箱奖励及概率失败: {e}"); } } private bool ValidateBeforeSave(out string errorMessage) { if (chestRewardList.Count == 0) { errorMessage = "至少需要保留一条宝箱奖励数据。"; return false; } for (int i = 0; i < chestRewardList.Count; i++) { FriendTreasureChestRowData rowData = chestRewardList[i]; if (!itemDict.ContainsKey(rowData.RewardItemId)) { errorMessage = $"第 {i + 1} 行选择的 ItemId 不存在于 Item.xlsx 中。"; return false; } if (rowData.Num < 1) { errorMessage = $"第 {i + 1} 行的数量必须大于等于 1。"; return false; } if (rowData.Prob < 0) { errorMessage = $"第 {i + 1} 行的概率不能小于 0。"; return false; } } int totalProb = chestRewardList.Sum(x => x.Prob); if (totalProb != PROB_TOTAL) { errorMessage = $"所有概率之和必须等于 {PROB_TOTAL},当前为 {totalProb}。"; return false; } errorMessage = string.Empty; return true; } private bool TryParseItems(string itemsText, out int itemId, out int num) { itemId = 0; num = 0; if (string.IsNullOrWhiteSpace(itemsText)) { return false; } Match idMatch = Regex.Match(itemsText, @"""Id""\s*:\s*(\d+)"); Match numMatch = Regex.Match(itemsText, @"""Num""\s*:\s*(-?\d+)"); if (!idMatch.Success || !numMatch.Success) { return false; } return int.TryParse(idMatch.Groups[1].Value, out itemId) && int.TryParse(numMatch.Groups[1].Value, out num); } private string BuildItemsJson(int itemId, int num) { return $"[{{\"Id\":{itemId},\"Num\":{num}}}]"; } private int ParseInt(string text) { return int.TryParse(text, out int value) ? value : 0; } private string GetPercentText(int prob) { return $"{prob / 10f:F1}%"; } private int GetCurrentItemType(int itemId) { return itemDict.TryGetValue(itemId, out FriendTreasureItemData itemData) ? itemData.IType : -1; } private string GetItemTypeDisplayName(int iType) { return ITEM_TYPE_NAMES.TryGetValue(iType, out string typeName) ? $"{typeName} ({iType})" : $"类型 {iType}"; } private void DrawItemPreview(int itemId) { if (!itemDict.TryGetValue(itemId, out FriendTreasureItemData itemData) || string.IsNullOrEmpty(itemData.Res)) { EditorGUILayout.LabelField("无图", GUILayout.Width(70)); return; } string[] parts = itemData.Res.Split(','); if (parts.Length != 2) { EditorGUILayout.LabelField("无图", GUILayout.Width(70)); return; } if (!int.TryParse(parts[0], out int tableId) || !int.TryParse(parts[1], out int artItemId)) { EditorGUILayout.LabelField("无图", GUILayout.Width(70)); return; } ArtTableSO artTable = allArtTables.FirstOrDefault(x => x.TableId == tableId); if (artTable == null) { EditorGUILayout.LabelField("无图", GUILayout.Width(70)); return; } var artItem = artTable.Items.FirstOrDefault(x => x.Id == artItemId); if (artItem == null || artItem.Sprite == null) { EditorGUILayout.LabelField("无图", GUILayout.Width(70)); return; } Rect rect = GUILayoutUtility.GetRect(50, 50, GUILayout.Width(50), GUILayout.Height(50)); GUI.DrawTexture(rect, artItem.Sprite.texture, ScaleMode.ScaleToFit); } private static readonly Dictionary ITEM_TYPE_NAMES = new Dictionary { { 1, "能量" }, { 2, "星星" }, { 3, "钻石" }, { 97, "Playroom宠物道具" }, { 98, "卡牌" }, { 99, "背包道具" }, { 100, "棋子" }, { 101, "卡包" }, { 102, "限时事件" }, { 103, "小猪存钱罐" }, { 104, "万能卡" }, { 105, "头像框" }, { 106, "活动代币" }, { 107, "竞赛游戏代币" }, { 108, "Pet Playroom拜访道具" }, { 109, "表情" }, { 110, "头像" }, { 111, "Playroom装饰" }, { 112, "Playroom服装" }, { 113, "Playroom装饰套装" }, { 114, "Playroom服装套装" }, { 115, "Playroom道具宝箱" }, { 116, "活动通行证代币道具" } }; } [Serializable] public class FriendTreasureChestRowData { public int Id; public int RewardItemId; public int InputItemId; public int Num; public int Prob; public int RowIndex; } [Serializable] public class FriendTreasureItemData { public int Id; public string Name; public int IType; public string Res; } }