451 lines
20 KiB
C#
451 lines
20 KiB
C#
using System;
|
|
using System.Threading;
|
|
using BrewMonster;
|
|
using Cysharp.Threading.Tasks;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
|
|
namespace BrewMonster.Scripts
|
|
{
|
|
/// <summary>
|
|
/// Bootstrap: lấy contentVersion + (tuỳ chọn) assetsBaseUrl qua server hoặc hardcode, 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: resolve version + optional assetsBaseUrl (server or hardcoded), apply URL rewrite before Addressables init,
|
|
/// then if first launch or stored version differs, update catalogs and download all entries under the configured label.
|
|
/// </summary>
|
|
[DefaultExecutionOrder(-2000)]
|
|
public class GameContentBootstrap : MonoBehaviour
|
|
{
|
|
const string PrefsFirstSyncDone = "PW_GameContent_FirstRemoteSyncDone";
|
|
const string PrefsLastContentVersion = "PW_GameContent_LastContentVersion";
|
|
|
|
/// <summary>
|
|
/// When non-null, <see cref="AddressableManager"/> waits on this before <see cref="UnityEngine.AddressableAssets.Addressables.InitializeAsync"/>.
|
|
/// </summary>
|
|
static UniTaskCompletionSource s_addressablesInitGate;
|
|
static int s_activeBootstrapInstanceId;
|
|
static bool s_bootstrapRunStarted;
|
|
|
|
[Header("Debug")]
|
|
[Tooltip("Log lifecycle (Awake/Start/OnDestroy), gate state, scene name — dùng khi debug mobile.")]
|
|
[SerializeField]
|
|
bool _verboseLifecycleDebug = true;
|
|
|
|
[Tooltip("Nếu gate chưa mở sau N giây, tự release để AddressableManager không treo vĩnh viễn.")]
|
|
[SerializeField]
|
|
float _gateReleaseTimeoutSeconds = 45f;
|
|
|
|
[Header("Version source")]
|
|
[Tooltip("True = gọi HTTP tới URL bên dưới. False = không request server, chỉ dùng hardcode (test / API chưa chốt).")]
|
|
[SerializeField]
|
|
bool _useServerForVersionInfo;
|
|
|
|
[Header("Hardcoded (khi tắt server hoặc Editor skip remote)")]
|
|
[Tooltip("Dùng khi _useServerForVersionInfo = false, hoặc Editor bật Skip Remote (ưu tiên field này nếu có).")]
|
|
[SerializeField]
|
|
string _hardcodedContentVersion = "0";
|
|
|
|
[Tooltip("Tuỳ chọn: base URL CDN mới (rewrite), để trống nếu không đổi URL runtime.")]
|
|
[SerializeField]
|
|
string _hardcodedAssetsBaseUrl;
|
|
|
|
[Header("Version API (chỉ khi bật server)")]
|
|
[Tooltip("URL đầy đủ (https://...). Server trả JSON: contentVersion + tuỳ chọn assetsBaseUrl (prefix CDN). / Full URL; server returns JSON: contentVersion + optional assetsBaseUrl (CDN prefix).")]
|
|
[SerializeField]
|
|
string _versionEndpointUrl;
|
|
|
|
[Tooltip("Bật = POST JSON body bên dưới; tắt = GET (không body). Server kiểm tra body rồi trả cùng format JSON. / On = POST JSON body below; off = GET.")]
|
|
[SerializeField]
|
|
bool _usePostForVersionRequest;
|
|
|
|
[Tooltip("Body POST (JSON). Để trống → gửi \"{}\". Ví dụ: {\"userId\":\"...\",\"platform\":\"Android\"}. / POST JSON body; empty sends \"{}\".")]
|
|
[TextArea(2, 8)]
|
|
[SerializeField]
|
|
string _versionPostBody;
|
|
|
|
[SerializeField]
|
|
int _requestTimeoutSeconds = 20;
|
|
|
|
[Header("Addressables")]
|
|
[Tooltip("If true, blocks Addressables init until version resolve + optional URL rewrite finish (recommended).")]
|
|
[SerializeField]
|
|
bool _holdAddressablesInitUntilVersionChecked = true;
|
|
|
|
[Tooltip("Prefix baked into remote InternalIds at build (Remote.LoadPath). When assetsBaseUrl is set, 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")]
|
|
[Tooltip("Editor: không gọi HTTP server; dùng hardcode (nếu có version) hoặc _editorFakeContentVersion.")]
|
|
[SerializeField]
|
|
bool _skipRemoteCallInEditor;
|
|
|
|
[SerializeField]
|
|
string _editorFakeContentVersion = "editor";
|
|
|
|
public event Action<BootstrapResult> Finished;
|
|
|
|
/// <summary>Trạng thái gate tĩnh — dùng log chẩn đoán từ <see cref="AddressableManager"/>.</summary>
|
|
public static string GetGateDebugState()
|
|
{
|
|
if (!_holdAddressablesInitConfigured)
|
|
return "holdAddressablesInit=false (no gate)";
|
|
if (s_addressablesInitGate == null)
|
|
return "gate=null (released or not created)";
|
|
return $"gate=pending, activeInstanceId={s_activeBootstrapInstanceId}, runStarted={s_bootstrapRunStarted}";
|
|
}
|
|
|
|
static bool _holdAddressablesInitConfigured = true;
|
|
|
|
public static UniTask WaitForPreAddressablesSetupIfAnyAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (s_addressablesInitGate == null)
|
|
return UniTask.CompletedTask;
|
|
return s_addressablesInitGate.Task.AttachExternalCancellation(cancellationToken);
|
|
}
|
|
|
|
void Awake()
|
|
{
|
|
s_activeBootstrapInstanceId = GetInstanceID();
|
|
_holdAddressablesInitConfigured = _holdAddressablesInitUntilVersionChecked;
|
|
LogLifecycle("Awake");
|
|
|
|
if (_holdAddressablesInitUntilVersionChecked)
|
|
{
|
|
s_addressablesInitGate ??= new UniTaskCompletionSource();
|
|
GateTimeoutWatchdog().Forget();
|
|
}
|
|
else
|
|
LogLifecycle("Awake: holdAddressablesInit=false — AddressableManager will not wait on gate.");
|
|
|
|
TryStartBootstrapRun("Awake");
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
LogLifecycle("Start");
|
|
TryStartBootstrapRun("Start");
|
|
}
|
|
|
|
void OnDestroy()
|
|
{
|
|
LogLifecycle("OnDestroy — releasing gate if still pending");
|
|
ReleaseGateIfPending("OnDestroy");
|
|
if (s_activeBootstrapInstanceId == GetInstanceID())
|
|
s_activeBootstrapInstanceId = 0;
|
|
}
|
|
|
|
void OnApplicationQuit()
|
|
{
|
|
LogLifecycle("OnApplicationQuit");
|
|
ReleaseGateIfPending("OnApplicationQuit");
|
|
}
|
|
|
|
void TryStartBootstrapRun(string caller)
|
|
{
|
|
if (!isActiveAndEnabled)
|
|
{
|
|
LogLifecycle($"{caller}: SKIP bootstrap — GameObject inactive or component disabled.");
|
|
return;
|
|
}
|
|
|
|
if (s_bootstrapRunStarted)
|
|
{
|
|
LogLifecycle($"{caller}: bootstrap already started (skip duplicate).");
|
|
return;
|
|
}
|
|
|
|
s_bootstrapRunStarted = true;
|
|
LogLifecycle($"{caller}: starting RunAsync.");
|
|
RunAsync().Forget();
|
|
}
|
|
|
|
async UniTaskVoid GateTimeoutWatchdog()
|
|
{
|
|
var timeout = Mathf.Max(5f, _gateReleaseTimeoutSeconds);
|
|
await UniTask.Delay(TimeSpan.FromSeconds(timeout));
|
|
|
|
if (s_addressablesInitGate == null)
|
|
return;
|
|
|
|
Debug.LogWarning(
|
|
$"[Cuong] GameContentBootstrap: Gate timeout ({timeout:F0}s) — releasing gate. " +
|
|
$"runStarted={s_bootstrapRunStarted}, scene={SceneManager.GetActiveScene().name}, {DescribeSelf()}");
|
|
AllowAddressablesInit("GateTimeoutWatchdog");
|
|
}
|
|
|
|
static void ReleaseGateIfPending(string reason)
|
|
{
|
|
if (s_addressablesInitGate == null)
|
|
return;
|
|
|
|
Debug.LogWarning(
|
|
$"[Cuong] GameContentBootstrap: ReleaseGate ({reason}) — gate opened without normal bootstrap completion.");
|
|
s_addressablesInitGate.TrySetResult();
|
|
s_addressablesInitGate = null;
|
|
}
|
|
|
|
void LogLifecycle(string phase)
|
|
{
|
|
if (!_verboseLifecycleDebug)
|
|
return;
|
|
|
|
Debug.Log(
|
|
$"[Cuong] GameContentBootstrap: {phase} | {DescribeSelf()} | gate={GetGateDebugState()} | " +
|
|
$"scene={SceneManager.GetActiveScene().name} frame={Time.frameCount}");
|
|
}
|
|
|
|
string DescribeSelf()
|
|
{
|
|
return
|
|
$"id={GetInstanceID()} active={gameObject.activeInHierarchy} enabled={enabled} " +
|
|
$"holdInit={_holdAddressablesInitUntilVersionChecked} server={_useServerForVersionInfo}";
|
|
}
|
|
|
|
async UniTaskVoid RunAsync()
|
|
{
|
|
var result = await RunInternalAsync();
|
|
Finished?.Invoke(result);
|
|
}
|
|
|
|
public async UniTask<BootstrapResult> RunInternalAsync()
|
|
{
|
|
try
|
|
{
|
|
Debug.Log("[Cuong] GameContentBootstrap: Bắt đầu bootstrap Addressables...");
|
|
|
|
Debug.Log("[Cuong] GameContentBootstrap: Đang lấy contentVersion / assetsBaseUrl...");
|
|
var fetch = await ResolveVersionFetchAsync();
|
|
if (!fetch.Ok)
|
|
{
|
|
Debug.LogError($"[Cuong] GameContentBootstrap: Lỗi lấy version — {fetch.Error}");
|
|
AllowAddressablesInit("version-fetch-failed");
|
|
return new BootstrapResult(false, false, fetch.Error, null);
|
|
}
|
|
|
|
Debug.Log($"[Cuong] GameContentBootstrap: Đã có contentVersion={fetch.ContentVersion}" +
|
|
(string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) ? "" : $", assetsBaseUrl={fetch.AssetsBaseUrl}"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) &&
|
|
!string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite))
|
|
{
|
|
AddressablesRuntimeUrlRewriter.InstallPrefixRewrite(
|
|
_bakedRemoteUrlPrefixForRewrite,
|
|
fetch.AssetsBaseUrl);
|
|
Debug.Log(
|
|
$"[Cuong] GameContentBootstrap: URL rewrite | from={_bakedRemoteUrlPrefixForRewrite.Trim()} → to={fetch.AssetsBaseUrl.Trim()}");
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) &&
|
|
string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite))
|
|
{
|
|
Debug.LogWarning(
|
|
"[Cuong] GameContentBootstrap: assetsBaseUrl có nhưng _bakedRemoteUrlPrefixForRewrite trống — " +
|
|
"catalog/bundle vẫn dùng URL bake trong build (vd CDN cũ). Điền prefix URL lúc build.");
|
|
}
|
|
|
|
AllowAddressablesInit("version-and-url-ready");
|
|
|
|
Debug.Log("[Cuong] GameContentBootstrap: Đang khởi tạo Addressables...");
|
|
await AddressablesCatalogUpdater.EnsureInitializedAsync();
|
|
Debug.Log("[Cuong] GameContentBootstrap: Addressables đã khởi tạo.");
|
|
|
|
bool firstRun = PlayerPrefs.GetInt(PrefsFirstSyncDone, 0) == 0;
|
|
string localVersion = PlayerPrefs.GetString(PrefsLastContentVersion, string.Empty);
|
|
bool needContentWork =
|
|
firstRun ||
|
|
!string.Equals(localVersion, fetch.ContentVersion ?? string.Empty, StringComparison.Ordinal);
|
|
|
|
if (!needContentWork)
|
|
{
|
|
BMLogger.Log("[Cuong] GameContentBootstrap: contentVersion unchanged, skip catalog/bulk download.");
|
|
Debug.Log("[Cuong] GameContentBootstrap: Hoàn tất — không cần tải catalog/bulk (version không đổi).");
|
|
return new BootstrapResult(true, false, null, fetch.ContentVersion);
|
|
}
|
|
|
|
Debug.Log($"[Cuong] GameContentBootstrap: Cần sync nội dung (firstRun={firstRun}, localVersion='{localVersion}', serverVersion='{fetch.ContentVersion}').");
|
|
|
|
Debug.Log("[Cuong] GameContentBootstrap: Đang kiểm tra / cập nhật catalog...");
|
|
var catalog = await AddressablesCatalogUpdater.CheckAndUpdateCatalogsIfNeededAsync(false);
|
|
if (!catalog.Success)
|
|
{
|
|
var errMsg = catalog.Error?.Message ?? "Catalog update failed";
|
|
Debug.LogError($"[Cuong] GameContentBootstrap: Catalog update thất bại — {errMsg}");
|
|
if (IsLikelyRemoteTlsOrNetworkError(errMsg))
|
|
{
|
|
Debug.LogError(
|
|
"[Cuong] GameContentBootstrap: Gợi ý — SSL CA / mạng trên mobile. " +
|
|
"Kiểm tra chuỗi chứng chỉ CDN; đảm bảo _bakedRemoteUrlPrefixForRewrite khớp host trong catalog build " +
|
|
"(vd https://prefect-world-asset....wcsapi.com/) và assetsBaseUrl trỏ CDN HTTPS hợp lệ. " +
|
|
"Không gọi Addressables.InitializeAsync trước bootstrap (AUIManager đã dùng AddressablesInitService).");
|
|
}
|
|
|
|
return new BootstrapResult(false, true, errMsg, fetch.ContentVersion);
|
|
}
|
|
Debug.Log("[Cuong] GameContentBootstrap: Catalog OK.");
|
|
|
|
if (string.IsNullOrWhiteSpace(_remoteBulkDownloadLabel))
|
|
{
|
|
BMLogger.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel is empty; skipping bulk download.");
|
|
Debug.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel trống — bỏ bulk download.");
|
|
return new BootstrapResult(false, true, "remoteBulkDownloadLabel empty", fetch.ContentVersion);
|
|
}
|
|
|
|
var bulkLabel = _remoteBulkDownloadLabel.Trim();
|
|
var downloadBytes = await AddressablesCatalogUpdater.GetDownloadSizeBytesAsync(bulkLabel);
|
|
if (downloadBytes > 0)
|
|
{
|
|
Debug.Log(
|
|
$"[Cuong] GameContentBootstrap: Đang tải remote content (label={bulkLabel}), dung lượng cần tải ~{downloadBytes / (1024f * 1024f):F1} MB...");
|
|
}
|
|
else if (downloadBytes == 0)
|
|
{
|
|
Debug.Log(
|
|
$"[Cuong] GameContentBootstrap: Remote content (label={bulkLabel}) đã có trong cache — vẫn chạy DownloadDependencies để đồng bộ.");
|
|
}
|
|
else
|
|
{
|
|
Debug.Log(
|
|
$"[Cuong] GameContentBootstrap: Đang tải remote content (label={bulkLabel}) — không lấy được dung lượng trước khi tải.");
|
|
}
|
|
|
|
bool dl = await AddressablesCatalogUpdater.DownloadDependenciesAsync(bulkLabel);
|
|
if (!dl)
|
|
{
|
|
Debug.LogError("[Cuong] GameContentBootstrap: Tải remote content thất bại.");
|
|
return new BootstrapResult(false, true, "DownloadDependencies failed", fetch.ContentVersion);
|
|
}
|
|
|
|
PlayerPrefs.SetString(PrefsLastContentVersion, fetch.ContentVersion);
|
|
PlayerPrefs.SetInt(PrefsFirstSyncDone, 1);
|
|
PlayerPrefs.Save();
|
|
|
|
BMLogger.Log($"[Cuong] GameContentBootstrap: content sync OK, version={fetch.ContentVersion}");
|
|
Debug.Log($"[Cuong] GameContentBootstrap: Bootstrap hoàn tất — đã tải xong hết nội dung (version={fetch.ContentVersion}).");
|
|
return new BootstrapResult(true, true, null, fetch.ContentVersion);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
BMLogger.LogError($"[Cuong] GameContentBootstrap: {e.Message}");
|
|
Debug.LogError($"[Cuong] GameContentBootstrap: Exception — {e.Message}");
|
|
AllowAddressablesInit("exception");
|
|
return new BootstrapResult(false, true, e.Message, null);
|
|
}
|
|
}
|
|
|
|
static void AllowAddressablesInit(string reason)
|
|
{
|
|
if (s_addressablesInitGate == null)
|
|
{
|
|
Debug.Log($"[Cuong] GameContentBootstrap: AllowAddressablesInit({reason}) — gate already null.");
|
|
return;
|
|
}
|
|
|
|
Debug.Log($"[Cuong] GameContentBootstrap: AllowAddressablesInit({reason}) — opening gate for AddressableManager.");
|
|
s_addressablesInitGate.TrySetResult();
|
|
s_addressablesInitGate = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Editor skip → hardcode / fake; không server → hardcode; bật server → <see cref="GameContentVersionServerClient.FetchAsync"/> (GET hoặc POST).
|
|
/// </summary>
|
|
async UniTask<GameContentVersionFetchResult> ResolveVersionFetchAsync()
|
|
{
|
|
#if UNITY_EDITOR
|
|
if (_skipRemoteCallInEditor)
|
|
{
|
|
BMLogger.Log("[Cuong] GameContentBootstrap: Editor skip remote — no HTTP.");
|
|
return BuildHardcodedOrEditorResult();
|
|
}
|
|
#endif
|
|
if (!_useServerForVersionInfo)
|
|
{
|
|
BMLogger.Log("[Cuong] GameContentBootstrap: server disabled — using hardcoded version / URL.");
|
|
return BuildHardcodedResult();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(_versionEndpointUrl))
|
|
{
|
|
BMLogger.LogWarning("[Cuong] GameContentBootstrap: server enabled but URL empty; falling back to hardcoded.");
|
|
return BuildHardcodedResult();
|
|
}
|
|
|
|
return await GameContentVersionServerClient.FetchAsync(
|
|
_versionEndpointUrl,
|
|
_requestTimeoutSeconds,
|
|
_usePostForVersionRequest,
|
|
_versionPostBody);
|
|
}
|
|
|
|
GameContentVersionFetchResult BuildHardcodedOrEditorResult()
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_hardcodedContentVersion))
|
|
{
|
|
return new GameContentVersionFetchResult(
|
|
true,
|
|
_hardcodedContentVersion.Trim(),
|
|
TrimOrNull(_hardcodedAssetsBaseUrl),
|
|
null);
|
|
}
|
|
|
|
return new GameContentVersionFetchResult(
|
|
true,
|
|
_editorFakeContentVersion?.Trim() ?? "editor",
|
|
TrimOrNull(_hardcodedAssetsBaseUrl),
|
|
null);
|
|
}
|
|
|
|
GameContentVersionFetchResult BuildHardcodedResult()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_hardcodedContentVersion))
|
|
{
|
|
BMLogger.LogError("[Cuong] GameContentBootstrap: _hardcodedContentVersion is empty.");
|
|
return new GameContentVersionFetchResult(false, null, null, "hardcoded contentVersion empty");
|
|
}
|
|
|
|
return new GameContentVersionFetchResult(
|
|
true,
|
|
_hardcodedContentVersion.Trim(),
|
|
TrimOrNull(_hardcodedAssetsBaseUrl),
|
|
null);
|
|
}
|
|
|
|
static string TrimOrNull(string s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s))
|
|
return null;
|
|
return s.Trim();
|
|
}
|
|
|
|
static bool IsLikelyRemoteTlsOrNetworkError(string message)
|
|
{
|
|
if (string.IsNullOrEmpty(message))
|
|
return false;
|
|
return message.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
message.IndexOf("certificate", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
message.IndexOf("ConnectionError", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
message.IndexOf("unable to load from url", StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
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; }
|
|
/// <summary>True if catalog/bulk download ran this session.</summary>
|
|
public bool DidContentWork { get; }
|
|
public string ErrorMessage { get; }
|
|
public string ServerContentVersion { get; }
|
|
}
|
|
}
|
|
}
|