using System.Drawing;
using System.Globalization;
namespace Shigure;
///
/// 图形化编辑 Fuyutsui core/classmacros.lua 的 ClassMacros
/// (dynamicSpells / staticSpells / specialSpells)。
///
public sealed class ClassMacrosEditorControl : UserControl
{
private readonly Func _resolveClassMacrosPath;
private readonly Func> _updateConfigAsync;
private readonly ListBox _classList = new();
private readonly Label _pathLabel = new();
private readonly Label _statusLabel = new();
private readonly ToolTip _toolTip = new();
private readonly Label _offsetLabel = new();
private readonly Button _reloadButton;
private readonly Button _saveButton;
private readonly ListBox _dynamicSpecList = new();
private readonly DataGridView _dynamicGrid = new();
private readonly DataGridView _staticGrid = new();
private readonly DataGridView _specialGrid = new();
private IReadOnlyDictionary _macroBodies =
new Dictionary(StringComparer.Ordinal);
private readonly Dictionary> _classSpellIdsByName = new(StringComparer.Ordinal);
private readonly Dictionary> _classItemIdsByName = new(StringComparer.Ordinal);
private ClassMacrosStore.MacrosDocument? _document;
private ClassMacrosStore.ClassMacros? _currentMacros;
private string? _currentClassFile;
private int? _currentClassId;
private int? _currentDynamicSpecIndex;
private bool _suppressUi;
private bool _updatingDerivedColumns;
private bool _dirty;
internal event Action? DirtyStateChanged;
internal bool HasUnsavedChanges => _dirty;
public ClassMacrosEditorControl(
Func resolveClassMacrosPath,
Func> updateConfigAsync)
{
_resolveClassMacrosPath = resolveClassMacrosPath;
_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)
{
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 = 2,
RowCount = 1,
Margin = new Padding(0)
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 146));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
Controls.Add(root);
root.Controls.Add(BuildSidebar(), 0, 0);
root.Controls.Add(BuildEditor(), 1, 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)
{
SelectClassFromList();
}
};
panel.Controls.Add(_classList, 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 = 3,
Margin = new Padding(0)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 36));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 80));
_offsetLabel.Dock = DockStyle.Fill;
_offsetLabel.AutoSize = false;
_offsetLabel.ForeColor = UiTheme.Muted;
_offsetLabel.BackColor = Color.Transparent;
_offsetLabel.TextAlign = ContentAlignment.MiddleLeft;
_offsetLabel.Padding = new Padding(14, 0, 14, 0);
_offsetLabel.Margin = new Padding(0);
_offsetLabel.AutoEllipsis = true;
_offsetLabel.Text = "创建顺序:动态宏(每项 30 槽)→ 静态宏 → 特殊宏;空字符串保留槽位";
root.Controls.Add(_offsetLabel, 0, 0);
root.Controls.Add(BuildSectionTabs(), 0, 1);
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, 2);
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\\core\\classmacros.lua";
_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 = 3,
RowCount = 1,
Margin = new Padding(0, 0, 0, 8),
Padding = new Padding(0)
};
for (var i = 0; i < 3; i++)
{
tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33f));
}
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[]
{
BuildDynamicPage(),
BuildArrayPage(_staticGrid, "class-macros-static", "完整宏", "注释", showParsedMacro: true),
BuildArrayPage(
_specialGrid,
"class-macros-special",
"完整宏",
"注释",
showParsedMacro: false,
editSpecialSpell: true)
};
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[3];
var selectedIndex = -1;
void SelectTab(int index)
{
if (selectedIndex == index)
{
return;
}
selectedIndex = 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 BuildDynamicPage()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 2,
RowCount = 1,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0)
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 184));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
root.Controls.Add(BuildDynamicSpecSidebar(), 0, 0);
var editor = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0)
};
editor.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
editor.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
ConfigureGrid(_dynamicGrid, "class-macros-dynamic");
_dynamicGrid.Columns.Add(CreateMacroIconColumn());
_dynamicGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "法术名(每项占 30 个团队点名槽)",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
_dynamicGrid.Columns.Add(CreateDeleteColumn());
WireGrid(_dynamicGrid);
editor.Controls.Add(_dynamicGrid, 0, 0);
editor.Controls.Add(BuildMoveButtons(_dynamicGrid), 0, 1);
root.Controls.Add(editor, 1, 0);
return root;
}
private Control BuildDynamicSpecSidebar()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(0, 0, 12, 0),
Margin = new Padding(0)
};
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 36));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.Controls.Add(new Label
{
Text = "专精",
Dock = DockStyle.Fill,
ForeColor = UiTheme.Muted,
TextAlign = ContentAlignment.MiddleLeft,
Padding = new Padding(10, 0, 0, 0),
Margin = new Padding(0)
}, 0, 0);
_dynamicSpecList.Dock = DockStyle.Fill;
UiTheme.StyleListBox(
_dynamicSpecList,
Font,
index => index >= 0 && index < _dynamicSpecList.Items.Count
&& _dynamicSpecList.Items[index] is DynamicSpecOption { ClassId: { } classId } option
? (classId, option.SpecIndex)
: (null, null),
showClassIconWithSpec: false,
logicalIconSize: 40);
_dynamicSpecList.SelectedIndexChanged += (_, _) =>
{
if (!_suppressUi)
{
SelectDynamicSpecFromList();
}
};
panel.Controls.Add(_dynamicSpecList, 0, 1);
return panel;
}
private Control BuildArrayPage(
DataGridView grid,
string cacheKey,
string textHeader,
string commentHeader,
bool showParsedMacro,
bool editSpecialSpell = false)
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
ConfigureGrid(grid, cacheKey);
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Index",
HeaderText = "顺序",
Width = 72,
ReadOnly = true
});
grid.Columns.Add(CreateMacroIconColumn());
if (showParsedMacro)
{
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Unit",
HeaderText = "单位",
Width = 72,
ReadOnly = true
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Condition",
HeaderText = "条件",
Width = 180,
ReadOnly = true
});
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Spell",
HeaderText = "技能",
Width = 180,
ReadOnly = true
});
}
else if (editSpecialSpell)
{
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Spell",
HeaderText = "技能",
Width = 180
});
}
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Text",
HeaderText = textHeader,
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
if (!editSpecialSpell)
{
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Comment", HeaderText = commentHeader, Width = 180 });
}
grid.Columns.Add(CreateDeleteColumn());
WireGrid(grid);
panel.Controls.Add(grid, 0, 0);
panel.Controls.Add(BuildMoveButtons(grid), 0, 1);
return panel;
}
private void WireGrid(DataGridView grid)
{
grid.CellContentClick += HandleDeleteClick;
grid.CellFormatting += (_, e) => FormatMacroLineBreaks(grid, e);
grid.CellParsing += (_, e) => ParseMacroLineBreaks(grid, e);
grid.CellValueChanged += (_, e) =>
{
if (_updatingDerivedColumns)
{
return;
}
if (grid == _staticGrid
&& e.RowIndex >= 0
&& e.ColumnIndex >= 0
&& grid.Columns[e.ColumnIndex].Name is "Text" or "Comment")
{
UpdateMacroDisplay(grid, grid.Rows[e.RowIndex]);
}
if (e.RowIndex >= 0 && e.RowIndex < grid.Rows.Count)
{
UpdateMacroIcon(grid, grid.Rows[e.RowIndex]);
}
MarkDirty();
UpdateOffsetHint();
};
grid.UserAddedRow += (_, _) =>
{
RenumberArrayRows(grid);
MarkDirty();
UpdateOffsetHint();
};
grid.RowsRemoved += (_, _) =>
{
if (!_suppressUi)
{
RenumberArrayRows(grid);
MarkDirty();
UpdateOffsetHint();
}
};
}
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;
}
// 宏文本底层保留真实换行(供解析及保存),表格中以字面量 \n 单行显示。
private void FormatMacroLineBreaks(DataGridView grid, DataGridViewCellFormattingEventArgs e)
{
if ((grid != _staticGrid && grid != _specialGrid)
|| e.RowIndex < 0
|| e.ColumnIndex < 0
|| grid.Columns[e.ColumnIndex].Name != "Text"
|| e.Value is not string text)
{
return;
}
e.Value = text
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Replace("\n", "\\n", StringComparison.Ordinal);
e.FormattingApplied = true;
}
private void ParseMacroLineBreaks(DataGridView grid, DataGridViewCellParsingEventArgs e)
{
if ((grid != _staticGrid && grid != _specialGrid)
|| e.RowIndex < 0
|| e.ColumnIndex < 0
|| grid.Columns[e.ColumnIndex].Name != "Text"
|| e.Value is not string text)
{
return;
}
e.Value = text.Replace("\\n", "\n", StringComparison.Ordinal);
e.ParsingApplied = true;
}
private static DataGridViewButtonColumn CreateDeleteColumn()
=> new()
{
Name = "Delete",
HeaderText = "",
Text = "×",
UseColumnTextForButtonValue = true,
Width = 44
};
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;
}
_document = null;
_currentMacros = null;
_currentClassFile = null;
_currentClassId = null;
_currentDynamicSpecIndex = null;
SetDirty(false);
_suppressUi = true;
try
{
_classList.Items.Clear();
ClearGrids();
var path = _resolveClassMacrosPath();
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
_pathLabel.Text = "未找到 core\\classmacros.lua";
_statusLabel.Text = "请确认程序目录中包含 Fuyutsui\\core\\classmacros.lua 后点击刷新。";
UpdateOffsetHint();
return;
}
try
{
_document = ClassMacrosStore.Load(path);
_macroBodies = ClassMacrosStore.LoadMacroBodies(path);
}
catch (Exception ex)
{
_pathLabel.Text = path;
_statusLabel.Text = $"加载失败: {ex.Message}";
return;
}
_pathLabel.Text = path;
foreach (var (classId, className) in ClassNames.GetClasses())
{
var classFile = ClassMacrosStore.ToClassFileKey(classId);
var has = _document.Classes.ContainsKey(classFile);
_classList.Items.Add(new ClassListItem(classId, className, classFile, has));
}
// 文件中有但 ClassNames 未覆盖的键
foreach (var classFile in _document.ClassOrder)
{
if (_classList.Items.Cast().Any(x =>
x.ClassFile.Equals(classFile, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
_classList.Items.Add(new ClassListItem(0, classFile, classFile, true));
}
_statusLabel.Text = $"已加载 {_document.Classes.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 && _currentClassFile is not null
&& !_currentClassFile.Equals(item.ClassFile, StringComparison.OrdinalIgnoreCase)
&& !ConfirmDiscard())
{
_suppressUi = true;
try
{
SelectClassInList(_currentClassFile);
}
finally
{
_suppressUi = false;
}
return;
}
var switching = _currentClassFile is not null
&& !_currentClassFile.Equals(item.ClassFile, StringComparison.OrdinalIgnoreCase);
if (_dirty && switching)
{
// 丢弃:从源文本重新解析当前职业
if (_document is not null)
{
try
{
var reloaded = ClassMacrosStore.Load(_document.FilePath);
_document = reloaded;
}
catch
{
// ignore
}
}
SetDirty(false);
}
else if (switching)
{
CommitCurrentFromUi();
}
_currentClassFile = item.ClassFile;
_currentClassId = item.ClassId > 0 ? item.ClassId : null;
_currentDynamicSpecIndex = null;
ReloadClassIconAliases();
if (_document is null)
{
_currentMacros = null;
ClearGrids();
UpdateOffsetHint();
return;
}
if (!_document.Classes.TryGetValue(item.ClassFile, out var macros))
{
macros = new ClassMacrosStore.ClassMacros();
_document.Classes[item.ClassFile] = macros;
if (!_document.ClassOrder.Contains(item.ClassFile, StringComparer.OrdinalIgnoreCase))
{
_document.ClassOrder.Add(item.ClassFile);
}
}
_currentMacros = macros;
_statusLabel.Text = _dirty ? "已修改(未保存)" : (item.HasData ? "可编辑" : "新建空表(保存后写入)");
FillAllEditors();
}
private void FillAllEditors()
{
_suppressUi = true;
try
{
_dynamicGrid.Rows.Clear();
_staticGrid.Rows.Clear();
_specialGrid.Rows.Clear();
_dynamicSpecList.Items.Clear();
_currentDynamicSpecIndex = null;
if (_currentMacros is null)
{
return;
}
RebuildDynamicSpecList();
AddArrayRows(_staticGrid, _currentMacros.StaticSpells);
AddArrayRows(_specialGrid, _currentMacros.SpecialSpells);
if (_dynamicSpecList.Items.Count > 0)
{
_dynamicSpecList.SelectedIndex = 0;
}
FillDynamicEditor();
}
finally
{
_suppressUi = false;
}
UpdateOffsetHint();
}
private void RebuildDynamicSpecList()
{
_dynamicSpecList.Items.Clear();
_dynamicSpecList.Items.Add(new DynamicSpecOption(_currentClassId, null, "通用"));
var knownSpecIndexes = new HashSet();
if (_currentClassId is { } classId)
{
foreach (var spec in ClassNames.GetSpecs(classId))
{
knownSpecIndexes.Add(spec.Id);
_dynamicSpecList.Items.Add(new DynamicSpecOption(classId, spec.Id, spec.Name));
}
}
if (_currentMacros is null)
{
return;
}
foreach (var specIndex in _currentMacros.DynamicBySpec.Keys.OrderBy(index => index))
{
if (knownSpecIndexes.Add(specIndex))
{
_dynamicSpecList.Items.Add(
new DynamicSpecOption(_currentClassId, specIndex, $"专精{specIndex}"));
}
}
}
private void SelectDynamicSpecFromList()
{
if (_currentMacros is null || _dynamicSpecList.SelectedItem is not DynamicSpecOption option)
{
return;
}
CommitCurrentDynamicFromUi();
_currentDynamicSpecIndex = option.SpecIndex;
ReloadClassIconAliases();
FillDynamicEditor();
RefreshAllMacroIcons();
UpdateOffsetHint();
}
private void FillDynamicEditor()
{
var wasSuppressing = _suppressUi;
_suppressUi = true;
try
{
_dynamicGrid.Rows.Clear();
if (_currentMacros is null)
{
return;
}
IReadOnlyList spells = _currentDynamicSpecIndex is { } specIndex
? _currentMacros.DynamicBySpec.GetValueOrDefault(specIndex) ?? []
: _currentMacros.DynamicCommon;
foreach (var name in spells)
{
var rowIndex = _dynamicGrid.Rows.Add(ResolveNamedMacroIcon(name)!, name, "×");
UpdateMacroIcon(_dynamicGrid, _dynamicGrid.Rows[rowIndex]);
}
}
finally
{
_suppressUi = wasSuppressing;
}
}
private void UpdateOffsetHint()
{
var commonCount = 0;
var specCount = 0;
var staticCount = 0;
var specialCount = 0;
if (_currentMacros is not null)
{
// 数组中的空字符串同样占槽,所以按实际行数计算。
staticCount = _staticGrid.Rows.Cast().Count(r => !r.IsNewRow);
specialCount = _specialGrid.Rows.Cast().Count(r => !r.IsNewRow);
var visibleDynamicCount = _dynamicGrid.Rows.Cast().Count(row => !row.IsNewRow);
if (_currentDynamicSpecIndex is { } specIndex)
{
commonCount = _currentMacros.DynamicCommon.Count;
specCount = visibleDynamicCount;
}
else
{
commonCount = visibleDynamicCount;
}
}
var dynamicCount = commonCount + specCount;
var dynamicSlots = dynamicCount * 30;
var totalSlots = dynamicSlots + staticCount + specialCount;
var scopeText = _currentDynamicSpecIndex is null
? $"通用 {commonCount} 项"
: $"{GetCurrentDynamicSpecName()}:通用 {commonCount} + 专精 {specCount},共 {dynamicCount} 项";
_offsetLabel.Text =
$"{scopeText};动态宏 {dynamicSlots} 个({dynamicCount} 项 × 30);静态宏 {staticCount} 个;特殊宏 {specialCount} 个;" +
$"共 {totalSlots} 个;最多 {FuyutsuiKeymapConverter.MacroSlotCapacity} 个";
}
private string GetCurrentDynamicSpecName()
=> _dynamicSpecList.SelectedItem is DynamicSpecOption option ? option.Name : "当前专精";
private void CommitCurrentFromUi()
{
if (_currentMacros is null)
{
return;
}
CommitCurrentDynamicFromUi();
WriteArrayGrid(_staticGrid, _currentMacros.StaticSpells);
WriteArrayGrid(_specialGrid, _currentMacros.SpecialSpells);
}
private void CommitCurrentDynamicFromUi()
{
if (_currentMacros is null)
{
return;
}
var values = new List();
foreach (DataGridViewRow row in _dynamicGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
values.Add(row.Cells["Name"].Value?.ToString()?.Trim() ?? string.Empty);
}
if (_currentDynamicSpecIndex is not { } specIndex)
{
_currentMacros.DynamicCommon.Clear();
_currentMacros.DynamicCommon.AddRange(values);
return;
}
if (values.Count > 0 || _currentMacros.DynamicBySpec.ContainsKey(specIndex))
{
_currentMacros.UsesSpecDynamicSpells = true;
_currentMacros.DynamicBySpec[specIndex] = values;
}
}
private static void WriteArrayGrid(DataGridView grid, List target)
{
target.Clear();
foreach (DataGridViewRow row in grid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var text = row.Cells["Text"].Value?.ToString() ?? "";
var metadataColumn = grid.Columns.Contains("Comment") ? "Comment" : "Spell";
var comment = row.Cells[metadataColumn].Value?.ToString()?.Trim();
target.Add(new ClassMacrosStore.ArrayEntry
{
Text = text.Replace("\r\n", "\n", StringComparison.Ordinal),
Comment = string.IsNullOrWhiteSpace(comment) ? null : comment
});
}
}
private async Task SaveAndUpdateAsync()
{
if (_document is null)
{
MessageBox.Show("请先刷新并加载 classmacros.lua。", "宏", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (_currentClassId is not { } classId)
{
MessageBox.Show("请先选择职业。", "宏", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var localSaved = false;
try
{
CommitCurrentFromUi();
ClassMacrosStore.Save(_document);
localSaved = true;
SetDirty(false);
_statusLabel.Text = "本地 Lua 已保存,正在更新配置并同步游戏…";
var updateResult = await _updateConfigAsync(_document.FilePath, classId);
if (IsDisposed)
{
return;
}
if (!string.IsNullOrWhiteSpace(updateResult.AddonSyncIssue))
{
_statusLabel.Text = "本地已保存并更新配置,但游戏同步失败";
MessageBox.Show(
$"本地 Lua、该职业的 {updateResult.SavedModuleCount} 个模块及 config/keymap 已保存,"
+ $"但游戏插件同步未完成:\n{updateResult.AddonSyncIssue}",
"游戏同步失败",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
UpdateOffsetHint();
return;
}
_statusLabel.Text = $"已保存宏及该职业的 {updateResult.SavedModuleCount} 个模块";
UpdateOffsetHint();
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(string? classFile)
{
for (var i = 0; i < _classList.Items.Count; i++)
{
if (_classList.Items[i] is ClassListItem item
&& item.ClassFile.Equals(classFile, StringComparison.OrdinalIgnoreCase))
{
_classList.SelectedIndex = i;
return;
}
}
}
private void ClearGrids()
{
_dynamicSpecList.Items.Clear();
_dynamicGrid.Rows.Clear();
_staticGrid.Rows.Clear();
_specialGrid.Rows.Clear();
_currentDynamicSpecIndex = null;
}
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);
RenumberArrayRows(grid);
MarkDirty();
UpdateOffsetHint();
}
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];
RenumberArrayRows(grid);
MarkDirty();
UpdateOffsetHint();
}
private void AddArrayRows(DataGridView grid, List entries)
{
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var rowIndex = grid.Rows.Add();
var row = grid.Rows[rowIndex];
row.Cells["Index"].Value = (i + 1).ToString(CultureInfo.InvariantCulture);
row.Cells["Text"].Value = entry.Text;
if (grid == _specialGrid)
{
row.Cells["Spell"].Value = entry.Comment ?? "";
}
else
{
row.Cells["Comment"].Value = entry.Comment ?? "";
}
row.Cells["Delete"].Value = "×";
if (grid == _staticGrid)
{
UpdateMacroDisplay(grid, row);
}
UpdateMacroIcon(grid, row);
}
}
private void UpdateMacroDisplay(DataGridView grid, DataGridViewRow row)
{
if (!grid.Columns.Contains("Unit")
|| !grid.Columns.Contains("Spell")
|| !grid.Columns.Contains("Condition")
|| row.IsNewRow)
{
return;
}
var text = row.Cells["Text"].Value?.ToString() ?? "";
var comment = row.Cells["Comment"].Value?.ToString();
var parsed = FuyutsuiKeymapConverter.ParseStaticMacro(text, comment);
_updatingDerivedColumns = true;
try
{
row.Cells["Unit"].Value = parsed.Unit.ToString(CultureInfo.InvariantCulture);
row.Cells["Spell"].Value = parsed.Spell;
row.Cells["Condition"].Value = MacroConditionText.ToDisplayText(parsed.Condition);
UpdateMacroIcon(grid, row);
}
finally
{
_updatingDerivedColumns = false;
}
}
private void OnSpellIconCatalogChanged()
{
if (IsDisposed || Disposing || !IsHandleCreated)
{
return;
}
if (InvokeRequired)
{
BeginInvoke(OnSpellIconCatalogChanged);
return;
}
ReloadClassIconAliases();
RefreshAllMacroIcons();
}
private void RefreshAllMacroIcons()
{
foreach (var grid in new[] { _dynamicGrid, _staticGrid, _specialGrid })
{
foreach (DataGridViewRow row in grid.Rows)
{
if (!row.IsNewRow)
{
UpdateMacroIcon(grid, row);
}
}
grid.Invalidate();
}
}
private void ReloadClassIconAliases()
{
_classSpellIdsByName.Clear();
_classItemIdsByName.Clear();
if (_currentClassId is not { } classId || classId <= 0)
{
return;
}
var macrosPath = _resolveClassMacrosPath();
if (string.IsNullOrWhiteSpace(macrosPath))
{
return;
}
var classDirectory = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(macrosPath)!, "..", "class"));
var classPath = Path.Combine(classDirectory, $"{ClassNames.GetConfigFileName(classId)}.lua");
if (!File.Exists(classPath))
{
return;
}
try
{
var document = ClassBlocksStore.Load(classPath);
foreach (var spell in document.SpellsList)
{
if (spell.SpellId <= 0 || string.IsNullOrWhiteSpace(spell.Name))
{
continue;
}
var name = spell.Name.Trim();
AddNamedId(_classSpellIdsByName, name, spell.SpellId);
SpellIconCatalog.Register(spell.SpellId, name, overwriteIdName: true);
}
foreach (var spec in OrderedSpecsForIcons(document))
{
foreach (var item in spec.Items)
{
if (item.ItemId is not { } itemId || itemId <= 0 || string.IsNullOrWhiteSpace(item.Name))
{
continue;
}
var name = item.Name.Trim();
AddNamedId(_classItemIdsByName, name, itemId);
SpellIconCatalog.RegisterItem(itemId, name);
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or InvalidDataException or ArgumentException)
{
// 当前职业配置缺失时仍可用数据包按名称匹配。
}
}
private IEnumerable OrderedSpecsForIcons(ClassBlocksStore.ClassFileDocument document)
{
if (_currentDynamicSpecIndex is { } specId && document.Specs.TryGetValue(specId, out var current))
{
yield return current;
foreach (var spec in document.Specs.Where(pair => pair.Key != specId).Select(pair => pair.Value))
{
yield return spec;
}
yield break;
}
foreach (var spec in document.Specs.Values)
{
yield return spec;
}
}
private static void AddNamedId(Dictionary> map, string name, long id)
{
if (!map.TryGetValue(name, out var ids))
{
ids = [];
map[name] = ids;
}
if (!ids.Contains(id))
{
ids.Add(id);
}
}
private void UpdateMacroIcon(DataGridView grid, DataGridViewRow row)
{
if (!grid.Columns.Contains("Icon") || row.IsNewRow)
{
return;
}
row.Cells["Icon"].Value = ResolveMacroItemIcon(grid, row);
}
private Image? ResolveMacroItemIcon(DataGridView grid, DataGridViewRow row)
{
if (grid.Columns.Contains("Spell"))
{
var icon = ResolveNamedMacroIcon(row.Cells["Spell"].Value?.ToString());
if (icon is not null)
{
return icon;
}
}
if (grid.Columns.Contains("Name"))
{
var icon = ResolveNamedMacroIcon(row.Cells["Name"].Value?.ToString());
if (icon is not null)
{
return icon;
}
}
if (grid.Columns.Contains("Text"))
{
return ResolveItemReferenceIcon(row.Cells["Text"].Value?.ToString());
}
return null;
}
private Image? ResolveNamedMacroIcon(string? name)
{
var normalized = name?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
var itemReferenceIcon = ResolveItemReferenceIcon(normalized);
if (itemReferenceIcon is not null)
{
return itemReferenceIcon;
}
if (_classSpellIdsByName.TryGetValue(normalized, out var spellIds))
{
foreach (var spellId in spellIds)
{
var icon = SpellIconCatalog.Get(spellId);
if (icon is not null)
{
return icon;
}
}
}
if (_classItemIdsByName.TryGetValue(normalized, out var itemIds))
{
foreach (var itemId in itemIds)
{
var icon = SpellIconCatalog.GetItem(itemId);
if (icon is not null)
{
return icon;
}
}
}
return SpellIconCatalog.Get(normalized) ?? SpellIconCatalog.GetItem(normalized);
}
private Image? ResolveItemReferenceIcon(string? text)
{
var normalized = text?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (SpellIconCatalog.TryParseItemReference(normalized, out var itemId))
{
return SpellIconCatalog.GetItem(itemId);
}
if (_macroBodies.TryGetValue(normalized, out var body)
&& SpellIconCatalog.TryParseItemReference(body, out itemId))
{
return SpellIconCatalog.GetItem(itemId);
}
return null;
}
private static DataGridViewImageColumn CreateMacroIconColumn()
=> 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 void RenumberArrayRows(DataGridView grid)
{
if (!grid.Columns.Contains("Index"))
{
return;
}
var index = 1;
foreach (DataGridViewRow row in grid.Rows)
{
if (!row.IsNewRow)
{
row.Cells["Index"].Value = index++.ToString(CultureInfo.InvariantCulture);
}
}
}
private sealed record DynamicSpecOption(int? ClassId, int? SpecIndex, string Name)
{
public override string ToString() => Name;
}
private sealed record ClassListItem(int ClassId, string Name, string ClassFile, bool HasData)
{
public override string ToString()
=> HasData ? Name : $"{Name}(无)";
}
}