Files
Shigure/UI/ClassConfigEditorControl.cs

4433 lines
149 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Drawing;
using System.Globalization;
namespace Shigure;
public sealed record ClassConfigPostSaveResult(
string? AddonSyncIssue,
int SavedModuleCount,
IReadOnlyList<string> ModuleWarnings);
/// <summary>
/// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocksstates / auras / spells / items / group
/// 并编辑同文件中的 spellsList 与 itemsList。
/// </summary>
public sealed class ClassConfigEditorControl : UserControl
{
private readonly Func<string?> _resolveClassDirectory;
private readonly Func<string, int, Task<ClassConfigPostSaveResult>> _updateConfigAsync;
private readonly ListBox _classList = new();
private readonly ListBox _specList = new();
private readonly Label _pathLabel = new();
private readonly Label _statusLabel = new();
private readonly ToolTip _toolTip = new();
private readonly Button _reloadButton = null!;
private readonly Button _saveButton = null!;
private readonly DataGridView _statesGrid = new();
private readonly DataGridViewComboBoxColumn _stateNameColumn = new();
private ToolStripDropDown? _stateComboDropDown;
private readonly DataGridView _aurasGrid = new();
private readonly DataGridView _spellsGrid = new();
private readonly TextBox _spellsSearchBox = 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();
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();
private readonly TextBox _spellDatabaseFilterBox = new();
private readonly Label _spellDatabaseStatusLabel = new();
private readonly System.Windows.Forms.Timer _spellDatabaseFilterTimer = new() { Interval = 150 };
private const int SpellDatabasePageSize = 20;
private SpellDatabaseResultSet _spellDatabaseResults = SpellDatabaseResultSet.Empty;
private int _spellDatabaseVisibleCount;
private bool _expandingSpellDatabaseRows;
private CancellationTokenSource? _spellDatabaseFilterCancellation;
private int _spellDatabaseFilterVersion;
private readonly NumericUpDown _groupNumBox = new();
private readonly NumericUpDown _groupHealthBox = new();
private readonly NumericUpDown _groupRoleBox = new();
private readonly NumericUpDown _groupDispelBox = new();
private readonly CheckBox _groupEnabledBox = new();
private readonly CheckBox _groupHasHealthBox = new();
private readonly CheckBox _groupHasRoleBox = new();
private readonly CheckBox _groupHasDispelBox = new();
private readonly DataGridView _groupAurasGrid = new();
private string? _classDirectory;
private readonly Dictionary<int, ClassBlocksStore.ClassFileDocument> _documents = new();
private ClassBlocksStore.ClassFileDocument? _currentDocument;
private ClassBlocksStore.SpecBlocks? _currentSpec;
private int? _currentClassId;
private int? _currentSpecId;
private bool _suppressUi;
private bool _dirty;
private int _editorTabIndex = -1;
internal event Action<bool>? DirtyStateChanged;
internal bool HasUnsavedChanges => _dirty;
private string _selectedStateCategory = ClassStateCatalog.CategoryState;
private string _lastStateCategory = ClassStateCatalog.CategoryState;
private string _lastAuraBucket = "player";
private static readonly string[] FixedStateNames = ["锚点", "职业", "专精"];
private static readonly (string Key, string Text)[] AuraBuckets =
[
("player", "玩家"),
("target.harmful", "目标·敌对"),
("target.helpful", "目标·友善"),
("focus.harmful", "焦点·敌对"),
("focus.helpful", "焦点·友善")
];
public ClassConfigEditorControl(
Func<string?> resolveClassDirectory,
Func<string, int, Task<ClassConfigPostSaveResult>> updateConfigAsync)
{
_resolveClassDirectory = resolveClassDirectory;
_updateConfigAsync = updateConfigAsync;
_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)
{
CloseStateComboDropDown();
_spellDatabaseFilterTimer.Stop();
_spellDatabaseFilterTimer.Dispose();
_spellDatabaseFilterCancellation?.Cancel();
_itemDatabaseFilterTimer.Stop();
_itemDatabaseFilterTimer.Dispose();
_itemDatabaseFilterCancellation?.Cancel();
SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged;
}
base.Dispose(disposing);
}
private void InitializeComponent()
{
Dock = DockStyle.Fill;
BackColor = UiTheme.Surface;
ForeColor = UiTheme.Text;
Font = new Font("Microsoft YaHei UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 3,
RowCount = 1,
Margin = new Padding(0)
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 146));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 184));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
Controls.Add(root);
root.Controls.Add(BuildSidebar(), 0, 0);
root.Controls.Add(BuildSpecSidebar(), 1, 0);
root.Controls.Add(BuildEditor(), 2, 0);
}
private Control BuildSidebar()
{
var panel = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(UiTheme.CardPadding),
Margin = new Padding(0, 0, UiTheme.PageGap, 0)
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.Controls.Add(new Label
{
Text = "职业",
Dock = DockStyle.Fill,
ForeColor = UiTheme.Text,
Font = new Font(Font.FontFamily, 10F, FontStyle.Bold),
TextAlign = ContentAlignment.MiddleLeft,
BackColor = Color.Transparent,
Margin = new Padding(0)
}, 0, 0);
_classList.Dock = DockStyle.Fill;
UiTheme.StyleClassIconListBox(
_classList,
item => (item as ClassListItem)?.ClassId,
iconSize: 40);
_classList.BackColor = UiTheme.SurfaceRaised;
_classList.SelectedIndexChanged += (_, _) =>
{
if (_suppressUi)
{
return;
}
SelectClassFromList();
};
panel.Controls.Add(_classList, 0, 1);
return panel;
}
private Control BuildSpecSidebar()
{
var panel = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(UiTheme.CardPadding),
Margin = new Padding(0, 0, UiTheme.PageGap, 0)
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.Controls.Add(new Label
{
Text = "专精",
Dock = DockStyle.Fill,
ForeColor = UiTheme.Text,
Font = new Font(Font.FontFamily, 10F, FontStyle.Bold),
TextAlign = ContentAlignment.MiddleLeft,
BackColor = Color.Transparent,
Margin = new Padding(0)
}, 0, 0);
_specList.Dock = DockStyle.Fill;
UiTheme.StyleSpecIconListBox(
_specList,
item => item is SpecOption spec ? (spec.ClassId, spec.Id) : null,
iconSize: 40);
_specList.BackColor = UiTheme.SurfaceRaised;
_specList.SelectedIndexChanged += (_, _) =>
{
if (_suppressUi)
{
return;
}
SelectSpec(_specList.SelectedItem as SpecOption);
};
panel.Controls.Add(_specList, 0, 1);
return panel;
}
private static void StyleActionButton(Button button)
{
UiTheme.StyleActionButton(button);
button.Margin = new Padding(0, 0, 0, 8);
}
private Control BuildEditor()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0)
};
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 80));
root.Controls.Add(BuildSectionTabs(), 0, 0);
var actionRow = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
Margin = new Padding(0, UiTheme.PageGap, 0, 0),
Padding = new Padding(UiTheme.CardPadding, 10, UiTheme.CardPadding, 10)
};
actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 228));
var actions = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
BackColor = Color.Transparent,
Margin = new Padding(0)
};
StyleActionButton(_reloadButton);
StyleActionButton(_saveButton);
_reloadButton.Margin = new Padding(0, 0, 8, 0);
_saveButton.Margin = new Padding(0);
_reloadButton.Click += (_, _) => ReloadFromAddon();
_saveButton.Click += async (_, _) => await SaveAndUpdateAsync();
actions.Controls.Add(_reloadButton);
actions.Controls.Add(_saveButton);
actionRow.Controls.Add(BuildFooterInfo(), 0, 0);
actionRow.Controls.Add(actions, 1, 0);
root.Controls.Add(actionRow, 0, 1);
return root;
}
private Control BuildFooterInfo()
{
var info = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
ColumnCount = 2,
RowCount = 2,
Margin = new Padding(0)
};
info.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56));
info.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
info.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
info.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
info.Controls.Add(CreateFieldCaption("状态"), 0, 0);
ConfigureInfoLabel(_statusLabel, UiTheme.Muted);
_statusLabel.Text = "点击刷新以加载项目 Fuyutsui\\class";
_statusLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_statusLabel, _statusLabel.Text);
_toolTip.SetToolTip(_statusLabel, _statusLabel.Text);
info.Controls.Add(_statusLabel, 1, 0);
info.Controls.Add(CreateFieldCaption("路径"), 0, 1);
ConfigureInfoLabel(_pathLabel, UiTheme.Text);
_pathLabel.Text = "未加载";
_pathLabel.TextChanged += (_, _) => _toolTip.SetToolTip(_pathLabel, _pathLabel.Text);
_toolTip.SetToolTip(_pathLabel, _pathLabel.Text);
info.Controls.Add(_pathLabel, 1, 1);
return info;
}
private Control BuildSectionTabs()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, UiTheme.TabBarHeight));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var tabBar = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 6,
RowCount = 1,
Margin = new Padding(0, 0, 0, 8),
Padding = new Padding(0)
};
for (var i = 0; i < 6; i++)
{
tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F / 6));
}
var contentCard = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 1,
Margin = new Padding(0),
Padding = new Padding(0)
};
contentCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
contentCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var contentHost = new Panel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0),
Padding = new Padding(0)
};
contentCard.Controls.Add(contentHost, 0, 0);
var pages = new Control[]
{
BuildStatesPage(),
BuildAurasPage(),
BuildSpellsPage(),
BuildGroupPage(),
BuildSpellsListPage(),
BuildItemsPage()
};
foreach (var page in pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
page.BackColor = UiTheme.SurfaceRaised;
page.Padding = new Padding(UiTheme.CardPadding);
contentHost.Controls.Add(page);
}
var tabs = new UiPillTab[6];
void SelectTab(int index)
{
if (_editorTabIndex == index)
{
return;
}
if (!_suppressUi && _editorTabIndex == 2)
{
_spellsGrid.EndEdit();
_itemsGrid.EndEdit();
WriteBackSpells();
WriteBackItems();
}
if (!_suppressUi && _editorTabIndex == 5)
{
_itemsListGrid.EndEdit();
WriteBackItemsList();
}
_editorTabIndex = index;
for (var i = 0; i < tabs.Length; i++)
{
var selected = i == index;
tabs[i].Selected = selected;
pages[i].Visible = selected;
if (selected)
{
pages[i].BringToFront();
}
}
}
var titles = new[] { "状态", "光环", "冷却", "队伍", "技能列表", "物品列表" };
for (var i = 0; i < titles.Length; i++)
{
var index = i;
var tab = new UiPillTab(titles[i]);
tab.Click += (_, _) => SelectTab(index);
tabs[i] = tab;
tabBar.Controls.Add(tab, i, 0);
}
root.Controls.Add(tabBar, 0, 0);
root.Controls.Add(contentCard, 0, 1);
SelectTab(0);
return root;
}
private Control BuildStatesPage()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
panel.Controls.Add(BuildStateCategoryTabs(), 0, 0);
ConfigureGrid(_statesGrid, "class-config-states");
_statesGrid.EditMode = DataGridViewEditMode.EditProgrammatically;
_stateNameColumn.Name = "Name";
_stateNameColumn.HeaderText = "状态名";
_stateNameColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
_stateNameColumn.DisplayMember = nameof(ClassStateCatalog.StateOption.Display);
_stateNameColumn.ValueMember = nameof(ClassStateCatalog.StateOption.Name);
_stateNameColumn.FlatStyle = FlatStyle.Flat;
_stateNameColumn.DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton;
_statesGrid.Columns.Add(_stateNameColumn);
_statesGrid.Columns.Add(CreateDeleteColumn());
_statesGrid.CellPainting += (_, e) =>
{
if (e.RowIndex >= 0
&& e.ColumnIndex >= 0
&& _statesGrid.Columns[e.ColumnIndex].Name == "Name")
{
UiTheme.PaintDataGridViewComboBoxCell(_statesGrid, e);
}
};
_statesGrid.CellClick += OnStatesGridCellClick;
_statesGrid.KeyDown += OnStatesGridKeyDown;
_statesGrid.CellContentClick += HandleDeleteClick;
_statesGrid.CellValueChanged += (_, _) => MarkDirty();
_statesGrid.UserAddedRow += (_, _) => MarkDirty();
_statesGrid.DataError += (_, e) => e.ThrowException = false;
_statesGrid.Disposed += (_, _) => CloseStateComboDropDown();
panel.Controls.Add(_statesGrid, 0, 1);
panel.Controls.Add(BuildMoveButtons(_statesGrid), 0, 2);
return panel;
}
private Control BuildItemsPage()
{
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 = 2,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0, 0, 5, 0),
Padding = new Padding(0)
};
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(_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
{
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("来自当前职业 Lua 的 Fuyutsui.itemsList。");
hint.TextAlign = ContentAlignment.MiddleRight;
currentListHeader.Controls.Add(hint, 1, 0);
currentListCard.Controls.Add(currentListHeader, 0, 0);
ConfigureGrid(_itemsListGrid, "class-config-items-list");
_itemsListGrid.AllowUserToAddRows = false;
_itemsListGrid.CellContentClick += HandleItemsListDeleteClick;
_itemsListGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _itemsListGrid.Rows.Count)
{
UpdateItemGridIcon(_itemsListGrid.Rows[e.RowIndex]);
}
};
_itemsListGrid.DataError += (_, e) => e.ThrowException = false;
_itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "ItemId",
HeaderText = "itemId",
Width = 125,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Index",
HeaderText = "索引",
Width = 72,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsListGrid.Columns.Add(CreateSpellIconColumn());
_itemsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "名称",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_itemsListGrid.Columns.Add(CreateDeleteColumn());
currentListCard.Controls.Add(_itemsListGrid, 0, 1);
leftColumn.Controls.Add(currentListCard, 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 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
});
_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()
{
var categories = ClassStateCatalog.TopCategories;
var tabBar = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
ColumnCount = categories.Length,
RowCount = 1,
Margin = new Padding(0),
Padding = new Padding(0)
};
foreach (var _ in categories)
{
tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F / categories.Length));
}
var tabs = new UiPillTab[categories.Length];
void ApplySelection()
{
for (var i = 0; i < tabs.Length; i++)
{
tabs[i].Selected = string.Equals(categories[i], _selectedStateCategory, StringComparison.Ordinal);
}
}
void SelectCategory(string category)
{
if (_suppressUi
|| string.Equals(category, _selectedStateCategory, StringComparison.Ordinal))
{
return;
}
_statesGrid.EndEdit();
WriteBackStatesCategory(_lastStateCategory);
_selectedStateCategory = category;
_lastStateCategory = category;
ApplySelection();
ReloadStatesGrid();
}
for (var i = 0; i < categories.Length; i++)
{
var category = categories[i];
var tab = new UiPillTab(ClassStateCatalog.GetCategoryDisplayName(category));
tab.Click += (_, _) => SelectCategory(category);
tabs[i] = tab;
tabBar.Controls.Add(tab, i, 0);
}
ApplySelection();
return tabBar;
}
private Control BuildAurasPage()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
panel.Controls.Add(BuildAuraBucketTabs(), 0, 0);
ConfigureGrid(_aurasGrid, "class-config-auras");
_aurasGrid.Columns.Add(CreateSpellIconColumn());
_aurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", Width = 160 });
_aurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "SpellId", HeaderText = "spellId", Width = 110 });
_aurasGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "SpellIds",
HeaderText = "spellIds逗号分隔",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
_aurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "MaxApps", HeaderText = "maxApps", Width = 135 });
_aurasGrid.Columns.Add(CreateDeleteColumn());
_aurasGrid.CellContentClick += HandleDeleteClick;
_aurasGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _aurasGrid.Rows.Count
&& e.ColumnIndex >= 0
&& _aurasGrid.Columns[e.ColumnIndex].Name is "Name" or "SpellId" or "SpellIds")
{
UpdateAuraGridIcon(_aurasGrid.Rows[e.RowIndex]);
}
};
_aurasGrid.UserAddedRow += (_, _) => MarkDirty();
panel.Controls.Add(_aurasGrid, 0, 1);
panel.Controls.Add(BuildMoveButtons(_aurasGrid), 0, 2);
return panel;
}
private Control BuildAuraBucketTabs()
{
var tabBar = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
ColumnCount = AuraBuckets.Length,
RowCount = 1,
Margin = new Padding(0),
Padding = new Padding(0)
};
foreach (var _ in AuraBuckets)
{
tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F / AuraBuckets.Length));
}
var tabs = new UiPillTab[AuraBuckets.Length];
void ApplySelection()
{
for (var i = 0; i < tabs.Length; i++)
{
tabs[i].Selected = string.Equals(AuraBuckets[i].Key, _lastAuraBucket, StringComparison.Ordinal);
}
}
void SelectBucket(string key)
{
if (_suppressUi || string.Equals(key, _lastAuraBucket, StringComparison.Ordinal))
{
return;
}
_aurasGrid.EndEdit();
WriteBackAuras(_lastAuraBucket);
_lastAuraBucket = key;
ApplySelection();
_suppressUi = true;
try
{
FillAurasGrid();
}
finally
{
_suppressUi = false;
}
}
for (var i = 0; i < AuraBuckets.Length; i++)
{
var bucket = AuraBuckets[i];
var tab = new UiPillTab(bucket.Text);
tab.Click += (_, _) => SelectBucket(bucket.Key);
tabs[i] = tab;
tabBar.Controls.Add(tab, i, 0);
}
ApplySelection();
return tabBar;
}
private Control BuildSpellsPage()
{
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,
Margin = new Padding(0, 0, 5, 0),
Padding = new Padding(0)
};
leftColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 68));
leftColumn.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
leftColumn.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
var spellSearchCard = new UiCardPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
Margin = new Padding(0, 0, 0, 10),
Padding = new Padding(12, 8, 12, 8)
};
spellSearchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 64));
spellSearchCard.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
spellSearchCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
spellSearchCard.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(_spellsSearchBox);
_spellsSearchBox.Dock = DockStyle.None;
_spellsSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_spellsSearchBox.Margin = new Padding(0);
_spellsSearchBox.Height = 30;
_spellsSearchBox.PlaceholderText = "spellId 或名称";
_spellsSearchBox.TextChanged += (_, _) => ApplySpellsFilter();
spellSearchCard.Controls.Add(_spellsSearchBox, 1, 0);
leftColumn.Controls.Add(spellSearchCard, 0, 0);
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.TextAlign = ContentAlignment.MiddleRight;
spellHeader.Controls.Add(textureOrderHint, 1, 0);
spellCard.Controls.Add(spellHeader, 0, 0);
ConfigureGrid(_spellsGrid, "class-config-spells");
_spellsGrid.Columns.Add(CreateSpellIconColumn());
_spellsGrid.Columns.Add(CreateSpellTextColumn("Name", "名称", 24, 160));
_spellsGrid.Columns.Add(CreateSpellTextColumn("SpellId", "法术 ID", 14, 120));
_spellsGrid.Columns.Add(CreateSpellCheckColumn("Charge", "充能", 10, 80));
_spellsGrid.Columns.Add(CreateSpellTextColumn("MaxCharge", "最大充能", 14, 110));
_spellsGrid.Columns.Add(CreateSpellTextColumn("CastCount", "施法次数", 14, 110));
_spellsGrid.Columns.Add(CreateSpellCheckColumn("ForcedKnown", "强制已学", 14, 110));
_spellsGrid.Columns.Add(CreateSpellCheckColumn("InSpellBook", "法术书中", 14, 110));
_spellsGrid.Columns.Add(CreateDeleteColumn());
_spellsGrid.CellContentClick += HandleDeleteClick;
_spellsGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _spellsGrid.Rows.Count)
{
UpdateSpellGridIcon(_spellsGrid.Rows[e.RowIndex]);
}
};
_spellsGrid.UserAddedRow += (_, _) => MarkDirty();
_spellsGrid.DataError += (_, e) => e.ThrowException = false;
spellCard.Controls.Add(_spellsGrid, 0, 1);
leftColumn.Controls.Add(spellCard, 0, 1);
leftColumn.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 2);
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()
{
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 = 2,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0, 0, 5, 0),
Padding = new Padding(0)
};
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(_spellsListSearchBox);
_spellsListSearchBox.Dock = DockStyle.None;
_spellsListSearchBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_spellsListSearchBox.Margin = new Padding(0);
_spellsListSearchBox.Height = 30;
_spellsListSearchBox.PlaceholderText = "法术 ID、索引或名称";
_spellsListSearchBox.TextChanged += (_, _) => ApplySpellsListFilter();
searchCard.Controls.Add(_spellsListSearchBox, 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("来自当前职业 Lua仅编辑索引 1100。");
hint.TextAlign = ContentAlignment.MiddleRight;
currentListHeader.Controls.Add(hint, 1, 0);
currentListCard.Controls.Add(currentListHeader, 0, 0);
ConfigureGrid(_spellsListGrid, "class-config-spells-list");
_spellsListGrid.AllowUserToAddRows = false;
_spellsListGrid.CellContentClick += HandleSpellsListDeleteClick;
_spellsListGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _spellsListGrid.Rows.Count)
{
UpdateSpellGridIcon(_spellsListGrid.Rows[e.RowIndex]);
}
};
_spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "SpellId",
HeaderText = "法术 ID",
Width = 125,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Index",
HeaderText = "索引",
Width = 76,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellsListGrid.Columns.Add(CreateSpellIconColumn());
_spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "名称",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellsListGrid.Columns.Add(CreateDeleteColumn());
currentListCard.Controls.Add(_spellsListGrid, 0, 1);
leftColumn.Controls.Add(currentListCard, 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 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(_spellDatabaseFilterBox);
_spellDatabaseFilterBox.Dock = DockStyle.None;
_spellDatabaseFilterBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_spellDatabaseFilterBox.Margin = new Padding(0);
_spellDatabaseFilterBox.Height = 30;
_spellDatabaseFilterBox.PlaceholderText = "spellId 或名称";
_spellDatabaseFilterBox.TextChanged += (_, _) => ScheduleSpellDatabaseFilter();
filterCard.Controls.Add(_spellDatabaseFilterBox, 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);
_spellDatabaseStatusLabel.Dock = DockStyle.Fill;
_spellDatabaseStatusLabel.ForeColor = UiTheme.Muted;
_spellDatabaseStatusLabel.BackColor = Color.Transparent;
_spellDatabaseStatusLabel.TextAlign = ContentAlignment.MiddleRight;
_spellDatabaseStatusLabel.Margin = new Padding(0);
databaseHeader.Controls.Add(_spellDatabaseStatusLabel, 1, 0);
databaseListCard.Controls.Add(databaseHeader, 0, 0);
ConfigureGrid(_spellDatabaseGrid, "class-config-spell-database");
_spellDatabaseGrid.AllowUserToAddRows = false;
_spellDatabaseGrid.ReadOnly = true;
_spellDatabaseGrid.VirtualMode = true;
_spellDatabaseGrid.EditMode = DataGridViewEditMode.EditProgrammatically;
_spellDatabaseGrid.RowCount = 0;
_spellDatabaseGrid.CellValueNeeded += OnSpellDatabaseCellValueNeeded;
_spellDatabaseGrid.CellContentClick += OnSpellDatabaseCellContentClick;
_spellDatabaseGrid.Scroll += OnSpellDatabaseScroll;
_spellDatabaseGrid.Columns.Add(CreateSpellIconColumn());
_spellDatabaseGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "SpellId",
HeaderText = "spellId",
Width = 125,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellDatabaseGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "名称",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellDatabaseGrid.Columns.Add(new DataGridViewButtonColumn
{
Name = "Add",
HeaderText = "添加",
Text = "添加",
UseColumnTextForButtonValue = true,
Width = 72,
SortMode = DataGridViewColumnSortMode.NotSortable
});
_spellDatabaseGrid.HandleCreated += (_, _) => RefreshSpellDatabase();
databaseListCard.Controls.Add(_spellDatabaseGrid, 0, 1);
rightColumn.Controls.Add(databaseListCard, 0, 1);
_spellDatabaseFilterTimer.Tick += async (_, _) =>
{
_spellDatabaseFilterTimer.Stop();
await ApplySpellDatabaseFilterAsync();
};
split.Controls.Add(rightColumn, 1, 0);
return split;
}
private Label CreateCardTitle(string text, int leftPadding = 0)
=> new()
{
Dock = DockStyle.Fill,
Text = 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(leftPadding, 0, 0, 0)
};
private Control BuildGroupPage()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 3,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 112));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
var fields = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
AutoScroll = true,
BackColor = UiTheme.SurfaceRaised,
Padding = new Padding(4, 6, 4, 6),
Margin = new Padding(0)
};
_groupEnabledBox.Text = "启用";
_groupEnabledBox.ForeColor = UiTheme.Text;
_groupEnabledBox.AutoSize = true;
_groupEnabledBox.CheckedChanged += (_, _) =>
{
if (!_suppressUi)
{
MarkDirty();
UpdateGroupEditorsEnabled();
}
};
var groupCards = new Control[]
{
CreateGroupCard("GROUP", _groupEnabledBox),
CreateGroupNumberCard("NUM", _groupNumBox, 1, 40, 5),
CreateGroupOptionalNumberCard("HEALTH PERCENT", _groupHasHealthBox, _groupHealthBox, 0, 40, 1),
CreateGroupOptionalNumberCard("ROLE", _groupHasRoleBox, _groupRoleBox, 0, 40, 2),
CreateGroupOptionalNumberCard("DISPEL", _groupHasDispelBox, _groupDispelBox, 0, 40, 3)
};
foreach (var card in groupCards)
{
fields.Controls.Add(card);
}
void FitGroupCards()
{
const int minimumCardWidth = 176;
var totalMargins = groupCards.Sum(card => card.Margin.Horizontal);
var availableWidth = Math.Max(0, fields.ClientSize.Width - fields.Padding.Horizontal - totalMargins);
var cardWidth = Math.Max(minimumCardWidth, availableWidth / groupCards.Length);
foreach (var card in groupCards)
{
card.Width = cardWidth;
}
}
fields.SizeChanged += (_, _) => FitGroupCards();
fields.HandleCreated += (_, _) => FitGroupCards();
panel.Controls.Add(fields, 0, 0);
ConfigureGrid(_groupAurasGrid, "class-config-group-auras");
_groupAurasGrid.Columns.Add(CreateSpellIconColumn());
_groupAurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Offset", HeaderText = "偏移", Width = 70 });
_groupAurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", Width = 160 });
_groupAurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "SpellId", HeaderText = "spellId", Width = 110 });
_groupAurasGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "SpellIds",
HeaderText = "spellIds逗号分隔",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
_groupAurasGrid.Columns.Add(CreateDeleteColumn());
_groupAurasGrid.CellContentClick += HandleDeleteClick;
_groupAurasGrid.CellValueChanged += (_, e) =>
{
MarkDirty();
if (e.RowIndex >= 0 && e.RowIndex < _groupAurasGrid.Rows.Count
&& e.ColumnIndex >= 0
&& _groupAurasGrid.Columns[e.ColumnIndex].Name is "Name" or "SpellId" or "SpellIds")
{
UpdateAuraGridIcon(_groupAurasGrid.Rows[e.RowIndex]);
}
};
_groupAurasGrid.UserAddedRow += (_, _) => MarkDirty();
panel.Controls.Add(_groupAurasGrid, 0, 1);
panel.Controls.Add(BuildMoveButtons(_groupAurasGrid), 0, 2);
return panel;
}
private Control CreateGroupNumberCard(
string title,
NumericUpDown box,
decimal min,
decimal max,
decimal value)
{
ConfigureGroupNumberBox(box, min, max, value);
box.AutoSize = false;
box.Width = 110;
box.Anchor = AnchorStyles.Left;
box.Margin = Padding.Empty;
return CreateGroupCard(title, box);
}
private void ConfigureGroupNumberBox(NumericUpDown box, decimal min, decimal max, decimal value)
{
UiTheme.StyleNumericUpDown(box);
box.Minimum = min;
box.Maximum = max;
box.Value = value;
box.ValueChanged += (_, _) =>
{
if (!_suppressUi)
{
MarkDirty();
}
};
}
private Control CreateGroupOptionalNumberCard(
string title,
CheckBox enabledBox,
NumericUpDown numberBox,
decimal min,
decimal max,
decimal value)
{
enabledBox.Text = "启用";
enabledBox.ForeColor = UiTheme.Text;
enabledBox.AutoSize = true;
enabledBox.Anchor = AnchorStyles.Left;
enabledBox.Margin = new Padding(0, 0, 10, 0);
enabledBox.CheckedChanged += (_, _) =>
{
if (!_suppressUi)
{
MarkDirty();
numberBox.Enabled = _groupEnabledBox.Checked && enabledBox.Checked;
}
};
ConfigureGroupNumberBox(numberBox, min, max, value);
numberBox.AutoSize = false;
numberBox.Width = 76;
numberBox.Anchor = AnchorStyles.Left;
numberBox.Margin = Padding.Empty;
var body = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
ColumnCount = 3,
RowCount = 1,
Margin = Padding.Empty,
Padding = Padding.Empty
};
// 复选框列按“勾选框 + 启用文字”的实际首选宽度计算,避免高 DPI 下文字被裁切。
body.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
body.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 76));
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
body.Controls.Add(enabledBox, 0, 0);
body.Controls.Add(numberBox, 1, 0);
return CreateGroupCard(title, body);
}
private Control CreateGroupCard(string title, Control content)
{
var card = new UiCardPanel
{
AutoSize = false,
Size = new Size(160, 88),
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(12, 10, 12, 10),
Margin = new Padding(4, 0, 4, 0)
};
card.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
card.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
card.Controls.Add(new Label
{
Text = title,
Dock = DockStyle.Fill,
AutoSize = false,
ForeColor = UiTheme.Muted,
BackColor = Color.Transparent,
Font = new Font(Font.FontFamily, 8F, FontStyle.Bold),
TextAlign = ContentAlignment.MiddleLeft,
AutoEllipsis = true,
Margin = Padding.Empty
}, 0, 0);
if (content is CheckBox checkBox)
{
checkBox.Anchor = AnchorStyles.Left;
checkBox.Margin = Padding.Empty;
}
else if (content is NumericUpDown numeric)
{
numeric.Dock = DockStyle.None;
numeric.Anchor = AnchorStyles.Left;
numeric.Margin = Padding.Empty;
}
else
{
content.Dock = DockStyle.Fill;
content.Margin = Padding.Empty;
}
card.Controls.Add(content, 0, 1);
return card;
}
private Control BuildMoveButtons(DataGridView grid)
{
var bar = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
BackColor = UiTheme.SurfaceRaised
};
var up = UiTheme.CreateButton("▲", UiTheme.Field, UiTheme.Text);
var down = UiTheme.CreateButton("▼", UiTheme.Field, UiTheme.Text);
UiTheme.StyleActionButton(up, 48);
UiTheme.StyleActionButton(down, 48);
up.Margin = new Padding(0, 4, 8, 4);
down.Margin = new Padding(0, 4, 0, 4);
up.Click += (_, _) => MoveSelectedRow(grid, -1);
down.Click += (_, _) => MoveSelectedRow(grid, 1);
bar.Controls.Add(up);
bar.Controls.Add(down);
return bar;
}
private static void ConfigureGrid(DataGridView grid, string cacheKey)
{
UiTheme.StyleDataGridView(grid);
UiTheme.CacheDataGridViewColumnWidths(grid, cacheKey);
grid.AllowUserToAddRows = true;
grid.AllowUserToDeleteRows = false;
grid.RowHeadersVisible = false;
grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
grid.MultiSelect = false;
grid.EditMode = DataGridViewEditMode.EditOnEnter;
grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None;
}
private static DataGridViewButtonColumn CreateDeleteColumn()
=> new()
{
Name = "Delete",
HeaderText = "",
Text = "×",
UseColumnTextForButtonValue = true,
Width = 44
};
private static DataGridViewTextBoxColumn CreateSpellTextColumn(
string name,
string headerText,
float fillWeight,
int minimumWidth)
=> new()
{
Name = name,
HeaderText = headerText,
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
FillWeight = fillWeight,
MinimumWidth = minimumWidth,
SortMode = DataGridViewColumnSortMode.NotSortable
};
private static DataGridViewImageColumn CreateSpellIconColumn()
=> 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 DataGridViewCheckBoxColumn CreateSpellCheckColumn(
string name,
string headerText,
float fillWeight,
int minimumWidth)
=> new()
{
Name = name,
HeaderText = headerText,
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
FillWeight = fillWeight,
MinimumWidth = minimumWidth,
SortMode = DataGridViewColumnSortMode.NotSortable,
TrueValue = true,
FalseValue = false,
IndeterminateValue = false,
DefaultCellStyle = new DataGridViewCellStyle
{
Alignment = DataGridViewContentAlignment.MiddleCenter,
NullValue = false
}
};
private Label CreateMutedLabel(string text)
=> new()
{
Text = text,
AutoSize = true,
ForeColor = UiTheme.Muted,
Margin = new Padding(0, 8, 8, 0),
TextAlign = ContentAlignment.MiddleLeft
};
private static Label CreateFieldCaption(string text)
=> new()
{
Text = text,
Dock = DockStyle.Fill,
AutoSize = false,
ForeColor = UiTheme.Muted,
BackColor = Color.Transparent,
TextAlign = ContentAlignment.MiddleLeft,
Margin = new Padding(0),
AutoEllipsis = true
};
private static void ConfigureInfoLabel(Label label, Color foreColor)
{
label.Dock = DockStyle.Fill;
label.AutoSize = false;
label.ForeColor = foreColor;
label.BackColor = Color.Transparent;
label.TextAlign = ContentAlignment.MiddleLeft;
label.AutoEllipsis = true;
label.Margin = new Padding(0);
}
public void ReloadFromAddon()
{
if (_dirty && !ConfirmDiscard())
{
return;
}
_classDirectory = _resolveClassDirectory();
_documents.Clear();
_currentDocument = null;
_currentSpec = null;
_currentClassId = null;
_currentSpecId = null;
SetDirty(false);
_suppressUi = true;
try
{
_classList.Items.Clear();
ClearSpecList();
ClearGrids();
if (string.IsNullOrWhiteSpace(_classDirectory) || !Directory.Exists(_classDirectory))
{
_pathLabel.Text = "未找到 Fuyutsui\\class";
_statusLabel.Text = "请确认程序目录中包含 Fuyutsui\\class 后点击刷新。";
return;
}
_pathLabel.Text = _classDirectory;
foreach (var (classId, className) in ClassNames.GetClasses())
{
var fileName = ClassNames.GetConfigFileName(classId);
var path = Path.Combine(_classDirectory, $"{fileName}.lua");
if (!File.Exists(path))
{
continue;
}
try
{
var doc = ClassBlocksStore.Load(path);
_documents[classId] = doc;
RegisterDocumentSpellNames(doc);
_classList.Items.Add(new ClassListItem(classId, className, fileName, doc.IsModernFormat));
}
catch (Exception ex)
{
_classList.Items.Add(new ClassListItem(classId, className, fileName, false, ex.Message));
}
}
_statusLabel.Text = $"已加载 {_documents.Count} 个职业文件";
if (_classList.Items.Count > 0)
{
_classList.SelectedIndex = 0;
}
}
finally
{
_suppressUi = false;
}
SelectClassFromList();
}
private static void RegisterDocumentSpellNames(ClassBlocksStore.ClassFileDocument document)
{
foreach (var spec in document.Specs.Values)
{
foreach (var spell in spec.Spells)
{
SpellIconCatalog.Register(spell.SpellId, spell.Name);
}
IEnumerable<ClassBlocksStore.AuraEntry>[] auraLists =
{
spec.PlayerAuras,
spec.TargetHarmfulAuras,
spec.TargetHelpfulAuras,
spec.FocusHarmfulAuras,
spec.FocusHelpfulAuras
};
foreach (var auras in auraLists)
{
foreach (var aura in auras)
{
if (aura.SpellId is { } spellId)
{
SpellIconCatalog.Register(spellId, aura.Name);
}
foreach (var candidate in aura.SpellIds)
{
SpellIconCatalog.Register(candidate, aura.Name);
}
}
}
foreach (var item in spec.Items)
{
if (item.ItemId is { } itemId)
{
SpellIconCatalog.RegisterItem(itemId, item.Name);
}
}
if (spec.Group is not { } group)
{
continue;
}
foreach (var aura in group.Auras)
{
if (aura.SpellId is { } spellId)
{
SpellIconCatalog.Register(spellId, aura.Name);
}
foreach (var candidate in aura.SpellIds)
{
SpellIconCatalog.Register(candidate, aura.Name);
}
}
}
// 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()
{
if (_classList.SelectedItem is not ClassListItem item)
{
return;
}
if (_dirty && _currentClassId != item.ClassId && !ConfirmDiscard())
{
_suppressUi = true;
try
{
SelectClassInList(_currentClassId);
}
finally
{
_suppressUi = false;
}
return;
}
var discarding = _dirty && _currentClassId != item.ClassId;
if (_dirty && _currentClassId == item.ClassId)
{
return;
}
if (discarding && _currentClassId is { } previousClassId && _documents.ContainsKey(previousClassId))
{
try
{
_documents[previousClassId] = ClassBlocksStore.Load(_documents[previousClassId].FilePath);
}
catch
{
// 丢弃失败时保留内存副本,避免阻断切换。
}
}
SetDirty(false);
_currentClassId = item.ClassId;
_documents.TryGetValue(item.ClassId, out _currentDocument);
_pathLabel.Text = _currentDocument?.FilePath ?? Path.Combine(_classDirectory ?? "", $"{item.FileName}.lua");
if (_currentDocument is null)
{
_statusLabel.Text = string.IsNullOrWhiteSpace(item.Error)
? "无法加载该职业文件"
: item.Error!;
_currentSpec = null;
_currentSpecId = null;
_suppressUi = true;
try
{
ClearSpecList();
ClearGrids();
}
finally
{
_suppressUi = false;
}
return;
}
if (!_currentDocument.IsModernFormat)
{
_statusLabel.Text = "此文件仍是旧版稀疏索引格式,请先迁移到 states/auras/spells/items/group 后再编辑。";
}
else
{
_statusLabel.Text = _dirty ? "已修改(未保存)" : "可编辑";
}
_suppressUi = true;
try
{
var options = new List<SpecOption>();
foreach (var spec in ClassNames.GetSpecs(item.ClassId))
{
if (_currentDocument.Specs.ContainsKey(spec.Id))
{
options.Add(new SpecOption(item.ClassId, spec.Id, spec.Name));
}
}
// 也显示文件中有但 ClassNames 未登记的专精。
foreach (var specId in _currentDocument.Specs.Keys.OrderBy(x => x))
{
if (options.Any(x => x.Id == specId))
{
continue;
}
options.Add(new SpecOption(item.ClassId, specId, $"专精{specId}"));
}
RebuildSpecList(options);
_currentSpecId = null;
_currentSpec = null;
}
finally
{
_suppressUi = false;
}
SelectSpec(_specList.SelectedItem as SpecOption);
_suppressUi = true;
try
{
FillSpellsListGrid();
FillItemsListGrid();
}
finally
{
_suppressUi = false;
}
}
private void RebuildSpecList(IReadOnlyList<SpecOption> options)
{
_specList.Items.Clear();
foreach (var option in options)
{
_specList.Items.Add(option);
}
if (_specList.Items.Count > 0)
{
_specList.SelectedIndex = 0;
}
}
private void ClearSpecList()
{
_specList.Items.Clear();
}
private void SelectSpec(SpecOption? spec)
{
if (_currentDocument is null || spec is null)
{
_currentSpec = null;
_currentSpecId = null;
_specList.Invalidate();
ClearGrids();
return;
}
if (_dirty && _currentSpecId is not null && _currentSpecId != spec.Id)
{
CommitCurrentSpecFromUi();
}
_currentSpecId = spec.Id;
if (!_currentDocument.Specs.TryGetValue(spec.Id, out var blocks))
{
blocks = new ClassBlocksStore.SpecBlocks();
_currentDocument.Specs[spec.Id] = blocks;
}
_currentSpec = blocks;
_specList.Invalidate();
FillAllEditors();
}
private void FillAllEditors()
{
_suppressUi = true;
try
{
_lastStateCategory = _selectedStateCategory;
FillStatesGrid();
FillAurasGrid();
FillSpellsGrid();
FillItemsGrid();
FillGroupEditors();
}
finally
{
_suppressUi = false;
}
}
private void FillStatesGrid()
{
CloseStateComboDropDown();
_statesGrid.Rows.Clear();
if (_currentSpec is null)
{
return;
}
var category = _selectedStateCategory;
BindStateNameColumn(ClassStateCatalog.GetAllOptions(category));
var storageCategory = ClassStateCatalog.GetStorageCategory(category);
IEnumerable<string> names = _currentSpec.NestedStates
? _currentSpec.CategorizedStates.GetValueOrDefault(storageCategory) ?? []
: _currentSpec.FlatStates;
names = names.Where(name =>
ClassStateCatalog.IsInCategory(name, category)
&& !IsHiddenStateName(name));
foreach (var name in names)
{
EnsureStateOptionAvailable(category, name);
_statesGrid.Rows.Add(name, "×");
}
}
private void FillItemsGrid()
{
_itemsGrid.Rows.Clear();
_itemsSearchBox.Clear();
if (_currentSpec is null)
{
return;
}
foreach (var item in _currentSpec.Items.OrderBy(item => item.ItemId ?? long.MaxValue))
{
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 ApplySpellsFilter()
{
var query = _spellsSearchBox.Text.Trim();
_spellsGrid.ClearSelection();
_spellsGrid.CurrentCell = null;
foreach (DataGridViewRow row in _spellsGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
row.Visible = string.IsNullOrEmpty(query) || SpellsRowMatches(row, query);
}
}
private static bool SpellsRowMatches(DataGridViewRow row, string query)
=> new[] { "SpellId", "Name" }
.Select(columnName => row.Cells[columnName].Value?.ToString() ?? string.Empty)
.Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase));
private void ApplyItemsFilter()
{
var query = _itemsSearchBox.Text.Trim();
_itemsGrid.ClearSelection();
_itemsGrid.CurrentCell = null;
foreach (DataGridViewRow row in _itemsGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
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;
}
AddItemsListFromDatabase(new ItemSuggestion(suggestion.ItemId, name));
}
private void AddItemsListFromDatabase(ItemSuggestion suggestion)
{
if (_currentDocument is null)
{
MessageBox.Show("请先选择一个职业文件。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
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;
}
var usedIndices = _currentDocument.ItemsList
.Select(item => item.Index)
.Where(index => index >= 1)
.ToHashSet();
var nextIndex = 1;
while (usedIndices.Contains(nextIndex))
{
nextIndex++;
}
var entry = new ClassBlocksStore.ItemsListEntry
{
ItemId = suggestion.ItemId,
Index = nextIndex,
Name = suggestion.Name
};
_currentDocument.ItemsList.Add(entry);
SpellIconCatalog.RegisterItem(suggestion.ItemId, suggestion.Name);
var rowIndex = _itemsListGrid.Rows.Add(
suggestion.ItemId.ToString(CultureInfo.InvariantCulture),
nextIndex.ToString(CultureInfo.InvariantCulture),
SpellIconCatalog.GetItem(suggestion.ItemId)!,
suggestion.Name);
var row = _itemsListGrid.Rows[rowIndex];
row.Tag = entry;
_itemsListSearchBox.Clear();
ApplyItemsListFilter();
row.Selected = true;
_itemsListGrid.CurrentCell = row.Cells["ItemId"];
_itemsListGrid.FirstDisplayedScrollingRowIndex = rowIndex;
MarkDirty();
}
private void ReloadStatesGrid()
{
CloseStateComboDropDown();
_suppressUi = true;
try
{
FillStatesGrid();
}
finally
{
_suppressUi = false;
}
}
private void BindStateNameColumn(IReadOnlyList<ClassStateCatalog.StateOption> options)
{
_stateNameColumn.DataSource = null;
_stateNameColumn.DataSource = options.ToList();
_stateNameColumn.DisplayMember = nameof(ClassStateCatalog.StateOption.Display);
_stateNameColumn.ValueMember = nameof(ClassStateCatalog.StateOption.Name);
}
private void EnsureStateOptionAvailable(string category, string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return;
}
if (_stateNameColumn.DataSource is IEnumerable<ClassStateCatalog.StateOption> current
&& current.Any(o => string.Equals(o.Name, name, StringComparison.Ordinal)))
{
return;
}
var options = ClassStateCatalog.GetAllOptions(category).ToList();
if (!options.Any(o => string.Equals(o.Name, name, StringComparison.Ordinal)))
{
var optionCategory = ClassStateCatalog.FindCategory(name) ?? "未识别";
options.Add(new ClassStateCatalog.StateOption(optionCategory, name));
}
BindStateNameColumn(options);
}
private void OnStatesGridCellClick(object? sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex < 0
|| e.ColumnIndex < 0
|| _statesGrid.Columns[e.ColumnIndex].Name != "Name")
{
return;
}
ShowStateNameDropDown(e.RowIndex, e.ColumnIndex);
}
private void OnStatesGridKeyDown(object? sender, KeyEventArgs e)
{
if (_statesGrid.CurrentCell is not DataGridViewComboBoxCell cell
|| cell.OwningColumn?.Name != "Name"
|| e.KeyCode is not (Keys.Enter or Keys.Space or Keys.F4 or Keys.Down))
{
return;
}
if (e.KeyCode == Keys.Down && !e.Alt)
{
return;
}
e.Handled = true;
e.SuppressKeyPress = true;
ShowStateNameDropDown(cell.RowIndex, cell.ColumnIndex);
}
private void ShowStateNameDropDown(int rowIndex, int columnIndex)
{
CloseStateComboDropDown();
if (rowIndex < 0
|| rowIndex >= _statesGrid.Rows.Count)
{
return;
}
var row = _statesGrid.Rows[rowIndex];
if (row.Cells[columnIndex] is not DataGridViewComboBoxCell cell)
{
return;
}
_statesGrid.CurrentCell = cell;
var category = _selectedStateCategory;
var current = cell.Value?.ToString()?.Trim();
var usedNames = GetUsedStateNames(category, rowIndex);
var options = ClassStateCatalog.GetOptions(category)
.Where(option => !usedNames.Contains(option.Name) && !IsHiddenStateName(option.Name))
.ToList();
if (!string.IsNullOrWhiteSpace(current)
&& !options.Any(o => string.Equals(o.Name, current, StringComparison.Ordinal)))
{
var optionCategory = ClassStateCatalog.FindCategory(current) ?? "未识别";
options.Insert(0, new ClassStateCatalog.StateOption(optionCategory, current));
}
var popupOptions = options
.Select(option => new UiDropDownOption(option.Name, option.Display))
.ToList();
var cellBounds = _statesGrid.GetCellDisplayRectangle(columnIndex, rowIndex, cutOverflow: true);
ToolStripDropDown? dropDown = null;
dropDown = UiDropDownPopup.Show(
_statesGrid,
cellBounds,
popupOptions,
current,
selected =>
{
var selectedValue = selected.Value?.ToString() ?? string.Empty;
if (row.IsNewRow)
{
var addedRowIndex = _statesGrid.Rows.Add(selectedValue, "×");
var addedCell = _statesGrid.Rows[addedRowIndex].Cells[columnIndex];
_statesGrid.CurrentCell = addedCell;
_statesGrid.InvalidateCell(addedCell);
}
else
{
cell.Value = selectedValue;
_statesGrid.InvalidateCell(cell);
}
MarkDirty();
},
closed: () =>
{
if (ReferenceEquals(_stateComboDropDown, dropDown))
{
_stateComboDropDown = null;
}
});
_stateComboDropDown = dropDown;
}
private void CloseStateComboDropDown()
{
var dropDown = _stateComboDropDown;
_stateComboDropDown = null;
dropDown?.Close(ToolStripDropDownCloseReason.AppClicked);
}
private HashSet<string> GetUsedStateNames(string category, int excludedRowIndex)
{
var usedNames = new HashSet<string>(StringComparer.Ordinal);
if (_currentSpec is not null)
{
IEnumerable<string> storedNames;
if (_currentSpec.NestedStates)
{
var storageCategory = ClassStateCatalog.GetStorageCategory(category);
storedNames = _currentSpec.CategorizedStates.GetValueOrDefault(storageCategory) ?? [];
}
else
{
storedNames = _currentSpec.FlatStates;
}
foreach (var name in storedNames)
{
// 当前分类以表格中的未保存内容为准,其它分类仍以专精数据为准。
if (!ClassStateCatalog.IsInCategory(name, category))
{
usedNames.Add(name);
}
}
}
foreach (DataGridViewRow row in _statesGrid.Rows)
{
if (row.IsNewRow || row.Index == excludedRowIndex)
{
continue;
}
var name = row.Cells["Name"].Value?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
usedNames.Add(name);
}
}
return usedNames;
}
private void FillAurasGrid()
{
_aurasGrid.Rows.Clear();
if (_currentSpec is null)
{
return;
}
foreach (var aura in GetCurrentAuraList())
{
var icon = GetAuraIcon(aura.SpellId, aura.SpellIds, aura.Name);
_aurasGrid.Rows.Add(
icon!,
aura.Name,
aura.SpellId?.ToString(CultureInfo.InvariantCulture) ?? "",
string.Join(", ", aura.SpellIds),
aura.MaxApps?.ToString(CultureInfo.InvariantCulture) ?? "",
"×");
}
}
private void FillSpellsGrid()
{
_spellsGrid.Rows.Clear();
_spellsSearchBox.Clear();
if (_currentSpec is null)
{
return;
}
foreach (var spell in _currentSpec.Spells)
{
SpellIconCatalog.Register(spell.SpellId, spell.Name);
_spellsGrid.Rows.Add(
SpellIconCatalog.Get(spell.SpellId)!,
spell.Name,
spell.SpellId.ToString(CultureInfo.InvariantCulture),
spell.Charge,
spell.MaxCharge?.ToString(CultureInfo.InvariantCulture) ?? "",
spell.CastCount?.ToString(CultureInfo.InvariantCulture) ?? "",
spell.ForcedKnown,
spell.InSpellBook,
"×");
}
}
private static void UpdateAuraGridIcon(DataGridViewRow row)
{
if (row.IsNewRow)
{
row.Cells["Icon"].Value = null;
return;
}
var name = row.Cells["Name"].Value?.ToString();
var spellId = long.TryParse(
row.Cells["SpellId"].Value?.ToString(),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var parsedSpellId)
? parsedSpellId
: (long?)null;
row.Cells["Icon"].Value = GetAuraIcon(
spellId,
ParseIdList(row.Cells["SpellIds"].Value?.ToString() ?? ""),
name);
}
private static Image? GetAuraIcon(long? spellId, IEnumerable<long> spellIds, string? name)
{
if (spellId is > 0)
{
SpellIconCatalog.Register(spellId.Value, name);
var icon = SpellIconCatalog.Get(spellId.Value);
if (icon is not null)
{
return icon;
}
}
foreach (var candidate in spellIds)
{
if (candidate <= 0 || candidate == spellId)
{
continue;
}
SpellIconCatalog.Register(candidate, name);
var icon = SpellIconCatalog.Get(candidate);
if (icon is not null)
{
return icon;
}
}
return SpellIconCatalog.Get(name);
}
private static void UpdateSpellGridIcon(DataGridViewRow row)
{
if (row.IsNewRow)
{
row.Cells["Icon"].Value = null;
return;
}
var name = row.Cells["Name"].Value?.ToString();
var icon = long.TryParse(
row.Cells["SpellId"].Value?.ToString(),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var spellId)
? SpellIconCatalog.Get(spellId)
: null;
if (spellId > 0)
{
SpellIconCatalog.Register(spellId, name);
}
row.Cells["Icon"].Value = icon ?? SpellIconCatalog.Get(name);
}
private void FillSpellsListGrid()
{
_spellsListGrid.Rows.Clear();
_spellsListSearchBox.Clear();
if (_currentDocument is null)
{
return;
}
foreach (var spell in _currentDocument.SpellsList.Where(spell => spell.Index is >= 1 and <= 100))
{
SpellIconCatalog.Register(spell.SpellId, spell.Name);
var rowIndex = _spellsListGrid.Rows.Add(
spell.SpellId.ToString(CultureInfo.InvariantCulture),
spell.Index.ToString(CultureInfo.InvariantCulture),
(SpellIconCatalog.Get(spell.SpellId) ?? SpellIconCatalog.Get(spell.Name))!,
spell.Name);
_spellsListGrid.Rows[rowIndex].Tag = spell;
}
}
private void FillItemsListGrid()
{
_itemsListGrid.Rows.Clear();
_itemsListSearchBox.Clear();
if (_currentDocument is null)
{
return;
}
foreach (var item in _currentDocument.ItemsList.OrderBy(entry => entry.Index).ThenBy(entry => entry.ItemId))
{
SpellIconCatalog.RegisterItem(item.ItemId, item.Name);
var rowIndex = _itemsListGrid.Rows.Add(
item.ItemId.ToString(CultureInfo.InvariantCulture),
item.Index.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) || ItemsListRowMatches(row, query);
}
}
private static bool ItemsListRowMatches(DataGridViewRow row, string query)
=> new[] { "ItemId", "Index", "Name" }
.Select(columnName => row.Cells[columnName].Value?.ToString() ?? string.Empty)
.Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase));
private void ApplySpellsListFilter()
{
var query = _spellsListSearchBox.Text.Trim();
_spellsListGrid.ClearSelection();
_spellsListGrid.CurrentCell = null;
foreach (DataGridViewRow row in _spellsListGrid.Rows)
{
row.Visible = string.IsNullOrEmpty(query)
|| SpellsListRowMatches(row, query);
}
}
private static bool SpellsListRowMatches(DataGridViewRow row, string query)
=> new[] { "SpellId", "Index", "Name" }
.Select(columnName => row.Cells[columnName].Value?.ToString() ?? string.Empty)
.Any(value => value.Contains(query, StringComparison.OrdinalIgnoreCase));
private void ScheduleSpellDatabaseFilter()
{
_spellDatabaseFilterTimer.Stop();
_spellDatabaseFilterVersion++;
CancelSpellDatabaseFilter();
if (!_spellDatabaseGrid.IsHandleCreated || IsDisposed || Disposing)
{
return;
}
if (!SpellIconCatalog.IsPackageAvailable)
{
ApplySpellDatabaseResults(SpellDatabaseResultSet.Empty, "未安装技能数据库");
return;
}
_spellDatabaseStatusLabel.Text = "正在筛选…";
_spellDatabaseFilterTimer.Start();
}
private void RefreshSpellDatabase()
{
if (IsDisposed || Disposing || !_spellDatabaseGrid.IsHandleCreated)
{
return;
}
_spellDatabaseFilterTimer.Stop();
_spellDatabaseFilterVersion++;
CancelSpellDatabaseFilter();
var packageAvailable = SpellIconCatalog.IsPackageAvailable;
_spellDatabaseFilterBox.Enabled = packageAvailable;
if (!packageAvailable)
{
ApplySpellDatabaseResults(SpellDatabaseResultSet.Empty, "未安装技能数据库");
return;
}
if (string.IsNullOrWhiteSpace(_spellDatabaseFilterBox.Text))
{
var snapshot = SpellIconCatalog.GetSuggestionsSnapshot();
ApplySpellDatabaseResults(SpellDatabaseResultSet.FromAll(snapshot));
return;
}
_spellDatabaseStatusLabel.Text = "正在筛选…";
_ = ApplySpellDatabaseFilterAsync();
}
private async Task ApplySpellDatabaseFilterAsync()
{
if (IsDisposed || Disposing || !_spellDatabaseGrid.IsHandleCreated)
{
return;
}
var version = _spellDatabaseFilterVersion;
var query = _spellDatabaseFilterBox.Text.Trim();
var snapshot = SpellIconCatalog.GetSuggestionsSnapshot();
var registeredNames = SpellIconCatalog.GetRegisteredSpellNamesSnapshot();
if (string.IsNullOrEmpty(query))
{
ApplySpellDatabaseResults(SpellDatabaseResultSet.FromAll(snapshot));
return;
}
var cancellation = new CancellationTokenSource();
_spellDatabaseFilterCancellation = cancellation;
try
{
var results = await Task.Run(
() => FilterSpellDatabase(snapshot, registeredNames, query, cancellation.Token),
cancellation.Token);
if (cancellation.IsCancellationRequested
|| version != _spellDatabaseFilterVersion
|| IsDisposed
|| Disposing)
{
return;
}
ApplySpellDatabaseResults(results);
}
catch (OperationCanceledException)
{
}
finally
{
if (ReferenceEquals(_spellDatabaseFilterCancellation, cancellation))
{
_spellDatabaseFilterCancellation = null;
}
cancellation.Dispose();
}
}
private static SpellDatabaseResultSet FilterSpellDatabase(
IReadOnlyList<SpellSuggestion> source,
IReadOnlyDictionary<long, string> registeredNames,
string query,
CancellationToken cancellationToken)
{
var numeric = query.All(character => character is >= '0' and <= '9');
if (numeric)
{
return FilterSpellDatabaseByIdPrefix(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.SpellId) ?? string.Empty
: suggestion.Name;
if (name.Contains(query, StringComparison.OrdinalIgnoreCase))
{
indices.Add(index);
}
}
return SpellDatabaseResultSet.FromIndices(source, indices.ToArray());
}
private static SpellDatabaseResultSet FilterSpellDatabaseByIdPrefix(
IReadOnlyList<SpellSuggestion> 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 SpellDatabaseResultSet.Empty;
}
var ranges = new List<SpellDatabaseRange>(19);
long scale = 1;
while (prefix <= long.MaxValue / scale)
{
var startSpellId = prefix * scale;
var intervalLength = scale - 1;
var endSpellId = intervalLength > long.MaxValue - startSpellId
? long.MaxValue
: startSpellId + intervalLength;
var startIndex = LowerBoundSpellSuggestion(source, startSpellId);
var endIndex = UpperBoundSpellSuggestion(source, endSpellId);
if (endIndex > startIndex)
{
ranges.Add(new SpellDatabaseRange(startIndex, endIndex - startIndex));
}
if (scale > long.MaxValue / 10)
{
break;
}
scale *= 10;
}
return SpellDatabaseResultSet.FromRanges(source, ranges.ToArray());
}
private static int LowerBoundSpellSuggestion(IReadOnlyList<SpellSuggestion> source, long spellId)
{
var low = 0;
var high = source.Count;
while (low < high)
{
var middle = low + (high - low) / 2;
if (source[middle].SpellId < spellId)
{
low = middle + 1;
}
else
{
high = middle;
}
}
return low;
}
private static int UpperBoundSpellSuggestion(IReadOnlyList<SpellSuggestion> source, long spellId)
{
var low = 0;
var high = source.Count;
while (low < high)
{
var middle = low + (high - low) / 2;
if (source[middle].SpellId <= spellId)
{
low = middle + 1;
}
else
{
high = middle;
}
}
return low;
}
private void ApplySpellDatabaseResults(SpellDatabaseResultSet results, string? status = null)
{
_spellDatabaseGrid.ClearSelection();
_spellDatabaseGrid.CurrentCell = null;
_spellDatabaseResults = results;
_spellDatabaseVisibleCount = Math.Min(SpellDatabasePageSize, results.Count);
_spellDatabaseGrid.RowCount = _spellDatabaseVisibleCount;
_spellDatabaseStatusLabel.Text = status ?? FormatSpellDatabaseStatus();
_spellDatabaseGrid.Invalidate();
}
private string FormatSpellDatabaseStatus()
=> _spellDatabaseResults.Count == 0
? "匹配 0 个技能"
: $"已显示 {_spellDatabaseVisibleCount:N0} / 共 {_spellDatabaseResults.Count:N0} 个技能";
private void OnSpellDatabaseScroll(object? sender, ScrollEventArgs e)
{
if (e.ScrollOrientation != ScrollOrientation.VerticalScroll
|| _expandingSpellDatabaseRows
|| _spellDatabaseVisibleCount >= _spellDatabaseResults.Count
|| _spellDatabaseGrid.FirstDisplayedScrollingRowIndex < 0)
{
return;
}
var lastDisplayedRow = _spellDatabaseGrid.FirstDisplayedScrollingRowIndex
+ _spellDatabaseGrid.DisplayedRowCount(includePartialRow: true);
if (lastDisplayedRow < _spellDatabaseVisibleCount - 2)
{
return;
}
_expandingSpellDatabaseRows = true;
try
{
_spellDatabaseVisibleCount = Math.Min(
_spellDatabaseVisibleCount + SpellDatabasePageSize,
_spellDatabaseResults.Count);
_spellDatabaseGrid.RowCount = _spellDatabaseVisibleCount;
_spellDatabaseStatusLabel.Text = FormatSpellDatabaseStatus();
}
finally
{
_expandingSpellDatabaseRows = false;
}
}
private void CancelSpellDatabaseFilter()
{
var cancellation = _spellDatabaseFilterCancellation;
_spellDatabaseFilterCancellation = null;
if (cancellation is null)
{
return;
}
cancellation.Cancel();
}
private void OnSpellDatabaseCellValueNeeded(object? sender, DataGridViewCellValueEventArgs e)
{
if (e.RowIndex < 0 || e.RowIndex >= _spellDatabaseVisibleCount || e.ColumnIndex < 0)
{
return;
}
var suggestion = _spellDatabaseResults[e.RowIndex];
var displayName = SpellIconCatalog.ResolveSuggestionName(suggestion.SpellId, suggestion.Name) ?? string.Empty;
e.Value = _spellDatabaseGrid.Columns[e.ColumnIndex].Name switch
{
"Icon" => SpellIconCatalog.Get(suggestion.SpellId),
"SpellId" => suggestion.SpellId.ToString(CultureInfo.InvariantCulture),
"Name" => displayName,
"Add" => "添加",
_ => null
};
}
private void OnSpellDatabaseCellContentClick(object? sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex < 0
|| e.RowIndex >= _spellDatabaseVisibleCount
|| e.ColumnIndex < 0
|| _spellDatabaseGrid.Columns[e.ColumnIndex].Name != "Add")
{
return;
}
var suggestion = _spellDatabaseResults[e.RowIndex];
var name = SpellIconCatalog.ResolveSuggestionName(suggestion.SpellId, suggestion.Name);
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.Show(
$"当前技能数据库缺少 spellId {suggestion.SpellId} 的名称,请更新技能数据包后再添加。",
"技能列表",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
AddSpellFromDatabase(new SpellSuggestion(suggestion.SpellId, name));
}
private void FillGroupEditors()
{
_groupAurasGrid.Rows.Clear();
if (_currentSpec?.Group is { } group)
{
_groupEnabledBox.Checked = true;
_groupNumBox.Value = Clamp(_groupNumBox, group.Num);
_groupHasHealthBox.Checked = group.HealthPercent is not null;
_groupHealthBox.Value = Clamp(_groupHealthBox, group.HealthPercent ?? 1);
_groupHasRoleBox.Checked = group.Role is not null;
_groupRoleBox.Value = Clamp(_groupRoleBox, group.Role ?? 2);
_groupHasDispelBox.Checked = group.Dispel is not null;
_groupDispelBox.Value = Clamp(_groupDispelBox, group.Dispel ?? 3);
foreach (var aura in group.Auras)
{
var icon = GetAuraIcon(aura.SpellId, aura.SpellIds, aura.Name);
_groupAurasGrid.Rows.Add(
icon!,
aura.Offset.ToString(CultureInfo.InvariantCulture),
aura.Name,
aura.SpellId?.ToString(CultureInfo.InvariantCulture) ?? "",
string.Join(", ", aura.SpellIds),
"×");
}
}
else
{
_groupEnabledBox.Checked = false;
_groupHasHealthBox.Checked = false;
_groupHasRoleBox.Checked = false;
_groupHasDispelBox.Checked = false;
_groupNumBox.Value = 5;
_groupHealthBox.Value = 1;
_groupRoleBox.Value = 2;
_groupDispelBox.Value = 3;
}
UpdateGroupEditorsEnabled();
}
private void UpdateGroupEditorsEnabled()
{
var enabled = _groupEnabledBox.Checked;
_groupNumBox.Enabled = enabled;
_groupHasHealthBox.Enabled = enabled;
_groupHealthBox.Enabled = enabled && _groupHasHealthBox.Checked;
_groupHasRoleBox.Enabled = enabled;
_groupRoleBox.Enabled = enabled && _groupHasRoleBox.Checked;
_groupHasDispelBox.Enabled = enabled;
_groupDispelBox.Enabled = enabled && _groupHasDispelBox.Checked;
_groupAurasGrid.Enabled = enabled;
_groupAurasGrid.ReadOnly = !enabled;
}
private List<ClassBlocksStore.AuraEntry> GetCurrentAuraList()
=> ResolveAuraList(_lastAuraBucket);
private List<ClassBlocksStore.AuraEntry> ResolveAuraList(string key)
{
if (_currentSpec is null)
{
return [];
}
return key switch
{
"target.harmful" => _currentSpec.TargetHarmfulAuras,
"target.helpful" => _currentSpec.TargetHelpfulAuras,
"focus.harmful" => _currentSpec.FocusHarmfulAuras,
"focus.helpful" => _currentSpec.FocusHelpfulAuras,
_ => _currentSpec.PlayerAuras
};
}
private void CommitCurrentSpecFromUi()
{
if (_currentSpec is null || _currentDocument is null || !_currentDocument.IsModernFormat)
{
return;
}
_itemsGrid.EndEdit();
_spellsGrid.EndEdit();
NormalizeFixedStateNames(_currentSpec);
WriteBackStatesCategory(_lastStateCategory);
WriteBackAuras(_lastAuraBucket);
WriteBackSpells();
WriteBackItems();
WriteBackGroup();
}
private void WriteBackSpellsList()
{
if (_currentDocument is null)
{
return;
}
foreach (DataGridViewRow row in _spellsListGrid.Rows)
{
if (row.Tag is not ClassBlocksStore.SpellsListEntry entry)
{
continue;
}
entry.SpellId = long.Parse(
row.Cells["SpellId"].Value?.ToString()?.Trim() ?? "",
NumberStyles.None,
CultureInfo.InvariantCulture);
entry.Index = int.Parse(
row.Cells["Index"].Value?.ToString()?.Trim() ?? "",
NumberStyles.None,
CultureInfo.InvariantCulture);
entry.Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? "";
}
}
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;
if (int.TryParse(
row.Cells["Index"].Value?.ToString()?.Trim(),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var index))
{
entry.Index = index;
}
entry.Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? "";
}
}
private void AddSpellFromDatabase(SpellSuggestion suggestion)
{
if (_currentDocument is null)
{
MessageBox.Show("请先选择一个职业文件。", "技能列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!_currentDocument.IsModernFormat)
{
MessageBox.Show("旧版稀疏索引格式暂不支持添加技能。", "技能列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_spellsListGrid.EndEdit();
if (!TryValidateSpellsList(out var validationError))
{
MessageBox.Show(validationError, "技能列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
WriteBackSpellsList();
if (_currentDocument.SpellsList.Any(entry => entry.SpellId == suggestion.SpellId))
{
MessageBox.Show(
$"已有此技能:{suggestion.Name}{suggestion.SpellId}",
"技能列表",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
var usedIndices = _currentDocument.SpellsList
.Select(entry => entry.Index)
.Where(index => index is >= 1 and <= 100)
.ToHashSet();
var nextIndex = Enumerable.Range(1, 100).FirstOrDefault(index => !usedIndices.Contains(index));
if (nextIndex == 0)
{
MessageBox.Show("索引 1100 已全部使用,无法继续添加技能。", "技能列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var entry = new ClassBlocksStore.SpellsListEntry
{
SpellId = suggestion.SpellId,
Index = nextIndex,
Name = suggestion.Name
};
_currentDocument.SpellsList.Add(entry);
SpellIconCatalog.Register(suggestion.SpellId, suggestion.Name);
var rowIndex = _spellsListGrid.Rows.Add(
suggestion.SpellId.ToString(CultureInfo.InvariantCulture),
nextIndex.ToString(CultureInfo.InvariantCulture),
(SpellIconCatalog.Get(suggestion.SpellId) ?? SpellIconCatalog.Get(suggestion.Name))!,
suggestion.Name);
var row = _spellsListGrid.Rows[rowIndex];
row.Tag = entry;
_spellsListSearchBox.Clear();
ApplySpellsListFilter();
row.Selected = true;
_spellsListGrid.CurrentCell = row.Cells["SpellId"];
_spellsListGrid.FirstDisplayedScrollingRowIndex = rowIndex;
MarkDirty();
}
private void OnSpellIconCatalogChanged()
{
if (IsDisposed || Disposing || !IsHandleCreated)
{
return;
}
if (InvokeRequired)
{
BeginInvoke(OnSpellIconCatalogChanged);
return;
}
RefreshSpellDatabase();
RefreshItemDatabase();
foreach (var grid in new[] { _spellsGrid, _spellsListGrid })
{
foreach (DataGridViewRow row in grid.Rows)
{
if (!row.IsNewRow)
{
UpdateSpellGridIcon(row);
}
}
grid.Invalidate();
}
foreach (var grid in new[] { _itemsGrid, _itemsListGrid })
{
foreach (DataGridViewRow row in grid.Rows)
{
if (!row.IsNewRow)
{
UpdateItemGridIcon(row);
}
}
grid.Invalidate();
}
_itemDatabaseGrid.Invalidate();
foreach (var grid in new[] { _aurasGrid, _groupAurasGrid })
{
foreach (DataGridViewRow row in grid.Rows)
{
UpdateAuraGridIcon(row);
}
grid.Invalidate();
}
}
private bool TryValidateSpellsList(out string error)
{
error = string.Empty;
if (_currentDocument is null)
{
return true;
}
var editedIds = new HashSet<long>();
foreach (DataGridViewRow row in _spellsListGrid.Rows)
{
var rowNumber = row.Index + 1;
if (!long.TryParse(row.Cells["SpellId"].Value?.ToString()?.Trim(), NumberStyles.None,
CultureInfo.InvariantCulture, out var spellId)
|| spellId <= 0)
{
error = $"技能列表第 {rowNumber} 行的法术 ID 必须是正整数。";
return false;
}
if (!int.TryParse(row.Cells["Index"].Value?.ToString()?.Trim(), NumberStyles.None,
CultureInfo.InvariantCulture, out var index)
|| index is < 1 or > 100)
{
error = $"技能列表第 {rowNumber} 行的索引必须是 1100 的整数。";
return false;
}
if (string.IsNullOrWhiteSpace(row.Cells["Name"].Value?.ToString()))
{
error = $"技能列表第 {rowNumber} 行的名称不能为空。";
return false;
}
if (!editedIds.Add(spellId))
{
error = $"技能列表中的法术 ID {spellId} 重复。";
return false;
}
}
var hiddenIds = _currentDocument.SpellsList
.Where(entry => entry.Index is < 1 or > 100)
.Select(entry => entry.SpellId)
.ToHashSet();
var conflictId = editedIds.FirstOrDefault(hiddenIds.Contains);
if (conflictId != 0)
{
error = $"法术 ID {conflictId} 已被技能列表中索引 101+ 的条目使用。";
return false;
}
return true;
}
private bool TryValidateItemsList(out string error)
{
error = string.Empty;
if (_currentDocument is null)
{
return true;
}
var itemIds = new HashSet<long>();
var indices = new HashSet<int>();
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;
}
if (!int.TryParse(row.Cells["Index"].Value?.ToString()?.Trim(), NumberStyles.None,
CultureInfo.InvariantCulture, out var index)
|| index <= 0)
{
error = $"物品列表第 {rowNumber} 行的索引必须是正整数。";
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 (!indices.Add(index))
{
error = $"物品列表索引 {index} 重复。";
return false;
}
}
return true;
}
private bool TryValidateItems(out string error)
{
error = string.Empty;
if (_currentDocument is null)
{
return true;
}
foreach (var (specId, spec) in _currentDocument.Specs.OrderBy(pair => pair.Key))
{
if (spec.Items.Count == 0)
{
continue;
}
var itemIds = new HashSet<long>();
var itemNames = new HashSet<string>(StringComparer.Ordinal);
var bareNames = new HashSet<string>(StringComparer.Ordinal);
if (spec.NestedStates)
{
foreach (var category in new[]
{
ClassStateCatalog.CategoryState,
ClassStateCatalog.CategorySpecial,
ClassStateCatalog.CategoryResource,
ClassStateCatalog.CategoryConfig
})
{
foreach (var name in spec.CategorizedStates.GetValueOrDefault(category) ?? [])
{
bareNames.Add(name);
}
}
}
else
{
bareNames.UnionWith(spec.FlatStates);
}
for (var index = 0; index < spec.Items.Count; index++)
{
var item = spec.Items[index];
var rowNumber = index + 1;
if (item.ItemId is not { } itemId || itemId <= 0)
{
error = $"专精 {specId} 的物品第 {rowNumber} 行 itemId 必须是正整数。";
return false;
}
if (string.IsNullOrWhiteSpace(item.Name))
{
error = $"专精 {specId} 的物品第 {rowNumber} 行名称不能为空。";
return false;
}
if (!itemIds.Add(itemId))
{
error = $"专精 {specId} 的物品 itemId {itemId} 重复。";
return false;
}
if (!itemNames.Add(item.Name))
{
error = $"专精 {specId} 的物品名称“{item.Name}”重复。";
return false;
}
if (bareNames.Contains(item.Name))
{
error = $"专精 {specId} 的物品名称“{item.Name}”与状态、特殊、能量或配置开关字段重名。";
return false;
}
}
}
return true;
}
private void WriteBackStatesCategory(string category)
{
if (_currentSpec is null)
{
return;
}
var storageCategory = ClassStateCatalog.GetStorageCategory(category);
List<string> list;
if (_currentSpec.NestedStates)
{
if (!_currentSpec.CategorizedStates.TryGetValue(storageCategory, out list!))
{
list = new List<string>();
_currentSpec.CategorizedStates[storageCategory] = list;
}
}
else
{
list = _currentSpec.FlatStates;
}
var editedNames = new List<string>();
foreach (DataGridViewRow row in _statesGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var name = row.Cells["Name"].Value?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
editedNames.Add(name);
}
}
var insertIndex = list.FindIndex(name =>
ClassStateCatalog.IsInCategory(name, category)
&& !IsHiddenStateName(name));
if (insertIndex < 0)
{
var anchorIndex = string.Equals(category, ClassStateCatalog.CategoryState, StringComparison.Ordinal)
? list.FindLastIndex(IsHiddenStateName)
: -1;
insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : list.Count;
}
list.RemoveAll(name =>
ClassStateCatalog.IsInCategory(name, category)
&& !IsHiddenStateName(name));
list.InsertRange(Math.Min(insertIndex, list.Count), editedNames);
}
private void WriteBackAuras(string bucketKey)
{
if (_currentSpec is null)
{
return;
}
var list = ResolveAuraList(bucketKey);
list.Clear();
foreach (DataGridViewRow row in _aurasGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var name = row.Cells["Name"].Value?.ToString()?.Trim() ?? "";
var spellIdsText = row.Cells["SpellIds"].Value?.ToString()?.Trim() ?? "";
var spellIdText = row.Cells["SpellId"].Value?.ToString()?.Trim() ?? "";
var maxAppsText = row.Cells["MaxApps"].Value?.ToString()?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(spellIdText) && string.IsNullOrWhiteSpace(spellIdsText))
{
continue;
}
var entry = new ClassBlocksStore.AuraEntry { Name = name };
foreach (var id in ParseIdList(spellIdsText))
{
entry.SpellIds.Add(id);
}
if (long.TryParse(spellIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid))
{
entry.SpellId = sid;
}
if (int.TryParse(maxAppsText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxApps))
{
entry.MaxApps = maxApps;
}
list.Add(entry);
}
}
private void WriteBackItems()
{
if (_currentSpec is null)
{
return;
}
_currentSpec.Items.Clear();
foreach (DataGridViewRow row in _itemsGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var idText = row.Cells["ItemId"].Value?.ToString()?.Trim();
var name = row.Cells["Name"].Value?.ToString()?.Trim() ?? string.Empty;
long? itemId = long.TryParse(idText, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedId)
? parsedId
: null;
if (itemId is not null || name.Length > 0)
{
_currentSpec.Items.Add(new ClassBlocksStore.ItemEntry
{
ItemId = itemId,
Name = name,
IsEquipped = row.Cells["IsEquipped"].Value is true
});
}
}
}
private void WriteBackSpells()
{
if (_currentSpec is null)
{
return;
}
_currentSpec.Spells.Clear();
foreach (DataGridViewRow row in _spellsGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var spellIdText = row.Cells["SpellId"].Value?.ToString()?.Trim() ?? "";
if (!long.TryParse(spellIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var spellId))
{
continue;
}
var entry = new ClassBlocksStore.SpellEntry
{
SpellId = spellId,
Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? "",
Charge = row.Cells["Charge"].Value is true,
ForcedKnown = row.Cells["ForcedKnown"].Value is true,
InSpellBook = row.Cells["InSpellBook"].Value is true
};
if (int.TryParse(row.Cells["MaxCharge"].Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxCharge))
{
entry.MaxCharge = maxCharge;
}
if (int.TryParse(row.Cells["CastCount"].Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var castCount))
{
entry.CastCount = castCount;
}
_currentSpec.Spells.Add(entry);
}
}
private void WriteBackGroup()
{
if (_currentSpec is null)
{
return;
}
if (!_groupEnabledBox.Checked)
{
_currentSpec.Group = null;
return;
}
var group = new ClassBlocksStore.GroupBlocks
{
Num = (int)_groupNumBox.Value,
HealthPercent = _groupHasHealthBox.Checked ? (int)_groupHealthBox.Value : null,
Role = _groupHasRoleBox.Checked ? (int)_groupRoleBox.Value : null,
Dispel = _groupHasDispelBox.Checked ? (int)_groupDispelBox.Value : null
};
foreach (DataGridViewRow row in _groupAurasGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
if (!int.TryParse(row.Cells["Offset"].Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var offset))
{
continue;
}
var entry = new ClassBlocksStore.GroupAuraEntry
{
Offset = offset,
Name = row.Cells["Name"].Value?.ToString()?.Trim() ?? ""
};
var spellIdsText = row.Cells["SpellIds"].Value?.ToString()?.Trim() ?? "";
foreach (var id in ParseIdList(spellIdsText))
{
entry.SpellIds.Add(id);
}
if (long.TryParse(row.Cells["SpellId"].Value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid))
{
entry.SpellId = sid;
}
group.Auras.Add(entry);
}
_currentSpec.Group = group;
}
private async Task SaveAndUpdateAsync()
{
if (_currentDocument is null || _currentClassId is null)
{
MessageBox.Show("请先选择一个职业文件。", "配置", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!_currentDocument.IsModernFormat)
{
MessageBox.Show("旧版稀疏索引格式暂不支持保存。", "配置", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var localSaved = false;
try
{
_spellsListGrid.EndEdit();
_itemsListGrid.EndEdit();
_statesGrid.EndEdit();
_spellsGrid.EndEdit();
_itemsGrid.EndEdit();
if (!TryValidateSpellsList(out var validationError))
{
MessageBox.Show(validationError, "技能列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
_statusLabel.Text = validationError;
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);
_statusLabel.Text = validationError;
return;
}
WriteBackSpellsList();
WriteBackItemsList();
ClassBlocksStore.Save(_currentDocument);
localSaved = true;
SetDirty(false);
_statusLabel.Text = "本地 Lua 已保存,正在更新配置并同步游戏…";
var updateResult = await _updateConfigAsync(_currentDocument.FilePath, _currentClassId.Value);
if (IsDisposed)
{
return;
}
if (!string.IsNullOrWhiteSpace(updateResult.AddonSyncIssue))
{
_statusLabel.Text = "本地已保存并更新配置,但游戏同步失败";
MessageBox.Show(
$"本地 Lua、该职业的 {updateResult.SavedModuleCount} 个模块及 config/keymap 已保存,"
+ $"但游戏插件同步未完成:\n{updateResult.AddonSyncIssue}",
"游戏同步失败",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
_statusLabel.Text = $"已保存配置及该职业的 {updateResult.SavedModuleCount} 个模块";
var warningText = updateResult.ModuleWarnings.Count == 0
? string.Empty
: $"\n\n有 {updateResult.ModuleWarnings.Count} 个模块未携带依赖,详情见日志。";
MessageBox.Show(
$"已一并保存该职业的 {updateResult.SavedModuleCount} 个模块。{warningText}\n\n请在游戏内重载界面, /reload",
"保存成功",
MessageBoxButtons.OK,
updateResult.ModuleWarnings.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
}
catch (Exception ex)
{
if (!IsDisposed)
{
var title = localSaved ? "保存后的更新失败" : "保存失败";
var message = localSaved
? $"本地 Lua 已保存,但后续更新失败:\n{ex.Message}"
: ex.Message;
MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error);
_statusLabel.Text = localSaved
? $"本地已保存,后续更新失败: {ex.Message}"
: $"保存失败: {ex.Message}";
}
}
}
private void MarkDirty()
{
if (_suppressUi)
{
return;
}
SetDirty(true);
if (_statusLabel.Text != "已修改(未保存)")
{
_statusLabel.Text = "已修改(未保存)";
}
}
private void SetDirty(bool dirty)
{
if (_dirty == dirty)
{
_saveButton.Enabled = dirty;
return;
}
_dirty = dirty;
_saveButton.Enabled = dirty;
DirtyStateChanged?.Invoke(dirty);
}
private bool ConfirmDiscard()
=> MessageBox.Show("当前修改尚未保存,确定丢弃吗?", "配置", MessageBoxButtons.YesNo, MessageBoxIcon.Question)
== DialogResult.Yes;
private void SelectClassInList(int? classId)
{
for (var i = 0; i < _classList.Items.Count; i++)
{
if (_classList.Items[i] is ClassListItem item && item.ClassId == classId)
{
_classList.SelectedIndex = i;
return;
}
}
}
private void ClearGrids()
{
_statesGrid.Rows.Clear();
_aurasGrid.Rows.Clear();
_spellsGrid.Rows.Clear();
_spellsSearchBox.Clear();
_itemsGrid.Rows.Clear();
_itemsSearchBox.Clear();
_itemsListGrid.Rows.Clear();
_itemsListSearchBox.Clear();
_spellsListGrid.Rows.Clear();
_spellsListSearchBox.Clear();
_groupAurasGrid.Rows.Clear();
}
private void HandleDeleteClick(object? sender, DataGridViewCellEventArgs e)
{
if (sender is not DataGridView grid || e.RowIndex < 0 || e.ColumnIndex < 0)
{
return;
}
if (grid.Columns[e.ColumnIndex].Name != "Delete")
{
return;
}
if (grid.Rows[e.RowIndex].IsNewRow)
{
return;
}
grid.Rows.RemoveAt(e.RowIndex);
MarkDirty();
}
private void HandleSpellsListDeleteClick(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.SpellsListEntry entry)
{
return;
}
if (_currentDocument is not null)
{
if (entry.OriginalSpellId > 0)
{
_currentDocument.DeletedSpellsListOriginalIds.Add(entry.OriginalSpellId);
}
_currentDocument.SpellsList.Remove(entry);
}
grid.Rows.RemoveAt(e.RowIndex);
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)
{
return;
}
var index = grid.CurrentRow.Index;
var target = index + delta;
while (target >= 0 && target < grid.Rows.Count && !grid.Rows[target].IsNewRow && !grid.Rows[target].Visible)
{
target += delta;
}
if (target < 0 || target >= grid.Rows.Count || grid.Rows[target].IsNewRow)
{
return;
}
var values = new object[grid.Columns.Count];
for (var i = 0; i < grid.Columns.Count; i++)
{
values[i] = grid.Rows[index].Cells[i].Value ?? DBNull.Value;
}
grid.Rows.RemoveAt(index);
grid.Rows.Insert(target, values);
grid.ClearSelection();
grid.Rows[target].Selected = true;
grid.CurrentCell = grid.Rows[target].Cells[0];
MarkDirty();
}
private static IEnumerable<long> ParseIdList(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
yield break;
}
foreach (var part in text.Split([',', ' ', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
yield return id;
}
}
}
private static decimal Clamp(NumericUpDown box, int value)
=> Math.Min(box.Maximum, Math.Max(box.Minimum, value));
private static void NormalizeFixedStateNames(ClassBlocksStore.SpecBlocks spec)
{
var states = spec.NestedStates
? spec.CategorizedStates[ClassStateCatalog.CategoryState]
: spec.FlatStates;
states.RemoveAll(IsHiddenStateName);
states.InsertRange(0, FixedStateNames);
}
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
{
private readonly IReadOnlyList<SpellSuggestion> _source;
private readonly SpellDatabaseRange[]? _ranges;
private readonly int[]? _indices;
private SpellDatabaseResultSet(
IReadOnlyList<SpellSuggestion> source,
SpellDatabaseRange[]? ranges,
int[]? indices,
int count)
{
_source = source;
_ranges = ranges;
_indices = indices;
Count = count;
}
public static SpellDatabaseResultSet Empty { get; } = new(
Array.Empty<SpellSuggestion>(),
Array.Empty<SpellDatabaseRange>(),
null,
0);
public int Count { get; }
public SpellSuggestion 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 SpellDatabaseResultSet FromAll(IReadOnlyList<SpellSuggestion> source)
=> source.Count == 0
? Empty
: new SpellDatabaseResultSet(
source,
[new SpellDatabaseRange(0, source.Count)],
null,
source.Count);
public static SpellDatabaseResultSet FromRanges(
IReadOnlyList<SpellSuggestion> source,
SpellDatabaseRange[] ranges)
{
var count = ranges.Sum(range => range.Count);
return count == 0
? Empty
: new SpellDatabaseResultSet(source, ranges, null, count);
}
public static SpellDatabaseResultSet FromIndices(
IReadOnlyList<SpellSuggestion> source,
int[] indices)
=> indices.Length == 0
? Empty
: new SpellDatabaseResultSet(source, null, indices, indices.Length);
}
private sealed record ClassListItem(int ClassId, string Name, string FileName, bool IsModern, string? Error = null)
{
public override string ToString()
=> Error is not null ? $"{Name}(错误)" : IsModern ? Name : $"{Name}(旧格式)";
}
private sealed record SpecOption(int ClassId, int Id, string Name)
{
public override string ToString() => Name;
}
}