diff --git a/App/Program.cs b/App/Program.cs index 25c2145b..9a590cb1 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -28,6 +28,18 @@ internal static class Program return; } - Application.Run(new MainForm(AppOptions.FromArgs(args))); + var options = AppOptions.FromArgs(args); + var baseDirectory = AppPaths.BaseDirectory; + var moduleStore = new ModuleStore(ModuleStore.ResolveModuleDirectory(baseDirectory)); + var triggerKeyState = new WindowsTriggerKeyState(); + var runtimeFactory = new ShigureRuntimeFactory(baseDirectory, moduleStore, triggerKeyState); + var runtimeSession = new RuntimeSessionCoordinator(runtimeFactory); + + Application.Run(new MainForm( + options, + baseDirectory, + moduleStore, + triggerKeyState, + runtimeSession)); } } diff --git a/App/RuntimeSessionCoordinator.cs b/App/RuntimeSessionCoordinator.cs new file mode 100644 index 00000000..62d07bc2 --- /dev/null +++ b/App/RuntimeSessionCoordinator.cs @@ -0,0 +1,248 @@ +namespace Shigure; + +/// +/// 串行管理运行时会话,确保并发的启动、重启和停止请求不会清理到错误的实例。 +/// +internal sealed class RuntimeSessionCoordinator : IAsyncDisposable +{ + private readonly IShigureRuntimeFactory _runtimeFactory; + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + private readonly object _requestSync = new(); + private RuntimeSession? _current; + private long _nextSessionId; + private long _stoppedSessionId; + private long _latestRequestVersion; + private bool _disposed; + + public RuntimeSessionCoordinator(IShigureRuntimeFactory runtimeFactory) + { + _runtimeFactory = runtimeFactory; + } + + public event Action? SnapshotUpdated; + + public event Action? RuntimeFailed; + + public event Action? RuntimeStopped; + + public bool HasSession => Volatile.Read(ref _current) is not null; + + public bool IsRunning + { + get + { + var current = Volatile.Read(ref _current); + return current is { RunTask.IsCompleted: false } + && Volatile.Read(ref _stoppedSessionId) != current.Id; + } + } + + public AppOptions? CurrentOptions => Volatile.Read(ref _current)?.Options; + + public long? CurrentSessionId => Volatile.Read(ref _current)?.Id; + + public Task StartAsync( + AppOptions options, + long requestVersion, + CancellationToken cancellationToken = default) + => ChangeSessionAsync(options, requestVersion, restart: false, cancellationToken); + + public Task RestartAsync( + AppOptions options, + long requestVersion, + CancellationToken cancellationToken = default) + => ChangeSessionAsync(options, requestVersion, restart: true, cancellationToken); + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await StopCurrentCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + public void ToggleEnabled() + { + var session = Volatile.Read(ref _current); + if (session is { RunTask.IsCompleted: false }) + { + session.Runtime.ToggleEnabled(); + } + } + + public void SetEnabled(bool enabled) + { + var session = Volatile.Read(ref _current); + if (session is { RunTask.IsCompleted: false }) + { + session.Runtime.SetEnabled(enabled); + } + } + + public async ValueTask DisposeAsync() + { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + { + return; + } + + await StopCurrentCoreAsync().ConfigureAwait(false); + _disposed = true; + } + finally + { + _lifecycleGate.Release(); + } + } + + private async Task ChangeSessionAsync( + AppOptions options, + long requestVersion, + bool restart, + CancellationToken cancellationToken) + { + RegisterLatestRequest(requestVersion); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (requestVersion != Volatile.Read(ref _latestRequestVersion)) + { + return; + } + + ObjectDisposedException.ThrowIf(_disposed, this); + + var current = _current; + if (!restart + && current is not null + && IsRunning + && current.Options == options) + { + return; + } + + if (current is not null) + { + await StopCurrentCoreAsync().ConfigureAwait(false); + } + + if (requestVersion != Volatile.Read(ref _latestRequestVersion)) + { + return; + } + + StartCore(options, requestVersion); + } + finally + { + _lifecycleGate.Release(); + } + } + + private void RegisterLatestRequest(long requestVersion) + { + lock (_requestSync) + { + if (requestVersion > _latestRequestVersion) + { + Volatile.Write(ref _latestRequestVersion, requestVersion); + } + } + } + + private void StartCore(AppOptions options, long requestVersion) + { + var runtime = _runtimeFactory.Create(options); + var cancellation = new CancellationTokenSource(); + var sessionId = Interlocked.Increment(ref _nextSessionId); + Action snapshotHandler = snapshot => SnapshotUpdated?.Invoke(sessionId, snapshot); + + lock (_requestSync) + { + if (requestVersion != _latestRequestVersion) + { + cancellation.Dispose(); + return; + } + + runtime.SnapshotUpdated += snapshotHandler; + try + { + var runTask = Task.Run(() => RunRuntimeAsync(sessionId, runtime, cancellation.Token)); + Volatile.Write( + ref _current, + new RuntimeSession(sessionId, options, runtime, cancellation, runTask, snapshotHandler)); + } + catch + { + runtime.SnapshotUpdated -= snapshotHandler; + cancellation.Dispose(); + throw; + } + } + } + + private async Task StopCurrentCoreAsync() + { + var session = _current; + if (session is null) + { + return; + } + + session.Cancellation.Cancel(); + try + { + await session.RunTask.ConfigureAwait(false); + } + finally + { + session.Runtime.SnapshotUpdated -= session.SnapshotHandler; + session.Cancellation.Dispose(); + if (ReferenceEquals(Volatile.Read(ref _current), session)) + { + Volatile.Write(ref _current, null); + } + } + } + + private async Task RunRuntimeAsync( + long sessionId, + ShigureRuntime runtime, + CancellationToken cancellationToken) + { + try + { + await runtime.RunAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 正常停止路径。 + } + catch (Exception ex) + { + RuntimeFailed?.Invoke(sessionId, ex); + } + finally + { + Volatile.Write(ref _stoppedSessionId, sessionId); + RuntimeStopped?.Invoke(sessionId); + } + } + + private sealed record RuntimeSession( + long Id, + AppOptions Options, + ShigureRuntime Runtime, + CancellationTokenSource Cancellation, + Task RunTask, + Action SnapshotHandler); +} diff --git a/App/ShigureRuntimeFactory.cs b/App/ShigureRuntimeFactory.cs new file mode 100644 index 00000000..c24d5a11 --- /dev/null +++ b/App/ShigureRuntimeFactory.cs @@ -0,0 +1,42 @@ +namespace Shigure; + +internal interface IShigureRuntimeFactory +{ + ShigureRuntime Create(AppOptions options); +} + +internal sealed class ShigureRuntimeFactory : IShigureRuntimeFactory +{ + private readonly string _baseDirectory; + private readonly ModuleStore _moduleStore; + private readonly ITriggerKeyState _triggerKeyState; + private readonly TimeProvider _timeProvider; + + public ShigureRuntimeFactory( + string baseDirectory, + ModuleStore moduleStore, + ITriggerKeyState triggerKeyState, + TimeProvider? timeProvider = null) + { + _baseDirectory = baseDirectory; + _moduleStore = moduleStore; + _triggerKeyState = triggerKeyState; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public ShigureRuntime Create(AppOptions options) + { + _moduleStore.Reload(); + var config = ConfigService.LoadFromBaseDirectory(_baseDirectory); + var keymap = new KeymapService(_baseDirectory, config); + + return new ShigureRuntime( + options, + new PixelScanner(options.WindowTitle), + new StateBuilder(config), + new KeySender(options.WindowTitle), + _triggerKeyState, + new LogicRegistry(keymap, _moduleStore, options.ModuleId), + _timeProvider); + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 4c748b62..649a1fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ PixelScanner.ScanScreenData() Runtime/PixelScanner.cs 截屏读像 ↓ StateBuilder.Build(rowData, barData) Runtime/StateBuilder.cs 按 config 把像素翻译成 GameState 字段 ↓ GameState (Runtime/GameState.cs: Values / Spells / Group) -LogicRegistry.Run(classId, specId, ...) Modules/LogicRegistry.cs +LogicRegistry.Evaluate(classId, specId, ...) Modules/LogicRegistry.cs ├─ 命中模块 → ModuleLogic.Run(module, state, keymap) ├─ 否则该职业注册了 IClassLogic → 它 └─ 否则 DefaultClassLogic @@ -45,13 +45,15 @@ 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 刷新。 +应用组合根在 [App/Program.cs](App/Program.cs):统一创建 `ModuleStore`、Win32 触发键适配器、`ShigureRuntimeFactory` 与 `RuntimeSessionCoordinator`。后者串行管理运行时的启动/重启/停止,避免 UI 的并发设置事件互相清理会话。[UI/MainForm.cs](UI/MainForm.cs) 是无边框置顶浮动条,把运行意图和生命周期交给协调器,并展示 [UI/StatusForm.cs](UI/StatusForm.cs)(九页签设置窗口:通用/配置/宏/模块/状态/队伍/逻辑/日志/关于)。运行时通过 `SnapshotUpdated` 事件推送 `RenderSnapshot` 给 UI 刷新。 + +`ShigureRuntime` 不创建具体 I/O 依赖;生产依赖由 `ShigureRuntimeFactory` 注入,窄端口定义在 [Runtime/RuntimeDependencies.cs](Runtime/RuntimeDependencies.cs)。外部启停请求先进入命令队列,只有 `RunAsync` 循环能修改运行状态。改动运行时依赖或生命周期时保留这两个约束。 ### 目录约定 ``` -App/ 入口、启动参数、随机重启 -Runtime/ 扫描、状态构建、主循环、快照 +App/ 入口、启动参数、随机重启、依赖组装、运行时会话协调 +Runtime/ 扫描、状态构建、主循环、运行时端口、快照 Modules/ 模块模型/存储/匹配/规则执行、条件求值(FormulaEvaluator)、字段目录、职业逻辑 Input/ keymap 读取、按键发送、Win32 API Infrastructure/ 配置读取(ConfigService)、JSON 辅助、UI 缓存、路径、Fuyutsui 插件文件读写 @@ -62,6 +64,7 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c ## 模块解析(改逻辑前必读) - 模块以 `module/模块名.json` 平铺保存,**文件名取自模块名,故模块名不可重复**;加载递归扫描子目录。模型在 [Modules/ModuleStore.cs](Modules/ModuleStore.cs)(`ModuleDefinition`/`ModuleMatch`/`ModuleRule`/`ModuleUnit`/`ModuleCountField`/`ModuleValueAdjustment`)。`RecommendedTalent` 是 `ModuleDefinition` 上的纯展示字段,不参与匹配(`ModuleMatch.Specificity` 不计入)。 +- `ModuleStore` 的 `Reload`/`Save`/`Delete` 会在同一个门锁内串行完整文件事务与内存快照更新;`Save` 通过同目录临时文件提交,重命名失败会回滚新文件。编辑器写入不要绕过它,避免运行时重载读到半次操作。 - 选择优先级:`ModuleStore.FindSelectedOrBestMatch` —— 先用 UI/参数选定的 `ModuleId`;否则取 `Match` 命中字段最多者(`ModuleMatch.Specificity` 越大越优先),并列按名称。`Match` 字段留空 = 任意。`PartyType` 数字会归一化为 `"1-40"`。 - 动态单位/数量/动态数值的语义见 [README.md](README.md#动态单位与数量字段);列表与编辑器的人类可读摘要统一走 [UI/UnitSummary.cs](UI/UnitSummary.cs)`.Describe(...)`(单一来源,勿再复制一份描述逻辑)。 @@ -100,6 +103,7 @@ config/ keymap/ module/ 运行时 JSON 数据(构建时复制到输出, 见 .c - [UI/ClassConfigEditorControl.cs](UI/ClassConfigEditorControl.cs):左侧职业列表 + 右侧按专精切换的四页编辑器(状态/光环/法术/队伍),状态字段用 `ClassStateCatalog` 驱动的 `ComboBoxColumn`。 - [UI/ClassMacrosEditorControl.cs](UI/ClassMacrosEditorControl.cs):左侧职业列表 + 右侧三页编辑器(动态宏/静态宏/特殊宏),偏移提示显示槽位编号计算。 - 两个编辑器均接受 `Func` 定位器 + `Func` 保存回调,由 `MainForm` 在构造时注入。保存流程:编辑器调 `Store.Save()` → 回调 `MainForm.UpdateConfigFromAddonAsync()` → 重新生成 config/keymap → 重启运行时。 +- `UpdateConfigFromAddonAsync` 的多个入口通过任务尾队列串行执行;运行时重启会等待该队列稳定,主窗口关闭也会等待正在写盘的转换完成。新增同步入口必须继续走这条队列。 ## UI 约定 diff --git a/Input/KeySender.cs b/Input/KeySender.cs index 545aeea0..944d9e77 100644 --- a/Input/KeySender.cs +++ b/Input/KeySender.cs @@ -1,66 +1,9 @@ namespace Shigure; -public sealed class KeySender +public sealed class KeySender : IRuntimeKeyOutput { private readonly string _windowTitle; - private static readonly Dictionary Vk = new(StringComparer.OrdinalIgnoreCase) - { - ["SHIFT"] = 0x10, - ["CONTROL"] = 0x11, - ["CTRL"] = 0x11, - ["MENU"] = 0x12, - ["ALT"] = 0x12, - ["XBUTTON1"] = 0x05, - ["X1"] = 0x05, - ["MOUSE4"] = 0x05, - ["XBUTTON2"] = 0x06, - ["X2"] = 0x06, - ["MOUSE5"] = 0x06, - ["F1"] = 0x70, - ["F2"] = 0x71, - ["F3"] = 0x72, - ["F4"] = 0x73, - ["F5"] = 0x74, - ["F6"] = 0x75, - ["F7"] = 0x76, - ["F8"] = 0x77, - ["F9"] = 0x78, - ["F10"] = 0x79, - ["F11"] = 0x7A, - ["F12"] = 0x7B, - ["NUMPAD0"] = 0x60, - ["NUMPAD1"] = 0x61, - ["NUMPAD2"] = 0x62, - ["NUMPAD3"] = 0x63, - ["NUMPAD4"] = 0x64, - ["NUMPAD5"] = 0x65, - ["NUMPAD6"] = 0x66, - ["NUMPAD7"] = 0x67, - ["NUMPAD8"] = 0x68, - ["NUMPAD9"] = 0x69, - ["NUMPADDECIMAL"] = 0x6E, - ["NUMPADPLUS"] = 0x6B, - ["NUMPADMINUS"] = 0x6D, - ["NUMPADMULTIPLY"] = 0x6A, - ["NUMPADDIVIDE"] = 0x6F - }; - - private static readonly Dictionary CharVk = new() - { - [","] = 0xBC, - ["."] = 0xBE, - ["/"] = 0xBF, - [";"] = 0xBA, - ["'"] = 0xDE, - ["["] = 0xDB, - ["]"] = 0xDD, - ["="] = 0xBB, - ["-"] = 0xBD, - ["`"] = 0xC0, - ["\\"] = 0xDC - }; - public KeySender(string windowTitle) { _windowTitle = windowTitle; @@ -70,14 +13,24 @@ public sealed class KeySender public bool Send(string hotkey) { - LastFailureReason = null; + var result = SendCore(hotkey); + LastFailureReason = result.FailureReason; + return result.Succeeded; + } + + public static int? GetVk(string keyName) => WindowsVirtualKeyMap.Resolve(keyName); + + KeySendResult IRuntimeKeyOutput.Send(string hotkey) => SendCore(hotkey); + + private KeySendResult SendCore(string hotkey) + { var (mods, mainKey) = ParseHotkey(hotkey); if (mainKey is null) { return Fail($"无法解析按键“{hotkey}”"); } - var vkMain = GetVk(mainKey); + var vkMain = WindowsVirtualKeyMap.Resolve(mainKey); if (vkMain is null) { return Fail($"无法识别主键“{mainKey}”"); @@ -89,9 +42,9 @@ public sealed class KeySender return Fail($"未找到目标窗口“{_windowTitle}”"); } - // ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在 Vk 表里且映射到互异 VK, - // 故 GetVk 不会为 null、结果天然去重。 - var modVks = mods.Select(m => GetVk(m)!.Value).ToList(); + // ParseHotkey 只产出去重后的 CTRL/ALT/SHIFT, 三者都在虚拟键表里且映射到互异 VK, + // 故 Resolve 不会为 null、结果天然去重。 + var modVks = mods.Select(m => WindowsVirtualKeyMap.Resolve(m)!.Value).ToList(); var succeeded = true; var firstError = 0; @@ -122,7 +75,7 @@ public sealed class KeySender if (succeeded) { - return true; + return KeySendResult.Success; } return Fail(firstError == 5 @@ -130,36 +83,6 @@ public sealed class KeySender : $"向目标窗口发送按键失败,Win32 错误码: {firstError}"); } - public static int? GetVk(string keyName) - { - if (string.IsNullOrWhiteSpace(keyName)) - { - return null; - } - - var key = keyName.Trim(); - if (Vk.TryGetValue(key, out var mapped)) - { - return mapped; - } - - if (key.Length == 1) - { - if (CharVk.TryGetValue(key, out var charMapped)) - { - return charMapped; - } - - var scan = NativeMethods.VkKeyScanW(key[0]); - if (scan != -1) - { - return scan & 0xFF; - } - } - - return null; - } - private static (List Mods, string? MainKey) ParseHotkey(string hotkey) { if (string.IsNullOrWhiteSpace(hotkey)) @@ -194,11 +117,7 @@ public sealed class KeySender return (mods, mainKey); } - private bool Fail(string reason) - { - LastFailureReason = reason; - return false; - } + private static KeySendResult Fail(string reason) => KeySendResult.Failure(reason); private static bool Post(nint hwnd, int keyCode, bool keyUp, out int error) { diff --git a/Input/KeymapService.cs b/Input/KeymapService.cs index bae0730f..d25923aa 100644 --- a/Input/KeymapService.cs +++ b/Input/KeymapService.cs @@ -3,7 +3,7 @@ using System.Text.Json.Nodes; namespace Shigure; -public sealed class KeymapService +public sealed class KeymapService : IKeymapResolver { private readonly string _baseDirectory; private readonly ConfigService _config; diff --git a/Input/WindowsTriggerKeyState.cs b/Input/WindowsTriggerKeyState.cs new file mode 100644 index 00000000..5c003173 --- /dev/null +++ b/Input/WindowsTriggerKeyState.cs @@ -0,0 +1,8 @@ +namespace Shigure; + +internal sealed class WindowsTriggerKeyState : ITriggerKeyState +{ + public int? ResolveVirtualKey(string keyName) => WindowsVirtualKeyMap.Resolve(keyName); + + public bool IsPressed(int virtualKey) => NativeMethods.IsKeyDown(virtualKey); +} diff --git a/Input/WindowsVirtualKeyMap.cs b/Input/WindowsVirtualKeyMap.cs new file mode 100644 index 00000000..6291ee55 --- /dev/null +++ b/Input/WindowsVirtualKeyMap.cs @@ -0,0 +1,88 @@ +namespace Shigure; + +internal static class WindowsVirtualKeyMap +{ + private static readonly Dictionary NamedKeys = new(StringComparer.OrdinalIgnoreCase) + { + ["SHIFT"] = 0x10, + ["CONTROL"] = 0x11, + ["CTRL"] = 0x11, + ["MENU"] = 0x12, + ["ALT"] = 0x12, + ["XBUTTON1"] = 0x05, + ["X1"] = 0x05, + ["MOUSE4"] = 0x05, + ["XBUTTON2"] = 0x06, + ["X2"] = 0x06, + ["MOUSE5"] = 0x06, + ["F1"] = 0x70, + ["F2"] = 0x71, + ["F3"] = 0x72, + ["F4"] = 0x73, + ["F5"] = 0x74, + ["F6"] = 0x75, + ["F7"] = 0x76, + ["F8"] = 0x77, + ["F9"] = 0x78, + ["F10"] = 0x79, + ["F11"] = 0x7A, + ["F12"] = 0x7B, + ["NUMPAD0"] = 0x60, + ["NUMPAD1"] = 0x61, + ["NUMPAD2"] = 0x62, + ["NUMPAD3"] = 0x63, + ["NUMPAD4"] = 0x64, + ["NUMPAD5"] = 0x65, + ["NUMPAD6"] = 0x66, + ["NUMPAD7"] = 0x67, + ["NUMPAD8"] = 0x68, + ["NUMPAD9"] = 0x69, + ["NUMPADDECIMAL"] = 0x6E, + ["NUMPADPLUS"] = 0x6B, + ["NUMPADMINUS"] = 0x6D, + ["NUMPADMULTIPLY"] = 0x6A, + ["NUMPADDIVIDE"] = 0x6F + }; + + private static readonly Dictionary CharacterKeys = new() + { + [","] = 0xBC, + ["."] = 0xBE, + ["/"] = 0xBF, + [";"] = 0xBA, + ["'"] = 0xDE, + ["["] = 0xDB, + ["]"] = 0xDD, + ["="] = 0xBB, + ["-"] = 0xBD, + ["`"] = 0xC0, + ["\\"] = 0xDC + }; + + public static int? Resolve(string keyName) + { + if (string.IsNullOrWhiteSpace(keyName)) + { + return null; + } + + var key = keyName.Trim(); + if (NamedKeys.TryGetValue(key, out var mapped)) + { + return mapped; + } + + if (key.Length != 1) + { + return null; + } + + if (CharacterKeys.TryGetValue(key, out var characterMapped)) + { + return characterMapped; + } + + var scan = NativeMethods.VkKeyScanW(key[0]); + return scan == -1 ? null : scan & 0xFF; + } +} diff --git a/Modules/IKeymapResolver.cs b/Modules/IKeymapResolver.cs new file mode 100644 index 00000000..833d1bef --- /dev/null +++ b/Modules/IKeymapResolver.cs @@ -0,0 +1,12 @@ +namespace Shigure; + +public interface IKeymapResolver +{ + void SelectForClass(int? classId, int? specId); + + string? GetHotkey(int? unit, string spell); + + IReadOnlyDictionary GetCurrentFailedSpells(); + + IReadOnlyDictionary GetCurrentOneKeySpells(); +} diff --git a/Modules/LogicRegistry.cs b/Modules/LogicRegistry.cs index 2bf6248c..b2956168 100644 --- a/Modules/LogicRegistry.cs +++ b/Modules/LogicRegistry.cs @@ -1,61 +1,59 @@ namespace Shigure; -public sealed record LogicDecision( - string? Hotkey, - string Step, - IReadOnlyDictionary UnitInfo, - string? ModuleName = null, - int DelayMs = 0, - string? RateLimitKey = null, - int LogicDelayMs = 0); - public interface IClassLogic { LogicDecision Run(GameState state, string? specName); } -public sealed class LogicRegistry +public sealed class LogicRegistry : IRuntimeLogic { - private readonly Dictionary _logicByClass = new(); + private readonly Dictionary _logicByClass; private readonly IClassLogic _defaultLogic; - private readonly KeymapService _keymap; + private readonly IKeymapResolver _keymap; private readonly ModuleStore _moduleStore; private readonly string? _selectedModuleId; - public LogicRegistry(KeymapService keymap, ModuleStore moduleStore, string? selectedModuleId) + public LogicRegistry( + IKeymapResolver keymap, + ModuleStore moduleStore, + string? selectedModuleId, + IEnumerable>? classLogics = null) { _keymap = keymap; _moduleStore = moduleStore; _selectedModuleId = string.IsNullOrWhiteSpace(selectedModuleId) ? null : selectedModuleId.Trim(); _defaultLogic = new DefaultClassLogic(keymap); + _logicByClass = classLogics?.ToDictionary(pair => pair.Key, pair => pair.Value) ?? new(); } - public LogicDecision Run(int? classId, int? specId, string? specName, GameState state) + public LogicEvaluation Evaluate( + int? classId, + int? specId, + string? specName, + GameState state, + bool runLogic) { + _keymap.SelectForClass(classId, specId); var module = FindModule(classId, specId, state); if (module is not null) { - return ModuleLogic.Run(module, state, _keymap); + ModuleLogic.ResolveDynamicFields(module, state); + return new LogicEvaluation( + module.Name, + runLogic ? ModuleLogic.Run(module, state, _keymap) : null); + } + + if (!runLogic) + { + return new LogicEvaluation(null, null); } if (classId is not null && _logicByClass.TryGetValue(classId.Value, out var logic)) { - return logic.Run(state, specName); + return new LogicEvaluation(null, logic.Run(state, specName)); } - return _defaultLogic.Run(state, specName); - } - - public string? ResolveDynamicState(int? classId, int? specId, GameState state) - { - var module = FindModule(classId, specId, state); - if (module is null) - { - return null; - } - - ModuleLogic.ResolveDynamicFields(module, state); - return module.Name; + return new LogicEvaluation(null, _defaultLogic.Run(state, specName)); } private ModuleDefinition? FindModule(int? classId, int? specId, GameState state) @@ -71,9 +69,9 @@ public sealed class LogicRegistry public sealed class DefaultClassLogic : IClassLogic { - private readonly KeymapService _keymap; + private readonly IKeymapResolver _keymap; - public DefaultClassLogic(KeymapService keymap) + public DefaultClassLogic(IKeymapResolver keymap) { _keymap = keymap; } diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs index 1686fafb..2a965c62 100644 --- a/Modules/ModuleStore.cs +++ b/Modules/ModuleStore.cs @@ -305,30 +305,30 @@ public sealed class ModuleStore public void Reload() { - Directory.CreateDirectory(ModuleDirectory); - var loaded = new List(); - foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories)) - { - try - { - var module = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOptions); - if (module is null) - { - continue; - } - - Normalize(module); - module.FilePath = file; - loaded.Add(module); - } - catch - { - // 单个模块损坏时跳过,避免影响其它模块加载。 - } - } - lock (_gate) { + Directory.CreateDirectory(ModuleDirectory); + var loaded = new List(); + foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories)) + { + try + { + var module = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOptions); + if (module is null) + { + continue; + } + + Normalize(module); + module.FilePath = file; + loaded.Add(module); + } + catch + { + // 单个模块损坏时跳过,避免影响其它模块加载。 + } + } + _modules = SortModules(loaded).ToList(); } } @@ -375,47 +375,65 @@ public sealed class ModuleStore { throw new InvalidOperationException($"模块名称“{module.Name}”已存在。"); } - } - if (File.Exists(path) - && (string.IsNullOrWhiteSpace(oldPath) || !PathsEqual(oldPath, path))) - { - throw new InvalidOperationException($"模块文件“{Path.GetFileName(path)}”已存在,请使用其他名称。"); - } + if (File.Exists(path) + && (string.IsNullOrWhiteSpace(oldPath) || !PathsEqual(oldPath, path))) + { + throw new InvalidOperationException($"模块文件“{Path.GetFileName(path)}”已存在,请使用其他名称。"); + } - if (!string.IsNullOrWhiteSpace(oldPath) - && IsInsideModuleDirectory(oldPath) - && !PathsEqual(oldPath, path) - && File.Exists(oldPath)) - { - File.Delete(oldPath); - } + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + WriteFileAtomically(path, JsonSerializer.Serialize(module, JsonOptions)); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, JsonSerializer.Serialize(module, JsonOptions)); - module.FilePath = path; + if (!string.IsNullOrWhiteSpace(oldPath) + && IsInsideModuleDirectory(oldPath) + && !PathsEqual(oldPath, path) + && File.Exists(oldPath)) + { + try + { + File.Delete(oldPath); + } + catch (Exception deleteError) + { + try + { + File.Delete(path); + } + catch (Exception rollbackError) + { + throw new AggregateException( + "模块已写入新文件,但旧文件删除失败,且无法回滚新文件。", + deleteError, + rollbackError); + } - lock (_gate) - { + throw; + } + } + + module.FilePath = path; _modules.RemoveAll(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(existing.FilePath, path, StringComparison.OrdinalIgnoreCase)); _modules.Add(module.Clone()); _modules = SortModules(_modules).ToList(); - } - return module.Clone(); + return module.Clone(); + } } public void Delete(ModuleDefinition module) { - if (!string.IsNullOrWhiteSpace(module.FilePath) && IsInsideModuleDirectory(module.FilePath) && File.Exists(module.FilePath)) - { - File.Delete(module.FilePath); - } - lock (_gate) { + if (!string.IsNullOrWhiteSpace(module.FilePath) + && IsInsideModuleDirectory(module.FilePath) + && File.Exists(module.FilePath)) + { + File.Delete(module.FilePath); + } + _modules.RemoveAll(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(existing.FilePath, module.FilePath, StringComparison.OrdinalIgnoreCase)); @@ -442,6 +460,43 @@ public sealed class ModuleStore } } + private static void WriteFileAtomically(string path, string content) + { + var directory = Path.GetDirectoryName(path)!; + var tempPath = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + + try + { + File.WriteAllText(tempPath, content); + if (File.Exists(path)) + { + File.Move(tempPath, path, overwrite: true); + } + else + { + File.Move(tempPath, path); + } + } + catch + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // 保留原始写入异常;残留临时文件不会被模块扫描加载。 + } + + throw; + } + } + private static IEnumerable SortModules(IEnumerable modules) { return modules @@ -617,7 +672,7 @@ public sealed class ModuleStore public static class ModuleLogic { - public static LogicDecision Run(ModuleDefinition module, GameState state, KeymapService keymap) + public static LogicDecision Run(ModuleDefinition module, GameState state, IKeymapResolver keymap) { var info = CreateInfo(module, state); var unitSlots = ResolveDynamicFields(module, state); diff --git a/UI/UnitSelector.cs b/Modules/UnitSelector.cs similarity index 100% rename from UI/UnitSelector.cs rename to Modules/UnitSelector.cs diff --git a/Runtime/PixelScanner.cs b/Runtime/PixelScanner.cs index ddaf305e..541b4cb2 100644 --- a/Runtime/PixelScanner.cs +++ b/Runtime/PixelScanner.cs @@ -10,7 +10,7 @@ public sealed record ScreenScanResult( IReadOnlyDictionary HealAbsorbData, string? FailureReason); -public sealed class PixelScanner +public sealed class PixelScanner : IRuntimeScreenScanner { private const int TopRowBlockCount = 510; private const int TopRowFirstSchemeMax = 255; diff --git a/Runtime/RuntimeDependencies.cs b/Runtime/RuntimeDependencies.cs new file mode 100644 index 00000000..2133163a --- /dev/null +++ b/Runtime/RuntimeDependencies.cs @@ -0,0 +1,54 @@ +namespace Shigure; + +public sealed record LogicDecision( + string? Hotkey, + string Step, + IReadOnlyDictionary UnitInfo, + string? ModuleName = null, + int DelayMs = 0, + string? RateLimitKey = null, + int LogicDelayMs = 0); + +internal interface IRuntimeScreenScanner +{ + ScreenScanResult ScanScreenData(); +} + +internal interface IRuntimeStateBuilder +{ + GameState Build( + IReadOnlyDictionary rowData, + IReadOnlyDictionary barData, + IReadOnlyDictionary? healAbsorbData = null); +} + +internal interface IRuntimeLogic +{ + LogicEvaluation Evaluate( + int? classId, + int? specId, + string? specName, + GameState state, + bool runLogic); +} + +public sealed record LogicEvaluation(string? ModuleName, LogicDecision? Decision); + +internal interface IRuntimeKeyOutput +{ + KeySendResult Send(string hotkey); +} + +public readonly record struct KeySendResult(bool Succeeded, string? FailureReason) +{ + public static KeySendResult Success { get; } = new(true, null); + + public static KeySendResult Failure(string reason) => new(false, reason); +} + +internal interface ITriggerKeyState +{ + int? ResolveVirtualKey(string keyName); + + bool IsPressed(int virtualKey); +} diff --git a/Runtime/ShigureRuntime.cs b/Runtime/ShigureRuntime.cs index 36557070..ba756945 100644 --- a/Runtime/ShigureRuntime.cs +++ b/Runtime/ShigureRuntime.cs @@ -5,12 +5,13 @@ namespace Shigure; public sealed class ShigureRuntime { private readonly AppOptions _options; - private readonly ConfigService _config; - private readonly KeymapService _keymap; - private readonly PixelScanner _scanner; - private readonly StateBuilder _stateBuilder; - private readonly KeySender _keySender; - private readonly LogicRegistry _logicRegistry; + private readonly IRuntimeScreenScanner _scanner; + private readonly IRuntimeStateBuilder _stateBuilder; + private readonly IRuntimeKeyOutput _keySender; + private readonly ITriggerKeyState _triggerKeyState; + private readonly IRuntimeLogic _logic; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentQueue _pendingCommands = new(); private GameState? _state; private string? _className; @@ -23,18 +24,25 @@ public sealed class ShigureRuntime private IReadOnlyDictionary _unitInfo = new Dictionary(); private bool _enabled; private bool _clickPending; - private readonly ConcurrentDictionary _lastRuleSentAt = new(StringComparer.Ordinal); + private readonly Dictionary _lastRuleSentAt = new(StringComparer.Ordinal); private DateTimeOffset _logicPausedUntil = DateTimeOffset.MinValue; - public ShigureRuntime(string baseDirectory, AppOptions options, ModuleStore moduleStore) + internal ShigureRuntime( + AppOptions options, + IRuntimeScreenScanner scanner, + IRuntimeStateBuilder stateBuilder, + IRuntimeKeyOutput keySender, + ITriggerKeyState triggerKeyState, + IRuntimeLogic logic, + TimeProvider timeProvider) { _options = options; - _config = ConfigService.LoadFromBaseDirectory(baseDirectory); - _keymap = new KeymapService(baseDirectory, _config); - _scanner = new PixelScanner(options.WindowTitle); - _stateBuilder = new StateBuilder(_config); - _keySender = new KeySender(options.WindowTitle); - _logicRegistry = new LogicRegistry(_keymap, moduleStore, options.ModuleId); + _scanner = scanner; + _stateBuilder = stateBuilder; + _keySender = keySender; + _triggerKeyState = triggerKeyState; + _logic = logic; + _timeProvider = timeProvider; } public event Action? SnapshotUpdated; @@ -42,6 +50,16 @@ public sealed class ShigureRuntime public AppOptions Options => _options; public void SetEnabled(bool enabled) + { + _pendingCommands.Enqueue(RuntimeCommand.SetEnabled(enabled)); + } + + public void ToggleEnabled() + { + _pendingCommands.Enqueue(RuntimeCommand.ToggleEnabled()); + } + + private void ApplyEnabled(bool enabled) { _enabled = enabled; _clickPending = false; @@ -55,9 +73,25 @@ public sealed class ShigureRuntime PublishSnapshot(); } + private void DrainPendingCommands() + { + while (_pendingCommands.TryDequeue(out var command)) + { + switch (command.Kind) + { + case RuntimeCommandKind.SetEnabled: + ApplyEnabled(command.Enabled); + break; + case RuntimeCommandKind.ToggleEnabled: + ApplyEnabled(!_enabled); + break; + } + } + } + public async Task RunAsync(CancellationToken cancellationToken = default) { - var toggleVk = KeySender.GetVk(_options.ToggleKey); + var toggleVk = _triggerKeyState.ResolveVirtualKey(_options.ToggleKey); if (toggleVk is null) { _currentStep = $"无法识别触发键: {_options.ToggleKey}"; @@ -76,8 +110,9 @@ public sealed class ShigureRuntime { while (!cancellationToken.IsCancellationRequested) { - var now = DateTimeOffset.UtcNow; - var pressed = NativeMethods.IsKeyDown(toggleVk.Value); + DrainPendingCommands(); + var now = _timeProvider.GetUtcNow(); + var pressed = _triggerKeyState.IsPressed(toggleVk.Value); var rising = pressed && !previousPressed && now - lastToggleAt >= TimeSpan.FromMilliseconds(120); var falling = !pressed && previousPressed; @@ -115,7 +150,7 @@ public sealed class ShigureRuntime PublishSnapshot(); } - await Task.Delay(25, cancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(25), _timeProvider, cancellationToken); } } finally @@ -181,8 +216,6 @@ public sealed class ShigureRuntime _classId = _state.GetInt("职业"); _specId = _state.GetInt("专精"); (_className, _specName) = ClassNames.GetClassAndSpecName(_classId, _specId); - _keymap.SelectForClass(_classId, _specId); - if (!_state.GetBool("有效性")) { _moduleName = null; @@ -191,7 +224,8 @@ public sealed class ShigureRuntime return; } - _moduleName = _logicRegistry.ResolveDynamicState(_classId, _specId, _state); + var evaluation = _logic.Evaluate(_classId, _specId, _specName, _state, _enabled); + _moduleName = evaluation.ModuleName; if (!_enabled) { @@ -199,7 +233,14 @@ public sealed class ShigureRuntime return; } - var decision = _logicRegistry.Run(_classId, _specId, _specName, _state); + var decision = evaluation.Decision; + if (decision is null) + { + _currentStep = "逻辑未返回决策"; + _unitInfo = new Dictionary(); + return; + } + _currentStep = decision.Step; _unitInfo = decision.UnitInfo; _moduleName = decision.ModuleName; @@ -207,10 +248,13 @@ public sealed class ShigureRuntime if (_options.Mode == SendMode.Click) { if (_clickPending - && !string.IsNullOrWhiteSpace(decision.Hotkey) - && CanSend(decision, DateTimeOffset.UtcNow)) + && !string.IsNullOrWhiteSpace(decision.Hotkey)) { - SendAndPauseLogic(decision); + var sendAttemptAt = _timeProvider.GetUtcNow(); + if (CanSend(decision, sendAttemptAt)) + { + SendAndPauseLogic(decision); + } } _enabled = false; @@ -218,30 +262,36 @@ public sealed class ShigureRuntime return; } - if (!string.IsNullOrWhiteSpace(decision.Hotkey) - && CanSend(decision, DateTimeOffset.UtcNow)) + if (!string.IsNullOrWhiteSpace(decision.Hotkey)) { - SendAndPauseLogic(decision); + var sendAttemptAt = _timeProvider.GetUtcNow(); + if (CanSend(decision, sendAttemptAt)) + { + SendAndPauseLogic(decision); + } } } private void SendAndPauseLogic(LogicDecision decision) { - if (!_keySender.Send(decision.Hotkey!)) + var sendResult = _keySender.Send(decision.Hotkey!); + if (!sendResult.Succeeded) { var info = _unitInfo.ToDictionary( entry => entry.Key, entry => entry.Value, StringComparer.Ordinal); - info["发送失败"] = _keySender.LastFailureReason ?? "未知原因"; + info["发送失败"] = sendResult.FailureReason ?? "未知原因"; _unitInfo = info; _currentStep = $"{decision.Step}(按键发送失败)"; return; } + var sentAt = _timeProvider.GetUtcNow(); + RecordSent(decision, sentAt); if (decision.LogicDelayMs > 0) { - _logicPausedUntil = DateTimeOffset.UtcNow.AddMilliseconds(decision.LogicDelayMs); + _logicPausedUntil = sentAt.AddMilliseconds(decision.LogicDelayMs); } } @@ -261,10 +311,22 @@ public sealed class ShigureRuntime return false; } - _lastRuleSentAt[key] = now; return true; } + private void RecordSent(LogicDecision decision, DateTimeOffset now) + { + if (decision.DelayMs <= 0) + { + return; + } + + var key = string.IsNullOrWhiteSpace(decision.RateLimitKey) + ? decision.Hotkey ?? string.Empty + : decision.RateLimitKey; + _lastRuleSentAt[key] = now; + } + private void PublishSnapshot() { SnapshotUpdated?.Invoke(new RenderSnapshot( @@ -353,4 +415,19 @@ public sealed class ShigureRuntime _ => value.ToString() ?? "-" }; } + + private enum RuntimeCommandKind + { + SetEnabled, + ToggleEnabled + } + + private readonly record struct RuntimeCommand(RuntimeCommandKind Kind, bool Enabled) + { + public static RuntimeCommand SetEnabled(bool enabled) + => new(RuntimeCommandKind.SetEnabled, enabled); + + public static RuntimeCommand ToggleEnabled() + => new(RuntimeCommandKind.ToggleEnabled, false); + } } diff --git a/Runtime/StateBuilder.cs b/Runtime/StateBuilder.cs index f748c79c..6b93c01f 100644 --- a/Runtime/StateBuilder.cs +++ b/Runtime/StateBuilder.cs @@ -2,7 +2,7 @@ namespace Shigure; -public sealed class StateBuilder +public sealed class StateBuilder : IRuntimeStateBuilder { private readonly ConfigService _config; diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 39445041..d068145c 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -1614,12 +1614,20 @@ public sealed class ClassConfigEditorControl : UserControl _dirty = false; _statusLabel.Text = "已写入 Lua,正在更新配置…"; await _updateConfigAsync(); + if (IsDisposed) + { + return; + } + _statusLabel.Text = "已保存并更新配置"; } catch (Exception ex) { - MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - _statusLabel.Text = $"保存失败: {ex.Message}"; + if (!IsDisposed) + { + MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + _statusLabel.Text = $"保存失败: {ex.Message}"; + } } } diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index cb014e25..f7694b0a 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -941,13 +941,21 @@ public sealed class ClassMacrosEditorControl : UserControl _dirty = false; _statusLabel.Text = "已写入 Lua,正在更新配置…"; await _updateConfigAsync(); + if (IsDisposed) + { + return; + } + _statusLabel.Text = "已保存并更新配置"; UpdateOffsetHint(); } catch (Exception ex) { - MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - _statusLabel.Text = $"保存失败: {ex.Message}"; + if (!IsDisposed) + { + MessageBox.Show(ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + _statusLabel.Text = $"保存失败: {ex.Message}"; + } } } diff --git a/UI/MainForm.cs b/UI/MainForm.cs index 8c2af7c3..6e4fadda 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -48,16 +48,16 @@ public sealed class MainForm : Form, IMessageFilter private Color? _currentHeaderIconColor; private readonly StatusForm _statusForm; + private readonly string _baseDirectory; private readonly ModuleStore _moduleStore; + private readonly ITriggerKeyState _triggerKeyState; + private readonly RuntimeSessionCoordinator _runtimeSession; 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; - private ShigureRuntime? _runtime; - private CancellationTokenSource? _runtimeCts; - private Task? _runtimeTask; private RenderSnapshot? _lastSnapshot; private string? _lastLoggedStep; private string? _lastLoggedStepDetails; @@ -65,12 +65,25 @@ public sealed class MainForm : Form, IMessageFilter private string? _lastLoggedClass; private string? _lastLoggedModule; private bool? _lastLoggedEnabled; + private readonly object _configUpdateSync = new(); + private Task _configUpdateTail = Task.CompletedTask; + private long _runtimeRequestVersion; + private bool _shutdownStarted; + private bool _shutdownCompleted; - public MainForm(AppOptions? initialOptions = null) + internal MainForm( + AppOptions initialOptions, + string baseDirectory, + ModuleStore moduleStore, + ITriggerKeyState triggerKeyState, + RuntimeSessionCoordinator runtimeSession) { - _initialOptions = initialOptions ?? AppOptions.FromArgs(Array.Empty()); + _initialOptions = initialOptions; + _baseDirectory = baseDirectory; + _moduleStore = moduleStore; + _triggerKeyState = triggerKeyState; + _runtimeSession = runtimeSession; _uiCache = UiCacheStore.Load(); - _moduleStore = new ModuleStore(ModuleStore.ResolveModuleDirectory(AppPaths.BaseDirectory)); _statusForm = new StatusForm(); _roundedCornerResizeTimer = new System.Windows.Forms.Timer { @@ -87,7 +100,7 @@ public sealed class MainForm : Form, IMessageFilter Application.AddMessageFilter(this); InitializeComponent(); _statusForm.AttachSettingsPanel(BuildSettingsPanel()); - _moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditor, AppPaths.BaseDirectory); + _moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditorAsync, _baseDirectory); _statusForm.AttachModuleEditor(_moduleEditor); _classConfigEditor = new ClassConfigEditorControl( () => WowAddonLocator.FindClassDirectory(_initialOptions.WindowTitle), @@ -106,6 +119,9 @@ public sealed class MainForm : Form, IMessageFilter ApplyCachedWindowState(); ApplyInitialOptions(); WireSettingEvents(); + _runtimeSession.SnapshotUpdated += HandleSnapshotUpdated; + _runtimeSession.RuntimeFailed += HandleRuntimeFailed; + _runtimeSession.RuntimeStopped += HandleRuntimeStopped; SetRuntimeControls(running: false); AppendLog("界面已就绪"); } @@ -118,18 +134,30 @@ public sealed class MainForm : Form, IMessageFilter _usesDwmRoundedCorners = UiTheme.ApplyRoundedCorners(this); } - protected override void OnShown(EventArgs e) + protected override async void OnShown(EventArgs e) { base.OnShown(e); - StartRuntime(); + await StartRuntimeAsync(); } protected override void OnFormClosing(FormClosingEventArgs e) { - SaveUiCache(); - _roundedCornerResizeTimer.Stop(); - Application.RemoveMessageFilter(this); - _runtimeCts?.Cancel(); + if (!_shutdownCompleted) + { + e.Cancel = true; + if (!_shutdownStarted) + { + _shutdownStarted = true; + SaveUiCache(); + _roundedCornerResizeTimer.Stop(); + Application.RemoveMessageFilter(this); + _ = CompleteShutdownAsync(); + } + + base.OnFormClosing(e); + return; + } + base.OnFormClosing(e); } @@ -139,6 +167,32 @@ public sealed class MainForm : Form, IMessageFilter base.OnFormClosed(e); } + private async Task CompleteShutdownAsync() + { + _runtimeSession.SnapshotUpdated -= HandleSnapshotUpdated; + _runtimeSession.RuntimeFailed -= HandleRuntimeFailed; + _runtimeSession.RuntimeStopped -= HandleRuntimeStopped; + + try + { + var runtimeShutdown = _runtimeSession.DisposeAsync().AsTask(); + await Task.WhenAll(runtimeShutdown, GetPendingConfigUpdateTask()); + } + catch (Exception ex) + { + AppendLog($"停止运行失败: {ex.Message}"); + } + finally + { + _statusForm.Dispose(); + _shutdownCompleted = true; + if (!IsDisposed) + { + Close(); + } + } + } + protected override void OnResize(EventArgs e) { base.OnResize(e); @@ -555,8 +609,69 @@ public sealed class MainForm : Form, IMessageFilter return panel; } - private async Task UpdateConfigFromAddonAsync() + private Task UpdateConfigFromAddonAsync() { + lock (_configUpdateSync) + { + if (_shutdownStarted) + { + return Task.CompletedTask; + } + + _configUpdateTail = RunQueuedConfigUpdateAsync(_configUpdateTail); + return _configUpdateTail; + } + } + + private async Task RunQueuedConfigUpdateAsync(Task previousUpdate) + { + await Task.Yield(); + try + { + await previousUpdate; + } + catch + { + // 前一个调用方会收到自己的异常;队列仍继续处理后续更新。 + } + + if (!_shutdownStarted) + { + await UpdateConfigFromAddonCoreAsync(); + } + } + + private Task GetPendingConfigUpdateTask() + { + lock (_configUpdateSync) + { + return _configUpdateTail; + } + } + + private async Task WaitForPendingConfigUpdatesAsync() + { + while (true) + { + var pending = GetPendingConfigUpdateTask(); + await pending; + lock (_configUpdateSync) + { + if (ReferenceEquals(pending, _configUpdateTail)) + { + return; + } + } + } + } + + private async Task UpdateConfigFromAddonCoreAsync() + { + if (_shutdownStarted) + { + return; + } + var windowTitle = _initialOptions.WindowTitle; var classDirectory = WowAddonLocator.FindClassDirectory(windowTitle); var classMacrosPath = WowAddonLocator.FindClassMacrosPath(windowTitle); @@ -574,14 +689,14 @@ public sealed class MainForm : Form, IMessageFilter _configSourceLabel.Text = string.IsNullOrWhiteSpace(classMacrosPath) ? $"Fuyutsui class: {classDirectory}" : $"Fuyutsui: {classDirectory} + classmacros.lua"; - var configDirectory = ConfigService.ResolveConfigPath(AppPaths.BaseDirectory); + var configDirectory = ConfigService.ResolveConfigPath(_baseDirectory); if (!Directory.Exists(configDirectory)) { MessageBox.Show($"配置目录不存在: {configDirectory}", "更新配置", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - var keymapDirectory = Path.Combine(AppPaths.BaseDirectory, "keymap"); + var keymapDirectory = Path.Combine(_baseDirectory, "keymap"); try { @@ -598,6 +713,11 @@ public sealed class MainForm : Form, IMessageFilter return (Config: configResult, Keymap: keymapResult); }); + if (_shutdownStarted) + { + return; + } + _moduleEditor.ReloadCatalogs(); AppendLog($"已从 Fuyutsui 更新配置: {result.Config.UpdatedFiles.Count} 个文件 ← {result.Config.ClassDirectory}"); foreach (var warning in result.Config.Warnings.Take(20)) @@ -618,11 +738,15 @@ public sealed class MainForm : Form, IMessageFilter AppendLog("未找到 core\\classmacros.lua,已跳过 keymap 更新"); } - if (_runtime is not null) + if (_runtimeSession.HasSession) { AppendLog("配置已更新, 重新启动运行"); - await StopRuntimeAsync(); - StartRuntime(); + await StartOrRestartRuntimeAsync(restart: true, waitForConfigUpdates: false); + } + + if (_shutdownStarted) + { + return; } var warningCount = result.Config.Warnings.Count + (result.Keymap?.Warnings.Count ?? 0); @@ -640,6 +764,11 @@ public sealed class MainForm : Form, IMessageFilter } catch (Exception ex) { + if (_shutdownStarted) + { + return; + } + AppendLog($"更新配置失败: {ex.Message}"); MessageBox.Show(ex.Message, "更新配置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); } @@ -681,132 +810,132 @@ public sealed class MainForm : Form, IMessageFilter await RestartRuntimeAfterSettingChangeAsync(); } - private void StartRuntime() + private async Task StartRuntimeAsync() { - if (_runtimeTask is { IsCompleted: false }) + if (_runtimeSession.IsRunning) { return; } + await StartOrRestartRuntimeAsync(restart: false); + } + + private async Task StartOrRestartRuntimeAsync( + bool restart, + bool waitForConfigUpdates = true) + { + if (_shutdownStarted) + { + return false; + } + var options = BuildOptions(); - if (IsUnsupportedToggleKey(options.ToggleKey)) + if (!ValidateRuntimeOptions(options)) { - MessageBox.Show("触发键不支持 ALT,请选择其他按键。", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; + return false; } - if (KeySender.GetVk(options.ToggleKey) is null) - { - MessageBox.Show($"无法识别触发键: {options.ToggleKey}", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } + var requestVersion = Interlocked.Increment(ref _runtimeRequestVersion); try { - _runtimeCts = new CancellationTokenSource(); - _moduleStore.Reload(); - _runtime = new ShigureRuntime(AppPaths.BaseDirectory, options, _moduleStore); - _runtime.SnapshotUpdated += HandleSnapshotUpdated; - _runtimeTask = Task.Run(() => RunRuntimeAsync(_runtime, _runtimeCts.Token)); + if (waitForConfigUpdates) + { + await WaitForPendingConfigUpdatesAsync(); + if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion)) + { + return false; + } + } + + if (restart) + { + await _runtimeSession.RestartAsync(options, requestVersion); + } + else + { + await _runtimeSession.StartAsync(options, requestVersion); + } } catch (Exception ex) { - MessageBox.Show(ex.Message, "启动失败", MessageBoxButtons.OK, MessageBoxIcon.Error); - AppendLog($"启动失败: {ex.Message}"); - return; + if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion)) + { + return false; + } + + var operation = restart ? "重启" : "启动"; + MessageBox.Show(ex.Message, $"{operation}失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + AppendLog($"{operation}失败: {ex.Message}"); + SetRuntimeControls(running: _runtimeSession.IsRunning); + return false; } + if (_shutdownStarted || requestVersion != Volatile.Read(ref _runtimeRequestVersion)) + { + return false; + } + + if (!_runtimeSession.IsRunning) + { + SetRuntimeControls(running: false); + return false; + } + + ResetRuntimeLogState(); + SetRuntimeControls(running: true); + AppendLog($"运行已{(restart ? "重启" : "启动")}: {options.WindowTitle} / {options.ToggleKey} / {ModeLabel(options.Mode)}"); + return true; + } + + private bool ValidateRuntimeOptions(AppOptions options) + { + if (IsUnsupportedToggleKey(options.ToggleKey)) + { + MessageBox.Show("触发键不支持 ALT,请选择其他按键。", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return false; + } + + if (_triggerKeyState.ResolveVirtualKey(options.ToggleKey) is null) + { + MessageBox.Show($"无法识别触发键: {options.ToggleKey}", "Shigure", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return false; + } + + return true; + } + + private void ResetRuntimeLogState() + { _lastLoggedStep = null; _lastLoggedStepDetails = null; _lastLoggedScanFailureReason = null; _lastLoggedClass = null; _lastLoggedModule = null; _lastLoggedEnabled = null; - SetRuntimeControls(running: true); - AppendLog($"运行已启动: {options.WindowTitle} / {options.ToggleKey} / {ModeLabel(options.Mode)}"); } - private async Task RunRuntimeAsync(ShigureRuntime runtime, CancellationToken cancellationToken) - { - try - { - await runtime.RunAsync(cancellationToken); - } - catch (OperationCanceledException) - { - // Normal stop path. - } - catch (Exception ex) - { - PostToUi(() => - { - AppendLog($"运行异常: {ex.Message}"); - _titleLabel.ForeColor = UiTheme.Danger; - }); - } - finally - { - PostToUi(() => SetRuntimeControls(running: false)); - } - } - - private async Task StopRuntimeAsync() - { - if (_runtimeCts is null) - { - return; - } - - _runtimeCts.Cancel(); - - if (_runtimeTask is not null) - { - try - { - await _runtimeTask; - } - catch (OperationCanceledException) - { - // Already handled by the runtime task. - } - } - - if (_runtime is not null) - { - _runtime.SnapshotUpdated -= HandleSnapshotUpdated; - } - - _runtimeCts.Dispose(); - _runtimeCts = null; - _runtimeTask = null; - _runtime = null; - SetRuntimeControls(running: false); - AppendLog("运行已停止"); - } - - private async void RestartRuntimeFromEditor() + private async Task RestartRuntimeFromEditorAsync() { RefreshModuleSelector(_lastSnapshot, reloadModules: true); - if (_runtime is null) + if (!_runtimeSession.HasSession) { _moduleStore.Reload(); return; } AppendLog("模块已变更, 重新启动运行"); - await StopRuntimeAsync(); - StartRuntime(); + await StartOrRestartRuntimeAsync(restart: true); } private void ToggleEnabled() { - if (_runtime is null) + if (!_runtimeSession.IsRunning) { return; } - var nextEnabled = !(_lastSnapshot?.Enabled ?? false); - _runtime.SetEnabled(nextEnabled); + _runtimeSession.ToggleEnabled(); } private AppOptions BuildOptions() @@ -828,9 +957,41 @@ public sealed class MainForm : Form, IMessageFilter }; } - private void HandleSnapshotUpdated(RenderSnapshot snapshot) + private void HandleSnapshotUpdated(long sessionId, RenderSnapshot snapshot) { - PostToUi(() => ApplySnapshot(snapshot)); + PostToUi(() => + { + if (_runtimeSession.CurrentSessionId == sessionId) + { + ApplySnapshot(snapshot); + } + }); + } + + private void HandleRuntimeFailed(long sessionId, Exception exception) + { + PostToUi(() => + { + if (_runtimeSession.CurrentSessionId != sessionId) + { + return; + } + + AppendLog($"运行异常: {exception.Message}"); + _titleLabel.ForeColor = UiTheme.Danger; + SetRuntimeControls(running: false); + }); + } + + private void HandleRuntimeStopped(long sessionId) + { + PostToUi(() => + { + if (_runtimeSession.CurrentSessionId == sessionId) + { + SetRuntimeControls(running: false); + } + }); } private void ApplySnapshot(RenderSnapshot snapshot) @@ -973,14 +1134,13 @@ public sealed class MainForm : Form, IMessageFilter private async Task RestartRuntimeAfterSettingChangeAsync() { var options = BuildOptions(); - if (_runtime is not null && options == _runtime.Options) + if (_runtimeSession.IsRunning && options == _runtimeSession.CurrentOptions) { return; } AppendLog("设置已变更, 重新启动运行"); - await StopRuntimeAsync(); - StartRuntime(); + await StartOrRestartRuntimeAsync(restart: _runtimeSession.HasSession); } private void WriteSnapshotLog(RenderSnapshot snapshot) @@ -1353,7 +1513,7 @@ public sealed class MainForm : Form, IMessageFilter return NativeMethods.HtClient; } - private static string? TryMapKeyToHotkey(Keys key) + private string? TryMapKeyToHotkey(Keys key) { var keyName = key.ToString().ToUpperInvariant(); if (IsUnsupportedToggleKey(keyName)) @@ -1389,7 +1549,7 @@ public sealed class MainForm : Form, IMessageFilter "SUBTRACT" => "NUMPADMINUS", "MULTIPLY" => "NUMPADMULTIPLY", "DIVIDE" => "NUMPADDIVIDE", - _ => KeySender.GetVk(keyName) is not null ? keyName : null + _ => _triggerKeyState.ResolveVirtualKey(keyName) is not null ? keyName : null }; } diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 30eb51c5..e6aa9b7f 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -9,7 +9,7 @@ public sealed class ModuleEditorControl : UserControl private const string ModuleWebsiteUrl = "https://www.shigure.club"; private readonly ModuleStore _moduleStore; - private readonly Action _runtimeRestartRequested; + private readonly Func _runtimeRestartRequested; private readonly string _baseDirectory; private ConditionFieldCatalog _fieldCatalog; private KeymapCatalog _keymapCatalog; @@ -35,6 +35,7 @@ public sealed class ModuleEditorControl : UserControl private readonly Label _editorEmptyHint = new(); private Button _saveButton = null!; private Button _deleteButton = null!; + private Button _addButton = null!; private readonly ToolTip _rulesGridToolTip = new() { InitialDelay = 300, @@ -53,6 +54,7 @@ public sealed class ModuleEditorControl : UserControl private bool _suppressUnitsColumnResize; // 载入时程序化写入"类型"单元格会触发 CellValueChanged; 置真以跳过"按类型清空数值"的联动。 private bool _suppressAdjustmentTypeChange; + private bool _moduleCommandInProgress; // 规则行拖拽重排: 拖动起始行, 以及拖动中的插入指示位置(显示一条强调线)。 private int _dragSourceRow = -1; private int _dragIndicatorRow = -1; @@ -82,7 +84,7 @@ public sealed class ModuleEditorControl : UserControl ("动态单位", ConditionFieldCategory.DynamicUnit) ]; - public ModuleEditorControl(ModuleStore moduleStore, Action runtimeRestartRequested, string baseDirectory) + public ModuleEditorControl(ModuleStore moduleStore, Func runtimeRestartRequested, string baseDirectory) { _moduleStore = moduleStore; _runtimeRestartRequested = runtimeRestartRequested; @@ -2557,19 +2559,19 @@ public sealed class ModuleEditorControl : UserControl _saveButton = UiTheme.CreateButton("保存", UiTheme.Accent, Color.Black); _saveButton.Margin = new Padding(8, 0, 0, 0); - _saveButton.Click += (_, _) => SaveSelectedModule(); + _saveButton.Click += async (_, _) => await RunModuleCommandAsync(SaveSelectedModuleAsync); _deleteButton = UiTheme.CreateButton("删除", UiTheme.Field, UiTheme.Danger); _deleteButton.Margin = new Padding(8, 0, 0, 0); - _deleteButton.Click += (_, _) => DeleteSelectedModule(); + _deleteButton.Click += async (_, _) => await RunModuleCommandAsync(DeleteSelectedModuleAsync); - var addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text); - addButton.Margin = new Padding(8, 0, 0, 0); - addButton.Click += (_, _) => AddModule(); + _addButton = UiTheme.CreateButton("新建", UiTheme.Field, UiTheme.Text); + _addButton.Margin = new Padding(8, 0, 0, 0); + _addButton.Click += async (_, _) => await RunModuleCommandAsync(AddModuleAsync); buttons.Controls.Add(_saveButton); buttons.Controls.Add(_deleteButton); - buttons.Controls.Add(addButton); + buttons.Controls.Add(_addButton); row.Controls.Add(hint); row.Controls.Add(buttons); return row; @@ -2697,8 +2699,9 @@ public sealed class ModuleEditorControl : UserControl // 无选中模块时禁用保存/删除(否则点了静默无反应), 并在编辑区显示引导提示。 private void SetEditorEnabled(bool hasModule) { - _saveButton.Enabled = hasModule; - _deleteButton.Enabled = hasModule; + _saveButton.Enabled = hasModule && !_moduleCommandInProgress; + _deleteButton.Enabled = hasModule && !_moduleCommandInProgress; + _addButton.Enabled = !_moduleCommandInProgress; _editorEmptyHint.Visible = !hasModule; if (!hasModule) { @@ -2706,7 +2709,37 @@ public sealed class ModuleEditorControl : UserControl } } - private void AddModule() + private async Task RunModuleCommandAsync(Func command) + { + if (_moduleCommandInProgress) + { + return; + } + + _moduleCommandInProgress = true; + SetEditorEnabled(_selectedModule is not null); + try + { + await command(); + } + catch (Exception ex) + { + if (!IsDisposed) + { + MessageBox.Show(ex.Message, "模块操作失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + finally + { + _moduleCommandInProgress = false; + if (!IsDisposed) + { + SetEditorEnabled(_selectedModule is not null); + } + } + } + + private async Task AddModuleAsync() { var module = ModuleDefinition.CreateDefault(_moduleStore.CreateNextModuleName()); try @@ -2726,10 +2759,10 @@ public sealed class ModuleEditorControl : UserControl _moduleList.SelectedIndex = index; } - _runtimeRestartRequested(); + await _runtimeRestartRequested(); } - private void SaveSelectedModule() + private async Task SaveSelectedModuleAsync() { if (_selectedModule is null) { @@ -2759,10 +2792,10 @@ public sealed class ModuleEditorControl : UserControl _moduleList.SelectedIndex = index; } - _runtimeRestartRequested(); + await _runtimeRestartRequested(); } - private void DeleteSelectedModule() + private async Task DeleteSelectedModuleAsync() { if (_selectedModule is null) { @@ -2781,7 +2814,7 @@ public sealed class ModuleEditorControl : UserControl _moduleStore.Delete(_selectedModule); LoadModules(); - _runtimeRestartRequested(); + await _runtimeRestartRequested(); } private bool TryReadModule(out ModuleDefinition module)