更新版本号至 1.2.1.19,优化 README 文档中的功能描述,增强用户对技能和物品图标数据包的理解。添加对物品 ID 的支持,改进状态管理和条件字段显示,确保用户界面信息展示更加清晰。

This commit is contained in:
waynebian01
2026-09-01 17:05:20 +08:00
parent 3a5b4e8db5
commit ccd670dfb5
13 changed files with 1596 additions and 63 deletions

View File

@@ -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<string, string> LoadMacroBodies(string filePath)
{
var result = new Dictionary<string, string>(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);

View File

@@ -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))

View File

@@ -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` 中索引 1100 的法术 ID、索引和名称也可输入 spellId 与名称并自动分配空闲索引。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/items/group` 格式。
- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块,或按职业、专精、英雄天赋和队伍类型指定默认模块;从 GitHub 按需下载或更新技能/物品图标数据包。
- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术、物品和队伍字段;物品页为当前专精列表与全量物品数据库双栏,维护 `itemId`、名称及是否装备中技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1100 的法术 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 配置同步

View File

@@ -24,6 +24,11 @@ public sealed class GameState
? displayTypes
: new Dictionary<string, string>();
public IReadOnlyDictionary<string, long> ItemIds =>
Values.TryGetValue("$itemIds", out var value) && value is IReadOnlyDictionary<string, long> itemIds
? itemIds
: new Dictionary<string, long>();
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>> Group =>
Values.TryGetValue("group", out var value) && value is IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>> group
? group

View File

@@ -22,6 +22,7 @@ public sealed class StateBuilder : IRuntimeStateBuilder
var result = new Dictionary<string, object?>();
healAbsorbData ??= new Dictionary<int, int>();
var itemIds = new Dictionary<string, long>(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)

View File

@@ -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<ItemSuggestion> source,
IReadOnlyDictionary<long, string> 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<int>();
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<ItemSuggestion> 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<ItemDatabaseRange>(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<ItemSuggestion> 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<ItemSuggestion> 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<string> CurrentSpecReservedItemNames()
{
var bareNames = new HashSet<string>(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<ItemSuggestion> _source;
private readonly ItemDatabaseRange[]? _ranges;
private readonly int[]? _indices;
private ItemDatabaseResultSet(
IReadOnlyList<ItemSuggestion> source,
ItemDatabaseRange[]? ranges,
int[]? indices,
int count)
{
_source = source;
_ranges = ranges;
_indices = indices;
Count = count;
}
public static ItemDatabaseResultSet Empty { get; } = new(
Array.Empty<ItemSuggestion>(),
Array.Empty<ItemDatabaseRange>(),
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<ItemSuggestion> source)
=> source.Count == 0
? Empty
: new ItemDatabaseResultSet(
source,
[new ItemDatabaseRange(0, source.Count)],
null,
source.Count);
public static ItemDatabaseResultSet FromRanges(
IReadOnlyList<ItemSuggestion> 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<ItemSuggestion> 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

View File

@@ -25,6 +25,10 @@ public sealed class ClassMacrosEditorControl : UserControl
private readonly DataGridView _staticGrid = new();
private readonly DataGridView _specialGrid = new();
private IReadOnlyDictionary<string, string> _macroBodies =
new Dictionary<string, string>(StringComparer.Ordinal);
private readonly Dictionary<string, List<long>> _classSpellIdsByName = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<long>> _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<ClassBlocksStore.SpecBlocks> 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<string, List<long>> 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"))

View File

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

View File

@@ -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);

View File

@@ -61,6 +61,8 @@ public sealed class ModuleEditorControl : UserControl
private readonly List<ModuleValueAdjustment> _valueAdjustments = new();
private readonly Dictionary<string, List<long>> _currentClassSpellIdsByName =
new(StringComparer.Ordinal);
private readonly Dictionary<string, List<long>> _currentSpecItemIdsByName =
new(StringComparer.Ordinal);
private readonly List<ConditionSpell> _currentClassConditionSpells = new();
private HashSet<string>? _availableConditionFields;
private HashSet<string>? _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)

View File

@@ -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);
/// <summary>
/// 技能名称/ID 到技能图标的只读目录。完整目录只来自外置数据包;数据包缺失或
/// 损坏时,技能图标与 spellId 联想均不可用。
/// 技能/物品名称与 ID 到图标的只读目录。完整目录只来自外置数据包;数据包缺失或
/// 损坏时,技能图标与 spellId 联想均不可用。仅技能旧包仍可加载技能,此时物品搜索库关闭。
/// </summary>
internal static class SpellIconCatalog
{
private static readonly object SyncRoot = new();
private static readonly Dictionary<long, Image> Icons = new();
private static readonly Dictionary<long, Image> ItemIcons = new();
private static readonly Dictionary<string, Image?> NamedIcons = new(StringComparer.Ordinal);
private static readonly Dictionary<string, long> RegisteredSpellIdsByName = new(StringComparer.Ordinal);
private static readonly Dictionary<long, string> RegisteredSpellNamesById = new();
private static readonly Dictionary<string, long> RegisteredItemIdsByName = new(StringComparer.Ordinal);
private static readonly Dictionary<long, string> RegisteredItemNamesById = new();
private static readonly Regex ItemReferenceRegex = new(
@"item:(\d+)",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly Dictionary<long, string> SpellIdIconResources = new()
{
@@ -39,7 +49,9 @@ internal static class SpellIconCatalog
private static SpellIconPackage? _package;
private static Dictionary<string, long> _spellIdsByName = new(StringComparer.Ordinal);
private static Dictionary<string, long> _itemIdsByName = new(StringComparer.Ordinal);
private static IReadOnlyList<SpellSuggestion> _suggestionsBySpellId = Array.Empty<SpellSuggestion>();
private static IReadOnlyList<ItemSuggestion> _suggestionsByItemId = Array.Empty<ItemSuggestion>();
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<long, string> GetRegisteredSpellNamesSnapshot()
{
lock (SyncRoot)
@@ -192,6 +276,14 @@ internal static class SpellIconCatalog
}
}
internal static IReadOnlyDictionary<long, string> GetRegisteredItemNamesSnapshot()
{
lock (SyncRoot)
{
return new Dictionary<long, string>(RegisteredItemNamesById);
}
}
internal static IReadOnlyList<SpellSuggestion> GetSuggestionsSnapshot()
{
lock (SyncRoot)
@@ -202,6 +294,54 @@ internal static class SpellIconCatalog
}
}
internal static IReadOnlyList<ItemSuggestion> GetItemSuggestionsSnapshot()
{
lock (SyncRoot)
{
return _package is { HasItemDatabase: true }
? _suggestionsByItemId
: Array.Empty<ItemSuggestion>();
}
}
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<long, Image> 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<string, long>(RegisteredSpellIdsByName, StringComparer.Ordinal);
_itemIdsByName = new Dictionary<string, long>(RegisteredItemIdsByName, StringComparer.Ordinal);
if (_package is null)
{
_suggestionsBySpellId = Array.Empty<SpellSuggestion>();
_suggestionsByItemId = Array.Empty<ItemSuggestion>();
return;
}
@@ -360,6 +502,24 @@ internal static class SpellIconCatalog
spellId,
_package.SpellNamesById.GetValueOrDefault(spellId) ?? string.Empty))
.ToArray());
if (!_package.HasItemDatabase)
{
_suggestionsByItemId = Array.Empty<ItemSuggestion>();
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<string, long>(StringComparer.Ordinal);
ItemNamesById = new Dictionary<long, string>();
if (!TryReadItemExtension(reader, dataOffset, iconCount, out _itemIds, out _itemIconIndices))
{
_itemIds = [];
_itemIconIndices = [];
}
}
catch
{
@@ -495,7 +673,11 @@ internal static class SpellIconCatalog
public Dictionary<string, long> SpellIdsByName { get; }
public Dictionary<long, string> SpellNamesById { get; }
public Dictionary<string, long> ItemIdsByName { get; }
public Dictionary<long, string> ItemNamesById { get; }
public IReadOnlyList<long> SpellIds => _spellIds;
public IReadOnlyList<long> 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();
}
}

View File

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

View File

@@ -18,6 +18,7 @@ internal static class UiTheme
private static readonly Dictionary<int, Image?> ClassIcons = new();
private static readonly Dictionary<(int ClassId, int SpecId), Image?> SpecIcons = new();
private static readonly ConditionalWeakTable<ListView, Func<ListViewItem, int, Image?>> ListViewSubItemIcons = new();
private static readonly ConditionalWeakTable<ListView, ListColumnLayoutState> ListColumnLayouts = new();
private const int DwmwaUseImmersiveDarkMode = 20;
@@ -907,6 +908,13 @@ internal static class UiTheme
return icon;
}
public static void SetListViewSubItemIconResolver(
ListView listView,
Func<ListViewItem, int, Image?> 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,