diff --git a/Infrastructure/FuyutsuiConfigConverter.cs b/Infrastructure/FuyutsuiConfigConverter.cs
index 738bd5dc..1d916723 100644
--- a/Infrastructure/FuyutsuiConfigConverter.cs
+++ b/Infrastructure/FuyutsuiConfigConverter.cs
@@ -479,6 +479,6 @@ internal static class FuyutsuiConfigConverter
private static string NormalizeStateName(string name)
=> string.Equals(name, "法术失败", StringComparison.Ordinal)
- ? ModuleSpecialActions.FailedSpell
+ ? ModuleSpecialActions.InsertSpellState
: name;
}
diff --git a/Infrastructure/FuyutsuiKeymapConverter.cs b/Infrastructure/FuyutsuiKeymapConverter.cs
index 0bf76133..a0275020 100644
--- a/Infrastructure/FuyutsuiKeymapConverter.cs
+++ b/Infrastructure/FuyutsuiKeymapConverter.cs
@@ -134,9 +134,11 @@ internal static partial class FuyutsuiKeymapConverter
{
var relativeIndex = i - dynamicSlots - 1;
MacroEntry? entry = null;
+ var isStaticEntry = false;
if (relativeIndex < staticSpells.Count)
{
entry = staticSpells[relativeIndex];
+ isStaticEntry = true;
}
else
{
@@ -149,7 +151,18 @@ internal static partial class FuyutsuiKeymapConverter
if (entry is { Body.Length: > 0 } macroEntry)
{
- spell = ResolveSpellName(macroEntry);
+ if (isStaticEntry)
+ {
+ var parsed = ParseStaticMacro(macroEntry.Body, macroEntry.Comment);
+ unit = parsed.Unit;
+ spell = parsed.Spell;
+ }
+ else
+ {
+ var parsed = ParseSpecialMacro(macroEntry.Body, macroEntry.Comment);
+ unit = parsed.Unit;
+ spell = parsed.Spell;
+ }
}
if (entry is { Body.Length: > 0 }
@@ -184,6 +197,56 @@ internal static partial class FuyutsuiKeymapConverter
return spell.StartsWith("item:", StringComparison.OrdinalIgnoreCase);
}
+ internal readonly record struct ParsedMacro(int Unit, string Spell);
+
+ ///
+ /// 解析静态宏供 keymap 与宏列表共用。玩家、当前目标、焦点、地面、鼠标指向分别使用保留单位 31-35。
+ ///
+ internal static ParsedMacro ParseStaticMacro(string raw, string? comment = null)
+ {
+ var target = StaticTargetRegex().Match(raw);
+ var unit = target.Success
+ ? ResolveUnitName(target.Groups["unit"].Value)
+ : ReservedUnit.None;
+
+ return new ParsedMacro(unit, ResolveSpellName(new MacroEntry(raw, comment)));
+ }
+
+ ///
+ /// 解析特殊宏:方括号中的首个单位作为 unit,技能沿用宏技能推导(castsequence 只取逗号前首项)。
+ ///
+ internal static ParsedMacro ParseSpecialMacro(string raw, string? comment = null)
+ {
+ // 标准 WoW 条件允许单位出现在其它条件之后,例如 [known:123,@cursor]。
+ var target = StaticTargetRegex().Match(raw);
+ var unit = target.Success
+ ? ResolveUnitName(target.Groups["unit"].Value)
+ : ReservedUnit.None;
+
+ // 特殊宏也接受 [player]/[玩家]/[31] 这种“方括号内直接写单位”的简写。
+ if (unit == ReservedUnit.None && SpecialUnitRegex().Match(raw) is { Success: true } specialUnit)
+ {
+ unit = ResolveUnitName(specialUnit.Groups["unit"].Value);
+ }
+
+ var spell = ResolveSpellName(new MacroEntry(raw, comment));
+ spell = spell.Split(',', 2, StringSplitOptions.TrimEntries)[0];
+ return new ParsedMacro(unit, spell);
+ }
+
+ private static int ResolveUnitName(string raw)
+ {
+ return raw.Trim().TrimStart('@').ToLowerInvariant() switch
+ {
+ "player" or "玩家" or "31" => ReservedUnit.Player,
+ "target" or "目标" or "32" => ReservedUnit.Target,
+ "focus" or "焦点" or "33" => ReservedUnit.Focus,
+ "cursor" or "地面" or "34" => ReservedUnit.Cursor,
+ "mouseover" or "鼠标" or "35" => ReservedUnit.Mouseover,
+ _ => ReservedUnit.None
+ };
+ }
+
/// 同行 `--` 注释优先作为技能名;否则从宏文本推导。
private static string ResolveSpellName(MacroEntry entry)
{
@@ -257,7 +320,6 @@ internal static partial class FuyutsuiKeymapConverter
// 取 ; 分支中第一段(专精/条件分支)
var firstBranch = text.Split(';', 2, StringSplitOptions.TrimEntries)[0];
- var hasFocus = FocusTargetRegex().IsMatch(firstBranch) || FocusTargetRegex().IsMatch(text);
var spell = StripConditions(firstBranch);
if (string.IsNullOrWhiteSpace(spell))
@@ -265,11 +327,6 @@ internal static partial class FuyutsuiKeymapConverter
return string.Empty;
}
- if (hasFocus && !spell.EndsWith("(焦点)", StringComparison.Ordinal))
- {
- return spell + "(焦点)";
- }
-
return spell;
}
@@ -390,6 +447,9 @@ internal static partial class FuyutsuiKeymapConverter
[GeneratedRegex(@"\[[^\]]*\]", RegexOptions.CultureInvariant)]
private static partial Regex ConditionRegex();
- [GeneratedRegex(@"@focus\b|target\s*=\s*focus\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
- private static partial Regex FocusTargetRegex();
+ [GeneratedRegex(@"\[[^\]]*@(?cursor|target|focus|player|mouseover)\b[^\]]*\]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex StaticTargetRegex();
+
+ [GeneratedRegex(@"\[\s*@?(?player|target|focus|cursor|mouseover|玩家|目标|焦点|地面|鼠标|无目标|0|31|32|33|34|35)\s*(?:,|\])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex SpecialUnitRegex();
}
diff --git a/Modules/ConditionFieldCatalog.cs b/Modules/ConditionFieldCatalog.cs
index 0a118457..82e89bdb 100644
--- a/Modules/ConditionFieldCatalog.cs
+++ b/Modules/ConditionFieldCatalog.cs
@@ -77,8 +77,7 @@ public sealed class ConditionFieldCatalog
foreach (var (key, node) in stateConfig)
{
if (key is "group" or "spells" or "auras"
- || key == "锚点"
- || ModuleSpecialActions.IsFailedSpell(key))
+ || key == "锚点")
{
continue;
}
@@ -111,7 +110,14 @@ public sealed class ConditionFieldCatalog
}
}
- AddField(fields, seen, ModuleSpecialActions.FailedSpell, ModuleSpecialActions.FailedSpell, ConditionFieldType.String, ConditionFieldCategory.State);
+ // 原始“插入法术”按 config 中的 int 状态保留;转换为技能名的特殊字段单独置底。
+ AddField(
+ fields,
+ seen,
+ ModuleSpecialActions.FailedSpell,
+ ModuleSpecialActions.FailedSpell,
+ ConditionFieldType.String,
+ ConditionFieldCategory.State);
return fields;
}
diff --git a/Modules/ModuleSpecialActions.cs b/Modules/ModuleSpecialActions.cs
index 929a05f3..99c458f2 100644
--- a/Modules/ModuleSpecialActions.cs
+++ b/Modules/ModuleSpecialActions.cs
@@ -5,7 +5,8 @@ namespace Shigure;
internal static class ModuleSpecialActions
{
public const string PauseSpell = "暂停";
- public const string FailedSpell = "插入法术";
+ public const string InsertSpellState = "插入法术";
+ public const string FailedSpell = "自动插入法术";
public const string OneKeySpell = "一键法术";
public static bool IsPauseSpell(string? spell)
@@ -18,6 +19,15 @@ internal static class ModuleSpecialActions
return string.Equals(spell?.Trim(), FailedSpell, StringComparison.Ordinal);
}
+ /// 旧模块动作中的“插入法术”迁移为新的特殊动作名,条件字段不在此处改写。
+ public static string NormalizeSpellAction(string? spell)
+ {
+ var normalized = spell?.Trim() ?? string.Empty;
+ return string.Equals(normalized, InsertSpellState, StringComparison.Ordinal)
+ ? FailedSpell
+ : normalized;
+ }
+
public static bool IsOneKeySpell(string? spell)
{
return string.Equals(spell?.Trim(), OneKeySpell, StringComparison.Ordinal);
@@ -25,7 +35,7 @@ internal static class ModuleSpecialActions
public static string? GetFailedSpell(GameState state, IReadOnlyDictionary? failedSpellMap)
{
- var failedSpellId = state.GetInt(FailedSpell);
+ var failedSpellId = state.GetInt(InsertSpellState);
if (failedSpellMap is null || !failedSpellMap.TryGetValue(failedSpellId, out var spellName))
{
return null;
diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs
index ba0bcbd9..68b12415 100644
--- a/Modules/ModuleStore.cs
+++ b/Modules/ModuleStore.cs
@@ -8,12 +8,16 @@ namespace Shigure;
public sealed class ModuleDefinition
{
+ internal const int CurrentUnitMappingVersion = 2;
+
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = "新模块";
public string Author { get; set; } = string.Empty;
public string RecommendedTalent { get; set; } = string.Empty;
// 保存时写入当时的 Shigure 版本(AppInfo.Version)。
public string Version { get; set; } = string.Empty;
+ // v2: 31=玩家、32=目标、33=焦点、34=地面、35=鼠标;旧模块 v1 的 31/34 含义相反。
+ public int? UnitMappingVersion { get; set; }
public bool Enabled { get; set; } = true;
public ModuleMatch Match { get; set; } = new();
public List Units { get; set; } = new();
@@ -33,6 +37,7 @@ public sealed class ModuleDefinition
Author = Author,
RecommendedTalent = RecommendedTalent,
Version = Version,
+ UnitMappingVersion = UnitMappingVersion,
Enabled = Enabled,
FilePath = FilePath,
Match = Match.Clone(),
@@ -49,6 +54,7 @@ public sealed class ModuleDefinition
{
Id = ModuleStore.CreateModuleId(name),
Name = name,
+ UnitMappingVersion = CurrentUnitMappingVersion,
Enabled = true,
Rules =
[
@@ -511,8 +517,24 @@ public sealed class ModuleStore
}
module.Rules ??= new List();
+ if (module.UnitMappingVersion.GetValueOrDefault() < ModuleDefinition.CurrentUnitMappingVersion)
+ {
+ foreach (var rule in module.Rules)
+ {
+ rule.Unit = rule.Unit switch
+ {
+ 31 => ReservedUnit.Cursor,
+ 34 => ReservedUnit.Player,
+ _ => rule.Unit
+ };
+ }
+
+ module.UnitMappingVersion = ModuleDefinition.CurrentUnitMappingVersion;
+ }
+
foreach (var rule in module.Rules)
{
+ rule.Spell = ModuleSpecialActions.NormalizeSpellAction(rule.Spell);
rule.DelayMs = rule.DelayMs is > 0 ? rule.DelayMs : null;
rule.LogicDelayMs = rule.LogicDelayMs is > 0 ? rule.LogicDelayMs : null;
if (rule.SubConditions is null)
diff --git a/Modules/ModuleUnit.cs b/Modules/ModuleUnit.cs
index 3d6b6f79..085350b3 100644
--- a/Modules/ModuleUnit.cs
+++ b/Modules/ModuleUnit.cs
@@ -30,7 +30,22 @@ public enum UnitSelectorKind
UnitWithAura,
/// 带某驱散类型的首个单位。get_unit_with_dispel_type
- UnitWithDispelType
+ UnitWithDispelType,
+
+ /// 治疗吸收高于阈值且治疗吸收最高的单位。
+ HighestHealingAbsorb,
+
+ /// 拥有任一光环、治疗吸收高于阈值且治疗吸收最高的单位。
+ HighestHealingAbsorbWithAnyAura,
+
+ /// 不带某光环、治疗吸收高于阈值且治疗吸收最高的单位。
+ HighestHealingAbsorbWithoutAura,
+
+ /// 带某光环、治疗吸收高于阈值且治疗吸收最高的单位。
+ HighestHealingAbsorbWithAura,
+
+ /// 某光环值等于指定值、治疗吸收高于阈值且治疗吸收最高的单位。
+ HighestHealingAbsorbWithAuraCount
}
///
@@ -86,7 +101,19 @@ public enum CountKind
UnitsWithoutAuraBelowHealth,
/// 拥有某光环的人数。count_units_with_aura
- UnitsWithAura
+ UnitsWithAura,
+
+ /// 拥有某光环且生命值低于阈值的人数。
+ UnitsWithAuraBelowHealth,
+
+ /// 治疗吸收大于阈值的人数。
+ UnitsAboveHealingAbsorb,
+
+ /// 不带某光环且治疗吸收大于阈值的人数。
+ UnitsWithoutAuraAboveHealingAbsorb,
+
+ /// 拥有某光环且治疗吸收大于阈值的人数。
+ UnitsWithAuraAboveHealingAbsorb
}
///
diff --git a/Modules/ReservedUnit.cs b/Modules/ReservedUnit.cs
new file mode 100644
index 00000000..4edc058b
--- /dev/null
+++ b/Modules/ReservedUnit.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+
+namespace Shigure;
+
+///
+/// keymap 中团队槽位 1-30 之外的保留单位,以及模块编辑器使用的中文显示名称。
+///
+internal static class ReservedUnit
+{
+ public const int None = 0;
+ public const int Player = 31;
+ public const int Target = 32;
+ public const int Focus = 33;
+ public const int Cursor = 34;
+ public const int Mouseover = 35;
+
+ public static string ToDisplayText(int unit)
+ {
+ return unit switch
+ {
+ None => "无目标",
+ Player => "玩家",
+ Target => "目标",
+ Focus => "焦点",
+ Cursor => "地面",
+ Mouseover => "鼠标",
+ _ => unit.ToString(CultureInfo.InvariantCulture)
+ };
+ }
+
+ public static int? ParseDisplayText(string? text)
+ {
+ var value = text?.Trim() ?? string.Empty;
+ return value switch
+ {
+ "无目标" => None,
+ "玩家" => Player,
+ "目标" => Target,
+ "焦点" => Focus,
+ "地面" => Cursor,
+ "鼠标" => Mouseover,
+ _ => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unit)
+ ? unit
+ : null
+ };
+ }
+}
diff --git a/README.md b/README.md
index b5e197e1..887a762b 100644
--- a/README.md
+++ b/README.md
@@ -208,8 +208,8 @@ module\ UI 可编辑模块
模块页规则表上方有“动态单位 / 数量字段”面板。这些是按 `group` 队伍状态**每帧实时解析**的命名对象,逻辑移植自旧 Python 项目 `utils.py`,统一只统计 `职责 != 0` 的单位、`生命值 0` 视为死亡跳过、阈值表示只考虑 `0 < 生命值 < 阈值`。
-- **动态单位**(可作目标,也可在条件引用):解析为一个队伍槽位(`1`~`30`)。选择器类型包括——`生命值最低`、`按职责取首/末`、`按职责且不带某光环`、`带某光环(持续最久)`、`带某驱散类型`。选择 `生命值最低` 后,可在新的“光环筛选”下拉里继续选择 `不筛选光环`、`带任一光环`、`不带某光环`、`带某光环` 或 `某光环值等于`。参数(血量阈值、职责、光环、光环值、驱散类型等)按所选类型自适应显示,光环候选来自当前职业/专精的 `group` 字段,并排除 `生命值`、`职责`、`驱散` 这类基础字段。编辑弹窗在第二行显示“名称”,选择 `生命值最低` 时还会显示“值名称”:填了之后,该单位解析槽位的“生命值”会暴露成一个同名数值条件字段——如取名 `最低血量` 后条件可直接写 `最低血量 < 50`(等价于 `单位名.生命值 < 50`);单位未解析时该字段视为缺失(关系比较不命中)。
-- **数量字段**(仅条件可用):解析为一个整数。类型包括 `低于阈值的人数`、`不带某光环且低血的人数`、`拥有某光环的人数`。
+- **动态单位**(可作目标,也可在条件引用):解析为一个队伍槽位(`1`~`30`)。选择器类型包括——`生命值最低`、`治疗吸收最高`、`按职责取首/末`、`按职责且不带某光环`、`带某光环(持续最久)`、`带某驱散类型`。`生命值最低` 与 `治疗吸收最高` 都支持固定/动态阈值,以及 `不筛选光环`、`带任一光环`、`不带某光环`、`带某光环`、`某光环值等于` 五种光环筛选;前者筛选 `0 < 生命值 < 阈值`,后者筛选 `治疗吸收 > 阈值`。`治疗吸收最高` 会在满足光环及阈值条件的单位中选择治疗吸收最高者,没有符合条件的单位时该动态单位不存在;其固定阈值默认值为 `0`。参数(阈值、职责、光环、光环值、驱散类型等)按所选类型自适应显示,光环候选来自当前职业/专精的 `group` 字段,并排除 `生命值`、`职责`、`驱散` 这类基础字段。编辑弹窗在第二行显示“名称”,选择 `生命值最低` 时还会显示“值名称”:填了之后,该单位解析槽位的“生命值”会暴露成一个同名数值条件字段——如取名 `最低血量` 后条件可直接写 `最低血量 < 50`(等价于 `单位名.生命值 < 50`);单位未解析时该字段视为缺失(关系比较不命中)。
+- **数量字段**(仅条件可用):解析为一个整数。选择器按数据类型分类显示:`血量 - 低于阈值`、`血量 - 低于阈值不带某光环`、`血量 - 低于阈值带某光环`、`治疗吸收 - 大于阈值`、`治疗吸收 - 大于阈值不带某光环`、`治疗吸收 - 大于阈值带某光环`、`光环 - 带某光环`。治疗吸收类型支持固定/动态阈值,固定阈值默认值为 `0`。
定义后:
diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs
index 50c98c10..111d355d 100644
--- a/UI/ClassMacrosEditorControl.cs
+++ b/UI/ClassMacrosEditorControl.cs
@@ -27,6 +27,7 @@ public sealed class ClassMacrosEditorControl : UserControl
private ClassMacrosStore.ClassMacros? _currentMacros;
private string? _currentClassFile;
private bool _suppressUi;
+ private bool _updatingDerivedColumns;
private bool _dirty;
public ClassMacrosEditorControl(Func resolveClassMacrosPath, Func updateConfigAsync)
@@ -231,8 +232,8 @@ public sealed class ClassMacrosEditorControl : UserControl
var pages = new Control[]
{
BuildDynamicPage(),
- BuildArrayPage(_staticGrid, "法术/条件或完整宏(空字符串 = 占位)", "注释"),
- BuildArrayPage(_specialGrid, "追加宏内容(空字符串 = 占位)", "注释")
+ BuildArrayPage(_staticGrid, "法术/条件或完整宏(空字符串 = 占位)", "注释", showParsedMacro: true),
+ BuildArrayPage(_specialGrid, "追加宏内容(空字符串 = 占位)", "注释", showParsedMacro: true)
};
foreach (var page in pages)
{
@@ -342,7 +343,11 @@ public sealed class ClassMacrosEditorControl : UserControl
return panel;
}
- private Control BuildArrayPage(DataGridView grid, string textHeader, string commentHeader)
+ private Control BuildArrayPage(
+ DataGridView grid,
+ string textHeader,
+ string commentHeader,
+ bool showParsedMacro)
{
var panel = new TableLayoutPanel
{
@@ -362,6 +367,24 @@ public sealed class ClassMacrosEditorControl : UserControl
Width = 72,
ReadOnly = true
});
+ if (showParsedMacro)
+ {
+ grid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ Name = "Unit",
+ HeaderText = "单位",
+ Width = 72,
+ ReadOnly = true
+ });
+ grid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ Name = "Spell",
+ HeaderText = "技能",
+ Width = 180,
+ ReadOnly = true
+ });
+ }
+
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Text",
@@ -379,8 +402,21 @@ public sealed class ClassMacrosEditorControl : UserControl
private void WireGrid(DataGridView grid)
{
grid.CellContentClick += HandleDeleteClick;
- grid.CellValueChanged += (_, _) =>
+ grid.CellValueChanged += (_, e) =>
{
+ if (_updatingDerivedColumns)
+ {
+ return;
+ }
+
+ if ((grid == _staticGrid || grid == _specialGrid)
+ && e.RowIndex >= 0
+ && e.ColumnIndex >= 0
+ && grid.Columns[e.ColumnIndex].Name is "Text" or "Comment")
+ {
+ UpdateMacroDisplay(grid, grid.Rows[e.RowIndex]);
+ }
+
MarkDirty();
UpdateOffsetHint();
};
@@ -640,25 +676,8 @@ public sealed class ClassMacrosEditorControl : UserControl
_dynamicGrid.Rows.Add(name, "×");
}
- for (var i = 0; i < _currentMacros.StaticSpells.Count; i++)
- {
- var entry = _currentMacros.StaticSpells[i];
- _staticGrid.Rows.Add(
- (i + 1).ToString(CultureInfo.InvariantCulture),
- entry.Text,
- entry.Comment ?? "",
- "×");
- }
-
- for (var i = 0; i < _currentMacros.SpecialSpells.Count; i++)
- {
- var entry = _currentMacros.SpecialSpells[i];
- _specialGrid.Rows.Add(
- (i + 1).ToString(CultureInfo.InvariantCulture),
- entry.Text,
- entry.Comment ?? "",
- "×");
- }
+ AddArrayRows(_staticGrid, _currentMacros.StaticSpells);
+ AddArrayRows(_specialGrid, _currentMacros.SpecialSpells);
}
finally
{
@@ -853,6 +872,53 @@ public sealed class ClassMacrosEditorControl : UserControl
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;
+ row.Cells["Comment"].Value = entry.Comment ?? "";
+ row.Cells["Delete"].Value = "×";
+ if (grid == _staticGrid || grid == _specialGrid)
+ {
+ UpdateMacroDisplay(grid, row);
+ }
+ }
+ }
+
+ private void UpdateMacroDisplay(DataGridView grid, DataGridViewRow row)
+ {
+ if (!grid.Columns.Contains("Unit")
+ || !grid.Columns.Contains("Spell")
+ || row.IsNewRow)
+ {
+ return;
+ }
+
+ var text = row.Cells["Text"].Value?.ToString() ?? "";
+ var comment = row.Cells["Comment"].Value?.ToString();
+ var parsed = grid == _specialGrid
+ ? FuyutsuiKeymapConverter.ParseSpecialMacro(text, comment)
+ : FuyutsuiKeymapConverter.ParseStaticMacro(text, comment);
+
+ _updatingDerivedColumns = true;
+ try
+ {
+ row.Cells["Unit"].Value = grid == _specialGrid
+ ? ReservedUnit.ToDisplayText(parsed.Unit)
+ : parsed.Unit.ToString(CultureInfo.InvariantCulture);
+ row.Cells["Spell"].Value = parsed.Spell;
+ }
+ finally
+ {
+ _updatingDerivedColumns = false;
+ }
+ }
+
private static void RenumberArrayRows(DataGridView grid)
{
if (!grid.Columns.Contains("Index"))
diff --git a/UI/ModuleDisplay.cs b/UI/ModuleDisplay.cs
index 256718cf..31ab799d 100644
--- a/UI/ModuleDisplay.cs
+++ b/UI/ModuleDisplay.cs
@@ -4,7 +4,7 @@ internal static class ModuleDisplay
{
public static string FormatListItem(ModuleDefinition module)
{
- return $"{module.Name} [{FormatMatch(module.Match)}]";
+ return module.Name;
}
public static string FormatMatch(ModuleMatch match)
diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs
index b7c49b4e..89c0e3f7 100644
--- a/UI/ModuleEditorControl.cs
+++ b/UI/ModuleEditorControl.cs
@@ -132,7 +132,12 @@ public sealed class ModuleEditorControl : UserControl
sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
_moduleList.Dock = DockStyle.Fill;
- UiTheme.StyleListBox(_moduleList, Font);
+ UiTheme.StyleListBox(
+ _moduleList,
+ Font,
+ index => index >= 0 && index < _modules.Count
+ ? (_modules[index].Match.ClassId, _modules[index].Match.SpecId)
+ : (null, null));
_moduleList.BackColor = UiTheme.Background;
_moduleList.SelectedIndexChanged += (_, _) => SelectModule(_moduleList.SelectedIndex);
sidebar.Controls.Add(_moduleList, 0, 0);
@@ -853,7 +858,7 @@ public sealed class ModuleEditorControl : UserControl
_unitColumn.Items.Add(string.Empty);
foreach (var unit in _keymapCatalog.GetUnits(classId))
{
- _unitColumn.Items.Add(unit.ToString());
+ _unitColumn.Items.Add(ReservedUnit.ToDisplayText(unit));
}
foreach (DataGridViewRow row in _rulesGrid.Rows)
@@ -905,8 +910,9 @@ public sealed class ModuleEditorControl : UserControl
if (ModuleSpecialActions.IsOneKeySpell(spell))
{
- cell.Items.Add("0");
- cell.Value = "0";
+ var noTarget = ReservedUnit.ToDisplayText(ReservedUnit.None);
+ cell.Items.Add(noTarget);
+ cell.Value = noTarget;
return;
}
@@ -917,7 +923,7 @@ public sealed class ModuleEditorControl : UserControl
foreach (var unit in allowed)
{
- cell.Items.Add(unit.ToString());
+ cell.Items.Add(ReservedUnit.ToDisplayText(unit));
}
// 动态单位与技能无关, 始终可选; 放在 keymap 编号之后。
@@ -946,7 +952,7 @@ public sealed class ModuleEditorControl : UserControl
}
else
{
- // 技能切换导致旧的数字目标非法, 清空。
+ // 技能切换导致旧目标非法, 清空。
cell.Value = string.Empty;
}
}
@@ -2433,7 +2439,7 @@ public sealed class ModuleEditorControl : UserControl
var hint = new Label
{
- Text = "目标可选 keymap 编号或上方定义的动态单位;点击“条件”列打开可视化编辑器",
+ Text = "目标可选技能支持的单位或上方定义的动态单位;点击“条件”列打开可视化编辑器",
Dock = DockStyle.Fill,
ForeColor = UiTheme.Muted,
TextAlign = ContentAlignment.MiddleLeft,
@@ -2549,10 +2555,10 @@ public sealed class ModuleEditorControl : UserControl
foreach (var rule in module.Rules)
{
- // 动态目标优先显示单位名, 否则显示数字单位。
+ // 动态目标优先显示单位名;保留单位显示中文,其余团队槽位显示数字。
var unitText = !string.IsNullOrWhiteSpace(rule.UnitName)
? rule.UnitName!
- : rule.Unit?.ToString() ?? string.Empty;
+ : rule.Unit is { } unit ? ReservedUnit.ToDisplayText(unit) : string.Empty;
EnsureComboItem(_spellColumn, rule.Spell);
// 先加行(目标先留空), 再按技能重建目标选项并写回目标值, 避免值不在选项内被吞掉。
var index = _rulesGrid.Rows.Add(rule.Enabled, rule.Spell, string.Empty, rule.Condition);
@@ -2817,7 +2823,7 @@ public sealed class ModuleEditorControl : UserControl
continue;
}
- // 目标文本命中已定义动态单位名 → UnitName; 否则按数字 → Unit; 都不是则留空。
+ // 目标文本命中已定义动态单位名 → UnitName;否则把中文保留单位或数字槽位还原为 Unit。
var isDynamic = unitNames.Contains(unitText);
var subs = metadata.SubConditions
.Select(sub => sub?.Trim() ?? string.Empty)
@@ -2827,7 +2833,7 @@ public sealed class ModuleEditorControl : UserControl
{
Enabled = CellBool(row, "Enabled", defaultValue: true),
Condition = condition,
- Unit = isDynamic ? null : ParseNullableInt(unitText),
+ Unit = isDynamic ? null : ReservedUnit.ParseDisplayText(unitText),
UnitName = isDynamic ? unitText : null,
Spell = spell,
Hotkey = string.Empty,
diff --git a/UI/UiTheme.cs b/UI/UiTheme.cs
index 18a6ea93..9750b3ef 100644
--- a/UI/UiTheme.cs
+++ b/UI/UiTheme.cs
@@ -268,7 +268,10 @@ internal static class UiTheme
};
}
- public static void StyleListBox(ListBox listBox, Font font)
+ public static void StyleListBox(
+ ListBox listBox,
+ Font font,
+ Func? moduleMatchSelector = null)
{
listBox.BackColor = Surface;
listBox.ForeColor = Text;
@@ -301,7 +304,40 @@ internal static class UiTheme
e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + 5, 3, e.Bounds.Height - 10);
}
- var textBounds = new Rectangle(e.Bounds.Left + 10, e.Bounds.Top, e.Bounds.Width - 14, e.Bounds.Height);
+ var textLeft = e.Bounds.Left + 10;
+ if (moduleMatchSelector is not null)
+ {
+ var (classId, specId) = moduleMatchSelector(e.Index);
+ var icons = new[]
+ {
+ classId is { } matchedClassId ? GetClassIcon(matchedClassId) : null,
+ classId is { } matchedSpecClassId && specId is { } matchedSpecId
+ ? GetSpecIcon(matchedSpecClassId, matchedSpecId)
+ : null
+ };
+ var iconSize = Math.Min(font.Height, e.Bounds.Height - 8);
+ foreach (var icon in icons)
+ {
+ if (icon is null)
+ {
+ continue;
+ }
+
+ var iconBounds = new Rectangle(
+ textLeft,
+ e.Bounds.Top + (e.Bounds.Height - iconSize) / 2,
+ iconSize,
+ iconSize);
+ e.Graphics.DrawImage(icon, iconBounds);
+ textLeft = iconBounds.Right + 4;
+ }
+ }
+
+ var textBounds = new Rectangle(
+ textLeft,
+ e.Bounds.Top,
+ Math.Max(0, e.Bounds.Right - textLeft - 4),
+ e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
listBox.Items[e.Index]?.ToString() ?? string.Empty,
diff --git a/UI/UnitEditorForm.cs b/UI/UnitEditorForm.cs
index 3c034cdc..f81ea944 100644
--- a/UI/UnitEditorForm.cs
+++ b/UI/UnitEditorForm.cs
@@ -39,6 +39,7 @@ public sealed class UnitEditorForm : Form
private static readonly SelectorItem[] UnitSelectors =
[
new("生命值最低", UnitSelectorKind.LowestHealth),
+ new("治疗吸收最高", UnitSelectorKind.HighestHealingAbsorb),
new("按职责", UnitSelectorKind.UnitWithRole),
new("按职责且不带某光环", UnitSelectorKind.UnitWithRoleWithoutAura),
new("带某光环(持续最久)", UnitSelectorKind.UnitWithAura),
@@ -47,9 +48,13 @@ public sealed class UnitEditorForm : Form
private static readonly CountItem[] CountSelectors =
[
- new("低于阈值的人数", CountKind.UnitsBelowHealth),
- new("不带某光环且低血的人数", CountKind.UnitsWithoutAuraBelowHealth),
- new("拥有某光环的人数", CountKind.UnitsWithAura)
+ new("血量 - 低于阈值", CountKind.UnitsBelowHealth),
+ new("血量 - 低于阈值不带某光环", CountKind.UnitsWithoutAuraBelowHealth),
+ new("血量 - 低于阈值带某光环", CountKind.UnitsWithAuraBelowHealth),
+ new("治疗吸收 - 大于阈值", CountKind.UnitsAboveHealingAbsorb),
+ new("治疗吸收 - 大于阈值不带某光环", CountKind.UnitsWithoutAuraAboveHealingAbsorb),
+ new("治疗吸收 - 大于阈值带某光环", CountKind.UnitsWithAuraAboveHealingAbsorb),
+ new("光环 - 带某光环", CountKind.UnitsWithAura)
];
private static readonly ThresholdModeItem[] ThresholdModeOptions =
@@ -84,6 +89,7 @@ public sealed class UnitEditorForm : Form
private Panel _thresholdModeRow = null!;
private Panel _thresholdRow = null!;
+ private Label _thresholdLabel = null!;
private Panel _thresholdFieldRow = null!;
private Panel _lowestHealthAuraFilterRow = null!;
private Panel _roleRow = null!;
@@ -92,6 +98,7 @@ public sealed class UnitEditorForm : Form
private Panel _aurasRow = null!;
private Panel _auraCountRow = null!;
private Panel _dispelRow = null!;
+ private bool _usesHealingAbsorbThreshold;
public ModuleUnit? ResultUnit { get; private set; }
public ModuleCountField? ResultCount { get; private set; }
@@ -290,6 +297,7 @@ public sealed class UnitEditorForm : Form
_thresholdModeRow = BuildLabeledRow("阈值类型", _thresholdModeBox);
_thresholdRow = BuildLabeledRow("血量阈值 (<)", _thresholdBox);
+ _thresholdLabel = _thresholdRow.Controls.OfType