diff --git a/CLASSMACROS_AI_Reference_zh-CN.md b/CLASSMACROS_AI_Reference_zh-CN.md index 4ec022c7..b5b98da9 100644 --- a/CLASSMACROS_AI_Reference_zh-CN.md +++ b/CLASSMACROS_AI_Reference_zh-CN.md @@ -8,7 +8,7 @@ related: - "Keymap.md" - "AGENTS.md" - "AI_CODEBASE_GUIDE_zh-CN.md" -purpose: "供 AI 理解、审查、新增职业宏时作为单一事实来源;说明 ClassMacros 三表规则与 CreateMacro 展开逻辑" +purpose: "供 AI 理解、审查、新增职业宏时作为单一事实来源;说明 ClassMacros 三表规则、MacroBodies 查表与 CreateMacro 展开逻辑" --- # Fuyutsui `core/classmacros.lua`:AI 宏规则参考 @@ -18,7 +18,11 @@ purpose: "供 AI 理解、审查、新增职业宏时作为单一事实来源; ## 0. 一句话定义 -`classmacros.lua` 只提供数据表 `Fuyutsui.ClassMacros[classFile]`。 +`classmacros.lua` 提供: + +1. `Fuyutsui.MacroBodies`:命名宏体查表(药水等)。 +2. `Fuyutsui.ClassMacros[classFile]`:各职业的三份**数组**列表。 + 运行时由 `main.lua:LoadPlayerMacros()` 按 `UnitClassBase("player")` 取出当前职业,再调用: ```lua @@ -27,31 +31,38 @@ 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`(可为空表 `{}`)。 -3. **需要点名队友/团队成员**的治疗、驱散、护盾等 → 放 `dynamicSpells`(数组,顺序敏感)。 -4. **普通单体施法**(目标默认、或条件写在法术名里)→ 放 `staticSpells[index]`,只写法术/条件字符串,**不要**自己加 `/cast `。 -5. **需要完整宏文本**(`/castsequence`、`/stopcasting`、`/cancelaura`、多行不以自动 `/cast` 开头等)→ 放 `specialSpells[index]`,**必须写完整命令**。 -6. **同序号冲突时**:`specialSpells[index]` **优先于** `staticSpells[index]`;被覆盖的 static 可删掉,或留注释说明原意。 -7. `staticSpells` / `specialSpells` 的 `index` 是**动态区之后的相对序号**,不是全局热键序号。 -8. 战斗中 `InCombatLockdown()` 时无法创建/修改安全按钮;改宏后需在脱战后 `/reload` 或等下次非战斗加载生效。 -9. 保持 Lua 5.1 / WoW 兼容;法术名用**本地化中文名**(与客户端一致),与现有表风格一致。 -10. 不要格式化整个文件;只改目标职业段落。 +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 定义 Fuyutsui.ClassMacros - │ +classmacros.lua + │ MacroBodies(命名宏体) + │ ClassMacros[classFile] + ▼ main.lua:LoadPlayerMacros() │ classFile = UnitClassBase("player") │ MacrosList = ClassMacros[classFile] ▼ macro.lua:CreateMacro(dynamic, static, special) - │ 按 macroKind[i] 生成按钮 s1..sN - │ SetOverrideBindingClick + macrotext + │ 1. dynamic:每组 × 30 键 + │ 2. static:ipairs 依次占键(resolveMacroBody) + │ 3. special:接在 static 后依次占键(resolveMacroBody) + │ SetOverrideBindingClick + macrotext → 按钮 s1..sN ▼ 玩家按覆盖热键 → SecureActionButton 执行宏 ``` @@ -64,38 +75,41 @@ macro.lua:CreateMacro(dynamic, static, special) | 修饰键顺序 | 基础键(每组 39 个) | |---|---| -| `CTRL` → `ALT` → `SHIFT` → `ALT-CTRL` → `ALT-SHIFT` → `CTRL-SHIFT` → `ALT-CTRL-SHIFT` | 小键盘 0–9 / 小数点 / ± / − / × / ÷;`F1–F3,F5–F12`(**无 F4**);`, . / ; ' [ ] \`;`7 8 9 0 =` | +| `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 的相对 `index`;不必手算全局热键,除非要核对外部程序按键映射。 +**AI 改宏时**:关心的是 `dynamic` 占用多少槽、以及 static/special 数组下标对应的全局热键;不必手算,除非要核对外部程序按键映射。 ## 4. 槽位分配算法(核心) -对每个热键下标 `i = 1 .. #macroKind`: +`CreateMacro` **按顺序推进槽位下标 `i`**(从 1 起),不再用「special 与 static 同序号互斥覆盖」: ```text -dynamicSlots = #dynamicSpells * 30 +i = 1 -if i <= dynamicSlots then - -- 动态宏:按技能组 + raid 位展开(见 §5) -else - index = i - dynamicSlots - if specialSpells[index] then - macroBody = specialSpells[index] -- 原样使用 - elseif staticSpells[index] then - macroBody = "/cast " .. staticSpells[index] -- 自动加前缀 - end -end +-- 1. dynamicSpells +for each spell in dynamicSpells: + for raidIdx = 1..30: + 按 §5 生成 macroBody(spell 为空则不建按钮) + 占用 macroKind[i],i = i + 1 -若 macroBody 非空 → 创建/更新按钮 s{i} +-- 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` 长度 | 动态占用槽 | static/special 的 `[1]` 对应全局 `i` | +| `dynamicSpells` 长度 | 动态占用槽 | `staticSpells[1]` 对应全局 `i` | |---:|---:|---:| | 0 | 0 | 1 | | 1 | 30 | 31 | @@ -103,7 +117,18 @@ end | 6 | 180 | 181 | | 7 | 210 | 211 | -**新增或删除 `dynamicSpells` 条目会平移其后所有 static/special 的实际热键。** 改动态列表前必须评估这个偏移。 +**新增或删除 `dynamicSpells` 条目会平移其后所有 static/special 的实际热键。** +**在 `staticSpells` 中间插入/删除条目(含 `""`)同样会平移其后键位。** + +### 4.1 `resolveMacroBody(spell)` + +| 情况 | 结果 | +|---|---| +| `spell` 为 `nil` 或 `""` | 不创建宏(槽位仍前进) | +| `Fuyutsui.MacroBodies[spell]` 存在,且值以 `/` 开头 | 原样使用表内值 | +| `MacroBodies[spell]` 存在,值不以 `/` 开头 | `"/cast " .. 表内值` | +| `spell` 本身以 `/` 开头 | 原样使用(完整宏文本) | +| 其他 | `"/cast " .. spell` | ## 5. `dynamicSpells` 规则 @@ -139,11 +164,11 @@ dynamicSpells = { "法术A", "法术B", "法术C" } -- 数组,1-based,顺 不适合: - 只打当前目标 / 自身 / 鼠标指向 / 焦点 → 用 `staticSpells` + 条件前缀。 -- 需要 sequence / stopcasting / cancelaura → 用 `specialSpells`。 +- 需要 sequence / stopcasting / cancelaura → 在 `staticSpells` 写以 `/` 开头的完整文本,或放入 `specialSpells`。 ### 5.4 现有职业占用(便于估算偏移) -| 职业键 | `#dynamicSpells` | 动态槽 | static `[1]` 的全局 `i` | +| 职业键 | `#dynamicSpells` | 动态槽 | static 第 1 项的全局 `i` | |---|---:|---:|---:| | WARRIOR / HUNTER / ROGUE / DEATHKNIGHT / WARLOCK / DEMONHUNTER | 0 | 0 | 1 | | MAGE | 1 | 30 | 31 | @@ -157,15 +182,19 @@ dynamicSpells = { "法术A", "法术B", "法术C" } -- 数组,1-based,顺 ```lua staticSpells = { - [1] = "英勇投掷", - [5] = "[@mouseover]保护祝福", - [20] = "[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒", - [37] = "item:241304\n/cast item:241305", -- 会变成 /cast item:...\n/cast item:... + "英勇投掷", + "[@mouseover]保护祝福", + "[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒", + "银月城生命药水", -- 走 MacroBodies + "/castsequence reset=0.3 真言术:耀,x", -- 以 / 开头:完整宏 + "", -- 空串:占位跳过,不建按钮 } ``` -- **稀疏表**合法:缺号表示该相对序号不建宏。 -- 值是**拼在 `/cast ` 后面的字符串**,不是完整宏(除非用 `\n` 续写后续行)。 +- **数组顺序 = 占键顺序**(接在 dynamic 区之后)。 +- 普通条目是**拼在 `/cast ` 后面的字符串**,不要自带前导 `/cast `。 +- 需要完整命令时,直接写以 `/` 开头的字符串。 +- `""` 保留槽位但不创建按钮(例如法师历史上空出的位置)。 ### 6.2 常见写法(直接抄现有风格) @@ -179,29 +208,54 @@ staticSpells = { | 专精分支 | `"[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒"` | `/cast [spec:2]...` | | 天赋已知 | `"[known:116844,@cursor]平心之环;[known:198898]赤精之歌"` | `/cast [known:...]...` | | 姿态/形态 | `"[nostance:1]暗影形态"` | `/cast [nostance:1]暗影形态` | -| 双物品一行 | `"item:241304\n/cast item:241305"` | `/cast item:241304` + 第二行 `/cast item:241305` | +| 命名药水 | `"银月城生命药水"` | `/cast item:241304` + 第二行 `/cast item:241305`(见 MacroBodies) | +| 完整宏 | `"/stopcasting"` | `/stopcasting`(不加 `/cast`) | +| 占位跳过 | `""` | 不创建 | -### 6.3 不要放进 static 的情况 +## 7. `Fuyutsui.MacroBodies`(命名宏体) -若第一行**不能**以自动加的 `/cast ` 开头,或需要 `/castsequence`、`/stopcasting`、`/cancelaura` 等,改用 `specialSpells`。 +### 7.1 作用 -## 7. `specialSpells` 规则 - -### 7.1 数据结构 +列表里写**可读名称**,实际宏体集中维护,避免多职业重复粘贴物品 ID。 ```lua -specialSpells = { - [36] = "/castsequence reset=0.3 真言术:耀,x", - [39] = "/stopcasting", - [17] = "/cancelaura [spec:4]猎豹形态\n/cast 万灵之召", +Fuyutsui.MacroBodies = { + ["鲁莽药水"] = "item:241288\n/cast item:241289", + ["银月城生命药水"] = "item:241304\n/cast item:241305", } ``` -- 值是**完整 `macrotext`**,原样写入按钮,**不会**再加 `/cast `。 -- `index` 与 `staticSpells` 共用同一套相对序号。 -- 同 index 若 special 存在,static **被忽略**(可注释掉对应 static,如 DH 的恶魔变形 / 烈火烙印)。 +在 `staticSpells` / `specialSpells` 中写 `"银月城生命药水"` 即可;`resolveMacroBody` 会查表并(对非 `/` 开头的值)自动加 `/cast `。 -### 7.2 典型用途 +### 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 典型完整宏 | 模式 | 示例(文件中已有) | |---|---| @@ -209,86 +263,106 @@ specialSpells = { | 停施法 | `"/stopcasting"` | | 取消光环再施法 | `"/cancelaura [spec:4]猎豹形态\n/cast 万灵之召"` | -`castsequence ...,x` 中的 `x` 是占位,用于快速连按重置序列的常见写法;改此类宏时保持现有 reset 秒数风格,除非有明确需求。 +`castsequence ...,x` 中的 `x` 是占位,用于快速连按重置序列;改此类宏时保持现有 reset 秒数风格,除非有明确需求。 -## 8. 决策树:新技能放哪 +## 9. 决策树:新技能放哪 ```text 需要按队友/团队槽位点名? ├─ 是 → dynamicSpells(纯法术名;注意 +30 偏移) - └─ 否 → 能否写成「/cast + 一段条件/法术名」? - ├─ 是 → staticSpells[index] - └─ 否 → specialSpells[index](完整宏文本) + └─ 否 → 是否已有 MacroBodies 名称? + ├─ 是 → staticSpells 写名称 + └─ 否 → 能否写成「/cast + 一段条件/法术名」? + ├─ 是 → staticSpells 写条件/法术名 + └─ 否 → staticSpells 写以 / 开头的完整宏 + (或追加到 specialSpells 末尾) ``` -同 index 既要特殊宏又想保留备注:special 写生效文本,static 删掉或改成注释。 +需要跳过某键:在数组该位置写 `""`。 -## 9. 修改检查清单(AI 改完自检) +## 10. 修改检查清单(AI 改完自检) 1. 职业键是否为正确的 `UnitClassBase` 字符串? -2. 三个字段是否都在(空也要 `{}`)? -3. 若改了 `dynamicSpells` 长度:是否意识到 static/special 实际热键整体平移? -4. static 值是否**没有**多余的前导 `/cast `(双物品续行除外)? -5. special 值是否**自带** `/` 命令? -6. 同序号 special 是否故意覆盖 static?被覆盖项是否已注释说明? -7. 条件语法是否与现有条目一致(`[@unit]`、`[spec:N]`、`[known:id]`、`[group:...]`)? -8. 法术名是否与游戏客户端中文一致? -9. 提醒:战斗中不会更新安全按钮;需脱战 `/reload` 验证。 +2. 三个字段是否都在(空也要 `{}`),且为**数组**而非 `[n]=` 稀疏表? +3. 若改了 `dynamicSpells` 长度:是否意识到后续热键整体平移? +4. 若在 `staticSpells` 中间插入/删除(含 `""`):是否评估了后续键位? +5. 普通 static 值是否**没有**多余的前导 `/cast `? +6. 完整宏是否以 `/` 开头(或走 MacroBodies)? +7. 药水等是否用 MacroBodies 名称,而不是内联 `item:...`? +8. 条件语法是否与现有条目一致(`[@unit]`、`[spec:N]`、`[known:id]`、`[group:...]`)? +9. 法术名是否与游戏客户端中文一致? +10. 提醒:战斗中不会更新安全按钮;需脱战 `/reload` 验证。 -## 10. 最小示例 +## 11. 最小示例 -### 10.1 无动态(近战输出职业常见) +### 11.1 无动态(近战输出职业常见) ```lua WARRIOR = { dynamicSpells = {}, - specialSpells = {}, staticSpells = { - [1] = "英勇投掷", - [38] = "拳击", - [39] = "[@focus]拳击", + "英勇投掷", + -- ... + "拳击", + "[@focus]拳击", }, + specialSpells = {}, } ``` -- `[1]` → 全局 `i=1` → `/cast 英勇投掷` -- `[39]` → `/cast [@focus]拳击` +- 第 1 项 → 全局 `i=1` → `/cast 英勇投掷` +- 最后一项 → `/cast [@focus]拳击` -### 10.2 有动态 + special 覆盖 +### 11.2 有动态 + 完整宏 + 命名药水 ```lua PRIEST = { dynamicSpells = { "苦修", "快速治疗", "真言术:盾", "愈合祷言", "纯净术", "圣言术:静" }, - -- 动态占 180 槽;此后 index 1 → 全局 i=181 - specialSpells = { - [36] = "/castsequence reset=0.3 真言术:耀,x", - [39] = "/stopcasting", - }, + -- 动态占 180 槽;static 第 1 项 → 全局 i=181 staticSpells = { - [1] = "心灵震爆", - [37] = "item:241304\n/cast item:241305", - -- [36] / [39] 由 special 接管,不必在 static 重复 + "心灵震爆", + -- ... + "圣言术:静", + "/castsequence reset=0.3 真言术:耀,x", -- 完整宏,占 static 相对第 36 槽 + "银月城生命药水", -- MacroBodies + "渐隐术", + "/stopcasting", }, + specialSpells = {}, } ``` -## 11. 与本文相关、但不要在这里改的东西 +### 11.3 空槽占位 + +```lua +-- 法师示例:数组中某处写 "",该全局槽不建按钮,后续项仍按顺序占下一键 +staticSpells = { + "[@cursor]暴风雪", + "", -- 占位 + "奥术智慧", +} +``` + +## 12. 与本文相关、但不要在这里改的东西 | 文件 | 职责 | 改宏时 | |---|---|---| -| `core/macro.lua` | 热键表、展开算法、安全按钮 | 仅当要改分配规则/键池时才动 | +| `core/macro.lua` | 热键表、顺序占键、`resolveMacroBody`、安全按钮 | 仅当要改分配规则/键池时才动 | | `main.lua` | `LoadPlayerMacros` 选职业表 | 一般不动 | | `Keymap.md` | 人读热键 ID 对照 | 键池变更时同步 | | `core/keybinds.lua` / `config.lua` keymap | 动作条扫描 → 像素协议 | **另一套**按键编码,与 ClassMacros 覆盖绑定无关 | | `class/*.lua` | ClassBlocks 色块 | 不放宏 | -## 12. 常见错误 +## 13. 常见错误 | 错误 | 后果 | |---|---| -| static 写成 `"/cast 火球术"` | 实际变成 `/cast /cast 火球术` | -| special 只写 `"火球术"` | 无 `/` 命令,宏无效 | +| 普通法术误加 `/cast ` 前缀 | 实际变成 `/cast /cast 火球术` | +| 完整宏未以 `/` 开头且未进 MacroBodies | 被加上 `/cast `,命令错误 | | 把点名治疗放进 static | 无法按 raid/party 槽位点名 | -| 在 `dynamicSpells` 中间插入技能却不评估偏移 | 后续所有按键语义错位 | +| 仍使用 `[36] = "..."` 稀疏表 | 与当前 `ipairs` 顺序占键不兼容,中间空洞行为不符合预期 | +| 药水内联 `item:...` 而不用 MacroBodies 名称 | 多职业重复、难统一改 ID | +| 在 `dynamicSpells` 或 `staticSpells` 中间插入却不评估偏移 | 后续所有按键语义错位 | | 用错职业键(如 `DeathKnight`) | `LoadPlayerMacros` 取不到表,宏不创建 | | 假设战斗中改表立即生效 | `InCombatLockdown` 直接 return,按钮不更新 | +| 误以为 special 与 static 仍按同序号覆盖 | 已改为顺序追加;同槽特殊宏应直接写在 static 对应位置 | diff --git a/CLAUDE.md b/CLAUDE.md index b7de7f35..4c748b62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,9 +91,9 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c [Infrastructure/ClassMacrosStore.cs](Infrastructure/ClassMacrosStore.cs) 读写 `core/classmacros.lua` 中的 `Fuyutsui.ClassMacros` 表。每职业三组: - **DynamicSpells**:每项占 30 热键槽位 -- **StaticSpells / SpecialSpells**:稀疏索引条目(`SparseEntry`:index + text + 可选行尾注释) +- **StaticSpells / SpecialSpells**:顺序数组条目(`ArrayEntry`:text + 可选行尾注释);空字符串保留槽位 -保存后 [Infrastructure/FuyutsuiKeymapConverter.cs](Infrastructure/FuyutsuiKeymapConverter.cs)`.UpdateFromClassMacros` 将 Lua 宏表转换为 `keymap/*.json`,把宏槽位映射到热键池(7 修饰符 × 35 键 = 245 组合/职业)。`DeriveSpellName` 解析 WoW 宏文本提取技能名。 +保存后 [Infrastructure/FuyutsuiKeymapConverter.cs](Infrastructure/FuyutsuiKeymapConverter.cs)`.UpdateFromClassMacros` 将 Lua 宏表按 dynamic(每项 30 槽)→ static → special 顺序转换为 `keymap/*.json`,把宏槽位映射到热键池(7 修饰符 × 39 键 = 273 组合/职业)。`DeriveSpellName` 解析 WoW 宏文本提取技能名。 ### UI 编辑器 diff --git a/Infrastructure/ClassBlocksStore.cs b/Infrastructure/ClassBlocksStore.cs index 47fcad66..fe830f61 100644 --- a/Infrastructure/ClassBlocksStore.cs +++ b/Infrastructure/ClassBlocksStore.cs @@ -11,6 +11,15 @@ namespace Shigure; internal static class ClassBlocksStore { public const string AssignmentName = "Fuyutsui.ClassBlocks"; + private static readonly string[] StateCategories = + [ + ClassStateCatalog.CategoryState, + ClassStateCatalog.CategoryResource, + ClassStateCatalog.CategoryItem, + ClassStateCatalog.CategoryConfig, + ClassStateCatalog.CategoryTarget, + ClassStateCatalog.CategoryFocus + ]; public sealed class ClassFileDocument { @@ -28,9 +37,12 @@ internal static class ClassBlocksStore public List FlatStates { get; } = new(); public Dictionary> CategorizedStates { get; } = new(StringComparer.Ordinal) { - ["状态"] = new List(), - ["目标"] = new List(), - ["焦点"] = new List() + [ClassStateCatalog.CategoryState] = new List(), + [ClassStateCatalog.CategoryResource] = new List(), + [ClassStateCatalog.CategoryItem] = new List(), + [ClassStateCatalog.CategoryConfig] = new List(), + [ClassStateCatalog.CategoryTarget] = new List(), + [ClassStateCatalog.CategoryFocus] = new List() }; public List PlayerAuras { get; } = new(); @@ -149,13 +161,11 @@ internal static class ClassBlocksStore if (spec.GetTable("states") is { } states) { - var nested = states.GetTable("状态") is not null - || states.GetTable("目标") is not null - || states.GetTable("焦点") is not null; + var nested = StateCategories.Any(category => states.GetTable(category) is not null); result.NestedStates = nested; if (nested) { - foreach (var category in new[] { "状态", "目标", "焦点" }) + foreach (var category in StateCategories) { if (states.GetTable(category) is not { } list) { @@ -350,7 +360,7 @@ internal static class ClassBlocksStore sb.Append(indent).AppendLine("states = {"); if (spec.NestedStates) { - foreach (var category in new[] { "状态", "目标", "焦点" }) + foreach (var category in StateCategories) { var list = spec.CategorizedStates.GetValueOrDefault(category); if (list is null || list.Count == 0) diff --git a/Infrastructure/ClassMacrosStore.cs b/Infrastructure/ClassMacrosStore.cs index 855eae81..6c750bc4 100644 --- a/Infrastructure/ClassMacrosStore.cs +++ b/Infrastructure/ClassMacrosStore.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text; using static Shigure.LuaLiteParser; @@ -31,13 +30,12 @@ internal static class ClassMacrosStore public sealed class ClassMacros { public List DynamicSpells { get; } = new(); - public List StaticSpells { get; } = new(); - public List SpecialSpells { get; } = new(); + public List StaticSpells { get; } = new(); + public List SpecialSpells { get; } = new(); } - public sealed class SparseEntry + public sealed class ArrayEntry { - public int Index { get; set; } public string Text { get; set; } = string.Empty; public string? Comment { get; set; } } @@ -101,41 +99,40 @@ internal static class ClassMacrosStore { foreach (var item in dynamic.IPairs()) { - if (item is StringValue s && !string.IsNullOrWhiteSpace(s.Value)) + if (item is StringValue s) { macros.DynamicSpells.Add(s.Value); } } } - ReadSparse(classTable.GetTable("staticSpells"), macros.StaticSpells); - ReadSparse(classTable.GetTable("specialSpells"), macros.SpecialSpells); + ReadArray(classTable.GetTable("staticSpells"), macros.StaticSpells); + ReadArray(classTable.GetTable("specialSpells"), macros.SpecialSpells); return macros; } - private static void ReadSparse(TableValue? table, List target) + private static void ReadArray(TableValue? table, List target) { if (table is null) { return; } - foreach (var (key, value) in table.Entries) + var index = 1; + foreach (var value in table.IPairs()) { - if (key is not long index || value is not StringValue text) + if (value is not StringValue text) { - continue; + break; } - target.Add(new SparseEntry + target.Add(new ArrayEntry { - Index = (int)index, Text = text.Value, - Comment = table.GetTrailingComment(key) + Comment = table.GetTrailingComment((long)index) }); + index++; } - - target.Sort((a, b) => a.Index.CompareTo(b.Index)); } public static string SerializeClassMacros(MacrosDocument document) @@ -203,17 +200,17 @@ internal static class ClassMacrosStore sb.AppendLine(" },"); } - // specialSpells - WriteSparseTable(sb, "specialSpells", macros.SpecialSpells); - // staticSpells - WriteSparseTable(sb, "staticSpells", macros.StaticSpells); + WriteArrayTable(sb, "staticSpells", macros.StaticSpells); + + // specialSpells + WriteArrayTable(sb, "specialSpells", macros.SpecialSpells); sb.AppendLine(" },"); sb.AppendLine(); } - private static void WriteSparseTable(StringBuilder sb, string name, List entries) + private static void WriteArrayTable(StringBuilder sb, string name, List entries) { if (entries.Count == 0) { @@ -222,10 +219,9 @@ internal static class ClassMacrosStore } sb.Append(" ").Append(name).AppendLine(" = {"); - foreach (var entry in entries.OrderBy(e => e.Index)) + foreach (var entry in entries) { - sb.Append(" [").Append(entry.Index.ToString(CultureInfo.InvariantCulture)) - .Append("] = \"").Append(Escape(entry.Text)).Append('"'); + sb.Append(" \"").Append(Escape(entry.Text)).Append('"'); if (!string.IsNullOrWhiteSpace(entry.Comment)) { sb.Append(", -- ").Append(entry.Comment.Trim()); diff --git a/Infrastructure/ClassStateCatalog.cs b/Infrastructure/ClassStateCatalog.cs index 7b421475..fa9e53ca 100644 --- a/Infrastructure/ClassStateCatalog.cs +++ b/Infrastructure/ClassStateCatalog.cs @@ -2,7 +2,7 @@ namespace Shigure; /// /// ClassBlocks states 可选字段目录。界面使用单级分类; -/// 「状态 / 配置开关 / 物品 / 能量」在 Lua 中统一写入 ["状态"]。 +/// 保存时按 Lua 原格式写入同名分类表,并保持固定分类顺序。 /// internal static class ClassStateCatalog { @@ -16,9 +16,9 @@ internal static class ClassStateCatalog public static readonly string[] TopCategories = [ CategoryState, - CategoryConfig, - CategoryItem, CategoryResource, + CategoryItem, + CategoryConfig, CategoryTarget, CategoryFocus ]; @@ -39,7 +39,7 @@ internal static class ClassStateCatalog ]), (CategoryItem, [ - "大红冷却", "大蓝冷却", "治疗石冷却", "鲁莽药水冷却", "圣光潜力冷却" + "治疗药水", "魔法药水", "治疗石", "鲁莽药水", "圣光潜力" ]), (CategoryResource, [ @@ -99,9 +99,7 @@ internal static class ClassStateCatalog } public static string GetStorageCategory(string category) - => category is CategoryTarget or CategoryFocus - ? category - : CategoryState; + => category; private static string[] GetNames(string category) => Categories.FirstOrDefault(item => diff --git a/Infrastructure/FuyutsuiConfigConverter.cs b/Infrastructure/FuyutsuiConfigConverter.cs index 35a6dcbe..8d4a8e5f 100644 --- a/Infrastructure/FuyutsuiConfigConverter.cs +++ b/Infrastructure/FuyutsuiConfigConverter.cs @@ -26,7 +26,15 @@ internal static class FuyutsuiConfigConverter "锚点", "有效性", "移动" }; - private static readonly string[] StateCategoryOrder = ["状态", "目标", "焦点"]; + private static readonly string[] StateCategoryOrder = + [ + ClassStateCatalog.CategoryState, + ClassStateCatalog.CategoryResource, + ClassStateCatalog.CategoryItem, + ClassStateCatalog.CategoryConfig, + ClassStateCatalog.CategoryTarget, + ClassStateCatalog.CategoryFocus + ]; public sealed record UpdateResult( string ClassDirectory, @@ -113,9 +121,7 @@ internal static class FuyutsuiConfigConverter // states if (spec.GetTable("states") is { } states) { - var nested = states.GetTable("状态") is not null - || states.GetTable("目标") is not null - || states.GetTable("焦点") is not null; + var nested = StateCategoryOrder.Any(category => states.GetTable(category) is not null); if (nested) { @@ -133,7 +139,9 @@ internal static class FuyutsuiConfigConverter continue; } - var key = category == "状态" ? nameValue.Value : category + nameValue.Value; + var key = category is ClassStateCatalog.CategoryTarget or ClassStateCatalog.CategoryFocus + ? category + nameValue.Value + : nameValue.Value; AddStateField(result, key, index, skipCommon: true); index++; } @@ -211,11 +219,18 @@ internal static class FuyutsuiConfigConverter name = ((long)spellId.Value).ToString(); } - var charge = spell.GetBool("charge") == true; - var fieldName = charge ? EnsureSuffix(name, "充能") : name; - spellsObject[fieldName] = Field(index, "int"); + // 主色块顺序与 LoadPlayerBlocks 一致: + // 所有法术先占一个冷却格;充能法术再紧接着占一个充能冷却格。 + spellsObject[name] = Field(index, "int"); index++; + var charge = spell.GetBool("charge") == true; + if (charge) + { + spellsObject[EnsureSuffix(name, "充能")] = Field(index, "int"); + index++; + } + var maxCharge = spell.GetNumber("maxCharge"); if (charge && maxCharge is not null) { diff --git a/Infrastructure/FuyutsuiKeymapConverter.cs b/Infrastructure/FuyutsuiKeymapConverter.cs index 2d839ddb..0bf76133 100644 --- a/Infrastructure/FuyutsuiKeymapConverter.cs +++ b/Infrastructure/FuyutsuiKeymapConverter.cs @@ -108,8 +108,8 @@ internal static partial class FuyutsuiKeymapConverter { var warnings = new List(); var dynamicSpells = ReadArrayStrings(classTable.GetTable("dynamicSpells")); - var staticSpells = ReadSparseStrings(classTable.GetTable("staticSpells")); - var specialSpells = ReadSparseStrings(classTable.GetTable("specialSpells")); + var staticSpells = ReadArrayEntries(classTable.GetTable("staticSpells")); + var specialSpells = ReadArrayEntries(classTable.GetTable("specialSpells")); var dynamicSlots = dynamicSpells.Count * 30; var root = new JsonObject(); @@ -123,7 +123,8 @@ internal static partial class FuyutsuiKeymapConverter { var groupIndex = (i - 1) / 30; var raidIdx = ((i - 1) % 30) + 1; - if (groupIndex < dynamicSpells.Count) + if (groupIndex < dynamicSpells.Count + && !string.IsNullOrWhiteSpace(dynamicSpells[groupIndex])) { spell = dynamicSpells[groupIndex]; unit = raidIdx; @@ -131,17 +132,28 @@ internal static partial class FuyutsuiKeymapConverter } else { - var index = i - dynamicSlots; - if (specialSpells.TryGetValue(index, out var specialEntry)) + var relativeIndex = i - dynamicSlots - 1; + MacroEntry? entry = null; + if (relativeIndex < staticSpells.Count) { - spell = ResolveSpellName(specialEntry); + entry = staticSpells[relativeIndex]; } - else if (staticSpells.TryGetValue(index, out var staticEntry)) + else { - spell = ResolveSpellName(staticEntry); + var specialIndex = relativeIndex - staticSpells.Count; + if (specialIndex < specialSpells.Count) + { + entry = specialSpells[specialIndex]; + } } - if (IsWeakSpellName(spell) + if (entry is { Body.Length: > 0 } macroEntry) + { + spell = ResolveSpellName(macroEntry); + } + + if (entry is { Body.Length: > 0 } + && IsWeakSpellName(spell) && existingSpellNames.TryGetValue(i, out var preserved) && !string.IsNullOrWhiteSpace(preserved) && !IsWeakSpellName(preserved)) @@ -277,7 +289,7 @@ internal static partial class FuyutsuiKeymapConverter foreach (var item in table.IPairs()) { - if (item is StringValue s && !string.IsNullOrWhiteSpace(s.Value)) + if (item is StringValue s) { result.Add(s.Value.Trim()); } @@ -290,31 +302,24 @@ internal static partial class FuyutsuiKeymapConverter return result; } - private static Dictionary ReadSparseStrings(TableValue? table) + private static List ReadArrayEntries(TableValue? table) { - var result = new Dictionary(); + var result = new List(); if (table is null) { return result; } - foreach (var (key, value) in table.Entries) + var index = 1; + foreach (var value in table.IPairs()) { - var index = key switch + if (value is not StringValue s) { - long l => (int)l, - int i => i, - double d => (int)d, - NumberValue n => n.AsInt(), - _ => (int?)null - }; - if (index is null || value is not StringValue s || string.IsNullOrWhiteSpace(s.Value)) - { - continue; + break; } - var comment = key is null ? null : table.GetTrailingComment(key); - result[index.Value] = new MacroEntry(s.Value, comment); + result.Add(new MacroEntry(s.Value, table.GetTrailingComment((long)index))); + index++; } return result; diff --git a/TEXTURE_LAYOUT_zh-CN.md b/TEXTURE_LAYOUT_zh-CN.md index 453ef369..91d33661 100644 --- a/TEXTURE_LAYOUT_zh-CN.md +++ b/TEXTURE_LAYOUT_zh-CN.md @@ -2,22 +2,48 @@ 本文描述 Fuyutsui 如何把 `ClassBlocks` 映射到屏幕上的色块 / 横向条,以及外部读取时应按什么顺序理解这些纹理。 -实现入口:`main.lua` 的 `Fuyutsui:LoadPlayerBlocks(specIndex)`;主色块写入:`Fuyutsui:CreateTexture(index, b)`(`core/block.lua`)。 +- 实现入口:`main.lua` 的 `Fuyutsui:LoadPlayerBlocks(specIndex)` +- 主色块写入:`Fuyutsui:CreateTexture(index, b)`(`core/block.lua`) +- 像素编码与容器细节:见 `BLOCK_AI_Reference_zh-CN.md` --- -## 1. 两行输出区域 +## 1. 三行输出区域 -| 区域 | 容器 | 用途 | -| --- | --- | --- | -| 主色块条 | `FuyutsuiColorBars`(屏幕最顶端) | 状态、光环剩余、法术冷却、队伍成员等,按 **整数索引** 从左到右排布 | -| 横向计数条 | `FuyutsuiCountBars`(紧贴主色块下方) | 充能层数、`castCount`、光环 `maxApps` 层数条;**不占用**主色块索引 | +| 区域 | 容器 | 屏幕 y(TOPLEFT) | 用途 | +| --- | --- | --- | --- | +| 主色块条 | `FuyutsuiColorBars` | `0` | 状态、光环剩余、法术冷却、队伍成员等,按 **整数索引** 从左到右排布 | +| 横向计数条 | `FuyutsuiCountBars` | `-BLOCK_HEIGHT`(默认 `-1`) | 充能层数、`castCount`、光环 `maxApps` 层数条;**不占用**主色块索引 | +| 治疗吸收网格 | `FuyutsuiHealAbsorbBars` | `-(BLOCK_HEIGHT+BAR_HEIGHT)`(默认 `-3`) | 队伍单位治疗吸收;**不占用**主色块索引,也不参与 CountBars 排布 | -主色块共 **510** 格。索引编码: +当前尺寸常量(`core/block.lua` 顶部): + +| 常量 | 默认 | 作用 | +| --- | ---: | --- | +| `BLOCK_FIX_COUNT` | 510 | 主色块总数 | +| `BLOCK_HEIGHT` | 1 | 主色块高度 | +| `BAR_HEIGHT` | 2 | CountBars 与 HealAbsorb **共用**行高 | +| `BAR_UNIT_COUNT` | 500 | CountBars / HealAbsorb 横向逻辑单元数 | + +主色块索引编码: - `1..255`:`r=0`,`g=index/255`,业务值在 `b` - `256..510`:`r=1/255`,`g=(index-255)/255`,业务值在 `b` +```text +y = 0 ColorBars 高度 1,510 格 +y = -1 CountBars 内容高度 2 +y = -3 HealAbsorb 第 1 行 高度 2;最多 6 行(5 列 × 30 槽) +``` + +```mermaid +flowchart TB + colorBars["ColorBars y=0 / 510格"] + countBars["CountBars y=-1 / 横向单元"] + absorb["HealAbsorb y=-3起 / 5列×6行"] + colorBars --> countBars --> absorb +``` + --- ## 2. 主色块总排序(同一专精) @@ -28,7 +54,7 @@ states → auras → spells → group ``` -没有空隙:上一段用完后,下一段紧接着递增。不同专精声明的条目数量不同,因此 **同一语义在不同职业/专精上的绝对索引可能不同**;外部程序应按「当前专精表顺序」解读,或按名称映射,而不是写死旧版固定格位。 +没有空隙:上一段用完后,下一段紧接着递增。不同专精声明的条目数量不同,因此 **同一语义在不同职业/专精上的绝对索引可能不同**;外部程序应按「当前专精表顺序」解读,或按名称 / `spellId` 映射,而不是写死固定格位。 ```mermaid flowchart LR @@ -41,20 +67,29 @@ flowchart LR ## 3. `states`:状态像素 -### 3.1 扁平写法(多数职业) +### 3.1 分类写法(当前全部职业) ```lua states = { - "锚点", - "职业", - -- ... - "战斗时间", + ["状态"] = { "锚点", "职业", ..., "敌人人数" }, + ["目标"] = { "类型", "生命值", "施法", "施法可打断" }, + ["焦点"] = { ... }, -- 可选 } ``` -按 `ipairs` 顺序写入 `blocks.state[名称] = index`,每项占 **1** 格。 +分配顺序固定为:**状态 → 目标 → 焦点**。每项占 **1** 格。 -约定:每个专精 `states` 开头通常包含这 8 项(已写入各职业表,不再由 `main.lua` 单独注入): +写入 `blocks.state` 的键名规则: + +| 分类 | 表内名称 | `blocks.state` 键 | +| --- | --- | --- | +| `状态` | `"生命值"` | `"生命值"` | +| `目标` | `"生命值"` | `"目标生命值"`(分类名 + 名称) | +| `焦点` | `"施法"` | `"焦点施法"` | + +运行时通过 `UpdateStateBlock("目标", "生命值")` 等写入对应像素。 + +约定:每个专精 `["状态"]` 开头通常包含这 8 项: | 顺序 | 名称 | | ---: | --- | @@ -67,29 +102,19 @@ states = { | 7 | 一键辅助 | | 8 | 法术失败 | -其后是专精自定义状态(战斗时间、生命值、能量等)。 +其后是专精自定义状态(战斗时间、生命值、能量、敌人人数等)。 -### 3.2 分类写法(如戒律牧师) +### 3.2 扁平写法(兼容) ```lua states = { - ["状态"] = { "锚点", "职业", ..., "敌人人数" }, - ["目标"] = { "类型", "生命值", "施法", "施法可打断" }, - ["焦点"] = { ... }, -- 可选 + "锚点", + "职业", + -- ... } ``` -分配顺序固定为:**状态 → 目标 → 焦点**。 - -写入 `blocks.state` 的键名规则: - -| 分类 | 表内名称 | `blocks.state` 键 | -| --- | --- | --- | -| `状态` | `"生命值"` | `"生命值"` | -| `目标` | `"生命值"` | `"目标生命值"`(分类名 + 名称) | -| `焦点` | `"施法"` | `"焦点施法"` | - -运行时通过 `UpdateStateBlock("目标", "生命值")` 等写入对应像素。 +若表中 **没有** `"状态"` / `"目标"` / `"焦点"` 键,则按 `ipairs` 顺序写入 `blocks.state[名称] = index`。新职业表请用分类写法。 --- @@ -98,7 +123,7 @@ states = { ```lua auras = { player = { - { name = "虚空之盾", spellId = 1253590 }, + { name = "黑暗主宰", spellId = 1253591 }, { name = "圣光涌动", spellId = 114255, maxApps = 2 }, }, target = { @@ -119,31 +144,34 @@ auras = { - 缺少 `spellId`/`spellIds` 的条目会跳过并打印警告,**不占位**。 - 兼容旧扁平数组:整表当作 `player` / `HELPFUL`。 +主色块上光环剩余时间由 AuraContainer DurationText(`█`)叠色;永久光环底层 `b=1`。细节见 `BLOCK_AI_Reference_zh-CN.md` §7。 + --- ## 5. `spells`:法术冷却 / 充能冷却 ```lua spells = { - { spellId = 47540, name = "苦修" }, { spellId = 47540, name = "苦修", charge = true, maxCharge = 2 }, } ``` -每条 `spells` 条目占主色块 **1** 格,按数组顺序排列。 +按数组顺序排列;缺少 `spellId` 的条目跳过并警告。占位规则: -| 条目字段 | 主色块含义 | 刷新来源 | +| 条目字段 | 主色块 | 刷新来源 | | --- | --- | --- | -| 默认(无 `charge`) | 技能冷却 | `GetSpellCooldown` → `blocks.spells[id].index` | -| `charge = true` | 充能回充冷却 | `GetSpellChargeDuration` → `blocks.spells[id].charge` | -| `maxCharge = N` | **不占**主色块;创建横向充能层数条 `0..N` | `GetSpellCharges().currentCharges` | -| `castCount = N` | **不占**额外主色块(该条本身仍占 1 格若单独声明);创建横向 `castCount` 条 | `GetSpellCastCount` | +| 默认(无 `charge`) | **1** 格:技能冷却 → `blocks.spells[id].index` | `GetSpellCooldown` / `GetSpellCooldownDuration` | +| `charge = true` | **2** 格:冷却 → `.index`,充能回充 → `.charge` | 冷却同上;充能用 `GetSpellChargeDuration` | +| 另有 `maxCharge = N` | **不另占**主色块;创建横向充能层数条 `0..N` | `GetSpellCharges().currentCharges` | +| `castCount = N` | **不另占**主色块(该条本身仍按上表占 1 或 2 格);创建横向 `castCount` 条 | `GetSpellCastCount` | -以苦修为例,在 `spells` 段内的相对顺序为: +以苦修为例,一条 `charge = true, maxCharge = 2` 同时产出: -1. 第 1 条 → **冷却**像素 -2. 第 2 条(`charge = true`)→ **充能冷却**像素 -3. 同条 `maxCharge = 2` → 横向条 **充能层数**(主色块下方) +1. `.index` → **冷却**像素 +2. `.charge` → **充能冷却**像素 +3. `maxCharge = 2` → 横向条 **充能层数**(CountBars 行) + +不要再为同一 `spellId` 写「无 charge + charge」双条目;一条带 `charge` 的即可。 可选透传:`forcedKnown`、`inSpellBook`(影响是否视为已学会,不改变占位)。 @@ -180,13 +208,18 @@ pixel = memberBase + offset | 2 | 66 | 67 | 68 | 69 | 70 | | … | … | … | … | … | … | -成员数量由运行时队伍列表决定;配置里的 `num` 是 **每位成员的槽宽**,不是人数上限本身。槽位超出 510 的部分不会写出。 +成员数量由运行时 `groupList` 决定;配置里的 `num` 是 **每位成员的槽宽**,不是人数上限。槽位超出 510 的部分不会写出。 + +队伍成员光环 / 驱散由 `RefreshGroupAuraContainers()` 驱动(`UpdateGroup` 内调用): + +- `groups.aura[offset]`:HELPFUL 剩余时间色块 +- `groups.dispel`:HARMFUL 可驱散类型;蓝通道 Magic=1 / Curse=2 / Disease=3 / Poison=4 / Bleed=11 --- -## 7. 横向条排序(第二行) +## 7. 横向条排序(第二行 CountBars) -与主色块独立,从左到右大致为: +与主色块独立,从左到右为: ```text spells 推导的计数条(CreateAutoLayoutBar) @@ -203,46 +236,74 @@ spells 推导的计数条(CreateAutoLayoutBar) | `charge = true` 且 `maxCharge = N` | `"charge"` | 0 | N | | `castCount = N`(正数) | `"castCount"` | 0 | N | -同一 `spellId` 的横向条只创建一次。首条从逻辑单元 `2` 起排;单条步进约为 `max + 3`(背景单元 + 间隔)。 +同一 `spellId` 的横向条只创建一次。首条从逻辑单元 `BAR_START_INDEX`(默认 `2`)起排;单条步进 `max + 3`(背景 `[-1..max]` + 终点预留 + 间隔)。 + +背景锚点编码(定位条段):`(r=1/255, g=相对索引/255, b=0)`。白色 StatusBar 表示当前层数 / 充能 / castCount。 ### 7.2 由 `auras` 的 `maxApps` 推导 -带 `maxApps` 的玩家光环在主色块显示剩余时间后,再在横向条追加层数条,排在 spell 计数条之后。 +带 `maxApps` 的 **玩家**光环在主色块显示剩余时间后,再在横向条追加层数条,排在 spell 计数条之后。 --- -## 8. 戒律牧师示意(相对顺序) +## 8. 治疗吸收网格(第三行 HealAbsorb) + +不由 `ClassBlocks` 声明;始终按 `groupList` 自动绑定,最多 **30** 槽(5 列 × 6 行)。 + +| 项 | 值 | +| --- | --- | +| 单槽结构 | 前锚点 1 + 条身 100 + 终点 1(共 102 逻辑单元) | +| 单位顺序 | `groupList[1..30]`;未绑定槽整槽隐藏 | +| 前锚点 | `(r=行号/255, g=单位编号/255, b=0)`;`player=1`,`party1..4=2..5`,`raidN=N` | +| 条身背景 | `(r=行号/255, g=1..100/255, b=单位编号/255)` | +| 白色 StatusBar | 治疗吸收量 / 最大生命(秘密值直通,禁止算术) | +| 终点色块 | 与 CountBars 相同的 `BAR_END_COLOR` | + +刷新:`UpdateGroup` 末尾 `RefreshGroupHealAbsorbBars()`;事件侧 `UpdateGroupHealAbsorbBar(unit)`。 + +**注意**:HealAbsorb 的 `r` 编码与 CountBars 固定 `r=1/255` **不同**,禁止混用读色逻辑。完整约定见 `BLOCK_AI_Reference_zh-CN.md` §6。 + +--- + +## 9. 戒律牧师示意(相对顺序) 配置见 `class/Priest.lua` `[1]`。主色块从左到右概念顺序: ```text [状态…] 锚点…敌人人数 → [目标…] 目标类型、目标生命值、目标施法、目标施法可打断 - → [auras] 虚空之盾、圣光涌动、熵能裂隙、福音、祸福相依 + → [auras.player] 黑暗主宰、圣光涌动、福音、祸福相依、熵能裂隙 + → [auras.target] 暗言术:痛 → 救赎 → 真言术:盾 + → [auras.focus] 暗言术:痛 → 救赎 → 真言术:盾 → [spells] 心灵尖啸 … 苦修(CD) → 苦修(充能CD) → 真言术:耀(CD) → 耀(充能CD) → … → [group] 从 start 起每位 5 格(血量/职责/驱散/救赎/盾) ``` -横向条(示例): +横向条 CountBars(示例): ```text 苦修层数(0..2) → 真言术:耀层数(0..2) → 圣光涌动层数 → 福音层数 → 祸福相依层数 → 终点色 ``` +其下 HealAbsorb:按当前 `groupList` 从左到右、自上而下绑定队伍单位吸收条。 + 具体绝对索引随 `states`/`auras`/`spells` 长度变化;以 `/reload` 后当前专精表为准。 --- -## 9. 外部读取建议 +## 10. 外部读取建议 -1. **主色块**:用 `(r,g)` 还原索引,用 `b` 读业务值;先确认当前专精的 `ClassBlocks` 顺序。 -2. **名称映射**:状态看 `blocks.state` 键规则(分类拼接);法术看 `spellId` 的 `.index` / `.charge`。 -3. **横向条**:用背景色 `(r=1/255, g=相对单元/255)` 定位段,再读 StatusBar / 层数条当前值。 -4. **切专精 / 天赋**:插件会清空并重建映射,外部缓存的绝对索引需失效重读。 +1. **主色块(y≈0)**:用 `(r,g)` 还原索引,用 `b` 读业务值;光环限时可能由 `█` 叠色。先确认当前专精的 `ClassBlocks` 顺序。 +2. **名称映射**:状态看 `blocks.state` 键规则(分类拼接);法术看 `spellId` 的 `.index` / `.charge`。 +3. **CountBars(y≈-1)**:用背景色 `(r=1/255, g=相对单元/255)` 定位段,再读白色 StatusBar 当前值。 +4. **HealAbsorb(y≈-3 起)**:点条前锚点,`r`=行号、`g`=单位编号;白条=吸收比例。不要用 CountBars 的 `r=1/255` 规则去扫这一行。 +5. **切专精 / 天赋**:插件会清空并重建映射(含 CountBars / Aura / HealAbsorb),外部缓存的绝对索引需失效重读。 + +反解整型通道:`round(channel * 255)`(注意截图 / 缩放误差)。 --- -## 10. 相关代码 +## 11. 相关代码 | 文件 | 职责 | | --- | --- | @@ -250,5 +311,7 @@ spells 推导的计数条(CreateAutoLayoutBar) | `main.lua` → `LoadPlayerBlocks` | 连续分配主色块索引,生成 `blocks.bars` | | `core/spells.lua` → `UpdateSpellCooldown` | 写技能 CD / 充能 CD | | `core/player.lua` → `UpdatePlayerBarInfo` | 创建横向计数条并布局光环层数条 | -| `core/block.lua` | `CreateTexture`、`CreateAutoLayoutBar`、AuraContainer | +| `core/group.lua` → `UpdateGroup` | 队伍状态 + 成员光环容器 + 吸收条刷新 | +| `core/block.lua` | `CreateTexture`、`CreateAutoLayoutBar`、AuraContainer、HealAbsorb | | `core/stateblocks.lua` | `UpdateStateBlock` 与状态 getter | +| `BLOCK_AI_Reference_zh-CN.md` | 三行像素协议与编码细节(AI / 读色单一事实来源) | diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index ca534361..d9cafbb4 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -43,6 +43,8 @@ public sealed class ClassConfigEditorControl : UserControl private string _lastStateCategory = ClassStateCatalog.CategoryState; private string _lastAuraBucket = "player"; + private const string HiddenAnchorStateName = "锚点"; + private static readonly (string Key, string Text)[] AuraBuckets = [ ("player", "玩家"), @@ -57,7 +59,7 @@ public sealed class ClassConfigEditorControl : UserControl _resolveClassDirectory = resolveClassDirectory; _updateConfigAsync = updateConfigAsync; _reloadButton = UiTheme.CreateButton("刷新", UiTheme.Field, UiTheme.Text); - _saveButton = UiTheme.CreateButton("保存并更新配置", UiTheme.Accent, Color.Black); + _saveButton = UiTheme.CreateButton("保存", UiTheme.Accent, Color.Black); InitializeComponent(); ReloadFromAddon(); } @@ -94,13 +96,12 @@ public sealed class ClassConfigEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, ColumnCount = 1, - RowCount = 3, + RowCount = 2, Padding = new Padding(12), Margin = new Padding(0, 0, 12, 0) }; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 88)); panel.Controls.Add(new Label { @@ -126,22 +127,6 @@ public sealed class ClassConfigEditorControl : UserControl SelectClassFromList(); }; panel.Controls.Add(_classList, 0, 1); - - var actions = new FlowLayoutPanel - { - Dock = DockStyle.Fill, - FlowDirection = FlowDirection.TopDown, - WrapContents = false, - BackColor = UiTheme.SurfaceRaised, - Margin = new Padding(0, 10, 0, 0) - }; - StyleActionButton(_reloadButton); - StyleActionButton(_saveButton); - _reloadButton.Click += (_, _) => ReloadFromAddon(); - _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); - actions.Controls.Add(_reloadButton); - actions.Controls.Add(_saveButton); - panel.Controls.Add(actions, 0, 2); return panel; } @@ -201,11 +186,12 @@ public sealed class ClassConfigEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 1, - RowCount = 2, + RowCount = 3, Margin = new Padding(0) }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); var header = new TableLayoutPanel { @@ -233,6 +219,37 @@ public sealed class ClassConfigEditorControl : UserControl root.Controls.Add(header, 0, 0); root.Controls.Add(BuildSectionTabs(), 0, 1); + + var actionRow = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Surface, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0), + Padding = new Padding(0, 8, 12, 12) + }; + actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 228)); + + var actions = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + BackColor = UiTheme.Surface, + Margin = new Padding(0) + }; + StyleActionButton(_reloadButton); + StyleActionButton(_saveButton); + _reloadButton.Margin = new Padding(0, 0, 8, 0); + _saveButton.Margin = new Padding(0); + _reloadButton.Click += (_, _) => ReloadFromAddon(); + _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); + actions.Controls.Add(_reloadButton); + actions.Controls.Add(_saveButton); + actionRow.Controls.Add(actions, 1, 0); + root.Controls.Add(actionRow, 0, 2); return root; } @@ -382,7 +399,7 @@ public sealed class ClassConfigEditorControl : UserControl }; top.Controls.Add(CreateMutedLabel("分类")); UiTheme.StyleComboBox(_stateCategoryBox); - _stateCategoryBox.Width = 120; + _stateCategoryBox.Width = 180; _stateCategoryBox.Items.AddRange(ClassStateCatalog.TopCategories); _stateCategoryBox.SelectedIndex = 0; _stateCategoryBox.SelectedIndexChanged += (_, _) => @@ -471,7 +488,7 @@ public sealed class ClassConfigEditorControl : UserControl HeaderText = "spellIds(逗号分隔)", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill }); - _aurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "MaxApps", HeaderText = "maxApps", Width = 90 }); + _aurasGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "MaxApps", HeaderText = "maxApps", Width = 135 }); _aurasGrid.Columns.Add(CreateDeleteColumn()); _aurasGrid.CellContentClick += HandleDeleteClick; _aurasGrid.CellValueChanged += (_, _) => MarkDirty(); @@ -487,26 +504,33 @@ public sealed class ClassConfigEditorControl : UserControl { Dock = DockStyle.Fill, ColumnCount = 1, - RowCount = 2, + RowCount = 3, BackColor = UiTheme.SurfaceRaised }; + panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + var textureOrderHint = CreateFieldCaption( + "纹理按行排列;充能法术连续占 2 格:冷却 → 充能冷却。最大充能、施法次数只生成横向计数条。"); + textureOrderHint.Padding = new Padding(8, 0, 0, 0); + panel.Controls.Add(textureOrderHint, 0, 0); + ConfigureGrid(_spellsGrid); - _spellsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", Width = 150 }); - _spellsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "SpellId", HeaderText = "spellId", Width = 110 }); - _spellsGrid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "Charge", HeaderText = "充能", Width = 60 }); - _spellsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "MaxCharge", HeaderText = "maxCharge", Width = 90 }); - _spellsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "CastCount", HeaderText = "castCount", Width = 90 }); - _spellsGrid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "ForcedKnown", HeaderText = "forcedKnown", Width = 100 }); - _spellsGrid.Columns.Add(new DataGridViewCheckBoxColumn { Name = "InSpellBook", HeaderText = "inSpellBook", Width = 100 }); + _spellsGrid.Columns.Add(CreateSpellTextColumn("Name", "名称", 24, 160)); + _spellsGrid.Columns.Add(CreateSpellTextColumn("SpellId", "法术 ID", 14, 120)); + _spellsGrid.Columns.Add(CreateSpellCheckColumn("Charge", "充能", 10, 80)); + _spellsGrid.Columns.Add(CreateSpellTextColumn("MaxCharge", "最大充能", 14, 110)); + _spellsGrid.Columns.Add(CreateSpellTextColumn("CastCount", "施法次数", 14, 110)); + _spellsGrid.Columns.Add(CreateSpellCheckColumn("ForcedKnown", "强制已学", 14, 110)); + _spellsGrid.Columns.Add(CreateSpellCheckColumn("InSpellBook", "法术书中", 14, 110)); _spellsGrid.Columns.Add(CreateDeleteColumn()); _spellsGrid.CellContentClick += HandleDeleteClick; _spellsGrid.CellValueChanged += (_, _) => MarkDirty(); _spellsGrid.UserAddedRow += (_, _) => MarkDirty(); - panel.Controls.Add(_spellsGrid, 0, 0); - panel.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 1); + _spellsGrid.DataError += (_, e) => e.ThrowException = false; + panel.Controls.Add(_spellsGrid, 0, 1); + panel.Controls.Add(BuildMoveButtons(_spellsGrid), 0, 2); return panel; } @@ -652,6 +676,44 @@ public sealed class ClassConfigEditorControl : UserControl Width = 44 }; + private static DataGridViewTextBoxColumn CreateSpellTextColumn( + string name, + string headerText, + float fillWeight, + int minimumWidth) + => new() + { + Name = name, + HeaderText = headerText, + AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, + FillWeight = fillWeight, + MinimumWidth = minimumWidth, + SortMode = DataGridViewColumnSortMode.NotSortable + }; + + private static DataGridViewCheckBoxColumn CreateSpellCheckColumn( + string name, + string headerText, + float fillWeight, + int minimumWidth) + => new() + { + Name = name, + HeaderText = headerText, + AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, + FillWeight = fillWeight, + MinimumWidth = minimumWidth, + SortMode = DataGridViewColumnSortMode.NotSortable, + TrueValue = true, + FalseValue = false, + IndeterminateValue = false, + DefaultCellStyle = new DataGridViewCellStyle + { + Alignment = DataGridViewContentAlignment.MiddleCenter, + NullValue = false + } + }; + private Label CreateMutedLabel(string text) => new() { @@ -938,7 +1000,9 @@ public sealed class ClassConfigEditorControl : UserControl IEnumerable names = _currentSpec.NestedStates ? _currentSpec.CategorizedStates.GetValueOrDefault(storageCategory) ?? [] : _currentSpec.FlatStates; - names = names.Where(name => ClassStateCatalog.IsInCategory(name, category)); + names = names.Where(name => + ClassStateCatalog.IsInCategory(name, category) + && !IsHiddenStateName(name)); foreach (var name in names) { @@ -1004,8 +1068,12 @@ public sealed class ClassConfigEditorControl : UserControl } var category = _stateCategoryBox.SelectedItem as string ?? ClassStateCatalog.CategoryState; - var options = ClassStateCatalog.GetOptions(category).ToList(); - var current = _statesGrid.CurrentCell.Value?.ToString(); + var current = _statesGrid.CurrentCell.Value?.ToString()?.Trim(); + var currentRowIndex = _statesGrid.CurrentCell.RowIndex; + var usedNames = GetUsedStateNames(category, currentRowIndex); + var options = ClassStateCatalog.GetOptions(category) + .Where(option => !usedNames.Contains(option.Name) && !IsHiddenStateName(option.Name)) + .ToList(); if (!string.IsNullOrWhiteSpace(current) && !options.Any(o => string.Equals(o.Name, current, StringComparison.Ordinal))) { @@ -1027,6 +1095,49 @@ public sealed class ClassConfigEditorControl : UserControl } } + private HashSet GetUsedStateNames(string category, int excludedRowIndex) + { + var usedNames = new HashSet(StringComparer.Ordinal); + if (_currentSpec is not null) + { + IEnumerable storedNames; + if (_currentSpec.NestedStates) + { + var storageCategory = ClassStateCatalog.GetStorageCategory(category); + storedNames = _currentSpec.CategorizedStates.GetValueOrDefault(storageCategory) ?? []; + } + else + { + storedNames = _currentSpec.FlatStates; + } + + foreach (var name in storedNames) + { + // 当前分类以表格中的未保存内容为准,其它分类仍以专精数据为准。 + if (!ClassStateCatalog.IsInCategory(name, category)) + { + usedNames.Add(name); + } + } + } + + foreach (DataGridViewRow row in _statesGrid.Rows) + { + if (row.IsNewRow || row.Index == excludedRowIndex) + { + continue; + } + + var name = row.Cells["Name"].Value?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(name)) + { + usedNames.Add(name); + } + } + + return usedNames; + } + private void FillAurasGrid() { _aurasGrid.Rows.Clear(); @@ -1185,13 +1296,20 @@ public sealed class ClassConfigEditorControl : UserControl } } - var insertIndex = list.FindIndex(name => ClassStateCatalog.IsInCategory(name, category)); + var insertIndex = list.FindIndex(name => + ClassStateCatalog.IsInCategory(name, category) + && !IsHiddenStateName(name)); if (insertIndex < 0) { - insertIndex = list.Count; + var anchorIndex = string.Equals(category, ClassStateCatalog.CategoryState, StringComparison.Ordinal) + ? list.FindIndex(IsHiddenStateName) + : -1; + insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : list.Count; } - list.RemoveAll(name => ClassStateCatalog.IsInCategory(name, category)); + list.RemoveAll(name => + ClassStateCatalog.IsInCategory(name, category) + && !IsHiddenStateName(name)); list.InsertRange(Math.Min(insertIndex, list.Count), editedNames); } @@ -1475,6 +1593,9 @@ public sealed class ClassConfigEditorControl : UserControl private static decimal Clamp(NumericUpDown box, int value) => Math.Min(box.Maximum, Math.Max(box.Minimum, value)); + private static bool IsHiddenStateName(string? name) + => string.Equals(name, HiddenAnchorStateName, StringComparison.Ordinal); + private sealed record ClassListItem(int ClassId, string Name, string FileName, bool IsModern, string? Error = null) { public override string ToString() diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index da75ec49..729ec14a 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -34,7 +34,7 @@ public sealed class ClassMacrosEditorControl : UserControl _resolveClassMacrosPath = resolveClassMacrosPath; _updateConfigAsync = updateConfigAsync; _reloadButton = UiTheme.CreateButton("刷新", UiTheme.Field, UiTheme.Text); - _saveButton = UiTheme.CreateButton("保存并更新配置", UiTheme.Accent, Color.Black); + _saveButton = UiTheme.CreateButton("保存", UiTheme.Accent, Color.Black); InitializeComponent(); ReloadFromAddon(); } @@ -69,13 +69,12 @@ public sealed class ClassMacrosEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, ColumnCount = 1, - RowCount = 3, + RowCount = 2, Padding = new Padding(12), Margin = new Padding(0, 0, 12, 0) }; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 88)); panel.Controls.Add(new Label { @@ -99,22 +98,6 @@ public sealed class ClassMacrosEditorControl : UserControl } }; panel.Controls.Add(_classList, 0, 1); - - var actions = new FlowLayoutPanel - { - Dock = DockStyle.Fill, - FlowDirection = FlowDirection.TopDown, - WrapContents = false, - BackColor = UiTheme.SurfaceRaised, - Margin = new Padding(0, 10, 0, 0) - }; - StyleActionButton(_reloadButton); - StyleActionButton(_saveButton); - _reloadButton.Click += (_, _) => ReloadFromAddon(); - _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); - actions.Controls.Add(_reloadButton); - actions.Controls.Add(_saveButton); - panel.Controls.Add(actions, 0, 2); return panel; } @@ -133,12 +116,13 @@ public sealed class ClassMacrosEditorControl : UserControl Dock = DockStyle.Fill, BackColor = UiTheme.Surface, ColumnCount = 1, - RowCount = 3, + RowCount = 4, Margin = new Padding(0) }; root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); root.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); var header = new TableLayoutPanel { @@ -172,10 +156,41 @@ public sealed class ClassMacrosEditorControl : UserControl _offsetLabel.Padding = new Padding(14, 0, 14, 0); _offsetLabel.Margin = new Padding(0); _offsetLabel.AutoEllipsis = true; - _offsetLabel.Text = "动态宏每项占 30 槽;static/special 的 [1] 对应全局热键偏移后的相对序号"; + _offsetLabel.Text = "创建顺序:动态宏(每项 30 槽)→ 静态宏 → 特殊宏;空字符串保留槽位"; root.Controls.Add(_offsetLabel, 0, 1); root.Controls.Add(BuildSectionTabs(), 0, 2); + + var actionRow = new TableLayoutPanel + { + Dock = DockStyle.Fill, + BackColor = UiTheme.Surface, + ColumnCount = 2, + RowCount = 1, + Margin = new Padding(0), + Padding = new Padding(0, 8, 12, 12) + }; + actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + actionRow.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 228)); + + var actions = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + BackColor = UiTheme.Surface, + Margin = new Padding(0) + }; + StyleActionButton(_reloadButton); + StyleActionButton(_saveButton); + _reloadButton.Margin = new Padding(0, 0, 8, 0); + _saveButton.Margin = new Padding(0); + _reloadButton.Click += (_, _) => ReloadFromAddon(); + _saveButton.Click += async (_, _) => await SaveAndUpdateAsync(); + actions.Controls.Add(_reloadButton); + actions.Controls.Add(_saveButton); + actionRow.Controls.Add(actions, 1, 0); + root.Controls.Add(actionRow, 0, 3); return root; } @@ -216,8 +231,8 @@ public sealed class ClassMacrosEditorControl : UserControl var pages = new Control[] { BuildDynamicPage(), - BuildSparsePage(_staticGrid, "相对序号", "内容(拼在 /cast 后)", "注释"), - BuildSparsePage(_specialGrid, "相对序号", "完整宏文本", "注释") + BuildArrayPage(_staticGrid, "法术/条件或完整宏(空字符串 = 占位)", "注释"), + BuildArrayPage(_specialGrid, "追加宏内容(空字符串 = 占位)", "注释") }; foreach (var page in pages) { @@ -327,7 +342,7 @@ public sealed class ClassMacrosEditorControl : UserControl return panel; } - private Control BuildSparsePage(DataGridView grid, string indexHeader, string textHeader, string commentHeader) + private Control BuildArrayPage(DataGridView grid, string textHeader, string commentHeader) { var panel = new TableLayoutPanel { @@ -340,7 +355,13 @@ public sealed class ClassMacrosEditorControl : UserControl panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); ConfigureGrid(grid); - grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Index", HeaderText = indexHeader, Width = 90 }); + grid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Index", + HeaderText = "顺序", + Width = 72, + ReadOnly = true + }); grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Text", @@ -361,21 +382,21 @@ public sealed class ClassMacrosEditorControl : UserControl grid.CellValueChanged += (_, _) => { MarkDirty(); - if (ReferenceEquals(grid, _dynamicGrid)) - { - UpdateOffsetHint(); - } + UpdateOffsetHint(); + }; + grid.UserAddedRow += (_, _) => + { + RenumberArrayRows(grid); + MarkDirty(); + UpdateOffsetHint(); }; - grid.UserAddedRow += (_, _) => MarkDirty(); grid.RowsRemoved += (_, _) => { if (!_suppressUi) { + RenumberArrayRows(grid); MarkDirty(); - if (ReferenceEquals(grid, _dynamicGrid)) - { - UpdateOffsetHint(); - } + UpdateOffsetHint(); } }; } @@ -618,19 +639,21 @@ public sealed class ClassMacrosEditorControl : UserControl _dynamicGrid.Rows.Add(name, "×"); } - foreach (var entry in _currentMacros.StaticSpells) + for (var i = 0; i < _currentMacros.StaticSpells.Count; i++) { + var entry = _currentMacros.StaticSpells[i]; _staticGrid.Rows.Add( - entry.Index.ToString(CultureInfo.InvariantCulture), + (i + 1).ToString(CultureInfo.InvariantCulture), entry.Text, entry.Comment ?? "", "×"); } - foreach (var entry in _currentMacros.SpecialSpells) + for (var i = 0; i < _currentMacros.SpecialSpells.Count; i++) { + var entry = _currentMacros.SpecialSpells[i]; _specialGrid.Rows.Add( - entry.Index.ToString(CultureInfo.InvariantCulture), + (i + 1).ToString(CultureInfo.InvariantCulture), entry.Text, entry.Comment ?? "", "×"); @@ -646,23 +669,25 @@ public sealed class ClassMacrosEditorControl : UserControl private void UpdateOffsetHint() { - var count = 0; + var dynamicCount = 0; + var staticCount = 0; if (_currentMacros is not null) { - // 优先用网格当前行数(含未提交编辑) - count = _dynamicGrid.Rows.Cast().Count(r => !r.IsNewRow - && !string.IsNullOrWhiteSpace(r.Cells["Name"].Value?.ToString())); - if (count == 0 && !_dirty) + // 数组中的空字符串同样占槽,所以按实际行数计算。 + dynamicCount = _dynamicGrid.Rows.Cast().Count(r => !r.IsNewRow); + staticCount = _staticGrid.Rows.Cast().Count(r => !r.IsNewRow); + if (!_dirty) { - count = _currentMacros.DynamicSpells.Count; + dynamicCount = _currentMacros.DynamicSpells.Count; + staticCount = _currentMacros.StaticSpells.Count; } } - var slots = count * 30; - var firstStatic = slots + 1; - _offsetLabel.Text = count == 0 - ? "无动态宏:static/special 的 [1] = 全局热键 1" - : $"动态宏 {count} 项,占 {slots} 槽;static/special 的 [1] = 全局热键 {firstStatic}"; + var dynamicSlots = dynamicCount * 30; + var firstStatic = dynamicSlots + 1; + var firstSpecial = dynamicSlots + staticCount + 1; + _offsetLabel.Text = + $"动态宏 {dynamicCount} 项,占 {dynamicSlots} 槽;静态宏 [1] = 全局 {firstStatic};特殊宏 [1] = 全局 {firstSpecial}"; } private void CommitCurrentFromUi() @@ -687,11 +712,11 @@ public sealed class ClassMacrosEditorControl : UserControl } } - WriteSparseGrid(_staticGrid, _currentMacros.StaticSpells); - WriteSparseGrid(_specialGrid, _currentMacros.SpecialSpells); + WriteArrayGrid(_staticGrid, _currentMacros.StaticSpells); + WriteArrayGrid(_specialGrid, _currentMacros.SpecialSpells); } - private static void WriteSparseGrid(DataGridView grid, List target) + private static void WriteArrayGrid(DataGridView grid, List target) { target.Clear(); foreach (DataGridViewRow row in grid.Rows) @@ -701,28 +726,14 @@ public sealed class ClassMacrosEditorControl : UserControl continue; } - var indexText = row.Cells["Index"].Value?.ToString()?.Trim() ?? ""; var text = row.Cells["Text"].Value?.ToString() ?? ""; var comment = row.Cells["Comment"].Value?.ToString()?.Trim(); - if (!int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) + target.Add(new ClassMacrosStore.ArrayEntry { - continue; - } - - if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(comment)) - { - continue; - } - - target.Add(new ClassMacrosStore.SparseEntry - { - Index = index, Text = text.Replace("\r\n", "\n", StringComparison.Ordinal), Comment = string.IsNullOrWhiteSpace(comment) ? null : comment }); } - - target.Sort((a, b) => a.Index.CompareTo(b.Index)); } private async Task SaveAndUpdateAsync() @@ -806,11 +817,9 @@ public sealed class ClassMacrosEditorControl : UserControl } grid.Rows.RemoveAt(e.RowIndex); + RenumberArrayRows(grid); MarkDirty(); - if (ReferenceEquals(grid, _dynamicGrid)) - { - UpdateOffsetHint(); - } + UpdateOffsetHint(); } private void MoveSelectedRow(DataGridView grid, int delta) @@ -838,10 +847,25 @@ public sealed class ClassMacrosEditorControl : UserControl grid.ClearSelection(); grid.Rows[target].Selected = true; grid.CurrentCell = grid.Rows[target].Cells[0]; + RenumberArrayRows(grid); MarkDirty(); - if (ReferenceEquals(grid, _dynamicGrid)) + UpdateOffsetHint(); + } + + private static void RenumberArrayRows(DataGridView grid) + { + if (!grid.Columns.Contains("Index")) { - UpdateOffsetHint(); + return; + } + + var index = 1; + foreach (DataGridViewRow row in grid.Rows) + { + if (!row.IsNewRow) + { + row.Cells["Index"].Value = index++.ToString(CultureInfo.InvariantCulture); + } } }