using System.Drawing; using System.Globalization; namespace Shigure; public sealed record ClassConfigPostSaveResult( string? AddonSyncIssue, int SavedModuleCount, IReadOnlyList ModuleWarnings); /// /// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocks(states / auras / spells / items / group), /// 并编辑同文件中的 spellsList 与 itemsList。 /// public sealed class ClassConfigEditorControl : UserControl { private readonly Func _resolveClassDirectory; private readonly Func> _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 _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? 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 resolveClassDirectory, Func> 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,仅编辑索引 1–100。"); 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[] 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(); 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 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 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 source, IReadOnlyDictionary registeredNames, string query, CancellationToken cancellationToken) { var numeric = query.All(character => character is >= '0' and <= '9'); if (numeric) { return FilterItemDatabaseByIdPrefix(source, query); } var indices = new List(); for (var index = 0; index < source.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var suggestion = source[index]; var name = string.IsNullOrWhiteSpace(suggestion.Name) ? registeredNames.GetValueOrDefault(suggestion.ItemId) ?? string.Empty : suggestion.Name; if (name.Contains(query, StringComparison.OrdinalIgnoreCase)) { indices.Add(index); } } return ItemDatabaseResultSet.FromIndices(source, indices.ToArray()); } private static ItemDatabaseResultSet FilterItemDatabaseByIdPrefix( IReadOnlyList source, string query) { if (source.Count == 0 || query.Length > 19 || query[0] == '0' || !long.TryParse(query, NumberStyles.None, CultureInfo.InvariantCulture, out var prefix) || prefix <= 0) { return ItemDatabaseResultSet.Empty; } var ranges = new List(19); long scale = 1; while (prefix <= long.MaxValue / scale) { var startItemId = prefix * scale; var intervalLength = scale - 1; var endItemId = intervalLength > long.MaxValue - startItemId ? long.MaxValue : startItemId + intervalLength; var startIndex = LowerBoundItemSuggestion(source, startItemId); var endIndex = UpperBoundItemSuggestion(source, endItemId); if (endIndex > startIndex) { ranges.Add(new ItemDatabaseRange(startIndex, endIndex - startIndex)); } if (scale > long.MaxValue / 10) { break; } scale *= 10; } return ItemDatabaseResultSet.FromRanges(source, ranges.ToArray()); } private static int LowerBoundItemSuggestion(IReadOnlyList source, long itemId) { var low = 0; var high = source.Count; while (low < high) { var middle = low + (high - low) / 2; if (source[middle].ItemId < itemId) { low = middle + 1; } else { high = middle; } } return low; } private static int UpperBoundItemSuggestion(IReadOnlyList source, long itemId) { var low = 0; var high = source.Count; while (low < high) { var middle = low + (high - low) / 2; if (source[middle].ItemId <= itemId) { low = middle + 1; } else { high = middle; } } return low; } private void ApplyItemDatabaseResults(ItemDatabaseResultSet results, string? status = null) { _itemDatabaseGrid.ClearSelection(); _itemDatabaseGrid.CurrentCell = null; _itemDatabaseResults = results; _itemDatabaseVisibleCount = Math.Min(ItemDatabasePageSize, results.Count); _itemDatabaseGrid.RowCount = _itemDatabaseVisibleCount; _itemDatabaseStatusLabel.Text = status ?? FormatItemDatabaseStatus(); _itemDatabaseGrid.Invalidate(); } private string FormatItemDatabaseStatus() => _itemDatabaseResults.Count == 0 ? "匹配 0 个物品" : $"已显示 {_itemDatabaseVisibleCount:N0} / 共 {_itemDatabaseResults.Count:N0} 个物品"; private void OnItemDatabaseScroll(object? sender, ScrollEventArgs e) { if (e.ScrollOrientation != ScrollOrientation.VerticalScroll || _expandingItemDatabaseRows || _itemDatabaseVisibleCount >= _itemDatabaseResults.Count || _itemDatabaseGrid.FirstDisplayedScrollingRowIndex < 0) { return; } var lastDisplayedRow = _itemDatabaseGrid.FirstDisplayedScrollingRowIndex + _itemDatabaseGrid.DisplayedRowCount(includePartialRow: true); if (lastDisplayedRow < _itemDatabaseVisibleCount - 2) { return; } _expandingItemDatabaseRows = true; try { _itemDatabaseVisibleCount = Math.Min( _itemDatabaseVisibleCount + ItemDatabasePageSize, _itemDatabaseResults.Count); _itemDatabaseGrid.RowCount = _itemDatabaseVisibleCount; _itemDatabaseStatusLabel.Text = FormatItemDatabaseStatus(); } finally { _expandingItemDatabaseRows = false; } } private void CancelItemDatabaseFilter() { var cancellation = _itemDatabaseFilterCancellation; _itemDatabaseFilterCancellation = null; if (cancellation is null) { return; } cancellation.Cancel(); } private void OnItemDatabaseCellValueNeeded(object? sender, DataGridViewCellValueEventArgs e) { if (e.RowIndex < 0 || e.RowIndex >= _itemDatabaseVisibleCount || e.ColumnIndex < 0) { return; } var suggestion = _itemDatabaseResults[e.RowIndex]; var displayName = SpellIconCatalog.ResolveItemSuggestionName(suggestion.ItemId, suggestion.Name) ?? string.Empty; e.Value = _itemDatabaseGrid.Columns[e.ColumnIndex].Name switch { "Icon" => SpellIconCatalog.GetItem(suggestion.ItemId), "ItemId" => suggestion.ItemId.ToString(CultureInfo.InvariantCulture), "Name" => displayName, "Add" => "添加", _ => null }; } private void OnItemDatabaseCellContentClick(object? sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || e.RowIndex >= _itemDatabaseVisibleCount || e.ColumnIndex < 0 || _itemDatabaseGrid.Columns[e.ColumnIndex].Name != "Add") { return; } var suggestion = _itemDatabaseResults[e.RowIndex]; var name = SpellIconCatalog.ResolveItemSuggestionName(suggestion.ItemId, suggestion.Name); if (string.IsNullOrWhiteSpace(name)) { MessageBox.Show( $"当前物品数据库缺少 itemId {suggestion.ItemId} 的名称,请更新技能/物品数据包后再添加。", "物品列表", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } 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 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 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 GetUsedStateNames(string category, int excludedRowIndex) { var usedNames = new HashSet(StringComparer.Ordinal); if (_currentSpec is not null) { IEnumerable 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 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 source, IReadOnlyDictionary 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(); 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 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(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 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 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 GetCurrentAuraList() => ResolveAuraList(_lastAuraBucket); private List 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("索引 1–100 已全部使用,无法继续添加技能。", "技能列表", 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(); 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} 行的索引必须是 1–100 的整数。"; 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(); var indices = new HashSet(); 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(); var itemNames = new HashSet(StringComparer.Ordinal); var bareNames = new HashSet(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 list; if (_currentSpec.NestedStates) { if (!_currentSpec.CategorizedStates.TryGetValue(storageCategory, out list!)) { list = new List(); _currentSpec.CategorizedStates[storageCategory] = list; } } else { list = _currentSpec.FlatStates; } var editedNames = new List(); 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 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 _source; private readonly ItemDatabaseRange[]? _ranges; private readonly int[]? _indices; private ItemDatabaseResultSet( IReadOnlyList source, ItemDatabaseRange[]? ranges, int[]? indices, int count) { _source = source; _ranges = ranges; _indices = indices; Count = count; } public static ItemDatabaseResultSet Empty { get; } = new( Array.Empty(), Array.Empty(), null, 0); public int Count { get; } public ItemSuggestion this[int index] { get { if (index < 0 || index >= Count) { throw new ArgumentOutOfRangeException(nameof(index)); } if (_indices is not null) { return _source[_indices[index]]; } var remaining = index; foreach (var range in _ranges!) { if (remaining < range.Count) { return _source[range.SourceIndex + remaining]; } remaining -= range.Count; } throw new ArgumentOutOfRangeException(nameof(index)); } } public static ItemDatabaseResultSet FromAll(IReadOnlyList source) => source.Count == 0 ? Empty : new ItemDatabaseResultSet( source, [new ItemDatabaseRange(0, source.Count)], null, source.Count); public static ItemDatabaseResultSet FromRanges( IReadOnlyList source, ItemDatabaseRange[] ranges) { var count = ranges.Sum(range => range.Count); return count == 0 ? Empty : new ItemDatabaseResultSet(source, ranges, null, count); } public static ItemDatabaseResultSet FromIndices( IReadOnlyList source, int[] indices) => indices.Length == 0 ? Empty : new ItemDatabaseResultSet(source, null, indices, indices.Length); } private readonly record struct SpellDatabaseRange(int SourceIndex, int Count); private sealed class SpellDatabaseResultSet { private readonly IReadOnlyList _source; private readonly SpellDatabaseRange[]? _ranges; private readonly int[]? _indices; private SpellDatabaseResultSet( IReadOnlyList source, SpellDatabaseRange[]? ranges, int[]? indices, int count) { _source = source; _ranges = ranges; _indices = indices; Count = count; } public static SpellDatabaseResultSet Empty { get; } = new( Array.Empty(), Array.Empty(), 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 source) => source.Count == 0 ? Empty : new SpellDatabaseResultSet( source, [new SpellDatabaseRange(0, source.Count)], null, source.Count); public static SpellDatabaseResultSet FromRanges( IReadOnlyList 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 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; } }