diff --git a/Infrastructure/ClassBlocksStore.cs b/Infrastructure/ClassBlocksStore.cs index fe830f61..47d30110 100644 --- a/Infrastructure/ClassBlocksStore.cs +++ b/Infrastructure/ClassBlocksStore.cs @@ -6,11 +6,12 @@ namespace Shigure; /// /// 读写 Fuyutsui class/*.lua 中的 ClassBlocks(states/auras/spells/group), -/// 保存时只替换 ClassBlocks 表字面量,保留文件其余内容。 +/// 同时读取 spellsList 供界面展示;保存时只替换 ClassBlocks 表字面量,保留文件其余内容。 /// internal static class ClassBlocksStore { public const string AssignmentName = "Fuyutsui.ClassBlocks"; + public const string SpellsListAssignmentName = "Fuyutsui.spellsList"; private static readonly string[] StateCategories = [ ClassStateCatalog.CategoryState, @@ -28,9 +29,17 @@ internal static class ClassBlocksStore public int TableStart { get; set; } public int TableEndExclusive { get; set; } public Dictionary Specs { get; set; } = new(); + public List SpellsList { get; set; } = new(); public bool IsModernFormat { get; set; } } + public sealed class SpellsListEntry + { + public long SpellId { get; set; } + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + } + public sealed class SpecBlocks { public bool NestedStates { get; set; } = true; @@ -112,6 +121,7 @@ internal static class ClassBlocksStore specs[(int)specId] = spec; } + var spellsList = ParseSpellsList(ExtractAssignedTable(source, SpellsListAssignmentName)); return new ClassFileDocument { FilePath = filePath, @@ -119,10 +129,48 @@ internal static class ClassBlocksStore TableStart = start, TableEndExclusive = end, Specs = specs, + SpellsList = spellsList, IsModernFormat = modern }; } + private static List ParseSpellsList(TableValue? table) + { + var result = new List(); + if (table is null) + { + return result; + } + + foreach (var (key, value) in table.Entries) + { + if (key is not long spellId || value is not TableValue spell) + { + continue; + } + + var indexValue = spell.GetNumber("index"); + var name = spell.GetString("name")?.Trim(); + if (indexValue is null + || indexValue.Value <= 0 + || indexValue.Value > int.MaxValue + || indexValue.Value != Math.Truncate(indexValue.Value) + || string.IsNullOrWhiteSpace(name)) + { + continue; + } + + result.Add(new SpellsListEntry + { + SpellId = spellId, + Index = (int)indexValue.Value, + Name = name + }); + } + + return result; + } + public static void Save(ClassFileDocument document) { if (!document.IsModernFormat) diff --git a/Infrastructure/ClassStateCatalog.cs b/Infrastructure/ClassStateCatalog.cs index d113b0b9..28bd32ab 100644 --- a/Infrastructure/ClassStateCatalog.cs +++ b/Infrastructure/ClassStateCatalog.cs @@ -31,7 +31,7 @@ internal static class ClassStateCatalog "生命值", "一键辅助", "插入法术", "队伍类型", "队伍人数", "首领战", "难度", "英雄天赋", "施法目标", "施法技能", "敌人人数", "施法", "引导", "蓄力", "蓄力层数", - "酒池", "符文", "姿态" + "酒池", "符文", "姿态", "救赎之魂1", "救赎之魂2", ]), (CategoryConfig, [ diff --git a/README.md b/README.md index 887a762b..361f1bc1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Shigure -Shigure 是 荒坂公司(Arasaka Corporation)开发的冲锋枪,有时人们只想把子弹全打出去,在硝烟过后品味眼前的一片狼藉。 +Shigure 是荒坂公司(Arasaka Corporation)开发的冲锋枪。有时人们只想把子弹全打出去,在硝烟过后品味眼前的一片狼藉。 -### 它需要配合 冬月电子(Fuyutsuki Electronics)的 [Fuyutsui](https://github.com/waynebian01/Fuyutsui)使用。 +### 它需要配合冬月电子(Fuyutsuki Electronics)的 [Fuyutsui](https://github.com/waynebian01/Fuyutsui) 使用。 # 免责声明 (Disclaimer) @@ -26,58 +26,69 @@ Shigure 是 荒坂公司(Arasaka Corporation)开发的冲锋枪,有时人 一旦开始使用本软件,即视为用户已阅读、理解并同意本免责声明及 MIT License 的全部内容。若不同意,请立即停止使用并删除本软件及其副本。 -## 真实作用 +## 项目简介 -Shigure 是一个 Windows WinForms 桌面程序,用于扫描目标窗口状态、根据职业 keymap 选择按键,并把实时状态、队伍信息、逻辑输出和运行日志集中显示在 UI 中。 +Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyutsui 绘制的像素状态,按职业 keymap 和模块规则选择按键,并在界面中展示状态、光环、技能、队伍、动态单位、逻辑结果和运行日志。 -当前版本:`1.2.0.0` +当前版本:`1.2.1` -## 当前界面 +## 主要功能 -- 置顶浮动条:无边框小窗口,显示 Shigure 标题和逻辑状态,右侧有 `开关`(文字在 `开启`/`关闭` 间切换)、`设置`、`✕` 三个按钮;窗口可拖动、可从边缘缩放,启动后会自动启动运行循环。 -- 设置窗口:点击 `设置` 打开,集中包含 `通用`、`模块`、`状态`、`队伍`、`逻辑`、`日志`、`关于` 七个页签。 -- 通用页:可设置触发键、发送模式和当前使用的模块。模块选择器会根据实时识别到的职业 / 专精 / 队伍类型 / 英雄天赋筛选可用模块,可保持自动选择最匹配模块,也可从符合条件的模块中手动指定一个。点击触发键按钮后按下目标键即可录入,支持键盘按键、`XBUTTON1`、`XBUTTON2`,不支持 `ALT`。 -- 模块页:左侧列出全部模块,右侧可新建、编辑、删除模块,并编辑模块名称、匹配条件(职业 / 专精 / 队伍类型 / 英雄天赋)和判断规则。这个页面只负责逻辑编辑;具体启用或选择哪个模块在“通用”页完成。规则表的“技能”“目标”列为下拉菜单(选项来自当前选中职业的 keymap),“条件”列为只读,点击后弹出可视化条件编辑器。页面内嵌“动态单位 / 数量字段”面板,可定义如“最低血量单位”这类按队伍状态实时解析的单位,供“目标”和“条件”引用。 -- 状态页:显示当前匹配的模块名以及扫描后构建出的状态字段,右侧并列显示 `spells` 技能状态。 -- 队伍页:显示 `group` 队伍成员摘要。 -- 逻辑页:显示模块或职业逻辑返回的诊断信息(命中条件、动作技能、动作按键等)。 -- 日志页:记录启动、停止、职业识别、模块匹配、逻辑状态变化、步骤变化和异常信息。 -- 关于页:显示产品名、版本、运行目录、模块目录和配置文件路径。 +- 扫描 510 格顶部状态行、左侧计数条和队伍治疗吸收数据。 +- 按职业、专精、队伍类型和英雄天赋自动匹配模块,也可手动锁定符合条件的模块。 +- 使用可视化编辑器维护规则、主/子条件、动态单位、数量字段、条件动态数值和公式动态数值。 +- 从已运行的游戏进程定位 `Interface\AddOns\Fuyutsui`,同步或直接编辑职业配置与宏。 +- 支持 `switch`、`click`、`hold` 三种触发模式,以及除 `ALT` 外的键盘按键、`XBUTTON1`、`XBUTTON2`。 -触发键、发送模式的修改会立即重启运行循环。模块会保存到 `module` 文件夹,修改并保存后同样会重启运行循环以加载最新模块。窗口位置/大小、UI 状态(如规则表的列宽)会被缓存到 `cache/window-state.json`,下次启动时自动恢复。 +## 界面 -## 运行环境 +- 置顶浮动条:显示程序名、当前职业图标颜色和逻辑状态,提供 `开启/关闭`、`设置`、`✕` 按钮。窗口可拖动和缩放,显示后自动启动运行循环。 +- `通用`:设置触发键和发送模式;更新 Fuyutsui 配置;按实时环境筛选、自动选择或手动指定模块。 +- `配置`:直接编辑 Fuyutsui `class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术和队伍字段。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/group` 格式。 +- `宏`:编辑 `core/classmacros.lua` 中各职业的动态宏、静态宏和特殊宏。动态宏每项占用 30 个团队点名槽位。 +- `模块`:新建、编辑、删除本地模块,维护作者、推荐天赋、匹配条件、动态字段和有序规则。规则支持拖拽、上移、下移、复制与插入。 +- `状态`:分栏显示基础状态、`auras`、`spells` 和模块计算出的动态单位/数值。 +- `队伍`:显示 `group` 中当前队伍成员及扫描字段摘要。 +- `逻辑`:显示命中模块、目标、按键和调试值。 +- `日志`:记录启动、停止、职业识别、模块匹配、逻辑状态、施放步骤、配置同步和异常。 +- `关于`:显示产品、公司、版本、模块目录和配置目录。 + +触发键、发送模式、模块选择、模块保存以及配置同步都会按需重启运行循环。窗口位置、大小、模块选择和表格列宽等 UI 状态保存在 `cache/window-state.json`。`module/` 与 `cache/` 都是本地数据,默认不提交到 Git。 + +## 环境要求 - Windows - .NET 10 SDK +- 已安装并启用 [Fuyutsui](https://github.com/waynebian01/Fuyutsui) +- 使用“更新配置”“配置”或“宏”页时,需要先打开目标游戏窗口,以便 Shigure 定位插件目录 ## 运行 -在仓库根目录运行: +在仓库根目录执行: ```powershell dotnet run --project .\Shigure.csproj ``` -也可以传入启动参数: +可选启动参数: ```powershell dotnet run --project .\Shigure.csproj -- --window 魔兽世界 --toggle XBUTTON2 --mode switch --logic-ms 100 --render-ms 100 ``` -参数说明: - - `--window`:目标窗口标题,默认 `魔兽世界`。 - `--toggle`:触发键,默认 `XBUTTON2`。 - `--mode`:发送模式,支持 `switch`、`click`、`hold`。 -- `--logic-ms`:逻辑循环间隔,默认 `100`,最小 `50`。 -- `--render-ms`:UI 刷新间隔,默认 `100`,最小 `100`。 +- `--logic-ms`:逻辑循环间隔,默认 `100` ms,最小 `50` ms。 +- `--render-ms`:UI 刷新间隔,默认 `100` ms,最小 `100` ms。 发送模式: -- `switch`:按一次触发键开启逻辑,再按一次关闭。 -- `click`:按一次触发键只执行一轮逻辑。 -- `hold`:按住触发键时运行,松开后停止。 +- `switch`:按一次开启,再按一次关闭。 +- `click`:每次按下只执行一轮逻辑。 +- `hold`:按住时运行,松开后停止。 + +启动时,程序会在系统临时目录创建随机名称的运行副本,并通过原始输出目录读取 `config/`、`keymap/` 和 `module/`。旧的临时副本会在下次启动时尝试清理。 ## 构建 @@ -85,74 +96,88 @@ dotnet run --project .\Shigure.csproj -- --window 魔兽世界 --toggle XBUTTON2 dotnet build .\Shigure.csproj ``` -应用图标来自: - -```text -Assets\shigure-emblem-arasaka.ico -``` +应用图标为 `Assets\arasaka-icon.ico`。项目会把 `config/*.json`、`keymap/*.json` 和本地 `module/**/*.json` 复制到输出目录;图标、职业图和专精图作为嵌入资源打包。 ## 项目结构 ```text -Shigure.csproj 项目文件,复制 Assets/config/keymap/module 到输出目录 -App\ 程序入口与启动参数解析 -UI\ WinForms 界面、编辑器、主题和 UI 缓存 -Runtime\ 扫描、状态构建、逻辑运行和运行时快照 -Modules\ 模块模型、存储、匹配、规则执行和字段目录 -Input\ keymap 读取、按键发送和 Win32 API 互操作 -Infrastructure\ 配置读取与 JSON 辅助方法 -Assets\ 图标与品牌资源 -keymap\ 各职业按键映射 -module\ UI 可编辑模块 +Shigure.csproj 项目文件与构建资源配置 +App\ 程序入口、启动参数和随机运行副本 +UI\ WinForms 主界面、配置/宏/模块编辑器和主题 +Runtime\ 像素扫描、状态构建、运行循环和快照 +Modules\ 模块模型、匹配、条件、公式和动态字段 +Input\ keymap 读取、按键发送和 Win32 API +Infrastructure\ 配置服务、Lua 读写、Fuyutsui 转换和路径定位 +Assets\ 应用图标、品牌资源、职业图和专精图 +config\ 由 Fuyutsui 职业配置生成的扫描映射 +keymap\ 由 Fuyutsui 职业宏生成的按键映射 +module\ 本地模块(运行后或在 UI 中保存时创建) +Tools\ 辅助脚本 ``` -## 配置文件 +## Fuyutsui 配置同步 -- `config/*.json`:状态扫描配置和职业 keymap 引用,`common.json` 为公共配置,其余文件按英文职业名命名(如 `Warrior.json`)。 -- `keymap/*.json`:职业按键映射,运行时按职业 ID 选择。 -- `Assets/*.ico|*.png|*.svg`:程序图标和品牌资源,构建时会复制到输出目录。 -- `cache/window-state.json`:UI 缓存,保存置顶浮动条位置、设置窗口位置/大小和上次触发键。 +`通用` 页的“更新配置”会从目标窗口对应的游戏目录读取: -### config 结构 +- `Interface\AddOns\Fuyutsui\class\*.lua` → `config/*.json` +- `Interface\AddOns\Fuyutsui\core\classmacros.lua` → `keymap/*.json` -`config` 目录描述了如何把扫描到的像素数据翻译成状态字段。每个字段是一个对象: +同步完成后会刷新模块编辑器的字段/keymap 目录并重启运行循环。`配置` 页和 `宏` 页保存时会先写回相应 Lua 文件,再自动执行同一同步流程。写回前请自行备份需要保留的 Fuyutsui 配置。 -- `step`:在顶部行数据中的列索引,支持 `1`~`510`;特殊值 `"bar"` 表示从左侧标记行数据读取,并配合 `bar` 字段指定段索引。 +### `config` 结构 + +`config` 描述如何把像素数据翻译成运行时状态。字段对象包含: + +- `step`:顶部状态行的列索引,支持 `1`~`510`;特殊值 `"bar"` 表示读取左侧计数条,并由 `bar` 指定段索引。 - `type`:字段类型,支持 `int`、`bool`、`string`。 -顶部状态行由 Fuyutsui 绘制为 510 个色块。Shigure 按两段颜色协议解码:`1`~`255` 使用 `(0, i / 255, b, 1)`,`256`~`510` 使用 `(1 / 255, (i - 255) / 255, b, 1)`;其中 `b` 会作为该 `step` 的原始值写入状态。左侧 `"bar"` 标记行仍按独立的计数条协议读取,不受顶部 510 色块数量影响。 +顶部状态行由 Fuyutsui 绘制为 510 个色块。Shigure 按两段颜色协议解码:`1`~`255` 使用 `(0, i / 255, b, 1)`,`256`~`510` 使用 `(1 / 255, (i - 255) / 255, b, 1)`,蓝色通道 `b` 是该 `step` 的原始值。左侧 `"bar"` 标记行使用独立计数条协议。 -`common.json` 包含 `锚点`、`职业`、`专精` 等基础字段和一个 `state` 公共状态块;其余职业文件如 `Warrior.json`、`Paladin.json` 按英文职业名命名,对应职业 ID,每个职业文件下: +`common.json` 保存固定启动字段 `锚点`、`职业`、`专精`。其余文件按英文职业名命名,例如 `Warrior.json`、`Priest.json`,每个文件包含: - `keymap`:该职业使用的 keymap 文件名。 -- 以专精 ID 为键(如 `"1"`、`"2"`、`"3"`)的对象,包含该专精特有的字段、`spells` 技能块和可选的 `group` 队伍块。 +- `一键法术`:状态编号到技能名的映射。 +- 以专精 ID 为键的对象,包含普通状态字段,以及可选的 `auras`、`spells`、`group`。 -运行时 `ConfigService` 会把 `锚点/职业/专精/state` 与当前 `职业/专精` 对应的配置合并,再由 `StateBuilder` 构建出 `GameState`。`group` 块用 `start`(起始列)和 `num`(每个成员占用的列数)描述队伍成员数据的排布。 +运行时先读取 `common.json` 确定职业和专精,再合并对应专精配置。`group.start` 是队伍数据起始列,`group.num` 是每名成员占用的列数;组内字段的 `step` 是相对偏移。 + +### `keymap` 与单位编号 + +每条 keymap 记录包含 `unit`、`技能`、`热键`。模块和 keymap 共用以下单位编号: + +- `0`:无目标。 +- `1`~`30`:队伍/团队槽位。 +- `31`:玩家。 +- `32`:当前目标。 +- `33`:焦点。 +- `34`:地面。 +- `35`:鼠标指向。 + +当前映射版本为 `UnitMappingVersion = 2`。旧模块会在加载时迁移此前 `31` 与 `34` 的旧含义。 ## 模块系统 -模块用于把“识别到的角色环境”和“要执行的判断逻辑”拆成可编辑 JSON。运行时每轮识别到 `职业`、`专精`、`队伍类型`、`英雄天赋` 后,会在 `module` 文件夹中寻找匹配模块。 +模块以 `模块名.json` 保存在 `module/`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。 -匹配字段: +模块匹配字段: -- `ClassId`:职业 ID,对应状态字段 `职业`。 -- `SpecId`:专精 ID,对应状态字段 `专精`。 -- `PartyType`:队伍类型,对应状态字段 `队伍类型`。模块页使用下拉菜单:`0` 表示单人,`1-40` 表示团队,`46` 表示队伍。 -- `HeroTalent`:英雄天赋,对应状态字段 `英雄天赋`。 -- `RecommendedTalent`:模块作者填写的推荐天赋说明,仅用于展示和保存,不参与模块匹配。 +- `ClassId`:职业 ID。 +- `SpecId`:专精 ID。 +- `PartyType`:`0` 为单人、`1-40` 为团队、`46` 为队伍。 +- `HeroTalent`:英雄天赋 ID。 -字段留空(界面上选择 `任意 (*)`)表示任意值。多个模块同时匹配时,程序会优先使用匹配字段更多的模块(`Specificity` 越大优先级越高),相同时按名称排序。 -旧模块中 `PartyType` 如果保存为数字仍可读取;保存团队模块时会归一化写入 `"1-40"`。 - -模块以 `模块名.json` 的形式平铺保存在 `module` 文件夹(文件名取自模块名称,因此模块名称不能重复)。加载时会递归扫描 `module` 下的所有子目录,旧版本保存在嵌套子文件夹中的模块文件仍可正常读取。 +任一字段留空(界面显示 `任意 (*)`)即为通配。多个模块同时匹配时,优先选择具体匹配字段更多的模块,相同则按名称排序。`RecommendedTalent` 仅用于说明,不参与匹配。 模块文件示例: ```json { - "Id": "戒律-20260612100954681", + "Id": "戒律-20260730120000000", "Name": "戒律", + "Author": "模块作者", "RecommendedTalent": "推荐天赋代码或说明", + "Version": "1.2.1", + "UnitMappingVersion": 2, "Enabled": true, "Match": { "ClassId": 5, @@ -160,75 +185,72 @@ module\ UI 可编辑模块 "PartyType": "46", "HeroTalent": 1 }, + "Units": [], + "Counts": [], + "ValueAdjustments": [], "Rules": [ { "Enabled": true, - "Condition": "一键辅助 == 10", - "Unit": 0, - "Spell": "一键辅助", + "Condition": "生命值 < 50 && spells.真言术:盾 == 0", + "Unit": 31, + "Spell": "真言术:盾", "Hotkey": "", - "Step": "" + "Step": "自保" } ] } ``` -`Id` 由界面在新建时自动生成(`名称-时间戳`),用于唯一标识模块。 +规则按顺序判断,第一条命中的规则会执行。`Spell` 通过当前职业 keymap 和目标单位查找热键;填写 `Hotkey` 时会直接发送该键。技能下拉还包含以下特殊动作: -规则按顺序判断,第一条命中的规则会执行。`Spell` 会通过当前职业 keymap 查找按键;如果填写了 `Hotkey`,则直接发送该按键(优先于 `Spell`)。`Unit` 表示技能对应的单位(默认为 `0`,即自身/当前单位)。 +- `暂停`:命中后不发送按键,并停止继续匹配本轮后续规则。 +- `自动插入法术`:读取状态字段 `插入法术`,按职业配置映射到当前可用技能。 +- `一键法术`:读取状态字段 `一键辅助`,按职业配置映射到技能。 -模块页规则表中“技能”和“目标”列为下拉菜单,选项取自当前选中职业的 keymap:技能按名称去重(keymap 中同名技能的多个 `unit` 条目只显示一次)。“目标”下拉跟随该行所选技能联动——只列出该技能在 keymap 中实际配置过的 `unit`(例如治疗技能可能有 `1-30`,而形态/自身技能往往只有 `0`),切换技能后非法的旧目标会被清空。职业选“任意 (*)”时回退读取 `keymap/keymap.json`。旧模块中不在 keymap 里的技能,或自定义技能下的目标值,会保留为额外下拉选项,不会丢失。 +### 条件 -判断条件支持: +支持的常见表达式: -- 状态字段:`生命值 < 50`、`战斗时间 > 0`。 -- 技能字段:`spells.圣疗术 == 0`。 -- 队伍字段:`group.1.生命值 < 60`。 -- 组合条件:`生命值 < 50 && spells.圣疗术 == 0`、`目标类型 == 1 || 首领战 > 0`。 -- 布尔简写:直接写字段名表示“为真”,前面加 `!` 表示“为假”,例如 `战斗`、`!移动`。 -- 条件留空表示始终命中。 +- 状态:`生命值 < 50`、`战斗时间 > 0`。 +- 光环:`auras.圣光涌动 > 0`。 +- 技能:`spells.圣疗术 == 0`。 +- 队伍:`group.1.生命值 < 60`。 +- 列表:`目标类型 in (1, 2)`、`职业 not in (4, 9)`。 +- 组合:`生命值 < 50 && spells.圣疗术 == 0`、`目标类型 == 1 || 首领战 > 0`。 +- 布尔简写:`移动` 表示为真,`!移动` 表示为假。 +- 空条件:始终命中。 -字符串字面量可用单引号或双引号包裹;`true/yes/是`、`false/no/否`、`null/nil/空` 会被识别为对应的布尔值或空值。 +字符串可以使用单引号或双引号。`true/yes/是`、`false/no/否`、`null/nil/空` 会转换为对应字面量。`&&` 的优先级高于 `||`,主表达式不支持括号嵌套。 -### 可视化条件编辑器 +规则还可添加多个“子条件”:主条件与子条件组是“且”关系,子条件之间是“或”关系,即 `主条件 && (子条件 1 || 子条件 2)`。这用于表达主编辑器无法用括号表示的逻辑。 -模块页规则表的“条件”列为只读,单击单元格会弹出条件编辑窗口(在新行占位符上点击并保存后会自动追加一条规则)。窗口中每行是一个比较项: +可视化条件编辑器会按当前职业/专精提供状态、光环、技能和动态单位字段,并支持: -- 连接:第二行起可选 `且`(`&&`)或 `或`(`||`),决定与上一行的组合方式。 -- 类型/字段:下拉菜单,状态内容来自 `config` 目录并按模块当前选中的职业/专精过滤(内部定位用的 `锚点` 不显示);技能字段以 `技能: 名称` 显示(保存为 `spells.名称`)。原条件中不在目录里的字段(如 `group.1.生命值` 或手写字段)会保留为“自定义”项,不会丢失。 -- Shigure 字段:选择 `Shigure → 延迟 (ms)` 可设置当前规则两次实际发送之间的最小间隔。例如填写 `500`,该规则持续命中时最多每 `0.5` 秒发送一次。延迟按规则独立计时,不参与真假条件判断;填 `0` 或删除该行表示不限制。 -- Shigure 字段:选择 `Shigure → 逻辑延迟 (ms)` 后,规则命中时会先立即发送按键,再暂停整个逻辑循环指定时间;暂停期间不扫描或判断规则,但触发键检测与界面刷新仍然运行。例如填写 `700`,发送后约 `0.7` 秒才恢复下一次逻辑判断。 -- 判断:数字字段提供 `==`、`!=`、`>`、`>=`、`<`、`<=`;布尔和字符串字段只提供 `==`、`!=`。 -- 值:按字段类型自适应——数字字段用数字输入框,布尔字段用 `是/否` 下拉,字符串和自定义字段用文本框。 -- 每行末尾有删除按钮,底部有“添加条件”按钮和生成表达式的实时预览。 +- 数字比较 `==`、`!=`、`>`、`>=`、`<`、`<=`。 +- 文本/数字列表比较 `in`、`not in`。 +- `延迟 (ms)`:限制同一规则两次实际发送的最小间隔。 +- `逻辑延迟 (ms)`:发送后暂停逻辑扫描指定时间;触发键检测和界面刷新仍继续。 -注意:表达式中 `&&` 的优先级高于 `||`(先算“且”再算“或”),编辑器按此语义把行序列分组,不支持括号嵌套。布尔简写(如 `战斗`、`!移动`)在编辑器中会归一化为显式比较(`战斗时间 > 0`、`移动 == false`),含义不变。关系比较(`>`、`<` 等)遇到缺失或非数字的值(如未解析的动态单位字段)时视为“不命中”,不会报错中断。 +缺失或非数字字段参与大小比较时视为不命中,不会中断运行。 -### 动态单位与数量字段 +### 动态单位、数量与动态数值 -模块页规则表上方有“动态单位 / 数量字段”面板。这些是按 `group` 队伍状态**每帧实时解析**的命名对象,逻辑移植自旧 Python 项目 `utils.py`,统一只统计 `职责 != 0` 的单位、`生命值 0` 视为死亡跳过、阈值表示只考虑 `0 < 生命值 < 阈值`。 +这些字段按 `group` 状态每帧重新计算。队伍统计会跳过 `职责 == 0` 或 `生命值 == 0` 的单位。 -- **动态单位**(可作目标,也可在条件引用):解析为一个队伍槽位(`1`~`30`)。选择器类型包括——`生命值最低`、`治疗吸收最高`、`按职责取首/末`、`按职责且不带某光环`、`带某光环(持续最久)`、`带某驱散类型`。`生命值最低` 与 `治疗吸收最高` 都支持固定/动态阈值,以及 `不筛选光环`、`带任一光环`、`不带某光环`、`带某光环`、`某光环值等于` 五种光环筛选;前者筛选 `0 < 生命值 < 阈值`,后者筛选 `治疗吸收 > 阈值`。`治疗吸收最高` 会在满足光环及阈值条件的单位中选择治疗吸收最高者,没有符合条件的单位时该动态单位不存在;其固定阈值默认值为 `0`。参数(阈值、职责、光环、光环值、驱散类型等)按所选类型自适应显示,光环候选来自当前职业/专精的 `group` 字段,并排除 `生命值`、`职责`、`驱散` 这类基础字段。编辑弹窗在第二行显示“名称”,选择 `生命值最低` 时还会显示“值名称”:填了之后,该单位解析槽位的“生命值”会暴露成一个同名数值条件字段——如取名 `最低血量` 后条件可直接写 `最低血量 < 50`(等价于 `单位名.生命值 < 50`);单位未解析时该字段视为缺失(关系比较不命中)。 -- **数量字段**(仅条件可用):解析为一个整数。选择器按数据类型分类显示:`血量 - 低于阈值`、`血量 - 低于阈值不带某光环`、`血量 - 低于阈值带某光环`、`治疗吸收 - 大于阈值`、`治疗吸收 - 大于阈值不带某光环`、`治疗吸收 - 大于阈值带某光环`、`光环 - 带某光环`。治疗吸收类型支持固定/动态阈值,固定阈值默认值为 `0`。 +- 动态单位:可按最低生命值、最高治疗吸收、职责、光环、光环值或驱散类型选择 `1`~`30` 的队伍槽位。可在规则目标中直接使用,也可用裸名称判断是否成功解析。 +- 数量字段:统计低于生命值阈值、高于治疗吸收阈值或带/不带指定光环的单位数,只能用于条件。 +- 条件动态数值:条件成立时对已有状态、光环、技能或动态字段加减一个整数。 +- 公式动态数值:使用字段和算术表达式生成命名数值,支持 `+ - * /`、括号、一元正负号和 `int`、`round`、`floor`、`ceil`、`min`、`max`。 -定义后: +动态单位和数量阈值既可使用固定值,也可引用其它动态数值。规则引用未解析出的动态目标时会跳过该规则,继续判断下一条。 -- 在规则表“目标”列的下拉里会出现动态单位名,选它即表示“对运行时解析出的那个槽位施法”。运行时若该选择器没选中任何单位(如没人掉血),该规则会被**跳过**,继续判断下一条。 -- 在“条件”里可引用:裸 `单位名`(解析到槽位即为真,可用于存在性判断)、单位的“值名称”(数值,等于该单位的生命值)、以及数量名(如 `低血量人数 >= 3`)。这些会出现在可视化条件编辑器的字段下拉中;`单位名.字段`(如 `最低血量单位.生命值 < 50`)仍可手写,但不会展开进下拉列表。 +模块 JSON 使用 `Units`、`Counts`、`ValueAdjustments` 保存这些定义;规则使用 `UnitName` 引用动态单位。名称不能是纯数字,不能包含 `.` 或 `$`,也不能与已有状态字段或其它动态名称冲突。 -模块 JSON 中以 `Units`、`Counts` 数组保存(单位的值名称存为 `HealthName`,未填则省略),规则用 `UnitName` 字段引用动态单位(与原有数字 `Unit` 互斥),可选 `DelayMs` 保存该行的发送间隔,`LogicDelayMs` 保存发送后的逻辑暂停时长。旧模块没有这些键也能正常加载。名称与值名称不能为纯数字、不能含 `.` 或 `$`,且不能与现有状态/队伍字段或其它单位/数量名重复。 +## 默认逻辑与 C# 扩展 -## 默认逻辑 +如果没有匹配模块,`LogicRegistry` 会尝试使用按职业注册的 `IClassLogic`。当前没有注册项时会进入 `DefaultClassLogic`:仅当 `一键辅助 == 10` 且 keymap 中存在 `一键辅助` 时发送按键,否则显示“C# 职业逻辑尚未迁移”。 -如果没有任何匹配的模块,程序会回退到 `LogicRegistry.cs` 中按职业 ID 注册的 C# 逻辑;若该职业也没有注册逻辑,则使用 `DefaultClassLogic`:当状态字段 `一键辅助 == 10` 且 keymap 中存在 `一键辅助` 时发送对应按键,否则提示“C# 职业逻辑尚未迁移”。 - -你可以在 `module` 文件夹中放置一个匹配字段全部留空的模块作为通配模块,让它在任何职业下都能命中,从而替代上述默认逻辑。 - -## 迁移职业逻辑 - -如果逻辑可以用简单条件表达式描述,优先在 UI 的 `模块` 页维护;如果需要复杂算法,再迁移为 C# 职业逻辑。 - -新增 C# 职业逻辑时,建议新建一个实现 `IClassLogic` 的类: +通常应优先在 `模块` 页维护逻辑。确实需要复杂算法时,可实现 `IClassLogic` 并在 `LogicRegistry` 中按职业 ID 注册: ```csharp public sealed class PriestLogic : IClassLogic @@ -236,20 +258,12 @@ public sealed class PriestLogic : IClassLogic public LogicDecision Run(GameState state, string? specName) { var health = state.GetInt("生命值"); - return new LogicDecision(null, $"当前生命值: {health}", new Dictionary()); + return new LogicDecision( + Hotkey: null, + Step: $"当前生命值: {health}", + UnitInfo: new Dictionary()); } } ``` -然后在 `LogicRegistry` 中按职业 ID 注册: - -```csharp -_logicByClass[5] = new PriestLogic(); -``` - -逻辑内可以通过 `GameState.GetInt(...)`、`GameState.GetBool(...)`、`state.Spells`、`state.Group` 读取状态,并返回 `LogicDecision`: - -- `Hotkey`:需要发送的按键;为空则不发送。 -- `Step`:当前逻辑步骤,会显示到 UI 和日志中。 -- `UnitInfo`:诊断信息,会显示在 `逻辑` 页。 -- `ModuleName`:命中的模块名(C# 逻辑通常留空,由模块逻辑填写)。 +职业逻辑可通过 `GameState.GetInt(...)`、`GameState.GetBool(...)`、`state.Auras`、`state.Spells`、`state.Group` 读取数据,并返回 `LogicDecision`。也可以创建匹配字段全部留空的通配模块,覆盖默认逻辑。 diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 3cb55358..39445041 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -4,7 +4,8 @@ using System.Globalization; namespace Shigure; /// -/// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocks(states / auras / spells / group)。 +/// 图形化编辑 Fuyutsui class/*.lua 的 ClassBlocks(states / auras / spells / group), +/// 并展示同文件中的 spellsList。 /// public sealed class ClassConfigEditorControl : UserControl { @@ -23,6 +24,7 @@ public sealed class ClassConfigEditorControl : UserControl private readonly DataGridView _aurasGrid = new(); private readonly ComboBox _auraBucketBox = new(); private readonly DataGridView _spellsGrid = new(); + private readonly DataGridView _spellsListGrid = new(); private readonly NumericUpDown _groupNumBox = new(); private readonly NumericUpDown _groupHealthBox = new(); private readonly NumericUpDown _groupRoleBox = new(); @@ -270,14 +272,14 @@ public sealed class ClassConfigEditorControl : UserControl { Dock = DockStyle.Fill, BackColor = UiTheme.Border, - ColumnCount = 4, + ColumnCount = 5, RowCount = 1, Margin = new Padding(0), Padding = new Padding(0) }; - for (var i = 0; i < 4; i++) + for (var i = 0; i < 5; i++) { - tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + tabBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20)); } var contentHost = new Panel @@ -293,7 +295,8 @@ public sealed class ClassConfigEditorControl : UserControl BuildStatesPage(), BuildAurasPage(), BuildSpellsPage(), - BuildGroupPage() + BuildGroupPage(), + BuildSpellsListPage() }; foreach (var page in pages) { @@ -302,7 +305,7 @@ public sealed class ClassConfigEditorControl : UserControl contentHost.Controls.Add(page); } - var labels = new Label[4]; + var labels = new Label[5]; var selectedIndex = -1; void SelectTab(int index) { @@ -326,7 +329,7 @@ public sealed class ClassConfigEditorControl : UserControl } } - var titles = new[] { "状态", "光环", "法术", "队伍" }; + var titles = new[] { "状态", "光环", "法术", "队伍", "技能列表" }; for (var i = 0; i < titles.Length; i++) { var index = i; @@ -602,6 +605,51 @@ public sealed class ClassConfigEditorControl : UserControl return panel; } + private Control BuildSpellsListPage() + { + var panel = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + BackColor = UiTheme.SurfaceRaised + }; + panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 34)); + panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + var hint = CreateFieldCaption("来自当前职业 Lua 的 Fuyutsui.spellsList,仅显示索引 1–100(只读)。"); + hint.Padding = new Padding(8, 0, 0, 0); + panel.Controls.Add(hint, 0, 0); + + ConfigureGrid(_spellsListGrid); + _spellsListGrid.AllowUserToAddRows = false; + _spellsListGrid.ReadOnly = true; + _spellsListGrid.EditMode = DataGridViewEditMode.EditProgrammatically; + _spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "SpellId", + HeaderText = "法术 ID", + Width = 150, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Index", + HeaderText = "索引", + Width = 120, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + _spellsListGrid.Columns.Add(new DataGridViewTextBoxColumn + { + Name = "Name", + HeaderText = "名称", + AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill, + SortMode = DataGridViewColumnSortMode.NotSortable + }); + panel.Controls.Add(_spellsListGrid, 0, 1); + return panel; + } + private Control BuildGroupPage() { var panel = new TableLayoutPanel @@ -1048,6 +1096,7 @@ public sealed class ClassConfigEditorControl : UserControl FillAurasGrid(); FillSpellsGrid(); FillGroupEditors(); + FillSpellsListGrid(); } finally { @@ -1248,6 +1297,23 @@ public sealed class ClassConfigEditorControl : UserControl } } + private void FillSpellsListGrid() + { + _spellsListGrid.Rows.Clear(); + if (_currentDocument is null) + { + return; + } + + foreach (var spell in _currentDocument.SpellsList.Where(spell => spell.Index is >= 1 and <= 100)) + { + _spellsListGrid.Rows.Add( + spell.SpellId.ToString(CultureInfo.InvariantCulture), + spell.Index.ToString(CultureInfo.InvariantCulture), + spell.Name); + } + } + private void FillGroupEditors() { _groupAurasGrid.Rows.Clear(); @@ -1592,6 +1658,7 @@ public sealed class ClassConfigEditorControl : UserControl _statesGrid.Rows.Clear(); _aurasGrid.Rows.Clear(); _spellsGrid.Rows.Clear(); + _spellsListGrid.Rows.Clear(); _groupAurasGrid.Rows.Clear(); } diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 89c0e3f7..30eb51c5 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -1,9 +1,13 @@ +using System.Diagnostics; using System.Drawing; +using System.Drawing.Drawing2D; namespace Shigure; public sealed class ModuleEditorControl : UserControl { + private const string ModuleWebsiteUrl = "https://www.shigure.club"; + private readonly ModuleStore _moduleStore; private readonly Action _runtimeRestartRequested; private readonly string _baseDirectory; @@ -129,7 +133,7 @@ public sealed class ModuleEditorControl : UserControl RowCount = 2 }; sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); _moduleList.Dock = DockStyle.Fill; UiTheme.StyleListBox( @@ -142,32 +146,124 @@ public sealed class ModuleEditorControl : UserControl _moduleList.SelectedIndexChanged += (_, _) => SelectModule(_moduleList.SelectedIndex); sidebar.Controls.Add(_moduleList, 0, 0); - var buttons = new FlowLayoutPanel + var buttons = new TableLayoutPanel { Dock = DockStyle.Fill, - FlowDirection = FlowDirection.LeftToRight, - WrapContents = false, BackColor = UiTheme.Background, - Margin = new Padding(0) + Margin = new Padding(0), + Padding = new Padding(14, 3, 14, 3), + ColumnCount = 3, + RowCount = 1 }; - - var addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text); - addButton.Width = 72; - addButton.Height = 34; - addButton.Click += (_, _) => AddModule(); + buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 6)); + buttons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + buttons.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); var reloadButton = UiTheme.CreateButton("刷新", UiTheme.Field, UiTheme.Text); - reloadButton.Width = 72; - reloadButton.Height = 34; + reloadButton.AutoSize = false; + reloadButton.Dock = DockStyle.Fill; + reloadButton.Margin = new Padding(0); reloadButton.Click += (_, _) => LoadModules(); - buttons.Controls.Add(addButton); - buttons.Controls.Add(reloadButton); + var getModulesButton = UiTheme.CreateButton( + "获取模块", + Color.FromArgb(252, 238, 10), + Color.Black); + getModulesButton.AutoSize = false; + getModulesButton.Dock = DockStyle.Fill; + getModulesButton.Margin = new Padding(0); + getModulesButton.Padding = new Padding(0, 2, 24, 2); + getModulesButton.TextAlign = ContentAlignment.MiddleCenter; + getModulesButton.FlatAppearance.BorderColor = Color.FromArgb(252, 238, 10); + getModulesButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(255, 244, 64); + getModulesButton.FlatAppearance.MouseDownBackColor = Color.FromArgb(220, 207, 8); + getModulesButton.Paint += (_, e) => DrawExternalLinkIcon( + e.Graphics, + getModulesButton.ClientRectangle, + getModulesButton.Text, + getModulesButton.Font, + getModulesButton.ForeColor, + getModulesButton.DeviceDpi / 96F); + getModulesButton.Click += (_, _) => OpenModuleWebsite(); + + buttons.Controls.Add(reloadButton, 0, 0); + buttons.Controls.Add(getModulesButton, 2, 0); sidebar.Controls.Add(buttons, 0, 1); return sidebar; } + private static void DrawExternalLinkIcon( + Graphics graphics, + Rectangle clientBounds, + string text, + Font font, + Color color, + float scale) + { + var iconSize = 17F * scale; + var iconGap = 6F * scale; + var textSize = TextRenderer.MeasureText( + graphics, + text, + font, + Size.Empty, + TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); + var groupWidth = textSize.Width + iconGap + iconSize; + var left = clientBounds.Left + + ((clientBounds.Width - groupWidth) / 2F) + + textSize.Width + + iconGap; + var top = clientBounds.Top + (clientBounds.Height - iconSize) / 2F; + + var previousSmoothingMode = graphics.SmoothingMode; + graphics.SmoothingMode = SmoothingMode.AntiAlias; + using var pen = new Pen(color, 1.8F * scale) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + LineJoin = LineJoin.Round + }; + + PointF Point(float x, float y) => new(left + (x * scale), top + (y * scale)); + + graphics.DrawLines( + pen, + [ + Point(7, 2), + Point(4, 2), + Point(2, 4), + Point(2, 13), + Point(4, 15), + Point(13, 15), + Point(15, 13), + Point(15, 9) + ]); + graphics.DrawLine(pen, Point(8, 9), Point(15, 2)); + graphics.DrawLines(pen, [Point(10, 2), Point(15, 2), Point(15, 7)]); + graphics.SmoothingMode = previousSmoothingMode; + } + + private static void OpenModuleWebsite() + { + try + { + Process.Start(new ProcessStartInfo(ModuleWebsiteUrl) + { + UseShellExecute = true + }); + } + catch (Exception ex) + { + MessageBox.Show( + $"无法打开模块网站: {ex.Message}", + "Shigure", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + private Control BuildEditor() { var editor = new TableLayoutPanel @@ -2467,8 +2563,13 @@ public sealed class ModuleEditorControl : UserControl _deleteButton.Margin = new Padding(8, 0, 0, 0); _deleteButton.Click += (_, _) => DeleteSelectedModule(); + var addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text); + addButton.Margin = new Padding(8, 0, 0, 0); + addButton.Click += (_, _) => AddModule(); + buttons.Controls.Add(_saveButton); buttons.Controls.Add(_deleteButton); + buttons.Controls.Add(addButton); row.Controls.Add(hint); row.Controls.Add(buttons); return row; diff --git a/UI/StatusForm.cs b/UI/StatusForm.cs index cd145ad6..48e59232 100644 --- a/UI/StatusForm.cs +++ b/UI/StatusForm.cs @@ -1,10 +1,16 @@ using System.Drawing; +using System.Drawing.Imaging; using System.Reflection; namespace Shigure; public sealed class StatusForm : Form { + private const string AboutWatermarkResourcePath = "Assets.arasaka-icon-transparent.png"; + private const int AboutWatermarkSize = 800; + private const int AboutWatermarkBottomMargin = 80; + private const float AboutWatermarkOpacity = 0.08F; + private readonly List<(Button Button, Control View)> _navItems = new(); private RenderSnapshot? _lastSnapshot; private bool _hasKnownBounds; @@ -314,26 +320,61 @@ public sealed class StatusForm : Form private Control BuildAboutPanel() { - var panel = new TableLayoutPanel + var scrollHost = new WatermarkPanel( + GetEmbeddedResourceName(AboutWatermarkResourcePath), + AboutWatermarkSize, + AboutWatermarkBottomMargin, + AboutWatermarkOpacity) { Dock = DockStyle.Fill, BackColor = UiTheme.SurfaceRaised, + AutoScroll = true, + Margin = new Padding(0) + }; + + var panel = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + BackColor = Color.Transparent, ColumnCount = 1, - RowCount = 2, + RowCount = 4, Padding = new Padding(18) }; panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - var title = new Label + var heading = new TableLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Top, + BackColor = Color.Transparent, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0, 0, 0, 22) + }; + heading.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + heading.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + heading.Controls.Add(new Label { Text = "Shigure", AutoSize = true, ForeColor = UiTheme.Text, Font = new Font(Font.FontFamily, 18F, FontStyle.Bold), - Margin = new Padding(0, 0, 0, 22) - }; - panel.Controls.Add(title, 0, 0); + Margin = new Padding(0, 0, 0, 4) + }, 0, 0); + heading.Controls.Add(new Label + { + Text = "应用信息与 ClassBlocks 可用状态字段参考", + AutoSize = true, + ForeColor = UiTheme.Muted, + Font = new Font(Font.FontFamily, 9.5F, FontStyle.Regular), + Margin = new Padding(0) + }, 0, 1); + panel.Controls.Add(heading, 0, 0); var assembly = Assembly.GetExecutingAssembly(); var version = AppInfo.Version; @@ -343,7 +384,7 @@ public sealed class StatusForm : Form { AutoSize = true, Dock = DockStyle.Top, - BackColor = UiTheme.SurfaceRaised, + BackColor = Color.Transparent, ColumnCount = 2, RowCount = 0, Padding = new Padding(0) @@ -361,7 +402,107 @@ public sealed class StatusForm : Form AddAboutRow(details, "配置目录", FormatAboutPath(ConfigService.ResolveConfigPath(AppPaths.BaseDirectory))); panel.Controls.Add(details, 0, 1); - return panel; + + panel.Controls.Add(new Label + { + Text = "可用状态字段", + AutoSize = true, + ForeColor = UiTheme.Text, + Font = new Font(Font.FontFamily, 12F, FontStyle.Bold), + Margin = new Padding(0, 18, 0, 12) + }, 0, 2); + + var fields = new TableLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Top, + BackColor = Color.Transparent, + ColumnCount = 2, + RowCount = 3, + Margin = new Padding(0) + }; + fields.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + fields.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + fields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + fields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + fields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + fields.Controls.Add(CreateAboutFieldCard( + "状态", + [ + "有效性", "战斗时间", "移动", "生命值", "一键辅助", "插入法术", + "队伍类型", "队伍人数", "首领战", "难度", "英雄天赋", "施法目标", + "施法技能", "敌人人数", "施法", "引导", "蓄力", "蓄力层数", + "酒池", "符文", "姿态", "救赎之魂1", "救赎之魂2", + ], + 150), 0, 0); + fields.Controls.Add(CreateAboutFieldCard( + "能量", + [ + "法力值", "怒气值", "集中值", "能量值", "符文", "符文能量", + "星界能量", "漩涡值", "狂乱值", "恶魔之怒", "痛苦值", + "连击点", "神圣能量", "精华能量", "灵魂碎片", "真气" + ], + 150), 1, 0); + fields.Controls.Add(CreateAboutFieldCard( + "配置开关", + ["爆发开关", "AOE开关", "输出模式", "爆发药水开关", "延迟"], + 92), 0, 1); + fields.Controls.Add(CreateAboutFieldCard( + "物品", + ["治疗药水", "魔法药水", "治疗石", "鲁莽药水", "圣光潜力"], + 92), 1, 1); + fields.Controls.Add(CreateAboutFieldCard( + "目标", + ["类型", "生命值", "距离", "施法", "施法可打断", "引导", "引导可打断"], + 104), 0, 2); + fields.Controls.Add(CreateAboutFieldCard( + "焦点", + ["类型", "生命值", "距离", "施法", "施法可打断", "引导", "引导可打断"], + 104), 1, 2); + + panel.Controls.Add(fields, 0, 3); + scrollHost.Controls.Add(panel); + return scrollHost; + } + + private static string GetEmbeddedResourceName(string resourcePath) + => $"{typeof(StatusForm).Namespace}.{resourcePath}"; + + private Control CreateAboutFieldCard(string title, IReadOnlyList items, int minimumHeight) + { + var card = new AboutFieldCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Padding = new Padding(16, 14, 16, 14), + Margin = new Padding(0, 0, 12, 12), + MinimumSize = new Size(0, minimumHeight) + }; + card.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); + card.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + card.Controls.Add(new Label + { + Text = title, + Dock = DockStyle.Fill, + ForeColor = UiTheme.Accent, + Font = new Font(Font.FontFamily, 10.5F, FontStyle.Bold), + TextAlign = ContentAlignment.MiddleLeft, + Margin = new Padding(0) + }, 0, 0); + card.Controls.Add(new Label + { + Text = string.Join(" · ", items), + Dock = DockStyle.Fill, + AutoSize = false, + ForeColor = UiTheme.Text, + Font = new Font(Font.FontFamily, 9.5F, FontStyle.Regular), + TextAlign = ContentAlignment.TopLeft, + Margin = new Padding(0, 6, 0, 0) + }, 0, 1); + return card; } private static string FormatAboutPath(string path) @@ -409,6 +550,96 @@ public sealed class StatusForm : Form }, 1, row); } + private sealed class WatermarkPanel : Panel + { + private readonly Bitmap? _watermark; + private readonly int _watermarkSize; + private readonly int _bottomMargin; + private readonly float _opacity; + + public WatermarkPanel(string resourceName, int watermarkSize, int bottomMargin, float opacity) + { + _watermarkSize = watermarkSize; + _bottomMargin = bottomMargin; + _opacity = Math.Clamp(opacity, 0F, 1F); + DoubleBuffered = true; + ResizeRedraw = true; + + using var stream = typeof(StatusForm).Assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + return; + } + + using var image = Image.FromStream(stream); + _watermark = new Bitmap(image); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + if (_watermark is null) + { + return; + } + + var bounds = new Rectangle( + (ClientSize.Width - _watermarkSize) / 2, + ClientSize.Height - _watermarkSize - _bottomMargin, + _watermarkSize, + _watermarkSize); + + using var attributes = new ImageAttributes(); + var colorMatrix = new ColorMatrix + { + Matrix33 = _opacity + }; + attributes.SetColorMatrix( + colorMatrix, + ColorMatrixFlag.Default, + ColorAdjustType.Bitmap); + + e.Graphics.DrawImage( + _watermark, + bounds, + 0, + 0, + _watermark.Width, + _watermark.Height, + GraphicsUnit.Pixel, + attributes); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _watermark?.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class AboutFieldCardPanel : TableLayoutPanel + { + private const int BackgroundOpacity = 166; + + public AboutFieldCardPanel() + { + SetStyle(ControlStyles.SupportsTransparentBackColor, true); + BackColor = Color.Transparent; + DoubleBuffered = true; + } + + protected override void OnPaintBackground(PaintEventArgs e) + { + base.OnPaintBackground(e); + using var background = new SolidBrush(Color.FromArgb(BackgroundOpacity, UiTheme.Field)); + e.Graphics.FillRectangle(background, e.ClipRectangle); + } + } + public void ShowOrActivate(RenderSnapshot? snapshot) { if (snapshot is not null)