From ccd670dfb579da0bcfaf781675135dc73de5f444 Mon Sep 17 00:00:00 2001 From: waynebian01 Date: Tue, 1 Sep 2026 17:05:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E8=87=B3=201.2.1.19=EF=BC=8C=E4=BC=98=E5=8C=96=20README=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=AD=E7=9A=84=E5=8A=9F=E8=83=BD=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7=E5=AF=B9?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=92=8C=E7=89=A9=E5=93=81=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8C=85=E7=9A=84=E7=90=86=E8=A7=A3=E3=80=82?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E7=89=A9=E5=93=81=20ID=20=E7=9A=84?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E6=94=B9=E8=BF=9B=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E6=9D=A1=E4=BB=B6=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BF=A1=E6=81=AF=E5=B1=95=E7=A4=BA=E6=9B=B4?= =?UTF-8?q?=E5=8A=A0=E6=B8=85=E6=99=B0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Infrastructure/ClassMacrosStore.cs | 29 ++ Modules/ConditionFieldCatalog.cs | 16 +- README.md | 12 +- Runtime/GameState.cs | 5 + Runtime/StateBuilder.cs | 11 + UI/ClassConfigEditorControl.cs | 809 ++++++++++++++++++++++++++++- UI/ClassMacrosEditorControl.cs | 278 +++++++++- UI/ConditionEditorForm.cs | 29 +- UI/MainForm.cs | 28 +- UI/ModuleEditorControl.cs | 57 ++ UI/SpellIconCatalog.cs | 310 ++++++++++- UI/StatusForm.cs | 45 +- UI/UiTheme.cs | 30 +- 13 files changed, 1596 insertions(+), 63 deletions(-) diff --git a/Infrastructure/ClassMacrosStore.cs b/Infrastructure/ClassMacrosStore.cs index da5b3d53..d767cb07 100644 --- a/Infrastructure/ClassMacrosStore.cs +++ b/Infrastructure/ClassMacrosStore.cs @@ -10,6 +10,7 @@ namespace Shigure; internal static class ClassMacrosStore { public const string AssignmentName = "Fuyutsui.ClassMacros"; + public const string MacroBodiesAssignmentName = "Fuyutsui.MacroBodies"; private static readonly string[] ClassFileOrder = [ @@ -95,6 +96,34 @@ internal static class ClassMacrosStore return doc; } + public static IReadOnlyDictionary LoadMacroBodies(string filePath) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!File.Exists(filePath)) + { + return result; + } + + var source = File.ReadAllText(filePath, Encoding.UTF8); + if (!TryExtractAssignedTable(source, MacroBodiesAssignmentName, out var table, out _, out _)) + { + return result; + } + + foreach (var (key, value) in table.Entries) + { + if (key is string name + && !string.IsNullOrWhiteSpace(name) + && value is StringValue text + && !string.IsNullOrWhiteSpace(text.Value)) + { + result.TryAdd(name.Trim(), text.Value); + } + } + + return result; + } + public static void Save(MacrosDocument document) { var serialized = SerializeClassMacros(document); diff --git a/Modules/ConditionFieldCatalog.cs b/Modules/ConditionFieldCatalog.cs index 4ea61e09..cadad938 100644 --- a/Modules/ConditionFieldCatalog.cs +++ b/Modules/ConditionFieldCatalog.cs @@ -31,7 +31,8 @@ public sealed record ConditionField( string DisplayName, ConditionFieldType Type, ConditionFieldCategory Category = ConditionFieldCategory.State, - string? Classification = null) + string? Classification = null, + long? ItemId = null) { public override string ToString() => DisplayName; } @@ -143,7 +144,8 @@ public sealed class ConditionFieldCatalog ConditionFieldCategory.State, ReadClassification(field) ?? sourceClassifications.GetValueOrDefault(key) - ?? InferStateClassification(key)); + ?? InferStateClassification(key), + ReadItemId(field)); } } @@ -314,11 +316,12 @@ public sealed class ConditionFieldCatalog string displayName, ConditionFieldType type, ConditionFieldCategory category = ConditionFieldCategory.State, - string? classification = null) + string? classification = null, + long? itemId = null) { if (seen.Add(name)) { - fields.Add(new ConditionField(name, displayName, type, category, classification)); + fields.Add(new ConditionField(name, displayName, type, category, classification, itemId)); } } @@ -418,6 +421,11 @@ public sealed class ConditionFieldCatalog private static long? ReadSpellId(JsonObject field) => JsonHelpers.GetLong(JsonHelpers.Get(field, "spellId")); + private static long? ReadItemId(JsonObject field) + => JsonHelpers.GetLong(JsonHelpers.Get(field, "itemId")) is > 0 and var itemId + ? itemId + : null; + private static string InferStateClassification(string name) { foreach (var classification in ClassStateCatalog.TopCategories.Where(IsUnitStateClassification)) diff --git a/README.md b/README.md index 3c444b20..2304560d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyut 程序采用单实例运行;再次启动时会提示“Shigure 已经在运行”,随后退出新实例。 -当前版本:`1.2.1.17` +当前版本:`1.2.1.19` ## 主要功能 @@ -45,8 +45,8 @@ Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyut ## 界面 - 置顶浮动条:显示程序名、当前职业图标颜色和逻辑状态,提供 `开启/关闭`、`设置`、`✕` 按钮。窗口可拖动和缩放,显示后自动启动运行循环。 -- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块,或按职业、专精、英雄天赋和队伍类型指定默认模块;从 GitHub 按需下载或更新技能图标数据包。 -- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术、物品和队伍字段;独立物品页维护 `itemId`、名称及是否装备中,技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1–100 的法术 ID、索引和名称,也可输入 spellId 与名称并自动分配空闲索引。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/items/group` 格式。 +- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块,或按职业、专精、英雄天赋和队伍类型指定默认模块;从 GitHub 按需下载或更新技能/物品图标数据包。 +- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术、物品和队伍字段;物品页为当前专精列表与全量物品数据库双栏,维护 `itemId`、名称及是否装备中;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1–100 的法术 ID、索引和名称,也可从技能数据库添加。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/items/group` 格式。 - `宏`:编辑项目 `Fuyutsui/core/classmacros.lua` 中各职业的动态宏、静态宏和特殊宏。动态宏每项占用 30 个团队点名槽位;特殊宏的技能名必须手工填写,不从宏正文解析,生成映射时固定为无目标、无宏条件。 - `模块`:新建、编辑、删除本地模块,维护作者、推荐天赋、匹配条件、动态字段和有序规则。规则支持拖拽、上移、下移、复制与插入。 - `状态`:分栏显示基础状态、`auras`、`spells` 和模块计算出的动态单位/数值。 @@ -97,7 +97,7 @@ dotnet run --project .\Shigure.csproj -- --toggle XBUTTON2 --mode switch --logic dotnet build .\Shigure.csproj ``` -应用图标为 `Assets\arasaka-icon.ico`。项目会把 `Fuyutsui/**`、`config/*.json`、`keymap/*.json` 和 `wow_process.txt` 复制到输出/发布目录;应用、职业、专精和少量程序专用图标作为嵌入资源打包。完整技能图标库不随发布版分发,用户可在“设置 → 通用 → 下载数据包”中从 GitHub 最新正式 Release 下载到 `data\SpellIcons.shgpack`。缺少数据包时技能图标与添加技能的 spellId 联想保持关闭,但仍可手工编辑技能。 +应用图标为 `Assets\arasaka-icon.ico`。项目会把 `Fuyutsui/**`、`config/*.json`、`keymap/*.json` 和 `wow_process.txt` 复制到输出/发布目录;应用、职业、专精和少量程序专用图标作为嵌入资源打包。完整技能/物品图标库不随发布版分发,用户可在“设置 → 通用 → 下载数据包”中从 GitHub 最新正式 Release 下载到 `data\SpellIcons.shgpack`。缺少数据包时技能图标与添加技能的 spellId 联想保持关闭,但仍可手工编辑技能。仅技能旧包仍可加载技能;物品搜索库关闭,手工编辑物品保持可用。 ## 项目结构 @@ -114,12 +114,12 @@ Fuyutsui\ 权威插件源码、配置/宏编辑源及游戏部署 config\ 由 Fuyutsui 职业配置生成的扫描映射 keymap\ 由 Fuyutsui 职业宏生成的按键映射 module\ 模块示例模板(运行时模块保存在我的文档目录 `{MyDocuments}/Shigure/module`) -SpellIconPackage\ 本地技能图标数据包清单、构建与修复工具(Git 忽略) +SpellIconPackage\ 本地技能/物品图标数据包清单、构建与修复工具(Git 忽略) Tools\ 辅助脚本 ``` `SpellIconPackage/` 中保存数据包源清单、本地构建工具和生成的 -`SpellIcons.shgpack`;整个目录由 `.gitignore` 排除,不提交到仓库。 +`SpellIcons.shgpack`;整个目录由 `.gitignore` 排除,不提交到仓库。完整包在 v1 技能索引后追加物品扩展段;仅技能旧包可被新版读取,此时只禁用物品搜索库。 ## Fuyutsui 配置同步 diff --git a/Runtime/GameState.cs b/Runtime/GameState.cs index d6f63e9d..d37da600 100644 --- a/Runtime/GameState.cs +++ b/Runtime/GameState.cs @@ -24,6 +24,11 @@ public sealed class GameState ? displayTypes : new Dictionary(); + public IReadOnlyDictionary ItemIds => + Values.TryGetValue("$itemIds", out var value) && value is IReadOnlyDictionary itemIds + ? itemIds + : new Dictionary(); + public IReadOnlyDictionary> Group => Values.TryGetValue("group", out var value) && value is IReadOnlyDictionary> group ? group diff --git a/Runtime/StateBuilder.cs b/Runtime/StateBuilder.cs index 3adf3a36..fe430e51 100644 --- a/Runtime/StateBuilder.cs +++ b/Runtime/StateBuilder.cs @@ -22,6 +22,7 @@ public sealed class StateBuilder : IRuntimeStateBuilder var result = new Dictionary(); healAbsorbData ??= new Dictionary(); + var itemIds = new Dictionary(StringComparer.Ordinal); foreach (var (key, node) in stateConfig) { if (key is "group" or "spells" or "auras" || node is not JsonObject field || !field.ContainsKey("step")) @@ -30,6 +31,16 @@ public sealed class StateBuilder : IRuntimeStateBuilder } result[key] = ConvertRawValue(ResolveRaw(field, rowData, barData), JsonHelpers.GetString(JsonHelpers.Get(field, "type"))); + var itemId = JsonHelpers.GetLong(JsonHelpers.Get(field, "itemId")); + if (itemId is > 0) + { + itemIds[key] = itemId.Value; + } + } + + if (itemIds.Count > 0) + { + result["$itemIds"] = itemIds; } if (JsonHelpers.Get(stateConfig, "spells") is JsonObject spellsConfig) diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 955552ae..b74c3dd2 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -31,6 +31,17 @@ public sealed class ClassConfigEditorControl : UserControl private readonly DataGridView _aurasGrid = new(); private readonly DataGridView _spellsGrid = new(); private readonly DataGridView _itemsGrid = new(); + private readonly TextBox _itemsSearchBox = new(); + private readonly DataGridView _itemDatabaseGrid = new(); + private readonly TextBox _itemDatabaseFilterBox = new(); + private readonly Label _itemDatabaseStatusLabel = new(); + private readonly System.Windows.Forms.Timer _itemDatabaseFilterTimer = new() { Interval = 150 }; + private const int ItemDatabasePageSize = 20; + private ItemDatabaseResultSet _itemDatabaseResults = ItemDatabaseResultSet.Empty; + private int _itemDatabaseVisibleCount; + private bool _expandingItemDatabaseRows; + private CancellationTokenSource? _itemDatabaseFilterCancellation; + private int _itemDatabaseFilterVersion; private readonly DataGridView _spellsListGrid = new(); private readonly TextBox _spellsListSearchBox = new(); private readonly DataGridView _spellDatabaseGrid = new(); @@ -100,6 +111,9 @@ public sealed class ClassConfigEditorControl : UserControl _spellDatabaseFilterTimer.Stop(); _spellDatabaseFilterTimer.Dispose(); _spellDatabaseFilterCancellation?.Cancel(); + _itemDatabaseFilterTimer.Stop(); + _itemDatabaseFilterTimer.Dispose(); + _itemDatabaseFilterCancellation?.Cancel(); SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; } @@ -460,21 +474,115 @@ public sealed class ClassConfigEditorControl : UserControl private Control BuildItemsPage() { - 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 = 1, - 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.Percent, 100)); + leftColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 68)); + leftColumn.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); + leftColumn.Controls.Add(searchCard, 0, 0); + + var currentListCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + currentListCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + currentListCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); + currentListCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var currentListHeader = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(12, 0, 12, 0) + }; + currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + var currentListTitle = CreateCardTitle("当前专精物品"); + currentListTitle.AutoSize = true; + currentListTitle.Dock = DockStyle.None; + currentListTitle.Anchor = AnchorStyles.Left; + currentListHeader.Controls.Add(currentListTitle, 0, 0); + var hint = CreateFieldCaption("名称可改为业务别名;图标始终按 itemId 匹配。"); + 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) => + { + MarkDirty(); + if (e.RowIndex >= 0 && e.RowIndex < _itemsGrid.Rows.Count) + { + UpdateItemGridIcon(_itemsGrid.Rows[e.RowIndex]); + } + }; + _itemsGrid.DataError += (_, e) => e.ThrowException = false; + _itemsGrid.Columns.Add(CreateSpellIconColumn()); _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "ItemId", HeaderText = "itemId", - Width = 180, + Width = 125, SortMode = DataGridViewColumnSortMode.NotSortable }); _itemsGrid.Columns.Add(new DataGridViewTextBoxColumn @@ -484,21 +592,122 @@ public sealed class ClassConfigEditorControl : UserControl AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, SortMode = DataGridViewColumnSortMode.NotSortable }); - _itemsGrid.Columns.Add(new DataGridViewCheckBoxColumn + _itemsGrid.Columns.Add(CreateSpellCheckColumn("IsEquipped", "是否装备中", 12, 110)); + _itemsGrid.Columns.Add(CreateDeleteColumn()); + currentListCard.Controls.Add(_itemsGrid, 0, 1); + leftColumn.Controls.Add(currentListCard, 0, 1); + split.Controls.Add(leftColumn, 0, 0); + + var rightColumn = new TableLayoutPanel { - Name = "IsEquipped", - HeaderText = "是否装备中", - Width = 130, - FlatStyle = FlatStyle.Flat, + 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 filterCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0, 0, 0, 10), + Padding = new Padding(12, 8, 12, 8) + }; + filterCard.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 64)); + filterCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + filterCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + filterCard.Controls.Add(CreateCardTitle("筛选", 8), 0, 0); + UiTheme.StyleTextBox(_itemDatabaseFilterBox); + _itemDatabaseFilterBox.Dock = DockStyle.None; + _itemDatabaseFilterBox.Anchor = AnchorStyles.Left | AnchorStyles.Right; + _itemDatabaseFilterBox.Margin = new Padding(0); + _itemDatabaseFilterBox.Height = 30; + _itemDatabaseFilterBox.PlaceholderText = "itemId 或名称"; + _itemDatabaseFilterBox.TextChanged += (_, _) => ScheduleItemDatabaseFilter(); + filterCard.Controls.Add(_itemDatabaseFilterBox, 1, 0); + rightColumn.Controls.Add(filterCard, 0, 0); + + var databaseListCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + databaseListCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + databaseListCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); + databaseListCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + var databaseHeader = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Margin = new Padding(0), + Padding = new Padding(12, 0, 12, 0) + }; + databaseHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + databaseHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + databaseHeader.Controls.Add(CreateCardTitle("物品数据库"), 0, 0); + _itemDatabaseStatusLabel.Dock = DockStyle.Fill; + _itemDatabaseStatusLabel.ForeColor = UiTheme.Muted; + _itemDatabaseStatusLabel.BackColor = Color.Transparent; + _itemDatabaseStatusLabel.TextAlign = ContentAlignment.MiddleRight; + _itemDatabaseStatusLabel.Margin = new Padding(0); + databaseHeader.Controls.Add(_itemDatabaseStatusLabel, 1, 0); + databaseListCard.Controls.Add(databaseHeader, 0, 0); + + ConfigureGrid(_itemDatabaseGrid, "class-config-item-database"); + _itemDatabaseGrid.AllowUserToAddRows = false; + _itemDatabaseGrid.ReadOnly = true; + _itemDatabaseGrid.VirtualMode = true; + _itemDatabaseGrid.EditMode = DataGridViewEditMode.EditProgrammatically; + _itemDatabaseGrid.RowCount = 0; + _itemDatabaseGrid.CellValueNeeded += OnItemDatabaseCellValueNeeded; + _itemDatabaseGrid.CellContentClick += OnItemDatabaseCellContentClick; + _itemDatabaseGrid.Scroll += OnItemDatabaseScroll; + _itemDatabaseGrid.Columns.Add(CreateSpellIconColumn()); + _itemDatabaseGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "ItemId", + HeaderText = "itemId", + Width = 125, SortMode = DataGridViewColumnSortMode.NotSortable }); - _itemsGrid.Columns.Add(CreateDeleteColumn()); - _itemsGrid.CellContentClick += HandleDeleteClick; - _itemsGrid.CellValueChanged += (_, _) => MarkDirty(); - _itemsGrid.UserAddedRow += (_, _) => MarkDirty(); - _itemsGrid.DataError += (_, e) => e.ThrowException = false; - panel.Controls.Add(_itemsGrid, 0, 0); - return panel; + _itemDatabaseGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Name", + HeaderText = "名称", + AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _itemDatabaseGrid.Columns.Add(new DataGridViewButtonColumn + { + Name = "Add", + HeaderText = "添加", + Text = "添加", + UseColumnTextForButtonValue = true, + Width = 72, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _itemDatabaseGrid.HandleCreated += (_, _) => RefreshItemDatabase(); + databaseListCard.Controls.Add(_itemDatabaseGrid, 0, 1); + rightColumn.Controls.Add(databaseListCard, 0, 1); + + _itemDatabaseFilterTimer.Tick += async (_, _) => + { + _itemDatabaseFilterTimer.Stop(); + await ApplyItemDatabaseFilterAsync(); + }; + + split.Controls.Add(rightColumn, 1, 0); + return split; } private Control BuildStateCategoryTabs() @@ -1411,6 +1620,14 @@ public sealed class ClassConfigEditorControl : UserControl } } + foreach (var item in spec.Items) + { + if (item.ItemId is { } itemId) + { + SpellIconCatalog.RegisterItem(itemId, item.Name); + } + } + if (spec.Group is not { } group) { continue; @@ -1650,6 +1867,7 @@ public sealed class ClassConfigEditorControl : UserControl private void FillItemsGrid() { _itemsGrid.Rows.Clear(); + _itemsSearchBox.Clear(); if (_currentSpec is null) { return; @@ -1657,12 +1875,470 @@ public sealed class ClassConfigEditorControl : UserControl foreach (var item in _currentSpec.Items.OrderBy(item => item.ItemId ?? long.MaxValue)) { - _itemsGrid.Rows.Add( + if (item.ItemId is { } itemId) + { + SpellIconCatalog.RegisterItem(itemId, item.Name); + } + + var rowIndex = _itemsGrid.Rows.Add( + (item.ItemId is { } id ? SpellIconCatalog.GetItem(id) : null)!, item.ItemId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, item.Name, item.IsEquipped, "×"); + _itemsGrid.Rows[rowIndex].Tag = item; } + + ApplyItemsFilter(); + } + + private static void UpdateItemGridIcon(DataGridViewRow row) + { + if (row.IsNewRow) + { + row.Cells["Icon"].Value = null; + return; + } + + var name = row.Cells["Name"].Value?.ToString(); + Image? icon = null; + if (long.TryParse( + row.Cells["ItemId"].Value?.ToString(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var itemId) + && itemId > 0) + { + SpellIconCatalog.RegisterItem(itemId, name); + icon = SpellIconCatalog.GetItem(itemId); + } + + row.Cells["Icon"].Value = icon; + } + + private void ApplyItemsFilter() + { + var query = _itemsSearchBox.Text.Trim(); + _itemsGrid.ClearSelection(); + _itemsGrid.CurrentCell = null; + + foreach (DataGridViewRow row in _itemsGrid.Rows) + { + row.Visible = string.IsNullOrEmpty(query) || ItemsRowMatches(row, query); + } + } + + private static bool ItemsRowMatches(DataGridViewRow row, string query) + => new[] { "ItemId", "Name" } + .Select(columnName => row.Cells[columnName].Value?.ToString() ?? string.Empty) + .Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase)); + + private void ScheduleItemDatabaseFilter() + { + _itemDatabaseFilterTimer.Stop(); + _itemDatabaseFilterVersion++; + CancelItemDatabaseFilter(); + if (!_itemDatabaseGrid.IsHandleCreated || IsDisposed || Disposing) + { + return; + } + + if (!SpellIconCatalog.IsItemDatabaseAvailable) + { + ApplyItemDatabaseResults(ItemDatabaseResultSet.Empty, "未安装物品数据库"); + return; + } + + _itemDatabaseStatusLabel.Text = "正在筛选…"; + _itemDatabaseFilterTimer.Start(); + } + + private void RefreshItemDatabase() + { + if (IsDisposed || Disposing || !_itemDatabaseGrid.IsHandleCreated) + { + return; + } + + _itemDatabaseFilterTimer.Stop(); + _itemDatabaseFilterVersion++; + CancelItemDatabaseFilter(); + var packageAvailable = SpellIconCatalog.IsItemDatabaseAvailable; + _itemDatabaseFilterBox.Enabled = packageAvailable; + if (!packageAvailable) + { + ApplyItemDatabaseResults(ItemDatabaseResultSet.Empty, "未安装物品数据库"); + return; + } + + if (string.IsNullOrWhiteSpace(_itemDatabaseFilterBox.Text)) + { + var snapshot = SpellIconCatalog.GetItemSuggestionsSnapshot(); + ApplyItemDatabaseResults(ItemDatabaseResultSet.FromAll(snapshot)); + return; + } + + _itemDatabaseStatusLabel.Text = "正在筛选…"; + _ = ApplyItemDatabaseFilterAsync(); + } + + private async Task ApplyItemDatabaseFilterAsync() + { + if (IsDisposed || Disposing || !_itemDatabaseGrid.IsHandleCreated) + { + return; + } + + var version = _itemDatabaseFilterVersion; + var query = _itemDatabaseFilterBox.Text.Trim(); + var snapshot = SpellIconCatalog.GetItemSuggestionsSnapshot(); + var registeredNames = SpellIconCatalog.GetRegisteredItemNamesSnapshot(); + if (string.IsNullOrEmpty(query)) + { + ApplyItemDatabaseResults(ItemDatabaseResultSet.FromAll(snapshot)); + return; + } + + var cancellation = new CancellationTokenSource(); + _itemDatabaseFilterCancellation = cancellation; + try + { + var results = await Task.Run( + () => FilterItemDatabase(snapshot, registeredNames, query, cancellation.Token), + cancellation.Token); + if (cancellation.IsCancellationRequested + || version != _itemDatabaseFilterVersion + || IsDisposed + || Disposing) + { + return; + } + + ApplyItemDatabaseResults(results); + } + catch (OperationCanceledException) + { + } + finally + { + if (ReferenceEquals(_itemDatabaseFilterCancellation, cancellation)) + { + _itemDatabaseFilterCancellation = null; + } + + cancellation.Dispose(); + } + } + + private static ItemDatabaseResultSet FilterItemDatabase( + IReadOnlyList source, + IReadOnlyDictionary registeredNames, + string query, + CancellationToken cancellationToken) + { + var numeric = query.All(character => character is >= '0' and <= '9'); + if (numeric) + { + return FilterItemDatabaseByIdPrefix(source, query); + } + + var indices = new List(); + for (var index = 0; index < source.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var suggestion = source[index]; + var name = string.IsNullOrWhiteSpace(suggestion.Name) + ? registeredNames.GetValueOrDefault(suggestion.ItemId) ?? string.Empty + : suggestion.Name; + if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + indices.Add(index); + } + } + + return ItemDatabaseResultSet.FromIndices(source, indices.ToArray()); + } + + private static ItemDatabaseResultSet FilterItemDatabaseByIdPrefix( + IReadOnlyList source, + string query) + { + if (source.Count == 0 + || query.Length > 19 + || query[0] == '0' + || !long.TryParse(query, NumberStyles.None, CultureInfo.InvariantCulture, out var prefix) + || prefix <= 0) + { + return ItemDatabaseResultSet.Empty; + } + + var ranges = new List(19); + long scale = 1; + while (prefix <= long.MaxValue / scale) + { + var startItemId = prefix * scale; + var intervalLength = scale - 1; + var endItemId = intervalLength > long.MaxValue - startItemId + ? long.MaxValue + : startItemId + intervalLength; + var startIndex = LowerBoundItemSuggestion(source, startItemId); + var endIndex = UpperBoundItemSuggestion(source, endItemId); + if (endIndex > startIndex) + { + ranges.Add(new ItemDatabaseRange(startIndex, endIndex - startIndex)); + } + + if (scale > long.MaxValue / 10) + { + break; + } + + scale *= 10; + } + + return ItemDatabaseResultSet.FromRanges(source, ranges.ToArray()); + } + + private static int LowerBoundItemSuggestion(IReadOnlyList source, long itemId) + { + var low = 0; + var high = source.Count; + while (low < high) + { + var middle = low + (high - low) / 2; + if (source[middle].ItemId < itemId) + { + low = middle + 1; + } + else + { + high = middle; + } + } + + return low; + } + + private static int UpperBoundItemSuggestion(IReadOnlyList source, long itemId) + { + var low = 0; + var high = source.Count; + while (low < high) + { + var middle = low + (high - low) / 2; + if (source[middle].ItemId <= itemId) + { + low = middle + 1; + } + else + { + high = middle; + } + } + + return low; + } + + private void ApplyItemDatabaseResults(ItemDatabaseResultSet results, string? status = null) + { + _itemDatabaseGrid.ClearSelection(); + _itemDatabaseGrid.CurrentCell = null; + _itemDatabaseResults = results; + _itemDatabaseVisibleCount = Math.Min(ItemDatabasePageSize, results.Count); + _itemDatabaseGrid.RowCount = _itemDatabaseVisibleCount; + _itemDatabaseStatusLabel.Text = status ?? FormatItemDatabaseStatus(); + _itemDatabaseGrid.Invalidate(); + } + + private string FormatItemDatabaseStatus() + => _itemDatabaseResults.Count == 0 + ? "匹配 0 个物品" + : $"已显示 {_itemDatabaseVisibleCount:N0} / 共 {_itemDatabaseResults.Count:N0} 个物品"; + + private void OnItemDatabaseScroll(object? sender, ScrollEventArgs e) + { + if (e.ScrollOrientation != ScrollOrientation.VerticalScroll + || _expandingItemDatabaseRows + || _itemDatabaseVisibleCount >= _itemDatabaseResults.Count + || _itemDatabaseGrid.FirstDisplayedScrollingRowIndex < 0) + { + return; + } + + var lastDisplayedRow = _itemDatabaseGrid.FirstDisplayedScrollingRowIndex + + _itemDatabaseGrid.DisplayedRowCount(includePartialRow: true); + if (lastDisplayedRow < _itemDatabaseVisibleCount - 2) + { + return; + } + + _expandingItemDatabaseRows = true; + try + { + _itemDatabaseVisibleCount = Math.Min( + _itemDatabaseVisibleCount + ItemDatabasePageSize, + _itemDatabaseResults.Count); + _itemDatabaseGrid.RowCount = _itemDatabaseVisibleCount; + _itemDatabaseStatusLabel.Text = FormatItemDatabaseStatus(); + } + finally + { + _expandingItemDatabaseRows = false; + } + } + + private void CancelItemDatabaseFilter() + { + var cancellation = _itemDatabaseFilterCancellation; + _itemDatabaseFilterCancellation = null; + if (cancellation is null) + { + return; + } + + cancellation.Cancel(); + } + + private void OnItemDatabaseCellValueNeeded(object? sender, DataGridViewCellValueEventArgs e) + { + if (e.RowIndex < 0 || e.RowIndex >= _itemDatabaseVisibleCount || e.ColumnIndex < 0) + { + return; + } + + var suggestion = _itemDatabaseResults[e.RowIndex]; + var displayName = SpellIconCatalog.ResolveItemSuggestionName(suggestion.ItemId, suggestion.Name) + ?? string.Empty; + e.Value = _itemDatabaseGrid.Columns[e.ColumnIndex].Name switch + { + "Icon" => SpellIconCatalog.GetItem(suggestion.ItemId), + "ItemId" => suggestion.ItemId.ToString(CultureInfo.InvariantCulture), + "Name" => displayName, + "Add" => "添加", + _ => null + }; + } + + private void OnItemDatabaseCellContentClick(object? sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 + || e.RowIndex >= _itemDatabaseVisibleCount + || e.ColumnIndex < 0 + || _itemDatabaseGrid.Columns[e.ColumnIndex].Name != "Add") + { + return; + } + + var suggestion = _itemDatabaseResults[e.RowIndex]; + var name = SpellIconCatalog.ResolveItemSuggestionName(suggestion.ItemId, suggestion.Name); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show( + $"当前物品数据库缺少 itemId {suggestion.ItemId} 的名称,请更新技能/物品数据包后再添加。", + "物品", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + AddItemFromDatabase(new ItemSuggestion(suggestion.ItemId, name)); + } + + private void AddItemFromDatabase(ItemSuggestion suggestion) + { + if (_currentSpec is null) + { + MessageBox.Show("请先选择一个专精。", "物品", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + _itemsGrid.EndEdit(); + WriteBackItems(); + if (_currentSpec.Items.Any(item => item.ItemId == suggestion.ItemId)) + { + MessageBox.Show( + $"已有此物品:{suggestion.Name}({suggestion.ItemId})", + "物品", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return; + } + + if (_currentSpec.Items.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 + { + ItemId = suggestion.ItemId, + Name = suggestion.Name, + IsEquipped = false + }; + _currentSpec.Items.Add(entry); + SpellIconCatalog.RegisterItem(suggestion.ItemId, suggestion.Name); + + var rowIndex = _itemsGrid.Rows.Add( + SpellIconCatalog.GetItem(suggestion.ItemId)!, + suggestion.ItemId.ToString(CultureInfo.InvariantCulture), + suggestion.Name, + false, + "×"); + var row = _itemsGrid.Rows[rowIndex]; + row.Tag = entry; + _itemsSearchBox.Clear(); + ApplyItemsFilter(); + row.Selected = true; + _itemsGrid.CurrentCell = row.Cells["ItemId"]; + _itemsGrid.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() @@ -2528,6 +3204,7 @@ public sealed class ClassConfigEditorControl : UserControl } RefreshSpellDatabase(); + RefreshItemDatabase(); foreach (var grid in new[] { _spellsGrid, _spellsListGrid }) { foreach (DataGridViewRow row in grid.Rows) @@ -2541,6 +3218,17 @@ public sealed class ClassConfigEditorControl : UserControl grid.Invalidate(); } + foreach (DataGridViewRow row in _itemsGrid.Rows) + { + if (!row.IsNewRow) + { + UpdateItemGridIcon(row); + } + } + + _itemsGrid.Invalidate(); + _itemDatabaseGrid.Invalidate(); + foreach (var grid in new[] { _aurasGrid, _groupAurasGrid }) { foreach (DataGridViewRow row in grid.Rows) @@ -3047,6 +3735,7 @@ public sealed class ClassConfigEditorControl : UserControl _aurasGrid.Rows.Clear(); _spellsGrid.Rows.Clear(); _itemsGrid.Rows.Clear(); + _itemsSearchBox.Clear(); _spellsListGrid.Rows.Clear(); _spellsListSearchBox.Clear(); _groupAurasGrid.Rows.Clear(); @@ -3162,6 +3851,90 @@ public sealed class ClassConfigEditorControl : UserControl private static bool IsHiddenStateName(string? name) => name is not null && FixedStateNames.Contains(name, StringComparer.Ordinal); + private readonly record struct ItemDatabaseRange(int SourceIndex, int Count); + + private sealed class ItemDatabaseResultSet + { + private readonly IReadOnlyList _source; + private readonly ItemDatabaseRange[]? _ranges; + private readonly int[]? _indices; + + private ItemDatabaseResultSet( + IReadOnlyList source, + ItemDatabaseRange[]? ranges, + int[]? indices, + int count) + { + _source = source; + _ranges = ranges; + _indices = indices; + Count = count; + } + + public static ItemDatabaseResultSet Empty { get; } = new( + Array.Empty(), + Array.Empty(), + null, + 0); + + public int Count { get; } + + public ItemSuggestion this[int index] + { + get + { + if (index < 0 || index >= Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + if (_indices is not null) + { + return _source[_indices[index]]; + } + + var remaining = index; + foreach (var range in _ranges!) + { + if (remaining < range.Count) + { + return _source[range.SourceIndex + remaining]; + } + + remaining -= range.Count; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + public static ItemDatabaseResultSet FromAll(IReadOnlyList source) + => source.Count == 0 + ? Empty + : new ItemDatabaseResultSet( + source, + [new ItemDatabaseRange(0, source.Count)], + null, + source.Count); + + public static ItemDatabaseResultSet FromRanges( + IReadOnlyList source, + ItemDatabaseRange[] ranges) + { + var count = ranges.Sum(range => range.Count); + return count == 0 + ? Empty + : new ItemDatabaseResultSet(source, ranges, null, count); + } + + public static ItemDatabaseResultSet FromIndices( + IReadOnlyList source, + int[] indices) + => indices.Length == 0 + ? Empty + : new ItemDatabaseResultSet(source, null, indices, indices.Length); + } + private readonly record struct SpellDatabaseRange(int SourceIndex, int Count); private sealed class SpellDatabaseResultSet diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index 603a719c..7be30f5d 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -25,6 +25,10 @@ public sealed class ClassMacrosEditorControl : UserControl private readonly DataGridView _staticGrid = new(); private readonly DataGridView _specialGrid = new(); + private IReadOnlyDictionary _macroBodies = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary> _classSpellIdsByName = new(StringComparer.Ordinal); + private readonly Dictionary> _classItemIdsByName = new(StringComparer.Ordinal); private ClassMacrosStore.MacrosDocument? _document; private ClassMacrosStore.ClassMacros? _currentMacros; private string? _currentClassFile; @@ -46,9 +50,20 @@ public sealed class ClassMacrosEditorControl : UserControl _reloadButton = UiTheme.CreateButton("刷新", UiTheme.ButtonKind.Secondary); _saveButton = UiTheme.CreateButton("保存", UiTheme.ButtonKind.Primary); InitializeComponent(); + SpellIconCatalog.CatalogChanged += OnSpellIconCatalogChanged; ReloadFromAddon(); } + protected override void Dispose(bool disposing) + { + if (disposing) + { + SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; + } + + base.Dispose(disposing); + } + private void InitializeComponent() { Dock = DockStyle.Fill; @@ -343,6 +358,7 @@ public sealed class ClassMacrosEditorControl : UserControl editor.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); ConfigureGrid(_dynamicGrid, "class-macros-dynamic"); + _dynamicGrid.Columns.Add(CreateMacroIconColumn()); _dynamicGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", @@ -428,6 +444,7 @@ public sealed class ClassMacrosEditorControl : UserControl Width = 72, ReadOnly = true }); + grid.Columns.Add(CreateMacroIconColumn()); if (showParsedMacro) { grid.Columns.Add(new DataGridViewTextBoxColumn @@ -499,6 +516,11 @@ public sealed class ClassMacrosEditorControl : UserControl UpdateMacroDisplay(grid, grid.Rows[e.RowIndex]); } + if (e.RowIndex >= 0 && e.RowIndex < grid.Rows.Count) + { + UpdateMacroIcon(grid, grid.Rows[e.RowIndex]); + } + MarkDirty(); UpdateOffsetHint(); }; @@ -664,6 +686,7 @@ public sealed class ClassMacrosEditorControl : UserControl try { _document = ClassMacrosStore.Load(path); + _macroBodies = ClassMacrosStore.LoadMacroBodies(path); } catch (Exception ex) { @@ -757,6 +780,8 @@ public sealed class ClassMacrosEditorControl : UserControl _currentClassFile = item.ClassFile; _currentClassId = item.ClassId > 0 ? item.ClassId : null; + _currentDynamicSpecIndex = null; + ReloadClassIconAliases(); if (_document is null) { _currentMacros = null; @@ -852,7 +877,9 @@ public sealed class ClassMacrosEditorControl : UserControl CommitCurrentDynamicFromUi(); _currentDynamicSpecIndex = option.SpecIndex; + ReloadClassIconAliases(); FillDynamicEditor(); + RefreshAllMacroIcons(); UpdateOffsetHint(); } @@ -873,7 +900,8 @@ public sealed class ClassMacrosEditorControl : UserControl : _currentMacros.DynamicCommon; foreach (var name in spells) { - _dynamicGrid.Rows.Add(name, "×"); + var rowIndex = _dynamicGrid.Rows.Add(ResolveNamedMacroIcon(name)!, name, "×"); + UpdateMacroIcon(_dynamicGrid, _dynamicGrid.Rows[rowIndex]); } } finally @@ -1170,6 +1198,8 @@ public sealed class ClassMacrosEditorControl : UserControl { UpdateMacroDisplay(grid, row); } + + UpdateMacroIcon(grid, row); } } @@ -1193,6 +1223,7 @@ public sealed class ClassMacrosEditorControl : UserControl row.Cells["Unit"].Value = parsed.Unit.ToString(CultureInfo.InvariantCulture); row.Cells["Spell"].Value = parsed.Spell; row.Cells["Condition"].Value = MacroConditionText.ToDisplayText(parsed.Condition); + UpdateMacroIcon(grid, row); } finally { @@ -1200,6 +1231,251 @@ public sealed class ClassMacrosEditorControl : UserControl } } + private void OnSpellIconCatalogChanged() + { + if (IsDisposed || Disposing || !IsHandleCreated) + { + return; + } + + if (InvokeRequired) + { + BeginInvoke(OnSpellIconCatalogChanged); + return; + } + + ReloadClassIconAliases(); + RefreshAllMacroIcons(); + } + + private void RefreshAllMacroIcons() + { + foreach (var grid in new[] { _dynamicGrid, _staticGrid, _specialGrid }) + { + foreach (DataGridViewRow row in grid.Rows) + { + if (!row.IsNewRow) + { + UpdateMacroIcon(grid, row); + } + } + + grid.Invalidate(); + } + } + + private void ReloadClassIconAliases() + { + _classSpellIdsByName.Clear(); + _classItemIdsByName.Clear(); + if (_currentClassId is not { } classId || classId <= 0) + { + return; + } + + var macrosPath = _resolveClassMacrosPath(); + if (string.IsNullOrWhiteSpace(macrosPath)) + { + return; + } + + var classDirectory = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(macrosPath)!, "..", "class")); + var classPath = Path.Combine(classDirectory, $"{ClassNames.GetConfigFileName(classId)}.lua"); + if (!File.Exists(classPath)) + { + return; + } + + try + { + var document = ClassBlocksStore.Load(classPath); + foreach (var spell in document.SpellsList) + { + if (spell.SpellId <= 0 || string.IsNullOrWhiteSpace(spell.Name)) + { + continue; + } + + var name = spell.Name.Trim(); + AddNamedId(_classSpellIdsByName, name, spell.SpellId); + SpellIconCatalog.Register(spell.SpellId, name, overwriteIdName: true); + } + + foreach (var spec in OrderedSpecsForIcons(document)) + { + foreach (var item in spec.Items) + { + if (item.ItemId is not { } itemId || itemId <= 0 || string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + var name = item.Name.Trim(); + AddNamedId(_classItemIdsByName, name, itemId); + SpellIconCatalog.RegisterItem(itemId, name); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or InvalidDataException or ArgumentException) + { + // 当前职业配置缺失时仍可用数据包按名称匹配。 + } + } + + private IEnumerable OrderedSpecsForIcons(ClassBlocksStore.ClassFileDocument document) + { + if (_currentDynamicSpecIndex is { } specId && document.Specs.TryGetValue(specId, out var current)) + { + yield return current; + foreach (var spec in document.Specs.Where(pair => pair.Key != specId).Select(pair => pair.Value)) + { + yield return spec; + } + + yield break; + } + + foreach (var spec in document.Specs.Values) + { + yield return spec; + } + } + + private static void AddNamedId(Dictionary> map, string name, long id) + { + if (!map.TryGetValue(name, out var ids)) + { + ids = []; + map[name] = ids; + } + + if (!ids.Contains(id)) + { + ids.Add(id); + } + } + + private void UpdateMacroIcon(DataGridView grid, DataGridViewRow row) + { + if (!grid.Columns.Contains("Icon") || row.IsNewRow) + { + return; + } + + row.Cells["Icon"].Value = ResolveMacroItemIcon(grid, row); + } + + private Image? ResolveMacroItemIcon(DataGridView grid, DataGridViewRow row) + { + if (grid.Columns.Contains("Spell")) + { + var icon = ResolveNamedMacroIcon(row.Cells["Spell"].Value?.ToString()); + if (icon is not null) + { + return icon; + } + } + + if (grid.Columns.Contains("Name")) + { + var icon = ResolveNamedMacroIcon(row.Cells["Name"].Value?.ToString()); + if (icon is not null) + { + return icon; + } + } + + if (grid.Columns.Contains("Text")) + { + return ResolveItemReferenceIcon(row.Cells["Text"].Value?.ToString()); + } + + return null; + } + + private Image? ResolveNamedMacroIcon(string? name) + { + var normalized = name?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + var itemReferenceIcon = ResolveItemReferenceIcon(normalized); + if (itemReferenceIcon is not null) + { + return itemReferenceIcon; + } + + if (_classSpellIdsByName.TryGetValue(normalized, out var spellIds)) + { + foreach (var spellId in spellIds) + { + var icon = SpellIconCatalog.Get(spellId); + if (icon is not null) + { + return icon; + } + } + } + + if (_classItemIdsByName.TryGetValue(normalized, out var itemIds)) + { + foreach (var itemId in itemIds) + { + var icon = SpellIconCatalog.GetItem(itemId); + if (icon is not null) + { + return icon; + } + } + } + + return SpellIconCatalog.Get(normalized) ?? SpellIconCatalog.GetItem(normalized); + } + + private Image? ResolveItemReferenceIcon(string? text) + { + var normalized = text?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + if (SpellIconCatalog.TryParseItemReference(normalized, out var itemId)) + { + return SpellIconCatalog.GetItem(itemId); + } + + if (_macroBodies.TryGetValue(normalized, out var body) + && SpellIconCatalog.TryParseItemReference(body, out itemId)) + { + return SpellIconCatalog.GetItem(itemId); + } + + return null; + } + + private static DataGridViewImageColumn CreateMacroIconColumn() + => new() + { + Name = "Icon", + HeaderText = "图标", + Width = 54, + MinimumWidth = 54, + AutoSizeMode = DataGridViewAutoSizeColumnMode.None, + ImageLayout = DataGridViewImageCellLayout.Zoom, + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.NotSortable, + DefaultCellStyle = new DataGridViewCellStyle + { + Alignment = DataGridViewContentAlignment.MiddleCenter, + NullValue = null, + BackColor = UiTheme.SurfaceRaised + } + }; + private static void RenumberArrayRows(DataGridView grid) { if (!grid.Columns.Contains("Index")) diff --git a/UI/ConditionEditorForm.cs b/UI/ConditionEditorForm.cs index f6631e35..22dd9aea 100644 --- a/UI/ConditionEditorForm.cs +++ b/UI/ConditionEditorForm.cs @@ -214,6 +214,7 @@ public sealed class ConditionEditorForm : Form } InitializeComponent(); + SpellIconCatalog.CatalogChanged += OnSpellIconCatalogChanged; foreach (var term in ConditionExpression.Parse(condition)) { @@ -268,11 +269,28 @@ public sealed class ConditionEditorForm : Form protected override void OnFormClosed(FormClosedEventArgs e) { + SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; CloseConditionComboDropDown(); SaveWindowSize(); base.OnFormClosed(e); } + private void OnSpellIconCatalogChanged() + { + if (IsDisposed || Disposing || !IsHandleCreated) + { + return; + } + + if (InvokeRequired) + { + BeginInvoke(OnSpellIconCatalogChanged); + return; + } + + _conditionsGrid.Invalidate(); + } + private void RestoreCachedWindowSize() { var cached = UiCacheStore.Load().ConditionEditorWindowSize; @@ -897,6 +915,11 @@ public sealed class ConditionEditorForm : Form private static Image? ResolveFieldIcon(FieldItem field) { + if (field.ItemId is > 0) + { + return SpellIconCatalog.GetItem(field.ItemId.Value); + } + if (field.IsCustom || field.Category is not (ConditionFieldCategory.Spell or ConditionFieldCategory.Aura)) { @@ -1280,7 +1303,8 @@ public sealed class ConditionEditorForm : Form field.Type, field.Category, field.Classification, - IsCustom: false)); + IsCustom: false, + field.ItemId)); } FieldItem? selected = null; @@ -1772,7 +1796,8 @@ public sealed class ConditionEditorForm : Form ConditionFieldType Type, ConditionFieldCategory Category, string? Classification, - bool IsCustom) + bool IsCustom, + long? ItemId = null) { public override string ToString() => Display; } diff --git a/UI/MainForm.cs b/UI/MainForm.cs index aafed1f5..db631ca2 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -1242,7 +1242,7 @@ public sealed class MainForm : Form, IMessageFilter ConfigureSettingsCardRows(spellIconPackageCard, 32, 30, null, settingsActionButtonHeight); spellIconPackageCard.Controls.Add(CreateTitle("下载数据包"), 0, 0); spellIconPackageCard.Controls.Add( - CreateDescription("从 GitHub 下载或更新技能图标数据包;不会随发布包自动附带"), + CreateDescription("从 GitHub 下载或更新技能/物品图标数据包;不会随发布包自动附带"), 0, 1); @@ -1304,7 +1304,7 @@ public sealed class MainForm : Form, IMessageFilter : "正在检查……"; }); - AppendLog("开始检查 GitHub 技能图标数据包"); + AppendLog("开始检查 GitHub 技能/物品图标数据包"); try { var result = await _spellIconPackageDownloadService.UpdateAsync(progress, cancellationToken); @@ -1315,15 +1315,16 @@ public sealed class MainForm : Form, IMessageFilter var sizeText = $"{result.Size / 1024d / 1024d:F2} MiB"; var hashText = result.Sha256[..Math.Min(12, result.Sha256.Length)]; + var kind = SpellIconCatalog.IsItemDatabaseAvailable ? "完整包" : "仅技能旧包"; if (result.UpToDate) { - _spellIconPackageStatusLabel.Text = $"已是最新:{sizeText},SHA-256 {hashText}…"; - AppendLog("技能图标数据包已是最新,本地文件未修改"); + _spellIconPackageStatusLabel.Text = $"已是最新({kind}):{sizeText},SHA-256 {hashText}…"; + AppendLog("技能/物品图标数据包已是最新,本地文件未修改"); } else { - _spellIconPackageStatusLabel.Text = $"安装完成:{sizeText},SHA-256 {hashText}…"; - AppendLog("技能图标数据包已下载、校验并热加载"); + _spellIconPackageStatusLabel.Text = $"安装完成({kind}):{sizeText},SHA-256 {hashText}…"; + AppendLog("技能/物品图标数据包已下载、校验并热加载"); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -1331,7 +1332,7 @@ public sealed class MainForm : Form, IMessageFilter if (!_shutdownStarted) { _spellIconPackageStatusLabel.Text = "下载已取消;原数据包未修改。"; - AppendLog("技能图标数据包下载已取消"); + AppendLog("技能/物品图标数据包下载已取消"); } } catch (Exception ex) @@ -1340,7 +1341,7 @@ public sealed class MainForm : Form, IMessageFilter { _spellIconPackageStatusLabel.Text = $"下载失败:{ex.Message}"; _settingsToolTip.SetToolTip(_spellIconPackageStatusLabel, ex.ToString()); - AppendLog($"技能图标数据包下载失败: {ex.Message}"); + AppendLog($"技能/物品图标数据包下载失败: {ex.Message}"); MessageBox.Show( this, ex.Message, @@ -1367,21 +1368,24 @@ public sealed class MainForm : Form, IMessageFilter if (SpellIconCatalog.IsPackageAvailable && File.Exists(packagePath)) { var length = new FileInfo(packagePath).Length; - _spellIconPackageStatusLabel.Text = - $"已安装:{length / 1024d / 1024d:F2} MiB。点击检查 GitHub 更新。"; + var sizeText = $"{length / 1024d / 1024d:F2} MiB"; + _spellIconPackageStatusLabel.Text = SpellIconCatalog.IsItemDatabaseAvailable + ? $"已安装完整包:{sizeText}。点击检查 GitHub 更新。" + : $"已安装仅技能旧包:{sizeText}。物品搜索库不可用,可检查更新以获取完整包。"; _downloadSpellIconPackageButton.Text = "检查更新"; } else if (File.Exists(packagePath)) { - _spellIconPackageStatusLabel.Text = "本地数据包损坏或格式不受支持;技能图标与添加技能联想不可用。"; + _spellIconPackageStatusLabel.Text = "本地数据包损坏或格式不受支持;技能/物品图标与添加联想不可用。"; _downloadSpellIconPackageButton.Text = "重新下载"; } else { - _spellIconPackageStatusLabel.Text = "未安装;技能图标与添加技能联想不可用。"; + _spellIconPackageStatusLabel.Text = "未安装;技能/物品图标与添加技能、物品联想不可用。"; _downloadSpellIconPackageButton.Text = "下载数据包"; } + _downloadSpellIconPackageButton.Enabled = true; _settingsToolTip.SetToolTip( _spellIconPackageStatusLabel, _spellIconPackageStatusLabel.Text); diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 5f0e9420..16d179b2 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -61,6 +61,8 @@ public sealed class ModuleEditorControl : UserControl private readonly List _valueAdjustments = new(); private readonly Dictionary> _currentClassSpellIdsByName = new(StringComparer.Ordinal); + private readonly Dictionary> _currentSpecItemIdsByName = + new(StringComparer.Ordinal); private readonly List _currentClassConditionSpells = new(); private HashSet? _availableConditionFields; private HashSet? _availableGroupConditionFields; @@ -595,6 +597,8 @@ public sealed class ModuleEditorControl : UserControl _specBox.SelectedIndexChanged += (_, _) => { ResetHeroTalentOptions(_heroTalentBox, ReadMatchCombo(_classBox), ReadMatchCombo(_specBox)); + ReloadCurrentClassSpellIds(); + RefreshRuleSpellIcons(); RefreshAdjustmentFieldColumn(); InvalidateConditionFieldValidation(); _rulesGrid.Invalidate(); @@ -1202,6 +1206,7 @@ public sealed class ModuleEditorControl : UserControl private void ReloadCurrentClassSpellIds() { _currentClassSpellIdsByName.Clear(); + _currentSpecItemIdsByName.Clear(); _currentClassConditionSpells.Clear(); var classId = ReadMatchCombo(_classBox); if (classId is null) @@ -1239,6 +1244,31 @@ public sealed class ModuleEditorControl : UserControl spellIds.Add(spell.SpellId); } } + + var specId = ReadMatchCombo(_specBox); + if (specId is not null && document.Specs.TryGetValue(specId.Value, out var spec)) + { + foreach (var item in spec.Items) + { + if (item.ItemId is not { } itemId || itemId <= 0 || string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + var name = item.Name.Trim(); + SpellIconCatalog.RegisterItem(itemId, name); + if (!_currentSpecItemIdsByName.TryGetValue(name, out var itemIds)) + { + itemIds = []; + _currentSpecItemIdsByName[name] = itemIds; + } + + if (!itemIds.Contains(itemId)) + { + itemIds.Add(itemId); + } + } + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException) @@ -1255,6 +1285,33 @@ public sealed class ModuleEditorControl : UserControl return null; } + if (_currentSpecItemIdsByName.TryGetValue(normalized, out var itemIds)) + { + foreach (var itemId in itemIds) + { + var icon = SpellIconCatalog.GetItem(itemId); + if (icon is not null) + { + return icon; + } + } + } + + if (SpellIconCatalog.TryParseItemReference(normalized, out var explicitItemId)) + { + var icon = SpellIconCatalog.GetItem(explicitItemId); + if (icon is not null) + { + return icon; + } + } + + var officialItemIcon = SpellIconCatalog.GetItem(normalized); + if (officialItemIcon is not null) + { + return officialItemIcon; + } + if (_currentClassSpellIdsByName.TryGetValue(normalized, out var spellIds)) { foreach (var spellId in spellIds) diff --git a/UI/SpellIconCatalog.cs b/UI/SpellIconCatalog.cs index 1574e86e..4f5ffc66 100644 --- a/UI/SpellIconCatalog.cs +++ b/UI/SpellIconCatalog.cs @@ -1,20 +1,30 @@ using System.Drawing; +using System.Globalization; +using System.Text.RegularExpressions; namespace Shigure; internal sealed record SpellSuggestion(long SpellId, string Name); +internal sealed record ItemSuggestion(long ItemId, string Name); + /// -/// 技能名称/ID 到技能图标的只读目录。完整目录只来自外置数据包;数据包缺失或 -/// 损坏时,技能图标与 spellId 联想均不可用。 +/// 技能/物品名称与 ID 到图标的只读目录。完整目录只来自外置数据包;数据包缺失或 +/// 损坏时,技能图标与 spellId 联想均不可用。仅技能旧包仍可加载技能,此时物品搜索库关闭。 /// internal static class SpellIconCatalog { private static readonly object SyncRoot = new(); private static readonly Dictionary Icons = new(); + private static readonly Dictionary ItemIcons = new(); private static readonly Dictionary NamedIcons = new(StringComparer.Ordinal); private static readonly Dictionary RegisteredSpellIdsByName = new(StringComparer.Ordinal); private static readonly Dictionary RegisteredSpellNamesById = new(); + private static readonly Dictionary RegisteredItemIdsByName = new(StringComparer.Ordinal); + private static readonly Dictionary RegisteredItemNamesById = new(); + private static readonly Regex ItemReferenceRegex = new( + @"item:(\d+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); private static readonly Dictionary SpellIdIconResources = new() { @@ -39,7 +49,9 @@ internal static class SpellIconCatalog private static SpellIconPackage? _package; private static Dictionary _spellIdsByName = new(StringComparer.Ordinal); + private static Dictionary _itemIdsByName = new(StringComparer.Ordinal); private static IReadOnlyList _suggestionsBySpellId = Array.Empty(); + private static IReadOnlyList _suggestionsByItemId = Array.Empty(); static SpellIconCatalog() { @@ -68,6 +80,17 @@ internal static class SpellIconCatalog } } + internal static bool IsItemDatabaseAvailable + { + get + { + lock (SyncRoot) + { + return _package is { HasItemDatabase: true }; + } + } + } + private static void PromotePendingPackage() { if (!File.Exists(PendingPackagePath)) @@ -116,8 +139,8 @@ internal static class SpellIconCatalog icon = LoadResource(resourceName); } - icon ??= _package.LoadIcon(spellId); - return icon is null ? null : CacheLocked(spellId, icon); + icon ??= _package.LoadSpellIcon(spellId); + return icon is null ? null : CacheLocked(Icons, spellId, icon); } } @@ -147,6 +170,37 @@ internal static class SpellIconCatalog } } + public static Image? GetItem(long itemId) + { + if (itemId <= 0) + { + return null; + } + + lock (SyncRoot) + { + if (_package is not { HasItemDatabase: true }) + { + return null; + } + + if (ItemIcons.TryGetValue(itemId, out var cached)) + { + return cached; + } + + var icon = _package.LoadItemIcon(itemId); + return icon is null ? null : CacheLocked(ItemIcons, itemId, icon); + } + } + + public static Image? GetItem(string? itemName) + { + return TryResolveItemId(itemName, out var itemId) + ? GetItem(itemId) + : null; + } + public static void Register(long spellId, string? spellName, bool overwriteIdName = false) { var normalized = spellName?.Trim(); @@ -171,6 +225,22 @@ internal static class SpellIconCatalog } } + public static void RegisterItem(long itemId, string? itemName) + { + var normalized = itemName?.Trim(); + if (itemId <= 0 || string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + lock (SyncRoot) + { + RegisteredItemIdsByName[normalized] = itemId; + RegisteredItemNamesById.TryAdd(itemId, normalized); + _itemIdsByName[normalized] = itemId; + } + } + internal static string? ResolveSuggestionName(long spellId, string? packageName) { if (!string.IsNullOrWhiteSpace(packageName)) @@ -184,6 +254,20 @@ internal static class SpellIconCatalog } } + internal static string? ResolveItemSuggestionName(long itemId, string? packageName) + { + if (!string.IsNullOrWhiteSpace(packageName)) + { + return packageName; + } + + lock (SyncRoot) + { + return RegisteredItemNamesById.GetValueOrDefault(itemId) + ?? _package?.ItemNamesById.GetValueOrDefault(itemId); + } + } + internal static IReadOnlyDictionary GetRegisteredSpellNamesSnapshot() { lock (SyncRoot) @@ -192,6 +276,14 @@ internal static class SpellIconCatalog } } + internal static IReadOnlyDictionary GetRegisteredItemNamesSnapshot() + { + lock (SyncRoot) + { + return new Dictionary(RegisteredItemNamesById); + } + } + internal static IReadOnlyList GetSuggestionsSnapshot() { lock (SyncRoot) @@ -202,6 +294,54 @@ internal static class SpellIconCatalog } } + internal static IReadOnlyList GetItemSuggestionsSnapshot() + { + lock (SyncRoot) + { + return _package is { HasItemDatabase: true } + ? _suggestionsByItemId + : Array.Empty(); + } + } + + internal static bool TryResolveItemId(string? text, out long itemId) + { + itemId = 0; + var normalized = text?.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + if (TryParseItemReference(normalized, out itemId)) + { + return true; + } + + lock (SyncRoot) + { + return _itemIdsByName.TryGetValue(normalized, out itemId); + } + } + + internal static bool TryParseItemReference(string? text, out long itemId) + { + itemId = 0; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + var match = ItemReferenceRegex.Match(text); + return match.Success + && long.TryParse( + match.Groups[1].Value, + NumberStyles.None, + CultureInfo.InvariantCulture, + out itemId) + && itemId > 0; + } + public static Image? GetLastRuleRowIcon() { lock (SyncRoot) @@ -262,7 +402,7 @@ internal static class SpellIconCatalog if (failure is not null) { - throw new IOException("安装技能图标数据包失败,已尝试恢复原数据包。", failure); + throw new IOException("安装技能/物品图标数据包失败,已尝试恢复原数据包。", failure); } } @@ -297,15 +437,15 @@ internal static class SpellIconCatalog } } - private static Image CacheLocked(long spellId, Image icon) + private static Image CacheLocked(Dictionary cache, long id, Image icon) { - if (Icons.TryGetValue(spellId, out var cached)) + if (cache.TryGetValue(id, out var cached)) { icon.Dispose(); return cached; } - Icons[spellId] = icon; + cache[id] = icon; return icon; } @@ -343,9 +483,11 @@ internal static class SpellIconCatalog private static void RebuildIndexesLocked() { _spellIdsByName = new Dictionary(RegisteredSpellIdsByName, StringComparer.Ordinal); + _itemIdsByName = new Dictionary(RegisteredItemIdsByName, StringComparer.Ordinal); if (_package is null) { _suggestionsBySpellId = Array.Empty(); + _suggestionsByItemId = Array.Empty(); return; } @@ -360,6 +502,24 @@ internal static class SpellIconCatalog spellId, _package.SpellNamesById.GetValueOrDefault(spellId) ?? string.Empty)) .ToArray()); + + if (!_package.HasItemDatabase) + { + _suggestionsByItemId = Array.Empty(); + return; + } + + foreach (var (name, itemId) in _package.ItemIdsByName) + { + _itemIdsByName.TryAdd(name, itemId); + } + + _suggestionsByItemId = Array.AsReadOnly( + _package.ItemIds + .Select(itemId => new ItemSuggestion( + itemId, + _package.ItemNamesById.GetValueOrDefault(itemId) ?? string.Empty)) + .ToArray()); } private static void DisposeImageCachesLocked() @@ -370,6 +530,12 @@ internal static class SpellIconCatalog } Icons.Clear(); + foreach (var image in ItemIcons.Values) + { + image.Dispose(); + } + + ItemIcons.Clear(); foreach (var image in NamedIcons.Values) { image?.Dispose(); @@ -381,13 +547,17 @@ internal static class SpellIconCatalog private sealed class SpellIconPackage : IDisposable { private static readonly byte[] Magic = "SHGICN1\0"u8.ToArray(); + private static readonly byte[] ItemFooterMagic = "SHGITM1\0"u8.ToArray(); private const int Version = 1; private const int HeaderSize = 56; private const int RecordSize = 12; + private const int ItemFooterSize = 48; private readonly FileStream _stream; private readonly long[] _spellIds; - private readonly int[] _iconIndices; + private readonly int[] _spellIconIndices; + private readonly long[] _itemIds; + private readonly int[] _itemIconIndices; private readonly long[] _iconOffsets; private readonly int[] _iconLengths; @@ -423,7 +593,7 @@ internal static class SpellIconCatalog } _spellIds = new long[spellCount]; - _iconIndices = new int[spellCount]; + _spellIconIndices = new int[spellCount]; _stream.Position = spellMapOffset; for (var index = 0; index < spellCount; index++) { @@ -438,7 +608,7 @@ internal static class SpellIconCatalog } _spellIds[index] = spellId; - _iconIndices[index] = iconIndex; + _spellIconIndices[index] = iconIndex; } _iconOffsets = new long[iconCount]; @@ -485,6 +655,14 @@ internal static class SpellIconCatalog { throw new InvalidDataException("Spell icon package index size mismatch."); } + + ItemIdsByName = new Dictionary(StringComparer.Ordinal); + ItemNamesById = new Dictionary(); + if (!TryReadItemExtension(reader, dataOffset, iconCount, out _itemIds, out _itemIconIndices)) + { + _itemIds = []; + _itemIconIndices = []; + } } catch { @@ -495,7 +673,11 @@ internal static class SpellIconCatalog public Dictionary SpellIdsByName { get; } public Dictionary SpellNamesById { get; } + public Dictionary ItemIdsByName { get; } + public Dictionary ItemNamesById { get; } public IReadOnlyList SpellIds => _spellIds; + public IReadOnlyList ItemIds => _itemIds; + public bool HasItemDatabase => _itemIds.Length > 0; public static SpellIconPackage Open(string path) => new(path); @@ -512,15 +694,111 @@ internal static class SpellIconCatalog } } - public Image? LoadIcon(long spellId) + public Image? LoadSpellIcon(long spellId) { var spellIndex = Array.BinarySearch(_spellIds, spellId); - if (spellIndex < 0) + return spellIndex < 0 ? null : LoadIconBlob(_spellIconIndices[spellIndex]); + } + + public Image? LoadItemIcon(long itemId) + { + var itemIndex = Array.BinarySearch(_itemIds, itemId); + return itemIndex < 0 ? null : LoadIconBlob(_itemIconIndices[itemIndex]); + } + + public void Dispose() => _stream.Dispose(); + + private bool TryReadItemExtension( + BinaryReader reader, + long dataOffset, + int iconCount, + out long[] itemIds, + out int[] itemIconIndices) + { + itemIds = []; + itemIconIndices = []; + if (_stream.Length < dataOffset + ItemFooterSize) { - return null; + return false; } - var iconIndex = _iconIndices[spellIndex]; + var footerOffset = _stream.Length - ItemFooterSize; + if (footerOffset < dataOffset) + { + return false; + } + + _stream.Position = footerOffset; + var magic = reader.ReadBytes(ItemFooterMagic.Length); + if (!magic.SequenceEqual(ItemFooterMagic)) + { + return false; + } + + var itemCount = reader.ReadInt32(); + var itemNameCount = reader.ReadInt32(); + var itemMapOffset = reader.ReadInt64(); + var itemNameOffset = reader.ReadInt64(); + _ = reader.ReadInt64(); + _ = reader.ReadInt64(); + if (itemCount is < 1 or > 2_000_000 + || itemNameCount is < 0 or > 2_000_000 + || itemMapOffset < dataOffset + || itemNameOffset != itemMapOffset + (long)itemCount * RecordSize + || itemNameOffset > footerOffset) + { + throw new InvalidDataException("Invalid item extension footer in icon package."); + } + + itemIds = new long[itemCount]; + itemIconIndices = new int[itemCount]; + _stream.Position = itemMapOffset; + for (var index = 0; index < itemCount; index++) + { + var itemId = reader.ReadInt64(); + var iconIndex = reader.ReadInt32(); + if (itemId <= 0 + || index > 0 && itemId <= itemIds[index - 1] + || iconIndex < 0 + || iconIndex >= iconCount) + { + throw new InvalidDataException("Invalid item map in icon package."); + } + + itemIds[index] = itemId; + itemIconIndices[index] = iconIndex; + } + + _stream.Position = itemNameOffset; + for (var index = 0; index < itemNameCount; index++) + { + var itemId = reader.ReadInt64(); + var byteLength = reader.ReadInt32(); + if (itemId <= 0 + || byteLength is < 1 or > 4096 + || _stream.Position > footerOffset - byteLength) + { + throw new InvalidDataException("Invalid item name index in icon package."); + } + + var name = System.Text.Encoding.UTF8.GetString(reader.ReadBytes(byteLength)); + if (!string.IsNullOrWhiteSpace(name)) + { + ItemIdsByName.TryAdd(name, itemId); + ItemNamesById.TryAdd(itemId, name); + } + } + + if (_stream.Position != footerOffset) + { + throw new InvalidDataException("Item name table size mismatch in icon package."); + } + + return true; + } + + private Image? LoadIconBlob(int iconIndex) + { var bytes = new byte[_iconLengths[iconIndex]]; try { @@ -535,7 +813,5 @@ internal static class SpellIconCatalog return null; } } - - public void Dispose() => _stream.Dispose(); } } diff --git a/UI/StatusForm.cs b/UI/StatusForm.cs index 52d0dd4a..80c92e2a 100644 --- a/UI/StatusForm.cs +++ b/UI/StatusForm.cs @@ -239,6 +239,18 @@ public sealed class StatusForm : Form public StatusForm() { InitializeComponent(); + UiTheme.SetListViewSubItemIconResolver(_stateList, ResolveStateListIcon); + SpellIconCatalog.CatalogChanged += OnSpellIconCatalogChanged; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; + } + + base.Dispose(disposing); } protected override void OnHandleCreated(EventArgs e) @@ -276,6 +288,27 @@ public sealed class StatusForm : Form base.OnFormClosing(e); } + private static Image? ResolveStateListIcon(ListViewItem item, int columnIndex) + => columnIndex == 1 && item.Tag is long itemId && itemId > 0 + ? SpellIconCatalog.GetItem(itemId) + : null; + + private void OnSpellIconCatalogChanged() + { + if (IsDisposed || Disposing || !IsHandleCreated) + { + return; + } + + if (InvokeRequired) + { + BeginInvoke(OnSpellIconCatalogChanged); + return; + } + + _stateList.Invalidate(); + } + private void InitializeComponent() { SuspendLayout(); @@ -1525,7 +1558,13 @@ public sealed class StatusForm : Form } index++; - items.Add(new ListViewItem(new[] { index.ToString(), key, UiTheme.FormatValue(value) })); + var row = new ListViewItem(new[] { index.ToString(), key, UiTheme.FormatValue(value) }); + if (snapshot.State.ItemIds.TryGetValue(key, out var itemId)) + { + row.Tag = itemId; + } + + items.Add(row); } } @@ -1784,7 +1823,8 @@ public sealed class StatusForm : Form { var current = listView.Items[row]; var next = items[row]; - if (current.ToolTipText != next.ToolTipText) + if (current.ToolTipText != next.ToolTipText + || !Equals(current.Tag, next.Tag)) { return false; } @@ -1827,6 +1867,7 @@ public sealed class StatusForm : Form var current = listView.Items[row]; var next = items[row]; current.ToolTipText = next.ToolTipText; + current.Tag = next.Tag; for (var column = 0; column < next.SubItems.Count; column++) { var nextText = next.SubItems[column].Text; diff --git a/UI/UiTheme.cs b/UI/UiTheme.cs index e94ec1be..25d5d7d4 100644 --- a/UI/UiTheme.cs +++ b/UI/UiTheme.cs @@ -18,6 +18,7 @@ internal static class UiTheme private static readonly Dictionary ClassIcons = new(); private static readonly Dictionary<(int ClassId, int SpecId), Image?> SpecIcons = new(); + private static readonly ConditionalWeakTable> ListViewSubItemIcons = new(); private static readonly ConditionalWeakTable ListColumnLayouts = new(); private const int DwmwaUseImmersiveDarkMode = 20; @@ -907,6 +908,13 @@ internal static class UiTheme return icon; } + public static void SetListViewSubItemIconResolver( + ListView listView, + Func resolver) + { + ListViewSubItemIcons.Add(listView, resolver); + } + public static void StyleListView(ListView listView, Font font) { listView.Dock = DockStyle.Fill; @@ -967,7 +975,27 @@ internal static class UiTheme e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + 4, 3, e.Bounds.Height - 8); } - var textBounds = new Rectangle(e.Bounds.X + 8, e.Bounds.Y, e.Bounds.Width - 10, e.Bounds.Height); + var textLeft = e.Bounds.X + 8; + var textWidth = e.Bounds.Width - 10; + if (ListViewSubItemIcons.TryGetValue(listView, out var iconResolver)) + { + var icon = iconResolver(e.Item, e.ColumnIndex); + if (icon is not null) + { + var iconSize = Math.Min(18, Math.Max(14, e.Bounds.Height - 10)); + var iconBounds = new Rectangle( + e.Bounds.X + 8, + e.Bounds.Y + (e.Bounds.Height - iconSize) / 2, + iconSize, + iconSize); + e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + e.Graphics.DrawImage(icon, iconBounds); + textLeft = iconBounds.Right + 6; + textWidth = Math.Max(0, e.Bounds.Right - 8 - textLeft); + } + } + + var textBounds = new Rectangle(textLeft, e.Bounds.Y, textWidth, e.Bounds.Height); TextRenderer.DrawText( e.Graphics, e.SubItem?.Text ?? string.Empty,