配置与宏
BIN
Assets/Class/deathknight.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Assets/Class/demonhunter.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Class/druid.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Class/evoker.jpg
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
Assets/Class/hunter.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Assets/Class/mage.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Class/monk.jpg
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
Assets/Class/paladin.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Class/priest.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Class/rogue.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Class/shaman.jpg
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Assets/Class/warlock.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Class/warrior.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
64
Assets/IconManifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
BIN
Assets/Spec/deathknight-blood.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Spec/deathknight-frost.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/deathknight-unholy.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/demonhunter-devourer.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
Assets/Spec/demonhunter-havoc.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Assets/Spec/demonhunter-vengeance.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Assets/Spec/druid-balance.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Assets/Spec/druid-feral.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Spec/druid-guardian.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/druid-restoration.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Assets/Spec/evoker-augmentation.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Assets/Spec/evoker-devastation.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/evoker-preservation.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/hunter-beastmastery.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/hunter-marksmanship.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Assets/Spec/hunter-survival.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Spec/mage-arcane.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/mage-fire.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Assets/Spec/mage-frost.jpg
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
Assets/Spec/monk-brewmaster.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Assets/Spec/monk-mistweaver.jpg
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Assets/Spec/monk-windwalker.jpg
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
Assets/Spec/paladin-holy.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Assets/Spec/paladin-protection.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/paladin-retribution.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/priest-discipline.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/priest-holy.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/priest-shadow.jpg
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Assets/Spec/rogue-assassination.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Assets/Spec/rogue-outlaw.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/rogue-subtlety.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Spec/shaman-elemental.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Assets/Spec/shaman-enhancement.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
Assets/Spec/shaman-restoration.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/warlock-affliction.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/warlock-demonology.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Spec/warlock-destruction.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Spec/warrior-arms.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
Assets/Spec/warrior-fury.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Assets/Spec/warrior-protection.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
294
CLASSMACROS_AI_Reference_zh-CN.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
title: "Fuyutsui core/classmacros.lua:AI 宏规则参考"
|
||||
language: "zh-CN"
|
||||
primary_file: "core/classmacros.lua"
|
||||
related:
|
||||
- "core/macro.lua"
|
||||
- "main.lua"
|
||||
- "Keymap.md"
|
||||
- "AGENTS.md"
|
||||
- "AI_CODEBASE_GUIDE_zh-CN.md"
|
||||
purpose: "供 AI 理解、审查、新增职业宏时作为单一事实来源;说明 ClassMacros 三表规则与 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` | 小键盘 0–9 / 小数点 / ± / − / × / ÷;`F1–F3,F5–F12`(**无 F4**);`, . / ; ' [ ] \`;`7 8 9 0 =` |
|
||||
|
||||
- 总槽位数 = `7 × 39 = 273`。
|
||||
- 按钮名:`s1`、`s2`、…(与 `macroKind` 下标一致)。
|
||||
- 人读对照表见 `Keymap.md`(ID 1 = `CTRL-NUMPAD1`,以此类推)。
|
||||
|
||||
**AI 改宏时**:关心的是 `dynamic` 占用多少槽、以及 static/special 的相对 `index`;不必手算全局热键,除非要核对外部程序按键映射。
|
||||
|
||||
## 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,按钮不更新 |
|
||||
42
CLAUDE.md
@@ -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`,删除只走 `×` 列。
|
||||
|
||||
|
||||
570
Infrastructure/ClassBlocksStore.cs
Normal file
@@ -0,0 +1,570 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using static Shigure.LuaLiteParser;
|
||||
|
||||
namespace Shigure;
|
||||
|
||||
/// <summary>
|
||||
/// 读写 Fuyutsui class/*.lua 中的 ClassBlocks(states/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);
|
||||
}
|
||||
274
Infrastructure/ClassMacrosStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
118
Infrastructure/ClassStateCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
390
Infrastructure/FuyutsuiKeymapConverter.cs
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
39
Tools/Download-WowIcons.ps1
Normal 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)"
|
||||
1493
UI/ClassConfigEditorControl.cs
Normal file
853
UI/ClassMacrosEditorControl.cs
Normal 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}(无)";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
262
UI/UiTheme.cs
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||