mirror of
https://github.com/waynebian01/Shigure.git
synced 2026-09-02 22:39:01 +08:00
更新版本号至 1.2.1.19,优化 README 文档中的功能描述,增强用户对技能和物品图标数据包的理解。添加对物品 ID 的支持,改进状态管理和条件字段显示,确保用户界面信息展示更加清晰。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
12
README.md
12
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 配置同步
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user