Files
Shigure/UI/SpellIconCatalog.cs

619 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Drawing;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Shigure;
/// <summary>
/// 技能名称/ID 到技能图标的目录。优先读取自定义嵌入资源和发布数据包,开发环境
/// 回退到 Assets/Spell未知 ID 会在后台从 Wowhead 下载并按图标资源名缓存。
/// </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 HashSet<long> PendingDownloads = new();
private static readonly Dictionary<long, DateTime> RetryAfter = new();
private static readonly SpellIconPackage? PackagedCatalog = SpellIconPackage.TryOpen();
private static readonly CatalogData Catalog = LoadCatalog();
private static readonly Dictionary<string, long> SpellIdsByName = LoadSpellIdsByName();
private static readonly Dictionary<long, string> 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<long, string> RuntimeIconsBySpellId = LoadRuntimeIndex();
private static readonly HttpClient HttpClient = CreateHttpClient();
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";
internal static event Action<long>? IconAvailable;
public static Image? Get(long spellId)
{
if (spellId <= 0)
{
return null;
}
lock (SyncRoot)
{
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)
{
return Cache(spellId, diskIcon);
}
}
QueueDownload(spellId);
return null;
}
public static Image? Get(string? spellName)
{
var normalized = spellName?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (NamedIconResources.TryGetValue(normalized, out var resourceName))
{
return GetNamedIcon(normalized, resourceName);
}
long spellId;
lock (SyncRoot)
{
if (!SpellIdsByName.TryGetValue(normalized, out spellId))
{
return null;
}
}
return Get(spellId);
}
public static void Register(long spellId, string? spellName)
{
var normalized = spellName?.Trim();
if (spellId <= 0 || string.IsNullOrWhiteSpace(normalized))
{
return;
}
lock (SyncRoot)
{
SpellIdsByName[normalized] = spellId;
}
}
public static Image? GetLastRuleRowIcon()
=> GetNamedIcon("last-rule-row", LastRuleRowIconResource);
private static Image? LoadCatalogIcon(long spellId)
{
string? resourceName;
lock (SyncRoot)
{
resourceName = SpellIdIconResources.GetValueOrDefault(spellId);
}
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);
}
private static Image Cache(long spellId, Image icon)
{
lock (SyncRoot)
{
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;
}
}
var icon = LoadResource(resourceName);
lock (SyncRoot)
{
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 Image? LoadImageFile(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
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);
}
_ = 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<long, string> LoadRuntimeIndex()
{
try
{
if (!File.Exists(RuntimeIndexPath))
{
return new Dictionary<long, string>();
}
return JsonSerializer.Deserialize<Dictionary<long, string>>(File.ReadAllText(RuntimeIndexPath))
?? new Dictionary<long, string>();
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return new Dictionary<long, string>();
}
}
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<string, long> LoadSpellIdsByName()
{
var result = new Dictionary<string, long>(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 CatalogData LoadCatalog()
{
var result = new CatalogData();
try
{
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 (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)
{
// 缺少或损坏清单时继续使用自定义图标和在线回退。
}
return result;
}
private sealed class CatalogData
{
public Dictionary<string, long> SpellIdsByName { get; } = new(StringComparer.Ordinal);
public Dictionary<long, string> TargetsBySpellId { get; } = new();
}
private sealed class SpellIconPackage
{
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);
_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);
}
}
if (_stream.Position != dataOffset)
{
throw new InvalidDataException("Spell icon package index size mismatch.");
}
}
catch
{
_stream.Dispose();
throw;
}
}
public Dictionary<string, long> SpellIdsByName { get; }
public static SpellIconPackage? TryOpen()
{
var path = Path.Combine(AppContext.BaseDirectory, "data", "SpellIcons.shgpack");
try
{
return File.Exists(path) ? new SpellIconPackage(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
{
lock (_stream)
{
_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)
{
return null;
}
}
}
}