using System.Drawing; using System.Globalization; namespace Shigure; /// /// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocks(states / auras / spells / group)。 /// 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 Button _reloadButton = null!; private readonly Button _saveButton = null!; private readonly DataGridView _statesGrid = new(); private readonly ComboBox _stateCategoryBox = new(); private readonly DataGridViewComboBoxColumn _stateNameColumn = new(); private readonly DataGridView _aurasGrid = new(); private readonly ComboBox _auraBucketBox = new(); private readonly DataGridView _spellsGrid = new(); 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 _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 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.Field, UiTheme.Text); _saveButton = UiTheme.CreateButton("保存", UiTheme.Accent, Color.Black); InitializeComponent(); ReloadFromAddon(); } 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, 154)); root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 196)); 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 TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, ColumnCount = 1, RowCount = 2, Padding = new Padding(12), Margin = new Padding(0, 0, 12, 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, Margin = new Padding(0) }, 0, 0); _classList.Dock = DockStyle.Fill; UiTheme.StyleClassIconListBox( _classList, item => (item as ClassListItem)?.ClassId); _classList.SelectedIndexChanged += (_, _) => { if (_suppressUi) { return; } SelectClassFromList(); }; panel.Controls.Add(_classList, 0, 1); return panel; } private Control BuildSpecSidebar() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, ColumnCount = 1, RowCount = 2, Padding = new Padding(12), Margin = new Padding(0, 0, 12, 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, Margin = new Padding(0) }, 0, 0); _specList.Dock = DockStyle.Fill; UiTheme.StyleSpecIconListBox( _specList, item => item is SpecOption spec ? (spec.ClassId, spec.Id) : null); _specList.SelectedIndexChanged += (_, _) => { if (_suppressUi) { return; } SelectSpec(_specList.SelectedItem as SpecOption); }; panel.Controls.Add(_specList, 0, 1); return panel; } private static void StyleActionButton(Button button) { button.AutoSize = false; button.Size = new Size(110, 36); button.Margin = new Padding(0, 0, 0, 8); button.TextAlign = ContentAlignment.MiddleCenter; } private Control BuildEditor() { var root = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 1, RowCount = 3, Margin = new Padding(0) }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); var header = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, ColumnCount = 2, RowCount = 2, Padding = new Padding(14, 12, 14, 12), Margin = new Padding(0, 0, 0, 8) }; header.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56)); header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); header.Controls.Add(CreateFieldCaption("路径"), 0, 0); ConfigureInfoLabel(_pathLabel, UiTheme.Text); _pathLabel.Text = "未加载"; header.Controls.Add(_pathLabel, 1, 0); header.Controls.Add(CreateFieldCaption("状态"), 0, 1); ConfigureInfoLabel(_statusLabel, UiTheme.Muted); _statusLabel.Text = "点击刷新以从游戏窗口定位 Fuyutsui\\class"; header.Controls.Add(_statusLabel, 1, 1); root.Controls.Add(header, 0, 0); root.Controls.Add(BuildSectionTabs(), 0, 1); var actionRow = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 2, RowCount = 1, Margin = new Padding(0), Padding = new Padding(0, 8, 12, 12) }; 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 = UiTheme.Surface, 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(actions, 1, 0); root.Controls.Add(actionRow, 0, 2); return root; } 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, 40)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); var tabBar = new TableLayoutPanel { Dock = DockStyle.Fill, BackColor = UiTheme.Border, ColumnCount = 4, RowCount = 1, Margin = new Padding(0), Padding = new Padding(0) }; for (var i = 0; i < 4; i++) { tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); } var contentHost = new Panel { Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, Margin = new Padding(0), Padding = new Padding(12) }; var pages = new Control[] { BuildStatesPage(), BuildAurasPage(), BuildSpellsPage(), BuildGroupPage() }; foreach (var page in pages) { page.Dock = DockStyle.Fill; page.Visible = false; contentHost.Controls.Add(page); } var labels = new Label[4]; var selectedIndex = -1; void SelectTab(int index) { if (selectedIndex == index) { return; } selectedIndex = index; for (var i = 0; i < labels.Length; i++) { var selected = i == index; labels[i].BackColor = selected ? UiTheme.Field : UiTheme.Surface; labels[i].ForeColor = selected ? UiTheme.Text : UiTheme.Muted; labels[i].Invalidate(); pages[i].Visible = selected; if (selected) { pages[i].BringToFront(); } } } var titles = new[] { "状态", "光环", "法术", "队伍" }; for (var i = 0; i < titles.Length; i++) { var index = i; var label = new Label { Text = titles[i], Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter, AutoSize = false, BackColor = UiTheme.Surface, ForeColor = UiTheme.Muted, Cursor = Cursors.Hand, Margin = new Padding(i == 0 ? 0 : 1, 0, 0, 0) }; label.Click += (_, _) => SelectTab(index); label.MouseEnter += (_, _) => { if (selectedIndex != index) { label.BackColor = UiTheme.Hover; } }; label.MouseLeave += (_, _) => { if (selectedIndex != index) { label.BackColor = UiTheme.Surface; } }; label.Paint += (_, e) => { if (selectedIndex != index) { return; } using var accent = new SolidBrush(UiTheme.Accent); e.Graphics.FillRectangle(accent, 8, label.Height - 3, Math.Max(0, label.Width - 16), 2); }; label.SizeChanged += (_, _) => label.Invalidate(); labels[i] = label; tabBar.Controls.Add(label, i, 0); } root.Controls.Add(tabBar, 0, 0); root.Controls.Add(contentHost, 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)); var top = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = UiTheme.SurfaceRaised }; top.Controls.Add(CreateMutedLabel("分类")); UiTheme.StyleComboBox(_stateCategoryBox); _stateCategoryBox.Width = 180; _stateCategoryBox.Items.AddRange(ClassStateCatalog.TopCategories); _stateCategoryBox.SelectedIndex = 0; _stateCategoryBox.SelectedIndexChanged += (_, _) => { if (_suppressUi) { return; } _statesGrid.EndEdit(); WriteBackStatesCategory(_lastStateCategory); _lastStateCategory = _stateCategoryBox.SelectedItem as string ?? ClassStateCatalog.CategoryState; ReloadStatesGrid(); }; top.Controls.Add(_stateCategoryBox); panel.Controls.Add(top, 0, 0); ConfigureGrid(_statesGrid); _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.CellContentClick += HandleDeleteClick; _statesGrid.CellValueChanged += (_, _) => MarkDirty(); _statesGrid.UserAddedRow += (_, _) => MarkDirty(); _statesGrid.DataError += (_, e) => e.ThrowException = false; _statesGrid.EditingControlShowing += HandleStateEditingControlShowing; panel.Controls.Add(_statesGrid, 0, 1); panel.Controls.Add(BuildMoveButtons(_statesGrid), 0, 2); return panel; } 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)); var top = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; top.Controls.Add(CreateMutedLabel("单位")); UiTheme.StyleComboBox(_auraBucketBox); _auraBucketBox.Width = 180; foreach (var bucket in AuraBuckets) { _auraBucketBox.Items.Add(new BucketOption(bucket.Key, bucket.Text)); } _auraBucketBox.SelectedIndex = 0; _auraBucketBox.SelectedIndexChanged += (_, _) => { if (_suppressUi) { return; } WriteBackAuras(_lastAuraBucket); _lastAuraBucket = (_auraBucketBox.SelectedItem as BucketOption)?.Key ?? "player"; FillAurasGrid(); }; top.Controls.Add(_auraBucketBox); panel.Controls.Add(top, 0, 0); ConfigureGrid(_aurasGrid); _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 += (_, _) => MarkDirty(); _aurasGrid.UserAddedRow += (_, _) => MarkDirty(); panel.Controls.Add(_aurasGrid, 0, 1); panel.Controls.Add(BuildMoveButtons(_aurasGrid), 0, 2); return panel; } private Control BuildSpellsPage() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, BackColor = UiTheme.SurfaceRaised }; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); var textureOrderHint = CreateFieldCaption( "纹理按行排列;充能法术连续占 2 格:冷却 → 充能冷却。最大充能、施法次数只生成横向计数条。"); textureOrderHint.Padding = new Padding(8, 0, 0, 0); panel.Controls.Add(textureOrderHint, 0, 0); ConfigureGrid(_spellsGrid); _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 += (_, _) => MarkDirty(); _spellsGrid.UserAddedRow += (_, _) => MarkDirty(); _spellsGrid.DataError += (_, e) => e.ThrowException = false; panel.Controls.Add(_spellsGrid, 0, 1); panel.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 2); return panel; } private Control BuildGroupPage() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3, BackColor = UiTheme.SurfaceRaised }; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 80)); 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 = true, BackColor = UiTheme.SurfaceRaised }; _groupEnabledBox.Text = "启用 group"; _groupEnabledBox.ForeColor = UiTheme.Text; _groupEnabledBox.AutoSize = true; _groupEnabledBox.CheckedChanged += (_, _) => { if (!_suppressUi) { MarkDirty(); UpdateGroupEditorsEnabled(); } }; fields.Controls.Add(_groupEnabledBox); fields.Controls.Add(CreateField("num", _groupNumBox, 1, 40, 5)); fields.Controls.Add(CreateField("healthPercent", _groupHealthBox, 0, 40, 1)); fields.Controls.Add(CreateField("role", _groupRoleBox, 0, 40, 2)); _groupHasDispelBox.Text = "dispel"; _groupHasDispelBox.ForeColor = UiTheme.Text; _groupHasDispelBox.AutoSize = true; _groupHasDispelBox.CheckedChanged += (_, _) => { if (!_suppressUi) { MarkDirty(); _groupDispelBox.Enabled = _groupEnabledBox.Checked && _groupHasDispelBox.Checked; } }; fields.Controls.Add(_groupHasDispelBox); fields.Controls.Add(CreateField("", _groupDispelBox, 0, 40, 3)); panel.Controls.Add(fields, 0, 0); ConfigureGrid(_groupAurasGrid); _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 += (_, _) => MarkDirty(); _groupAurasGrid.UserAddedRow += (_, _) => MarkDirty(); panel.Controls.Add(_groupAurasGrid, 0, 1); panel.Controls.Add(BuildMoveButtons(_groupAurasGrid), 0, 2); return panel; } private Control CreateField(string label, NumericUpDown box, decimal min, decimal max, decimal value) { var host = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, Margin = new Padding(12, 0, 0, 0) }; if (!string.IsNullOrEmpty(label)) { host.Controls.Add(CreateMutedLabel(label)); } UiTheme.StyleNumericUpDown(box); box.Minimum = min; box.Maximum = max; box.Value = value; box.Width = 70; box.ValueChanged += (_, _) => { if (!_suppressUi) { MarkDirty(); } }; host.Controls.Add(box); return host; } 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); up.AutoSize = false; down.AutoSize = false; up.Size = new Size(48, 32); down.Size = new Size(48, 32); 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) { UiTheme.StyleDataGridView(grid); grid.AllowUserToAddRows = true; grid.AllowUserToDeleteRows = false; grid.RowHeadersVisible = false; grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; grid.MultiSelect = false; 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 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, 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.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; _dirty = false; _suppressUi = true; try { _classList.Items.Clear(); ClearSpecList(); ClearGrids(); if (string.IsNullOrWhiteSpace(_classDirectory) || !Directory.Exists(_classDirectory)) { _pathLabel.Text = "未找到 Fuyutsui\\class"; _statusLabel.Text = "请先打开游戏窗口,或确认已安装 Fuyutsui 插件后点击刷新。"; 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; _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 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 { // 丢弃失败时保留内存副本,避免阻断切换。 } } _dirty = 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/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); } 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 = _stateCategoryBox.SelectedItem as string ?? ClassStateCatalog.CategoryState; _lastAuraBucket = (_auraBucketBox.SelectedItem as BucketOption)?.Key ?? "player"; FillStatesGrid(); FillAurasGrid(); FillSpellsGrid(); FillGroupEditors(); } finally { _suppressUi = false; } } private void FillStatesGrid() { _statesGrid.Rows.Clear(); if (_currentSpec is null) { return; } var category = _stateCategoryBox.SelectedItem as string ?? ClassStateCatalog.CategoryState; 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 ReloadStatesGrid() { _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 HandleStateEditingControlShowing(object? sender, DataGridViewEditingControlShowingEventArgs e) { if (_statesGrid.CurrentCell?.OwningColumn?.Name != "Name") { return; } if (e.Control is not ComboBox combo) { return; } var category = _stateCategoryBox.SelectedItem as string ?? ClassStateCatalog.CategoryState; var current = _statesGrid.CurrentCell.Value?.ToString()?.Trim(); var currentRowIndex = _statesGrid.CurrentCell.RowIndex; var usedNames = GetUsedStateNames(category, currentRowIndex); 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)); } combo.DropDownStyle = ComboBoxStyle.DropDownList; combo.FlatStyle = FlatStyle.Flat; combo.BackColor = UiTheme.Field; combo.ForeColor = UiTheme.Text; combo.DataSource = null; combo.DisplayMember = nameof(ClassStateCatalog.StateOption.Display); combo.ValueMember = nameof(ClassStateCatalog.StateOption.Name); combo.DataSource = options; if (!string.IsNullOrWhiteSpace(current)) { combo.SelectedValue = current; } } 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()) { _aurasGrid.Rows.Add( aura.Name, aura.SpellId?.ToString(CultureInfo.InvariantCulture) ?? "", string.Join(", ", aura.SpellIds), aura.MaxApps?.ToString(CultureInfo.InvariantCulture) ?? "", "×"); } } private void FillSpellsGrid() { _spellsGrid.Rows.Clear(); if (_currentSpec is null) { return; } foreach (var spell in _currentSpec.Spells) { _spellsGrid.Rows.Add( spell.Name, spell.SpellId.ToString(CultureInfo.InvariantCulture), spell.Charge, spell.MaxCharge?.ToString(CultureInfo.InvariantCulture) ?? "", spell.CastCount?.ToString(CultureInfo.InvariantCulture) ?? "", spell.ForcedKnown, spell.InSpellBook, "×"); } } private void FillGroupEditors() { _groupAurasGrid.Rows.Clear(); if (_currentSpec?.Group is { } group) { _groupEnabledBox.Checked = true; _groupNumBox.Value = Clamp(_groupNumBox, group.Num); _groupHealthBox.Value = Clamp(_groupHealthBox, group.HealthPercent ?? 1); _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) { _groupAurasGrid.Rows.Add( aura.Offset.ToString(CultureInfo.InvariantCulture), aura.Name, aura.SpellId?.ToString(CultureInfo.InvariantCulture) ?? "", string.Join(", ", aura.SpellIds), "×"); } } else { _groupEnabledBox.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; _groupHealthBox.Enabled = enabled; _groupRoleBox.Enabled = enabled; _groupHasDispelBox.Enabled = enabled; _groupDispelBox.Enabled = enabled && _groupHasDispelBox.Checked; _groupAurasGrid.Enabled = enabled; _groupAurasGrid.ReadOnly = !enabled; } private List GetCurrentAuraList() => ResolveAuraList((_auraBucketBox.SelectedItem as BucketOption)?.Key ?? "player"); 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; } NormalizeFixedStateNames(_currentSpec); WriteBackStatesCategory(_lastStateCategory); WriteBackAuras(_lastAuraBucket); WriteBackSpells(); WriteBackGroup(); } 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 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 = (int)_groupHealthBox.Value, Role = (int)_groupRoleBox.Value, 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; } try { // 切换分类前把当前状态表写回。 CommitCurrentSpecFromUi(); ClassBlocksStore.Save(_currentDocument); _dirty = false; _statusLabel.Text = "已写入 Lua,正在更新配置…"; await _updateConfigAsync(); _statusLabel.Text = "已保存并更新配置"; } catch (Exception ex) { MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); _statusLabel.Text = $"保存失败: {ex.Message}"; } } private void MarkDirty() { if (_suppressUi) { return; } _dirty = true; if (_statusLabel.Text != "已修改(未保存)") { _statusLabel.Text = "已修改(未保存)"; } } 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(); _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 MoveSelectedRow(DataGridView grid, int delta) { if (grid.CurrentRow is null || grid.CurrentRow.IsNewRow) { return; } var index = grid.CurrentRow.Index; var target = index + 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 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; } private sealed record BucketOption(string Key, string Text) { public override string ToString() => Text; } }