diff --git a/.gitignore b/.gitignore index 382b535f..834cdb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ cache/ 提示词帮助.md # 本地模块(不上传) -module/ \ No newline at end of file +module/ +# 说明文档 +docs/ diff --git a/CLASSMACROS_AI_Reference_zh-CN.md b/CLASSMACROS_AI_Reference_zh-CN.md deleted file mode 100644 index b5b98da9..00000000 --- a/CLASSMACROS_AI_Reference_zh-CN.md +++ /dev/null @@ -1,368 +0,0 @@ ---- -title: "Fuyutsui core/classmacros.lua:AI 宏规则参考" -language: "zh-CN" -primary_file: "core/classmacros.lua" -related: - - "core/macro.lua" - - "main.lua" - - "Keymap.md" - - "AGENTS.md" - - "AI_CODEBASE_GUIDE_zh-CN.md" -purpose: "供 AI 理解、审查、新增职业宏时作为单一事实来源;说明 ClassMacros 三表规则、MacroBodies 查表与 CreateMacro 展开逻辑" ---- - -# Fuyutsui `core/classmacros.lua`:AI 宏规则参考 - -> 本文描述如何在 `ClassMacros` 里声明职业宏,以及 `CreateMacro` 如何把它们展开成 SecureActionButton 覆盖绑定。 -> **改宏只改 `core/classmacros.lua`**,不要改 `class/*.lua`,也不要在别处硬编码职业宏列表。 - -## 0. 一句话定义 - -`classmacros.lua` 提供: - -1. `Fuyutsui.MacroBodies`:命名宏体查表(药水等)。 -2. `Fuyutsui.ClassMacros[classFile]`:各职业的三份**数组**列表。 - -运行时由 `main.lua:LoadPlayerMacros()` 按 `UnitClassBase("player")` 取出当前职业,再调用: - -```lua -Fuyutsui:CreateMacro(m.dynamicSpells, m.staticSpells, m.specialSpells) -``` - -真正创建按钮、拼宏文本、绑定热键的逻辑在 `core/macro.lua`。 - -**创建顺序(依次占键)**:`dynamicSpells`(每组 30 键)→ `staticSpells` → `specialSpells`。 - -## 1. AI 必须遵守的规则 - -1. **键名必须是** `UnitClassBase` 返回值:`WARRIOR` / `PALADIN` / `HUNTER` / `ROGUE` / `PRIEST` / `DEATHKNIGHT` / `SHAMAN` / `MAGE` / `WARLOCK` / `MONK` / `DRUID` / `DEMONHUNTER` / `EVOKER`。 -2. 每个职业表**必须有**三个字段:`dynamicSpells`、`staticSpells`、`specialSpells`(可为空表 `{}`)。字段顺序建议与文件一致:dynamic → static → special。 -3. 三表一律用**数组**(`{"牺牲祝福", "代祷", "圣盾术"}`),**不要**再写 `[n] = "..."` 稀疏键值表。 -4. **需要点名队友/团队成员**的治疗、驱散、护盾等 → 放 `dynamicSpells`(数组,顺序敏感)。 -5. **普通单体施法**(目标默认、或条件写在法术名里)→ 放 `staticSpells`,只写法术/条件字符串,**不要**自己加 `/cast `。 -6. **完整宏文本**(`/castsequence`、`/stopcasting`、`/cancelaura` 等)→ 可直接写在 `staticSpells`(以 `/` 开头)或追加到 `specialSpells`;以 `/` 开头的字符串**不会**再加 `/cast `。 -7. 药水等复用宏体 → 在列表里写**名称**(如 `"银月城生命药水"`),并在 `Fuyutsui.MacroBodies` 中登记;创建时用表内值。 -8. 需要跳过某个槽位但不平移后续键 → 在数组对应位置写 `""`(占位,不创建按钮)。 -9. 战斗中 `InCombatLockdown()` 时无法创建/修改安全按钮;改宏后需在脱战后 `/reload` 或等下次非战斗加载生效。 -10. 保持 Lua 5.1 / WoW 兼容;法术名用**本地化中文名**(与客户端一致),与现有表风格一致。 -11. 不要格式化整个文件;只改目标职业段落。 - -## 2. 加载与消费链路 - -```text -classmacros.lua - │ MacroBodies(命名宏体) - │ ClassMacros[classFile] - ▼ -main.lua:LoadPlayerMacros() - │ classFile = UnitClassBase("player") - │ MacrosList = ClassMacros[classFile] - ▼ -macro.lua:CreateMacro(dynamic, static, special) - │ 1. dynamic:每组 × 30 键 - │ 2. static:ipairs 依次占键(resolveMacroBody) - │ 3. special:接在 static 后依次占键(resolveMacroBody) - │ SetOverrideBindingClick + macrotext → 按钮 s1..sN - ▼ -玩家按覆盖热键 → SecureActionButton 执行宏 -``` - -触发时机:玩家数据加载路径会调用 `LoadPlayerMacros()`(见 `core/player.lua`)。专精切换等场景若重新加载宏,同样走此函数。 - -## 3. 热键池(`macroKind`) - -`core/macro.lua` 用修饰键 × 基础键生成有序列表 `macroKind[1..N]`。 - -| 修饰键顺序 | 基础键(每组 39 个) | -|---|---| -| `CTRL` → `ALT` → `SHIFT` → `ALT-CTRL` → `ALT-SHIFT` → `CTRL-SHIFT` → `ALT-CTRL-SHIFT` | 小键盘 0–9 / 小数点 / + / − / × / ÷;`F1–F3,F5–F12`(**无 F4**);`, . / ; ' [ ] \`;`7 8 9 0 =` | - -- 总槽位数 = `7 × 39 = 273`。 -- 按钮名:`s1`、`s2`、…(与 `macroKind` 下标一致)。 -- 人读对照表见 `Keymap.md`(ID 1 = `CTRL-NUMPAD1`,以此类推)。 - -**AI 改宏时**:关心的是 `dynamic` 占用多少槽、以及 static/special 数组下标对应的全局热键;不必手算,除非要核对外部程序按键映射。 - -## 4. 槽位分配算法(核心) - -`CreateMacro` **按顺序推进槽位下标 `i`**(从 1 起),不再用「special 与 static 同序号互斥覆盖」: - -```text -i = 1 - --- 1. dynamicSpells -for each spell in dynamicSpells: - for raidIdx = 1..30: - 按 §5 生成 macroBody(spell 为空则不建按钮) - 占用 macroKind[i],i = i + 1 - --- 2. staticSpells -for each entry in staticSpells: - macroBody = resolveMacroBody(entry) -- "" → nil,不建按钮但仍占位 - 占用 macroKind[i],i = i + 1 - --- 3. specialSpells -for each entry in specialSpells: - macroBody = resolveMacroBody(entry) - 占用 macroKind[i],i = i + 1 -``` - -因此: - -| `dynamicSpells` 长度 | 动态占用槽 | `staticSpells[1]` 对应全局 `i` | -|---:|---:|---:| -| 0 | 0 | 1 | -| 1 | 30 | 31 | -| 5 | 150 | 151 | -| 6 | 180 | 181 | -| 7 | 210 | 211 | - -**新增或删除 `dynamicSpells` 条目会平移其后所有 static/special 的实际热键。** -**在 `staticSpells` 中间插入/删除条目(含 `""`)同样会平移其后键位。** - -### 4.1 `resolveMacroBody(spell)` - -| 情况 | 结果 | -|---|---| -| `spell` 为 `nil` 或 `""` | 不创建宏(槽位仍前进) | -| `Fuyutsui.MacroBodies[spell]` 存在,且值以 `/` 开头 | 原样使用表内值 | -| `MacroBodies[spell]` 存在,值不以 `/` 开头 | `"/cast " .. 表内值` | -| `spell` 本身以 `/` 开头 | 原样使用(完整宏文本) | -| 其他 | `"/cast " .. spell` | - -## 5. `dynamicSpells` 规则 - -### 5.1 数据结构 - -```lua -dynamicSpells = { "法术A", "法术B", "法术C" } -- 数组,1-based,顺序 = 组号 -``` - -- 用**纯法术名**(中文),不要写 `/cast`,不要写 `@raid`(展开逻辑会加)。 -- 每组固定占 **30** 个连续热键,对应 `raid1` … `raid30`。 - -### 5.2 单组内 30 键展开 - -设组内相对位 `raidIdx = 1..30`,法术名为 `spell`: - -| raidIdx | 生成的 `macrotext` | -|---:|---| -| 1 | `/cast [group:raid,@raid1]spell;[group:party,@player]spell;[nogroup,@player]spell` | -| 2..5 | `/cast [group:raid,@raidN]spell;[group:party,@party(N-1)]spell` | -| 6..30 | `/cast [group:raid,@raidN]spell` | - -含义: - -- 团队:始终 `@raid1` … `@raid30`。 -- 小队:仅前 5 键有意义 → `@player`、`@party1`…`@party4`。 -- 单人:仅第 1 键落到 `@player`。 - -### 5.3 何时放入 dynamic - -适合:治疗术、驱散、护盾、急救类等**必须点名不同队友**的技能。 - -不适合: - -- 只打当前目标 / 自身 / 鼠标指向 / 焦点 → 用 `staticSpells` + 条件前缀。 -- 需要 sequence / stopcasting / cancelaura → 在 `staticSpells` 写以 `/` 开头的完整文本,或放入 `specialSpells`。 - -### 5.4 现有职业占用(便于估算偏移) - -| 职业键 | `#dynamicSpells` | 动态槽 | static 第 1 项的全局 `i` | -|---|---:|---:|---:| -| WARRIOR / HUNTER / ROGUE / DEATHKNIGHT / WARLOCK / DEMONHUNTER | 0 | 0 | 1 | -| MAGE | 1 | 30 | 31 | -| DRUID | 5 | 150 | 151 | -| PALADIN / PRIEST / SHAMAN / MONK | 6 | 180 | 181 | -| EVOKER | 7 | 210 | 211 | - -## 6. `staticSpells` 规则 - -### 6.1 数据结构 - -```lua -staticSpells = { - "英勇投掷", - "[@mouseover]保护祝福", - "[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒", - "银月城生命药水", -- 走 MacroBodies - "/castsequence reset=0.3 真言术:耀,x", -- 以 / 开头:完整宏 - "", -- 空串:占位跳过,不建按钮 -} -``` - -- **数组顺序 = 占键顺序**(接在 dynamic 区之后)。 -- 普通条目是**拼在 `/cast ` 后面的字符串**,不要自带前导 `/cast `。 -- 需要完整命令时,直接写以 `/` 开头的字符串。 -- `""` 保留槽位但不创建按钮(例如法师历史上空出的位置)。 - -### 6.2 常见写法(直接抄现有风格) - -| 意图 | `staticSpells` 值示例 | 最终宏 | -|---|---|---| -| 默认目标施法 | `"审判"` | `/cast 审判` | -| 自身 | `"[@player]荣耀圣令"` | `/cast [@player]荣耀圣令` | -| 鼠标指向 | `"[@mouseover]破咒祝福"` | `/cast [@mouseover]破咒祝福` | -| 光标地面 | `"[@cursor]乱射"` | `/cast [@cursor]乱射` | -| 焦点优先 | `"[target=focus,exists] 窒息;窒息"` | `/cast [target=focus,exists] 窒息;窒息` | -| 专精分支 | `"[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒"` | `/cast [spec:2]...` | -| 天赋已知 | `"[known:116844,@cursor]平心之环;[known:198898]赤精之歌"` | `/cast [known:...]...` | -| 姿态/形态 | `"[nostance:1]暗影形态"` | `/cast [nostance:1]暗影形态` | -| 命名药水 | `"银月城生命药水"` | `/cast item:241304` + 第二行 `/cast item:241305`(见 MacroBodies) | -| 完整宏 | `"/stopcasting"` | `/stopcasting`(不加 `/cast`) | -| 占位跳过 | `""` | 不创建 | - -## 7. `Fuyutsui.MacroBodies`(命名宏体) - -### 7.1 作用 - -列表里写**可读名称**,实际宏体集中维护,避免多职业重复粘贴物品 ID。 - -```lua -Fuyutsui.MacroBodies = { - ["鲁莽药水"] = "item:241288\n/cast item:241289", - ["银月城生命药水"] = "item:241304\n/cast item:241305", -} -``` - -在 `staticSpells` / `specialSpells` 中写 `"银月城生命药水"` 即可;`resolveMacroBody` 会查表并(对非 `/` 开头的值)自动加 `/cast `。 - -### 7.2 新增命名宏体 - -1. 在 `MacroBodies` 增加 `["名称"] = "宏体片段或完整宏"`。 -2. 各职业数组中写 `"名称"`,不要再内联物品 ID。 -3. 若宏体已是完整命令(以 `/` 开头),查表后原样使用;否则前缀 `/cast `。 - -## 8. `specialSpells` 规则 - -### 8.1 数据结构 - -```lua -specialSpells = { - "/castsequence reset=0.5 死亡之握,x", - "/stopcasting", -} -``` - -- 同样是**数组**,接在该职业**全部** `staticSpells` 之后依次占键。 -- 也走 `resolveMacroBody`:可用完整 `/...` 文本,或 MacroBodies 名称,或普通法术名(会加 `/cast`)。 -- 当前仓库多数职业 `specialSpells = {}`;历史上与 static 同槽的特殊宏(sequence / stopcasting 等)已直接写在 `staticSpells` 对应位置(以 `/` 开头),以保持键位。 - -### 8.2 何时用 special 而不是写进 static - -| 选择 | 适用 | -|---|---| -| 写进 `staticSpells`(`/` 开头或 MacroBodies 名) | 需要固定在 static 区某一相对位置 | -| 追加到 `specialSpells` | 明确接在 static **末尾之后**的额外宏 | - -### 8.3 典型完整宏 - -| 模式 | 示例(文件中已有) | -|---|---| -| castsequence + 哑元 | `"/castsequence reset=0.5 死亡之握,x"` | -| 停施法 | `"/stopcasting"` | -| 取消光环再施法 | `"/cancelaura [spec:4]猎豹形态\n/cast 万灵之召"` | - -`castsequence ...,x` 中的 `x` 是占位,用于快速连按重置序列;改此类宏时保持现有 reset 秒数风格,除非有明确需求。 - -## 9. 决策树:新技能放哪 - -```text -需要按队友/团队槽位点名? - ├─ 是 → dynamicSpells(纯法术名;注意 +30 偏移) - └─ 否 → 是否已有 MacroBodies 名称? - ├─ 是 → staticSpells 写名称 - └─ 否 → 能否写成「/cast + 一段条件/法术名」? - ├─ 是 → staticSpells 写条件/法术名 - └─ 否 → staticSpells 写以 / 开头的完整宏 - (或追加到 specialSpells 末尾) -``` - -需要跳过某键:在数组该位置写 `""`。 - -## 10. 修改检查清单(AI 改完自检) - -1. 职业键是否为正确的 `UnitClassBase` 字符串? -2. 三个字段是否都在(空也要 `{}`),且为**数组**而非 `[n]=` 稀疏表? -3. 若改了 `dynamicSpells` 长度:是否意识到后续热键整体平移? -4. 若在 `staticSpells` 中间插入/删除(含 `""`):是否评估了后续键位? -5. 普通 static 值是否**没有**多余的前导 `/cast `? -6. 完整宏是否以 `/` 开头(或走 MacroBodies)? -7. 药水等是否用 MacroBodies 名称,而不是内联 `item:...`? -8. 条件语法是否与现有条目一致(`[@unit]`、`[spec:N]`、`[known:id]`、`[group:...]`)? -9. 法术名是否与游戏客户端中文一致? -10. 提醒:战斗中不会更新安全按钮;需脱战 `/reload` 验证。 - -## 11. 最小示例 - -### 11.1 无动态(近战输出职业常见) - -```lua -WARRIOR = { - dynamicSpells = {}, - staticSpells = { - "英勇投掷", - -- ... - "拳击", - "[@focus]拳击", - }, - specialSpells = {}, -} -``` - -- 第 1 项 → 全局 `i=1` → `/cast 英勇投掷` -- 最后一项 → `/cast [@focus]拳击` - -### 11.2 有动态 + 完整宏 + 命名药水 - -```lua -PRIEST = { - dynamicSpells = { "苦修", "快速治疗", "真言术:盾", "愈合祷言", "纯净术", "圣言术:静" }, - -- 动态占 180 槽;static 第 1 项 → 全局 i=181 - staticSpells = { - "心灵震爆", - -- ... - "圣言术:静", - "/castsequence reset=0.3 真言术:耀,x", -- 完整宏,占 static 相对第 36 槽 - "银月城生命药水", -- MacroBodies - "渐隐术", - "/stopcasting", - }, - specialSpells = {}, -} -``` - -### 11.3 空槽占位 - -```lua --- 法师示例:数组中某处写 "",该全局槽不建按钮,后续项仍按顺序占下一键 -staticSpells = { - "[@cursor]暴风雪", - "", -- 占位 - "奥术智慧", -} -``` - -## 12. 与本文相关、但不要在这里改的东西 - -| 文件 | 职责 | 改宏时 | -|---|---|---| -| `core/macro.lua` | 热键表、顺序占键、`resolveMacroBody`、安全按钮 | 仅当要改分配规则/键池时才动 | -| `main.lua` | `LoadPlayerMacros` 选职业表 | 一般不动 | -| `Keymap.md` | 人读热键 ID 对照 | 键池变更时同步 | -| `core/keybinds.lua` / `config.lua` keymap | 动作条扫描 → 像素协议 | **另一套**按键编码,与 ClassMacros 覆盖绑定无关 | -| `class/*.lua` | ClassBlocks 色块 | 不放宏 | - -## 13. 常见错误 - -| 错误 | 后果 | -|---|---| -| 普通法术误加 `/cast ` 前缀 | 实际变成 `/cast /cast 火球术` | -| 完整宏未以 `/` 开头且未进 MacroBodies | 被加上 `/cast `,命令错误 | -| 把点名治疗放进 static | 无法按 raid/party 槽位点名 | -| 仍使用 `[36] = "..."` 稀疏表 | 与当前 `ipairs` 顺序占键不兼容,中间空洞行为不符合预期 | -| 药水内联 `item:...` 而不用 MacroBodies 名称 | 多职业重复、难统一改 ID | -| 在 `dynamicSpells` 或 `staticSpells` 中间插入却不评估偏移 | 后续所有按键语义错位 | -| 用错职业键(如 `DeathKnight`) | `LoadPlayerMacros` 取不到表,宏不创建 | -| 假设战斗中改表立即生效 | `InCombatLockdown` 直接 return,按钮不更新 | -| 误以为 special 与 static 仍按同序号覆盖 | 已改为顺序追加;同槽特殊宏应直接写在 static 对应位置 | diff --git a/Infrastructure/ClassMacrosStore.cs b/Infrastructure/ClassMacrosStore.cs index 6c750bc4..c5206cc6 100644 --- a/Infrastructure/ClassMacrosStore.cs +++ b/Infrastructure/ClassMacrosStore.cs @@ -29,9 +29,26 @@ internal static class ClassMacrosStore public sealed class ClassMacros { - public List DynamicSpells { get; } = new(); + /// + /// false 表示旧式纯数组;true 表示 common + [specIndex] 分组格式。 + /// 两种格式的通用项都保存在 DynamicCommon 中,以便旧文件无损回写。 + /// + public bool UsesSpecDynamicSpells { get; set; } + public List DynamicCommon { get; } = new(); + public Dictionary> DynamicBySpec { get; } = new(); public List StaticSpells { get; } = new(); public List SpecialSpells { get; } = new(); + + public IReadOnlyList ResolveDynamicSpells(int? specIndex) + { + if (!UsesSpecDynamicSpells || specIndex is null + || !DynamicBySpec.TryGetValue(specIndex.Value, out var specSpells)) + { + return DynamicCommon; + } + + return [.. DynamicCommon, .. specSpells]; + } } public sealed class ArrayEntry @@ -97,13 +114,30 @@ internal static class ClassMacrosStore var macros = new ClassMacros(); if (classTable.GetTable("dynamicSpells") is { } dynamic) { - foreach (var item in dynamic.IPairs()) + var specIndexes = dynamic.Entries + .Select(entry => TryGetPositiveIndex(entry.Key)) + .Where(index => index is not null) + .Select(index => index!.Value) + .Distinct() + .Where(index => dynamic.GetTable((long)index) is not null) + .OrderBy(index => index) + .ToList(); + + macros.UsesSpecDynamicSpells = dynamic.GetTable("common") is not null || specIndexes.Count > 0; + if (macros.UsesSpecDynamicSpells) { - if (item is StringValue s) + ReadStringArray(dynamic.GetTable("common"), macros.DynamicCommon); + foreach (var specIndex in specIndexes) { - macros.DynamicSpells.Add(s.Value); + var spells = new List(); + ReadStringArray(dynamic.GetTable((long)specIndex), spells); + macros.DynamicBySpec[specIndex] = spells; } } + else + { + ReadStringArray(dynamic, macros.DynamicCommon); + } } ReadArray(classTable.GetTable("staticSpells"), macros.StaticSpells); @@ -135,6 +169,35 @@ internal static class ClassMacrosStore } } + private static void ReadStringArray(TableValue? table, List target) + { + if (table is null) + { + return; + } + + foreach (var value in table.IPairs()) + { + if (value is not StringValue text) + { + break; + } + + target.Add(text.Value); + } + } + + private static int? TryGetPositiveIndex(object? key) + { + var value = key switch + { + long number when number is > 0 and <= int.MaxValue => (int)number, + int number when number > 0 => number, + _ => (int?)null + }; + return value; + } + public static string SerializeClassMacros(MacrosDocument document) { var sb = new StringBuilder(); @@ -188,17 +251,7 @@ internal static class ClassMacrosStore { sb.Append(" ").Append(classFile).AppendLine(" = {"); - // dynamicSpells - if (macros.DynamicSpells.Count == 0) - { - sb.AppendLine(" dynamicSpells = {},"); - } - else - { - sb.Append(" dynamicSpells = { "); - sb.Append(string.Join(", ", macros.DynamicSpells.Select(s => $"\"{Escape(s)}\""))); - sb.AppendLine(" },"); - } + WriteDynamicSpells(sb, macros); // staticSpells WriteArrayTable(sb, "staticSpells", macros.StaticSpells); @@ -210,6 +263,38 @@ internal static class ClassMacrosStore sb.AppendLine(); } + private static void WriteDynamicSpells(StringBuilder sb, ClassMacros macros) + { + if (!macros.UsesSpecDynamicSpells) + { + WriteInlineStringArray(sb, " dynamicSpells = ", macros.DynamicCommon); + return; + } + + sb.AppendLine(" dynamicSpells = {"); + WriteInlineStringArray(sb, " common = ", macros.DynamicCommon); + foreach (var (specIndex, spells) in macros.DynamicBySpec.OrderBy(item => item.Key)) + { + WriteInlineStringArray(sb, $" [{specIndex}] = ", spells); + } + + sb.AppendLine(" },"); + } + + private static void WriteInlineStringArray(StringBuilder sb, string prefix, IReadOnlyList values) + { + sb.Append(prefix); + if (values.Count == 0) + { + sb.AppendLine("{},"); + return; + } + + sb.Append("{ "); + sb.Append(string.Join(", ", values.Select(value => $"\"{Escape(value)}\""))); + sb.AppendLine(" },"); + } + private static void WriteArrayTable(StringBuilder sb, string name, List entries) { if (entries.Count == 0) diff --git a/Infrastructure/ClassStateCatalog.cs b/Infrastructure/ClassStateCatalog.cs index 28bd32ab..3cd4070d 100644 --- a/Infrastructure/ClassStateCatalog.cs +++ b/Infrastructure/ClassStateCatalog.cs @@ -30,8 +30,9 @@ internal static class ClassStateCatalog "职业", "专精", "有效性", "战斗时间", "移动", "生命值", "一键辅助", "插入法术", "队伍类型", "队伍人数", "首领战", "难度", "英雄天赋", "施法目标", "施法技能", - "敌人人数", "施法", "引导", "蓄力", "蓄力层数", - "酒池", "符文", "姿态", "救赎之魂1", "救赎之魂2", + "敌人数量", "敌人数-无仇恨", "敌人数-有仇恨","施法", + "引导", "蓄力", "蓄力层数", "酒池", "符文", "姿态", + "救赎之魂1", "救赎之魂2", ]), (CategoryConfig, [ diff --git a/Infrastructure/FuyutsuiKeymapConverter.cs b/Infrastructure/FuyutsuiKeymapConverter.cs index a0275020..3aa5ccaa 100644 --- a/Infrastructure/FuyutsuiKeymapConverter.cs +++ b/Infrastructure/FuyutsuiKeymapConverter.cs @@ -37,6 +37,8 @@ internal static partial class FuyutsuiKeymapConverter private static readonly string[] MacroKind = BuildMacroKind(); + internal static int MacroSlotCapacity => Modifiers.Length * Keys.Length; + private static readonly Dictionary ClassFileToId = new(StringComparer.OrdinalIgnoreCase) { ["WARRIOR"] = 1, @@ -86,7 +88,7 @@ internal static partial class FuyutsuiKeymapConverter var jsonPath = Path.Combine(keymapDirectory, fileName); var existing = LoadExistingSpellNames(jsonPath); - var (root, classWarnings) = CompileClassKeymap(classTable, existing, classFile); + var (root, classWarnings) = CompileClassKeymap(classTable, existing, classFile, classId); warnings.AddRange(classWarnings); File.WriteAllText(jsonPath, root.ToJsonString(WriteOptions) + Environment.NewLine, Encoding.UTF8); @@ -103,14 +105,82 @@ internal static partial class FuyutsuiKeymapConverter private static (JsonObject Root, List Warnings) CompileClassKeymap( TableValue classTable, - IReadOnlyDictionary existingSpellNames, - string classFile) + ExistingSpellNames existingSpellNames, + string classFile, + int classId) { var warnings = new List(); - var dynamicSpells = ReadArrayStrings(classTable.GetTable("dynamicSpells")); + var dynamicTable = classTable.GetTable("dynamicSpells"); var staticSpells = ReadArrayEntries(classTable.GetTable("staticSpells")); var specialSpells = ReadArrayEntries(classTable.GetTable("specialSpells")); + + if (!IsSpecializedDynamicFormat(dynamicTable)) + { + var dynamicSpells = ReadArrayStrings(dynamicTable); + return ( + CompileSlotMap( + dynamicSpells, + staticSpells, + specialSpells, + existingSpellNames, + null, + classFile, + warnings), + warnings); + } + + var commonSpells = ReadArrayStrings(dynamicTable?.GetTable("common")); + var root = CompileSlotMap( + commonSpells, + staticSpells, + specialSpells, + existingSpellNames, + null, + $"{classFile}[兼容回退]", + warnings); + var specRoot = new JsonObject(); + var specs = ClassNames.GetSpecs(classId); + var knownSpecIds = specs.Select(spec => spec.Id).ToHashSet(); + foreach (var unknownSpecId in GetDynamicSpecIndexes(dynamicTable).Where(id => !knownSpecIds.Contains(id))) + { + warnings.Add($"{classFile}[专精 {unknownSpecId}]: ClassNames 未登记,未生成此专精映射"); + } + + foreach (var spec in specs) + { + var dynamicSpells = new List(commonSpells); + dynamicSpells.AddRange(ReadArrayStrings(GetIndexedTable(dynamicTable, spec.Id))); + specRoot[spec.Id.ToString()] = CompileSlotMap( + dynamicSpells, + staticSpells, + specialSpells, + existingSpellNames, + spec.Id, + $"{classFile}[专精 {spec.Id} {spec.Name}]", + warnings); + } + + root["专精"] = specRoot; + return (root, warnings); + } + + private static JsonObject CompileSlotMap( + IReadOnlyList dynamicSpells, + IReadOnlyList staticSpells, + IReadOnlyList specialSpells, + ExistingSpellNames existingSpellNames, + int? specId, + string warningContext, + List warnings) + { var dynamicSlots = dynamicSpells.Count * 30; + var requiredSlots = (long)dynamicSpells.Count * 30 + staticSpells.Count + specialSpells.Count; + if (requiredSlots > MacroKind.Length) + { + warnings.Add( + $"{warningContext}: 槽位容量溢出,需要 {requiredSlots} 个,最多 {MacroKind.Length} 个;" + + $"末尾 {requiredSlots - MacroKind.Length} 个槽位不会写入 keymap"); + } var root = new JsonObject(); for (var i = 1; i <= MacroKind.Length; i++) @@ -167,11 +237,11 @@ internal static partial class FuyutsuiKeymapConverter if (entry is { Body.Length: > 0 } && IsWeakSpellName(spell) - && existingSpellNames.TryGetValue(i, out var preserved) + && TryGetExistingSpellName(existingSpellNames, specId, i, out var preserved) && !string.IsNullOrWhiteSpace(preserved) && !IsWeakSpellName(preserved)) { - warnings.Add($"{classFile}[{i}]: 保留原技能名「{preserved}」(宏推导为「{spell}」)"); + warnings.Add($"{warningContext}[{i}]: 保留原技能名「{preserved}」(宏推导为「{spell}」)"); spell = preserved; } } @@ -184,7 +254,45 @@ internal static partial class FuyutsuiKeymapConverter }; } - return (root, warnings); + return root; + } + + private static bool IsSpecializedDynamicFormat(TableValue? dynamicTable) + { + if (dynamicTable is null) + { + return false; + } + + return dynamicTable.GetTable("common") is not null + || GetDynamicSpecIndexes(dynamicTable).Count > 0; + } + + private static TableValue? GetIndexedTable(TableValue? table, int index) + { + return table?.GetTable((long)index) ?? table?.GetTable(index); + } + + private static IReadOnlyList GetDynamicSpecIndexes(TableValue? table) + { + if (table is null) + { + return []; + } + + return table.Entries + .Where(entry => entry.Value is TableValue) + .Select(entry => entry.Key switch + { + long value when value is > 0 and <= int.MaxValue => (int?)value, + int value when value > 0 => value, + _ => null + }) + .Where(index => index is not null) + .Select(index => index!.Value) + .Distinct() + .OrderBy(index => index) + .ToList(); } private static bool IsWeakSpellName(string? spell) @@ -200,7 +308,7 @@ internal static partial class FuyutsuiKeymapConverter internal readonly record struct ParsedMacro(int Unit, string Spell); /// - /// 解析静态宏供 keymap 与宏列表共用。玩家、当前目标、焦点、地面、鼠标指向分别使用保留单位 31-35。 + /// 解析静态宏供 keymap 与宏列表共用。目标类单位使用 31-35,引导/非引导使用 36-37。 /// internal static ParsedMacro ParseStaticMacro(string raw, string? comment = null) { @@ -209,6 +317,11 @@ internal static partial class FuyutsuiKeymapConverter ? ResolveUnitName(target.Groups["unit"].Value) : ReservedUnit.None; + if (unit == ReservedUnit.None && SpecialUnitRegex().Match(raw) is { Success: true } directUnit) + { + unit = ResolveUnitName(directUnit.Groups["unit"].Value); + } + return new ParsedMacro(unit, ResolveSpellName(new MacroEntry(raw, comment))); } @@ -243,6 +356,8 @@ internal static partial class FuyutsuiKeymapConverter "focus" or "焦点" or "33" => ReservedUnit.Focus, "cursor" or "地面" or "34" => ReservedUnit.Cursor, "mouseover" or "鼠标" or "35" => ReservedUnit.Mouseover, + "channeling" or "引导中" or "36" => ReservedUnit.Channeling, + "nochanneling" or "非引导" or "37" => ReservedUnit.NoChanneling, _ => ReservedUnit.None }; } @@ -382,44 +497,93 @@ internal static partial class FuyutsuiKeymapConverter return result; } - private static Dictionary LoadExistingSpellNames(string jsonPath) + private sealed record ExistingSpellNames( + IReadOnlyDictionary Fallback, + IReadOnlyDictionary> BySpec) + { + public static readonly ExistingSpellNames Empty = new( + new Dictionary(), + new Dictionary>()); + } + + private static ExistingSpellNames LoadExistingSpellNames(string jsonPath) { - var result = new Dictionary(); if (!File.Exists(jsonPath)) { - return result; + return ExistingSpellNames.Empty; } try { if (JsonNode.Parse(File.ReadAllText(jsonPath)) is not JsonObject root) { - return result; + return ExistingSpellNames.Empty; } - foreach (var (key, node) in root) + var fallback = ReadExistingSpellNames(root); + var bySpec = new Dictionary>(); + if (JsonHelpers.Get(root, "专精") is JsonObject specRoot) { - if (!int.TryParse(key, out var id) || node is not JsonObject entry) + foreach (var (key, node) in specRoot) { - continue; - } + if (!int.TryParse(key, out var specId) || node is not JsonObject specMap) + { + continue; + } - var spell = JsonHelpers.GetString(JsonHelpers.Get(entry, "技能")) - ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "spell")); - if (!string.IsNullOrWhiteSpace(spell)) - { - result[id] = spell; + bySpec[specId] = ReadExistingSpellNames(specMap); } } + + return new ExistingSpellNames(fallback, bySpec); } catch { // 旧 keymap 损坏时忽略,按宏全量重建。 + return ExistingSpellNames.Empty; + } + } + + private static IReadOnlyDictionary ReadExistingSpellNames(JsonObject map) + { + var result = new Dictionary(); + foreach (var (key, node) in map) + { + if (!int.TryParse(key, out var id) || node is not JsonObject entry) + { + continue; + } + + var spell = JsonHelpers.GetString(JsonHelpers.Get(entry, "技能")) + ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "spell")); + if (!string.IsNullOrWhiteSpace(spell)) + { + result[id] = spell; + } } return result; } + private static bool TryGetExistingSpellName( + ExistingSpellNames existingSpellNames, + int? specId, + int slot, + out string spell) + { + if (specId is { } id + && existingSpellNames.BySpec.TryGetValue(id, out var specNames) + && specNames.TryGetValue(slot, out var specSpell) + && !string.IsNullOrWhiteSpace(specSpell) + && !IsWeakSpellName(specSpell)) + { + spell = specSpell; + return true; + } + + return existingSpellNames.Fallback.TryGetValue(slot, out spell!); + } + private static string[] BuildMacroKind() { var list = new string[Modifiers.Length * Keys.Length]; @@ -450,6 +614,6 @@ internal static partial class FuyutsuiKeymapConverter [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)] + [GeneratedRegex(@"\[\s*@?(?player|target|focus|cursor|mouseover|channeling|nochanneling|玩家|目标|焦点|地面|鼠标|引导中|非引导|无目标|0|31|32|33|34|35|36|37)\s*(?:,|\])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex SpecialUnitRegex(); } diff --git a/Input/KeySender.cs b/Input/KeySender.cs index b639efba..545aeea0 100644 --- a/Input/KeySender.cs +++ b/Input/KeySender.cs @@ -66,44 +66,68 @@ public sealed class KeySender _windowTitle = windowTitle; } + public string? LastFailureReason { get; private set; } + public bool Send(string hotkey) { + LastFailureReason = null; var (mods, mainKey) = ParseHotkey(hotkey); if (mainKey is null) { - return false; + return Fail($"无法解析按键“{hotkey}”"); } var vkMain = GetVk(mainKey); if (vkMain is null) { - return false; + return Fail($"无法识别主键“{mainKey}”"); } var hwnd = NativeMethods.FindWindow(null, _windowTitle); if (hwnd == 0) { - return false; + return Fail($"未找到目标窗口“{_windowTitle}”"); } // ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在 Vk 表里且映射到互异 VK, // 故 GetVk 不会为 null、结果天然去重。 var modVks = mods.Select(m => GetVk(m)!.Value).ToList(); - foreach (var vk in modVks) + var succeeded = true; + var firstError = 0; + void SendMessage(int vk, bool keyUp) { - Post(hwnd, vk, keyUp: false); + if (!Post(hwnd, vk, keyUp, out var error)) + { + succeeded = false; + if (firstError == 0) + { + firstError = error; + } + } } - Post(hwnd, vkMain.Value, keyUp: false); - Post(hwnd, vkMain.Value, keyUp: true); + foreach (var vk in modVks) + { + SendMessage(vk, keyUp: false); + } + + SendMessage(vkMain.Value, keyUp: false); + SendMessage(vkMain.Value, keyUp: true); for (var i = modVks.Count - 1; i >= 0; i--) { - Post(hwnd, modVks[i], keyUp: true); + SendMessage(modVks[i], keyUp: true); } - return true; + if (succeeded) + { + return true; + } + + return Fail(firstError == 5 + ? "权限不足(Win32 错误码 5):请确认 Shigure 与魔兽世界使用相同的管理员权限运行" + : $"向目标窗口发送按键失败,Win32 错误码: {firstError}"); } public static int? GetVk(string keyName) @@ -170,14 +194,32 @@ public sealed class KeySender return (mods, mainKey); } - private static void Post(nint hwnd, int keyCode, bool keyUp) + private bool Fail(string reason) { - nint lParam = keyUp ? unchecked((nint)(int)0xC0000001) : (nint)0x00000001; - NativeMethods.PostMessageW( + LastFailureReason = reason; + return false; + } + + private static bool Post(nint hwnd, int keyCode, bool keyUp, out int error) + { + var scanCode = NativeMethods.MapVirtualKeyW((uint)keyCode, 0) & 0xFF; + var value = 1u | (scanCode << 16); + if (keyCode == 0x6F) // VK_DIVIDE 是扩展键。 + { + value |= 1u << 24; + } + + if (keyUp) + { + value |= (1u << 30) | (1u << 31); + } + + var posted = NativeMethods.PostMessageW( hwnd, keyUp ? NativeMethods.WmKeyUp : NativeMethods.WmKeyDown, (nint)keyCode, - lParam); + unchecked((nint)(int)value)); + error = posted ? 0 : System.Runtime.InteropServices.Marshal.GetLastWin32Error(); + return posted; } } - diff --git a/Input/KeymapCatalog.cs b/Input/KeymapCatalog.cs index d3a79ebc..a554d638 100644 --- a/Input/KeymapCatalog.cs +++ b/Input/KeymapCatalog.cs @@ -6,7 +6,7 @@ namespace Shigure; /// /// 从职业 keymap 文件构建模块编辑器可选择的技能与目标(unit)目录。 /// 同名技能只保留一个;unit 去重后升序排列。 -/// 文件解析规则与 KeymapService.SelectForClass 保持一致。 +/// 专精格式会聚合顶层回退与所有专精映射;有效条目规则与 KeymapService 保持一致。 /// public sealed class KeymapCatalog { @@ -150,44 +150,15 @@ public sealed class KeymapCatalog var seenSpells = new HashSet(StringComparer.Ordinal); var seenUnits = new HashSet(); - foreach (var (_, node) in root) + AddMap(root); + if (JsonHelpers.Get(root, "专精") is JsonObject specRoot) { - if (node is not JsonObject entry) + foreach (var (_, node) in specRoot) { - continue; - } - - var spell = JsonHelpers.GetString(JsonHelpers.Get(entry, "spell")) - ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "技能")); - var hotkey = JsonHelpers.GetString(JsonHelpers.Get(entry, "hotkey")) - ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "热键")); - - // 与运行时一致: 只有技能和热键都非空的条目才能被查到并发送。 - if (string.IsNullOrWhiteSpace(spell) || string.IsNullOrWhiteSpace(hotkey)) - { - continue; - } - - var unit = JsonHelpers.GetInt(JsonHelpers.Get(entry, "unit")) ?? 0; - if (seenSpells.Add(spell)) - { - spells.Add(spell); - } - - if (seenUnits.Add(unit)) - { - units.Add(unit); - } - - if (!unitsBySpell.TryGetValue(spell, out var spellUnits)) - { - spellUnits = new List(); - unitsBySpell[spell] = spellUnits; - } - - if (!spellUnits.Contains(unit)) - { - spellUnits.Add(unit); + if (node is JsonObject specMap) + { + AddMap(specMap); + } } } @@ -196,6 +167,50 @@ public sealed class KeymapCatalog { spellUnits.Sort(); } + + void AddMap(JsonObject map) + { + foreach (var (_, node) in map) + { + if (node is not JsonObject entry) + { + continue; + } + + var spell = JsonHelpers.GetString(JsonHelpers.Get(entry, "spell")) + ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "技能")); + var hotkey = JsonHelpers.GetString(JsonHelpers.Get(entry, "hotkey")) + ?? JsonHelpers.GetString(JsonHelpers.Get(entry, "热键")); + + // 与运行时一致: 只有技能和热键都非空的条目才能被查到并发送。 + if (string.IsNullOrWhiteSpace(spell) || string.IsNullOrWhiteSpace(hotkey)) + { + continue; + } + + var unit = JsonHelpers.GetInt(JsonHelpers.Get(entry, "unit")) ?? 0; + if (seenSpells.Add(spell)) + { + spells.Add(spell); + } + + if (seenUnits.Add(unit)) + { + units.Add(unit); + } + + if (!unitsBySpell.TryGetValue(spell, out var spellUnits)) + { + spellUnits = new List(); + unitsBySpell[spell] = spellUnits; + } + + if (!spellUnits.Contains(unit)) + { + spellUnits.Add(unit); + } + } + } } catch { diff --git a/Input/KeymapService.cs b/Input/KeymapService.cs index b7cce364..bae0730f 100644 --- a/Input/KeymapService.cs +++ b/Input/KeymapService.cs @@ -9,6 +9,7 @@ public sealed class KeymapService private readonly ConfigService _config; private readonly Dictionary<(int Unit, string Spell), string> _hotkeys = new(); private int? _currentClassId; + private int? _currentSpecId; public KeymapService(string baseDirectory, ConfigService config) { @@ -18,12 +19,18 @@ public sealed class KeymapService public void SelectForClass(int? classId) { - if (_currentClassId == classId && _hotkeys.Count > 0) + SelectForClass(classId, null); + } + + public void SelectForClass(int? classId, int? specId) + { + if (_currentClassId == classId && _currentSpecId == specId && _hotkeys.Count > 0) { return; } _currentClassId = classId; + _currentSpecId = specId; _hotkeys.Clear(); var path = KeymapCatalog.ResolveKeymapFilePath(_baseDirectory, _config.GetKeymapName(classId)); @@ -43,7 +50,15 @@ public sealed class KeymapService return; } - foreach (var (_, node) in root) + var entries = root; + if (specId is { } id + && JsonHelpers.Get(root, "专精") is JsonObject specRoot + && JsonHelpers.Get(specRoot, id.ToString()) is JsonObject specEntries) + { + entries = specEntries; + } + + foreach (var (_, node) in entries) { if (node is not JsonObject entry) { @@ -79,4 +94,3 @@ public sealed class KeymapService return _config.GetOneKeySpells(_currentClassId); } } - diff --git a/Input/NativeMethods.cs b/Input/NativeMethods.cs index 20f80985..67e47ce2 100644 --- a/Input/NativeMethods.cs +++ b/Input/NativeMethods.cs @@ -83,6 +83,9 @@ internal static class NativeMethods [return: MarshalAs(UnmanagedType.Bool)] public static extern bool PostMessageW(nint hWnd, uint msg, nint wParam, nint lParam); + [DllImport("user32.dll")] + public static extern uint MapVirtualKeyW(uint uCode, uint uMapType); + [DllImport("user32.dll")] public static extern nint SendMessageW(nint hWnd, uint msg, nint wParam, nint lParam); diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs index 68b12415..1686fafb 100644 --- a/Modules/ModuleStore.cs +++ b/Modules/ModuleStore.cs @@ -16,7 +16,7 @@ public sealed class ModuleDefinition 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 含义相反。 + // v2: 31=玩家、32=目标、33=焦点、34=地面、35=鼠标、36=引导中、37=非引导;旧模块 v1 的 31/34 含义相反。 public int? UnitMappingVersion { get; set; } public bool Enabled { get; set; } = true; public ModuleMatch Match { get; set; } = new(); @@ -627,6 +627,7 @@ public static class ModuleLogic for (var ruleIndex = 0; ruleIndex < module.Rules.Count; ruleIndex++) { var rule = module.Rules[ruleIndex]; + var rateLimitKey = $"{module.Id}:{ruleIndex}"; if (!rule.Enabled) { continue; @@ -636,6 +637,7 @@ public static class ModuleLogic { info["条件错误"] = error; info["规则条件"] = rule.DescribeCondition(); + AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, null); return new LogicDecision(null, $"{module.Name}: 条件错误", info, module.Name); } @@ -650,6 +652,7 @@ public static class ModuleLogic info["动作技能"] = ModuleSpecialActions.PauseSpell; info["动作按键"] = "-"; info["动作单位"] = "-"; + AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, null); return new LogicDecision(null, $"{module.Name}: 暂停", info, module.Name); } @@ -667,6 +670,7 @@ public static class ModuleLogic } var actionSpell = rule.Spell; + var isOneKeySpell = false; if (ModuleSpecialActions.IsFailedSpell(actionSpell)) { actionSpell = ModuleSpecialActions.GetFailedSpell(state, failedSpells); @@ -677,6 +681,7 @@ public static class ModuleLogic } else if (ModuleSpecialActions.IsOneKeySpell(actionSpell)) { + isOneKeySpell = true; actionSpell = ModuleSpecialActions.GetOneKeySpell(state, oneKeySpells); if (string.IsNullOrWhiteSpace(actionSpell)) { @@ -689,6 +694,18 @@ public static class ModuleLogic var hotkey = string.IsNullOrWhiteSpace(rule.Hotkey) ? string.IsNullOrWhiteSpace(actionSpell) ? null : keymap.GetHotkey(resolvedUnit, actionSpell) : rule.Hotkey.Trim(); + if (isOneKeySpell + && string.IsNullOrWhiteSpace(rule.Hotkey) + && string.IsNullOrWhiteSpace(hotkey) + && !string.IsNullOrWhiteSpace(actionSpell)) + { + hotkey = keymap.GetHotkey(ReservedUnit.NoChanneling, actionSpell); + if (!string.IsNullOrWhiteSpace(hotkey)) + { + resolvedUnit = ReservedUnit.NoChanneling; + } + } + var step = BuildStep(module, rule, hotkey, actionSpell); info["命中条件"] = string.IsNullOrWhiteSpace(rule.Condition) ? "始终" : rule.Condition; info["动作技能"] = string.IsNullOrWhiteSpace(actionSpell) ? "-" : actionSpell; @@ -696,15 +713,14 @@ public static class ModuleLogic info["动作单位"] = string.IsNullOrWhiteSpace(rule.UnitName) ? resolvedUnit.GetValueOrDefault() : $"{rule.UnitName} → {resolvedUnit.GetValueOrDefault()}"; - info["动作延迟"] = rule.DelayMs is > 0 ? $"{rule.DelayMs.Value} ms" : "-"; - info["逻辑延迟"] = rule.LogicDelayMs is > 0 ? $"{rule.LogicDelayMs.Value} ms" : "-"; + AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, hotkey); return new LogicDecision( hotkey, step, info, module.Name, rule.DelayMs.GetValueOrDefault(), - $"{module.Id}:{ruleIndex}", + rateLimitKey, rule.LogicDelayMs.GetValueOrDefault()); } @@ -712,6 +728,20 @@ public static class ModuleLogic return new LogicDecision(null, $"{module.Name}: 无匹配规则", info, module.Name); } + private static void AddRuleLogInfo( + IDictionary info, + ModuleRule rule, + int ruleIndex, + string rateLimitKey, + string? hotkey) + { + info["动作按键"] = string.IsNullOrWhiteSpace(hotkey) ? "-" : hotkey; + info["动作延迟"] = rule.DelayMs is > 0 ? $"{rule.DelayMs.Value} ms" : "-"; + info["逻辑延迟"] = rule.LogicDelayMs is > 0 ? $"{rule.LogicDelayMs.Value} ms" : "-"; + info["规则编号"] = ruleIndex + 1; + info["限流键"] = rateLimitKey; + } + // 把模块定义的动态单位/数量各解析一次, 写入当前帧 state.Values 供条件求值与目标解析使用。 public static Dictionary ResolveDynamicFields(ModuleDefinition module, GameState state) { diff --git a/Modules/ReservedUnit.cs b/Modules/ReservedUnit.cs index 4edc058b..226f59af 100644 --- a/Modules/ReservedUnit.cs +++ b/Modules/ReservedUnit.cs @@ -13,6 +13,8 @@ internal static class ReservedUnit public const int Focus = 33; public const int Cursor = 34; public const int Mouseover = 35; + public const int Channeling = 36; + public const int NoChanneling = 37; public static string ToDisplayText(int unit) { @@ -24,6 +26,8 @@ internal static class ReservedUnit Focus => "焦点", Cursor => "地面", Mouseover => "鼠标", + Channeling => "引导中", + NoChanneling => "非引导", _ => unit.ToString(CultureInfo.InvariantCulture) }; } @@ -39,6 +43,8 @@ internal static class ReservedUnit "焦点" => Focus, "地面" => Cursor, "鼠标" => Mouseover, + "引导中" => Channeling, + "非引导" => NoChanneling, _ => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unit) ? unit : null diff --git a/Runtime/PixelScanner.cs b/Runtime/PixelScanner.cs index af0f00ce..ddaf305e 100644 --- a/Runtime/PixelScanner.cs +++ b/Runtime/PixelScanner.cs @@ -7,7 +7,8 @@ namespace Shigure; public sealed record ScreenScanResult( IReadOnlyDictionary? RowData, IReadOnlyDictionary BarData, - IReadOnlyDictionary HealAbsorbData); + IReadOnlyDictionary HealAbsorbData, + string? FailureReason); public sealed class PixelScanner { @@ -35,22 +36,40 @@ public sealed class PixelScanner var emptyBars = new Dictionary(); var emptyAbsorb = new Dictionary(); var hwnd = NativeMethods.FindWindow(null, _windowTitle); - if (hwnd == 0 || NativeMethods.IsIconic(hwnd)) + if (hwnd == 0) { - return new ScreenScanResult(null, emptyBars, emptyAbsorb); + return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"未找到目标窗口“{_windowTitle}”"); + } + + if (NativeMethods.IsIconic(hwnd)) + { + return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"目标窗口“{_windowTitle}”已最小化"); } var point = new NativeMethods.Point(0, 0); - if (!NativeMethods.ClientToScreen(hwnd, ref point) || !NativeMethods.GetClientRect(hwnd, out var rect)) + if (!NativeMethods.ClientToScreen(hwnd, ref point)) { - return new ScreenScanResult(null, emptyBars, emptyAbsorb); + return new ScreenScanResult( + null, + emptyBars, + emptyAbsorb, + $"无法获取目标窗口的屏幕坐标,Win32 错误码: {Marshal.GetLastWin32Error()}"); + } + + if (!NativeMethods.GetClientRect(hwnd, out var rect)) + { + return new ScreenScanResult( + null, + emptyBars, + emptyAbsorb, + $"无法获取目标窗口的客户区尺寸,Win32 错误码: {Marshal.GetLastWin32Error()}"); } var width = rect.Right - rect.Left; var height = rect.Bottom - rect.Top; if (width <= 0 || height <= 0) { - return new ScreenScanResult(null, emptyBars, emptyAbsorb); + return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"目标窗口客户区尺寸无效: {width}×{height}"); } try @@ -63,11 +82,17 @@ public sealed class PixelScanner var healAbsorbData = markerY is null ? emptyAbsorb : ScanHealAbsorbGrid(point.X, point.Y, width, height, markerY.Value); - return new ScreenScanResult(rowData.Count == 0 ? null : rowData, barData, healAbsorbData); + return rowData.Count == 0 + ? new ScreenScanResult(null, barData, healAbsorbData, "未找到有效的状态像素起始标记") + : new ScreenScanResult( + rowData, + barData, + healAbsorbData, + markerY is null ? "未找到 CountBars 标记,层数条和治疗吸收数据未采集" : null); } - catch + catch (Exception ex) { - return new ScreenScanResult(null, emptyBars, emptyAbsorb); + return new ScreenScanResult(null, emptyBars, emptyAbsorb, $"{ex.GetType().Name}: {ex.Message}"); } } diff --git a/Runtime/RenderSnapshot.cs b/Runtime/RenderSnapshot.cs index e11fda08..805d74a4 100644 --- a/Runtime/RenderSnapshot.cs +++ b/Runtime/RenderSnapshot.cs @@ -10,6 +10,7 @@ public sealed record RenderSnapshot( GameState? State, string CurrentStep, IReadOnlyDictionary UnitInfo, - IReadOnlyList DynamicValues); + IReadOnlyList DynamicValues, + string? ScanFailureReason); public sealed record DynamicValueSnapshot(string Kind, string Name, string Value); diff --git a/Runtime/ShigureRuntime.cs b/Runtime/ShigureRuntime.cs index fc85ab04..36557070 100644 --- a/Runtime/ShigureRuntime.cs +++ b/Runtime/ShigureRuntime.cs @@ -18,6 +18,7 @@ public sealed class ShigureRuntime private int? _classId; private int? _specId; private string? _moduleName; + private string? _scanFailureReason; private string _currentStep = "等待启动"; private IReadOnlyDictionary _unitInfo = new Dictionary(); private bool _enabled; @@ -157,6 +158,7 @@ public sealed class ShigureRuntime private void TickLogic() { var scan = _scanner.ScanScreenData(); + _scanFailureReason = scan.FailureReason; if (scan.RowData is null) { @@ -179,7 +181,7 @@ public sealed class ShigureRuntime _classId = _state.GetInt("职业"); _specId = _state.GetInt("专精"); (_className, _specName) = ClassNames.GetClassAndSpecName(_classId, _specId); - _keymap.SelectForClass(_classId); + _keymap.SelectForClass(_classId, _specId); if (!_state.GetBool("有效性")) { @@ -225,7 +227,18 @@ public sealed class ShigureRuntime private void SendAndPauseLogic(LogicDecision decision) { - _keySender.Send(decision.Hotkey!); + if (!_keySender.Send(decision.Hotkey!)) + { + var info = _unitInfo.ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.Ordinal); + info["发送失败"] = _keySender.LastFailureReason ?? "未知原因"; + _unitInfo = info; + _currentStep = $"{decision.Step}(按键发送失败)"; + return; + } + if (decision.LogicDelayMs > 0) { _logicPausedUntil = DateTimeOffset.UtcNow.AddMilliseconds(decision.LogicDelayMs); @@ -264,7 +277,8 @@ public sealed class ShigureRuntime _state, _currentStep, _unitInfo, - BuildDynamicValues(_state))); + BuildDynamicValues(_state), + _scanFailureReason)); } private static IReadOnlyList BuildDynamicValues(GameState? state) diff --git a/TEXTURE_LAYOUT_zh-CN.md b/TEXTURE_LAYOUT_zh-CN.md index 9b5a641f..77abb992 100644 --- a/TEXTURE_LAYOUT_zh-CN.md +++ b/TEXTURE_LAYOUT_zh-CN.md @@ -71,7 +71,7 @@ flowchart LR ```lua states = { - ["状态"] = { "锚点", "职业", ..., "敌人人数" }, + ["状态"] = { "锚点", "职业", ..., "敌人数量" }, ["目标"] = { "类型", "生命值", "施法", "施法可打断" }, ["焦点"] = { ... }, -- 可选 } @@ -102,7 +102,7 @@ states = { | 7 | 一键辅助 | | 8 | 插入法术 | -其后是专精自定义状态(战斗时间、生命值、能量、敌人人数等)。 +其后是专精自定义状态(战斗时间、生命值、能量、敌人数量等)。 ### 3.2 扁平写法(兼容) @@ -270,7 +270,7 @@ spells 推导的计数条(CreateAutoLayoutBar) 配置见 `class/Priest.lua` `[1]`。主色块从左到右概念顺序: ```text -[状态…] 锚点…敌人人数 +[状态…] 锚点…敌人数量 → [目标…] 目标类型、目标生命值、目标施法、目标施法可打断 → [auras.player] 黑暗主宰、圣光涌动、福音、祸福相依、熵能裂隙 → [auras.target] 暗言术:痛 → 救赎 → 真言术:盾 diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index 111d355d..cb014e25 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -19,6 +19,7 @@ public sealed class ClassMacrosEditorControl : UserControl 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(); @@ -26,6 +27,8 @@ public sealed class ClassMacrosEditorControl : UserControl 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; @@ -319,15 +322,29 @@ public sealed class ClassMacrosEditorControl : UserControl private Control BuildDynamicPage() { - var panel = new TableLayoutPanel + 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, 196)); + 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 + BackColor = UiTheme.SurfaceRaised, + Margin = new Padding(0) }; - panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + editor.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + editor.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); ConfigureGrid(_dynamicGrid); _dynamicGrid.Columns.Add(new DataGridViewTextBoxColumn @@ -338,8 +355,53 @@ public sealed class ClassMacrosEditorControl : UserControl }); _dynamicGrid.Columns.Add(CreateDeleteColumn()); WireGrid(_dynamicGrid); - panel.Controls.Add(_dynamicGrid, 0, 0); - panel.Controls.Add(BuildMoveButtons(_dynamicGrid), 0, 1); + 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); + _dynamicSpecList.SelectedIndexChanged += (_, _) => + { + if (!_suppressUi) + { + SelectDynamicSpecFromList(); + } + }; + panel.Controls.Add(_dynamicSpecList, 0, 1); return panel; } @@ -523,6 +585,8 @@ public sealed class ClassMacrosEditorControl : UserControl _document = null; _currentMacros = null; _currentClassFile = null; + _currentClassId = null; + _currentDynamicSpecIndex = null; _dirty = false; _suppressUi = true; @@ -635,6 +699,7 @@ public sealed class ClassMacrosEditorControl : UserControl } _currentClassFile = item.ClassFile; + _currentClassId = item.ClassId > 0 ? item.ClassId : null; if (_document is null) { _currentMacros = null; @@ -666,18 +731,22 @@ public sealed class ClassMacrosEditorControl : UserControl _dynamicGrid.Rows.Clear(); _staticGrid.Rows.Clear(); _specialGrid.Rows.Clear(); + _dynamicSpecList.Items.Clear(); + _currentDynamicSpecIndex = null; if (_currentMacros is null) { return; } - foreach (var name in _currentMacros.DynamicSpells) - { - _dynamicGrid.Rows.Add(name, "×"); - } - + RebuildDynamicSpecList(); AddArrayRows(_staticGrid, _currentMacros.StaticSpells); AddArrayRows(_specialGrid, _currentMacros.SpecialSpells); + if (_dynamicSpecList.Items.Count > 0) + { + _dynamicSpecList.SelectedIndex = 0; + } + + FillDynamicEditor(); } finally { @@ -687,29 +756,112 @@ public sealed class ClassMacrosEditorControl : UserControl UpdateOffsetHint(); } - private void UpdateOffsetHint() + private void RebuildDynamicSpecList() { - var dynamicCount = 0; - var staticCount = 0; - if (_currentMacros is not null) + _dynamicSpecList.Items.Clear(); + _dynamicSpecList.Items.Add(new DynamicSpecOption(_currentClassId, null, "通用")); + + var knownSpecIndexes = new HashSet(); + if (_currentClassId is { } classId) { - // 数组中的空字符串同样占槽,所以按实际行数计算。 - dynamicCount = _dynamicGrid.Rows.Cast().Count(r => !r.IsNewRow); - staticCount = _staticGrid.Rows.Cast().Count(r => !r.IsNewRow); - if (!_dirty) + foreach (var spec in ClassNames.GetSpecs(classId)) { - dynamicCount = _currentMacros.DynamicSpells.Count; - staticCount = _currentMacros.StaticSpells.Count; + knownSpecIndexes.Add(spec.Id); + _dynamicSpecList.Items.Add(new DynamicSpecOption(classId, spec.Id, spec.Name)); } } - var dynamicSlots = dynamicCount * 30; - var firstStatic = dynamicSlots + 1; - var firstSpecial = dynamicSlots + staticCount + 1; - _offsetLabel.Text = - $"动态宏 {dynamicCount} 项,占 {dynamicSlots} 槽;静态宏 [1] = 全局 {firstStatic};特殊宏 [1] = 全局 {firstSpecial}"; + 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; + FillDynamicEditor(); + 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) + { + _dynamicGrid.Rows.Add(name, "×"); + } + } + 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) @@ -717,7 +869,19 @@ public sealed class ClassMacrosEditorControl : UserControl return; } - _currentMacros.DynamicSpells.Clear(); + 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) @@ -725,15 +889,21 @@ public sealed class ClassMacrosEditorControl : UserControl continue; } - var name = row.Cells["Name"].Value?.ToString()?.Trim(); - if (!string.IsNullOrWhiteSpace(name)) - { - _currentMacros.DynamicSpells.Add(name); - } + values.Add(row.Cells["Name"].Value?.ToString()?.Trim() ?? string.Empty); } - WriteArrayGrid(_staticGrid, _currentMacros.StaticSpells); - WriteArrayGrid(_specialGrid, _currentMacros.SpecialSpells); + 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) @@ -814,9 +984,11 @@ public sealed class ClassMacrosEditorControl : UserControl private void ClearGrids() { + _dynamicSpecList.Items.Clear(); _dynamicGrid.Rows.Clear(); _staticGrid.Rows.Clear(); _specialGrid.Rows.Clear(); + _currentDynamicSpecIndex = null; } private void HandleDeleteClick(object? sender, DataGridViewCellEventArgs e) @@ -936,6 +1108,11 @@ public sealed class ClassMacrosEditorControl : UserControl } } + 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() diff --git a/UI/MainForm.cs b/UI/MainForm.cs index 60107b22..8c2af7c3 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -60,7 +60,8 @@ public sealed class MainForm : Form, IMessageFilter private Task? _runtimeTask; private RenderSnapshot? _lastSnapshot; private string? _lastLoggedStep; - private string? _lastLoggedStepTarget; + private string? _lastLoggedStepDetails; + private string? _lastLoggedScanFailureReason; private string? _lastLoggedClass; private string? _lastLoggedModule; private bool? _lastLoggedEnabled; @@ -716,7 +717,8 @@ public sealed class MainForm : Form, IMessageFilter } _lastLoggedStep = null; - _lastLoggedStepTarget = null; + _lastLoggedStepDetails = null; + _lastLoggedScanFailureReason = null; _lastLoggedClass = null; _lastLoggedModule = null; _lastLoggedEnabled = null; @@ -983,6 +985,23 @@ public sealed class MainForm : Form, IMessageFilter private void WriteSnapshotLog(RenderSnapshot snapshot) { + if (!string.Equals(snapshot.ScanFailureReason, _lastLoggedScanFailureReason, StringComparison.Ordinal)) + { + if (string.IsNullOrWhiteSpace(snapshot.ScanFailureReason)) + { + if (!string.IsNullOrWhiteSpace(_lastLoggedScanFailureReason)) + { + AppendLog("扫描已恢复"); + } + } + else + { + AppendLog($"扫描失败: {snapshot.ScanFailureReason}"); + } + + _lastLoggedScanFailureReason = snapshot.ScanFailureReason; + } + var classSpec = snapshot.ClassName is null ? null : $"{snapshot.ClassName} / {snapshot.SpecName ?? "-"}"; if (!string.IsNullOrWhiteSpace(classSpec) && classSpec != _lastLoggedClass) { @@ -1007,26 +1026,44 @@ public sealed class MainForm : Form, IMessageFilter if (!string.IsNullOrWhiteSpace(snapshot.CurrentStep)) { - var target = GetActionTarget(snapshot); - if (snapshot.CurrentStep != _lastLoggedStep || target != _lastLoggedStepTarget) + var details = BuildStepLogDetails(snapshot); + if (snapshot.CurrentStep != _lastLoggedStep || details != _lastLoggedStepDetails) { _lastLoggedStep = snapshot.CurrentStep; - _lastLoggedStepTarget = target; - var targetText = string.IsNullOrWhiteSpace(target) ? string.Empty : $",目标: {target}"; - AppendLog($"步骤: {snapshot.CurrentStep}{targetText}"); + _lastLoggedStepDetails = details; + AppendLog($"步骤: {snapshot.CurrentStep}{details}"); } } } - private static string? GetActionTarget(RenderSnapshot snapshot) + private static string BuildStepLogDetails(RenderSnapshot snapshot) { - if (!snapshot.UnitInfo.TryGetValue("动作单位", out var value)) + var fields = new (string Key, string Label)[] { - return null; + ("动作单位", "目标"), + ("动作按键", "按键"), + ("动作延迟", "动作延迟"), + ("逻辑延迟", "逻辑延迟"), + ("规则编号", "规则编号"), + ("限流键", "限流键"), + ("发送失败", "发送失败") + }; + var details = new List(); + foreach (var (key, label) in fields) + { + if (!snapshot.UnitInfo.TryGetValue(key, out var value)) + { + continue; + } + + var text = UiTheme.FormatValue(value); + if (!string.IsNullOrWhiteSpace(text)) + { + details.Add($"{label}: {text}"); + } } - var text = UiTheme.FormatValue(value); - return string.IsNullOrWhiteSpace(text) || text == "-" ? null : text; + return details.Count == 0 ? string.Empty : $",{string.Join(",", details)}"; } private void SetRuntimeControls(bool running) diff --git a/UI/StatusForm.cs b/UI/StatusForm.cs index 48e59232..021c48c0 100644 --- a/UI/StatusForm.cs +++ b/UI/StatusForm.cs @@ -7,8 +7,8 @@ namespace Shigure; public sealed class StatusForm : Form { private const string AboutWatermarkResourcePath = "Assets.arasaka-icon-transparent.png"; - private const int AboutWatermarkSize = 800; - private const int AboutWatermarkBottomMargin = 80; + private const int AboutWatermarkSize = 440; + private const int AboutWatermarkTopMargin = 16; private const float AboutWatermarkOpacity = 0.08F; private readonly List<(Button Button, Control View)> _navItems = new(); @@ -23,11 +23,11 @@ public sealed class StatusForm : Form private ListView _unitInfoList = null!; private TextBox _logTextBox = null!; private Panel _contentHost = null!; - private Panel _settingsHost = null!; - private Panel _configHost = null!; - private Panel _macrosHost = null!; - private Panel _moduleHost = null!; - private Panel _aboutHost = null!; + private Panel _settingsHost = null!; + private Panel _configHost = null!; + private Panel _macrosHost = null!; + private Panel _moduleHost = null!; + private Panel _aboutHost = null!; public StatusForm() { @@ -323,7 +323,7 @@ public sealed class StatusForm : Form var scrollHost = new WatermarkPanel( GetEmbeddedResourceName(AboutWatermarkResourcePath), AboutWatermarkSize, - AboutWatermarkBottomMargin, + AboutWatermarkTopMargin, AboutWatermarkOpacity) { Dock = DockStyle.Fill, @@ -432,8 +432,9 @@ public sealed class StatusForm : Form [ "有效性", "战斗时间", "移动", "生命值", "一键辅助", "插入法术", "队伍类型", "队伍人数", "首领战", "难度", "英雄天赋", "施法目标", - "施法技能", "敌人人数", "施法", "引导", "蓄力", "蓄力层数", - "酒池", "符文", "姿态", "救赎之魂1", "救赎之魂2", + "施法技能", "敌人数量", "敌人数-无仇恨", "敌人数-有仇恨", "施法", + "引导", "蓄力", "蓄力层数", "酒池", "符文", "姿态", + "救赎之魂1", "救赎之魂2", ], 150), 0, 0); fields.Controls.Add(CreateAboutFieldCard( @@ -554,13 +555,13 @@ public sealed class StatusForm : Form { private readonly Bitmap? _watermark; private readonly int _watermarkSize; - private readonly int _bottomMargin; + private readonly int _topMargin; private readonly float _opacity; - public WatermarkPanel(string resourceName, int watermarkSize, int bottomMargin, float opacity) + public WatermarkPanel(string resourceName, int watermarkSize, int topMargin, float opacity) { _watermarkSize = watermarkSize; - _bottomMargin = bottomMargin; + _topMargin = topMargin; _opacity = Math.Clamp(opacity, 0F, 1F); DoubleBuffered = true; ResizeRedraw = true; @@ -583,9 +584,10 @@ public sealed class StatusForm : Form return; } + var preferredLeft = ClientSize.Width / 2; var bounds = new Rectangle( - (ClientSize.Width - _watermarkSize) / 2, - ClientSize.Height - _watermarkSize - _bottomMargin, + Math.Min(preferredLeft, Math.Max(0, ClientSize.Width - _watermarkSize)), + _topMargin, _watermarkSize, _watermarkSize); diff --git a/UI/UiTheme.cs b/UI/UiTheme.cs index 9750b3ef..6624dc5a 100644 --- a/UI/UiTheme.cs +++ b/UI/UiTheme.cs @@ -271,7 +271,8 @@ internal static class UiTheme public static void StyleListBox( ListBox listBox, Font font, - Func? moduleMatchSelector = null) + Func? moduleMatchSelector = null, + bool showClassIconWithSpec = true) { listBox.BackColor = Surface; listBox.ForeColor = Text; @@ -308,13 +309,22 @@ internal static class UiTheme 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 - }; + Image?[] icons = showClassIconWithSpec + ? + [ + classId is { } matchedClassId ? GetClassIcon(matchedClassId) : null, + classId is { } matchedSpecClassId && specId is { } matchedSpecId + ? GetSpecIcon(matchedSpecClassId, matchedSpecId) + : null + ] + : + [ + classId is { } singleClassId + ? specId is { } singleSpecId + ? GetSpecIcon(singleClassId, singleSpecId) + : GetClassIcon(singleClassId) + : null + ]; var iconSize = Math.Min(font.Height, e.Bounds.Height - 8); foreach (var icon in icons) { diff --git a/config/DeathKnight.json b/config/DeathKnight.json index 03abe27e..4f8fc653 100644 --- a/config/DeathKnight.json +++ b/config/DeathKnight.json @@ -134,7 +134,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -318,7 +318,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -499,7 +499,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/DemonHunter.json b/config/DemonHunter.json index d07f96f1..a9d5895f 100644 --- a/config/DemonHunter.json +++ b/config/DemonHunter.json @@ -148,7 +148,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -328,7 +328,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -557,7 +557,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Druid.json b/config/Druid.json index dc3e2e6b..f879e090 100644 --- a/config/Druid.json +++ b/config/Druid.json @@ -160,7 +160,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -300,7 +300,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -436,7 +436,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Hunter.json b/config/Hunter.json index d9e15631..7b896473 100644 --- a/config/Hunter.json +++ b/config/Hunter.json @@ -138,7 +138,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Mage.json b/config/Mage.json index cfc94050..a85981d8 100644 --- a/config/Mage.json +++ b/config/Mage.json @@ -355,7 +355,7 @@ "step": 19, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 20, "type": "int" }, diff --git a/config/Monk.json b/config/Monk.json index 6bfd29ec..94bcf451 100644 --- a/config/Monk.json +++ b/config/Monk.json @@ -126,7 +126,7 @@ "step": 19, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 20, "type": "int" }, @@ -335,7 +335,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -533,7 +533,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Paladin.json b/config/Paladin.json index 56f38863..e45d277c 100644 --- a/config/Paladin.json +++ b/config/Paladin.json @@ -498,7 +498,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Priest.json b/config/Priest.json index f39c1ec8..03154a18 100644 --- a/config/Priest.json +++ b/config/Priest.json @@ -135,7 +135,7 @@ "step": 20, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 21, "type": "int" }, @@ -576,7 +576,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Shaman.json b/config/Shaman.json index db5e1818..4aa45514 100644 --- a/config/Shaman.json +++ b/config/Shaman.json @@ -145,7 +145,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, @@ -305,7 +305,7 @@ "step": 18, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 19, "type": "int" }, diff --git a/config/Warrior.json b/config/Warrior.json index 860ed621..b5c0f22c 100644 --- a/config/Warrior.json +++ b/config/Warrior.json @@ -115,7 +115,7 @@ "step": 14, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 15, "type": "int" }, @@ -279,7 +279,7 @@ "step": 14, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 15, "type": "int" }, @@ -419,7 +419,7 @@ "step": 14, "type": "int" }, - "敌人人数": { + "敌人数量": { "step": 15, "type": "int" },