diff --git a/CLAUDE.md b/CLAUDE.md index e2fb6f09..32086798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,12 @@ wow_process.txt 目标游戏进程名列表;构建时复制,运行期间每 [Infrastructure/ClassBlocksStore.cs](Infrastructure/ClassBlocksStore.cs) 读写 `class/*.lua` 中的 `Fuyutsui.ClassBlocks` 表。每个职业一个 Lua 文件,按专精 ID 分块,包含: - **States**(状态字段):分平面列表或按 `"状态"/"目标"/"焦点"` 分类(现代格式) - **Auras**(光环):5 桶——玩家/目标有害/目标有益/焦点有害/焦点有益 -- **Spells**(法术):ID、名称、充能、施法计数、强制已知、法术书 +- **Spells**(技能冷却):ID、名称、充能、施法计数、强制已知、法术书 +- **Items**(物品冷却):专精级 `[itemId] = { name, isEquipped }` - **Group**(队伍):人数/生命百分比/角色/驱散 + 队伍光环列表 +同文件另有职业级 `Fuyutsui.spellsList` 与 `Fuyutsui.itemsList`(`[itemId] = { name }`),与专精冷却表分开。 + 字段名从 [Infrastructure/ClassStateCatalog.cs](Infrastructure/ClassStateCatalog.cs) 的静态目录验证,不允许自由输入。 ### 宏存储(ClassMacros) @@ -99,7 +102,7 @@ wow_process.txt 目标游戏进程名列表;构建时复制,运行期间每 ### UI 编辑器 -- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的六页编辑器(状态/光环/法术/物品/队伍/技能列表),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`。 +- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的六页编辑器(状态/光环/冷却/物品列表/队伍/技能列表),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`。 - [UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs):左侧职业列表 + 右侧三页编辑器(动态宏/静态宏/特殊宏),偏移提示显示槽位编号计算。 - 两个编辑器均接受 `Func` 项目路径解析器 + `Func>` 保存回调,由 `MainForm` 在构造时注入。保存流程:编辑器调 `Store.Save()` → 传入已保存文件路径 → 重新生成 config/keymap → 单文件部署游戏 → 重启运行时;部署失败返回说明,但不回滚本地文件。 - 配置更新的多个入口通过任务尾队列串行执行;运行时重启会等待该队列稳定,主窗口关闭也会等待正在写盘的转换和部署完成。新增同步入口必须继续走这条队列。 diff --git a/Infrastructure/ClassBlocksStore.cs b/Infrastructure/ClassBlocksStore.cs index 51a25981..008f98ea 100644 --- a/Infrastructure/ClassBlocksStore.cs +++ b/Infrastructure/ClassBlocksStore.cs @@ -7,12 +7,13 @@ namespace Shigure; /// /// 读写 Fuyutsui class/*.lua 中的 ClassBlocks(states/auras/spells/items/group), -/// 同时读写 spellsList;保存时替换 ClassBlocks 表字面量,并原位更新 spellsList 中已编辑的条目。 +/// 同时读写 spellsList 与 itemsList;保存时替换 ClassBlocks 表字面量,并原位更新列表条目。 /// internal static class ClassBlocksStore { public const string AssignmentName = "Fuyutsui.ClassBlocks"; public const string SpellsListAssignmentName = "Fuyutsui.spellsList"; + public const string ItemsListAssignmentName = "Fuyutsui.itemsList"; private static readonly string[] StateCategories = [ ClassStateCatalog.CategoryState, @@ -38,6 +39,8 @@ internal static class ClassBlocksStore public Dictionary Specs { get; set; } = new(); public List SpellsList { get; set; } = new(); public HashSet DeletedSpellsListOriginalIds { get; } = new(); + public List ItemsList { get; set; } = new(); + public HashSet DeletedItemsListOriginalIds { get; } = new(); public bool IsModernFormat { get; set; } } @@ -51,6 +54,14 @@ internal static class ClassBlocksStore public string OriginalName { get; set; } = string.Empty; } + public sealed class ItemsListEntry + { + public long ItemId { get; set; } + public string Name { get; set; } = string.Empty; + public long OriginalItemId { get; set; } + public string OriginalName { get; set; } = string.Empty; + } + public sealed class SpecBlocks { public bool NestedStates { get; set; } = true; @@ -140,6 +151,7 @@ internal static class ClassBlocksStore } var spellsList = ParseSpellsList(ExtractAssignedTable(source, SpellsListAssignmentName)); + var itemsList = ParseItemsList(ExtractAssignedTable(source, ItemsListAssignmentName)); return new ClassFileDocument { FilePath = filePath, @@ -148,6 +160,7 @@ internal static class ClassBlocksStore TableEndExclusive = end, Specs = specs, SpellsList = spellsList, + ItemsList = itemsList, IsModernFormat = modern }; } @@ -192,6 +205,39 @@ internal static class ClassBlocksStore return result; } + private static List ParseItemsList(TableValue? table) + { + var result = new List(); + if (table is null) + { + return result; + } + + foreach (var (key, value) in table.Entries) + { + if (key is not long itemId || itemId <= 0 || value is not TableValue item) + { + continue; + } + + var name = item.GetString("name")?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + result.Add(new ItemsListEntry + { + ItemId = itemId, + Name = name, + OriginalItemId = itemId, + OriginalName = name + }); + } + + return result; + } + public static void Save(ClassFileDocument document) { if (!document.IsModernFormat) @@ -203,6 +249,10 @@ internal static class ClassBlocksStore document.SourceText, document.SpellsList, document.DeletedSpellsListOriginalIds); + updated = UpdateItemsListEntries( + updated, + document.ItemsList, + document.DeletedItemsListOriginalIds); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var classBlocksStart, out var classBlocksEnd)) { throw new InvalidOperationException("保存前无法重新定位 ClassBlocks 表。"); @@ -230,6 +280,13 @@ internal static class ClassBlocksStore } document.DeletedSpellsListOriginalIds.Clear(); + foreach (var item in document.ItemsList) + { + item.OriginalItemId = item.ItemId; + item.OriginalName = item.Name; + } + + document.DeletedItemsListOriginalIds.Clear(); } public sealed class ItemEntry @@ -346,6 +403,143 @@ internal static class ClassBlocksStore return source[..tableStart] + updatedTable + source[tableEnd..]; } + private static string UpdateItemsListEntries( + string source, + IReadOnlyList entries, + IReadOnlySet deletedOriginalIds) + { + var newline = source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var newEntries = entries.Where(entry => entry.OriginalItemId == 0).ToArray(); + var changedEntries = entries + .Where(entry => entry.OriginalItemId != 0 + && (entry.ItemId != entry.OriginalItemId + || !string.Equals(entry.Name, entry.OriginalName, StringComparison.Ordinal))) + .ToDictionary(entry => entry.OriginalItemId); + if (!TryExtractAssignedTable(source, ItemsListAssignmentName, out _, out var tableStart, out var tableEnd)) + { + if (!TryExtractAssignedTable(source, SpellsListAssignmentName, out _, out _, out var spellsEnd)) + { + throw new InvalidOperationException( + $"当前文件中未找到 {SpellsListAssignmentName},无法写入物品列表。"); + } + + return source[..spellsEnd] + + newline + + newline + + SerializeItemsListTable(entries, newline) + + source[spellsEnd..]; + } + + if (changedEntries.Count == 0 && newEntries.Length == 0 && deletedOriginalIds.Count == 0) + { + return source; + } + + var tableText = source[tableStart..tableEnd]; + var updatedOriginalIds = new HashSet(); + var deletedIdsFound = new HashSet(); + var pattern = new Regex( + """^(?[ \t]*\[[ \t]*)(?\d+)(?[ \t]*\][ \t]*=[ \t]*\{[ \t]*name[ \t]*=[ \t]*)(?"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?[^\r\n]*)(?\r?\n|$)""", + RegexOptions.Multiline | RegexOptions.CultureInvariant); + var updatedTable = pattern.Replace(tableText, match => + { + if (!long.TryParse(match.Groups["itemId"].Value, NumberStyles.None, + CultureInfo.InvariantCulture, out var originalItemId)) + { + return match.Value; + } + + if (deletedOriginalIds.Contains(originalItemId)) + { + deletedIdsFound.Add(originalItemId); + return string.Empty; + } + + if (!changedEntries.TryGetValue(originalItemId, out var entry)) + { + updatedOriginalIds.Add(originalItemId); + return match.Value; + } + + updatedOriginalIds.Add(originalItemId); + var quotedName = match.Groups["name"].Value; + var quote = quotedName[0]; + return match.Groups["prefix"].Value + + entry.ItemId.ToString(CultureInfo.InvariantCulture) + + match.Groups["beforeName"].Value + + quote + + EscapeLuaString(entry.Name, quote) + + quote + + match.Groups["suffix"].Value + + match.Groups["lineEnd"].Value; + }); + + var missing = changedEntries.Keys.Where(id => !updatedOriginalIds.Contains(id)).ToArray(); + if (missing.Length > 0) + { + throw new InvalidOperationException( + $"无法在 {ItemsListAssignmentName} 中定位 itemId {string.Join(", ", missing)} 的原始条目。"); + } + + var missingDeleted = deletedOriginalIds.Where(id => !deletedIdsFound.Contains(id)).ToArray(); + if (missingDeleted.Length > 0) + { + throw new InvalidOperationException( + $"无法在 {ItemsListAssignmentName} 中定位待删除的 itemId {string.Join(", ", missingDeleted)}。"); + } + + if (newEntries.Length > 0) + { + var closingBraceIndex = updatedTable.Length - 1; + var closingLineBreak = updatedTable.LastIndexOf('\n', Math.Max(0, closingBraceIndex - 1)); + var insertionIndex = closingLineBreak >= 0 ? closingLineBreak + 1 : closingBraceIndex; + var insertion = new StringBuilder(); + if (closingLineBreak < 0) + { + insertion.Append(newline); + } + + foreach (var entry in newEntries.OrderBy(entry => entry.ItemId)) + { + insertion.Append(" [") + .Append(entry.ItemId.ToString(CultureInfo.InvariantCulture)) + .Append("] = { name = \"") + .Append(EscapeLuaString(entry.Name, '"')) + .Append("\" },") + .Append(newline); + } + + updatedTable = updatedTable.Insert(insertionIndex, insertion.ToString()); + } + + return source[..tableStart] + updatedTable + source[tableEnd..]; + } + + private static string SerializeItemsListTable(IReadOnlyList entries, string newline) + { + var builder = new StringBuilder(); + builder.Append(ItemsListAssignmentName).Append(" = {"); + if (entries.Count == 0) + { + builder.Append('}'); + return builder.ToString(); + } + + builder.Append(newline); + foreach (var entry in entries.OrderBy(item => item.ItemId)) + { + builder.Append(" [") + .Append(entry.ItemId.ToString(CultureInfo.InvariantCulture)) + .Append("] = { name = \"") + .Append(EscapeLuaString(entry.Name, '"')) + .Append("\" },") + .Append(newline); + } + + builder.Append('}'); + return builder.ToString(); + } + private static string EscapeLuaString(string value, char quote) { var escaped = value diff --git a/README.md b/README.md index 8d8b9a24..a39f0980 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyut - 置顶浮动条:显示程序名、当前职业图标颜色和逻辑状态,提供 `开启/关闭`、`设置`、`✕` 按钮。窗口可拖动和缩放,显示后自动启动运行循环。 - `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块,或按职业、专精、英雄天赋和队伍类型指定默认模块;从 GitHub 按需下载或更新技能/物品图标数据包。 -- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术、物品和队伍字段;物品页为当前专精列表与全量物品数据库双栏,维护 `itemId`、名称及是否装备中;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1–100 的法术 ID、索引和名称,也可从技能数据库添加。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/items/group` 格式。 +- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、冷却(技能冷却与当前专精物品冷却)和队伍字段;物品列表页编辑职业级 `Fuyutsui.itemsList`,并可从全量物品数据库添加;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1–100 的法术 ID、索引和名称,也可从技能数据库添加。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/items/group` 格式。 - `宏`:编辑项目 `Fuyutsui/core/classmacros.lua` 中各职业的动态宏、静态宏和特殊宏。动态宏每项占用 30 个团队点名槽位;特殊宏的技能名必须手工填写,不从宏正文解析,生成映射时固定为无目标、无宏条件。 - `模块`:新建、编辑、删除本地模块,维护作者、推荐天赋、匹配条件、动态字段和有序规则。规则支持拖拽、上移、下移、复制与插入。 - `状态`:分栏显示基础状态、`auras`、`spells` 和模块计算出的动态单位/数值。 diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index b74c3dd2..4fdea51b 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -10,7 +10,7 @@ public sealed record ClassConfigPostSaveResult( /// /// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocks(states / auras / spells / items / group), -/// 并编辑同文件中的 spellsList。 +/// 并编辑同文件中的 spellsList 与 itemsList。 /// public sealed class ClassConfigEditorControl : UserControl { @@ -32,6 +32,8 @@ public sealed class ClassConfigEditorControl : UserControl private readonly DataGridView _spellsGrid = new(); private readonly DataGridView _itemsGrid = new(); private readonly TextBox _itemsSearchBox = new(); + private readonly DataGridView _itemsListGrid = new(); + private readonly TextBox _itemsListSearchBox = new(); private readonly DataGridView _itemDatabaseGrid = new(); private readonly TextBox _itemDatabaseFilterBox = new(); private readonly Label _itemDatabaseStatusLabel = new(); @@ -72,6 +74,7 @@ public sealed class ClassConfigEditorControl : UserControl private int? _currentSpecId; private bool _suppressUi; private bool _dirty; + private int _editorTabIndex = -1; internal event Action? DirtyStateChanged; internal bool HasUnsavedChanges => _dirty; @@ -382,21 +385,28 @@ public sealed class ClassConfigEditorControl : UserControl } var tabs = new UiPillTab[6]; - var selectedIndex = -1; void SelectTab(int index) { - if (selectedIndex == index) + if (_editorTabIndex == index) { return; } - if (!_suppressUi && selectedIndex == 3) + if (!_suppressUi && _editorTabIndex == 2) { + _spellsGrid.EndEdit(); _itemsGrid.EndEdit(); + WriteBackSpells(); WriteBackItems(); } - selectedIndex = index; + if (!_suppressUi && _editorTabIndex == 3) + { + _itemsListGrid.EndEdit(); + WriteBackItemsList(); + } + + _editorTabIndex = index; for (var i = 0; i < tabs.Length; i++) { var selected = i == index; @@ -409,7 +419,7 @@ public sealed class ClassConfigEditorControl : UserControl } } - var titles = new[] { "状态", "光环", "法术", "物品", "队伍", "技能列表" }; + var titles = new[] { "状态", "光环", "冷却", "物品列表", "队伍", "技能列表" }; for (var i = 0; i < titles.Length; i++) { var index = i; @@ -522,14 +532,14 @@ public sealed class ClassConfigEditorControl : UserControl Padding = new Padding(8, 0, 0, 0) }, 0, 0); - UiTheme.StyleTextBox(_itemsSearchBox); - _itemsSearchBox.Dock = DockStyle.None; - _itemsSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; - _itemsSearchBox.Margin = new Padding(0); - _itemsSearchBox.Height = 30; - _itemsSearchBox.PlaceholderText = "itemId 或名称"; - _itemsSearchBox.TextChanged += (_, _) => ApplyItemsFilter(); - searchCard.Controls.Add(_itemsSearchBox, 1, 0); + UiTheme.StyleTextBox(_itemsListSearchBox); + _itemsListSearchBox.Dock = DockStyle.None; + _itemsListSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _itemsListSearchBox.Margin = new Padding(0); + _itemsListSearchBox.Height = 30; + _itemsListSearchBox.PlaceholderText = "itemId 或名称"; + _itemsListSearchBox.TextChanged += (_, _) => ApplyItemsListFilter(); + searchCard.Controls.Add(_itemsListSearchBox, 1, 0); leftColumn.Controls.Add(searchCard, 0, 0); var currentListCard = new UiCardPanel @@ -555,46 +565,45 @@ public sealed class ClassConfigEditorControl : UserControl }; currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - var currentListTitle = CreateCardTitle("当前专精物品"); + var currentListTitle = CreateCardTitle("物品列表"); currentListTitle.AutoSize = true; currentListTitle.Dock = DockStyle.None; currentListTitle.Anchor = AnchorStyles.Left; currentListHeader.Controls.Add(currentListTitle, 0, 0); - var hint = CreateFieldCaption("名称可改为业务别名;图标始终按 itemId 匹配。"); + var hint = CreateFieldCaption("来自当前职业 Lua 的 Fuyutsui.itemsList。"); hint.TextAlign = ContentAlignment.MiddleRight; currentListHeader.Controls.Add(hint, 1, 0); currentListCard.Controls.Add(currentListHeader, 0, 0); - ConfigureGrid(_itemsGrid, "class-config-items"); - _itemsGrid.AllowUserToAddRows = false; - _itemsGrid.CellContentClick += HandleDeleteClick; - _itemsGrid.CellValueChanged += (_, e) => + ConfigureGrid(_itemsListGrid, "class-config-items-list"); + _itemsListGrid.AllowUserToAddRows = false; + _itemsListGrid.CellContentClick += HandleItemsListDeleteClick; + _itemsListGrid.CellValueChanged += (_, e) => { MarkDirty(); - if (e.RowIndex >= 0 && e.RowIndex < _itemsGrid.Rows.Count) + if (e.RowIndex >= 0 && e.RowIndex < _itemsListGrid.Rows.Count) { - UpdateItemGridIcon(_itemsGrid.Rows[e.RowIndex]); + UpdateItemGridIcon(_itemsListGrid.Rows[e.RowIndex]); } }; - _itemsGrid.DataError += (_, e) => e.ThrowException = false; - _itemsGrid.Columns.Add(CreateSpellIconColumn()); - _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn + _itemsListGrid.DataError += (_, e) => e.ThrowException = false; + _itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "ItemId", HeaderText = "itemId", Width = 125, SortMode = DataGridViewColumnSortMode.NotSortable }); - _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn + _itemsListGrid.Columns.Add(CreateSpellIconColumn()); + _itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, SortMode = DataGridViewColumnSortMode.NotSortable }); - _itemsGrid.Columns.Add(CreateSpellCheckColumn("IsEquipped", "是否装备中", 12, 110)); - _itemsGrid.Columns.Add(CreateDeleteColumn()); - currentListCard.Controls.Add(_itemsGrid, 0, 1); + _itemsListGrid.Columns.Add(CreateDeleteColumn()); + currentListCard.Controls.Add(_itemsListGrid, 0, 1); leftColumn.Controls.Add(currentListCard, 0, 1); split.Controls.Add(leftColumn, 0, 0); @@ -871,21 +880,63 @@ public sealed class ClassConfigEditorControl : UserControl private Control BuildSpellsPage() { - var panel = new TableLayoutPanel + var split = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = UiTheme.SurfaceRaised, + Margin = new Padding(0), + Padding = new Padding(0) + }; + split.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + split.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + split.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var leftColumn = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, - RowCount = 3, - BackColor = UiTheme.SurfaceRaised + RowCount = 2, + BackColor = UiTheme.SurfaceRaised, + Margin = new Padding(0, 0, 5, 0), + Padding = new Padding(0) }; - panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34)); - panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + leftColumn.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + leftColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + var spellCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + spellCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); + spellCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var spellHeader = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(12, 0, 12, 0) + }; + spellHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + spellHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + var spellTitle = CreateCardTitle("技能冷却"); + spellTitle.AutoSize = true; + spellTitle.Dock = DockStyle.None; + spellTitle.Anchor = AnchorStyles.Left; + spellHeader.Controls.Add(spellTitle, 0, 0); var textureOrderHint = CreateFieldCaption( - "纹理按行排列;充能法术连续占 2 格:冷却 → 充能冷却。最大充能、施法次数只生成横向计数条。"); - textureOrderHint.Padding = new Padding(8, 0, 0, 0); - panel.Controls.Add(textureOrderHint, 0, 0); + "充能法术连续占 2 格:冷却 → 充能冷却。"); + textureOrderHint.TextAlign = ContentAlignment.MiddleRight; + spellHeader.Controls.Add(textureOrderHint, 1, 0); + spellCard.Controls.Add(spellHeader, 0, 0); ConfigureGrid(_spellsGrid, "class-config-spells"); _spellsGrid.Columns.Add(CreateSpellIconColumn()); @@ -908,9 +959,122 @@ public sealed class ClassConfigEditorControl : UserControl }; _spellsGrid.UserAddedRow += (_, _) => MarkDirty(); _spellsGrid.DataError += (_, e) => e.ThrowException = false; - panel.Controls.Add(_spellsGrid, 0, 1); - panel.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 2); - return panel; + spellCard.Controls.Add(_spellsGrid, 0, 1); + leftColumn.Controls.Add(spellCard, 0, 0); + leftColumn.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 1); + split.Controls.Add(leftColumn, 0, 0); + + var rightColumn = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.SurfaceRaised, + Margin = new Padding(5, 0, 0, 0), + Padding = new Padding(0) + }; + rightColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 68)); + rightColumn.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var searchCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0, 0, 0, 10), + Padding = new Padding(12, 8, 12, 8) + }; + searchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 64)); + searchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + searchCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + searchCard.Controls.Add(new Label + { + Dock = DockStyle.Fill, + Text = "搜索", + ForeColor = UiTheme.Text, + BackColor = Color.Transparent, + Font = new Font(Font.FontFamily, 9F, FontStyle.Bold, GraphicsUnit.Point), + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0), + Padding = new Padding(8, 0, 0, 0) + }, 0, 0); + + UiTheme.StyleTextBox(_itemsSearchBox); + _itemsSearchBox.Dock = DockStyle.None; + _itemsSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _itemsSearchBox.Margin = new Padding(0); + _itemsSearchBox.Height = 30; + _itemsSearchBox.PlaceholderText = "itemId 或名称"; + _itemsSearchBox.TextChanged += (_, _) => ApplyItemsFilter(); + searchCard.Controls.Add(_itemsSearchBox, 1, 0); + rightColumn.Controls.Add(searchCard, 0, 0); + + var itemCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + itemCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + itemCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); + itemCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var itemHeader = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(12, 0, 12, 0) + }; + itemHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + itemHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + var itemTitle = CreateCardTitle("物品冷却"); + itemTitle.AutoSize = true; + itemTitle.Dock = DockStyle.None; + itemTitle.Anchor = AnchorStyles.Left; + itemHeader.Controls.Add(itemTitle, 0, 0); + var itemHint = CreateFieldCaption("名称可改为业务别名;图标始终按 itemId 匹配。"); + itemHint.TextAlign = ContentAlignment.MiddleRight; + itemHeader.Controls.Add(itemHint, 1, 0); + itemCard.Controls.Add(itemHeader, 0, 0); + + ConfigureGrid(_itemsGrid, "class-config-items"); + _itemsGrid.CellContentClick += HandleDeleteClick; + _itemsGrid.CellValueChanged += (_, e) => + { + MarkDirty(); + if (e.RowIndex >= 0 && e.RowIndex < _itemsGrid.Rows.Count) + { + UpdateItemGridIcon(_itemsGrid.Rows[e.RowIndex]); + } + }; + _itemsGrid.UserAddedRow += (_, _) => MarkDirty(); + _itemsGrid.DataError += (_, e) => e.ThrowException = false; + _itemsGrid.Columns.Add(CreateSpellIconColumn()); + _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "ItemId", + HeaderText = "itemId", + Width = 125, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Name", + HeaderText = "名称", + AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _itemsGrid.Columns.Add(CreateSpellCheckColumn("IsEquipped", "是否装备中", 12, 110)); + _itemsGrid.Columns.Add(CreateDeleteColumn()); + itemCard.Controls.Add(_itemsGrid, 0, 1); + rightColumn.Controls.Add(itemCard, 0, 1); + split.Controls.Add(rightColumn, 1, 0); + return split; } private Control BuildSpellsListPage() @@ -1647,11 +1811,16 @@ public sealed class ClassConfigEditorControl : UserControl } } - // spellsList 是用户实际添加/保存技能时使用的名称,优先级高于专精规则中的同 ID 别名。 + // spellsList / itemsList 是用户实际添加/保存时使用的名称,优先级高于专精规则中的同 ID 别名。 foreach (var spell in document.SpellsList) { SpellIconCatalog.Register(spell.SpellId, spell.Name, overwriteIdName: true); } + + foreach (var item in document.ItemsList) + { + SpellIconCatalog.RegisterItem(item.ItemId, item.Name); + } } private void SelectClassFromList() @@ -1766,6 +1935,7 @@ public sealed class ClassConfigEditorControl : UserControl try { FillSpellsListGrid(); + FillItemsListGrid(); } finally { @@ -1924,6 +2094,11 @@ public sealed class ClassConfigEditorControl : UserControl foreach (DataGridViewRow row in _itemsGrid.Rows) { + if (row.IsNewRow) + { + continue; + } + row.Visible = string.IsNullOrEmpty(query) || ItemsRowMatches(row, query); } } @@ -2235,112 +2410,80 @@ public sealed class ClassConfigEditorControl : UserControl { MessageBox.Show( $"当前物品数据库缺少 itemId {suggestion.ItemId} 的名称,请更新技能/物品数据包后再添加。", - "物品", + "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } - AddItemFromDatabase(new ItemSuggestion(suggestion.ItemId, name)); + AddItemsListFromDatabase(new ItemSuggestion(suggestion.ItemId, name)); } - private void AddItemFromDatabase(ItemSuggestion suggestion) + private void AddItemsListFromDatabase(ItemSuggestion suggestion) { - if (_currentSpec is null) + if (_currentDocument is null) { - MessageBox.Show("请先选择一个专精。", "物品", MessageBoxButtons.OK, MessageBoxIcon.Information); + MessageBox.Show("请先选择一个职业文件。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - _itemsGrid.EndEdit(); - WriteBackItems(); - if (_currentSpec.Items.Any(item => item.ItemId == suggestion.ItemId)) + if (!_currentDocument.IsModernFormat) + { + MessageBox.Show("旧版稀疏索引格式暂不支持添加物品。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + _itemsListGrid.EndEdit(); + if (!TryValidateItemsList(out var validationError)) + { + MessageBox.Show(validationError, "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + WriteBackItemsList(); + if (_currentDocument.ItemsList.Any(item => item.ItemId == suggestion.ItemId)) { MessageBox.Show( $"已有此物品:{suggestion.Name}({suggestion.ItemId})", - "物品", + "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - if (_currentSpec.Items.Any(item => + if (_currentDocument.ItemsList.Any(item => string.Equals(item.Name, suggestion.Name, StringComparison.Ordinal))) { MessageBox.Show( $"已有同名物品“{suggestion.Name}”。", - "物品", + "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } - if (CurrentSpecReservedItemNames().Contains(suggestion.Name)) - { - MessageBox.Show( - $"物品名称“{suggestion.Name}”与状态、能量或配置开关字段重名。", - "物品", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); - return; - } - - var entry = new ClassBlocksStore.ItemEntry + var entry = new ClassBlocksStore.ItemsListEntry { ItemId = suggestion.ItemId, - Name = suggestion.Name, - IsEquipped = false + Name = suggestion.Name }; - _currentSpec.Items.Add(entry); + _currentDocument.ItemsList.Add(entry); SpellIconCatalog.RegisterItem(suggestion.ItemId, suggestion.Name); - var rowIndex = _itemsGrid.Rows.Add( - SpellIconCatalog.GetItem(suggestion.ItemId)!, + var rowIndex = _itemsListGrid.Rows.Add( suggestion.ItemId.ToString(CultureInfo.InvariantCulture), - suggestion.Name, - false, - "×"); - var row = _itemsGrid.Rows[rowIndex]; + SpellIconCatalog.GetItem(suggestion.ItemId)!, + suggestion.Name); + var row = _itemsListGrid.Rows[rowIndex]; row.Tag = entry; - _itemsSearchBox.Clear(); - ApplyItemsFilter(); + _itemsListSearchBox.Clear(); + ApplyItemsListFilter(); row.Selected = true; - _itemsGrid.CurrentCell = row.Cells["ItemId"]; - _itemsGrid.FirstDisplayedScrollingRowIndex = rowIndex; + _itemsListGrid.CurrentCell = row.Cells["ItemId"]; + _itemsListGrid.FirstDisplayedScrollingRowIndex = rowIndex; MarkDirty(); } - private HashSet CurrentSpecReservedItemNames() - { - var bareNames = new HashSet(StringComparer.Ordinal); - if (_currentSpec is null) - { - return bareNames; - } - - if (_currentSpec.NestedStates) - { - foreach (var category in new[] - { - ClassStateCatalog.CategoryState, - ClassStateCatalog.CategoryResource, - ClassStateCatalog.CategoryConfig - }) - { - foreach (var name in _currentSpec.CategorizedStates.GetValueOrDefault(category) ?? []) - { - bareNames.Add(name); - } - } - } - else - { - bareNames.UnionWith(_currentSpec.FlatStates); - } - - return bareNames; - } - private void ReloadStatesGrid() { CloseStateComboDropDown(); @@ -2677,6 +2820,43 @@ public sealed class ClassConfigEditorControl : UserControl } } + private void FillItemsListGrid() + { + _itemsListGrid.Rows.Clear(); + _itemsListSearchBox.Clear(); + if (_currentDocument is null) + { + return; + } + + foreach (var item in _currentDocument.ItemsList.OrderBy(entry => entry.ItemId)) + { + SpellIconCatalog.RegisterItem(item.ItemId, item.Name); + var rowIndex = _itemsListGrid.Rows.Add( + item.ItemId.ToString(CultureInfo.InvariantCulture), + SpellIconCatalog.GetItem(item.ItemId)!, + item.Name); + _itemsListGrid.Rows[rowIndex].Tag = item; + } + } + + private void ApplyItemsListFilter() + { + var query = _itemsListSearchBox.Text.Trim(); + _itemsListGrid.ClearSelection(); + _itemsListGrid.CurrentCell = null; + + foreach (DataGridViewRow row in _itemsListGrid.Rows) + { + if (row.IsNewRow) + { + continue; + } + + row.Visible = string.IsNullOrEmpty(query) || ItemsRowMatches(row, query); + } + } + private void ApplySpellsListFilter() { var query = _spellsListSearchBox.Text.Trim(); @@ -3087,6 +3267,7 @@ public sealed class ClassConfigEditorControl : UserControl } _itemsGrid.EndEdit(); + _spellsGrid.EndEdit(); NormalizeFixedStateNames(_currentSpec); WriteBackStatesCategory(_lastStateCategory); @@ -3122,6 +3303,34 @@ public sealed class ClassConfigEditorControl : UserControl } } + private void WriteBackItemsList() + { + if (_currentDocument is null) + { + return; + } + + foreach (DataGridViewRow row in _itemsListGrid.Rows) + { + if (row.IsNewRow || row.Tag is not ClassBlocksStore.ItemsListEntry entry) + { + continue; + } + + if (!long.TryParse( + row.Cells["ItemId"].Value?.ToString()?.Trim(), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var itemId)) + { + continue; + } + + entry.ItemId = itemId; + entry.Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? ""; + } + } + private void AddSpellFromDatabase(SpellSuggestion suggestion) { if (_currentDocument is null) @@ -3218,15 +3427,19 @@ public sealed class ClassConfigEditorControl : UserControl grid.Invalidate(); } - foreach (DataGridViewRow row in _itemsGrid.Rows) + foreach (var grid in new[] { _itemsGrid, _itemsListGrid }) { - if (!row.IsNewRow) + foreach (DataGridViewRow row in grid.Rows) { - UpdateItemGridIcon(row); + if (!row.IsNewRow) + { + UpdateItemGridIcon(row); + } } + + grid.Invalidate(); } - _itemsGrid.Invalidate(); _itemDatabaseGrid.Invalidate(); foreach (var grid in new[] { _aurasGrid, _groupAurasGrid }) @@ -3295,6 +3508,55 @@ public sealed class ClassConfigEditorControl : UserControl return true; } + private bool TryValidateItemsList(out string error) + { + error = string.Empty; + if (_currentDocument is null) + { + return true; + } + + var itemIds = new HashSet(); + var itemNames = new HashSet(StringComparer.Ordinal); + foreach (DataGridViewRow row in _itemsListGrid.Rows) + { + if (row.IsNewRow) + { + continue; + } + + var rowNumber = row.Index + 1; + if (!long.TryParse(row.Cells["ItemId"].Value?.ToString()?.Trim(), NumberStyles.None, + CultureInfo.InvariantCulture, out var itemId) + || itemId <= 0) + { + error = $"物品列表第 {rowNumber} 行 itemId 必须是正整数。"; + return false; + } + + var name = row.Cells["Name"].Value?.ToString()?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) + { + error = $"物品列表第 {rowNumber} 行名称不能为空。"; + return false; + } + + if (!itemIds.Add(itemId)) + { + error = $"物品列表 itemId {itemId} 重复。"; + return false; + } + + if (!itemNames.Add(name)) + { + error = $"物品列表名称“{name}”重复。"; + return false; + } + } + + return true; + } + private bool TryValidateItems(out string error) { error = string.Empty; @@ -3619,7 +3881,9 @@ public sealed class ClassConfigEditorControl : UserControl try { _spellsListGrid.EndEdit(); + _itemsListGrid.EndEdit(); _statesGrid.EndEdit(); + _spellsGrid.EndEdit(); _itemsGrid.EndEdit(); if (!TryValidateSpellsList(out var validationError)) { @@ -3628,16 +3892,24 @@ public sealed class ClassConfigEditorControl : UserControl return; } + if (!TryValidateItemsList(out validationError)) + { + MessageBox.Show(validationError, "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); + _statusLabel.Text = validationError; + return; + } + // 切换分类前把当前状态表写回。 CommitCurrentSpecFromUi(); if (!TryValidateItems(out validationError)) { - MessageBox.Show(validationError, "物品", MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBox.Show(validationError, "物品冷却", MessageBoxButtons.OK, MessageBoxIcon.Warning); _statusLabel.Text = validationError; return; } WriteBackSpellsList(); + WriteBackItemsList(); ClassBlocksStore.Save(_currentDocument); localSaved = true; SetDirty(false); @@ -3736,6 +4008,8 @@ public sealed class ClassConfigEditorControl : UserControl _spellsGrid.Rows.Clear(); _itemsGrid.Rows.Clear(); _itemsSearchBox.Clear(); + _itemsListGrid.Rows.Clear(); + _itemsListSearchBox.Clear(); _spellsListGrid.Rows.Clear(); _spellsListSearchBox.Clear(); _groupAurasGrid.Rows.Clear(); @@ -3792,6 +4066,36 @@ public sealed class ClassConfigEditorControl : UserControl MarkDirty(); } + private void HandleItemsListDeleteClick(object? sender, DataGridViewCellEventArgs e) + { + if (sender is not DataGridView grid + || e.RowIndex < 0 + || e.ColumnIndex < 0 + || grid.Columns[e.ColumnIndex].Name != "Delete") + { + return; + } + + var row = grid.Rows[e.RowIndex]; + if (row.IsNewRow || row.Tag is not ClassBlocksStore.ItemsListEntry entry) + { + return; + } + + if (_currentDocument is not null) + { + if (entry.OriginalItemId > 0) + { + _currentDocument.DeletedItemsListOriginalIds.Add(entry.OriginalItemId); + } + + _currentDocument.ItemsList.Remove(entry); + } + + grid.Rows.RemoveAt(e.RowIndex); + MarkDirty(); + } + private void MoveSelectedRow(DataGridView grid, int delta) { if (grid.CurrentRow is null || grid.CurrentRow.IsNewRow)