配置与宏

This commit is contained in:
waynebian01
2026-07-28 20:31:34 +08:00
parent 1794059fcc
commit 3c206fc86b
72 changed files with 4684 additions and 1406 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
Assets/Class/druid.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
Assets/Class/evoker.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
Assets/Class/hunter.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
Assets/Class/mage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
Assets/Class/monk.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
Assets/Class/paladin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Assets/Class/priest.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
Assets/Class/rogue.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
Assets/Class/shaman.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
Assets/Class/warlock.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Assets/Class/warrior.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

64
Assets/IconManifest.json Normal file
View File

@@ -0,0 +1,64 @@
{
"game": "World of Warcraft",
"updated": "2026-07-28",
"source": {
"specializationIds": "https://warcraft.wiki.gg/wiki/SpecID",
"imageBaseUrl": "https://wow.zamimg.com/images/wow/icons/large/"
},
"icons": [
{ "kind": "class", "classId": 1, "class": "Warrior", "nameZhCn": "战士", "slug": "classicon-warrior", "target": "Class/warrior.jpg" },
{ "kind": "class", "classId": 2, "class": "Paladin", "nameZhCn": "圣骑士", "slug": "classicon-paladin", "target": "Class/paladin.jpg" },
{ "kind": "class", "classId": 3, "class": "Hunter", "nameZhCn": "猎人", "slug": "classicon-hunter", "target": "Class/hunter.jpg" },
{ "kind": "class", "classId": 4, "class": "Rogue", "nameZhCn": "潜行者", "slug": "classicon-rogue", "target": "Class/rogue.jpg" },
{ "kind": "class", "classId": 5, "class": "Priest", "nameZhCn": "牧师", "slug": "classicon-priest", "target": "Class/priest.jpg" },
{ "kind": "class", "classId": 6, "class": "DeathKnight", "nameZhCn": "死亡骑士", "slug": "classicon-deathknight", "target": "Class/deathknight.jpg" },
{ "kind": "class", "classId": 7, "class": "Shaman", "nameZhCn": "萨满祭司", "slug": "classicon-shaman", "target": "Class/shaman.jpg" },
{ "kind": "class", "classId": 8, "class": "Mage", "nameZhCn": "法师", "slug": "classicon-mage", "target": "Class/mage.jpg" },
{ "kind": "class", "classId": 9, "class": "Warlock", "nameZhCn": "术士", "slug": "classicon-warlock", "target": "Class/warlock.jpg" },
{ "kind": "class", "classId": 10, "class": "Monk", "nameZhCn": "武僧", "slug": "classicon-monk", "target": "Class/monk.jpg" },
{ "kind": "class", "classId": 11, "class": "Druid", "nameZhCn": "德鲁伊", "slug": "classicon-druid", "target": "Class/druid.jpg" },
{ "kind": "class", "classId": 12, "class": "DemonHunter", "nameZhCn": "恶魔猎手", "slug": "classicon-demonhunter", "target": "Class/demonhunter.jpg" },
{ "kind": "class", "classId": 13, "class": "Evoker", "nameZhCn": "唤魔师", "slug": "classicon-evoker", "target": "Class/evoker.jpg" },
{ "kind": "spec", "classId": 1, "specId": 1, "wowSpecId": 71, "fileDataId": 132355, "class": "Warrior", "name": "Arms", "nameZhCn": "武器", "slug": "ability_warrior_savageblow", "target": "Spec/warrior-arms.jpg" },
{ "kind": "spec", "classId": 1, "specId": 2, "wowSpecId": 72, "fileDataId": 132347, "class": "Warrior", "name": "Fury", "nameZhCn": "狂怒", "slug": "ability_warrior_innerrage", "target": "Spec/warrior-fury.jpg" },
{ "kind": "spec", "classId": 1, "specId": 3, "wowSpecId": 73, "fileDataId": 132341, "class": "Warrior", "name": "Protection", "nameZhCn": "防护", "slug": "ability_warrior_defensivestance", "target": "Spec/warrior-protection.jpg" },
{ "kind": "spec", "classId": 2, "specId": 1, "wowSpecId": 65, "fileDataId": 135920, "class": "Paladin", "name": "Holy", "nameZhCn": "神圣", "slug": "spell_holy_holybolt", "target": "Spec/paladin-holy.jpg" },
{ "kind": "spec", "classId": 2, "specId": 2, "wowSpecId": 66, "fileDataId": 236264, "class": "Paladin", "name": "Protection", "nameZhCn": "防护", "slug": "ability_paladin_shieldofthetemplar", "target": "Spec/paladin-protection.jpg" },
{ "kind": "spec", "classId": 2, "specId": 3, "wowSpecId": 70, "fileDataId": 135873, "class": "Paladin", "name": "Retribution", "nameZhCn": "惩戒", "slug": "spell_holy_auraoflight", "target": "Spec/paladin-retribution.jpg" },
{ "kind": "spec", "classId": 3, "specId": 1, "wowSpecId": 253, "fileDataId": 461112, "class": "Hunter", "name": "Beast Mastery", "nameZhCn": "野兽控制", "slug": "ability_hunter_bestialdiscipline", "target": "Spec/hunter-beastmastery.jpg" },
{ "kind": "spec", "classId": 3, "specId": 2, "wowSpecId": 254, "fileDataId": 236179, "class": "Hunter", "name": "Marksmanship", "nameZhCn": "射击", "slug": "ability_hunter_focusedaim", "target": "Spec/hunter-marksmanship.jpg" },
{ "kind": "spec", "classId": 3, "specId": 3, "wowSpecId": 255, "fileDataId": 461113, "class": "Hunter", "name": "Survival", "nameZhCn": "生存", "slug": "ability_hunter_camouflage", "target": "Spec/hunter-survival.jpg" },
{ "kind": "spec", "classId": 4, "specId": 1, "wowSpecId": 259, "fileDataId": 236270, "class": "Rogue", "name": "Assassination", "nameZhCn": "奇袭", "slug": "ability_rogue_eviscerate", "target": "Spec/rogue-assassination.jpg" },
{ "kind": "spec", "classId": 4, "specId": 2, "wowSpecId": 260, "fileDataId": 236286, "class": "Rogue", "name": "Outlaw", "nameZhCn": "狂徒", "slug": "ability_rogue_waylay", "target": "Spec/rogue-outlaw.jpg" },
{ "kind": "spec", "classId": 4, "specId": 3, "wowSpecId": 261, "fileDataId": 132320, "class": "Rogue", "name": "Subtlety", "nameZhCn": "敏锐", "slug": "ability_stealth", "target": "Spec/rogue-subtlety.jpg" },
{ "kind": "spec", "classId": 5, "specId": 1, "wowSpecId": 256, "fileDataId": 135940, "class": "Priest", "name": "Discipline", "nameZhCn": "戒律", "slug": "spell_holy_powerwordshield", "target": "Spec/priest-discipline.jpg" },
{ "kind": "spec", "classId": 5, "specId": 2, "wowSpecId": 257, "fileDataId": 237542, "class": "Priest", "name": "Holy", "nameZhCn": "神圣", "slug": "spell_holy_guardianspirit", "target": "Spec/priest-holy.jpg" },
{ "kind": "spec", "classId": 5, "specId": 3, "wowSpecId": 258, "fileDataId": 136207, "class": "Priest", "name": "Shadow", "nameZhCn": "暗影", "slug": "spell_shadow_shadowwordpain", "target": "Spec/priest-shadow.jpg" },
{ "kind": "spec", "classId": 6, "specId": 1, "wowSpecId": 250, "fileDataId": 135770, "class": "DeathKnight", "name": "Blood", "nameZhCn": "鲜血", "slug": "spell_deathknight_bloodpresence", "target": "Spec/deathknight-blood.jpg" },
{ "kind": "spec", "classId": 6, "specId": 2, "wowSpecId": 251, "fileDataId": 135773, "class": "DeathKnight", "name": "Frost", "nameZhCn": "冰霜", "slug": "spell_deathknight_frostpresence", "target": "Spec/deathknight-frost.jpg" },
{ "kind": "spec", "classId": 6, "specId": 3, "wowSpecId": 252, "fileDataId": 135775, "class": "DeathKnight", "name": "Unholy", "nameZhCn": "邪恶", "slug": "spell_deathknight_unholypresence", "target": "Spec/deathknight-unholy.jpg" },
{ "kind": "spec", "classId": 7, "specId": 1, "wowSpecId": 262, "fileDataId": 136048, "class": "Shaman", "name": "Elemental", "nameZhCn": "元素", "slug": "spell_nature_lightning", "target": "Spec/shaman-elemental.jpg" },
{ "kind": "spec", "classId": 7, "specId": 2, "wowSpecId": 263, "fileDataId": 237581, "class": "Shaman", "name": "Enhancement", "nameZhCn": "增强", "slug": "spell_shaman_improvedreincarnation", "target": "Spec/shaman-enhancement.jpg" },
{ "kind": "spec", "classId": 7, "specId": 3, "wowSpecId": 264, "fileDataId": 136052, "class": "Shaman", "name": "Restoration", "nameZhCn": "恢复", "slug": "spell_nature_magicimmunity", "target": "Spec/shaman-restoration.jpg" },
{ "kind": "spec", "classId": 8, "specId": 1, "wowSpecId": 62, "fileDataId": 135932, "class": "Mage", "name": "Arcane", "nameZhCn": "奥术", "slug": "spell_holy_magicalsentry", "target": "Spec/mage-arcane.jpg" },
{ "kind": "spec", "classId": 8, "specId": 2, "wowSpecId": 63, "fileDataId": 135810, "class": "Mage", "name": "Fire", "nameZhCn": "火焰", "slug": "spell_fire_firebolt02", "target": "Spec/mage-fire.jpg" },
{ "kind": "spec", "classId": 8, "specId": 3, "wowSpecId": 64, "fileDataId": 135846, "class": "Mage", "name": "Frost", "nameZhCn": "冰霜", "slug": "spell_frost_frostbolt02", "target": "Spec/mage-frost.jpg" },
{ "kind": "spec", "classId": 9, "specId": 1, "wowSpecId": 265, "fileDataId": 136145, "class": "Warlock", "name": "Affliction", "nameZhCn": "痛苦", "slug": "spell_shadow_deathcoil", "target": "Spec/warlock-affliction.jpg" },
{ "kind": "spec", "classId": 9, "specId": 2, "wowSpecId": 266, "fileDataId": 136172, "class": "Warlock", "name": "Demonology", "nameZhCn": "恶魔学识", "slug": "spell_shadow_metamorphosis", "target": "Spec/warlock-demonology.jpg" },
{ "kind": "spec", "classId": 9, "specId": 3, "wowSpecId": 267, "fileDataId": 136186, "class": "Warlock", "name": "Destruction", "nameZhCn": "毁灭", "slug": "spell_shadow_rainoffire", "target": "Spec/warlock-destruction.jpg" },
{ "kind": "spec", "classId": 10, "specId": 1, "wowSpecId": 268, "fileDataId": 608951, "class": "Monk", "name": "Brewmaster", "nameZhCn": "酒仙", "slug": "spell_monk_brewmaster_spec", "target": "Spec/monk-brewmaster.jpg" },
{ "kind": "spec", "classId": 10, "specId": 2, "wowSpecId": 270, "fileDataId": 608952, "class": "Monk", "name": "Mistweaver", "nameZhCn": "织雾", "slug": "spell_monk_mistweaver_spec", "target": "Spec/monk-mistweaver.jpg" },
{ "kind": "spec", "classId": 10, "specId": 3, "wowSpecId": 269, "fileDataId": 608953, "class": "Monk", "name": "Windwalker", "nameZhCn": "踏风", "slug": "spell_monk_windwalker_spec", "target": "Spec/monk-windwalker.jpg" },
{ "kind": "spec", "classId": 11, "specId": 1, "wowSpecId": 102, "fileDataId": 136096, "class": "Druid", "name": "Balance", "nameZhCn": "平衡", "slug": "spell_nature_starfall", "target": "Spec/druid-balance.jpg" },
{ "kind": "spec", "classId": 11, "specId": 2, "wowSpecId": 103, "fileDataId": 132115, "class": "Druid", "name": "Feral", "nameZhCn": "野性", "slug": "ability_druid_catform", "target": "Spec/druid-feral.jpg" },
{ "kind": "spec", "classId": 11, "specId": 3, "wowSpecId": 104, "fileDataId": 132276, "class": "Druid", "name": "Guardian", "nameZhCn": "守护", "slug": "ability_racial_cannibalize", "target": "Spec/druid-guardian.jpg" },
{ "kind": "spec", "classId": 11, "specId": 4, "wowSpecId": 105, "fileDataId": 136041, "class": "Druid", "name": "Restoration", "nameZhCn": "恢复", "slug": "spell_nature_healingtouch", "target": "Spec/druid-restoration.jpg" },
{ "kind": "spec", "classId": 12, "specId": 1, "wowSpecId": 577, "fileDataId": 1247264, "class": "DemonHunter", "name": "Havoc", "nameZhCn": "浩劫", "slug": "ability_demonhunter_specdps", "target": "Spec/demonhunter-havoc.jpg" },
{ "kind": "spec", "classId": 12, "specId": 2, "wowSpecId": 581, "fileDataId": 1247265, "class": "DemonHunter", "name": "Vengeance", "nameZhCn": "复仇", "slug": "ability_demonhunter_spectank", "target": "Spec/demonhunter-vengeance.jpg" },
{ "kind": "spec", "classId": 12, "specId": 3, "wowSpecId": 1480, "fileDataId": 7455385, "class": "DemonHunter", "name": "Devourer", "nameZhCn": "噬灭", "slug": "classicon_demonhunter_void", "target": "Spec/demonhunter-devourer.jpg" },
{ "kind": "spec", "classId": 13, "specId": 1, "wowSpecId": 1467, "fileDataId": 4511811, "class": "Evoker", "name": "Devastation", "nameZhCn": "湮灭", "slug": "classicon_evoker_devastation", "target": "Spec/evoker-devastation.jpg" },
{ "kind": "spec", "classId": 13, "specId": 2, "wowSpecId": 1468, "fileDataId": 4511812, "class": "Evoker", "name": "Preservation", "nameZhCn": "恩护", "slug": "classicon_evoker_preservation", "target": "Spec/evoker-preservation.jpg" },
{ "kind": "spec", "classId": 13, "specId": 3, "wowSpecId": 1473, "fileDataId": 5198700, "class": "Evoker", "name": "Augmentation", "nameZhCn": "增辉", "slug": "classicon_evoker_augmentation", "target": "Spec/evoker-augmentation.jpg" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
Assets/Spec/druid-feral.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
Assets/Spec/mage-arcane.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Assets/Spec/mage-fire.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
Assets/Spec/mage-frost.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
Assets/Spec/priest-holy.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,294 @@
---
title: "Fuyutsui core/classmacros.luaAI 宏规则参考"
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 三表规则与 CreateMacro 展开逻辑"
---
# Fuyutsui `core/classmacros.lua`AI 宏规则参考
> 本文描述如何在 `ClassMacros` 里声明职业宏,以及 `CreateMacro` 如何把它们展开成 SecureActionButton 覆盖绑定。
> **改宏只改 `core/classmacros.lua`**,不要改 `class/*.lua`,也不要在别处硬编码职业宏列表。
## 0. 一句话定义
`classmacros.lua` 只提供数据表 `Fuyutsui.ClassMacros[classFile]`
运行时由 `main.lua:LoadPlayerMacros()``UnitClassBase("player")` 取出当前职业,再调用:
```lua
Fuyutsui:CreateMacro(m.dynamicSpells, m.staticSpells, m.specialSpells)
```
真正创建按钮、拼宏文本、绑定热键的逻辑在 `core/macro.lua`
## 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. 加载与消费链路
```text
classmacros.lua 定义 Fuyutsui.ClassMacros
main.lua:LoadPlayerMacros()
│ classFile = UnitClassBase("player")
│ MacrosList = ClassMacros[classFile]
macro.lua:CreateMacro(dynamic, static, special)
│ 按 macroKind[i] 生成按钮 s1..sN
│ SetOverrideBindingClick + macrotext
玩家按覆盖热键 → 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` | 小键盘 09 / 小数点 / ± / / × / ÷;`F1F3,F5F12`**无 F4**`, . / ; ' [ ] \``7 8 9 0 =` |
- 总槽位数 = `7 × 39 = 273`
- 按钮名:`s1``s2`、…(与 `macroKind` 下标一致)。
- 人读对照表见 `Keymap.md`ID 1 = `CTRL-NUMPAD1`,以此类推)。
**AI 改宏时**:关心的是 `dynamic` 占用多少槽、以及 static/special 的相对 `index`;不必手算全局热键,除非要核对外部程序按键映射。
## 4. 槽位分配算法(核心)
对每个热键下标 `i = 1 .. #macroKind`
```text
dynamicSlots = #dynamicSpells * 30
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
若 macroBody 非空 → 创建/更新按钮 s{i}
```
因此:
| `dynamicSpells` 长度 | 动态占用槽 | static/special 的 `[1]` 对应全局 `i` |
|---:|---:|---:|
| 0 | 0 | 1 |
| 1 | 30 | 31 |
| 5 | 150 | 151 |
| 6 | 180 | 181 |
| 7 | 210 | 211 |
**新增或删除 `dynamicSpells` 条目会平移其后所有 static/special 的实际热键。** 改动态列表前必须评估这个偏移。
## 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 → 用 `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 = {
[1] = "英勇投掷",
[5] = "[@mouseover]保护祝福",
[20] = "[spec:2]圣洁鸣钟;[spec:3]灰烬觉醒",
[37] = "item:241304\n/cast item:241305", -- 会变成 /cast item:...\n/cast item:...
}
```
- **稀疏表**合法:缺号表示该相对序号不建宏。
- 值是**拼在 `/cast ` 后面的字符串**,不是完整宏(除非用 `\n` 续写后续行)。
### 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]暗影形态` |
| 双物品一行 | `"item:241304\n/cast item:241305"` | `/cast item:241304` + 第二行 `/cast item:241305` |
### 6.3 不要放进 static 的情况
若第一行**不能**以自动加的 `/cast ` 开头,或需要 `/castsequence``/stopcasting``/cancelaura` 等,改用 `specialSpells`
## 7. `specialSpells` 规则
### 7.1 数据结构
```lua
specialSpells = {
[36] = "/castsequence reset=0.3 真言术:耀,x",
[39] = "/stopcasting",
[17] = "/cancelaura [spec:4]猎豹形态\n/cast 万灵之召",
}
```
- 值是**完整 `macrotext`**,原样写入按钮,**不会**再加 `/cast `
- `index``staticSpells` 共用同一套相对序号。
- 同 index 若 special 存在static **被忽略**(可注释掉对应 static如 DH 的恶魔变形 / 烈火烙印)。
### 7.2 典型用途
| 模式 | 示例(文件中已有) |
|---|---|
| castsequence + 哑元 | `"/castsequence reset=0.5 死亡之握,x"` |
| 停施法 | `"/stopcasting"` |
| 取消光环再施法 | `"/cancelaura [spec:4]猎豹形态\n/cast 万灵之召"` |
`castsequence ...,x` 中的 `x` 是占位,用于快速连按重置序列的常见写法;改此类宏时保持现有 reset 秒数风格,除非有明确需求。
## 8. 决策树:新技能放哪
```text
需要按队友/团队槽位点名?
├─ 是 → dynamicSpells纯法术名注意 +30 偏移)
└─ 否 → 能否写成「/cast + 一段条件/法术名」?
├─ 是 → staticSpells[index]
└─ 否 → specialSpells[index](完整宏文本)
```
同 index 既要特殊宏又想保留备注special 写生效文本static 删掉或改成注释。
## 9. 修改检查清单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` 验证。
## 10. 最小示例
### 10.1 无动态(近战输出职业常见)
```lua
WARRIOR = {
dynamicSpells = {},
specialSpells = {},
staticSpells = {
[1] = "英勇投掷",
[38] = "拳击",
[39] = "[@focus]拳击",
},
}
```
- `[1]` → 全局 `i=1``/cast 英勇投掷`
- `[39]``/cast [@focus]拳击`
### 10.2 有动态 + special 覆盖
```lua
PRIEST = {
dynamicSpells = { "苦修", "快速治疗", "真言术:盾", "愈合祷言", "纯净术", "圣言术:静" },
-- 动态占 180 槽;此后 index 1 → 全局 i=181
specialSpells = {
[36] = "/castsequence reset=0.3 真言术:耀,x",
[39] = "/stopcasting",
},
staticSpells = {
[1] = "心灵震爆",
[37] = "item:241304\n/cast item:241305",
-- [36] / [39] 由 special 接管,不必在 static 重复
},
}
```
## 11. 与本文相关、但不要在这里改的东西
| 文件 | 职责 | 改宏时 |
|---|---|---|
| `core/macro.lua` | 热键表、展开算法、安全按钮 | 仅当要改分配规则/键池时才动 |
| `main.lua` | `LoadPlayerMacros` 选职业表 | 一般不动 |
| `Keymap.md` | 人读热键 ID 对照 | 键池变更时同步 |
| `core/keybinds.lua` / `config.lua` keymap | 动作条扫描 → 像素协议 | **另一套**按键编码,与 ClassMacros 覆盖绑定无关 |
| `class/*.lua` | ClassBlocks 色块 | 不放宏 |
## 12. 常见错误
| 错误 | 后果 |
|---|---|
| static 写成 `"/cast 火球术"` | 实际变成 `/cast /cast 火球术` |
| special 只写 `"火球术"` | 无 `/` 命令,宏无效 |
| 把点名治疗放进 static | 无法按 raid/party 槽位点名 |
| 在 `dynamicSpells` 中间插入技能却不评估偏移 | 后续所有按键语义错位 |
| 用错职业键(如 `DeathKnight` | `LoadPlayerMacros` 取不到表,宏不创建 |
| 假设战斗中改表立即生效 | `InCombatLockdown` 直接 return按钮不更新 |

View File

@@ -45,7 +45,7 @@ LogicRegistry.Run(classId, specId, ...) Modules/LogicRegistry.cs
KeySender.Send(hotkey) Input/KeySender.cs (+ Input/NativeMethods.cs Win32 互操作)
```
UI 侧:[UI/MainForm.cs](UI/MainForm.cs) 是无边框置顶浮动条,托管 `ShigureRuntime` 和 [UI/StatusForm.cs](UI/StatusForm.cs)页签设置窗口)。运行时通过 `SnapshotUpdated` 事件推送 `RenderSnapshot` 给 UI 刷新。
UI 侧:[UI/MainForm.cs](UI/MainForm.cs) 是无边框置顶浮动条,托管 `ShigureRuntime` 和 [UI/StatusForm.cs](UI/StatusForm.cs)页签设置窗口:通用/配置/宏/模块/状态/队伍/逻辑/日志/关于)。运行时通过 `SnapshotUpdated` 事件推送 `RenderSnapshot` 给 UI 刷新。
### 目录约定
@@ -54,7 +54,7 @@ App/ 入口、启动参数、随机重启
Runtime/ 扫描、状态构建、主循环、快照
Modules/ 模块模型/存储/匹配/规则执行、条件求值(FormulaEvaluator)、字段目录、职业逻辑
Input/ keymap 读取、按键发送、Win32 API
Infrastructure/ 配置读取(ConfigService)、JSON 辅助、UI 缓存、路径
Infrastructure/ 配置读取(ConfigService)、JSON 辅助、UI 缓存、路径、Fuyutsui 插件文件读写
UI/ WinForms 界面、编辑器、主题
config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .csproj 的 None+CopyToOutputDirectory)
```
@@ -65,10 +65,46 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c
- 选择优先级:`ModuleStore.FindSelectedOrBestMatch` —— 先用 UI/参数选定的 `ModuleId`;否则取 `Match` 命中字段最多者(`ModuleMatch.Specificity` 越大越优先),并列按名称。`Match` 字段留空 = 任意。`PartyType` 数字会归一化为 `"1-40"`
- 动态单位/数量/动态数值的语义见 [README.md](README.md#动态单位与数量字段);列表与编辑器的人类可读摘要统一走 [UI/UnitSummary.cs](UI/UnitSummary.cs)`.Describe(...)`(单一来源,勿再复制一份描述逻辑)。
## Fuyutsui 插件集成(配置/宏页面)
设置窗口的「配置」和「宏」两个页签编辑的是游戏内 Fuyutsui 插件的 Lua 文件,**不是** Shigure 本地的 config/keymap/module。整个子系统通过游戏进程窗口定位插件目录读写 Lua 表字面量,保存后触发 Shigure 侧的 config/keymap 更新。
### 定位
[Infrastructure/WowAddonLocator.cs](Infrastructure/WowAddonLocator.cs) 通过 `FindWindow` → 进程路径 → 向上查找 `Interface\AddOns\Fuyutsui`。提供三个入口:`FindAddonRoot``FindClassDirectory``class/` 子目录)、`FindClassMacrosPath``core/classmacros.lua`)。
### Lua 解析
[Infrastructure/LuaLiteParser.cs](Infrastructure/LuaLiteParser.cs) 是轻量 Lua 表字面量解析器。关键方法 `TryExtractAssignedTable(source, assignmentName, out table, out start, out end)` 按赋值名定位 `{ ... }` 并返回解析结果 + 字符偏移量,用于 round-trip 编辑(只替换表字面量,保留文件其余部分)。支持行尾 `-- comment` 捕获(`CaptureEntryTrailingComment`)。
### 配置存储ClassBlocks
[Infrastructure/ClassBlocksStore.cs](Infrastructure/ClassBlocksStore.cs) 读写 `class/*.lua` 中的 `Fuyutsui.ClassBlocks` 表。每个职业一个 Lua 文件,按专精 ID 分块,包含:
- **States**(状态字段):分平面列表或按 `"状态"/"目标"/"焦点"` 分类(现代格式)
- **Auras**光环5 桶——玩家/目标有害/目标有益/焦点有害/焦点有益
- **Spells**法术ID、名称、充能、施法计数、强制已知、法术书
- **Group**(队伍):人数/生命百分比/角色/驱散 + 队伍光环列表
字段名从 [Infrastructure/ClassStateCatalog.cs](Infrastructure/ClassStateCatalog.cs) 的静态目录验证,不允许自由输入。
### 宏存储ClassMacros
[Infrastructure/ClassMacrosStore.cs](Infrastructure/ClassMacrosStore.cs) 读写 `core/classmacros.lua` 中的 `Fuyutsui.ClassMacros` 表。每职业三组:
- **DynamicSpells**:每项占 30 热键槽位
- **StaticSpells / SpecialSpells**:稀疏索引条目(`SparseEntry`index + text + 可选行尾注释)
保存后 [Infrastructure/FuyutsuiKeymapConverter.cs](Infrastructure/FuyutsuiKeymapConverter.cs)`.UpdateFromClassMacros` 将 Lua 宏表转换为 `keymap/*.json`把宏槽位映射到热键池7 修饰符 × 35 键 = 245 组合/职业)。`DeriveSpellName` 解析 WoW 宏文本提取技能名。
### UI 编辑器
- [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的四页编辑器(状态/光环/法术/队伍),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`
- [UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs):左侧职业列表 + 右侧三页编辑器(动态宏/静态宏/特殊宏),偏移提示显示槽位编号计算。
- 两个编辑器均接受 `Func<string?>` 定位器 + `Func<Task>` 保存回调,由 `MainForm` 在构造时注入。保存流程:编辑器调 `Store.Save()` → 回调 `MainForm.UpdateConfigFromAddonAsync()` → 重新生成 config/keymap → 重启运行时。
## UI 约定
- **暗色主题集中在 [UI/UiTheme.cs](UI/UiTheme.cs)**:新控件一律复用它(`CreateButton`/`StyleComboBox`/`StyleTextBox`/`StyleDataGridView`/`StyleListView` 与颜色常量 `Background/Surface/Field/Hover/Border/Text/Muted/Accent/Danger`),不要写裸色值或系统默认样式。
- 编辑器:[UI/ModuleEditorControl.cs](UI/ModuleEditorControl.cs)(模块主编辑器:侧栏列表 + 规则表 + 动态单位列表 + 两个动态数值表,自定义标签栏切换三页)、弹窗 [UI/ConditionEditorForm.cs](UI/ConditionEditorForm.cs)(可视化条件,含 `ConditionExpression` 文本⇆比较项互转)、[UI/UnitEditorForm.cs](UI/UnitEditorForm.cs)、[UI/FormulaEditorForm.cs](UI/FormulaEditorForm.cs)。新弹窗按现有模式同时设 `AcceptButton`/`CancelButton`
- 编辑器:[UI/ModuleEditorControl.cs](UI/ModuleEditorControl.cs)(模块主编辑器:侧栏列表 + 规则表 + 动态单位列表 + 两个动态数值表,自定义标签栏切换三页)、[UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs)(配置编辑器)、[UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs)(宏编辑器)、弹窗 [UI/ConditionEditorForm.cs](UI/ConditionEditorForm.cs)(可视化条件,含 `ConditionExpression` 文本⇆比较项互转)、[UI/UnitEditorForm.cs](UI/UnitEditorForm.cs)、[UI/FormulaEditorForm.cs](UI/FormulaEditorForm.cs)。新弹窗按现有模式同时设 `AcceptButton`/`CancelButton`三个编辑器 UserControl 均遵循相同模式:左侧列表 + 右侧分页编辑区 + `UiTheme` 样式。
- **规则表 `_rulesGrid` 的列陷阱**`FillEditor`/`OpenConditionEditor` 用**位置参数** `Rows.Add(enabled, spell, "", condition)`,按列集合索引前 4 列填值。所以新增列(如拖拽手柄 `Drag`)要**加到集合末尾**、再用 `DisplayIndex` 调显示位置,避免打乱前四列;单元格访问一律按列名(`Cells["Spell"]`)。
- 规则重排:`▲▼` 单步(`MoveRule`+ 手柄列拖拽(`MoveRuleByDrag`,读全表→重排→写回,复用 `ReadRuleRow`/`WriteRuleRow`)。三个 grid 都 `AllowUserToDeleteRows=false`,删除只走 `×` 列。

View File

@@ -0,0 +1,570 @@
using System.Globalization;
using System.Text;
using static Shigure.LuaLiteParser;
namespace Shigure;
/// <summary>
/// 读写 Fuyutsui class/*.lua 中的 ClassBlocksstates/auras/spells/group
/// 保存时只替换 ClassBlocks 表字面量,保留文件其余内容。
/// </summary>
internal static class ClassBlocksStore
{
public const string AssignmentName = "Fuyutsui.ClassBlocks";
public sealed class ClassFileDocument
{
public string FilePath { get; set; } = string.Empty;
public string SourceText { get; set; } = string.Empty;
public int TableStart { get; set; }
public int TableEndExclusive { get; set; }
public Dictionary<int, SpecBlocks> Specs { get; set; } = new();
public bool IsModernFormat { get; set; }
}
public sealed class SpecBlocks
{
public bool NestedStates { get; set; } = true;
public List<string> FlatStates { get; } = new();
public Dictionary<string, List<string>> CategorizedStates { get; } = new(StringComparer.Ordinal)
{
["状态"] = new List<string>(),
["目标"] = new List<string>(),
["焦点"] = new List<string>()
};
public List<AuraEntry> PlayerAuras { get; } = new();
public List<AuraEntry> TargetHarmfulAuras { get; } = new();
public List<AuraEntry> TargetHelpfulAuras { get; } = new();
public List<AuraEntry> FocusHarmfulAuras { get; } = new();
public List<AuraEntry> FocusHelpfulAuras { get; } = new();
public List<SpellEntry> Spells { get; } = new();
public GroupBlocks? Group { get; set; }
}
public sealed class AuraEntry
{
public string Name { get; set; } = string.Empty;
public long? SpellId { get; set; }
public List<long> SpellIds { get; } = new();
public int? MaxApps { get; set; }
}
public sealed class SpellEntry
{
public string Name { get; set; } = string.Empty;
public long SpellId { get; set; }
public bool Charge { get; set; }
public int? MaxCharge { get; set; }
public int? CastCount { get; set; }
public bool ForcedKnown { get; set; }
public bool InSpellBook { get; set; }
}
public sealed class GroupBlocks
{
public int Num { get; set; } = 5;
public int? HealthPercent { get; set; } = 1;
public int? Role { get; set; } = 2;
public int? Dispel { get; set; }
public List<GroupAuraEntry> Auras { get; } = new();
}
public sealed class GroupAuraEntry
{
public int Offset { get; set; }
public string Name { get; set; } = string.Empty;
public long? SpellId { get; set; }
public List<long> SpellIds { get; } = new();
}
public static ClassFileDocument Load(string filePath)
{
var source = File.ReadAllText(filePath, Encoding.UTF8);
if (!TryExtractAssignedTable(source, AssignmentName, out var table, out var start, out var end))
{
throw new InvalidDataException($"{Path.GetFileName(filePath)} 中未找到 {AssignmentName}");
}
var specs = new Dictionary<int, SpecBlocks>();
var modern = false;
foreach (var (key, value) in table.Entries)
{
if (key is not long specId || value is not TableValue specTable)
{
continue;
}
var spec = ParseSpec(specTable, out var specModern);
modern |= specModern;
specs[(int)specId] = spec;
}
return new ClassFileDocument
{
FilePath = filePath,
SourceText = source,
TableStart = start,
TableEndExclusive = end,
Specs = specs,
IsModernFormat = modern
};
}
public static void Save(ClassFileDocument document)
{
if (!document.IsModernFormat)
{
throw new InvalidOperationException("当前文件仍是旧版稀疏索引 ClassBlocks无法用图形编辑器保存。");
}
var serialized = SerializeClassBlocks(document.Specs);
var updated = document.SourceText[..document.TableStart]
+ serialized
+ document.SourceText[document.TableEndExclusive..];
File.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var start, out var end))
{
throw new InvalidOperationException("保存后无法重新定位 ClassBlocks 表。");
}
document.SourceText = updated;
document.TableStart = start;
document.TableEndExclusive = end;
}
private static SpecBlocks ParseSpec(TableValue spec, out bool isModern)
{
var result = new SpecBlocks();
isModern = spec.GetTable("states") is not null
|| spec.GetTable("auras") is not null
|| spec.GetTable("spells") is not null
|| spec.GetTable("group") is not null;
if (!isModern)
{
return result;
}
if (spec.GetTable("states") is { } states)
{
var nested = states.GetTable("状态") is not null
|| states.GetTable("目标") is not null
|| states.GetTable("焦点") is not null;
result.NestedStates = nested;
if (nested)
{
foreach (var category in new[] { "状态", "目标", "焦点" })
{
if (states.GetTable(category) is not { } list)
{
continue;
}
var target = result.CategorizedStates[category];
foreach (var item in list.IPairs())
{
if (item is StringValue name && !string.IsNullOrWhiteSpace(name.Value))
{
target.Add(name.Value);
}
}
}
}
else
{
foreach (var item in states.IPairs())
{
if (item is StringValue name && !string.IsNullOrWhiteSpace(name.Value))
{
result.FlatStates.Add(name.Value);
}
}
}
}
if (spec.GetTable("auras") is { } auras)
{
var nested = auras.GetTable("player") is not null
|| auras.GetTable("target") is not null
|| auras.GetTable("focus") is not null;
if (nested)
{
AppendAuraList(auras.GetTable("player"), result.PlayerAuras);
if (auras.GetTable("target") is { } target)
{
AppendAuraList(target.GetTable("harmful"), result.TargetHarmfulAuras);
AppendAuraList(target.GetTable("helpful"), result.TargetHelpfulAuras);
}
if (auras.GetTable("focus") is { } focus)
{
AppendAuraList(focus.GetTable("harmful"), result.FocusHarmfulAuras);
AppendAuraList(focus.GetTable("helpful"), result.FocusHelpfulAuras);
}
}
else
{
AppendAuraList(auras, result.PlayerAuras);
}
}
if (spec.GetTable("spells") is { } spells)
{
foreach (var item in spells.IPairs())
{
if (item is not TableValue spell)
{
continue;
}
var spellId = spell.GetNumber("spellId");
if (spellId is null)
{
continue;
}
result.Spells.Add(new SpellEntry
{
SpellId = (long)spellId.Value,
Name = spell.GetString("name")?.Trim() ?? string.Empty,
Charge = spell.GetBool("charge") == true,
MaxCharge = spell.GetNumber("maxCharge") is { } maxCharge ? (int)maxCharge : null,
CastCount = spell.GetNumber("castCount") is { } castCount ? (int)castCount : null,
ForcedKnown = spell.GetBool("forcedKnown") == true,
InSpellBook = spell.GetBool("inSpellBook") == true
});
}
}
if (spec.GetTable("group") is { } group)
{
var groupBlocks = new GroupBlocks
{
Num = (int)(group.GetNumber("num") ?? 5),
HealthPercent = group.GetNumber("healthPercent") is { } hp ? (int)hp : null,
Role = group.GetNumber("role") is { } role ? (int)role : null,
Dispel = group.GetNumber("dispel") is { } dispel ? (int)dispel : null
};
if (group.GetTable("aura") is { } auraOffsets)
{
foreach (var (key, value) in auraOffsets.Entries)
{
if (key is not long offset || value is not TableValue auraInfo)
{
continue;
}
var entry = new GroupAuraEntry
{
Offset = (int)offset,
Name = auraInfo.GetString("name")?.Trim() ?? string.Empty
};
if (auraInfo.GetNumber("spellId") is { } sid)
{
entry.SpellId = (long)sid;
}
if (auraInfo.Get("spellIds") is TableValue ids)
{
foreach (var idItem in ids.IPairs())
{
if (idItem is NumberValue n)
{
entry.SpellIds.Add(n.AsInt());
}
}
}
groupBlocks.Auras.Add(entry);
}
groupBlocks.Auras.Sort((a, b) => a.Offset.CompareTo(b.Offset));
}
result.Group = groupBlocks;
}
return result;
}
private static void AppendAuraList(TableValue? list, List<AuraEntry> target)
{
if (list is null)
{
return;
}
foreach (var item in list.IPairs())
{
if (item is not TableValue aura)
{
continue;
}
var entry = new AuraEntry
{
Name = aura.GetString("name")?.Trim() ?? string.Empty,
MaxApps = aura.GetNumber("maxApps") is { } maxApps ? (int)maxApps : null
};
if (aura.GetNumber("spellId") is { } sid)
{
entry.SpellId = (long)sid;
}
if (aura.Get("spellIds") is TableValue ids)
{
foreach (var idItem in ids.IPairs())
{
if (idItem is NumberValue n)
{
entry.SpellIds.Add(n.AsInt());
}
}
}
target.Add(entry);
}
}
public static string SerializeClassBlocks(IReadOnlyDictionary<int, SpecBlocks> specs)
{
var sb = new StringBuilder();
sb.AppendLine("{");
foreach (var specId in specs.Keys.OrderBy(x => x))
{
sb.Append(" [").Append(specId).AppendLine("] = {");
WriteSpec(sb, specs[specId], " ");
sb.AppendLine(" },");
}
sb.Append('}');
return sb.ToString();
}
private static void WriteSpec(StringBuilder sb, SpecBlocks spec, string indent)
{
// states
sb.Append(indent).AppendLine("states = {");
if (spec.NestedStates)
{
foreach (var category in new[] { "状态", "目标", "焦点" })
{
var list = spec.CategorizedStates.GetValueOrDefault(category);
if (list is null || list.Count == 0)
{
continue;
}
sb.Append(indent).Append(" [\"").Append(Escape(category)).AppendLine("\"] = {");
foreach (var name in list)
{
sb.Append(indent).Append(" \"").Append(Escape(name)).AppendLine("\",");
}
sb.Append(indent).AppendLine(" },");
}
}
else
{
foreach (var name in spec.FlatStates)
{
sb.Append(indent).Append(" \"").Append(Escape(name)).AppendLine("\",");
}
}
sb.Append(indent).AppendLine("},");
// auras
var hasAuras = spec.PlayerAuras.Count > 0
|| spec.TargetHarmfulAuras.Count > 0
|| spec.TargetHelpfulAuras.Count > 0
|| spec.FocusHarmfulAuras.Count > 0
|| spec.FocusHelpfulAuras.Count > 0;
if (hasAuras)
{
sb.Append(indent).AppendLine("auras = {");
WriteAuraUnit(sb, "player", null, spec.PlayerAuras, indent + " ");
WriteAuraSplitUnit(sb, "target", spec.TargetHarmfulAuras, spec.TargetHelpfulAuras, indent + " ");
WriteAuraSplitUnit(sb, "focus", spec.FocusHarmfulAuras, spec.FocusHelpfulAuras, indent + " ");
sb.Append(indent).AppendLine("},");
}
// spells
if (spec.Spells.Count > 0)
{
sb.Append(indent).AppendLine("spells = {");
foreach (var spell in spec.Spells)
{
sb.Append(indent).Append(" { spellId = ").Append(spell.SpellId);
if (!string.IsNullOrWhiteSpace(spell.Name))
{
sb.Append(", name = \"").Append(Escape(spell.Name)).Append('"');
}
if (spell.Charge)
{
sb.Append(", charge = true");
}
if (spell.MaxCharge is { } maxCharge)
{
sb.Append(", maxCharge = ").Append(maxCharge);
}
if (spell.CastCount is { } castCount)
{
sb.Append(", castCount = ").Append(castCount);
}
if (spell.ForcedKnown)
{
sb.Append(", forcedKnown = true");
}
if (spell.InSpellBook)
{
sb.Append(", inSpellBook = true");
}
sb.AppendLine(" },");
}
sb.Append(indent).AppendLine("},");
}
// group
if (spec.Group is { } group)
{
sb.Append(indent).AppendLine("group = {");
sb.Append(indent).Append(" num = ").Append(group.Num).AppendLine(",");
if (group.HealthPercent is { } hp)
{
sb.Append(indent).Append(" healthPercent = ").Append(hp).AppendLine(",");
}
if (group.Role is { } role)
{
sb.Append(indent).Append(" role = ").Append(role).AppendLine(",");
}
if (group.Dispel is { } dispel)
{
sb.Append(indent).Append(" dispel = ").Append(dispel).AppendLine(",");
}
if (group.Auras.Count > 0)
{
sb.Append(indent).AppendLine(" aura = {");
foreach (var aura in group.Auras.OrderBy(a => a.Offset))
{
sb.Append(indent).Append(" [").Append(aura.Offset).Append("] = {");
if (!string.IsNullOrWhiteSpace(aura.Name))
{
sb.Append(" name = \"").Append(Escape(aura.Name)).Append("\",");
}
WriteSpellIdFields(sb, aura.SpellId, aura.SpellIds);
sb.AppendLine(" },");
}
sb.Append(indent).AppendLine(" },");
}
sb.Append(indent).AppendLine("},");
}
}
private static void WriteAuraUnit(StringBuilder sb, string unit, string? filter, List<AuraEntry> list, string indent)
{
if (list.Count == 0)
{
return;
}
if (filter is null)
{
sb.Append(indent).Append(unit).AppendLine(" = {");
foreach (var aura in list)
{
WriteAuraEntry(sb, aura, indent + " ");
}
sb.Append(indent).AppendLine("},");
}
}
private static void WriteAuraSplitUnit(
StringBuilder sb,
string unit,
List<AuraEntry> harmful,
List<AuraEntry> helpful,
string indent)
{
if (harmful.Count == 0 && helpful.Count == 0)
{
return;
}
sb.Append(indent).Append(unit).AppendLine(" = {");
if (harmful.Count > 0)
{
sb.Append(indent).AppendLine(" harmful = {");
foreach (var aura in harmful)
{
WriteAuraEntry(sb, aura, indent + " ");
}
sb.Append(indent).AppendLine(" },");
}
if (helpful.Count > 0)
{
sb.Append(indent).AppendLine(" helpful = {");
foreach (var aura in helpful)
{
WriteAuraEntry(sb, aura, indent + " ");
}
sb.Append(indent).AppendLine(" },");
}
sb.Append(indent).AppendLine("},");
}
private static void WriteAuraEntry(StringBuilder sb, AuraEntry aura, string indent)
{
sb.Append(indent).Append("{");
if (!string.IsNullOrWhiteSpace(aura.Name))
{
sb.Append(" name = \"").Append(Escape(aura.Name)).Append("\",");
}
WriteSpellIdFields(sb, aura.SpellId, aura.SpellIds);
if (aura.MaxApps is { } maxApps)
{
sb.Append(" maxApps = ").Append(maxApps).Append(',');
}
sb.AppendLine(" },");
}
private static void WriteSpellIdFields(StringBuilder sb, long? spellId, List<long> spellIds)
{
if (spellIds.Count > 0)
{
sb.Append(" spellIds = { ");
sb.Append(string.Join(", ", spellIds.Select(id => id.ToString(CultureInfo.InvariantCulture))));
sb.Append(" },");
}
else if (spellId is { } id)
{
sb.Append(" spellId = ").Append(id).Append(',');
}
}
private static string Escape(string value)
=> value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,274 @@
using System.Globalization;
using System.Text;
using static Shigure.LuaLiteParser;
namespace Shigure;
/// <summary>
/// 读写 Fuyutsui core/classmacros.lua 中的 ClassMacros
/// 保存时只替换 ClassMacros 表字面量,保留文件其余内容。
/// </summary>
internal static class ClassMacrosStore
{
public const string AssignmentName = "Fuyutsui.ClassMacros";
private static readonly string[] ClassFileOrder =
[
"WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", "DEATHKNIGHT",
"SHAMAN", "MAGE", "WARLOCK", "MONK", "DRUID", "DEMONHUNTER", "EVOKER"
];
public sealed class MacrosDocument
{
public string FilePath { get; set; } = string.Empty;
public string SourceText { get; set; } = string.Empty;
public int TableStart { get; set; }
public int TableEndExclusive { get; set; }
public Dictionary<string, ClassMacros> Classes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<string> ClassOrder { get; set; } = new();
}
public sealed class ClassMacros
{
public List<string> DynamicSpells { get; } = new();
public List<SparseEntry> StaticSpells { get; } = new();
public List<SparseEntry> SpecialSpells { get; } = new();
}
public sealed class SparseEntry
{
public int Index { get; set; }
public string Text { get; set; } = string.Empty;
public string? Comment { get; set; }
}
public static string ToClassFileKey(int classId)
=> ClassNames.GetConfigFileName(classId).ToUpperInvariant();
public static MacrosDocument Load(string filePath)
{
var source = File.ReadAllText(filePath, Encoding.UTF8);
if (!TryExtractAssignedTable(source, AssignmentName, out var table, out var start, out var end))
{
throw new InvalidDataException($"{Path.GetFileName(filePath)} 中未找到 {AssignmentName}");
}
var doc = new MacrosDocument
{
FilePath = filePath,
SourceText = source,
TableStart = start,
TableEndExclusive = end
};
foreach (var (key, value) in table.Entries)
{
if (key is not string classFile || value is not TableValue classTable)
{
continue;
}
var macros = ParseClass(classTable);
doc.Classes[classFile] = macros;
doc.ClassOrder.Add(classFile);
}
return doc;
}
public static void Save(MacrosDocument document)
{
var serialized = SerializeClassMacros(document);
var updated = document.SourceText[..document.TableStart]
+ serialized
+ document.SourceText[document.TableEndExclusive..];
File.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var start, out var end))
{
throw new InvalidOperationException("保存后无法重新定位 ClassMacros 表。");
}
document.SourceText = updated;
document.TableStart = start;
document.TableEndExclusive = end;
}
private static ClassMacros ParseClass(TableValue classTable)
{
var macros = new ClassMacros();
if (classTable.GetTable("dynamicSpells") is { } dynamic)
{
foreach (var item in dynamic.IPairs())
{
if (item is StringValue s && !string.IsNullOrWhiteSpace(s.Value))
{
macros.DynamicSpells.Add(s.Value);
}
}
}
ReadSparse(classTable.GetTable("staticSpells"), macros.StaticSpells);
ReadSparse(classTable.GetTable("specialSpells"), macros.SpecialSpells);
return macros;
}
private static void ReadSparse(TableValue? table, List<SparseEntry> target)
{
if (table is null)
{
return;
}
foreach (var (key, value) in table.Entries)
{
if (key is not long index || value is not StringValue text)
{
continue;
}
target.Add(new SparseEntry
{
Index = (int)index,
Text = text.Value,
Comment = table.GetTrailingComment(key)
});
}
target.Sort((a, b) => a.Index.CompareTo(b.Index));
}
public static string SerializeClassMacros(MacrosDocument document)
{
var sb = new StringBuilder();
sb.AppendLine("{");
var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var classFile in EnumerateClassOrder(document))
{
if (!document.Classes.TryGetValue(classFile, out var macros))
{
continue;
}
written.Add(classFile);
WriteClass(sb, classFile, macros);
}
foreach (var (classFile, macros) in document.Classes)
{
if (written.Contains(classFile))
{
continue;
}
WriteClass(sb, classFile, macros);
}
sb.Append('}');
return sb.ToString();
}
private static IEnumerable<string> EnumerateClassOrder(MacrosDocument document)
{
if (document.ClassOrder.Count > 0)
{
foreach (var key in document.ClassOrder)
{
yield return key;
}
yield break;
}
foreach (var key in ClassFileOrder)
{
yield return key;
}
}
private static void WriteClass(StringBuilder sb, string classFile, ClassMacros macros)
{
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(" },");
}
// specialSpells
WriteSparseTable(sb, "specialSpells", macros.SpecialSpells);
// staticSpells
WriteSparseTable(sb, "staticSpells", macros.StaticSpells);
sb.AppendLine(" },");
sb.AppendLine();
}
private static void WriteSparseTable(StringBuilder sb, string name, List<SparseEntry> entries)
{
if (entries.Count == 0)
{
sb.Append(" ").Append(name).AppendLine(" = {},");
return;
}
sb.Append(" ").Append(name).AppendLine(" = {");
foreach (var entry in entries.OrderBy(e => e.Index))
{
sb.Append(" [").Append(entry.Index.ToString(CultureInfo.InvariantCulture))
.Append("] = \"").Append(Escape(entry.Text)).Append('"');
if (!string.IsNullOrWhiteSpace(entry.Comment))
{
sb.Append(", -- ").Append(entry.Comment.Trim());
}
else
{
sb.Append(',');
}
sb.AppendLine();
}
sb.AppendLine(" },");
}
private static string Escape(string value)
{
var sb = new StringBuilder(value.Length + 8);
foreach (var ch in value)
{
switch (ch)
{
case '\\':
sb.Append("\\\\");
break;
case '"':
sb.Append("\\\"");
break;
case '\n':
sb.Append("\\n");
break;
case '\r':
sb.Append("\\r");
break;
case '\t':
sb.Append("\\t");
break;
default:
sb.Append(ch);
break;
}
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,118 @@
namespace Shigure;
/// <summary>
/// ClassBlocks states 可选字段目录。界面使用单级分类;
/// 「状态 / 配置开关 / 物品 / 能量」在 Lua 中统一写入 ["状态"]。
/// </summary>
internal static class ClassStateCatalog
{
public const string CategoryState = "状态";
public const string CategoryConfig = "配置开关";
public const string CategoryItem = "物品";
public const string CategoryResource = "能量";
public const string CategoryTarget = "目标";
public const string CategoryFocus = "焦点";
public static readonly string[] TopCategories =
[
CategoryState,
CategoryConfig,
CategoryItem,
CategoryResource,
CategoryTarget,
CategoryFocus
];
private static readonly (string Category, string[] Names)[] Categories =
[
(CategoryState,
[
"职业", "专精", "有效性", "战斗时间", "移动",
"生命值", "一键辅助", "法术失败", "队伍类型", "队伍人数",
"首领战", "难度", "英雄天赋", "施法目标", "施法技能",
"敌人人数", "施法", "引导", "蓄力", "蓄力层数",
"酒池", "符文", "姿态"
]),
(CategoryConfig,
[
"爆发开关", "AOE开关", "输出模式", "爆发药水开关", "延迟"
]),
(CategoryItem,
[
"大红冷却", "大蓝冷却", "治疗石冷却", "鲁莽药水冷却", "圣光潜力冷却"
]),
(CategoryResource,
[
"法力值", "怒气值", "集中值", "能量值", "符文", "符文能量",
"星界能量", "漩涡值", "狂乱值", "奥术充能", "恶魔之怒", "痛苦值",
"连击点", "神圣能量", "精华能量", "灵魂碎片", "真气"
]),
(CategoryTarget,
[
"类型", "生命值", "距离", "施法", "施法可打断", "引导", "引导可打断"
]),
(CategoryFocus,
[
"类型", "生命值", "距离", "施法", "施法可打断", "引导", "引导可打断"
])
];
public static IReadOnlyList<StateOption> GetOptions(string category)
{
var definition = Categories.FirstOrDefault(item =>
string.Equals(item.Category, category, StringComparison.Ordinal));
return definition.Names is null
? []
: definition.Names.Select(name => new StateOption(category, name)).ToList();
}
public static IReadOnlyList<StateOption> GetAllOptions(string category)
=> GetOptions(category);
public static bool IsKnown(string category, string name)
=> !string.IsNullOrWhiteSpace(name)
&& GetNames(category).Contains(name, StringComparer.Ordinal);
public static string? FindCategory(string name)
{
foreach (var (category, names) in Categories)
{
if (names.Contains(name, StringComparer.Ordinal))
{
return category;
}
}
return null;
}
public static bool IsInCategory(string name, string category)
{
if (IsKnown(category, name))
{
return true;
}
// Lua 中已有但目录尚未收录的状态仍放在“状态”中,保证可见、可编辑。
return string.Equals(category, CategoryState, StringComparison.Ordinal)
&& FindCategory(name) is null;
}
public static string GetStorageCategory(string category)
=> category is CategoryTarget or CategoryFocus
? category
: CategoryState;
private static string[] GetNames(string category)
=> Categories.FirstOrDefault(item =>
string.Equals(item.Category, category, StringComparison.Ordinal)).Names ?? [];
public sealed record StateOption(string Category, string Name)
{
public string Display => string.Equals(Category, "未识别", StringComparison.Ordinal)
? $"未识别 · {Name}"
: Name;
public override string ToString() => Display;
}
}

View File

@@ -0,0 +1,390 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using static Shigure.LuaLiteParser;
namespace Shigure;
/// <summary>
/// 将 Fuyutsui core/classmacros.lua 的 ClassMacros 展开为 keymap/*.json
/// (对齐 core/macro.lua CreateMacro 的槽位与热键池)。
/// </summary>
internal static partial class FuyutsuiKeymapConverter
{
private static readonly JsonSerializerOptions WriteOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly string[] Modifiers =
[
"CTRL", "ALT", "SHIFT",
"ALT-CTRL", "ALT-SHIFT", "CTRL-SHIFT",
"ALT-CTRL-SHIFT"
];
private static readonly string[] Keys =
[
"NUMPAD1", "NUMPAD2", "NUMPAD3", "NUMPAD4", "NUMPAD5",
"NUMPAD6", "NUMPAD7", "NUMPAD8", "NUMPAD9", "NUMPAD0",
"NUMPADDECIMAL", "NUMPADPLUS", "NUMPADMINUS", "NUMPADMULTIPLY", "NUMPADDIVIDE",
"F1", "F2", "F3", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
",", ".", "/", ";", "'", "[", "]", "\\",
"7", "8", "9", "0", "="
];
private static readonly string[] MacroKind = BuildMacroKind();
private static readonly Dictionary<string, int> ClassFileToId = new(StringComparer.OrdinalIgnoreCase)
{
["WARRIOR"] = 1,
["PALADIN"] = 2,
["HUNTER"] = 3,
["ROGUE"] = 4,
["PRIEST"] = 5,
["DEATHKNIGHT"] = 6,
["SHAMAN"] = 7,
["MAGE"] = 8,
["WARLOCK"] = 9,
["MONK"] = 10,
["DRUID"] = 11,
["DEMONHUNTER"] = 12,
["EVOKER"] = 13
};
public sealed record UpdateResult(
string ClassMacrosPath,
IReadOnlyList<string> UpdatedFiles,
IReadOnlyList<string> Warnings);
public static UpdateResult UpdateFromClassMacros(string classMacrosPath, string keymapDirectory)
{
if (!File.Exists(classMacrosPath))
{
throw new FileNotFoundException($"找不到 classmacros.lua: {classMacrosPath}", classMacrosPath);
}
Directory.CreateDirectory(keymapDirectory);
var lua = File.ReadAllText(classMacrosPath, Encoding.UTF8);
var classMacros = ExtractAssignedTable(lua, "Fuyutsui.ClassMacros")
?? throw new InvalidDataException("classmacros.lua 中未找到 Fuyutsui.ClassMacros");
var updated = new List<string>();
var warnings = new List<string>();
foreach (var (classFile, classId) in ClassFileToId)
{
if (classMacros.GetTable(classFile) is not { } classTable)
{
warnings.Add($"跳过 {classFile}: ClassMacros 中无此职业表");
continue;
}
var fileName = ClassNames.GetConfigFileName(classId).ToLowerInvariant() + ".json";
var jsonPath = Path.Combine(keymapDirectory, fileName);
var existing = LoadExistingSpellNames(jsonPath);
var (root, classWarnings) = CompileClassKeymap(classTable, existing, classFile);
warnings.AddRange(classWarnings);
File.WriteAllText(jsonPath, root.ToJsonString(WriteOptions) + Environment.NewLine, Encoding.UTF8);
updated.Add(jsonPath);
}
if (updated.Count == 0)
{
throw new InvalidOperationException("未成功转换任何职业 keymap。");
}
return new UpdateResult(classMacrosPath, updated, warnings);
}
private static (JsonObject Root, List<string> Warnings) CompileClassKeymap(
TableValue classTable,
IReadOnlyDictionary<int, string> existingSpellNames,
string classFile)
{
var warnings = new List<string>();
var dynamicSpells = ReadArrayStrings(classTable.GetTable("dynamicSpells"));
var staticSpells = ReadSparseStrings(classTable.GetTable("staticSpells"));
var specialSpells = ReadSparseStrings(classTable.GetTable("specialSpells"));
var dynamicSlots = dynamicSpells.Count * 30;
var root = new JsonObject();
for (var i = 1; i <= MacroKind.Length; i++)
{
var hotkey = MacroKind[i - 1];
var unit = 0;
var spell = string.Empty;
if (i <= dynamicSlots)
{
var groupIndex = (i - 1) / 30;
var raidIdx = ((i - 1) % 30) + 1;
if (groupIndex < dynamicSpells.Count)
{
spell = dynamicSpells[groupIndex];
unit = raidIdx;
}
}
else
{
var index = i - dynamicSlots;
if (specialSpells.TryGetValue(index, out var specialEntry))
{
spell = ResolveSpellName(specialEntry);
}
else if (staticSpells.TryGetValue(index, out var staticEntry))
{
spell = ResolveSpellName(staticEntry);
}
if (IsWeakSpellName(spell)
&& existingSpellNames.TryGetValue(i, out var preserved)
&& !string.IsNullOrWhiteSpace(preserved)
&& !IsWeakSpellName(preserved))
{
warnings.Add($"{classFile}[{i}]: 保留原技能名「{preserved}」(宏推导为「{spell}」)");
spell = preserved;
}
}
root[i.ToString()] = new JsonObject
{
["unit"] = unit,
["技能"] = spell,
["热键"] = hotkey
};
}
return (root, warnings);
}
private static bool IsWeakSpellName(string? spell)
{
if (string.IsNullOrWhiteSpace(spell))
{
return true;
}
return spell.StartsWith("item:", StringComparison.OrdinalIgnoreCase);
}
/// <summary>同行 `--` 注释优先作为技能名;否则从宏文本推导。</summary>
private static string ResolveSpellName(MacroEntry entry)
{
if (!string.IsNullOrWhiteSpace(entry.Comment))
{
return entry.Comment.Trim();
}
return DeriveSpellName(entry.Body);
}
private readonly record struct MacroEntry(string Body, string? Comment);
internal static string DeriveSpellName(string raw)
{
var text = raw.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
if (text.Length == 0)
{
return string.Empty;
}
if (StopCastingRegex().IsMatch(text))
{
return "停止施法";
}
var castSequence = CastSequenceRegex().Match(text);
if (castSequence.Success)
{
var sequenceBody = castSequence.Groups[1].Value.Trim();
sequenceBody = ResetOptionRegex().Replace(sequenceBody, string.Empty).Trim();
foreach (var part in sequenceBody.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (part.Equals("x", StringComparison.OrdinalIgnoreCase))
{
continue;
}
return StripConditions(part);
}
}
// cancelaura 后再 /cast取最后一个 /cast 段;纯物品宏保留首行 item:
var lines = text.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (lines.Length > 0 && lines[0].StartsWith("item:", StringComparison.OrdinalIgnoreCase))
{
return lines[0].Trim();
}
for (var i = lines.Length - 1; i >= 0; i--)
{
var line = lines[i];
if (line.StartsWith("/cast", StringComparison.OrdinalIgnoreCase)
&& !line.StartsWith("/castsequence", StringComparison.OrdinalIgnoreCase))
{
text = line["/cast".Length..].TrimStart();
break;
}
if (i == 0 && !line.StartsWith('/'))
{
text = line;
}
}
if (text.StartsWith("/cast", StringComparison.OrdinalIgnoreCase)
&& !text.StartsWith("/castsequence", StringComparison.OrdinalIgnoreCase))
{
text = text["/cast".Length..].TrimStart();
}
// 取 ; 分支中第一段(专精/条件分支)
var firstBranch = text.Split(';', 2, StringSplitOptions.TrimEntries)[0];
var hasFocus = FocusTargetRegex().IsMatch(firstBranch) || FocusTargetRegex().IsMatch(text);
var spell = StripConditions(firstBranch);
if (string.IsNullOrWhiteSpace(spell))
{
return string.Empty;
}
if (hasFocus && !spell.EndsWith("(焦点)", StringComparison.Ordinal))
{
return spell + "(焦点)";
}
return spell;
}
private static string StripConditions(string text)
{
var stripped = ConditionRegex().Replace(text, string.Empty).Trim();
return stripped;
}
private static List<string> ReadArrayStrings(TableValue? table)
{
var result = new List<string>();
if (table is null)
{
return result;
}
foreach (var item in table.IPairs())
{
if (item is StringValue s && !string.IsNullOrWhiteSpace(s.Value))
{
result.Add(s.Value.Trim());
}
else
{
break;
}
}
return result;
}
private static Dictionary<int, MacroEntry> ReadSparseStrings(TableValue? table)
{
var result = new Dictionary<int, MacroEntry>();
if (table is null)
{
return result;
}
foreach (var (key, value) in table.Entries)
{
var index = key switch
{
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;
}
var comment = key is null ? null : table.GetTrailingComment(key);
result[index.Value] = new MacroEntry(s.Value, comment);
}
return result;
}
private static Dictionary<int, string> LoadExistingSpellNames(string jsonPath)
{
var result = new Dictionary<int, string>();
if (!File.Exists(jsonPath))
{
return result;
}
try
{
if (JsonNode.Parse(File.ReadAllText(jsonPath)) is not JsonObject root)
{
return result;
}
foreach (var (key, node) in root)
{
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;
}
}
}
catch
{
// 旧 keymap 损坏时忽略,按宏全量重建。
}
return result;
}
private static string[] BuildMacroKind()
{
var list = new string[Modifiers.Length * Keys.Length];
var i = 0;
foreach (var modifier in Modifiers)
{
foreach (var key in Keys)
{
list[i++] = $"{modifier}-{key}";
}
}
return list;
}
[GeneratedRegex(@"^\s*/stopcasting\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex StopCastingRegex();
[GeneratedRegex(@"^\s*/castsequence\b\s*(.*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline)]
private static partial Regex CastSequenceRegex();
[GeneratedRegex(@"\breset\s*=\s*\S+\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ResetOptionRegex();
[GeneratedRegex(@"\[[^\]]*\]", RegexOptions.CultureInvariant)]
private static partial Regex ConditionRegex();
[GeneratedRegex(@"@focus\b|target\s*=\s*focus\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex FocusTargetRegex();
}

View File

@@ -32,11 +32,12 @@ internal static class LuaLiteParser
public sealed class TableValue : Value
{
private readonly Dictionary<object, Value> _map = new();
private readonly Dictionary<object, string> _trailingComments = new();
private readonly List<(object? Key, Value Value)> _entries = new();
public IReadOnlyList<(object? Key, Value Value)> Entries => _entries;
public void Set(object? key, Value value)
public void Set(object? key, Value value, string? trailingComment = null)
{
if (key is null)
{
@@ -46,12 +47,20 @@ internal static class LuaLiteParser
_map[key] = value;
_entries.Add((key, value));
if (!string.IsNullOrWhiteSpace(trailingComment))
{
_trailingComments[key] = trailingComment.Trim();
}
}
public bool TryGet(object key, out Value value) => _map.TryGetValue(key, out value!);
public Value? Get(object key) => _map.TryGetValue(key, out var value) ? value : null;
/// <summary>表项同行 `--` 行注释(不含 `--` 前缀),无则 null。</summary>
public string? GetTrailingComment(object key)
=> _trailingComments.TryGetValue(key, out var comment) ? comment : null;
public IEnumerable<Value> IPairs()
{
for (var i = 1; ; i++)
@@ -79,17 +88,32 @@ internal static class LuaLiteParser
}
public static TableValue? ExtractAssignedTable(string source, string assignmentName)
=> TryExtractAssignedTable(source, assignmentName, out var table, out _, out _) ? table : null;
/// <summary>
/// 提取赋值表字面量并返回表起止下标含首尾大括号endExclusive
/// </summary>
public static bool TryExtractAssignedTable(
string source,
string assignmentName,
out TableValue table,
out int tableStart,
out int tableEndExclusive)
{
table = null!;
tableStart = -1;
tableEndExclusive = -1;
var index = source.IndexOf(assignmentName, StringComparison.Ordinal);
if (index < 0)
{
return null;
return false;
}
var eq = source.IndexOf('=', index + assignmentName.Length);
if (eq < 0)
{
return null;
return false;
}
var cursor = eq + 1;
@@ -100,11 +124,19 @@ internal static class LuaLiteParser
if (cursor >= source.Length || source[cursor] != '{')
{
return null;
return false;
}
var parser = new Parser(source, cursor);
return parser.ParseValue() as TableValue;
if (parser.ParseValue() is not TableValue parsed)
{
return false;
}
table = parsed;
tableStart = cursor;
tableEndExclusive = parser.Position;
return true;
}
private sealed class Parser
@@ -112,6 +144,8 @@ internal static class LuaLiteParser
private readonly string _text;
private int _pos;
public int Position => _pos;
public Parser(string text, int pos)
{
_text = text;
@@ -211,23 +245,77 @@ internal static class LuaLiteParser
}
}
table.Set(key, value);
SkipTrivia();
if (_pos < _text.Length && _text[_pos] == ',')
{
_pos++;
continue;
}
if (_pos < _text.Length && _text[_pos] == ';')
{
_pos++;
}
// 支持 `[37] = "…", -- 银月城生命药水` / `[37] = "…" -- 名称`
var trailingComment = CaptureEntryTrailingComment();
table.Set(key, value, trailingComment);
}
return table;
}
/// <summary>
/// 读取表项值后的分隔符与同行 `--` 注释。
/// 常见写法:`value, -- 名称` 或 `value -- 名称`(逗号可在下一行)。
/// </summary>
private string? CaptureEntryTrailingComment()
{
SkipSpacesAndTabs();
string? comment = null;
if (TryReadLineComment(out var beforeSeparator))
{
comment = beforeSeparator;
}
// 注释后可能换行再写逗号;也跳过值与逗号之间的空白。
SkipTrivia();
if (_pos < _text.Length && _text[_pos] is ',' or ';')
{
_pos++;
SkipSpacesAndTabs();
if (comment is null && TryReadLineComment(out var afterSeparator))
{
comment = afterSeparator;
}
}
return string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
}
private void SkipSpacesAndTabs()
{
while (_pos < _text.Length && _text[_pos] is ' ' or '\t')
{
_pos++;
}
}
/// <summary>若当前位置是行注释 `--`(非 `--[[`),消费并返回注释正文。</summary>
private bool TryReadLineComment(out string comment)
{
comment = string.Empty;
if (_pos + 1 >= _text.Length || _text[_pos] != '-' || _text[_pos + 1] != '-')
{
return false;
}
// 块注释交给 SkipTrivia不当作名称来源。
if (_pos + 3 < _text.Length && _text[_pos + 2] == '[' && _text[_pos + 3] == '[')
{
return false;
}
_pos += 2;
var start = _pos;
while (_pos < _text.Length && _text[_pos] is not ('\r' or '\n'))
{
_pos++;
}
comment = _text[start.._pos];
return true;
}
private bool IsIdentifierAt(int start)
{
if (start < 0 || start >= _text.Length)

View File

@@ -3,13 +3,28 @@ using System.Text;
namespace Shigure;
/// <summary>
/// 从目标窗口进程定位 Fuyutsui 的 class 目录
/// 从目标窗口进程定位 Fuyutsui 插件目录与相关文件
/// </summary>
internal static class WowAddonLocator
{
private const string AddonRelativePath = @"Interface\AddOns\Fuyutsui\class";
private const string AddonRelativePath = @"Interface\AddOns\Fuyutsui";
private const string ClassRelativePath = @"Interface\AddOns\Fuyutsui\class";
private const string ClassMacrosRelativePath = @"Interface\AddOns\Fuyutsui\core\classmacros.lua";
public static string? FindClassDirectory(string windowTitle)
{
var addonRoot = FindAddonRoot(windowTitle);
if (addonRoot is null)
{
return null;
}
var classDirectory = Path.Combine(addonRoot, "class");
return Directory.Exists(classDirectory) ? classDirectory : null;
}
/// <summary>定位 Fuyutsui 插件根目录(含 class/、core/)。</summary>
public static string? FindAddonRoot(string windowTitle)
{
var exePath = TryGetProcessPathByWindowTitle(windowTitle);
if (string.IsNullOrWhiteSpace(exePath))
@@ -20,10 +35,50 @@ internal static class WowAddonLocator
var directory = Path.GetDirectoryName(exePath);
while (!string.IsNullOrWhiteSpace(directory))
{
var classDirectory = Path.Combine(directory, AddonRelativePath);
var addonRoot = Path.Combine(directory, AddonRelativePath);
if (Directory.Exists(addonRoot))
{
return addonRoot;
}
// 兼容旧探测:仅存在 class 子目录时回退到其父目录。
var classDirectory = Path.Combine(directory, ClassRelativePath);
if (Directory.Exists(classDirectory))
{
return classDirectory;
return Path.GetDirectoryName(classDirectory);
}
directory = Path.GetDirectoryName(directory);
}
return null;
}
public static string? FindClassMacrosPath(string windowTitle)
{
var addonRoot = FindAddonRoot(windowTitle);
if (addonRoot is not null)
{
var fromRoot = Path.Combine(addonRoot, "core", "classmacros.lua");
if (File.Exists(fromRoot))
{
return fromRoot;
}
}
var exePath = TryGetProcessPathByWindowTitle(windowTitle);
if (string.IsNullOrWhiteSpace(exePath))
{
return null;
}
var directory = Path.GetDirectoryName(exePath);
while (!string.IsNullOrWhiteSpace(directory))
{
var macrosPath = Path.Combine(directory, ClassMacrosRelativePath);
if (File.Exists(macrosPath))
{
return macrosPath;
}
directory = Path.GetDirectoryName(directory);

View File

@@ -81,6 +81,50 @@ public static class ClassNames
[(13, 3)] = "增辉"
};
private static readonly Dictionary<(int ClassId, int SpecId), string> SpecIconFileNames = new()
{
[(1, 1)] = "warrior-arms",
[(1, 2)] = "warrior-fury",
[(1, 3)] = "warrior-protection",
[(2, 1)] = "paladin-holy",
[(2, 2)] = "paladin-protection",
[(2, 3)] = "paladin-retribution",
[(3, 1)] = "hunter-beastmastery",
[(3, 2)] = "hunter-marksmanship",
[(3, 3)] = "hunter-survival",
[(4, 1)] = "rogue-assassination",
[(4, 2)] = "rogue-outlaw",
[(4, 3)] = "rogue-subtlety",
[(5, 1)] = "priest-discipline",
[(5, 2)] = "priest-holy",
[(5, 3)] = "priest-shadow",
[(6, 1)] = "deathknight-blood",
[(6, 2)] = "deathknight-frost",
[(6, 3)] = "deathknight-unholy",
[(7, 1)] = "shaman-elemental",
[(7, 2)] = "shaman-enhancement",
[(7, 3)] = "shaman-restoration",
[(8, 1)] = "mage-arcane",
[(8, 2)] = "mage-fire",
[(8, 3)] = "mage-frost",
[(9, 1)] = "warlock-affliction",
[(9, 2)] = "warlock-demonology",
[(9, 3)] = "warlock-destruction",
[(10, 1)] = "monk-brewmaster",
[(10, 2)] = "monk-mistweaver",
[(10, 3)] = "monk-windwalker",
[(11, 1)] = "druid-balance",
[(11, 2)] = "druid-feral",
[(11, 3)] = "druid-guardian",
[(11, 4)] = "druid-restoration",
[(12, 1)] = "demonhunter-havoc",
[(12, 2)] = "demonhunter-vengeance",
[(12, 3)] = "demonhunter-devourer",
[(13, 1)] = "evoker-devastation",
[(13, 2)] = "evoker-preservation",
[(13, 3)] = "evoker-augmentation"
};
private static readonly Dictionary<(int ClassId, int SpecId, int HeroTalentId), string> HeroTalents = new()
{
[(1, 1, 1)] = "巨神兵",
@@ -206,6 +250,9 @@ public static class ClassNames
.ToList();
}
public static string? GetSpecIconFileName(int classId, int specId)
=> SpecIconFileNames.GetValueOrDefault((classId, specId));
public static IReadOnlyList<(int Id, string Name)> GetHeroTalents(int classId, int specId)
{
return HeroTalents
@@ -215,4 +262,3 @@ public static class ClassNames
.ToList();
}
}

View File

@@ -19,6 +19,8 @@
<EmbeddedResource Include="Assets\*.ico" />
<EmbeddedResource Include="Assets\*.png" />
<EmbeddedResource Include="Assets\*.svg" />
<EmbeddedResource Include="Assets\Class\*.jpg" />
<EmbeddedResource Include="Assets\Spec\*.jpg" />
<None Include="config\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="keymap\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="module\**\*.json" CopyToOutputDirectory="PreserveNewest" />

View File

@@ -0,0 +1,39 @@
param(
[switch]$Force
)
$repoRoot = Split-Path -Parent $PSScriptRoot
$assetRoot = Join-Path $repoRoot "Assets"
$manifestPath = Join-Path $assetRoot "IconManifest.json"
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$downloaded = 0
$skipped = 0
foreach ($icon in $manifest.icons) {
$target = Join-Path $assetRoot $icon.target
if ((Test-Path -LiteralPath $target) -and -not $Force) {
$skipped++
continue
}
$directory = Split-Path -Parent $target
New-Item -ItemType Directory -Path $directory -Force | Out-Null
$uri = "$($manifest.source.imageBaseUrl)$($icon.slug).jpg"
$temporary = "$target.download"
try {
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing
if ((Get-Item -LiteralPath $temporary).Length -lt 512) {
throw "Downloaded file is unexpectedly small."
}
Move-Item -LiteralPath $temporary -Destination $target -Force
$downloaded++
}
finally {
if (Test-Path -LiteralPath $temporary) {
Remove-Item -LiteralPath $temporary -Force
}
}
}
Write-Output "Downloaded: $downloaded; skipped existing: $skipped; total: $($manifest.icons.Count)"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,853 @@
using System.Drawing;
using System.Globalization;
namespace Shigure;
/// <summary>
/// 图形化编辑 Fuyutsui core/classmacros.lua 的 ClassMacros
/// dynamicSpells / staticSpells / specialSpells
/// </summary>
public sealed class ClassMacrosEditorControl : UserControl
{
private readonly Func<string?> _resolveClassMacrosPath;
private readonly Func<Task> _updateConfigAsync;
private readonly ListBox _classList = new();
private readonly Label _pathLabel = new();
private readonly Label _statusLabel = new();
private readonly Label _offsetLabel = new();
private readonly Button _reloadButton;
private readonly Button _saveButton;
private readonly DataGridView _dynamicGrid = new();
private readonly DataGridView _staticGrid = new();
private readonly DataGridView _specialGrid = new();
private ClassMacrosStore.MacrosDocument? _document;
private ClassMacrosStore.ClassMacros? _currentMacros;
private string? _currentClassFile;
private bool _suppressUi;
private bool _dirty;
public ClassMacrosEditorControl(Func<string?> resolveClassMacrosPath, Func<Task> updateConfigAsync)
{
_resolveClassMacrosPath = resolveClassMacrosPath;
_updateConfigAsync = updateConfigAsync;
_reloadButton = UiTheme.CreateButton("刷新", UiTheme.Field, UiTheme.Text);
_saveButton = UiTheme.CreateButton("保存并更新配置", UiTheme.Accent, Color.Black);
InitializeComponent();
ReloadFromAddon();
}
private void InitializeComponent()
{
Dock = DockStyle.Fill;
BackColor = UiTheme.Surface;
ForeColor = UiTheme.Text;
Font = new Font("Microsoft YaHei UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 2,
RowCount = 1,
Margin = new Padding(0)
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 154));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
Controls.Add(root);
root.Controls.Add(BuildSidebar(), 0, 0);
root.Controls.Add(BuildEditor(), 1, 0);
}
private Control BuildSidebar()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
ColumnCount = 1,
RowCount = 3,
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
{
Text = "职业",
Dock = DockStyle.Fill,
ForeColor = UiTheme.Text,
Font = new Font(Font.FontFamily, 10F, FontStyle.Bold),
TextAlign = ContentAlignment.MiddleLeft,
Margin = new Padding(0)
}, 0, 0);
_classList.Dock = DockStyle.Fill;
UiTheme.StyleClassIconListBox(
_classList,
item => (item as ClassListItem)?.ClassId);
_classList.SelectedIndexChanged += (_, _) =>
{
if (!_suppressUi)
{
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;
}
private static void StyleActionButton(Button button)
{
button.AutoSize = false;
button.Size = new Size(110, 36);
button.Margin = new Padding(0, 0, 0, 8);
button.TextAlign = ContentAlignment.MiddleCenter;
}
private Control BuildEditor()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 1,
RowCount = 3,
Margin = new Padding(0)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 36));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var header = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
ColumnCount = 2,
RowCount = 2,
Padding = new Padding(14, 12, 14, 12),
Margin = new Padding(0, 0, 0, 8)
};
header.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 56));
header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
header.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
header.Controls.Add(CreateFieldCaption("路径"), 0, 0);
ConfigureInfoLabel(_pathLabel, UiTheme.Text);
_pathLabel.Text = "未加载";
header.Controls.Add(_pathLabel, 1, 0);
header.Controls.Add(CreateFieldCaption("状态"), 0, 1);
ConfigureInfoLabel(_statusLabel, UiTheme.Muted);
_statusLabel.Text = "点击刷新以从游戏窗口定位 core\\classmacros.lua";
header.Controls.Add(_statusLabel, 1, 1);
root.Controls.Add(header, 0, 0);
_offsetLabel.Dock = DockStyle.Fill;
_offsetLabel.AutoSize = false;
_offsetLabel.ForeColor = UiTheme.Muted;
_offsetLabel.TextAlign = ContentAlignment.MiddleLeft;
_offsetLabel.Padding = new Padding(14, 0, 14, 0);
_offsetLabel.Margin = new Padding(0);
_offsetLabel.AutoEllipsis = true;
_offsetLabel.Text = "动态宏每项占 30 槽static/special 的 [1] 对应全局热键偏移后的相对序号";
root.Controls.Add(_offsetLabel, 0, 1);
root.Controls.Add(BuildSectionTabs(), 0, 2);
return root;
}
private Control BuildSectionTabs()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Surface,
ColumnCount = 1,
RowCount = 2,
Margin = new Padding(0)
};
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
var tabBar = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.Border,
ColumnCount = 3,
RowCount = 1,
Margin = new Padding(0)
};
for (var i = 0; i < 3; i++)
{
tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33f));
}
var contentHost = new Panel
{
Dock = DockStyle.Fill,
BackColor = UiTheme.SurfaceRaised,
Margin = new Padding(0),
Padding = new Padding(12)
};
var pages = new Control[]
{
BuildDynamicPage(),
BuildSparsePage(_staticGrid, "相对序号", "内容(拼在 /cast 后)", "注释"),
BuildSparsePage(_specialGrid, "相对序号", "完整宏文本", "注释")
};
foreach (var page in pages)
{
page.Dock = DockStyle.Fill;
page.Visible = false;
contentHost.Controls.Add(page);
}
var labels = new Label[3];
var selectedIndex = -1;
void SelectTab(int index)
{
if (selectedIndex == index)
{
return;
}
selectedIndex = index;
for (var i = 0; i < labels.Length; i++)
{
var selected = i == index;
labels[i].BackColor = selected ? UiTheme.Field : UiTheme.Surface;
labels[i].ForeColor = selected ? UiTheme.Text : UiTheme.Muted;
labels[i].Invalidate();
pages[i].Visible = selected;
if (selected)
{
pages[i].BringToFront();
}
}
}
var titles = new[] { "动态宏", "静态宏", "特殊宏" };
for (var i = 0; i < titles.Length; i++)
{
var index = i;
var label = new Label
{
Text = titles[i],
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleCenter,
AutoSize = false,
BackColor = UiTheme.Surface,
ForeColor = UiTheme.Muted,
Cursor = Cursors.Hand,
Margin = new Padding(i == 0 ? 0 : 1, 0, 0, 0)
};
label.Click += (_, _) => SelectTab(index);
label.MouseEnter += (_, _) =>
{
if (selectedIndex != index)
{
label.BackColor = UiTheme.Hover;
}
};
label.MouseLeave += (_, _) =>
{
if (selectedIndex != index)
{
label.BackColor = UiTheme.Surface;
}
};
label.Paint += (_, e) =>
{
if (selectedIndex != index)
{
return;
}
using var accent = new SolidBrush(UiTheme.Accent);
e.Graphics.FillRectangle(accent, 8, label.Height - 3, Math.Max(0, label.Width - 16), 2);
};
label.SizeChanged += (_, _) => label.Invalidate();
labels[i] = label;
tabBar.Controls.Add(label, i, 0);
}
root.Controls.Add(tabBar, 0, 0);
root.Controls.Add(contentHost, 0, 1);
SelectTab(0);
return root;
}
private Control BuildDynamicPage()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
ConfigureGrid(_dynamicGrid);
_dynamicGrid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Name",
HeaderText = "法术名(每项占 30 个团队点名槽)",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
_dynamicGrid.Columns.Add(CreateDeleteColumn());
WireGrid(_dynamicGrid);
panel.Controls.Add(_dynamicGrid, 0, 0);
panel.Controls.Add(BuildMoveButtons(_dynamicGrid), 0, 1);
return panel;
}
private Control BuildSparsePage(DataGridView grid, string indexHeader, string textHeader, string commentHeader)
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
BackColor = UiTheme.SurfaceRaised
};
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 44));
ConfigureGrid(grid);
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Index", HeaderText = indexHeader, Width = 90 });
grid.Columns.Add(new DataGridViewTextBoxColumn
{
Name = "Text",
HeaderText = textHeader,
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill
});
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Comment", HeaderText = commentHeader, Width = 180 });
grid.Columns.Add(CreateDeleteColumn());
WireGrid(grid);
panel.Controls.Add(grid, 0, 0);
panel.Controls.Add(BuildMoveButtons(grid), 0, 1);
return panel;
}
private void WireGrid(DataGridView grid)
{
grid.CellContentClick += HandleDeleteClick;
grid.CellValueChanged += (_, _) =>
{
MarkDirty();
if (ReferenceEquals(grid, _dynamicGrid))
{
UpdateOffsetHint();
}
};
grid.UserAddedRow += (_, _) => MarkDirty();
grid.RowsRemoved += (_, _) =>
{
if (!_suppressUi)
{
MarkDirty();
if (ReferenceEquals(grid, _dynamicGrid))
{
UpdateOffsetHint();
}
}
};
}
private Control BuildMoveButtons(DataGridView grid)
{
var bar = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
BackColor = UiTheme.SurfaceRaised
};
var up = UiTheme.CreateButton("▲", UiTheme.Field, UiTheme.Text);
var down = UiTheme.CreateButton("▼", UiTheme.Field, UiTheme.Text);
up.AutoSize = false;
down.AutoSize = false;
up.Size = new Size(48, 32);
down.Size = new Size(48, 32);
up.Click += (_, _) => MoveSelectedRow(grid, -1);
down.Click += (_, _) => MoveSelectedRow(grid, 1);
bar.Controls.Add(up);
bar.Controls.Add(down);
return bar;
}
private static void ConfigureGrid(DataGridView grid)
{
UiTheme.StyleDataGridView(grid);
grid.AllowUserToAddRows = true;
grid.AllowUserToDeleteRows = false;
grid.RowHeadersVisible = false;
grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
grid.MultiSelect = false;
grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None;
}
private static DataGridViewButtonColumn CreateDeleteColumn()
=> new()
{
Name = "Delete",
HeaderText = "",
Text = "×",
UseColumnTextForButtonValue = true,
Width = 44
};
private Label CreateMutedLabel(string text)
=> new()
{
Text = text,
AutoSize = true,
ForeColor = UiTheme.Muted,
Margin = new Padding(0, 8, 8, 0),
TextAlign = ContentAlignment.MiddleLeft
};
private static Label CreateFieldCaption(string text)
=> new()
{
Text = text,
Dock = DockStyle.Fill,
AutoSize = false,
ForeColor = UiTheme.Muted,
TextAlign = ContentAlignment.MiddleLeft,
Margin = new Padding(0),
AutoEllipsis = true
};
private static void ConfigureInfoLabel(Label label, Color foreColor)
{
label.Dock = DockStyle.Fill;
label.AutoSize = false;
label.ForeColor = foreColor;
label.TextAlign = ContentAlignment.MiddleLeft;
label.AutoEllipsis = true;
label.Margin = new Padding(0);
}
public void ReloadFromAddon()
{
if (_dirty && !ConfirmDiscard())
{
return;
}
_document = null;
_currentMacros = null;
_currentClassFile = null;
_dirty = false;
_suppressUi = true;
try
{
_classList.Items.Clear();
ClearGrids();
var path = _resolveClassMacrosPath();
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
_pathLabel.Text = "未找到 core\\classmacros.lua";
_statusLabel.Text = "请先打开游戏窗口,或确认已安装 Fuyutsui 插件后点击刷新。";
UpdateOffsetHint();
return;
}
try
{
_document = ClassMacrosStore.Load(path);
}
catch (Exception ex)
{
_pathLabel.Text = path;
_statusLabel.Text = $"加载失败: {ex.Message}";
return;
}
_pathLabel.Text = path;
foreach (var (classId, className) in ClassNames.GetClasses())
{
var classFile = ClassMacrosStore.ToClassFileKey(classId);
var has = _document.Classes.ContainsKey(classFile);
_classList.Items.Add(new ClassListItem(classId, className, classFile, has));
}
// 文件中有但 ClassNames 未覆盖的键
foreach (var classFile in _document.ClassOrder)
{
if (_classList.Items.Cast<ClassListItem>().Any(x =>
x.ClassFile.Equals(classFile, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
_classList.Items.Add(new ClassListItem(0, classFile, classFile, true));
}
_statusLabel.Text = $"已加载 {_document.Classes.Count} 个职业宏表";
if (_classList.Items.Count > 0)
{
_classList.SelectedIndex = 0;
}
}
finally
{
_suppressUi = false;
}
SelectClassFromList();
}
private void SelectClassFromList()
{
if (_classList.SelectedItem is not ClassListItem item)
{
return;
}
if (_dirty && _currentClassFile is not null
&& !_currentClassFile.Equals(item.ClassFile, StringComparison.OrdinalIgnoreCase)
&& !ConfirmDiscard())
{
_suppressUi = true;
try
{
SelectClassInList(_currentClassFile);
}
finally
{
_suppressUi = false;
}
return;
}
var switching = _currentClassFile is not null
&& !_currentClassFile.Equals(item.ClassFile, StringComparison.OrdinalIgnoreCase);
if (_dirty && switching)
{
// 丢弃:从源文本重新解析当前职业
if (_document is not null)
{
try
{
var reloaded = ClassMacrosStore.Load(_document.FilePath);
_document = reloaded;
}
catch
{
// ignore
}
}
_dirty = false;
}
else if (switching)
{
CommitCurrentFromUi();
}
_currentClassFile = item.ClassFile;
if (_document is null)
{
_currentMacros = null;
ClearGrids();
UpdateOffsetHint();
return;
}
if (!_document.Classes.TryGetValue(item.ClassFile, out var macros))
{
macros = new ClassMacrosStore.ClassMacros();
_document.Classes[item.ClassFile] = macros;
if (!_document.ClassOrder.Contains(item.ClassFile, StringComparer.OrdinalIgnoreCase))
{
_document.ClassOrder.Add(item.ClassFile);
}
}
_currentMacros = macros;
_statusLabel.Text = _dirty ? "已修改(未保存)" : (item.HasData ? "可编辑" : "新建空表(保存后写入)");
FillAllEditors();
}
private void FillAllEditors()
{
_suppressUi = true;
try
{
_dynamicGrid.Rows.Clear();
_staticGrid.Rows.Clear();
_specialGrid.Rows.Clear();
if (_currentMacros is null)
{
return;
}
foreach (var name in _currentMacros.DynamicSpells)
{
_dynamicGrid.Rows.Add(name, "×");
}
foreach (var entry in _currentMacros.StaticSpells)
{
_staticGrid.Rows.Add(
entry.Index.ToString(CultureInfo.InvariantCulture),
entry.Text,
entry.Comment ?? "",
"×");
}
foreach (var entry in _currentMacros.SpecialSpells)
{
_specialGrid.Rows.Add(
entry.Index.ToString(CultureInfo.InvariantCulture),
entry.Text,
entry.Comment ?? "",
"×");
}
}
finally
{
_suppressUi = false;
}
UpdateOffsetHint();
}
private void UpdateOffsetHint()
{
var count = 0;
if (_currentMacros is not null)
{
// 优先用网格当前行数(含未提交编辑)
count = _dynamicGrid.Rows.Cast<DataGridViewRow>().Count(r => !r.IsNewRow
&& !string.IsNullOrWhiteSpace(r.Cells["Name"].Value?.ToString()));
if (count == 0 && !_dirty)
{
count = _currentMacros.DynamicSpells.Count;
}
}
var slots = count * 30;
var firstStatic = slots + 1;
_offsetLabel.Text = count == 0
? "无动态宏static/special 的 [1] = 全局热键 1"
: $"动态宏 {count} 项,占 {slots} 槽static/special 的 [1] = 全局热键 {firstStatic}";
}
private void CommitCurrentFromUi()
{
if (_currentMacros is null)
{
return;
}
_currentMacros.DynamicSpells.Clear();
foreach (DataGridViewRow row in _dynamicGrid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var name = row.Cells["Name"].Value?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
_currentMacros.DynamicSpells.Add(name);
}
}
WriteSparseGrid(_staticGrid, _currentMacros.StaticSpells);
WriteSparseGrid(_specialGrid, _currentMacros.SpecialSpells);
}
private static void WriteSparseGrid(DataGridView grid, List<ClassMacrosStore.SparseEntry> target)
{
target.Clear();
foreach (DataGridViewRow row in grid.Rows)
{
if (row.IsNewRow)
{
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))
{
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()
{
if (_document is null)
{
MessageBox.Show("请先刷新并加载 classmacros.lua。", "宏", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
try
{
CommitCurrentFromUi();
ClassMacrosStore.Save(_document);
_dirty = false;
_statusLabel.Text = "已写入 Lua正在更新配置…";
await _updateConfigAsync();
_statusLabel.Text = "已保存并更新配置";
UpdateOffsetHint();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
_statusLabel.Text = $"保存失败: {ex.Message}";
}
}
private void MarkDirty()
{
if (_suppressUi)
{
return;
}
_dirty = true;
if (_statusLabel.Text != "已修改(未保存)")
{
_statusLabel.Text = "已修改(未保存)";
}
}
private bool ConfirmDiscard()
=> MessageBox.Show("当前修改尚未保存,确定丢弃吗?", "宏", MessageBoxButtons.YesNo, MessageBoxIcon.Question)
== DialogResult.Yes;
private void SelectClassInList(string? classFile)
{
for (var i = 0; i < _classList.Items.Count; i++)
{
if (_classList.Items[i] is ClassListItem item
&& item.ClassFile.Equals(classFile, StringComparison.OrdinalIgnoreCase))
{
_classList.SelectedIndex = i;
return;
}
}
}
private void ClearGrids()
{
_dynamicGrid.Rows.Clear();
_staticGrid.Rows.Clear();
_specialGrid.Rows.Clear();
}
private void HandleDeleteClick(object? sender, DataGridViewCellEventArgs e)
{
if (sender is not DataGridView grid || e.RowIndex < 0 || e.ColumnIndex < 0)
{
return;
}
if (grid.Columns[e.ColumnIndex].Name != "Delete")
{
return;
}
if (grid.Rows[e.RowIndex].IsNewRow)
{
return;
}
grid.Rows.RemoveAt(e.RowIndex);
MarkDirty();
if (ReferenceEquals(grid, _dynamicGrid))
{
UpdateOffsetHint();
}
}
private void MoveSelectedRow(DataGridView grid, int delta)
{
if (grid.CurrentRow is null || grid.CurrentRow.IsNewRow)
{
return;
}
var index = grid.CurrentRow.Index;
var target = index + delta;
if (target < 0 || target >= grid.Rows.Count || grid.Rows[target].IsNewRow)
{
return;
}
var values = new object[grid.Columns.Count];
for (var i = 0; i < grid.Columns.Count; i++)
{
values[i] = grid.Rows[index].Cells[i].Value ?? DBNull.Value;
}
grid.Rows.RemoveAt(index);
grid.Rows.Insert(target, values);
grid.ClearSelection();
grid.Rows[target].Selected = true;
grid.CurrentCell = grid.Rows[target].Cells[0];
MarkDirty();
if (ReferenceEquals(grid, _dynamicGrid))
{
UpdateOffsetHint();
}
}
private sealed record ClassListItem(int ClassId, string Name, string ClassFile, bool HasData)
{
public override string ToString()
=> HasData ? Name : $"{Name}(无)";
}
}

View File

@@ -50,6 +50,8 @@ public sealed class MainForm : Form, IMessageFilter
private readonly StatusForm _statusForm;
private readonly ModuleStore _moduleStore;
private readonly ModuleEditorControl _moduleEditor;
private readonly ClassConfigEditorControl _classConfigEditor;
private readonly ClassMacrosEditorControl _classMacrosEditor;
private readonly AppOptions _initialOptions;
private readonly UiCacheState _uiCache;
private readonly System.Windows.Forms.Timer _roundedCornerResizeTimer;
@@ -86,6 +88,14 @@ public sealed class MainForm : Form, IMessageFilter
_statusForm.AttachSettingsPanel(BuildSettingsPanel());
_moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditor, AppPaths.BaseDirectory);
_statusForm.AttachModuleEditor(_moduleEditor);
_classConfigEditor = new ClassConfigEditorControl(
() => WowAddonLocator.FindClassDirectory(_initialOptions.WindowTitle),
UpdateConfigFromAddonAsync);
_statusForm.AttachConfigEditor(_classConfigEditor);
_classMacrosEditor = new ClassMacrosEditorControl(
() => WowAddonLocator.FindClassMacrosPath(_initialOptions.WindowTitle),
UpdateConfigFromAddonAsync);
_statusForm.AttachMacrosEditor(_classMacrosEditor);
_statusForm.FormClosing += (_, _) =>
{
CancelToggleKeyCapture();
@@ -458,7 +468,7 @@ public sealed class MainForm : Form, IMessageFilter
configPanel.Controls.Add(configTitle, 0, 0);
configPanel.SetColumnSpan(configTitle, 2);
_configSourceLabel = CreateInfoLabel("Fuyutsui class: 点击「更新配置」时从游戏窗口定位");
_configSourceLabel = CreateInfoLabel("Fuyutsui: 点击「更新配置」时同步 class 与 classmacros → keymap");
_configSourceLabel.Margin = new Padding(0, 4, 0, 10);
configPanel.Controls.Add(_configSourceLabel, 0, 1);
configPanel.SetColumnSpan(_configSourceLabel, 2);
@@ -548,6 +558,7 @@ public sealed class MainForm : Form, IMessageFilter
{
var windowTitle = _initialOptions.WindowTitle;
var classDirectory = WowAddonLocator.FindClassDirectory(windowTitle);
var classMacrosPath = WowAddonLocator.FindClassMacrosPath(windowTitle);
if (string.IsNullOrWhiteSpace(classDirectory))
{
_configSourceLabel.Text = $"Fuyutsui class: 未找到(请先打开「{windowTitle}」窗口)";
@@ -559,7 +570,9 @@ public sealed class MainForm : Form, IMessageFilter
return;
}
_configSourceLabel.Text = $"Fuyutsui class: {classDirectory}";
_configSourceLabel.Text = string.IsNullOrWhiteSpace(classMacrosPath)
? $"Fuyutsui class: {classDirectory}"
: $"Fuyutsui: {classDirectory} + classmacros.lua";
var configDirectory = ConfigService.ResolveConfigPath(AppPaths.BaseDirectory);
if (!Directory.Exists(configDirectory))
{
@@ -567,17 +580,43 @@ public sealed class MainForm : Form, IMessageFilter
return;
}
var keymapDirectory = Path.Combine(AppPaths.BaseDirectory, "keymap");
try
{
UseWaitCursor = true;
var result = await Task.Run(() => FuyutsuiConfigConverter.UpdateFromClassDirectory(classDirectory, configDirectory));
var result = await Task.Run(() =>
{
var configResult = FuyutsuiConfigConverter.UpdateFromClassDirectory(classDirectory, configDirectory);
FuyutsuiKeymapConverter.UpdateResult? keymapResult = null;
if (!string.IsNullOrWhiteSpace(classMacrosPath))
{
keymapResult = FuyutsuiKeymapConverter.UpdateFromClassMacros(classMacrosPath, keymapDirectory);
}
return (Config: configResult, Keymap: keymapResult);
});
_moduleEditor.ReloadCatalogs();
AppendLog($"已从 Fuyutsui 更新配置: {result.UpdatedFiles.Count} 个文件 ← {result.ClassDirectory}");
foreach (var warning in result.Warnings.Take(20))
AppendLog($"已从 Fuyutsui 更新配置: {result.Config.UpdatedFiles.Count} 个文件 ← {result.Config.ClassDirectory}");
foreach (var warning in result.Config.Warnings.Take(20))
{
AppendLog($"配置警告: {warning}");
}
if (result.Keymap is { } keymap)
{
AppendLog($"已从 classmacros 更新 keymap: {keymap.UpdatedFiles.Count} 个文件 ← {keymap.ClassMacrosPath}");
foreach (var warning in keymap.Warnings.Take(20))
{
AppendLog($"keymap 警告: {warning}");
}
}
else
{
AppendLog("未找到 core\\classmacros.lua已跳过 keymap 更新");
}
if (_runtime is not null)
{
AppendLog("配置已更新, 重新启动运行");
@@ -585,11 +624,15 @@ public sealed class MainForm : Form, IMessageFilter
StartRuntime();
}
var warningText = result.Warnings.Count == 0
var warningCount = result.Config.Warnings.Count + (result.Keymap?.Warnings.Count ?? 0);
var warningText = warningCount == 0
? string.Empty
: $"\n警告 {result.Warnings.Count} 条(详见日志)。";
: $"\n警告 {warningCount} 条(详见日志)。";
var keymapText = result.Keymap is { } km
? $"\nkeymap: {km.UpdatedFiles.Count} 个文件\n{km.ClassMacrosPath}"
: "\nkeymap: 未更新(缺少 classmacros.lua";
MessageBox.Show(
$"已更新 {result.UpdatedFiles.Count} 个职业配置。\n来源:\n{result.ClassDirectory}{warningText}",
$"已更新 {result.Config.UpdatedFiles.Count} 个职业配置。\n来源:\n{result.Config.ClassDirectory}{keymapText}{warningText}",
"更新配置",
MessageBoxButtons.OK,
MessageBoxIcon.Information);

View File

@@ -17,9 +17,11 @@ public sealed class StatusForm : Form
private ListView _unitInfoList = null!;
private TextBox _logTextBox = null!;
private Panel _contentHost = null!;
private Panel _settingsHost = 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()
{
@@ -71,6 +73,8 @@ public sealed class StatusForm : Form
Controls.Add(root);
_settingsHost = CreatePageHost();
_configHost = CreatePageHost();
_macrosHost = CreatePageHost();
_moduleHost = CreatePageHost();
_aboutHost = CreatePageHost();
@@ -111,6 +115,8 @@ public sealed class StatusForm : Form
};
AddNavItem(nav, "通用", _settingsHost);
AddNavItem(nav, "配置", _configHost);
AddNavItem(nav, "宏", _macrosHost);
AddNavItem(nav, "模块", _moduleHost);
AddNavItem(nav, "状态", BuildStatusPage());
AddNavItem(nav, "队伍", BuildSection("队伍", _partyList, "当前队伍单位与扫描到的字段摘要"));
@@ -206,6 +212,18 @@ public sealed class StatusForm : Form
_settingsHost.Controls.Add(panel);
}
public void AttachConfigEditor(Control panel)
{
panel.Dock = DockStyle.Fill;
_configHost.Controls.Add(panel);
}
public void AttachMacrosEditor(Control panel)
{
panel.Dock = DockStyle.Fill;
_macrosHost.Controls.Add(panel);
}
public void AttachModuleEditor(Control panel)
{
panel.Dock = DockStyle.Fill;

View File

@@ -6,6 +6,9 @@ namespace Shigure;
internal static class UiTheme
{
private static readonly Dictionary<int, Image?> ClassIcons = new();
private static readonly Dictionary<(int ClassId, int SpecId), Image?> SpecIcons = new();
private const int DwmwaUseImmersiveDarkMode = 20;
private const int DwmwaMicaEffect = 1029;
private const int DwmwaSystemBackdropType = 38;
@@ -309,6 +312,265 @@ internal static class UiTheme
};
}
public static void StyleClassIconListBox(
ListBox listBox,
Func<object?, int?> classIdSelector)
{
listBox.BackColor = Surface;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.None;
listBox.DrawMode = DrawMode.OwnerDrawFixed;
listBox.ItemHeight = 80;
listBox.IntegralHeight = false;
var hoveredIndex = -1;
listBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var hovered = e.Index == hoveredIndex;
var backgroundColor = selected
? Hover
: e.Index % 2 == 0
? listBox.BackColor
: RowAlt;
using (var background = new SolidBrush(backgroundColor))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
if (selected || hovered)
{
using var indicator = new SolidBrush(selected ? Color.White : Muted);
e.Graphics.FillRectangle(
indicator,
e.Bounds.Left,
e.Bounds.Top + 12,
4,
e.Bounds.Height - 24);
}
var item = listBox.Items[e.Index];
var classId = classIdSelector(item);
var icon = classId is null ? null : GetClassIcon(classId.Value);
var iconSize = Math.Min(64, e.Bounds.Height - 8);
var visibleWidth = GetVisibleClientWidth(listBox);
var iconBounds = new Rectangle(
e.Bounds.Left + (visibleWidth - iconSize) / 2,
e.Bounds.Top + (e.Bounds.Height - iconSize) / 2,
iconSize,
iconSize);
if (icon is not null)
{
e.Graphics.DrawImage(icon, iconBounds);
using var border = new Pen(selected ? Accent : Border);
e.Graphics.DrawRectangle(border, iconBounds);
}
else
{
TextRenderer.DrawText(
e.Graphics,
"?",
listBox.Font,
iconBounds,
selected ? Text : Muted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
};
listBox.MouseMove += (_, e) =>
{
var index = listBox.IndexFromPoint(e.Location);
if (index == hoveredIndex)
{
return;
}
hoveredIndex = index;
listBox.Invalidate();
};
listBox.MouseLeave += (_, _) =>
{
hoveredIndex = -1;
listBox.Invalidate();
};
}
private static int GetVisibleClientWidth(Control control)
{
var visibleBounds = control.RectangleToScreen(control.ClientRectangle);
for (var parent = control.Parent; parent is not null; parent = parent.Parent)
{
visibleBounds = Rectangle.Intersect(
visibleBounds,
parent.RectangleToScreen(parent.ClientRectangle));
}
return Math.Max(0, visibleBounds.Width);
}
private static Image? GetClassIcon(int classId)
{
if (ClassIcons.TryGetValue(classId, out var cached))
{
return cached;
}
var fileName = ClassNames.GetConfigFileName(classId).ToLowerInvariant();
var resourceName = $"{typeof(UiTheme).Namespace}.Assets.Class.{fileName}.jpg";
using var stream = typeof(UiTheme).Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
ClassIcons[classId] = null;
return null;
}
using var source = Image.FromStream(stream);
var icon = new Bitmap(source);
ClassIcons[classId] = icon;
return icon;
}
public static void StyleSpecIconListBox(
ListBox listBox,
Func<object?, (int ClassId, int SpecId)?> specIdSelector)
{
listBox.BackColor = Surface;
listBox.ForeColor = Text;
listBox.BorderStyle = BorderStyle.None;
listBox.DrawMode = DrawMode.OwnerDrawFixed;
listBox.ItemHeight = 80;
listBox.IntegralHeight = false;
var hoveredIndex = -1;
listBox.DrawItem += (_, e) =>
{
if (e.Index < 0)
{
return;
}
var selected = (e.State & DrawItemState.Selected) == DrawItemState.Selected;
var hovered = e.Index == hoveredIndex;
var backgroundColor = selected
? Hover
: hovered
? SurfaceRaised
: e.Index % 2 == 0
? listBox.BackColor
: RowAlt;
using (var background = new SolidBrush(backgroundColor))
{
e.Graphics.FillRectangle(background, e.Bounds);
}
if (selected || hovered)
{
using var indicator = new SolidBrush(selected ? Accent : Muted);
e.Graphics.FillRectangle(
indicator,
e.Bounds.Left,
e.Bounds.Top + 12,
4,
e.Bounds.Height - 24);
}
var item = listBox.Items[e.Index];
var specIds = specIdSelector(item);
var icon = specIds is { } ids ? GetSpecIcon(ids.ClassId, ids.SpecId) : null;
var iconSize = Math.Min(56, e.Bounds.Height - 12);
var iconBounds = new Rectangle(
e.Bounds.Left + 12,
e.Bounds.Top + (e.Bounds.Height - iconSize) / 2,
iconSize,
iconSize);
if (icon is not null)
{
e.Graphics.DrawImage(icon, iconBounds);
using var border = new Pen(selected ? Accent : Border);
e.Graphics.DrawRectangle(border, iconBounds);
}
else
{
using var placeholder = new SolidBrush(Field);
e.Graphics.FillRectangle(placeholder, iconBounds);
TextRenderer.DrawText(
e.Graphics,
"?",
listBox.Font,
iconBounds,
selected ? Text : Muted,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
var textBounds = new Rectangle(
iconBounds.Right + 12,
e.Bounds.Top,
Math.Max(0, e.Bounds.Right - iconBounds.Right - 18),
e.Bounds.Height);
TextRenderer.DrawText(
e.Graphics,
item?.ToString() ?? string.Empty,
listBox.Font,
textBounds,
selected ? Text : Muted,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left |
TextFormatFlags.EndEllipsis | TextFormatFlags.SingleLine);
};
listBox.MouseMove += (_, e) =>
{
var index = listBox.IndexFromPoint(e.Location);
if (index == hoveredIndex)
{
return;
}
hoveredIndex = index;
listBox.Invalidate();
};
listBox.MouseLeave += (_, _) =>
{
hoveredIndex = -1;
listBox.Invalidate();
};
}
private static Image? GetSpecIcon(int classId, int specId)
{
var key = (classId, specId);
if (SpecIcons.TryGetValue(key, out var cached))
{
return cached;
}
var fileName = ClassNames.GetSpecIconFileName(classId, specId);
if (fileName is null)
{
SpecIcons[key] = null;
return null;
}
var resourceName = $"{typeof(UiTheme).Namespace}.Assets.Spec.{fileName}.jpg";
using var stream = typeof(UiTheme).Assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
SpecIcons[key] = null;
return null;
}
using var source = Image.FromStream(stream);
var icon = new Bitmap(source);
SpecIcons[key] = icon;
return icon;
}
public static void StyleListView(ListView listView, Font font)
{
listView.Dock = DockStyle.Fill;

View File

@@ -1,4 +1,4 @@
{
{
"1": {
"unit": 1,
"技能": "解除诅咒",
@@ -266,7 +266,7 @@
},
"54": {
"unit": 0,
"技能": "暴风雪",
"技能": "",
"热键": "ALT-NUMPADDIVIDE"
},
"55": {
@@ -411,7 +411,7 @@
},
"83": {
"unit": 0,
"技能": "烈焰风暴",
"技能": "",
"热键": "SHIFT-NUMPAD5"
},
"84": {

File diff suppressed because it is too large Load Diff