From 4b9ebb61930aa5e2c9cc8f8af8b3cfd5336c3ca8 Mon Sep 17 00:00:00 2001 From: OLC Date: Sat, 22 Nov 2025 06:59:45 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=A3=80=E6=9F=A5=E5=8A=9F=E8=83=BD=E4=BB=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ModEntry 中初始化 UpdateChecker,确保在启动时检查更新 - 新增 UpdateInfoConfig 类以存储更新信息 - 实现 UpdateChecker 类,定期检查更新并处理更新信息 - 更新 UI 组件以显示更新状态和信息,包括更新指示器和检查按钮 - 增强多语言支持,添加更新相关的本地化文本 --- DuckovCustomModel/Configs/UpdateInfoConfig.cs | 37 +++ DuckovCustomModel/Localizations/Chinese.json | 12 +- .../Localizations/ChineseTraditional.json | 12 +- DuckovCustomModel/Localizations/English.json | 12 +- DuckovCustomModel/Localizations/Japanese.json | 12 +- .../Localizations/Localization.cs | 20 ++ DuckovCustomModel/Managers/UpdateChecker.cs | 273 ++++++++++++++++++ DuckovCustomModel/ModEntry.cs | 11 + DuckovCustomModel/UI/ConfigWindow.cs | 70 ++++- DuckovCustomModel/UI/Tabs/SettingsTab.cs | 179 ++++++++++++ 10 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 DuckovCustomModel/Configs/UpdateInfoConfig.cs create mode 100644 DuckovCustomModel/Managers/UpdateChecker.cs diff --git a/DuckovCustomModel/Configs/UpdateInfoConfig.cs b/DuckovCustomModel/Configs/UpdateInfoConfig.cs new file mode 100644 index 0000000..9f3a568 --- /dev/null +++ b/DuckovCustomModel/Configs/UpdateInfoConfig.cs @@ -0,0 +1,37 @@ +using System; + +namespace DuckovCustomModel.Configs +{ + public class UpdateInfoConfig : ConfigBase + { + public string LatestVersion { get; set; } = string.Empty; + public string LatestReleaseName { get; set; } = string.Empty; + public DateTime? LastCheckTime { get; set; } + public DateTime? LatestPublishedAt { get; set; } + public bool HasUpdate { get; set; } + + public override void LoadDefault() + { + LatestVersion = string.Empty; + LatestReleaseName = string.Empty; + LastCheckTime = null; + LatestPublishedAt = null; + HasUpdate = false; + } + + public override bool Validate() + { + return false; + } + + public override void CopyFrom(IConfigBase other) + { + if (other is not UpdateInfoConfig otherConfig) return; + LatestVersion = otherConfig.LatestVersion; + LatestReleaseName = otherConfig.LatestReleaseName; + LastCheckTime = otherConfig.LastCheckTime; + LatestPublishedAt = otherConfig.LatestPublishedAt; + HasUpdate = otherConfig.HasUpdate; + } + } +} diff --git a/DuckovCustomModel/Localizations/Chinese.json b/DuckovCustomModel/Localizations/Chinese.json index dfb3771..07565da 100644 --- a/DuckovCustomModel/Localizations/Chinese.json +++ b/DuckovCustomModel/Localizations/Chinese.json @@ -53,6 +53,16 @@ "BottomCenter": "下中", "BottomRight": "右下", "OffsetX": "偏移 X", - "OffsetY": "偏移 Y" + "OffsetY": "偏移 Y", + "UpdateAvailable": "有可用更新", + "CheckForUpdate": "检查更新", + "LatestVersion": "最新版本", + "LastCheckTime": "上次检查", + "UpdateCheckNotAvailable": "更新检查不可用", + "NeverChecked": "从未检查", + "JustNow": "刚刚", + "MinutesAgo": "分钟前", + "HoursAgo": "小时前", + "DaysAgo": "天前" } diff --git a/DuckovCustomModel/Localizations/ChineseTraditional.json b/DuckovCustomModel/Localizations/ChineseTraditional.json index 6255756..4ac56e1 100644 --- a/DuckovCustomModel/Localizations/ChineseTraditional.json +++ b/DuckovCustomModel/Localizations/ChineseTraditional.json @@ -53,6 +53,16 @@ "BottomCenter": "下中", "BottomRight": "右下", "OffsetX": "偏移 X", - "OffsetY": "偏移 Y" + "OffsetY": "偏移 Y", + "UpdateAvailable": "有可用更新", + "CheckForUpdate": "檢查更新", + "LatestVersion": "最新版本", + "LastCheckTime": "上次檢查", + "UpdateCheckNotAvailable": "更新檢查不可用", + "NeverChecked": "從未檢查", + "JustNow": "剛剛", + "MinutesAgo": "分鐘前", + "HoursAgo": "小時前", + "DaysAgo": "天前" } diff --git a/DuckovCustomModel/Localizations/English.json b/DuckovCustomModel/Localizations/English.json index 62466ad..a89968e 100644 --- a/DuckovCustomModel/Localizations/English.json +++ b/DuckovCustomModel/Localizations/English.json @@ -53,6 +53,16 @@ "BottomCenter": "Bottom-Center", "BottomRight": "Bottom-Right", "OffsetX": "Offset X", - "OffsetY": "Offset Y" + "OffsetY": "Offset Y", + "UpdateAvailable": "Update Available", + "CheckForUpdate": "Check for Update", + "LatestVersion": "Latest Version", + "LastCheckTime": "Last Check", + "UpdateCheckNotAvailable": "Update check not available", + "NeverChecked": "Never checked", + "JustNow": "Just now", + "MinutesAgo": "minutes ago", + "HoursAgo": "hours ago", + "DaysAgo": "days ago" } diff --git a/DuckovCustomModel/Localizations/Japanese.json b/DuckovCustomModel/Localizations/Japanese.json index 89d66c2..2a8ea0f 100644 --- a/DuckovCustomModel/Localizations/Japanese.json +++ b/DuckovCustomModel/Localizations/Japanese.json @@ -53,6 +53,16 @@ "BottomCenter": "下中央", "BottomRight": "右下", "OffsetX": "オフセット X", - "OffsetY": "オフセット Y" + "OffsetY": "オフセット Y", + "UpdateAvailable": "更新あり", + "CheckForUpdate": "更新を確認", + "LatestVersion": "最新バージョン", + "LastCheckTime": "最終確認", + "UpdateCheckNotAvailable": "更新確認が利用できません", + "NeverChecked": "未確認", + "JustNow": "たった今", + "MinutesAgo": "分前", + "HoursAgo": "時間前", + "DaysAgo": "日前" } diff --git a/DuckovCustomModel/Localizations/Localization.cs b/DuckovCustomModel/Localizations/Localization.cs index a5382cf..e3dce96 100644 --- a/DuckovCustomModel/Localizations/Localization.cs +++ b/DuckovCustomModel/Localizations/Localization.cs @@ -81,6 +81,16 @@ private static string LocalizationDirectory { "BottomRight", "Bottom-Right" }, { "OffsetX", "Offset X" }, { "OffsetY", "Offset Y" }, + { "UpdateAvailable", "Update Available" }, + { "CheckForUpdate", "Check for Update" }, + { "LatestVersion", "Latest Version" }, + { "LastCheckTime", "Last Check" }, + { "UpdateCheckNotAvailable", "Update check not available" }, + { "NeverChecked", "Never checked" }, + { "JustNow", "Just now" }, + { "MinutesAgo", "minutes ago" }, + { "HoursAgo", "hours ago" }, + { "DaysAgo", "days ago" }, }; public static string Title => GetText("Title"); @@ -138,6 +148,16 @@ private static string LocalizationDirectory public static string BottomRight => GetText("BottomRight"); public static string OffsetX => GetText("OffsetX"); public static string OffsetY => GetText("OffsetY"); + public static string UpdateAvailable => GetText("UpdateAvailable"); + public static string CheckForUpdate => GetText("CheckForUpdate"); + public static string LatestVersion => GetText("LatestVersion"); + public static string LastCheckTime => GetText("LastCheckTime"); + public static string UpdateCheckNotAvailable => GetText("UpdateCheckNotAvailable"); + public static string NeverChecked => GetText("NeverChecked"); + public static string JustNow => GetText("JustNow"); + public static string MinutesAgo => GetText("MinutesAgo"); + public static string HoursAgo => GetText("HoursAgo"); + public static string DaysAgo => GetText("DaysAgo"); public static event Action? OnLanguageChangedEvent; diff --git a/DuckovCustomModel/Managers/UpdateChecker.cs b/DuckovCustomModel/Managers/UpdateChecker.cs new file mode 100644 index 0000000..30d145a --- /dev/null +++ b/DuckovCustomModel/Managers/UpdateChecker.cs @@ -0,0 +1,273 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading; +using Cysharp.Threading.Tasks; +using DuckovCustomModel.Configs; +using Newtonsoft.Json; +using UnityEngine; +using UnityEngine.Networking; + +namespace DuckovCustomModel.Managers +{ + public class UpdateChecker : MonoBehaviour + { + private const string UpdateUrl = "https://duckov-custom-model-release-version.ritsukage.com/"; + private const float CheckIntervalHours = 1f; + private CancellationTokenSource? _periodicCheckCts; + private UpdateInfoConfig? _updateInfoConfig; + + public static UpdateChecker? Instance { get; private set; } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + + Instance = this; + DontDestroyOnLoad(gameObject); + } + + private void Start() + { + LoadUpdateInfo(); + StartPeriodicCheck(); + CheckForUpdateAsync().Forget(); + } + + private void OnDestroy() + { + _periodicCheckCts?.Cancel(); + _periodicCheckCts?.Dispose(); + _periodicCheckCts = null; + } + + public static event Action? OnUpdateCheckCompleted; + + private void LoadUpdateInfo() + { + try + { + _updateInfoConfig = ConfigManager.LoadConfigFromFile("UpdateInfoConfig.json"); + } + catch (Exception ex) + { + ModLogger.LogError($"Failed to load update info config: {ex.Message}"); + _updateInfoConfig = new UpdateInfoConfig(); + } + } + + private void SaveUpdateInfo() + { + if (_updateInfoConfig == null) return; + try + { + ConfigManager.SaveConfigToFile(_updateInfoConfig, "UpdateInfoConfig.json"); + } + catch (Exception ex) + { + ModLogger.LogError($"Failed to save update info config: {ex.Message}"); + } + } + + private void StartPeriodicCheck() + { + _periodicCheckCts?.Cancel(); + _periodicCheckCts?.Dispose(); + _periodicCheckCts = new CancellationTokenSource(); + PeriodicCheckAsync(_periodicCheckCts.Token).Forget(); + } + + private async UniTaskVoid PeriodicCheckAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await UniTask.Delay(TimeSpan.FromHours(CheckIntervalHours), cancellationToken: cancellationToken); + if (!cancellationToken.IsCancellationRequested) CheckForUpdateAsync().Forget(); + } + } + + public void CheckForUpdate() + { + CheckForUpdateAsync().Forget(); + } + + private async UniTaskVoid CheckForUpdateAsync() + { + const int maxRetries = 3; + const int retryDelaySeconds = 2; + const int requestTimeout = 60; + + for (var attempt = 1; attempt <= maxRetries; attempt++) + try + { + using var request = UnityWebRequest.Get(UpdateUrl); + request.timeout = requestTimeout; + + await request.SendWebRequest().ToUniTask(); + + if (request.result == UnityWebRequest.Result.Success) + { + var json = request.downloadHandler.text; + var releaseInfo = JsonConvert.DeserializeObject(json); + + if (releaseInfo == null || string.IsNullOrEmpty(releaseInfo.Version)) + { + ModLogger.LogWarning("Invalid release info received"); + OnUpdateCheckCompleted?.Invoke(false, null); + return; + } + + var latestVersion = NormalizeVersion(releaseInfo.Version); + var currentVersion = NormalizeVersion(Constant.ModVersion); + var hasUpdate = CompareVersions(currentVersion, latestVersion) < 0; + + _updateInfoConfig ??= new UpdateInfoConfig(); + + _updateInfoConfig.LatestVersion = releaseInfo.Version; + _updateInfoConfig.LatestReleaseName = releaseInfo.ReleaseName ?? releaseInfo.Version; + _updateInfoConfig.LastCheckTime = DateTime.Now; + _updateInfoConfig.HasUpdate = hasUpdate; + + if (DateTime.TryParse(releaseInfo.PublishedAt, out var publishedAt)) + _updateInfoConfig.LatestPublishedAt = publishedAt; + + SaveUpdateInfo(); + + ModLogger.Log( + $"Update check completed. Current: {Constant.ModVersion}, Latest: {releaseInfo.Version}, HasUpdate: {hasUpdate}"); + OnUpdateCheckCompleted?.Invoke(hasUpdate, releaseInfo.Version); + return; + } + + var errorMessage = request.error ?? "Unknown error"; + var isTimeout = errorMessage.Contains("timeout", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("timed out", StringComparison.OrdinalIgnoreCase); + + ModLogger.LogWarning( + isTimeout + ? $"Update check timeout (attempt {attempt}/{maxRetries}). This may be due to network issues." + : $"Failed to check for updates (attempt {attempt}/{maxRetries}): {errorMessage}"); + + if (attempt < maxRetries) + { + var delaySeconds = retryDelaySeconds * attempt; + ModLogger.Log($"Retrying update check in {delaySeconds} seconds..."); + await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds)); + } + else + { + ModLogger.LogWarning("Update check failed after all retry attempts."); + OnUpdateCheckCompleted?.Invoke(false, null); + } + } + catch (Exception ex) + { + ModLogger.LogWarning( + $"Error checking for updates (attempt {attempt}/{maxRetries}): {ex.Message}"); + + if (attempt < maxRetries) + { + await UniTask.Delay(TimeSpan.FromSeconds(retryDelaySeconds * attempt)); + } + else + { + ModLogger.LogError($"Failed to check for updates after {maxRetries} attempts: {ex.Message}"); + OnUpdateCheckCompleted?.Invoke(false, null); + } + } + } + + private static string NormalizeVersion(string version) + { + if (string.IsNullOrEmpty(version)) + return string.Empty; + + version = version.Trim(); + + if (version.StartsWith("v", StringComparison.OrdinalIgnoreCase)) version = version.Substring(1); + + var dashIndex = version.IndexOf('-'); + if (dashIndex >= 0) version = version.Substring(0, dashIndex); + + return version.Trim(); + } + + private static int CompareVersions(string version1, string version2) + { + if (string.IsNullOrEmpty(version1) && string.IsNullOrEmpty(version2)) + return 0; + if (string.IsNullOrEmpty(version1)) + return -1; + if (string.IsNullOrEmpty(version2)) + return 1; + + var v1Parts = version1.Split('.'); + var v2Parts = version2.Split('.'); + + var maxLength = Math.Max(v1Parts.Length, v2Parts.Length); + + for (var i = 0; i < maxLength; i++) + { + var v1Part = i < v1Parts.Length ? ParseVersionPart(v1Parts[i]) : 0; + var v2Part = i < v2Parts.Length ? ParseVersionPart(v2Parts[i]) : 0; + + if (v1Part < v2Part) + return -1; + if (v1Part > v2Part) + return 1; + } + + return 0; + } + + private static int ParseVersionPart(string part) + { + if (string.IsNullOrEmpty(part)) + return 0; + + part = part.Trim(); + var match = Regex.Match(part, @"^\d+"); + if (match.Success && int.TryParse(match.Value, out var result)) + return result; + + return 0; + } + + public bool HasUpdate() + { + return _updateInfoConfig?.HasUpdate ?? false; + } + + public string? GetLatestVersion() + { + return _updateInfoConfig?.LatestVersion; + } + + public string? GetLatestReleaseName() + { + return _updateInfoConfig?.LatestReleaseName; + } + + public DateTime? GetLastCheckTime() + { + return _updateInfoConfig?.LastCheckTime; + } + + public DateTime? GetLatestPublishedAt() + { + return _updateInfoConfig?.LatestPublishedAt; + } + + private class ReleaseInfo + { + [JsonProperty("version")] public string Version { get; set; } = string.Empty; + + [JsonProperty("release_name")] public string? ReleaseName { get; set; } + + [JsonProperty("published_at")] public string? PublishedAt { get; set; } + } + } +} diff --git a/DuckovCustomModel/ModEntry.cs b/DuckovCustomModel/ModEntry.cs index 864d56a..7dadc30 100644 --- a/DuckovCustomModel/ModEntry.cs +++ b/DuckovCustomModel/ModEntry.cs @@ -63,6 +63,7 @@ into modelID ModelListManager.RefreshModelList(priorityModelIDs); InitializeConfigWindow(); + InitializeUpdateChecker(); CustomDialogueManager.Initialize(); @@ -309,6 +310,16 @@ private static void InitializeConfigWindow() ModLogger.Log("ConfigWindow initialized."); } + private static void InitializeUpdateChecker() + { + if (UpdateChecker.Instance != null) return; + + var updateCheckerObject = new GameObject("UpdateChecker"); + updateCheckerObject.AddComponent(); + Object.DontDestroyOnLoad(updateCheckerObject); + ModLogger.Log("UpdateChecker initialized."); + } + private static void InitializeModelHandlerToCharacter(CharacterMainControl characterMainControl, string characterName, ModelTarget target = ModelTarget.Character) { diff --git a/DuckovCustomModel/UI/ConfigWindow.cs b/DuckovCustomModel/UI/ConfigWindow.cs index 1e46a11..8a24d74 100644 --- a/DuckovCustomModel/UI/ConfigWindow.cs +++ b/DuckovCustomModel/UI/ConfigWindow.cs @@ -57,6 +57,8 @@ public class ConfigWindow : MonoBehaviour private TabSystem? _tabSystem; private bool _uiActive; private GameObject? _uiRoot; + private GameObject? _updateIndicatorButton; + private GameObject? _updateIndicatorTitle; private static UIConfig? UIConfig => ModEntry.UIConfig; private static CharacterInputControl? CharacterInputControl => CharacterInputControl.Instance; @@ -74,6 +76,7 @@ private void Update() } UpdateSettingsButtonVisibility(); + UpdateUpdateIndicators(); if (uiConfig.DCMButtonAnchor != _lastAnchorPosition || Math.Abs(uiConfig.DCMButtonOffsetX - _lastOffsetX) > 0.01f || @@ -121,6 +124,11 @@ private void LateUpdate() Cursor.lockState = CursorLockMode.None; } + private void OnDestroy() + { + UpdateChecker.OnUpdateCheckCompleted -= OnUpdateCheckCompleted; + } + private void OnGUI() { if (!_showAnimatorParamsWindow) return; @@ -161,6 +169,12 @@ private void InitializeUI() _lastOffsetY = uiConfig.DCMButtonOffsetY; } + if (UpdateChecker.Instance != null) + { + UpdateChecker.OnUpdateCheckCompleted += OnUpdateCheckCompleted; + UpdateChecker.Instance.CheckForUpdate(); + } + ModLogger.Log("ConfigWindow initialized."); } @@ -220,8 +234,8 @@ private void BuildTitleBar() titleContainer.transform.SetParent(titleBar.transform, false); UIFactory.SetupRectTransform(titleContainer, new(0, 0), new(1, 1), offsetMin: new(10, 0), offsetMax: new(-10, 0)); - UIFactory.SetupHorizontalLayoutGroup(titleContainer, 8f, new(0, 0, 0, 0), TextAnchor.MiddleCenter, - false, false, false, false); + UIFactory.SetupHorizontalLayoutGroup(titleContainer, 12f, new(0, 0, 0, 0), TextAnchor.MiddleCenter, + true, false, false, false); var titleText = UIFactory.CreateText("Title", titleContainer.transform, Localization.Title, 20, @@ -236,6 +250,12 @@ private void BuildTitleBar() UIFactory.SetupRectTransform(versionLabel, Vector2.zero, Vector2.zero, new(0, 0)); UIFactory.SetupContentSizeFitter(versionLabel); + _updateIndicatorTitle = UIFactory.CreateText("UpdateIndicator", titleContainer.transform, "", 16, + new Color(1f, 0.6f, 0f, 1), TextAnchor.MiddleCenter, FontStyle.Bold); + UIFactory.SetupRectTransform(_updateIndicatorTitle, Vector2.zero, Vector2.zero, new(0, 0)); + UIFactory.SetupContentSizeFitter(_updateIndicatorTitle); + _updateIndicatorTitle.SetActive(false); + var titleContainerRect = titleContainer.GetComponent(); LayoutRebuilder.ForceRebuildLayoutImmediate(titleContainerRect); @@ -757,6 +777,13 @@ private void BuildSettingsButton() TextAnchor.MiddleCenter); UIFactory.SetupButtonText(buttonText, 16, 24); + _updateIndicatorButton = UIFactory.CreateText("UpdateIndicator", _settingsButton.transform, "!", 16, + new Color(1f, 0.6f, 0f, 1), TextAnchor.MiddleCenter); + UIFactory.SetupRectTransform(_updateIndicatorButton, new(0.8f, 0.8f), new(1f, 1f), new(0, 0), + pivot: new Vector2(1f, 1f), anchoredPosition: new Vector2(-2, -2)); + UIFactory.SetupContentSizeFitter(_updateIndicatorButton); + _updateIndicatorButton.SetActive(false); + UIFactory.SetupButtonColors(_settingsButton.GetComponent