diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs index 394eb1ccb8..bb0316bb99 100644 --- a/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs +++ b/Assets/PerfectWorld/Scripts/Addressable/AddressableManager.cs @@ -53,6 +53,15 @@ namespace BrewMonster.Scripts base.Initialize(); _isInitialized = false; _initializationTcs = new UniTaskCompletionSource(); + StartAddressablesInitAfterBootstrapGate().Forget(); + } + + /// + /// Waits for (version HTTP + optional URL rewrite) when that gate is active. + /// + async UniTaskVoid StartAddressablesInitAfterBootstrapGate() + { + await GameContentBootstrap.WaitForPreAddressablesSetupIfAnyAsync(); Addressables.InitializeAsync().Completed += OnInitializeComplete; } diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs new file mode 100644 index 0000000000..20b29d3fd7 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using BrewMonster; +using Cysharp.Threading.Tasks; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.AddressableAssets.ResourceLocators; +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace BrewMonster.Scripts +{ + /// + /// Remote catalog / bundle flows: CheckForCatalogUpdates, UpdateCatalogs, optional download size and dependencies. + /// Matches the “catalog new → bundle new / cache” behaviour described in project docs (Addressables overview). + /// + public static class AddressablesCatalogUpdater + { + /// + /// Result of checking the remote catalog hash list (may be empty when nothing changed). + /// + public readonly struct CatalogCheckResult + { + public CatalogCheckResult(bool success, IReadOnlyList catalogsWithUpdates, Exception error) + { + Success = success; + CatalogsWithUpdates = catalogsWithUpdates ?? Array.Empty(); + Error = error; + } + + public bool Success { get; } + public IReadOnlyList CatalogsWithUpdates { get; } + public bool HasUpdates => CatalogsWithUpdates.Count > 0; + public Exception Error { get; } + } + + /// + /// Result after applying . + /// + public readonly struct CatalogApplyResult + { + public CatalogApplyResult(bool success, IReadOnlyList locators, Exception error) + { + Success = success; + Locators = locators ?? Array.Empty(); + Error = error; + } + + public bool Success { get; } + public IReadOnlyList Locators { get; } + public Exception Error { get; } + } + + /// + /// Ensures Addressables finished initial catalog load before checking remote updates. + /// + public static async UniTask EnsureInitializedAsync() + { + await Addressables.InitializeAsync().ToUniTask(); + } + + /// + /// Calls — lightweight compared to full bundle downloads. + /// + /// Pass through to Addressables (default true). + public static async UniTask CheckForCatalogUpdatesAsync(bool autoReleaseHandle = true) + { + try + { + await EnsureInitializedAsync(); + var handle = Addressables.CheckForCatalogUpdates(autoReleaseHandle); + await handle.ToUniTask(); + if (handle.Status != AsyncOperationStatus.Succeeded) + { + var err = handle.OperationException; + BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates failed: {err?.Message}"); + return new CatalogCheckResult(false, null, err); + } + + var list = handle.Result; + var ids = list != null ? (IReadOnlyList)list : Array.Empty(); + return new CatalogCheckResult(true, ids, null); + } + catch (Exception e) + { + BMLogger.LogError($"AddressablesCatalogUpdater: CheckForCatalogUpdates exception: {e.Message}"); + return new CatalogCheckResult(false, null, e); + } + } + + /// + /// Downloads new catalog JSON and refreshes locators. Pass from + /// or null to use Addressables’ internal list. + /// + /// When true, removes unreferenced bundles after update (see Addressables docs). + public static async UniTask UpdateCatalogsAsync( + IEnumerable catalogIds = null, + bool autoCleanBundleCache = false, + bool autoReleaseHandle = true) + { + try + { + await EnsureInitializedAsync(); + AsyncOperationHandle> handle; + if (autoCleanBundleCache) + handle = Addressables.UpdateCatalogs(true, catalogIds, autoReleaseHandle); + else + handle = Addressables.UpdateCatalogs(catalogIds, autoReleaseHandle); + + await handle.ToUniTask(); + if (handle.Status != AsyncOperationStatus.Succeeded) + { + var err = handle.OperationException; + BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs failed: {err?.Message}"); + return new CatalogApplyResult(false, null, err); + } + + var locators = handle.Result; + var read = locators != null ? (IReadOnlyList)locators : Array.Empty(); + return new CatalogApplyResult(true, read, null); + } + catch (Exception e) + { + BMLogger.LogError($"AddressablesCatalogUpdater: UpdateCatalogs exception: {e.Message}"); + return new CatalogApplyResult(false, null, e); + } + } + + /// + /// Check remote catalog, then apply updates if any catalog ids were returned. + /// + public static async UniTask CheckAndUpdateCatalogsIfNeededAsync(bool autoCleanBundleCache = false) + { + var check = await CheckForCatalogUpdatesAsync(true); + if (!check.Success) + return new CatalogApplyResult(false, null, check.Error); + if (!check.HasUpdates) + return new CatalogApplyResult(true, Array.Empty(), null); + + return await UpdateCatalogsAsync(check.CatalogsWithUpdates, autoCleanBundleCache, true); + } + + /// + /// Bytes that would be downloaded for given the current catalog and local cache (0 if fully cached). + /// + public static async UniTask GetDownloadSizeBytesAsync(object key) + { + try + { + await EnsureInitializedAsync(); + var handle = Addressables.GetDownloadSizeAsync(key); + await handle.ToUniTask(); + if (handle.Status != AsyncOperationStatus.Succeeded) + { + BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeAsync failed: {handle.OperationException?.Message}"); + return -1; + } + return handle.Result; + } + catch (Exception e) + { + BMLogger.LogError($"AddressablesCatalogUpdater: GetDownloadSizeBytesAsync exception: {e.Message}"); + return -1; + } + } + + /// + /// Ensures dependencies for exist on disk; uses cache when CRC/catalog match (no redundant full download). + /// + public static async UniTask DownloadDependenciesAsync(object key, bool autoReleaseHandle = true) + { + try + { + await EnsureInitializedAsync(); + var handle = Addressables.DownloadDependenciesAsync(key, autoReleaseHandle); + await handle.ToUniTask(); + if (handle.Status != AsyncOperationStatus.Succeeded) + { + BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync failed: {handle.OperationException?.Message}"); + return false; + } + return true; + } + catch (Exception e) + { + BMLogger.LogError($"AddressablesCatalogUpdater: DownloadDependenciesAsync exception: {e.Message}"); + return false; + } + } + + /// + /// Removes bundles not referenced by the given catalogs (optional maintenance / “clear old cache”). + /// + public static async UniTask CleanBundleCacheAsync(IEnumerable catalogIds = null) + { + try + { + await EnsureInitializedAsync(); + var handle = Addressables.CleanBundleCache(catalogIds); + await handle.ToUniTask(); + if (handle.Status != AsyncOperationStatus.Succeeded) + { + BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCache failed: {handle.OperationException?.Message}"); + return false; + } + return handle.Result; + } + catch (Exception e) + { + BMLogger.LogError($"AddressablesCatalogUpdater: CleanBundleCacheAsync exception: {e.Message}"); + return false; + } + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta new file mode 100644 index 0000000000..cf10f310a6 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesCatalogUpdater.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e0ec4bd4b83dbd488a581d5288a5e52 \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs new file mode 100644 index 0000000000..04187a6b4d --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs @@ -0,0 +1,80 @@ +using System; +using BrewMonster; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.ResourceLocations; + +namespace BrewMonster.Scripts +{ + /// + /// Runtime rewrite of bundle/catalog URLs via + /// (e.g. swap CDN host or path prefix). Must run before the first successful remote load — ideally before + /// if all remote ids should be rewritten. + /// + public static class AddressablesRuntimeUrlRewriter + { + static Func s_previousTransform; + static string s_fromPrefix; + static string s_toPrefix; + + /// + /// When true, the installed rewriter calls the previous first. + /// + public static bool ChainPreviousTransform { get; set; } = true; + + /// + /// Replaces the start of each location’s when it begins with + /// (e.g. build-time profile URL) by (runtime CDN). + /// + public static void InstallPrefixRewrite(string fromPrefix, string toPrefix) + { + if (string.IsNullOrEmpty(fromPrefix)) + { + BMLogger.LogError("AddressablesRuntimeUrlRewriter: fromPrefix is null or empty."); + return; + } + + s_fromPrefix = fromPrefix; + s_toPrefix = toPrefix ?? string.Empty; + + if (ChainPreviousTransform) + s_previousTransform = Addressables.InternalIdTransformFunc; + + Addressables.InternalIdTransformFunc = Transform; + BMLogger.Log($"AddressablesRuntimeUrlRewriter: Installed prefix rewrite (from length={fromPrefix.Length}, to length={s_toPrefix.Length})."); + } + + /// + /// Removes this package’s rewrite and restores the delegate that was present at install time (if any). + /// + public static void ClearInstalledRewrite() + { + if (Addressables.InternalIdTransformFunc == Transform) + { + Addressables.InternalIdTransformFunc = s_previousTransform; + s_previousTransform = null; + s_fromPrefix = null; + s_toPrefix = null; + BMLogger.Log("AddressablesRuntimeUrlRewriter: Cleared installed rewrite."); + } + } + + static string Transform(IResourceLocation location) + { + string id = location != null ? location.InternalId : null; + if (string.IsNullOrEmpty(id)) + return id; + + if (ChainPreviousTransform && s_previousTransform != null) + id = s_previousTransform(location); + + if (!string.IsNullOrEmpty(s_fromPrefix) && + id.StartsWith(s_fromPrefix, StringComparison.OrdinalIgnoreCase)) + { + return s_toPrefix + id.Substring(s_fromPrefix.Length); + } + + return id; + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta new file mode 100644 index 0000000000..5792b8b9e7 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/AddressablesRuntimeUrlRewriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57d28e0e103bc3743a57b12f74be99ae \ No newline at end of file diff --git a/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs new file mode 100644 index 0000000000..7289244734 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs @@ -0,0 +1,244 @@ +using System; +using BrewMonster; +using Cysharp.Threading.Tasks; +using UnityEngine; +using UnityEngine.Networking; + +namespace BrewMonster.Scripts +{ + /// + /// Bootstrap: gọi server lấy contentVersion + (tuỳ chọn) assetsBaseUrl, gắn URL rewrite trước khi Addressables init, + /// rồi nếu lần đầu hoặc contentVersion khác đã lưu thì cập nhật catalog và tải toàn bộ entry gắn label. + /// First run / version bump: call server for contentVersion + optional assetsBaseUrl, apply URL rewrite before Addressables init, + /// then if first launch or stored version differs, update catalogs and download all entries under the configured label. + /// + [DefaultExecutionOrder(-2000)] + public class GameContentBootstrap : MonoBehaviour + { + const string PrefsFirstSyncDone = "PW_GameContent_FirstRemoteSyncDone"; + const string PrefsLastContentVersion = "PW_GameContent_LastContentVersion"; + + /// + /// When non-null, waits on this before . + /// + static UniTaskCompletionSource s_addressablesInitGate; + + [Header("Version API")] + [Tooltip("GET JSON: { \"contentVersion\": \"12\", \"assetsBaseUrl\": \"https://cdn/.../\" }")] + [SerializeField] string _versionEndpointUrl; + + [SerializeField] int _requestTimeoutSeconds = 20; + + [Header("Addressables")] + [Tooltip("If true, blocks Addressables init until version HTTP + optional URL rewrite finish (recommended).")] + [SerializeField] bool _holdAddressablesInitUntilVersionChecked = true; + + [Tooltip("Prefix baked into remote InternalIds at build (Remote.LoadPath). When server sends assetsBaseUrl, replace this prefix.")] + [SerializeField] string _bakedRemoteUrlPrefixForRewrite; + + [Tooltip("Label applied in Addressables Groups to every remote asset that must download on first run / content update.")] + [SerializeField] string _remoteBulkDownloadLabel = "RemoteContent"; + + [Header("Editor")] + [SerializeField] bool _skipRemoteCallInEditor; + + [SerializeField] string _editorFakeContentVersion = "editor"; + + public event Action Finished; + + public static UniTask WaitForPreAddressablesSetupIfAnyAsync() + { + if (s_addressablesInitGate == null) + return UniTask.CompletedTask; + return s_addressablesInitGate.Task; + } + + void Awake() + { + if (_holdAddressablesInitUntilVersionChecked) + s_addressablesInitGate ??= new UniTaskCompletionSource(); + } + + void Start() + { + RunAsync().Forget(); + } + + void OnDestroy() + { + ReleaseGateIfPending(); + } + + void OnApplicationQuit() + { + ReleaseGateIfPending(); + } + + static void ReleaseGateIfPending() + { + if (s_addressablesInitGate != null) + { + s_addressablesInitGate.TrySetResult(); + s_addressablesInitGate = null; + } + } + + async UniTaskVoid RunAsync() + { + var result = await RunInternalAsync(); + Finished?.Invoke(result); + } + + public async UniTask RunInternalAsync() + { + try + { + var dto = await FetchVersionDtoAsync(); + if (!dto.Ok) + { + AllowAddressablesInit(); + return new BootstrapResult(false, false, dto.Error, null); + } + + if (!string.IsNullOrWhiteSpace(dto.AssetsBaseUrl) && + !string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite)) + { + AddressablesRuntimeUrlRewriter.InstallPrefixRewrite( + _bakedRemoteUrlPrefixForRewrite, + dto.AssetsBaseUrl); + } + + AllowAddressablesInit(); + + await AddressablesCatalogUpdater.EnsureInitializedAsync(); + + bool firstRun = PlayerPrefs.GetInt(PrefsFirstSyncDone, 0) == 0; + string localVersion = PlayerPrefs.GetString(PrefsLastContentVersion, string.Empty); + bool needContentWork = + firstRun || + !string.Equals(localVersion, dto.ContentVersion ?? string.Empty, StringComparison.Ordinal); + + if (!needContentWork) + { + BMLogger.Log("[Cuong] GameContentBootstrap: contentVersion unchanged, skip catalog/bulk download."); + return new BootstrapResult(true, false, null, dto.ContentVersion); + } + + var catalog = await AddressablesCatalogUpdater.CheckAndUpdateCatalogsIfNeededAsync(false); + if (!catalog.Success) + return new BootstrapResult(false, true, catalog.Error?.Message ?? "Catalog update failed", dto.ContentVersion); + + if (string.IsNullOrWhiteSpace(_remoteBulkDownloadLabel)) + { + BMLogger.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel is empty; skipping bulk download."); + return new BootstrapResult(false, true, "remoteBulkDownloadLabel empty", dto.ContentVersion); + } + + bool dl = await AddressablesCatalogUpdater.DownloadDependenciesAsync(_remoteBulkDownloadLabel.Trim(), true); + if (!dl) + return new BootstrapResult(false, true, "DownloadDependencies failed", dto.ContentVersion); + + PlayerPrefs.SetString(PrefsLastContentVersion, dto.ContentVersion); + PlayerPrefs.SetInt(PrefsFirstSyncDone, 1); + PlayerPrefs.Save(); + + BMLogger.Log($"[Cuong] GameContentBootstrap: content sync OK, version={dto.ContentVersion}"); + return new BootstrapResult(true, true, null, dto.ContentVersion); + } + catch (Exception e) + { + BMLogger.LogError($"[Cuong] GameContentBootstrap: {e.Message}"); + AllowAddressablesInit(); + return new BootstrapResult(false, true, e.Message, null); + } + } + + static void AllowAddressablesInit() + { + if (s_addressablesInitGate != null) + { + s_addressablesInitGate.TrySetResult(); + s_addressablesInitGate = null; + } + } + + async UniTask FetchVersionDtoAsync() + { +#if UNITY_EDITOR + if (_skipRemoteCallInEditor) + { + return new VersionDtoResult(true, _editorFakeContentVersion?.Trim() ?? "editor", null, null); + } +#endif + if (string.IsNullOrWhiteSpace(_versionEndpointUrl)) + { + BMLogger.LogWarning("[Cuong] GameContentBootstrap: _versionEndpointUrl empty; using empty contentVersion (no remote check)."); + return new VersionDtoResult(true, string.Empty, null, null); + } + + using var req = UnityWebRequest.Get(_versionEndpointUrl); + req.timeout = Mathf.Max(5, _requestTimeoutSeconds); + await req.SendWebRequest().ToUniTask(); + + if (req.result != UnityWebRequest.Result.Success) + return new VersionDtoResult(false, null, null, req.error); + + string json = req.downloadHandler?.text; + if (string.IsNullOrWhiteSpace(json)) + return new VersionDtoResult(false, null, null, "Empty response body"); + + try + { + var parsed = JsonUtility.FromJson(json); + if (parsed == null || string.IsNullOrWhiteSpace(parsed.contentVersion)) + return new VersionDtoResult(false, null, null, "JSON missing contentVersion"); + + return new VersionDtoResult(true, parsed.contentVersion.Trim(), parsed.assetsBaseUrl?.Trim(), null); + } + catch (Exception e) + { + return new VersionDtoResult(false, null, null, $"JSON parse: {e.Message}"); + } + } + + readonly struct VersionDtoResult + { + public VersionDtoResult(bool ok, string contentVersion, string assetsBaseUrl, string error) + { + Ok = ok; + ContentVersion = contentVersion; + AssetsBaseUrl = assetsBaseUrl; + Error = error; + } + + public bool Ok { get; } + public string ContentVersion { get; } + public string AssetsBaseUrl { get; } + public string Error { get; } + } + + [Serializable] + class VersionResponseJson + { + public string contentVersion; + public string assetsBaseUrl; + } + + public readonly struct BootstrapResult + { + public BootstrapResult(bool success, bool didContentWork, string errorMessage, string serverContentVersion) + { + Success = success; + DidContentWork = didContentWork; + ErrorMessage = errorMessage; + ServerContentVersion = serverContentVersion; + } + + public bool Success { get; } + /// True if catalog/bulk download ran this session. + public bool DidContentWork { get; } + public string ErrorMessage { get; } + public string ServerContentVersion { get; } + } + } +} diff --git a/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta new file mode 100644 index 0000000000..54bcdf57c5 --- /dev/null +++ b/Assets/PerfectWorld/Scripts/Addressable/GameContentBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d93de6847f714b43830673032ac76d8 \ No newline at end of file