配置页改为冷却双栏与职业级物品列表

将配置页「法术」改为「冷却」(左技能冷却、右专精物品冷却),「物品」改为职业级 Fuyutsui.itemsList 编辑;保存时缺表则在 spellsList 后插入。

Co-authored-by: Wayne-Arasaka <waynebian01@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-01 12:01:05 +00:00
parent 8b009cbdb2
commit 75bf8a3518
4 changed files with 618 additions and 117 deletions

View File

@@ -84,9 +84,12 @@ wow_process.txt 目标游戏进程名列表;构建时复制,运行期间每
[Infrastructure/ClassBlocksStore.cs](Infrastructure/ClassBlocksStore.cs) 读写 `class/*.lua` 中的 `Fuyutsui.ClassBlocks` 表。每个职业一个 Lua 文件,按专精 ID 分块,包含:
- **States**(状态字段):分平面列表或按 `"状态"/"目标"/"焦点"` 分类(现代格式)
- **Auras**光环5 桶——玩家/目标有害/目标有益/焦点有害/焦点有益
- **Spells**法术ID、名称、充能、施法计数、强制已知、法术书
- **Spells**技能冷却ID、名称、充能、施法计数、强制已知、法术书
- **Items**(物品冷却):专精级 `[itemId] = { name, isEquipped }`
- **Group**(队伍):人数/生命百分比/角色/驱散 + 队伍光环列表
同文件另有职业级 `Fuyutsui.spellsList``Fuyutsui.itemsList``[itemId] = { name }`),与专精冷却表分开。
字段名从 [Infrastructure/ClassStateCatalog.cs](Infrastructure/ClassStateCatalog.cs) 的静态目录验证,不允许自由输入。
### 宏存储ClassMacros
@@ -99,7 +102,7 @@ wow_process.txt 目标游戏进程名列表;构建时复制,运行期间每
### UI 编辑器
- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的六页编辑器(状态/光环/法术/物品/队伍/技能列表),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`
- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的六页编辑器(状态/光环/冷却/物品列表/队伍/技能列表),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`
- [UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs):左侧职业列表 + 右侧三页编辑器(动态宏/静态宏/特殊宏),偏移提示显示槽位编号计算。
- 两个编辑器均接受 `Func<string?>` 项目路径解析器 + `Func<string, Task<string?>>` 保存回调,由 `MainForm` 在构造时注入。保存流程:编辑器调 `Store.Save()` → 传入已保存文件路径 → 重新生成 config/keymap → 单文件部署游戏 → 重启运行时;部署失败返回说明,但不回滚本地文件。
- 配置更新的多个入口通过任务尾队列串行执行;运行时重启会等待该队列稳定,主窗口关闭也会等待正在写盘的转换和部署完成。新增同步入口必须继续走这条队列。

View File

@@ -7,12 +7,13 @@ namespace Shigure;
/// <summary>
/// 读写 Fuyutsui class/*.lua 中的 ClassBlocksstates/auras/spells/items/group
/// 同时读写 spellsList保存时替换 ClassBlocks 表字面量,并原位更新 spellsList 中已编辑的条目。
/// 同时读写 spellsList 与 itemsList;保存时替换 ClassBlocks 表字面量,并原位更新列表条目。
/// </summary>
internal static class ClassBlocksStore
{
public const string AssignmentName = "Fuyutsui.ClassBlocks";
public const string SpellsListAssignmentName = "Fuyutsui.spellsList";
public const string ItemsListAssignmentName = "Fuyutsui.itemsList";
private static readonly string[] StateCategories =
[
ClassStateCatalog.CategoryState,
@@ -38,6 +39,8 @@ internal static class ClassBlocksStore
public Dictionary<int, SpecBlocks> Specs { get; set; } = new();
public List<SpellsListEntry> SpellsList { get; set; } = new();
public HashSet<long> DeletedSpellsListOriginalIds { get; } = new();
public List<ItemsListEntry> ItemsList { get; set; } = new();
public HashSet<long> DeletedItemsListOriginalIds { get; } = new();
public bool IsModernFormat { get; set; }
}
@@ -51,6 +54,14 @@ internal static class ClassBlocksStore
public string OriginalName { get; set; } = string.Empty;
}
public sealed class ItemsListEntry
{
public long ItemId { get; set; }
public string Name { get; set; } = string.Empty;
public long OriginalItemId { get; set; }
public string OriginalName { get; set; } = string.Empty;
}
public sealed class SpecBlocks
{
public bool NestedStates { get; set; } = true;
@@ -140,6 +151,7 @@ internal static class ClassBlocksStore
}
var spellsList = ParseSpellsList(ExtractAssignedTable(source, SpellsListAssignmentName));
var itemsList = ParseItemsList(ExtractAssignedTable(source, ItemsListAssignmentName));
return new ClassFileDocument
{
FilePath = filePath,
@@ -148,6 +160,7 @@ internal static class ClassBlocksStore
TableEndExclusive = end,
Specs = specs,
SpellsList = spellsList,
ItemsList = itemsList,
IsModernFormat = modern
};
}
@@ -192,6 +205,39 @@ internal static class ClassBlocksStore
return result;
}
private static List<ItemsListEntry> ParseItemsList(TableValue? table)
{
var result = new List<ItemsListEntry>();
if (table is null)
{
return result;
}
foreach (var (key, value) in table.Entries)
{
if (key is not long itemId || itemId <= 0 || value is not TableValue item)
{
continue;
}
var name = item.GetString("name")?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
result.Add(new ItemsListEntry
{
ItemId = itemId,
Name = name,
OriginalItemId = itemId,
OriginalName = name
});
}
return result;
}
public static void Save(ClassFileDocument document)
{
if (!document.IsModernFormat)
@@ -203,6 +249,10 @@ internal static class ClassBlocksStore
document.SourceText,
document.SpellsList,
document.DeletedSpellsListOriginalIds);
updated = UpdateItemsListEntries(
updated,
document.ItemsList,
document.DeletedItemsListOriginalIds);
if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var classBlocksStart, out var classBlocksEnd))
{
throw new InvalidOperationException("保存前无法重新定位 ClassBlocks 表。");
@@ -230,6 +280,13 @@ internal static class ClassBlocksStore
}
document.DeletedSpellsListOriginalIds.Clear();
foreach (var item in document.ItemsList)
{
item.OriginalItemId = item.ItemId;
item.OriginalName = item.Name;
}
document.DeletedItemsListOriginalIds.Clear();
}
public sealed class ItemEntry
@@ -346,6 +403,143 @@ internal static class ClassBlocksStore
return source[..tableStart] + updatedTable + source[tableEnd..];
}
private static string UpdateItemsListEntries(
string source,
IReadOnlyList<ItemsListEntry> entries,
IReadOnlySet<long> deletedOriginalIds)
{
var newline = source.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
var newEntries = entries.Where(entry => entry.OriginalItemId == 0).ToArray();
var changedEntries = entries
.Where(entry => entry.OriginalItemId != 0
&& (entry.ItemId != entry.OriginalItemId
|| !string.Equals(entry.Name, entry.OriginalName, StringComparison.Ordinal)))
.ToDictionary(entry => entry.OriginalItemId);
if (!TryExtractAssignedTable(source, ItemsListAssignmentName, out _, out var tableStart, out var tableEnd))
{
if (!TryExtractAssignedTable(source, SpellsListAssignmentName, out _, out _, out var spellsEnd))
{
throw new InvalidOperationException(
$"当前文件中未找到 {SpellsListAssignmentName},无法写入物品列表。");
}
return source[..spellsEnd]
+ newline
+ newline
+ SerializeItemsListTable(entries, newline)
+ source[spellsEnd..];
}
if (changedEntries.Count == 0 && newEntries.Length == 0 && deletedOriginalIds.Count == 0)
{
return source;
}
var tableText = source[tableStart..tableEnd];
var updatedOriginalIds = new HashSet<long>();
var deletedIdsFound = new HashSet<long>();
var pattern = new Regex(
"""^(?<prefix>[ \t]*\[[ \t]*)(?<itemId>\d+)(?<beforeName>[ \t]*\][ \t]*=[ \t]*\{[ \t]*name[ \t]*=[ \t]*)(?<name>"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')(?<suffix>[^\r\n]*)(?<lineEnd>\r?\n|$)""",
RegexOptions.Multiline | RegexOptions.CultureInvariant);
var updatedTable = pattern.Replace(tableText, match =>
{
if (!long.TryParse(match.Groups["itemId"].Value, NumberStyles.None,
CultureInfo.InvariantCulture, out var originalItemId))
{
return match.Value;
}
if (deletedOriginalIds.Contains(originalItemId))
{
deletedIdsFound.Add(originalItemId);
return string.Empty;
}
if (!changedEntries.TryGetValue(originalItemId, out var entry))
{
updatedOriginalIds.Add(originalItemId);
return match.Value;
}
updatedOriginalIds.Add(originalItemId);
var quotedName = match.Groups["name"].Value;
var quote = quotedName[0];
return match.Groups["prefix"].Value
+ entry.ItemId.ToString(CultureInfo.InvariantCulture)
+ match.Groups["beforeName"].Value
+ quote
+ EscapeLuaString(entry.Name, quote)
+ quote
+ match.Groups["suffix"].Value
+ match.Groups["lineEnd"].Value;
});
var missing = changedEntries.Keys.Where(id => !updatedOriginalIds.Contains(id)).ToArray();
if (missing.Length > 0)
{
throw new InvalidOperationException(
$"无法在 {ItemsListAssignmentName} 中定位 itemId {string.Join(", ", missing)} 的原始条目。");
}
var missingDeleted = deletedOriginalIds.Where(id => !deletedIdsFound.Contains(id)).ToArray();
if (missingDeleted.Length > 0)
{
throw new InvalidOperationException(
$"无法在 {ItemsListAssignmentName} 中定位待删除的 itemId {string.Join(", ", missingDeleted)}。");
}
if (newEntries.Length > 0)
{
var closingBraceIndex = updatedTable.Length - 1;
var closingLineBreak = updatedTable.LastIndexOf('\n', Math.Max(0, closingBraceIndex - 1));
var insertionIndex = closingLineBreak >= 0 ? closingLineBreak + 1 : closingBraceIndex;
var insertion = new StringBuilder();
if (closingLineBreak < 0)
{
insertion.Append(newline);
}
foreach (var entry in newEntries.OrderBy(entry => entry.ItemId))
{
insertion.Append(" [")
.Append(entry.ItemId.ToString(CultureInfo.InvariantCulture))
.Append("] = { name = \"")
.Append(EscapeLuaString(entry.Name, '"'))
.Append("\" },")
.Append(newline);
}
updatedTable = updatedTable.Insert(insertionIndex, insertion.ToString());
}
return source[..tableStart] + updatedTable + source[tableEnd..];
}
private static string SerializeItemsListTable(IReadOnlyList<ItemsListEntry> entries, string newline)
{
var builder = new StringBuilder();
builder.Append(ItemsListAssignmentName).Append(" = {");
if (entries.Count == 0)
{
builder.Append('}');
return builder.ToString();
}
builder.Append(newline);
foreach (var entry in entries.OrderBy(item => item.ItemId))
{
builder.Append(" [")
.Append(entry.ItemId.ToString(CultureInfo.InvariantCulture))
.Append("] = { name = \"")
.Append(EscapeLuaString(entry.Name, '"'))
.Append("\" },")
.Append(newline);
}
builder.Append('}');
return builder.ToString();
}
private static string EscapeLuaString(string value, char quote)
{
var escaped = value

View File

@@ -46,7 +46,7 @@ Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyut
- 置顶浮动条:显示程序名、当前职业图标颜色和逻辑状态,提供 `开启/关闭``设置``✕` 按钮。窗口可拖动和缩放,显示后自动启动运行循环。
- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块,或按职业、专精、英雄天赋和队伍类型指定默认模块;从 GitHub 按需下载或更新技能/物品图标数据包。
- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术、物品和队伍字段;物品页为当前专精列表与全量物品数据库双栏,维护 `itemId`、名称及是否装备中;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1100 的法术 ID、索引和名称也可从技能数据库添加。旧版稀疏索引格式只读需先迁移到 `states/auras/spells/items/group` 格式。
- `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、冷却(技能冷却与当前专精物品冷却)和队伍字段;物品列表页编辑职业级 `Fuyutsui.itemsList`,并可从全量物品数据库添加;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1100 的法术 ID、索引和名称也可从技能数据库添加。旧版稀疏索引格式只读需先迁移到 `states/auras/spells/items/group` 格式。
- `宏`:编辑项目 `Fuyutsui/core/classmacros.lua` 中各职业的动态宏、静态宏和特殊宏。动态宏每项占用 30 个团队点名槽位;特殊宏的技能名必须手工填写,不从宏正文解析,生成映射时固定为无目标、无宏条件。
- `模块`:新建、编辑、删除本地模块,维护作者、推荐天赋、匹配条件、动态字段和有序规则。规则支持拖拽、上移、下移、复制与插入。
- `状态`:分栏显示基础状态、`auras``spells` 和模块计算出的动态单位/数值。

View File

@@ -10,7 +10,7 @@ public sealed record ClassConfigPostSaveResult(
/// <summary>
/// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocksstates / auras / spells / items / group
/// 并编辑同文件中的 spellsList。
/// 并编辑同文件中的 spellsList 与 itemsList
/// </summary>
public sealed class ClassConfigEditorControl : UserControl
{
@@ -32,6 +32,8 @@ public sealed class ClassConfigEditorControl : UserControl
private readonly DataGridView _spellsGrid = new();
private readonly DataGridView _itemsGrid = new();
private readonly TextBox _itemsSearchBox = new();
private readonly DataGridView _itemsListGrid = new();
private readonly TextBox _itemsListSearchBox = new();
private readonly DataGridView _itemDatabaseGrid = new();
private readonly TextBox _itemDatabaseFilterBox = new();
private readonly Label _itemDatabaseStatusLabel = new();
@@ -72,6 +74,7 @@ public sealed class ClassConfigEditorControl : UserControl
private int? _currentSpecId;
private bool _suppressUi;
private bool _dirty;
private int _editorTabIndex = -1;
internal event Action<bool>? DirtyStateChanged;
internal bool HasUnsavedChanges => _dirty;
@@ -382,21 +385,28 @@ public sealed class ClassConfigEditorControl : UserControl
}
var tabs = new UiPillTab[6];
var selectedIndex = -1;
void SelectTab(int index)
{
if (selectedIndex == index)
if (_editorTabIndex == index)
{
return;
}
if (!_suppressUi && selectedIndex == 3)
if (!_suppressUi && _editorTabIndex == 2)
{
_spellsGrid.EndEdit();
_itemsGrid.EndEdit();
WriteBackSpells();
WriteBackItems();
}
selectedIndex = index;
if (!_suppressUi && _editorTabIndex == 3)
{
_itemsListGrid.EndEdit();
WriteBackItemsList();
}
_editorTabIndex = index;
for (var i = 0; i < tabs.Length; i++)
{
var selected = i == index;
@@ -409,7 +419,7 @@ public sealed class ClassConfigEditorControl : UserControl
}
}
var titles = new[] { "状态", "光环", "法术", "物品", "队伍", "技能列表" };
var titles = new[] { "状态", "光环", "冷却", "物品列表", "队伍", "技能列表" };
for (var i = 0; i < titles.Length; i++)
{
var index = i;
@@ -522,14 +532,14 @@ public sealed class ClassConfigEditorControl : UserControl
Padding = new Padding(8, 0, 0, 0)
}, 0, 0);
UiTheme.StyleTextBox(_itemsSearchBox);
_itemsSearchBox.Dock = DockStyle.None;
_itemsSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_itemsSearchBox.Margin = new Padding(0);
_itemsSearchBox.Height = 30;
_itemsSearchBox.PlaceholderText = "itemId 或名称";
_itemsSearchBox.TextChanged += (_, _) => ApplyItemsFilter();
searchCard.Controls.Add(_itemsSearchBox, 1, 0);
UiTheme.StyleTextBox(_itemsListSearchBox);
_itemsListSearchBox.Dock = DockStyle.None;
_itemsListSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_itemsListSearchBox.Margin = new Padding(0);
_itemsListSearchBox.Height = 30;
_itemsListSearchBox.PlaceholderText = "itemId 或名称";
_itemsListSearchBox.TextChanged += (_, _) => ApplyItemsListFilter();
searchCard.Controls.Add(_itemsListSearchBox, 1, 0);
leftColumn.Controls.Add(searchCard, 0, 0);
var currentListCard = new UiCardPanel
@@ -555,46 +565,45 @@ public sealed class ClassConfigEditorControl : UserControl
};
currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
currentListHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var currentListTitle = CreateCardTitle("当前专精物品");
var currentListTitle = CreateCardTitle("物品列表");
currentListTitle.AutoSize = true;
currentListTitle.Dock = DockStyle.None;
currentListTitle.Anchor = AnchorStyles.Left;
currentListHeader.Controls.Add(currentListTitle, 0, 0);
var hint = CreateFieldCaption("名称可改为业务别名;图标始终按 itemId 匹配。");
var hint = CreateFieldCaption("来自当前职业 Lua 的 Fuyutsui.itemsList。");
hint.TextAlign = ContentAlignment.MiddleRight;
currentListHeader.Controls.Add(hint, 1, 0);
currentListCard.Controls.Add(currentListHeader, 0, 0);
ConfigureGrid(_itemsGrid, "class-config-items");
_itemsGrid.AllowUserToAddRows = false;
_itemsGrid.CellContentClick += HandleDeleteClick;
_itemsGrid.CellValueChanged += (_, e) =>
ConfigureGrid(_itemsListGrid, "class-config-items-list");
_itemsListGrid.AllowUserToAddRows = false;
_itemsListGrid.CellContentClick += HandleItemsListDeleteClick;
_itemsListGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _itemsGrid.Rows.Count)
if (e.RowIndex >= 0 && e.RowIndex < _itemsListGrid.Rows.Count)
{
UpdateItemGridIcon(_itemsGrid.Rows[e.RowIndex]);
UpdateItemGridIcon(_itemsListGrid.Rows[e.RowIndex]);
}
};
_itemsGrid.DataError += (_, e) => e.ThrowException = false;
_itemsGrid.Columns.Add(CreateSpellIconColumn());
_itemsGrid.Columns.Add(new DataGridViewTextBoxColumn
_itemsListGrid.DataError += (_, e) => e.ThrowException = false;
_itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "ItemId",
HeaderText = "itemId",
Width = 125,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsGrid.Columns.Add(new DataGridViewTextBoxColumn
_itemsListGrid.Columns.Add(CreateSpellIconColumn());
_itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "名称",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsGrid.Columns.Add(CreateSpellCheckColumn("IsEquipped", "是否装备中", 12, 110));
_itemsGrid.Columns.Add(CreateDeleteColumn());
currentListCard.Controls.Add(_itemsGrid, 0, 1);
_itemsListGrid.Columns.Add(CreateDeleteColumn());
currentListCard.Controls.Add(_itemsListGrid, 0, 1);
leftColumn.Controls.Add(currentListCard, 0, 1);
split.Controls.Add(leftColumn, 0, 0);
@@ -871,21 +880,63 @@ public sealed class ClassConfigEditorControl : UserControl
private Control BuildSpellsPage()
{
var panel = new TableLayoutPanel
var split = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0),
Padding = new Padding(0)
};
split.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
split.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
split.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var leftColumn = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
BackColor = UiTheme.SurfaceRaised
RowCount = 2,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0, 0, 5, 0),
Padding = new Padding(0)
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
leftColumn.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
leftColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
var spellCard = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0),
Padding = new Padding(0)
};
spellCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50));
spellCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var spellHeader = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
BackColor = Color.Transparent,
Margin = new Padding(0),
Padding = new Padding(12, 0, 12, 0)
};
spellHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
spellHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var spellTitle = CreateCardTitle("技能冷却");
spellTitle.AutoSize = true;
spellTitle.Dock = DockStyle.None;
spellTitle.Anchor = AnchorStyles.Left;
spellHeader.Controls.Add(spellTitle, 0, 0);
var textureOrderHint = CreateFieldCaption(
"纹理按行排列;充能法术连续占 2 格:冷却 → 充能冷却。最大充能、施法次数只生成横向计数条。");
textureOrderHint.Padding = new Padding(8, 0, 0, 0);
panel.Controls.Add(textureOrderHint, 0, 0);
"充能法术连续占 2 格:冷却 → 充能冷却。");
textureOrderHint.TextAlign = ContentAlignment.MiddleRight;
spellHeader.Controls.Add(textureOrderHint, 1, 0);
spellCard.Controls.Add(spellHeader, 0, 0);
ConfigureGrid(_spellsGrid, "class-config-spells");
_spellsGrid.Columns.Add(CreateSpellIconColumn());
@@ -908,9 +959,122 @@ public sealed class ClassConfigEditorControl : UserControl
};
_spellsGrid.UserAddedRow += (_, _) => MarkDirty();
_spellsGrid.DataError += (_, e) => e.ThrowException = false;
panel.Controls.Add(_spellsGrid, 0, 1);
panel.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 2);
return panel;
spellCard.Controls.Add(_spellsGrid, 0, 1);
leftColumn.Controls.Add(spellCard, 0, 0);
leftColumn.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 1);
split.Controls.Add(leftColumn, 0, 0);
var rightColumn = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(5, 0, 0, 0),
Padding = new Padding(0)
};
rightColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 68));
rightColumn.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var searchCard = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
Margin = new Padding(0, 0, 0, 10),
Padding = new Padding(12, 8, 12, 8)
};
searchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 64));
searchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
searchCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
searchCard.Controls.Add(new Label
{
Dock = DockStyle.Fill,
Text = "搜索",
ForeColor = UiTheme.Text,
BackColor = Color.Transparent,
Font = new Font(Font.FontFamily, 9F, FontStyle.Bold, GraphicsUnit.Point),
TextAlign = ContentAlignment.MiddleLeft,
Margin = new Padding(0),
Padding = new Padding(8, 0, 0, 0)
}, 0, 0);
UiTheme.StyleTextBox(_itemsSearchBox);
_itemsSearchBox.Dock = DockStyle.None;
_itemsSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_itemsSearchBox.Margin = new Padding(0);
_itemsSearchBox.Height = 30;
_itemsSearchBox.PlaceholderText = "itemId 或名称";
_itemsSearchBox.TextChanged += (_, _) => ApplyItemsFilter();
searchCard.Controls.Add(_itemsSearchBox, 1, 0);
rightColumn.Controls.Add(searchCard, 0, 0);
var itemCard = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0),
Padding = new Padding(0)
};
itemCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
itemCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 50));
itemCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var itemHeader = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
BackColor = Color.Transparent,
Margin = new Padding(0),
Padding = new Padding(12, 0, 12, 0)
};
itemHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
itemHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var itemTitle = CreateCardTitle("物品冷却");
itemTitle.AutoSize = true;
itemTitle.Dock = DockStyle.None;
itemTitle.Anchor = AnchorStyles.Left;
itemHeader.Controls.Add(itemTitle, 0, 0);
var itemHint = CreateFieldCaption("名称可改为业务别名;图标始终按 itemId 匹配。");
itemHint.TextAlign = ContentAlignment.MiddleRight;
itemHeader.Controls.Add(itemHint, 1, 0);
itemCard.Controls.Add(itemHeader, 0, 0);
ConfigureGrid(_itemsGrid, "class-config-items");
_itemsGrid.CellContentClick += HandleDeleteClick;
_itemsGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _itemsGrid.Rows.Count)
{
UpdateItemGridIcon(_itemsGrid.Rows[e.RowIndex]);
}
};
_itemsGrid.UserAddedRow += (_, _) => MarkDirty();
_itemsGrid.DataError += (_, e) => e.ThrowException = false;
_itemsGrid.Columns.Add(CreateSpellIconColumn());
_itemsGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "ItemId",
HeaderText = "itemId",
Width = 125,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "名称",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsGrid.Columns.Add(CreateSpellCheckColumn("IsEquipped", "是否装备中", 12, 110));
_itemsGrid.Columns.Add(CreateDeleteColumn());
itemCard.Controls.Add(_itemsGrid, 0, 1);
rightColumn.Controls.Add(itemCard, 0, 1);
split.Controls.Add(rightColumn, 1, 0);
return split;
}
private Control BuildSpellsListPage()
@@ -1647,11 +1811,16 @@ public sealed class ClassConfigEditorControl : UserControl
}
}
// spellsList 是用户实际添加/保存技能时使用的名称,优先级高于专精规则中的同 ID 别名。
// spellsList / itemsList 是用户实际添加/保存时使用的名称,优先级高于专精规则中的同 ID 别名。
foreach (var spell in document.SpellsList)
{
SpellIconCatalog.Register(spell.SpellId, spell.Name, overwriteIdName: true);
}
foreach (var item in document.ItemsList)
{
SpellIconCatalog.RegisterItem(item.ItemId, item.Name);
}
}
private void SelectClassFromList()
@@ -1766,6 +1935,7 @@ public sealed class ClassConfigEditorControl : UserControl
try
{
FillSpellsListGrid();
FillItemsListGrid();
}
finally
{
@@ -1924,6 +2094,11 @@ public sealed class ClassConfigEditorControl : UserControl
foreach (DataGridViewRow row in _itemsGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
row.Visible = string.IsNullOrEmpty(query) || ItemsRowMatches(row, query);
}
}
@@ -2235,112 +2410,80 @@ public sealed class ClassConfigEditorControl : UserControl
{
MessageBox.Show(
$"当前物品数据库缺少 itemId {suggestion.ItemId} 的名称,请更新技能/物品数据包后再添加。",
"物品",
"物品列表",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
AddItemFromDatabase(new ItemSuggestion(suggestion.ItemId, name));
AddItemsListFromDatabase(new ItemSuggestion(suggestion.ItemId, name));
}
private void AddItemFromDatabase(ItemSuggestion suggestion)
private void AddItemsListFromDatabase(ItemSuggestion suggestion)
{
if (_currentSpec is null)
if (_currentDocument is null)
{
MessageBox.Show("请先选择一个专精。", "物品", MessageBoxButtons.OK, MessageBoxIcon.Information);
MessageBox.Show("请先选择一个职业文件。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
_itemsGrid.EndEdit();
WriteBackItems();
if (_currentSpec.Items.Any(item => item.ItemId == suggestion.ItemId))
if (!_currentDocument.IsModernFormat)
{
MessageBox.Show("旧版稀疏索引格式暂不支持添加物品。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_itemsListGrid.EndEdit();
if (!TryValidateItemsList(out var validationError))
{
MessageBox.Show(validationError, "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
WriteBackItemsList();
if (_currentDocument.ItemsList.Any(item => item.ItemId == suggestion.ItemId))
{
MessageBox.Show(
$"已有此物品:{suggestion.Name}{suggestion.ItemId}",
"物品",
"物品列表",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
if (_currentSpec.Items.Any(item =>
if (_currentDocument.ItemsList.Any(item =>
string.Equals(item.Name, suggestion.Name, StringComparison.Ordinal)))
{
MessageBox.Show(
$"已有同名物品“{suggestion.Name}”。",
"物品",
"物品列表",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
if (CurrentSpecReservedItemNames().Contains(suggestion.Name))
{
MessageBox.Show(
$"物品名称“{suggestion.Name}”与状态、能量或配置开关字段重名。",
"物品",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
var entry = new ClassBlocksStore.ItemEntry
var entry = new ClassBlocksStore.ItemsListEntry
{
ItemId = suggestion.ItemId,
Name = suggestion.Name,
IsEquipped = false
Name = suggestion.Name
};
_currentSpec.Items.Add(entry);
_currentDocument.ItemsList.Add(entry);
SpellIconCatalog.RegisterItem(suggestion.ItemId, suggestion.Name);
var rowIndex = _itemsGrid.Rows.Add(
SpellIconCatalog.GetItem(suggestion.ItemId)!,
var rowIndex = _itemsListGrid.Rows.Add(
suggestion.ItemId.ToString(CultureInfo.InvariantCulture),
suggestion.Name,
false,
"×");
var row = _itemsGrid.Rows[rowIndex];
SpellIconCatalog.GetItem(suggestion.ItemId)!,
suggestion.Name);
var row = _itemsListGrid.Rows[rowIndex];
row.Tag = entry;
_itemsSearchBox.Clear();
ApplyItemsFilter();
_itemsListSearchBox.Clear();
ApplyItemsListFilter();
row.Selected = true;
_itemsGrid.CurrentCell = row.Cells["ItemId"];
_itemsGrid.FirstDisplayedScrollingRowIndex = rowIndex;
_itemsListGrid.CurrentCell = row.Cells["ItemId"];
_itemsListGrid.FirstDisplayedScrollingRowIndex = rowIndex;
MarkDirty();
}
private HashSet<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()
{
CloseStateComboDropDown();
@@ -2677,6 +2820,43 @@ public sealed class ClassConfigEditorControl : UserControl
}
}
private void FillItemsListGrid()
{
_itemsListGrid.Rows.Clear();
_itemsListSearchBox.Clear();
if (_currentDocument is null)
{
return;
}
foreach (var item in _currentDocument.ItemsList.OrderBy(entry => entry.ItemId))
{
SpellIconCatalog.RegisterItem(item.ItemId, item.Name);
var rowIndex = _itemsListGrid.Rows.Add(
item.ItemId.ToString(CultureInfo.InvariantCulture),
SpellIconCatalog.GetItem(item.ItemId)!,
item.Name);
_itemsListGrid.Rows[rowIndex].Tag = item;
}
}
private void ApplyItemsListFilter()
{
var query = _itemsListSearchBox.Text.Trim();
_itemsListGrid.ClearSelection();
_itemsListGrid.CurrentCell = null;
foreach (DataGridViewRow row in _itemsListGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
row.Visible = string.IsNullOrEmpty(query) || ItemsRowMatches(row, query);
}
}
private void ApplySpellsListFilter()
{
var query = _spellsListSearchBox.Text.Trim();
@@ -3087,6 +3267,7 @@ public sealed class ClassConfigEditorControl : UserControl
}
_itemsGrid.EndEdit();
_spellsGrid.EndEdit();
NormalizeFixedStateNames(_currentSpec);
WriteBackStatesCategory(_lastStateCategory);
@@ -3122,6 +3303,34 @@ public sealed class ClassConfigEditorControl : UserControl
}
}
private void WriteBackItemsList()
{
if (_currentDocument is null)
{
return;
}
foreach (DataGridViewRow row in _itemsListGrid.Rows)
{
if (row.IsNewRow || row.Tag is not ClassBlocksStore.ItemsListEntry entry)
{
continue;
}
if (!long.TryParse(
row.Cells["ItemId"].Value?.ToString()?.Trim(),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var itemId))
{
continue;
}
entry.ItemId = itemId;
entry.Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? "";
}
}
private void AddSpellFromDatabase(SpellSuggestion suggestion)
{
if (_currentDocument is null)
@@ -3218,15 +3427,19 @@ public sealed class ClassConfigEditorControl : UserControl
grid.Invalidate();
}
foreach (DataGridViewRow row in _itemsGrid.Rows)
foreach (var grid in new[] { _itemsGrid, _itemsListGrid })
{
if (!row.IsNewRow)
foreach (DataGridViewRow row in grid.Rows)
{
UpdateItemGridIcon(row);
if (!row.IsNewRow)
{
UpdateItemGridIcon(row);
}
}
grid.Invalidate();
}
_itemsGrid.Invalidate();
_itemDatabaseGrid.Invalidate();
foreach (var grid in new[] { _aurasGrid, _groupAurasGrid })
@@ -3295,6 +3508,55 @@ public sealed class ClassConfigEditorControl : UserControl
return true;
}
private bool TryValidateItemsList(out string error)
{
error = string.Empty;
if (_currentDocument is null)
{
return true;
}
var itemIds = new HashSet<long>();
var itemNames = new HashSet<string>(StringComparer.Ordinal);
foreach (DataGridViewRow row in _itemsListGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var rowNumber = row.Index + 1;
if (!long.TryParse(row.Cells["ItemId"].Value?.ToString()?.Trim(), NumberStyles.None,
CultureInfo.InvariantCulture, out var itemId)
|| itemId <= 0)
{
error = $"物品列表第 {rowNumber} 行 itemId 必须是正整数。";
return false;
}
var name = row.Cells["Name"].Value?.ToString()?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
error = $"物品列表第 {rowNumber} 行名称不能为空。";
return false;
}
if (!itemIds.Add(itemId))
{
error = $"物品列表 itemId {itemId} 重复。";
return false;
}
if (!itemNames.Add(name))
{
error = $"物品列表名称“{name}”重复。";
return false;
}
}
return true;
}
private bool TryValidateItems(out string error)
{
error = string.Empty;
@@ -3619,7 +3881,9 @@ public sealed class ClassConfigEditorControl : UserControl
try
{
_spellsListGrid.EndEdit();
_itemsListGrid.EndEdit();
_statesGrid.EndEdit();
_spellsGrid.EndEdit();
_itemsGrid.EndEdit();
if (!TryValidateSpellsList(out var validationError))
{
@@ -3628,16 +3892,24 @@ public sealed class ClassConfigEditorControl : UserControl
return;
}
if (!TryValidateItemsList(out validationError))
{
MessageBox.Show(validationError, "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
_statusLabel.Text = validationError;
return;
}
// 切换分类前把当前状态表写回。
CommitCurrentSpecFromUi();
if (!TryValidateItems(out validationError))
{
MessageBox.Show(validationError, "物品", MessageBoxButtons.OK, MessageBoxIcon.Warning);
MessageBox.Show(validationError, "物品冷却", MessageBoxButtons.OK, MessageBoxIcon.Warning);
_statusLabel.Text = validationError;
return;
}
WriteBackSpellsList();
WriteBackItemsList();
ClassBlocksStore.Save(_currentDocument);
localSaved = true;
SetDirty(false);
@@ -3736,6 +4008,8 @@ public sealed class ClassConfigEditorControl : UserControl
_spellsGrid.Rows.Clear();
_itemsGrid.Rows.Clear();
_itemsSearchBox.Clear();
_itemsListGrid.Rows.Clear();
_itemsListSearchBox.Clear();
_spellsListGrid.Rows.Clear();
_spellsListSearchBox.Clear();
_groupAurasGrid.Rows.Clear();
@@ -3792,6 +4066,36 @@ public sealed class ClassConfigEditorControl : UserControl
MarkDirty();
}
private void HandleItemsListDeleteClick(object? sender, DataGridViewCellEventArgs e)
{
if (sender is not DataGridView grid
|| e.RowIndex < 0
|| e.ColumnIndex < 0
|| grid.Columns[e.ColumnIndex].Name != "Delete")
{
return;
}
var row = grid.Rows[e.RowIndex];
if (row.IsNewRow || row.Tag is not ClassBlocksStore.ItemsListEntry entry)
{
return;
}
if (_currentDocument is not null)
{
if (entry.OriginalItemId > 0)
{
_currentDocument.DeletedItemsListOriginalIds.Add(entry.OriginalItemId);
}
_currentDocument.ItemsList.Remove(entry);
}
grid.Rows.RemoveAt(e.RowIndex);
MarkDirty();
}
private void MoveSelectedRow(DataGridView grid, int delta)
{
if (grid.CurrentRow is null || grid.CurrentRow.IsNewRow)