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