diff --git a/.gitignore b/.gitignore index c786c5d9..9ab35ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,3 @@ __pycache__/ /.plan outputs/ - -# Generated only inside the one-click packer's temporary build environment -/Assets/SpellIcons.shgpack diff --git a/Fuyutsui/class/DeathKnight.lua b/Fuyutsui/class/DeathKnight.lua index 0f020698..1f4400ed 100644 --- a/Fuyutsui/class/DeathKnight.lua +++ b/Fuyutsui/class/DeathKnight.lua @@ -28,6 +28,7 @@ Fuyutsui.ClassBlocks = { "符文", "一键辅助", "施法技能", + "吸血鬼打击", }, ["能量"] = { "符文能量", @@ -234,6 +235,7 @@ Fuyutsui.ClassBlocks = { "难度", "敌人数量", "天启骑士数量", + "吸血鬼打击", }, ["能量"] = { diff --git a/Fuyutsui/class/Paladin.lua b/Fuyutsui/class/Paladin.lua index 1c58222c..cfcf6d16 100644 --- a/Fuyutsui/class/Paladin.lua +++ b/Fuyutsui/class/Paladin.lua @@ -120,9 +120,8 @@ Fuyutsui.ClassBlocks = { role = 2, dispel = 3, aura = { - [4] = { name = "救赎之魂", spellId = 27827, }, - [5] = { name = "圣光道标", spellIds = { 53563, 156910, 1244893 }, }, - [6] = { name = "永恒之火", spellId = 156322, }, + [4] = { name = "圣光道标", spellIds = { 53563, 156910, 1244893 }, }, + [5] = { name = "永恒之火", spellId = 156322, }, }, }, }, diff --git a/Fuyutsui/class/Priest.lua b/Fuyutsui/class/Priest.lua index c8ddff50..e5719b2d 100644 --- a/Fuyutsui/class/Priest.lua +++ b/Fuyutsui/class/Priest.lua @@ -26,7 +26,7 @@ Fuyutsui.ClassBlocks = { "施法技能", "施法目标", "敌人数量", - + }, ["能量"] = { "法力值", @@ -47,7 +47,7 @@ Fuyutsui.ClassBlocks = { "施法(正计时)", "施法可打断", "驱散类型", - + }, ["鼠标"] = { "类型", @@ -145,7 +145,7 @@ Fuyutsui.ClassBlocks = { "难度", "施法技能", "施法目标", - + "救赎之魂1", "救赎之魂2", }, @@ -165,7 +165,7 @@ Fuyutsui.ClassBlocks = { "施法(正计时)", "施法可打断", "驱散类型", - + }, ["鼠标"] = { "类型", @@ -233,14 +233,13 @@ Fuyutsui.ClassBlocks = { { spellId = 528, name = "驱散魔法" }, }, group = { - num = 6, + num = 5, healthPercent = 1, role = 2, dispel = 3, aura = { [4] = { name = "恢复", spellId = 139, }, [5] = { name = "愈合祷言", spellId = 41635, }, - [6] = { name = "救赎之魂", spellId = 27827, }, }, }, }, @@ -269,7 +268,7 @@ Fuyutsui.ClassBlocks = { "敌人数量", "施法技能", "姿态", - + }, ["能量"] = { "狂乱值", @@ -289,7 +288,7 @@ Fuyutsui.ClassBlocks = { "施法可打断", "引导", "引导可打断", - + }, ["焦点"] = { "施法(倒计时)", @@ -297,7 +296,7 @@ Fuyutsui.ClassBlocks = { "施法可打断", "引导", "引导可打断", - + }, ["鼠标"] = { "类型", diff --git a/Fuyutsui/class/Shaman.lua b/Fuyutsui/class/Shaman.lua index 68070eec..f9e6498e 100644 --- a/Fuyutsui/class/Shaman.lua +++ b/Fuyutsui/class/Shaman.lua @@ -24,7 +24,7 @@ Fuyutsui.ClassBlocks = { "首领战", "难度", "敌人数量", - + }, ["能量"] = { "漩涡值", @@ -44,7 +44,7 @@ Fuyutsui.ClassBlocks = { "施法可打断", "引导", "引导可打断", - + }, ["焦点"] = { "施法(倒计时)", @@ -52,7 +52,7 @@ Fuyutsui.ClassBlocks = { "施法可打断", "引导", "引导可打断", - + }, ["鼠标"] = { "类型", @@ -229,7 +229,7 @@ Fuyutsui.ClassBlocks = { "敌人数量", "敌人数-无仇恨", "敌人数-有仇恨", - + }, ["能量"] = { "法力值", @@ -246,7 +246,7 @@ Fuyutsui.ClassBlocks = { "引导可打断", "驱散类型", "距离", - + }, ["焦点"] = { "施法(倒计时)", @@ -254,7 +254,7 @@ Fuyutsui.ClassBlocks = { "施法可打断", "引导", "引导可打断", - + }, ["鼠标"] = { "类型", @@ -325,14 +325,13 @@ Fuyutsui.ClassBlocks = { { spellId = 192077, name = "狂风图腾" }, }, group = { - num = 6, + num = 5, healthPercent = 1, role = 2, dispel = 3, aura = { [4] = { name = "激流", spellId = 61295, }, [5] = { name = "大地之盾", spellIds = { 974, 383648 }, }, - [6] = { name = "救赎之魂", spellId = 27827, }, }, }, }, diff --git a/Fuyutsui/core/events.lua b/Fuyutsui/core/events.lua index 29ce2d46..ba5ebfc6 100644 --- a/Fuyutsui/core/events.lua +++ b/Fuyutsui/core/events.lua @@ -26,6 +26,7 @@ end function Fuyutsui:PLAYER_ENTERING_WORLD() state.mapID = C_Map.GetBestMapForUnit("player") or 0 self:UpdateHolyArmaments(375576) + self:UpdateVampiricStrike(206930) self:UpdateReaverGlaive(204157) self:UpdateHeroTalent() self:GetMountsInfo() @@ -240,6 +241,10 @@ function Fuyutsui:UNIT_HEALTH(_, unit) self:UpdatePlayerHealth() self:UpdatePlayerStagger() end + if self.group[unit] then + self:UpdateUnitHealthInfo(unit) + self:UpdateUnitDeath(unit, "health") + end if unit == "target" then self:UpdateTargetHealth() end @@ -255,15 +260,16 @@ function Fuyutsui:UNIT_HEALTH(_, unit) if unit and unit:match("^boss[1-5]$") then self:UpdateUnitHealthBlock(unit) end - if self.group[unit] then - self:UpdateUnitDeath(unit, "health") - end end function Fuyutsui:UNIT_MAXHEALTH(_, unit) if unit == "player" then self:UpdatePlayerHealth() end + if self.group[unit] then + self:UpdateUnitHealthInfo(unit) + self:UpdateUnitDeath(unit, "health") + end if unit == "mouseover" then self:UpdateMouseoverHealth() end @@ -273,9 +279,6 @@ function Fuyutsui:UNIT_MAXHEALTH(_, unit) if unit and unit:match("^boss[1-5]$") then self:UpdateUnitHealthBlock(unit) end - if self.group[unit] then - self:UpdateUnitDeath(unit, "health") - end end function Fuyutsui:UNIT_HEAL_ABSORB_AMOUNT_CHANGED(_, unit) @@ -283,6 +286,7 @@ function Fuyutsui:UNIT_HEAL_ABSORB_AMOUNT_CHANGED(_, unit) self:UpdatePlayerHealth() end if self.group[unit] then + self:UpdateUnitHealthInfo(unit) self:UpdateUnitDeath(unit, "health") end end @@ -292,6 +296,7 @@ function Fuyutsui:UNIT_HEAL_PREDICTION(_, unit) self:UpdatePlayerHealth() end if self.group[unit] then + self:UpdateUnitHealthInfo(unit) self:UpdateUnitDeath(unit, "health") end end @@ -319,6 +324,7 @@ end function Fuyutsui:SPELL_UPDATE_ICON(_, spellID) if issecretvalue(spellID) then return end self:UpdateHolyArmaments(spellID) + self:UpdateVampiricStrike(spellID) self:UpdateReaverGlaive(spellID) self:UpdateHeroicStrike(spellID) end @@ -470,13 +476,6 @@ Fuyutsui.timeElapsed = 0 Fuyutsui.timeElapsed1 = 0 function Fuyutsui:OnUpdate(elapsed) - self:UpdatePlayerCastBlocks() - self:UpdateUnitCastingOrChannelingInfo("target") - self:UpdateUnitCastingOrChannelingInfo("focus") - self:UpdateUnitCastingOrChannelingInfo("mouseover") - for index = 1, 5 do - self:UpdateUnitCastingOrChannelingInfo("boss" .. index) - end self:UpdateGroupInRangeAndHealth() self.timeElapsed = self.timeElapsed + elapsed @@ -499,4 +498,12 @@ function Fuyutsui:OnUpdate(elapsed) self:UpdateKnightStatusCount() self.timeElapsed1 = 0 end + + self:UpdatePlayerCastBlocks() + self:UpdateUnitCastingOrChannelingInfo("target") + self:UpdateUnitCastingOrChannelingInfo("focus") + self:UpdateUnitCastingOrChannelingInfo("mouseover") + for index = 1, 5 do + self:UpdateUnitCastingOrChannelingInfo("boss" .. index) + end end diff --git a/Fuyutsui/core/player.lua b/Fuyutsui/core/player.lua index c959b084..a76d3bb0 100644 --- a/Fuyutsui/core/player.lua +++ b/Fuyutsui/core/player.lua @@ -315,11 +315,26 @@ function Fuyutsui:UpdateHolyArmaments(spellID) -- 神圣军备 end end +function Fuyutsui:UpdateVampiricStrike(spellID) -- 吸血鬼打击 + if spellID == 206930 or spellID == 55090 then + local overrideSpellID1 = C_Spell.GetOverrideSpell(206930) + local overrideSpellID2 = C_Spell.GetOverrideSpell(55090) + + if overrideSpellID1 == 433895 or overrideSpellID2 == 433895 then + state.VampiricStrike = 1 / 255 + self:UpdateStateBlock("状态", "吸血鬼打击") + else + state.VampiricStrike = 0 + self:UpdateStateBlock("状态", "吸血鬼打击") + end + end +end + function Fuyutsui:UpdateReaverGlaive(spellID) -- 收割者战刃 - if not spellID or spellID ~= 204157 then return end + if not spellID or spellID ~= 206930 then return end local overrideSpellID = C_Spell.GetOverrideSpell(204157) - if overrideSpellID == 1283344 then + if overrideSpellID == 433895 then state.reaverGlaive = 1 / 255 self:UpdateStateBlock("状态", "收割者战刃") else diff --git a/Fuyutsui/core/stateblocks.lua b/Fuyutsui/core/stateblocks.lua index d68bcd75..0659c047 100644 --- a/Fuyutsui/core/stateblocks.lua +++ b/Fuyutsui/core/stateblocks.lua @@ -132,6 +132,7 @@ local stateBlockGetters = { ["敌人数-有仇恨"] = function() return state.threatEnemyCount or 0 end, ["酒池"] = function() return state.staggerPercent or 0 end, ["神圣军备"] = function() return state.holyArmaments or 0 end, + ["吸血鬼打击"] = function() return state.VampiricStrike or 0 end, ["收割者战刃"] = function() return state.reaverGlaive or 0 end, ["英勇打击"] = function() return state.heroicStrike or 0 end, -- 兼容:旧职业表仍把能量/配置/物品写在 ["状态"] 下 diff --git a/Fuyutsui/core/target.lua b/Fuyutsui/core/target.lua index f4a65b17..efd92e36 100644 --- a/Fuyutsui/core/target.lua +++ b/Fuyutsui/core/target.lua @@ -145,13 +145,13 @@ end function Fuyutsui:UpdateUnitCastingOrChannelingInfo(unit) local obj = unitZHMap[unit] - if not obj then return end - - self:UpdateStateBlock(obj, "施法(倒计时)") - self:UpdateStateBlock(obj, "施法(正计时)") - self:UpdateStateBlock(obj, "施法可打断") - self:UpdateStateBlock(obj, "引导") - self:UpdateStateBlock(obj, "引导可打断") + if obj then + self:UpdateStateBlock(obj, "施法(倒计时)") + self:UpdateStateBlock(obj, "施法(正计时)") + self:UpdateStateBlock(obj, "施法可打断") + self:UpdateStateBlock(obj, "引导") + self:UpdateStateBlock(obj, "引导可打断") + end end function Fuyutsui:UpdateUnitDeathStatus(unit) diff --git a/Infrastructure/ClassStateCatalog.cs b/Infrastructure/ClassStateCatalog.cs index 771529e2..e214f318 100644 --- a/Infrastructure/ClassStateCatalog.cs +++ b/Infrastructure/ClassStateCatalog.cs @@ -47,7 +47,7 @@ internal static class ClassStateCatalog "首领战", "难度", "英雄天赋", "施法目标", "施法技能", "敌人数量", "敌人数-无仇恨", "敌人数-有仇恨", "施法(正计时)", "施法(倒计时)", "引导", "蓄力", "蓄力层数", - "酒池", "符文", "姿态", "神圣军备", "自律", "英勇打击", "收割者战刃", + "酒池", "符文", "姿态", "神圣军备", "自律", "英勇打击", "吸血鬼打击", "收割者战刃", ]), (CategoryConfig, [ diff --git a/Infrastructure/ModuleDependencyService.cs b/Infrastructure/ModuleDependencyService.cs index a563bbdf..923b211b 100644 --- a/Infrastructure/ModuleDependencyService.cs +++ b/Infrastructure/ModuleDependencyService.cs @@ -164,6 +164,10 @@ internal sealed class ModuleDependencyService if (!counters.HasConfigChanges && counters.MacrosAdded == 0) { result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); + if (counters.Conflicts.Count > 0) + { + result.ConflictedModuleIds.Add(module.Id); + } return; } @@ -173,6 +177,10 @@ internal sealed class ModuleDependencyService result.MacrosAdded += counters.MacrosAdded; result.ChangedModules.Add(module.Name); result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); + if (counters.Conflicts.Count > 0) + { + result.ConflictedModuleIds.Add(module.Id); + } } private static void ValidateSnapshot(ModuleDefinition module, ModuleDependencySnapshot snapshot) @@ -1049,6 +1057,7 @@ internal sealed class ModuleDependencyImportResult public int MacrosAdded { get; set; } public List ChangedModules { get; } = new(); public List Conflicts { get; } = new(); + public HashSet ConflictedModuleIds { get; } = new(StringComparer.OrdinalIgnoreCase); public List Rejected { get; } = new(); public bool HasChanges => ConfigAdded > 0 || ConfigUpdated > 0 || MacrosAdded > 0; } diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs index db6a08fc..a53876c8 100644 --- a/Modules/ModuleStore.cs +++ b/Modules/ModuleStore.cs @@ -281,6 +281,8 @@ public sealed class ModuleStore private readonly object _gate = new(); private List _modules = new(); + private readonly HashSet _rejectedModuleIds = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _importIssueModuleIds = new(StringComparer.OrdinalIgnoreCase); public ModuleStore(string moduleDirectory) { @@ -297,6 +299,17 @@ public sealed class ModuleStore } public IReadOnlyList GetModules() + { + lock (_gate) + { + return _modules + .Where(module => !_rejectedModuleIds.Contains(module.Id)) + .Select(module => module.Clone()) + .ToList(); + } + } + + public IReadOnlyList GetModulesForDisplay() { lock (_gate) { @@ -304,17 +317,26 @@ public sealed class ModuleStore } } - public void RejectModules(IEnumerable moduleIds) + public void SetImportIssues( + IEnumerable rejectedModuleIds, + IEnumerable conflictedModuleIds) { - var rejected = new HashSet(moduleIds, StringComparer.OrdinalIgnoreCase); - if (rejected.Count == 0) - { - return; - } - lock (_gate) { - _modules.RemoveAll(module => rejected.Contains(module.Id)); + _rejectedModuleIds.Clear(); + _rejectedModuleIds.UnionWith(rejectedModuleIds); + + _importIssueModuleIds.Clear(); + _importIssueModuleIds.UnionWith(_rejectedModuleIds); + _importIssueModuleIds.UnionWith(conflictedModuleIds); + } + } + + public bool HasImportIssue(string moduleId) + { + lock (_gate) + { + return _importIssueModuleIds.Contains(moduleId); } } @@ -345,6 +367,8 @@ public sealed class ModuleStore } _modules = SortModules(loaded).ToList(); + _rejectedModuleIds.Clear(); + _importIssueModuleIds.Clear(); } } @@ -352,7 +376,13 @@ public sealed class ModuleStore { lock (_gate) { - var matches = SortMatches(_modules, classId, specId, partyType, heroTalent).ToList(); + var matches = SortMatches( + _modules.Where(module => !_rejectedModuleIds.Contains(module.Id)), + classId, + specId, + partyType, + heroTalent) + .ToList(); if (!string.IsNullOrWhiteSpace(selectedModuleId)) { var selected = matches.FirstOrDefault(module => @@ -371,7 +401,12 @@ public sealed class ModuleStore { lock (_gate) { - return SortMatches(_modules, classId, specId, partyType, heroTalent) + return SortMatches( + _modules.Where(module => !_rejectedModuleIds.Contains(module.Id)), + classId, + specId, + partyType, + heroTalent) .Select(module => module.Clone()) .ToList(); } @@ -433,6 +468,8 @@ public sealed class ModuleStore || string.Equals(existing.FilePath, path, StringComparison.OrdinalIgnoreCase)); _modules.Add(module.Clone()); _modules = SortModules(_modules).ToList(); + _rejectedModuleIds.Remove(module.Id); + _importIssueModuleIds.Remove(module.Id); return module.Clone(); } @@ -452,6 +489,8 @@ public sealed class ModuleStore _modules.RemoveAll(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(existing.FilePath, module.FilePath, StringComparison.OrdinalIgnoreCase)); + _rejectedModuleIds.Remove(module.Id); + _importIssueModuleIds.Remove(module.Id); } } diff --git a/README.md b/README.md index 5b8fc3de..9f65cdd2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Shigure 是一个 Windows WinForms 桌面程序。它从目标窗口读取 Fuyut ## 界面 - 置顶浮动条:显示程序名、当前职业图标颜色和逻辑状态,提供 `开启/关闭`、`设置`、`✕` 按钮。窗口可拖动和缩放,显示后自动启动运行循环。 -- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境筛选、自动选择或手动指定模块。 +- `通用`:设置触发键和发送模式;从项目 Fuyutsui 更新配置并同步游戏插件;按实时环境选择模块;从 GitHub 按需下载或更新技能图标数据包。 - `配置`:直接编辑项目 `Fuyutsui/class/*.lua` 中的 `ClassBlocks`,包括状态、光环、法术和队伍字段;技能列表页可编辑 `Fuyutsui.spellsList` 中索引 1–100 的法术 ID、索引和名称,也可输入 spellId 与名称并自动分配空闲索引。旧版稀疏索引格式只读,需先迁移到 `states/auras/spells/group` 格式。 - `宏`:编辑项目 `Fuyutsui/core/classmacros.lua` 中各职业的动态宏、静态宏和特殊宏。动态宏每项占用 30 个团队点名槽位。 - `模块`:新建、编辑、删除本地模块,维护作者、推荐天赋、匹配条件、动态字段和有序规则。规则支持拖拽、上移、下移、复制与插入。 @@ -97,7 +97,7 @@ dotnet run --project .\Shigure.csproj -- --toggle XBUTTON2 --mode switch --logic dotnet build .\Shigure.csproj ``` -应用图标为 `Assets\arasaka-icon.ico`。项目会把 `Fuyutsui/**`、`config/*.json`、`keymap/*.json` 和 `wow_process.txt` 复制到输出/发布目录;图标、职业图和专精图作为嵌入资源打包。 +应用图标为 `Assets\arasaka-icon.ico`。项目会把 `Fuyutsui/**`、`config/*.json`、`keymap/*.json` 和 `wow_process.txt` 复制到输出/发布目录;应用、职业、专精和少量程序专用图标作为嵌入资源打包。完整技能图标库不随发布版分发,用户可在“设置 → 通用 → 下载数据包”中从 GitHub 最新正式 Release 下载到 `data\SpellIcons.shgpack`。缺少数据包时技能图标与添加技能的 spellId 联想保持关闭,但仍可手工编辑技能。 ## 项目结构 diff --git a/Shigure.csproj b/Shigure.csproj index 5ac15689..d445c3f1 100644 --- a/Shigure.csproj +++ b/Shigure.csproj @@ -28,20 +28,10 @@ - + - - - diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 0373e085..9563fad1 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -76,7 +76,7 @@ public sealed class ClassConfigEditorControl : UserControl _reloadButton = UiTheme.CreateButton("刷新", UiTheme.ButtonKind.Secondary); _saveButton = UiTheme.CreateButton("保存", UiTheme.ButtonKind.Primary); InitializeComponent(); - SpellIconCatalog.IconAvailable += OnSpellIconAvailable; + SpellIconCatalog.CatalogChanged += OnSpellIconCatalogChanged; ReloadFromAddon(); } @@ -86,7 +86,7 @@ public sealed class ClassConfigEditorControl : UserControl { CloseStateComboDropDown(); CloseSpellSuggestions(); - SpellIconCatalog.IconAvailable -= OnSpellIconAvailable; + SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; } base.Dispose(disposing); @@ -2113,66 +2113,41 @@ public sealed class ClassConfigEditorControl : UserControl MarkDirty(); } - private void OnSpellIconAvailable(long spellId) + private void OnSpellIconCatalogChanged() { if (IsDisposed || Disposing || !IsHandleCreated) { return; } - BeginInvoke((Action)(() => + if (InvokeRequired) { - if (IsDisposed || Disposing) + BeginInvoke(OnSpellIconCatalogChanged); + return; + } + + CloseSpellSuggestions(); + foreach (var grid in new[] { _spellsGrid, _spellsListGrid }) + { + foreach (DataGridViewRow row in grid.Rows) { - return; + if (!row.IsNewRow) + { + UpdateSpellGridIcon(row); + } } - RefreshDownloadedIcons(_spellsGrid, spellId); - RefreshDownloadedIcons(_spellsListGrid, spellId); - RefreshDownloadedAuraIcons(spellId); - })); - } + grid.Invalidate(); + } - private void RefreshDownloadedAuraIcons(long spellId) - { foreach (var grid in new[] { _aurasGrid, _groupAurasGrid }) { foreach (DataGridViewRow row in grid.Rows) { - if (!row.IsNewRow && AuraRowUsesSpellId(row, spellId)) - { - UpdateAuraGridIcon(row); - } + UpdateAuraGridIcon(row); } - } - } - private static bool AuraRowUsesSpellId(DataGridViewRow row, long spellId) - { - if (long.TryParse( - row.Cells["SpellId"].Value?.ToString(), - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out var primarySpellId) - && primarySpellId == spellId) - { - return true; - } - - return ParseIdList(row.Cells["SpellIds"].Value?.ToString() ?? "").Contains(spellId); - } - - private static void RefreshDownloadedIcons(DataGridView grid, long spellId) - { - foreach (DataGridViewRow row in grid.Rows) - { - if (!row.IsNewRow - && long.TryParse(row.Cells["SpellId"].Value?.ToString(), NumberStyles.Integer, - CultureInfo.InvariantCulture, out var rowSpellId) - && rowSpellId == spellId) - { - UpdateSpellGridIcon(row); - } + grid.Invalidate(); } } diff --git a/UI/MainForm.cs b/UI/MainForm.cs index fa9daed3..6ef6e9f5 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -46,6 +46,8 @@ public sealed class MainForm : Form, IMessageFilter private Label _moduleCountLabel = null!; private Label _configSourceLabel = null!; private Button _updateConfigButton = null!; + private Label _spellIconPackageStatusLabel = null!; + private Button _downloadSpellIconPackageButton = null!; private readonly ToolTip _settingsToolTip = new(); private Button _horizontalLayoutButton = null!; private Button _verticalLayoutButton = null!; @@ -100,7 +102,10 @@ public sealed class MainForm : Form, IMessageFilter private bool? _lastLoggedEnabled; private readonly object _configUpdateSync = new(); private readonly SemaphoreSlim _moduleImportGate = new(1, 1); + private readonly SpellIconPackageDownloadService _spellIconPackageDownloadService = new(); private Task _configUpdateTail = Task.CompletedTask; + private Task _spellIconPackageDownloadTask = Task.CompletedTask; + private CancellationTokenSource? _spellIconPackageDownloadCts; private long _runtimeRequestVersion; private bool _exitRequested; private bool _shutdownStarted; @@ -293,7 +298,9 @@ public sealed class MainForm : Form, IMessageFilter return false; } - _moduleStore.RejectModules(result.Rejected.Select(item => item.ModuleId)); + _moduleStore.SetImportIssues( + result.Rejected.Select(item => item.ModuleId), + result.ConflictedModuleIds); _moduleEditor.ReloadModulesFromStore(reloadStore: false); RefreshModuleSelector(_lastSnapshot, forceRefresh: false); @@ -377,6 +384,7 @@ public sealed class MainForm : Form, IMessageFilter SaveUiCache(); _roundedCornerResizeTimer.Stop(); _wowProcessMonitorTimer.Stop(); + _spellIconPackageDownloadCts?.Cancel(); Application.RemoveMessageFilter(this); _ = CompleteShutdownAsync(); } @@ -442,7 +450,10 @@ public sealed class MainForm : Form, IMessageFilter try { var runtimeShutdown = _runtimeSession.DisposeAsync().AsTask(); - await Task.WhenAll(runtimeShutdown, GetPendingConfigUpdateTask()); + await Task.WhenAll( + runtimeShutdown, + GetPendingConfigUpdateTask(), + _spellIconPackageDownloadTask); } catch (Exception ex) { @@ -451,6 +462,8 @@ public sealed class MainForm : Form, IMessageFilter finally { _statusForm.Dispose(); + _spellIconPackageDownloadCts?.Dispose(); + _spellIconPackageDownloadService.Dispose(); _shutdownCompleted = true; if (!IsDisposed) { @@ -618,7 +631,7 @@ public sealed class MainForm : Form, IMessageFilter { Dock = DockStyle.Fill, ColumnCount = 1, - RowCount = 3, + RowCount = 4, BackColor = Color.Transparent, Margin = new Padding(0) }; @@ -826,6 +839,7 @@ public sealed class MainForm : Form, IMessageFilter const int settingsActionButtonHeight = UiTheme.ActionButtonHeight; panel.RowStyles.Add(new RowStyle(SizeType.Absolute, settingsCardHeight + settingsCardGap)); panel.RowStyles.Add(new RowStyle(SizeType.Absolute, settingsCardHeight + settingsCardGap)); + panel.RowStyles.Add(new RowStyle(SizeType.Absolute, settingsCardHeight + settingsCardGap)); panel.RowStyles.Add(new RowStyle(SizeType.Absolute, settingsCardHeight)); Label CreateTitle(string text) => UiTheme.CreateSectionTitle(Font, text); @@ -1123,13 +1137,165 @@ public sealed class MainForm : Form, IMessageFilter closeBehaviorCard.Controls.Add(closeBehaviorActions, 0, 3); panel.Controls.Add(closeBehaviorCard, 1, 2); + var spellIconPackageCard = new UiCardPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4, + Padding = new Padding(UiTheme.CardPadding), + Margin = new Padding(0) + }; + spellIconPackageCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); + spellIconPackageCard.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); + spellIconPackageCard.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + spellIconPackageCard.RowStyles.Add(new RowStyle(SizeType.Absolute, settingsActionButtonHeight)); + spellIconPackageCard.Controls.Add(CreateTitle("下载数据包"), 0, 0); + spellIconPackageCard.Controls.Add( + CreateDescription("从 GitHub 下载或更新技能图标数据包;不会随发布包自动附带"), + 0, + 1); + + _spellIconPackageStatusLabel = CreateInfoLabel(string.Empty); + _spellIconPackageStatusLabel.Dock = DockStyle.Fill; + _spellIconPackageStatusLabel.AutoSize = false; + _spellIconPackageStatusLabel.AutoEllipsis = true; + _spellIconPackageStatusLabel.TextAlign = ContentAlignment.TopLeft; + _spellIconPackageStatusLabel.Margin = new Padding(0, 10, 0, 8); + spellIconPackageCard.Controls.Add(_spellIconPackageStatusLabel, 0, 2); + + _downloadSpellIconPackageButton = UiTheme.CreateButton( + "下载数据包", + UiTheme.ButtonKind.Secondary); + _downloadSpellIconPackageButton.AutoSize = false; + _downloadSpellIconPackageButton.Size = new Size(140, settingsActionButtonHeight); + _downloadSpellIconPackageButton.Dock = DockStyle.Left; + _downloadSpellIconPackageButton.Margin = new Padding(0); + _downloadSpellIconPackageButton.Click += (_, _) => StartSpellIconPackageDownload(); + spellIconPackageCard.Controls.Add(_downloadSpellIconPackageButton, 0, 3); + panel.Controls.Add(spellIconPackageCard, 0, 3); + panel.SetColumnSpan(spellIconPackageCard, 2); + UpdateLayoutButtons(); UpdateCloseBehaviorButtons(); + UpdateSpellIconPackageCard(); scrollHost.Controls.Add(panel); scrollHost.Resize += (_, _) => panel.Width = Math.Max(0, scrollHost.ClientSize.Width - SystemInformation.VerticalScrollBarWidth); return scrollHost; } + private void StartSpellIconPackageDownload() + { + if (!_spellIconPackageDownloadTask.IsCompleted || _shutdownStarted) + { + return; + } + + _spellIconPackageDownloadCts?.Dispose(); + _spellIconPackageDownloadCts = new CancellationTokenSource(); + _spellIconPackageDownloadTask = DownloadSpellIconPackageWithFeedbackAsync( + _spellIconPackageDownloadCts.Token); + } + + private async Task DownloadSpellIconPackageWithFeedbackAsync(CancellationToken cancellationToken) + { + _downloadSpellIconPackageButton.Enabled = false; + var progress = new Progress(value => + { + if (_shutdownStarted || _spellIconPackageStatusLabel.IsDisposed) + { + return; + } + + _spellIconPackageStatusLabel.Text = value.Message; + _settingsToolTip.SetToolTip(_spellIconPackageStatusLabel, value.Message); + _downloadSpellIconPackageButton.Text = value.Percentage is { } percentage + ? $"正在下载 {percentage}%" + : "正在检查……"; + }); + + AppendLog("开始检查 GitHub 技能图标数据包"); + try + { + var result = await _spellIconPackageDownloadService.UpdateAsync(progress, cancellationToken); + if (_shutdownStarted) + { + return; + } + + var sizeText = $"{result.Size / 1024d / 1024d:F2} MiB"; + var hashText = result.Sha256[..Math.Min(12, result.Sha256.Length)]; + if (result.UpToDate) + { + _spellIconPackageStatusLabel.Text = $"已是最新:{sizeText},SHA-256 {hashText}…"; + AppendLog("技能图标数据包已是最新,本地文件未修改"); + } + else + { + _spellIconPackageStatusLabel.Text = $"安装完成:{sizeText},SHA-256 {hashText}…"; + AppendLog("技能图标数据包已下载、校验并热加载"); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (!_shutdownStarted) + { + _spellIconPackageStatusLabel.Text = "下载已取消;原数据包未修改。"; + AppendLog("技能图标数据包下载已取消"); + } + } + catch (Exception ex) + { + if (!_shutdownStarted) + { + _spellIconPackageStatusLabel.Text = $"下载失败:{ex.Message}"; + _settingsToolTip.SetToolTip(_spellIconPackageStatusLabel, ex.ToString()); + AppendLog($"技能图标数据包下载失败: {ex.Message}"); + MessageBox.Show( + this, + ex.Message, + "下载数据包失败", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + } + finally + { + if (!_shutdownStarted && !_downloadSpellIconPackageButton.IsDisposed) + { + _downloadSpellIconPackageButton.Enabled = true; + _downloadSpellIconPackageButton.Text = SpellIconCatalog.IsPackageAvailable + ? "检查更新" + : "下载数据包"; + } + } + } + + private void UpdateSpellIconPackageCard() + { + var packagePath = SpellIconCatalog.PackagePath; + if (SpellIconCatalog.IsPackageAvailable && File.Exists(packagePath)) + { + var length = new FileInfo(packagePath).Length; + _spellIconPackageStatusLabel.Text = + $"已安装:{length / 1024d / 1024d:F2} MiB。点击检查 GitHub 更新。"; + _downloadSpellIconPackageButton.Text = "检查更新"; + } + else if (File.Exists(packagePath)) + { + _spellIconPackageStatusLabel.Text = "本地数据包损坏或格式不受支持;技能图标与添加技能联想不可用。"; + _downloadSpellIconPackageButton.Text = "重新下载"; + } + else + { + _spellIconPackageStatusLabel.Text = "未安装;技能图标与添加技能联想不可用。"; + _downloadSpellIconPackageButton.Text = "下载数据包"; + } + + _settingsToolTip.SetToolTip( + _spellIconPackageStatusLabel, + _spellIconPackageStatusLabel.Text); + } + private void OpenModuleWebsite() { try diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 9137cde7..009440c9 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -107,7 +107,7 @@ public sealed class ModuleEditorControl : UserControl _fieldCatalog = ConditionFieldCatalog.Load(baseDirectory); _keymapCatalog = KeymapCatalog.Load(baseDirectory); InitializeComponent(); - SpellIconCatalog.IconAvailable += OnSpellIconAvailable; + SpellIconCatalog.CatalogChanged += OnSpellIconCatalogChanged; LoadModules(); } @@ -117,7 +117,7 @@ public sealed class ModuleEditorControl : UserControl { CloseRulesComboDropDown(); CloseAdjustmentComboDropDown(); - SpellIconCatalog.IconAvailable -= OnSpellIconAvailable; + SpellIconCatalog.CatalogChanged -= OnSpellIconCatalogChanged; } base.Dispose(disposing); @@ -184,7 +184,12 @@ public sealed class ModuleEditorControl : UserControl Font, index => index >= 0 && index < _modules.Count ? (_modules[index].Match.ClassId, _modules[index].Match.SpecId) - : (null, null)); + : (null, null), + itemForeColorSelector: index => index >= 0 + && index < _modules.Count + && _moduleStore.HasImportIssue(_modules[index].Id) + ? UiTheme.Danger + : null); _moduleList.BackColor = UiTheme.SurfaceRaised; _moduleList.SelectedIndexChanged += (_, _) => SelectModule(_moduleList.SelectedIndex); sidebar.Controls.Add(_moduleList, 0, 0); @@ -1168,22 +1173,21 @@ public sealed class ModuleEditorControl : UserControl } } - private void OnSpellIconAvailable(long spellId) + private void OnSpellIconCatalogChanged() { if (IsDisposed || Disposing || !IsHandleCreated) { return; } - BeginInvoke((Action)(() => + if (InvokeRequired) { - if (IsDisposed || Disposing) - { - return; - } + BeginInvoke(OnSpellIconCatalogChanged); + return; + } - RefreshRuleSpellIcons(); - })); + RefreshRuleSpellIcons(); + _rulesGrid.Invalidate(); } private void ReloadCurrentClassSpellIds() @@ -3172,7 +3176,7 @@ public sealed class ModuleEditorControl : UserControl { _moduleStore.Reload(); } - _modules = _moduleStore.GetModules().ToList(); + _modules = _moduleStore.GetModulesForDisplay().ToList(); _moduleList.Items.Clear(); foreach (var module in _modules) { diff --git a/UI/SpellIconCatalog.cs b/UI/SpellIconCatalog.cs index 9f1a3492..689edc16 100644 --- a/UI/SpellIconCatalog.cs +++ b/UI/SpellIconCatalog.cs @@ -1,34 +1,19 @@ using System.Drawing; -using System.Net.Http.Headers; -using System.Text.Json; namespace Shigure; internal sealed record SpellSuggestion(long SpellId, string Name); /// -/// 技能名称/ID 到技能图标的目录。优先读取自定义嵌入资源和发布数据包,开发环境 -/// 回退到 Assets/Spell;未知 ID 会在后台从 Wowhead 下载并按图标资源名缓存。 +/// 技能名称/ID 到技能图标的只读目录。完整目录只来自外置数据包;数据包缺失或 +/// 损坏时,技能图标与 spellId 联想均不可用。 /// internal static class SpellIconCatalog { private static readonly object SyncRoot = new(); private static readonly Dictionary Icons = new(); private static readonly Dictionary NamedIcons = new(StringComparer.Ordinal); - private static readonly HashSet PendingDownloads = new(); - private static readonly Dictionary RetryAfter = new(); - private static readonly SpellIconPackage? PackagedCatalog = SpellIconPackage.TryOpen(); - private static readonly CatalogData Catalog = LoadCatalog(); - private static readonly Dictionary SpellIdsByName = LoadSpellIdsByName(); - private static readonly SpellSuggestion[] SuggestionsBySpellId = LoadSpellSuggestions(); - private static readonly Dictionary IconTargetsBySpellId = Catalog.TargetsBySpellId; - private static readonly string RuntimeCacheDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Shigure", - "SpellIcons"); - private static readonly string RuntimeIndexPath = Path.Combine(RuntimeCacheDirectory, "index.json"); - private static readonly Dictionary RuntimeIconsBySpellId = LoadRuntimeIndex(); - private static readonly HttpClient HttpClient = CreateHttpClient(); + private static readonly Dictionary RegisteredSpellIdsByName = new(StringComparer.Ordinal); private static readonly Dictionary SpellIdIconResources = new() { @@ -51,7 +36,33 @@ internal static class SpellIconCatalog private static readonly string LastRuleRowIconResource = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.last-rule-row.png"; - internal static event Action? IconAvailable; + private static SpellIconPackage? _package; + private static Dictionary _spellIdsByName = new(StringComparer.Ordinal); + private static SpellSuggestion[] _suggestionsBySpellId = []; + + static SpellIconCatalog() + { + _package = SpellIconPackage.TryOpen(PackagePath); + RebuildIndexesLocked(); + } + + internal static event Action? CatalogChanged; + + internal static string PackagePath => Path.Combine( + AppContext.BaseDirectory, + "data", + "SpellIcons.shgpack"); + + internal static bool IsPackageAvailable + { + get + { + lock (SyncRoot) + { + return _package is not null; + } + } + } public static Image? Get(long spellId) { @@ -62,35 +73,25 @@ internal static class SpellIconCatalog lock (SyncRoot) { + if (_package is null) + { + return null; + } + if (Icons.TryGetValue(spellId, out var cached)) { return cached; } - } - var local = LoadCatalogIcon(spellId); - if (local is not null) - { - return Cache(spellId, local); - } - - string? runtimeIcon; - lock (SyncRoot) - { - RuntimeIconsBySpellId.TryGetValue(spellId, out runtimeIcon); - } - - if (!string.IsNullOrWhiteSpace(runtimeIcon)) - { - var diskIcon = LoadImageFile(GetRuntimeIconPath(runtimeIcon)); - if (diskIcon is not null) + Image? icon = null; + if (SpellIdIconResources.TryGetValue(spellId, out var resourceName)) { - return Cache(spellId, diskIcon); + icon = LoadResource(resourceName); } - } - QueueDownload(spellId); - return null; + icon ??= _package.LoadIcon(spellId); + return icon is null ? null : CacheLocked(spellId, icon); + } } public static Image? Get(string? spellName) @@ -101,21 +102,22 @@ internal static class SpellIconCatalog return null; } - if (NamedIconResources.TryGetValue(normalized, out var resourceName)) - { - return GetNamedIcon(normalized, resourceName); - } - - long spellId; lock (SyncRoot) { - if (!SpellIdsByName.TryGetValue(normalized, out spellId)) + if (_package is null) { return null; } - } - return Get(spellId); + if (NamedIconResources.TryGetValue(normalized, out var resourceName)) + { + return GetNamedIconLocked(normalized, resourceName); + } + + return _spellIdsByName.TryGetValue(normalized, out var spellId) + ? Get(spellId) + : null; + } } public static void Register(long spellId, string? spellName) @@ -128,103 +130,203 @@ internal static class SpellIconCatalog lock (SyncRoot) { - SpellIdsByName[normalized] = spellId; + RegisteredSpellIdsByName[normalized] = spellId; + _spellIdsByName[normalized] = spellId; } } internal static IReadOnlyList SearchByIdPrefix(string? prefix, int limit) { - var normalized = prefix; - if (limit <= 0 - || string.IsNullOrEmpty(normalized) - || normalized.Length > 19 - || normalized[0] == '0' - || SuggestionsBySpellId.Length == 0) + lock (SyncRoot) { - return Array.Empty(); - } - - long prefixValue = 0; - foreach (var character in normalized) - { - if (character is < '0' or > '9') + var normalized = prefix; + if (_package is null + || limit <= 0 + || string.IsNullOrEmpty(normalized) + || normalized.Length > 19 + || normalized[0] == '0' + || _suggestionsBySpellId.Length == 0) { return Array.Empty(); } - var digit = character - '0'; - if (prefixValue > (long.MaxValue - digit) / 10) + long prefixValue = 0; + foreach (var character in normalized) { - return Array.Empty(); - } - - prefixValue = prefixValue * 10 + digit; - } - - if (prefixValue <= 0) - { - return Array.Empty(); - } - - var maximumSpellId = SuggestionsBySpellId[^1].SpellId; - var maximumDigits = 1; - for (var remaining = maximumSpellId; remaining >= 10; remaining /= 10) - { - maximumDigits++; - } - - var maximumSuffixDigits = maximumDigits - normalized.Length; - if (maximumSuffixDigits < 0) - { - return Array.Empty(); - } - - var matches = new List(Math.Min(limit, 8)); - long scale = 1; - for (var suffixDigits = 0; suffixDigits <= maximumSuffixDigits; suffixDigits++) - { - if (prefixValue > long.MaxValue / scale) - { - break; - } - - var start = prefixValue * scale; - var intervalLength = scale - 1; - var end = intervalLength > long.MaxValue - start - ? long.MaxValue - : start + intervalLength; - var index = LowerBoundSuggestion(start); - while (index < SuggestionsBySpellId.Length - && SuggestionsBySpellId[index].SpellId <= end) - { - matches.Add(SuggestionsBySpellId[index]); - if (matches.Count >= limit) + if (character is < '0' or > '9') { - return matches; + return Array.Empty(); } - index++; + var digit = character - '0'; + if (prefixValue > (long.MaxValue - digit) / 10) + { + return Array.Empty(); + } + + prefixValue = prefixValue * 10 + digit; } - if (scale > long.MaxValue / 10) + if (prefixValue <= 0) { - break; + return Array.Empty(); } - scale *= 10; - } + var maximumSpellId = _suggestionsBySpellId[^1].SpellId; + var maximumDigits = 1; + for (var remaining = maximumSpellId; remaining >= 10; remaining /= 10) + { + maximumDigits++; + } - return matches; + var maximumSuffixDigits = maximumDigits - normalized.Length; + if (maximumSuffixDigits < 0) + { + return Array.Empty(); + } + + var matches = new List(Math.Min(limit, 8)); + long scale = 1; + for (var suffixDigits = 0; suffixDigits <= maximumSuffixDigits; suffixDigits++) + { + if (prefixValue > long.MaxValue / scale) + { + break; + } + + var start = prefixValue * scale; + var intervalLength = scale - 1; + var end = intervalLength > long.MaxValue - start + ? long.MaxValue + : start + intervalLength; + var index = LowerBoundSuggestionLocked(start); + while (index < _suggestionsBySpellId.Length + && _suggestionsBySpellId[index].SpellId <= end) + { + matches.Add(_suggestionsBySpellId[index]); + if (matches.Count >= limit) + { + return matches; + } + + index++; + } + + if (scale > long.MaxValue / 10) + { + break; + } + + scale *= 10; + } + + return matches; + } } - private static int LowerBoundSuggestion(long spellId) + public static Image? GetLastRuleRowIcon() + { + lock (SyncRoot) + { + return _package is null + ? null + : GetNamedIconLocked("last-rule-row", LastRuleRowIconResource); + } + } + + internal static void ValidatePackage(string path) + { + using var package = SpellIconPackage.Open(path); + } + + internal static void InstallPackage(string downloadedPath) + { + ValidatePackage(downloadedPath); + + var targetPath = PackagePath; + var targetExisted = File.Exists(targetPath); + var backupPath = $"{targetPath}.{Guid.NewGuid():N}.backup"; + Exception? failure = null; + + lock (SyncRoot) + { + var oldPackage = _package; + _package = null; + oldPackage?.Dispose(); + DisposeImageCachesLocked(); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + if (targetExisted) + { + File.Replace(downloadedPath, targetPath, backupPath, ignoreMetadataErrors: true); + } + else + { + File.Move(downloadedPath, targetPath); + } + + _package = SpellIconPackage.Open(targetPath); + RebuildIndexesLocked(); + } + catch (Exception ex) + { + failure = ex; + TryRestorePreviousPackage(targetPath, backupPath, targetExisted); + _package = SpellIconPackage.TryOpen(targetPath); + RebuildIndexesLocked(); + } + } + + TryDeleteFile(backupPath); + CatalogChanged?.Invoke(); + + if (failure is not null) + { + throw new IOException("安装技能图标数据包失败,已尝试恢复原数据包。", failure); + } + } + + private static void TryRestorePreviousPackage(string targetPath, string backupPath, bool targetExisted) + { + try + { + if (targetExisted && File.Exists(backupPath)) + { + File.Move(backupPath, targetPath, overwrite: true); + } + else if (!targetExisted) + { + TryDeleteFile(targetPath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 后续重新打开会把无法恢复的状态视为“未安装”。 + } + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 临时/备份文件清理失败不覆盖主要操作结果。 + } + } + + private static int LowerBoundSuggestionLocked(long spellId) { var low = 0; - var high = SuggestionsBySpellId.Length; + var high = _suggestionsBySpellId.Length; while (low < high) { var middle = low + (high - low) / 2; - if (SuggestionsBySpellId[middle].SpellId < spellId) + if (_suggestionsBySpellId[middle].SpellId < spellId) { low = middle + 1; } @@ -237,86 +339,27 @@ internal static class SpellIconCatalog return low; } - public static Image? GetLastRuleRowIcon() - => GetNamedIcon("last-rule-row", LastRuleRowIconResource); - - private static Image? LoadCatalogIcon(long spellId) + private static Image CacheLocked(long spellId, Image icon) { - string? resourceName; - lock (SyncRoot) + if (Icons.TryGetValue(spellId, out var cached)) { - resourceName = SpellIdIconResources.GetValueOrDefault(spellId); + icon.Dispose(); + return cached; } - if (resourceName is not null) - { - return LoadResource(resourceName); - } - - var packaged = PackagedCatalog?.LoadIcon(spellId); - if (packaged is not null) - { - return packaged; - } - - string? target; - lock (SyncRoot) - { - target = IconTargetsBySpellId.GetValueOrDefault(spellId); - } - - if (!string.IsNullOrWhiteSpace(target)) - { - var assetRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Assets")); - var iconPath = Path.GetFullPath(Path.Combine(assetRoot, target.Replace('/', Path.DirectorySeparatorChar))); - var spellRoot = Path.GetFullPath(Path.Combine(assetRoot, "Spell")); - if (string.Equals(Path.GetDirectoryName(iconPath), spellRoot, StringComparison.OrdinalIgnoreCase) - && string.Equals(Path.GetExtension(iconPath), ".jpg", StringComparison.OrdinalIgnoreCase)) - { - var icon = LoadImageFile(iconPath); - if (icon is not null) - { - return icon; - } - } - } - - // Compatibility with the earlier per-spell embedded resource layout. - resourceName = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.spell-{spellId}.jpg"; - return LoadResource(resourceName); + Icons[spellId] = icon; + return icon; } - private static Image Cache(long spellId, Image icon) + private static Image? GetNamedIconLocked(string cacheKey, string resourceName) { - lock (SyncRoot) + if (NamedIcons.TryGetValue(cacheKey, out var cached)) { - if (Icons.TryGetValue(spellId, out var cached)) - { - icon.Dispose(); - return cached; - } - - Icons[spellId] = icon; - return icon; - } - } - - private static Image? GetNamedIcon(string cacheKey, string resourceName) - { - lock (SyncRoot) - { - if (NamedIcons.TryGetValue(cacheKey, out var cached)) - { - return cached; - } + return cached; } var icon = LoadResource(resourceName); - lock (SyncRoot) - { - NamedIcons[cacheKey] = icon; - } - + NamedIcons[cacheKey] = icon; return icon; } @@ -339,253 +382,44 @@ internal static class SpellIconCatalog } } - private static Image? LoadImageFile(string path) + private static void RebuildIndexesLocked() { - if (!File.Exists(path)) + _spellIdsByName = new Dictionary(RegisteredSpellIdsByName, StringComparer.Ordinal); + if (_package is null) { - return null; + _suggestionsBySpellId = []; + return; } - try + foreach (var (name, spellId) in _package.SpellIdsByName) { - using var source = Image.FromFile(path); - return new Bitmap(source); - } - catch (Exception ex) when (ex is ArgumentException or IOException) - { - return null; - } - } - - private static void QueueDownload(long spellId) - { - lock (SyncRoot) - { - if (PendingDownloads.Contains(spellId) - || RetryAfter.TryGetValue(spellId, out var retryAfter) && retryAfter > DateTime.UtcNow) - { - return; - } - - PendingDownloads.Add(spellId); + _spellIdsByName.TryAdd(name, spellId); } - _ = DownloadAndCacheAsync(spellId); - } - - private static async Task DownloadAndCacheAsync(long spellId) - { - try - { - using var tooltipResponse = await HttpClient.GetAsync( - $"https://nether.wowhead.com/tooltip/spell/{spellId}?dataEnv=1").ConfigureAwait(false); - tooltipResponse.EnsureSuccessStatusCode(); - await using var tooltipStream = await tooltipResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var tooltip = await JsonDocument.ParseAsync(tooltipStream).ConfigureAwait(false); - if (!tooltip.RootElement.TryGetProperty("icon", out var iconElement)) - { - throw new InvalidDataException("Spell tooltip did not contain an icon."); - } - - var iconName = NormalizeIconName(iconElement.GetString()); - if (iconName is null) - { - throw new InvalidDataException("Spell tooltip returned an invalid icon name."); - } - - Directory.CreateDirectory(RuntimeCacheDirectory); - var target = GetRuntimeIconPath(iconName); - if (!File.Exists(target)) - { - var bytes = await HttpClient.GetByteArrayAsync( - $"https://wow.zamimg.com/images/wow/icons/large/{iconName}.jpg").ConfigureAwait(false); - if (bytes.Length < 512) - { - throw new InvalidDataException("Downloaded icon is unexpectedly small."); - } - - using (var memory = new MemoryStream(bytes, writable: false)) - using (Image.FromStream(memory)) - { - // Decode before persisting so error payloads never enter the cache. - } - - var temporary = $"{target}.{Guid.NewGuid():N}.download"; - await File.WriteAllBytesAsync(temporary, bytes).ConfigureAwait(false); - File.Move(temporary, target, overwrite: true); - } - - var image = LoadImageFile(target) - ?? throw new InvalidDataException("Downloaded icon could not be decoded."); - Cache(spellId, image); - - lock (SyncRoot) - { - RuntimeIconsBySpellId[spellId] = iconName; - RetryAfter.Remove(spellId); - SaveRuntimeIndex(); - } - - IconAvailable?.Invoke(spellId); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException - or IOException or JsonException or InvalidDataException or ArgumentException) - { - lock (SyncRoot) - { - RetryAfter[spellId] = DateTime.UtcNow.AddMinutes(5); - } - } - finally - { - lock (SyncRoot) - { - PendingDownloads.Remove(spellId); - } - } - } - - private static string? NormalizeIconName(string? value) - { - var normalized = value?.Trim().ToLowerInvariant(); - return !string.IsNullOrWhiteSpace(normalized) - && normalized.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-') - ? normalized - : null; - } - - private static string GetRuntimeIconPath(string iconName) - => Path.Combine(RuntimeCacheDirectory, $"icon-{iconName}.jpg"); - - private static HttpClient CreateHttpClient() - { - var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Shigure", "1.0")); - return client; - } - - private static Dictionary LoadRuntimeIndex() - { - try - { - if (!File.Exists(RuntimeIndexPath)) - { - return new Dictionary(); - } - - return JsonSerializer.Deserialize>(File.ReadAllText(RuntimeIndexPath)) - ?? new Dictionary(); - } - catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) - { - return new Dictionary(); - } - } - - private static void SaveRuntimeIndex() - { - Directory.CreateDirectory(RuntimeCacheDirectory); - var temporary = $"{RuntimeIndexPath}.download"; - File.WriteAllText(temporary, JsonSerializer.Serialize(RuntimeIconsBySpellId)); - File.Move(temporary, RuntimeIndexPath, overwrite: true); - } - - private static Dictionary LoadSpellIdsByName() - { - var result = new Dictionary(Catalog.SpellIdsByName, StringComparer.Ordinal); - if (PackagedCatalog is null) - { - return result; - } - - foreach (var (name, spellId) in PackagedCatalog.SpellIdsByName) - { - result.TryAdd(name, spellId); - } - - return result; - } - - private static SpellSuggestion[] LoadSpellSuggestions() - { - var namesBySpellId = new Dictionary(Catalog.SpellNamesById); - if (PackagedCatalog is not null) - { - foreach (var (spellId, name) in PackagedCatalog.SpellNamesById) - { - namesBySpellId.TryAdd(spellId, name); - } - } - - return namesBySpellId + _suggestionsBySpellId = _package.SpellNamesById .Where(pair => pair.Key > 0 && !string.IsNullOrWhiteSpace(pair.Value)) .OrderBy(pair => pair.Key) .Select(pair => new SpellSuggestion(pair.Key, pair.Value)) .ToArray(); } - private static CatalogData LoadCatalog() + private static void DisposeImageCachesLocked() { - var result = new CatalogData(); - try + foreach (var image in Icons.Values) { - var manifestPath = Path.Combine(AppContext.BaseDirectory, "Assets", "SpellIconManifest.json"); - using var stream = File.OpenRead(manifestPath); - using var document = JsonDocument.Parse(stream); - if (!document.RootElement.TryGetProperty("spells", out var spells) - || spells.ValueKind != JsonValueKind.Array) - { - return result; - } - - foreach (var spell in spells.EnumerateArray()) - { - if (!spell.TryGetProperty("spellId", out var idElement) - || !idElement.TryGetInt64(out var id)) - { - continue; - } - - if (spell.TryGetProperty("name", out var nameElement)) - { - var name = nameElement.GetString()?.Trim(); - if (!string.IsNullOrWhiteSpace(name) && !result.SpellIdsByName.ContainsKey(name)) - { - result.SpellIdsByName[name] = id; - } - - if (!string.IsNullOrWhiteSpace(name)) - { - result.SpellNamesById[id] = name; - } - } - - if (spell.TryGetProperty("target", out var targetElement)) - { - var target = targetElement.GetString()?.Trim(); - if (!string.IsNullOrWhiteSpace(target)) - { - result.TargetsBySpellId[id] = target; - } - } - } - } - catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) - { - // 缺少或损坏清单时继续使用自定义图标和在线回退。 + image.Dispose(); } - return result; + Icons.Clear(); + foreach (var image in NamedIcons.Values) + { + image?.Dispose(); + } + + NamedIcons.Clear(); } - private sealed class CatalogData - { - public Dictionary SpellIdsByName { get; } = new(StringComparer.Ordinal); - public Dictionary SpellNamesById { get; } = new(); - public Dictionary TargetsBySpellId { get; } = new(); - } - - private sealed class SpellIconPackage + private sealed class SpellIconPackage : IDisposable { private static readonly byte[] Magic = "SHGICN1\0"u8.ToArray(); private const int Version = 1; @@ -603,10 +437,7 @@ internal static class SpellIconCatalog _stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); try { - using var reader = new BinaryReader( - _stream, - System.Text.Encoding.UTF8, - leaveOpen: true); + using var reader = new BinaryReader(_stream, System.Text.Encoding.UTF8, leaveOpen: true); if (!reader.ReadBytes(Magic.Length).SequenceEqual(Magic) || reader.ReadInt32() != Version) { @@ -706,17 +537,17 @@ internal static class SpellIconCatalog public Dictionary SpellIdsByName { get; } public Dictionary SpellNamesById { get; } - public static SpellIconPackage? TryOpen() + public static SpellIconPackage Open(string path) => new(path); + + public static SpellIconPackage? TryOpen(string path) { - var path = Path.Combine(AppContext.BaseDirectory, "data", "SpellIcons.shgpack"); try { - return File.Exists(path) ? new SpellIconPackage(path) : null; + return File.Exists(path) ? Open(path) : null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException) { - // 缺少或损坏数据包时继续使用文件夹、自定义图标和在线回退。 return null; } } @@ -733,21 +564,18 @@ internal static class SpellIconCatalog var bytes = new byte[_iconLengths[iconIndex]]; try { - lock (_stream) - { - _stream.Position = _iconOffsets[iconIndex]; - _stream.ReadExactly(bytes); - } - + _stream.Position = _iconOffsets[iconIndex]; + _stream.ReadExactly(bytes); using var memory = new MemoryStream(bytes, writable: false); using var source = Image.FromStream(memory); return new Bitmap(source); } - catch (Exception ex) when (ex is IOException or ArgumentException) + catch (Exception ex) when (ex is IOException or ArgumentException or ObjectDisposedException) { return null; } } - } + public void Dispose() => _stream.Dispose(); + } } diff --git a/UI/SpellIconPackageDownloadService.cs b/UI/SpellIconPackageDownloadService.cs new file mode 100644 index 00000000..72b412d6 --- /dev/null +++ b/UI/SpellIconPackageDownloadService.cs @@ -0,0 +1,277 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Shigure; + +internal sealed record SpellIconDownloadProgress(string Message, int? Percentage = null); + +internal sealed record SpellIconDownloadResult( + bool Changed, + bool UpToDate, + long Size, + string Sha256); + +internal sealed class SpellIconPackageDownloadService : IDisposable +{ + internal const string LatestReleaseApiUrl = + "https://api.github.com/repos/waynebian01/Shigure/releases/latest"; + private const string AssetName = "SpellIcons.shgpack"; + + private readonly HttpClient _httpClient; + + public SpellIconPackageDownloadService() + { + _httpClient = new HttpClient + { + Timeout = Timeout.InfiniteTimeSpan + }; + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Shigure", "1.0")); + _httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + } + + public async Task UpdateAsync( + IProgress? progress, + CancellationToken cancellationToken) + { + progress?.Report(new SpellIconDownloadProgress("正在读取 GitHub 最新正式版本……")); + // UpdateAsync is invoked by MainForm. Preserve that UI context so the final hot swap + // cannot dispose images while a DataGridView is painting them on the UI thread. + var asset = await GetLatestAssetAsync(cancellationToken); + + var localPath = SpellIconCatalog.PackagePath; + if (File.Exists(localPath)) + { + progress?.Report(new SpellIconDownloadProgress("正在比较本地与远端 SHA-256……")); + var localHash = await ComputeSha256Async(localPath, cancellationToken); + if (string.Equals(localHash, asset.Sha256, StringComparison.OrdinalIgnoreCase) + && SpellIconCatalog.IsPackageAvailable) + { + return new SpellIconDownloadResult( + Changed: false, + UpToDate: true, + asset.Size, + asset.Sha256); + } + } + + var dataDirectory = Path.GetDirectoryName(localPath) + ?? throw new InvalidOperationException("无法确定技能图标数据目录。"); + Directory.CreateDirectory(dataDirectory); + var temporaryPath = Path.Combine( + dataDirectory, + $".{AssetName}.{Guid.NewGuid():N}.download"); + + try + { + var downloadedHash = await DownloadAsync( + asset, + temporaryPath, + progress, + cancellationToken); + if (!string.Equals(downloadedHash, asset.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"数据包 SHA-256 校验失败:远端 {asset.Sha256},下载结果 {downloadedHash}。"); + } + + progress?.Report(new SpellIconDownloadProgress("正在验证并安装数据包……", 100)); + SpellIconCatalog.ValidatePackage(temporaryPath); + SpellIconCatalog.InstallPackage(temporaryPath); + return new SpellIconDownloadResult( + Changed: true, + UpToDate: false, + asset.Size, + asset.Sha256); + } + finally + { + TryDeleteFile(temporaryPath); + } + } + + private async Task GetLatestAssetAsync(CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync( + LatestReleaseApiUrl, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken) + .ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("assets", out var assets) + || assets.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("GitHub 最新正式版本没有返回 assets 列表。"); + } + + foreach (var item in assets.EnumerateArray()) + { + if (!item.TryGetProperty("name", out var nameElement) + || !string.Equals(nameElement.GetString(), AssetName, StringComparison.Ordinal)) + { + continue; + } + + var state = item.TryGetProperty("state", out var stateElement) + ? stateElement.GetString() + : null; + if (!string.Equals(state, "uploaded", StringComparison.Ordinal)) + { + throw new InvalidDataException($"GitHub 资产 {AssetName} 尚未上传完成。"); + } + + if (!item.TryGetProperty("size", out var sizeElement) + || !sizeElement.TryGetInt64(out var size) + || size <= 0) + { + throw new InvalidDataException($"GitHub 资产 {AssetName} 的大小无效。"); + } + + var downloadUrl = item.TryGetProperty("browser_download_url", out var urlElement) + ? urlElement.GetString() + : null; + if (!Uri.TryCreate(downloadUrl, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"GitHub 资产 {AssetName} 的下载地址无效。"); + } + + var digest = item.TryGetProperty("digest", out var digestElement) + ? digestElement.GetString() + : null; + const string prefix = "sha256:"; + if (string.IsNullOrWhiteSpace(digest) + || !digest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"GitHub 资产 {AssetName} 缺少 SHA-256 digest。"); + } + + var sha256 = digest[prefix.Length..].Trim(); + if (sha256.Length != 64 || !sha256.All(Uri.IsHexDigit)) + { + throw new InvalidDataException($"GitHub 资产 {AssetName} 的 SHA-256 digest 无效。"); + } + + return new ReleaseAsset(uri, size, sha256.ToUpperInvariant()); + } + + throw new FileNotFoundException($"GitHub 最新正式版本中找不到资产 {AssetName}。"); + } + + private async Task DownloadAsync( + ReleaseAsset asset, + string destination, + IProgress? progress, + CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync( + asset.DownloadUrl, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength is { } contentLength + && contentLength != asset.Size) + { + throw new InvalidDataException( + $"数据包大小与 GitHub 资产信息不一致:预计 {asset.Size},实际 {contentLength}。"); + } + + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + await using var output = new FileStream( + destination, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + + var buffer = new byte[1024 * 1024]; + long downloaded = 0; + var lastPercentage = -1; + int read; + while ((read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + hash.AppendData(buffer, 0, read); + downloaded += read; + if (downloaded > asset.Size) + { + throw new InvalidDataException("下载的数据超过 GitHub 声明的资产大小。"); + } + + var percentage = (int)Math.Min(100, downloaded * 100 / asset.Size); + if (percentage != lastPercentage) + { + lastPercentage = percentage; + progress?.Report(new SpellIconDownloadProgress( + $"正在下载:{FormatBytes(downloaded)} / {FormatBytes(asset.Size)}", + percentage)); + } + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + if (downloaded != asset.Size) + { + throw new InvalidDataException( + $"数据包下载不完整:预计 {asset.Size} 字节,实际 {downloaded} 字节。"); + } + + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private static async Task ComputeSha256Async( + string path, + CancellationToken cancellationToken) + { + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var digest = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(digest); + } + + private static string FormatBytes(long value) + { + string[] units = ["B", "KiB", "MiB", "GiB"]; + double size = value; + var unit = 0; + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + + return $"{size:0.##} {units[unit]}"; + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 下次运行不会读取 .download 文件。 + } + } + + public void Dispose() => _httpClient.Dispose(); + + private sealed record ReleaseAsset(Uri DownloadUrl, long Size, string Sha256); +} diff --git a/UI/StatusForm.cs b/UI/StatusForm.cs index 9ad83b14..d52e1c77 100644 --- a/UI/StatusForm.cs +++ b/UI/StatusForm.cs @@ -353,7 +353,7 @@ public sealed class StatusForm : Form }; AddNavGroup(nav, "常用"); - AddNavItem(nav, SettingsPage.General, SettingsNavIcon.General, "通用", CreatePageShell("通用", "运行控制、配置同步与模块选择", _settingsHost)); + AddNavItem(nav, SettingsPage.General, SettingsNavIcon.General, "通用", CreatePageShell("通用", "运行控制、配置同步、数据包与模块选择", _settingsHost)); AddNavGroup(nav, "编辑"); AddNavItem(nav, SettingsPage.Config, SettingsNavIcon.Config, "配置", CreatePageShell("配置", "编辑职业、专精和扫描字段", _configHost)); AddNavItem(nav, SettingsPage.Macros, SettingsNavIcon.Macros, "宏", CreatePageShell("宏", "维护职业动态宏、静态宏与特殊宏", _macrosHost)); @@ -1194,7 +1194,7 @@ public sealed class StatusForm : Form "队伍类型", "队伍人数", "首领战", "难度", "英雄天赋", "施法目标", "施法技能", "敌人数量", "敌人数-无仇恨", "敌人数-有仇恨", "施法(正计时)", "施法(倒计时)", "引导", "蓄力", "蓄力层数", - "酒池", "符文", "姿态", "神圣军备", "自律", "英勇打击", "收割者战刃" + "酒池", "符文", "姿态", "神圣军备", "自律", "英勇打击", "吸血鬼打击", "收割者战刃" ], 150), 0, 0); fields.Controls.Add(CreateCommonFieldCard( diff --git a/UI/UiTheme.cs b/UI/UiTheme.cs index 732cdcd2..e94ec1be 100644 --- a/UI/UiTheme.cs +++ b/UI/UiTheme.cs @@ -513,7 +513,8 @@ internal static class UiTheme Font font, Func? moduleMatchSelector = null, bool showClassIconWithSpec = true, - int? logicalIconSize = null) + int? logicalIconSize = null, + Func? itemForeColorSelector = null) { var logicalItemHeight = logicalIconSize is { } requestedIconSize ? Math.Max(40, Math.Clamp(requestedIconSize, 24, 64) + 16) @@ -607,7 +608,7 @@ internal static class UiTheme listBox.Items[e.Index]?.ToString() ?? string.Empty, font, textBounds, - selected ? Text : Muted, + itemForeColorSelector?.Invoke(e.Index) ?? (selected ? Text : Muted), TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis); }; diff --git a/一键打包.py b/一键打包.py index aeeb173d..10712db7 100644 --- a/一键打包.py +++ b/一键打包.py @@ -3,9 +3,6 @@ from __future__ import annotations import os import re import shutil -import hashlib -import json -import struct import subprocess import sys import tempfile @@ -19,6 +16,7 @@ ADDON_OLD_NAME = "Fuyutsui" OLD_NAMES = (OLD_NAME, ADDON_OLD_NAME) PROTECTED_TEXTS = ( "https://www.shigure.club", + "https://api.github.com/repos/waynebian01/Shigure/releases/latest", "访问 Shigure 官网,浏览并获取可用模块", ) @@ -75,14 +73,6 @@ TARGET_FRAMEWORK_PATTERN = re.compile( re.IGNORECASE, ) DOTNET_INSTALL_SCRIPT_URL = "https://dot.net/v1/dotnet-install.ps1" -SPELL_ICON_PACKAGE_MAGIC = b"SHGICN1\0" -SPELL_ICON_PACKAGE_VERSION = 1 -SPELL_ICON_PACKAGE_HEADER = struct.Struct("<8sIIIIqqqq") -SPELL_ICON_PACKAGE_SPELL_RECORD = struct.Struct(" None: for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): @@ -287,151 +277,19 @@ def copy_to_build_environment(root: Path, script_path: Path, build_root: Path) - def ignore_files(directory: str, names: list[str]) -> set[str]: ignored = {name for name in names if name in SKIP_DIRS} - if Path(directory).resolve() == resolved_root: + resolved_directory = Path(directory).resolve() + if resolved_directory == resolved_root: ignored.add(script_path.name) + if resolved_directory == resolved_root / "Assets": + ignored.add("SpellIconManifest.json") + ignored.add("SpellIcons.shgpack") + if resolved_directory == resolved_root / "Assets" / "Spell": + ignored.update(name for name in names if name.startswith("icon-") and name.endswith(".jpg")) return ignored shutil.copytree(root, build_root, ignore=ignore_files, dirs_exist_ok=True) -def build_spell_icon_package(build_root: Path) -> Path: - asset_root = (build_root / "Assets").resolve() - spell_root = (asset_root / "Spell").resolve() - manifest_path = asset_root / "SpellIconManifest.json" - output_path = asset_root / "SpellIcons.shgpack" - - if not manifest_path.is_file(): - raise FileNotFoundError(f"找不到法术图标清单: {manifest_path}") - if not spell_root.is_dir(): - raise FileNotFoundError(f"找不到法术图标目录: {spell_root}") - - with manifest_path.open("r", encoding="utf-8") as stream: - manifest = json.load(stream) - - rows = manifest.get("spells") - if not isinstance(rows, list): - raise ValueError("法术图标清单缺少 spells 数组。") - - spells: list[tuple[int, str, str]] = [] - for row in rows: - if not isinstance(row, dict): - continue - spell_id = int(row.get("spellId", 0)) - target = str(row.get("target", "")).strip().replace("\\", "/") - name = str(row.get("name", "")).strip() - if spell_id > 0 and target: - spells.append((spell_id, target, name)) - - spells.sort(key=lambda item: item[0]) - if len(spells) < 100_000: - raise ValueError(f"法术图标清单记录过少: {len(spells)}") - if any(spells[index - 1][0] == spells[index][0] for index in range(1, len(spells))): - raise ValueError("法术图标清单包含重复 spellId。") - - targets = sorted({target for _, target, _ in spells}, key=str.casefold) - if len(targets) < 10_000: - raise ValueError(f"法术图标数量过少: {len(targets)}") - target_indices = {target: index for index, target in enumerate(targets)} - - icon_paths: list[Path] = [] - for target in targets: - icon_path = (asset_root / target).resolve() - if ( - icon_path.parent != spell_root - or not icon_path.name.startswith("icon-") - or icon_path.suffix.casefold() != ".jpg" - or not icon_path.is_file() - ): - raise ValueError(f"无效或缺失的法术图标: {target}") - icon_paths.append(icon_path) - - name_records: list[tuple[int, bytes]] = [] - for spell_id, _, name in spells: - if not name: - continue - encoded = name.encode("utf-8") - if len(encoded) > 4096: - raise ValueError(f"法术名称异常过长: {spell_id}") - name_records.append((spell_id, encoded)) - - spell_map_offset = SPELL_ICON_PACKAGE_HEADER.size - icon_index_offset = ( - spell_map_offset + len(spells) * SPELL_ICON_PACKAGE_SPELL_RECORD.size - ) - name_index_offset = ( - icon_index_offset + len(icon_paths) * SPELL_ICON_PACKAGE_ICON_RECORD.size - ) - data_offset = name_index_offset + sum( - SPELL_ICON_PACKAGE_NAME_RECORD.size + len(name) - for _, name in name_records - ) - - icon_records: list[tuple[int, int]] = [] - next_offset = data_offset - for icon_path in icon_paths: - length = icon_path.stat().st_size - if length < 512 or length > 10 * 1024 * 1024: - raise ValueError(f"图标文件大小异常: {icon_path.name} ({length})") - icon_records.append((next_offset, length)) - next_offset += length - - descriptor, temporary_name = tempfile.mkstemp( - prefix=f".{output_path.name}.", - suffix=".building", - dir=output_path.parent, - ) - os.close(descriptor) - temporary_path = Path(temporary_name) - try: - with temporary_path.open("wb") as output: - output.write(SPELL_ICON_PACKAGE_HEADER.pack( - SPELL_ICON_PACKAGE_MAGIC, - SPELL_ICON_PACKAGE_VERSION, - len(spells), - len(icon_paths), - len(name_records), - spell_map_offset, - icon_index_offset, - name_index_offset, - data_offset, - )) - for spell_id, target, _ in spells: - output.write(SPELL_ICON_PACKAGE_SPELL_RECORD.pack( - spell_id, - target_indices[target], - )) - for offset, length in icon_records: - output.write(SPELL_ICON_PACKAGE_ICON_RECORD.pack(offset, length)) - for spell_id, name in name_records: - output.write(SPELL_ICON_PACKAGE_NAME_RECORD.pack(spell_id, len(name))) - output.write(name) - for icon_path in icon_paths: - with icon_path.open("rb") as icon: - shutil.copyfileobj(icon, output, length=1024 * 1024) - - if temporary_path.stat().st_size != next_offset: - raise IOError( - f"法术图标数据包大小不一致: 预计 {next_offset}," - f"实际 {temporary_path.stat().st_size}" - ) - os.replace(temporary_path, output_path) - finally: - temporary_path.unlink(missing_ok=True) - - digest = hashlib.sha256() - with output_path.open("rb") as package: - while chunk := package.read(1024 * 1024): - digest.update(chunk) - - print( - "法术图标数据包生成完成: " - f"spellId={len(spells)},图标={len(icon_paths)},名称={len(name_records)}," - f"大小={output_path.stat().st_size / 1024 / 1024:.2f} MiB," - f"SHA-256={digest.hexdigest()}" - ) - return output_path - - def get_required_dotnet_sdk(project_path: Path) -> tuple[str, int]: result = read_text(project_path) if result is None: @@ -682,9 +540,6 @@ def main() -> int: update_company_name(project_path, company_name) print(f"隔离副本中的公司名称已设置为: {company_name}") - print("正在生成只读法术图标数据包……") - build_spell_icon_package(build_root) - output_dir = root / "artifacts" / "publish" / "win-x64" publish(build_root, new_name, dotnet_command, output_dir) except BaseException as exc: diff --git a/打包说明.md b/打包说明.md index 4c01bb39..8b3f2ddb 100644 --- a/打包说明.md +++ b/打包说明.md @@ -68,8 +68,8 @@ MyShigure - 把当前项目复制到系统临时目录,建立独立的构建环境 - 仅在隔离副本中把 `Shigure` 和 `Fuyutsui` 替换成输入的新名称,包括插件的 `.lua` 和 `.toc` 文件 - 仅在隔离副本中递归重命名相关文件和目录,并修改公司名称 -- 根据 `Assets\SpellIconManifest.json` 和 `Assets\Spell` 在隔离副本中生成只读的 `SpellIcons.shgpack` -- 每次发布前清空旧发布目录,避免松散图标和旧数据包残留混用 +- 从隔离副本排除完整技能图标源和清单,不生成或附带 `SpellIcons.shgpack` +- 每次发布前清空旧发布目录,避免旧数据包残留在新发布版中 - 在隔离副本中运行 `dotnet publish`,不会产生或修改当前项目的源码、`bin`、`obj` - 发布结束后自动删除临时构建环境 - 打包成功后自动打开 `artifacts\publish\win-x64` @@ -142,19 +142,18 @@ artifacts\publish\win-x64 ```text Fuyutsui -data\SpellIcons.shgpack config keymap wow_process.txt ``` -其中插件目录是插件权威源,程序会在启动或“更新配置”时把它部署到已运行游戏的 `Interface\AddOns\插件名称`。如果 `config` 或 `keymap` 缺失或文件不完整,程序启动时会从插件目录中的职业配置和宏自动补齐。职业/专精和自定义动作图标仍嵌入程序;一键打包生成的完整 spellId 图标库位于外置的 `data\SpellIcons.shgpack`,必须随程序分发。上述运行数据会在发布时复制到输出目录,最稳妥的方式仍是把 `win-x64` 整个文件夹压缩成 zip 再发送。 +其中插件目录是插件权威源,程序会在启动或“更新配置”时把它部署到已运行游戏的 `Interface\AddOns\插件名称`。如果 `config` 或 `keymap` 缺失或文件不完整,程序启动时会从插件目录中的职业配置和宏自动补齐。完整技能图标库不再由打包流程生成或分发;用户在“设置 → 通用 → 下载数据包”中从 GitHub 最新正式 Release 下载后,程序会把它保存到 `data\SpellIcons.shgpack`。上述运行数据会在发布时复制到输出目录,最稳妥的方式仍是把 `win-x64` 整个文件夹压缩成 zip 再发送。 -开发构建和直接执行 `dotnet publish` 时,如果项目根目录没有 `Assets\SpellIcons.shgpack`,程序仍使用 `Assets\Spell` 与 `Assets\SpellIconManifest.json`。运行时的读取顺序是:自定义嵌入图标 → `data\SpellIcons.shgpack` → 松散图标文件夹 → 在线下载缓存。 +开发构建和直接执行 `dotnet publish` 同样不会复制完整图标、清单或数据包。运行时只读取 `data\SpellIcons.shgpack`;缺少或损坏时所有技能图标留空,添加技能的 spellId 联想关闭,不会再从松散文件或 Wowhead 在线补图。 模块不随发布包分发:模块保存在我的文档目录 `{MyDocuments}/Shigure/module`(首次运行自动创建),全新用户首次启动模块列表为空。使用改名脚本打包时该目录随程序名变化,例如改名为 `MyShigure` 后为 `{MyDocuments}/MyShigure/module`。 -不要只发送单个 exe,也不要删除发布目录中的插件目录或 `data\SpellIcons.shgpack`。缺少外置图标库时程序仍可在线补图,但完整本地图标库将不可用;缺少内置插件目录时配置/宏编辑和游戏插件部署会失败。 +不要只发送单个 exe,也不要删除发布目录中的插件目录。数据包由每位用户按需下载;已下载的数据包不要在程序运行时手工覆盖。缺少内置插件目录时,配置/宏编辑和游戏插件部署会失败。 ## 7. 常见问题