mirror of
https://github.com/waynebian01/Shigure.git
synced 2026-09-03 07:15:24 +08:00
- Increased RowCount in MainForm's layout to accommodate additional elements. - Adjusted margins for various cards in MainForm to enhance spacing. - Removed unnecessary column span setting for spellIconPackageCard in MainForm. - Introduced a new dictionary to map spell IDs to names in SpellIconCatalog. - Added functionality to promote pending packages in SpellIconCatalog. - Modified the Register method in SpellIconCatalog to allow overwriting spell names. - Implemented ResolveSuggestionName and GetRegisteredSpellNamesSnapshot methods in SpellIconCatalog. - Removed the SpellSuggestionPopup class as it is no longer needed. - Updated build script to ignore the SpellIconPackage directory during the build process.
542 lines
18 KiB
C#
542 lines
18 KiB
C#
using System.Drawing;
|
|
|
|
namespace Shigure;
|
|
|
|
internal sealed record SpellSuggestion(long SpellId, string Name);
|
|
|
|
/// <summary>
|
|
/// 技能名称/ID 到技能图标的只读目录。完整目录只来自外置数据包;数据包缺失或
|
|
/// 损坏时,技能图标与 spellId 联想均不可用。
|
|
/// </summary>
|
|
internal static class SpellIconCatalog
|
|
{
|
|
private static readonly object SyncRoot = new();
|
|
private static readonly Dictionary<long, Image> Icons = new();
|
|
private static readonly Dictionary<string, Image?> NamedIcons = new(StringComparer.Ordinal);
|
|
private static readonly Dictionary<string, long> RegisteredSpellIdsByName = new(StringComparer.Ordinal);
|
|
private static readonly Dictionary<long, string> RegisteredSpellNamesById = new();
|
|
|
|
private static readonly Dictionary<long, string> SpellIdIconResources = new()
|
|
{
|
|
[35395] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.crusader-strike.png"
|
|
};
|
|
|
|
private static readonly Dictionary<string, string> NamedIconResources = new(StringComparer.Ordinal)
|
|
{
|
|
["银月城生命药水"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.silvermoon-city-health-potion.png",
|
|
[ModuleSpecialActions.OneKeySpell] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.one-key-spell.png",
|
|
[ModuleSpecialActions.PauseSpell] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.pause.png",
|
|
[ModuleSpecialActions.FailedSpell] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.auto-insert-spell.png",
|
|
["鲁莽药水"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.recklessness-potion.jpg",
|
|
["圣光潜力"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.lights-potential.jpg",
|
|
["光注法力药水"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.light-infused-mana-potion.jpg",
|
|
["十字军打击"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.crusader-strike.png",
|
|
["停止施法"] = $"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.stop-casting.png"
|
|
};
|
|
|
|
private static readonly string LastRuleRowIconResource =
|
|
$"{typeof(SpellIconCatalog).Namespace}.Assets.Spell.last-rule-row.png";
|
|
|
|
private static SpellIconPackage? _package;
|
|
private static Dictionary<string, long> _spellIdsByName = new(StringComparer.Ordinal);
|
|
private static IReadOnlyList<SpellSuggestion> _suggestionsBySpellId = Array.Empty<SpellSuggestion>();
|
|
|
|
static SpellIconCatalog()
|
|
{
|
|
PromotePendingPackage();
|
|
_package = SpellIconPackage.TryOpen(PackagePath);
|
|
RebuildIndexesLocked();
|
|
}
|
|
|
|
internal static event Action? CatalogChanged;
|
|
|
|
internal static string PackagePath => Path.Combine(
|
|
AppContext.BaseDirectory,
|
|
"data",
|
|
"SpellIcons.shgpack");
|
|
|
|
internal static string PendingPackagePath => $"{PackagePath}.pending";
|
|
|
|
internal static bool IsPackageAvailable
|
|
{
|
|
get
|
|
{
|
|
lock (SyncRoot)
|
|
{
|
|
return _package is not null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void PromotePendingPackage()
|
|
{
|
|
if (!File.Exists(PendingPackagePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using (SpellIconPackage.Open(PendingPackagePath))
|
|
{
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(PackagePath)!);
|
|
File.Move(PendingPackagePath, PackagePath, overwrite: true);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
|
or InvalidDataException or ArgumentException)
|
|
{
|
|
// 保留当前可用数据包;待下次启动时再次尝试应用已校验的名称修复包。
|
|
}
|
|
}
|
|
|
|
public static Image? Get(long spellId)
|
|
{
|
|
if (spellId <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
lock (SyncRoot)
|
|
{
|
|
if (_package is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (Icons.TryGetValue(spellId, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
Image? icon = null;
|
|
if (SpellIdIconResources.TryGetValue(spellId, out var resourceName))
|
|
{
|
|
icon = LoadResource(resourceName);
|
|
}
|
|
|
|
icon ??= _package.LoadIcon(spellId);
|
|
return icon is null ? null : CacheLocked(spellId, icon);
|
|
}
|
|
}
|
|
|
|
public static Image? Get(string? spellName)
|
|
{
|
|
var normalized = spellName?.Trim();
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
lock (SyncRoot)
|
|
{
|
|
if (_package is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
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, bool overwriteIdName = false)
|
|
{
|
|
var normalized = spellName?.Trim();
|
|
if (spellId <= 0 || string.IsNullOrWhiteSpace(normalized))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (SyncRoot)
|
|
{
|
|
RegisteredSpellIdsByName[normalized] = spellId;
|
|
if (overwriteIdName)
|
|
{
|
|
RegisteredSpellNamesById[spellId] = normalized;
|
|
}
|
|
else
|
|
{
|
|
RegisteredSpellNamesById.TryAdd(spellId, normalized);
|
|
}
|
|
|
|
_spellIdsByName[normalized] = spellId;
|
|
}
|
|
}
|
|
|
|
internal static string? ResolveSuggestionName(long spellId, string? packageName)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(packageName))
|
|
{
|
|
return packageName;
|
|
}
|
|
|
|
lock (SyncRoot)
|
|
{
|
|
return RegisteredSpellNamesById.GetValueOrDefault(spellId);
|
|
}
|
|
}
|
|
|
|
internal static IReadOnlyDictionary<long, string> GetRegisteredSpellNamesSnapshot()
|
|
{
|
|
lock (SyncRoot)
|
|
{
|
|
return new Dictionary<long, string>(RegisteredSpellNamesById);
|
|
}
|
|
}
|
|
|
|
internal static IReadOnlyList<SpellSuggestion> GetSuggestionsSnapshot()
|
|
{
|
|
lock (SyncRoot)
|
|
{
|
|
return _package is null
|
|
? Array.Empty<SpellSuggestion>()
|
|
: _suggestionsBySpellId;
|
|
}
|
|
}
|
|
|
|
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 Image CacheLocked(long spellId, Image icon)
|
|
{
|
|
if (Icons.TryGetValue(spellId, out var cached))
|
|
{
|
|
icon.Dispose();
|
|
return cached;
|
|
}
|
|
|
|
Icons[spellId] = icon;
|
|
return icon;
|
|
}
|
|
|
|
private static Image? GetNamedIconLocked(string cacheKey, string resourceName)
|
|
{
|
|
if (NamedIcons.TryGetValue(cacheKey, out var cached))
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
var icon = LoadResource(resourceName);
|
|
NamedIcons[cacheKey] = icon;
|
|
return icon;
|
|
}
|
|
|
|
private static Image? LoadResource(string resourceName)
|
|
{
|
|
using var stream = typeof(SpellIconCatalog).Assembly.GetManifestResourceStream(resourceName);
|
|
if (stream is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var source = Image.FromStream(stream);
|
|
return new Bitmap(source);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void RebuildIndexesLocked()
|
|
{
|
|
_spellIdsByName = new Dictionary<string, long>(RegisteredSpellIdsByName, StringComparer.Ordinal);
|
|
if (_package is null)
|
|
{
|
|
_suggestionsBySpellId = Array.Empty<SpellSuggestion>();
|
|
return;
|
|
}
|
|
|
|
foreach (var (name, spellId) in _package.SpellIdsByName)
|
|
{
|
|
_spellIdsByName.TryAdd(name, spellId);
|
|
}
|
|
|
|
_suggestionsBySpellId = Array.AsReadOnly(
|
|
_package.SpellIds
|
|
.Select(spellId => new SpellSuggestion(
|
|
spellId,
|
|
_package.SpellNamesById.GetValueOrDefault(spellId) ?? string.Empty))
|
|
.ToArray());
|
|
}
|
|
|
|
private static void DisposeImageCachesLocked()
|
|
{
|
|
foreach (var image in Icons.Values)
|
|
{
|
|
image.Dispose();
|
|
}
|
|
|
|
Icons.Clear();
|
|
foreach (var image in NamedIcons.Values)
|
|
{
|
|
image?.Dispose();
|
|
}
|
|
|
|
NamedIcons.Clear();
|
|
}
|
|
|
|
private sealed class SpellIconPackage : IDisposable
|
|
{
|
|
private static readonly byte[] Magic = "SHGICN1\0"u8.ToArray();
|
|
private const int Version = 1;
|
|
private const int HeaderSize = 56;
|
|
private const int RecordSize = 12;
|
|
|
|
private readonly FileStream _stream;
|
|
private readonly long[] _spellIds;
|
|
private readonly int[] _iconIndices;
|
|
private readonly long[] _iconOffsets;
|
|
private readonly int[] _iconLengths;
|
|
|
|
private SpellIconPackage(string path)
|
|
{
|
|
_stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
try
|
|
{
|
|
using var reader = new BinaryReader(_stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
|
if (!reader.ReadBytes(Magic.Length).SequenceEqual(Magic)
|
|
|| reader.ReadInt32() != Version)
|
|
{
|
|
throw new InvalidDataException("Unsupported spell icon package.");
|
|
}
|
|
|
|
var spellCount = reader.ReadInt32();
|
|
var iconCount = reader.ReadInt32();
|
|
var nameCount = reader.ReadInt32();
|
|
var spellMapOffset = reader.ReadInt64();
|
|
var iconIndexOffset = reader.ReadInt64();
|
|
var nameIndexOffset = reader.ReadInt64();
|
|
var dataOffset = reader.ReadInt64();
|
|
if (spellCount is < 1 or > 2_000_000
|
|
|| iconCount is < 1 or > 100_000
|
|
|| nameCount is < 0 or > 2_000_000
|
|
|| spellMapOffset != HeaderSize
|
|
|| iconIndexOffset != spellMapOffset + (long)spellCount * RecordSize
|
|
|| nameIndexOffset != iconIndexOffset + (long)iconCount * RecordSize
|
|
|| dataOffset < nameIndexOffset
|
|
|| dataOffset > _stream.Length)
|
|
{
|
|
throw new InvalidDataException("Invalid spell icon package header.");
|
|
}
|
|
|
|
_spellIds = new long[spellCount];
|
|
_iconIndices = new int[spellCount];
|
|
_stream.Position = spellMapOffset;
|
|
for (var index = 0; index < spellCount; index++)
|
|
{
|
|
var spellId = reader.ReadInt64();
|
|
var iconIndex = reader.ReadInt32();
|
|
if (spellId <= 0
|
|
|| index > 0 && spellId <= _spellIds[index - 1]
|
|
|| iconIndex < 0
|
|
|| iconIndex >= iconCount)
|
|
{
|
|
throw new InvalidDataException("Invalid spell map in icon package.");
|
|
}
|
|
|
|
_spellIds[index] = spellId;
|
|
_iconIndices[index] = iconIndex;
|
|
}
|
|
|
|
_iconOffsets = new long[iconCount];
|
|
_iconLengths = new int[iconCount];
|
|
_stream.Position = iconIndexOffset;
|
|
for (var index = 0; index < iconCount; index++)
|
|
{
|
|
var offset = reader.ReadInt64();
|
|
var length = reader.ReadInt32();
|
|
if (offset < dataOffset
|
|
|| length is < 512 or > 10 * 1024 * 1024
|
|
|| offset > _stream.Length - length)
|
|
{
|
|
throw new InvalidDataException("Invalid image index in icon package.");
|
|
}
|
|
|
|
_iconOffsets[index] = offset;
|
|
_iconLengths[index] = length;
|
|
}
|
|
|
|
SpellIdsByName = new Dictionary<string, long>(StringComparer.Ordinal);
|
|
SpellNamesById = new Dictionary<long, string>();
|
|
_stream.Position = nameIndexOffset;
|
|
for (var index = 0; index < nameCount; index++)
|
|
{
|
|
var spellId = reader.ReadInt64();
|
|
var byteLength = reader.ReadInt32();
|
|
if (spellId <= 0
|
|
|| byteLength is < 1 or > 4096
|
|
|| _stream.Position > dataOffset - byteLength)
|
|
{
|
|
throw new InvalidDataException("Invalid name index in icon package.");
|
|
}
|
|
|
|
var name = System.Text.Encoding.UTF8.GetString(reader.ReadBytes(byteLength));
|
|
if (!string.IsNullOrWhiteSpace(name))
|
|
{
|
|
SpellIdsByName.TryAdd(name, spellId);
|
|
SpellNamesById.TryAdd(spellId, name);
|
|
}
|
|
}
|
|
|
|
if (_stream.Position != dataOffset)
|
|
{
|
|
throw new InvalidDataException("Spell icon package index size mismatch.");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
_stream.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public Dictionary<string, long> SpellIdsByName { get; }
|
|
public Dictionary<long, string> SpellNamesById { get; }
|
|
public IReadOnlyList<long> SpellIds => _spellIds;
|
|
|
|
public static SpellIconPackage Open(string path) => new(path);
|
|
|
|
public static SpellIconPackage? TryOpen(string path)
|
|
{
|
|
try
|
|
{
|
|
return File.Exists(path) ? Open(path) : null;
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
|
or InvalidDataException or ArgumentException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public Image? LoadIcon(long spellId)
|
|
{
|
|
var spellIndex = Array.BinarySearch(_spellIds, spellId);
|
|
if (spellIndex < 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var iconIndex = _iconIndices[spellIndex];
|
|
var bytes = new byte[_iconLengths[iconIndex]];
|
|
try
|
|
{
|
|
_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 or ObjectDisposedException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _stream.Dispose();
|
|
}
|
|
}
|