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;
}
}