UI update 02

This commit is contained in:
FloatGaming
2026-06-30 21:21:30 +08:00
parent b2a1e307a4
commit f62f643cfc
1666 changed files with 211081 additions and 22799 deletions
@@ -12,6 +12,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
private readonly Dictionary<int, int> currentExpByHeroId = new Dictionary<int, int>();
private readonly Dictionary<int, int> unlockedTierByHeroId = new Dictionary<int, int>();
private readonly Dictionary<int, bool> levelLockByHeroId = new Dictionary<int, bool>();
private readonly Dictionary<int, bool> autoBreakthroughEnabledByHeroId = new Dictionary<int, bool>();
private readonly Dictionary<int, int> deployCountsByHeroId = new Dictionary<int, int>();
private readonly Dictionary<int, int> finishCountsByHeroId = new Dictionary<int, int>();
@@ -153,6 +154,13 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
return autoBreakthroughEnabledByHeroId.TryGetValue(heroId, out value) && value;
}
public bool IsLevelLockEnabled(int heroId)
{
InitializeIfNeeded();
bool value;
return levelLockByHeroId.TryGetValue(heroId, out value) && value;
}
public void SetCurrentExp(AllyHero_SO hero, int value)
{
if (hero == null || hero.ally_heroID <= 0)
@@ -164,6 +172,11 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
int safeValue = Mathf.Max(0, value);
currentExpByHeroId[hero.ally_heroID] = safeValue;
hero.ally_currentEXP = safeValue;
int unlockedTier = GetUnlockedTierIndex(hero.ally_heroID);
bool levelLock = ResolveLevelLockState(hero, safeValue, unlockedTier);
levelLockByHeroId[hero.ally_heroID] = levelLock;
hero.level_lock = levelLock;
SyncLegacySelectedSlotExpKeys(hero.ally_heroID, safeValue);
MarkDirty(hero);
SaveNow();
if (OnHeroGrowthChanged != null)
@@ -183,7 +196,11 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
int safeValue = Mathf.Clamp(value, 0, 3);
unlockedTierByHeroId[hero.ally_heroID] = safeValue;
hero.ally_growthUnlockedTierIndex = safeValue;
hero.ally_currentEXP = GetCurrentExp(hero.ally_heroID);
int currentExp = GetCurrentExp(hero.ally_heroID);
hero.ally_currentEXP = currentExp;
bool levelLock = ResolveLevelLockState(hero, currentExp, safeValue);
levelLockByHeroId[hero.ally_heroID] = levelLock;
hero.level_lock = levelLock;
MarkDirty(hero);
SaveNow();
if (OnHeroGrowthChanged != null)
@@ -292,6 +309,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
}
SyncAllMirrorFlags();
SyncAllLegacySelectedSlotExpKeys();
AllyHeroDeployLedgerStorage.TrySave(BuildPayload());
}
@@ -300,6 +318,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
InitializeIfNeeded();
currentExpByHeroId.Clear();
unlockedTierByHeroId.Clear();
levelLockByHeroId.Clear();
autoBreakthroughEnabledByHeroId.Clear();
ResetGrowthStateToDefaults();
ClearAllPendingDebtKeys();
@@ -311,6 +330,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
{
currentExpByHeroId.Clear();
unlockedTierByHeroId.Clear();
levelLockByHeroId.Clear();
autoBreakthroughEnabledByHeroId.Clear();
deployCountsByHeroId.Clear();
finishCountsByHeroId.Clear();
@@ -341,6 +361,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
currentExpByHeroId[entry.heroId] = currentExp;
unlockedTierByHeroId[entry.heroId] = unlockedTier;
levelLockByHeroId[entry.heroId] = entry.levelLock;
autoBreakthroughEnabledByHeroId[entry.heroId] = entry.autoBreakthroughEnabled;
deployCountsByHeroId[entry.heroId] = Mathf.Max(0, entry.deployCount);
finishCountsByHeroId[entry.heroId] = Mathf.Max(0, entry.finishCount);
@@ -356,8 +377,9 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
{
currentExpByHeroId.Clear();
unlockedTierByHeroId.Clear();
levelLockByHeroId.Clear();
autoBreakthroughEnabledByHeroId.Clear();
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
AllyHero_SO[] heroes = RuntimeResourcesCache.LoadAllAllyHeroes();
for (int i = 0; i < heroes.Length; i++)
{
AllyHero_SO hero = heroes[i];
@@ -368,6 +390,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
currentExpByHeroId[hero.ally_heroID] = 0;
unlockedTierByHeroId[hero.ally_heroID] = 0;
levelLockByHeroId[hero.ally_heroID] = false;
autoBreakthroughEnabledByHeroId[hero.ally_heroID] = false;
if (hero.ally_currentEXP != 0)
@@ -387,12 +410,18 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
hero.ally_autoBreakthroughEnabled = false;
MarkDirty(hero);
}
if (hero.level_lock)
{
hero.level_lock = false;
MarkDirty(hero);
}
}
}
private void SyncAllMirrorFlags()
{
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
AllyHero_SO[] heroes = RuntimeResourcesCache.LoadAllAllyHeroes();
for (int i = 0; i < heroes.Length; i++)
{
AllyHero_SO hero = heroes[i];
@@ -421,6 +450,12 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
unlockedTierByHeroId[hero.ally_heroID] = unlockedTier;
}
bool levelLock;
if (!levelLockByHeroId.TryGetValue(hero.ally_heroID, out levelLock))
{
levelLock = false;
}
bool autoBreakthroughEnabled;
if (!autoBreakthroughEnabledByHeroId.TryGetValue(hero.ally_heroID, out autoBreakthroughEnabled))
{
@@ -431,6 +466,8 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
NormalizeGrowthState(hero, ref currentExp, ref unlockedTier);
currentExpByHeroId[hero.ally_heroID] = currentExp;
unlockedTierByHeroId[hero.ally_heroID] = unlockedTier;
levelLock = ResolveLevelLockState(hero, currentExp, unlockedTier);
levelLockByHeroId[hero.ally_heroID] = levelLock;
int finishCount;
if (!finishCountsByHeroId.TryGetValue(hero.ally_heroID, out finishCount))
@@ -469,6 +506,12 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
changed = true;
}
if (hero.level_lock != levelLock)
{
hero.level_lock = levelLock;
changed = true;
}
if (hero.ally_battleDeployCount != count)
{
hero.ally_battleDeployCount = count;
@@ -503,7 +546,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
private static Dictionary<int, AllyHero_SO> BuildHeroLookup()
{
Dictionary<int, AllyHero_SO> heroById = new Dictionary<int, AllyHero_SO>();
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
AllyHero_SO[] heroes = RuntimeResourcesCache.LoadAllAllyHeroes();
for (int i = 0; i < heroes.Length; i++)
{
AllyHero_SO hero = heroes[i];
@@ -564,9 +607,41 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
unlockedTier = Mathf.Min(unlockedTier, reachableTier);
}
private static bool ResolveLevelLockState(AllyHero_SO hero, int currentExp, int unlockedTier)
{
if (hero == null || hero.levelStats == null || hero.levelStats.Count == 0)
{
return false;
}
List<AllyHero_SO.AllyLevelInfo> levels = new List<AllyHero_SO.AllyLevelInfo>();
for (int i = 0; i < hero.levelStats.Count; i++)
{
if (hero.levelStats[i] != null)
{
levels.Add(hero.levelStats[i]);
}
}
if (levels.Count < 2)
{
return false;
}
levels.Sort((left, right) => left.requiredEXP.CompareTo(right.requiredEXP));
int clampedTier = Mathf.Clamp(unlockedTier, 0, levels.Count - 1);
if (clampedTier >= levels.Count - 1)
{
return false;
}
int currentCap = Mathf.Max(levels[clampedTier].requiredEXP, levels[clampedTier + 1].requiredEXP);
return Mathf.Max(0, currentExp) >= currentCap;
}
private static void ClearAllPendingDebtKeys()
{
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
AllyHero_SO[] heroes = RuntimeResourcesCache.LoadAllAllyHeroes();
for (int i = 0; i < heroes.Length; i++)
{
AllyHero_SO hero = heroes[i];
@@ -581,6 +656,63 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
PlayerPrefs.Save();
}
private static void SyncLegacySelectedSlotExpKeys(int heroId, int expValue)
{
if (heroId <= 0)
{
return;
}
int safeExp = Mathf.Max(0, expValue);
bool changed = false;
for (int slot = 1; slot <= 5; slot++)
{
string heroKey = $"selected_heroSlot0{slot}_heroID";
if (PlayerPrefs.GetInt(heroKey, 0) != heroId)
{
continue;
}
string expKey = $"selected_heroSlot0{slot}_exp";
if (PlayerPrefs.GetInt(expKey, int.MinValue) == safeExp)
{
continue;
}
PlayerPrefs.SetInt(expKey, safeExp);
changed = true;
}
if (changed)
{
PlayerPrefs.Save();
}
}
private void SyncAllLegacySelectedSlotExpKeys()
{
bool changed = false;
for (int slot = 1; slot <= 5; slot++)
{
string heroKey = $"selected_heroSlot0{slot}_heroID";
int heroId = PlayerPrefs.GetInt(heroKey, 0);
string expKey = $"selected_heroSlot0{slot}_exp";
int expValue = heroId > 0 ? GetCurrentExp(heroId) : 0;
if (PlayerPrefs.GetInt(expKey, int.MinValue) == expValue)
{
continue;
}
PlayerPrefs.SetInt(expKey, expValue);
changed = true;
}
if (changed)
{
PlayerPrefs.Save();
}
}
private AllyHeroDeployLedgerPayload BuildPayload()
{
AllyHeroDeployLedgerPayload payload = AllyHeroDeployLedgerStorage.CreateDefaultPayload();
@@ -595,6 +727,11 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
allHeroIds.Add(pair.Key);
}
foreach (KeyValuePair<int, bool> pair in levelLockByHeroId)
{
allHeroIds.Add(pair.Key);
}
foreach (KeyValuePair<int, bool> pair in autoBreakthroughEnabledByHeroId)
{
allHeroIds.Add(pair.Key);
@@ -627,6 +764,7 @@ public sealed class AllyHeroDeployLedger : MonoBehaviour
heroId = heroId,
currentExp = Mathf.Max(0, GetCurrentExp(heroId)),
unlockedTierIndex = Mathf.Clamp(GetUnlockedTierIndex(heroId), 0, 3),
levelLock = IsLevelLockEnabled(heroId),
autoBreakthroughEnabled = IsAutoBreakthroughEnabled(heroId),
deployCount = Mathf.Max(0, GetDeployCount(heroId)),
finishCount = Mathf.Max(0, GetFinishCount(heroId)),
@@ -7,6 +7,7 @@ public class AllyHeroDeployEntry
public int heroId;
public int currentExp;
public int unlockedTierIndex;
public bool levelLock;
public bool autoBreakthroughEnabled;
public int deployCount;
public int finishCount;
@@ -14,6 +14,21 @@ public static class DlcContentAccess
return owningDlc == null || DlcOwnershipService.IsDlcOwned(owningDlc);
}
public static bool IsHeroAccessible(AllyHero_SO hero)
{
if (hero == null)
{
return false;
}
if (string.IsNullOrWhiteSpace(hero.sourceDlcId))
{
return true;
}
return DlcOwnershipService.IsDlcOwned(hero.sourceDlcId);
}
public static bool IsSkinAccessible(HeroSkinResolvedData skin)
{
if (skin == null)
@@ -24,6 +24,7 @@ public class DlcManifestEntry
public string[] songLabels;
public string[] songContentLabels;
public string[] heroSkinLabels;
public string[] heroLabels;
public string GetSafeDlcKey()
{
@@ -50,6 +51,11 @@ public class DlcManifestEntry
return GetResolvedLabels(heroSkinLabels, "dlc:" + GetSafeDlcKey() + ":heroskins");
}
public string[] GetHeroLabels()
{
return GetResolvedLabels(heroLabels, "dlc:" + GetSafeDlcKey() + ":heroes");
}
public string[] GetDependencyKeys()
{
List<string> result = new List<string>();
@@ -58,6 +64,7 @@ public class DlcManifestEntry
AppendUnique(result, GetSongLabels());
AppendUnique(result, GetSongContentLabels());
AppendUnique(result, GetHeroSkinLabels());
AppendUnique(result, GetHeroLabels());
return result.ToArray();
}
@@ -15,6 +15,8 @@ public static class DlcManifestService
public static List<DlcManifestRuntimeEntry> LoadInstalledEntries()
{
DlcPackageArchiveService.PrepareInstalledPackages();
List<DlcManifestRuntimeEntry> result = new List<DlcManifestRuntimeEntry>();
HashSet<string> visitedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string[] roots = GetManifestSearchRoots();
@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
public static class DlcPackageArchiveService
{
[Serializable]
private sealed class InstallState
{
public string sourcePath;
public long sourceFileLength;
public long sourceWriteTicksUtc;
}
private const string RuntimeFolderName = "DLC";
private const string PackageFolderName = "packages";
private const string InstalledFolderName = "installed_packages";
private const string PackageExtension = ".bsnkdlc";
private const string StateFileName = ".bsnkdlc.installstate.json";
private static readonly HashSet<string> PreparedPackagesThisSession =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetRuntimeState()
{
PreparedPackagesThisSession.Clear();
}
public static void PrepareInstalledPackages()
{
string[] searchRoots = GetPackageSearchRoots();
HashSet<string> visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < searchRoots.Length; i++)
{
string root = searchRoots[i];
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
continue;
}
string[] packageFiles;
try
{
packageFiles = Directory.GetFiles(root, "*" + PackageExtension, SearchOption.AllDirectories);
}
catch (Exception ex)
{
Debug.LogWarning("[DLC] Failed to enumerate package files in '" + root + "': " + ex.Message);
continue;
}
for (int packageIndex = 0; packageIndex < packageFiles.Length; packageIndex++)
{
string packagePath = packageFiles[packageIndex];
if (string.IsNullOrWhiteSpace(packagePath))
{
continue;
}
string fullPackagePath;
try
{
fullPackagePath = Path.GetFullPath(packagePath);
}
catch
{
continue;
}
if (!visited.Add(fullPackagePath))
{
continue;
}
TryInstallPackage(fullPackagePath);
}
}
}
public static string[] GetPackageSearchRoots()
{
List<string> result = new List<string>();
AppendIfValid(result, Path.Combine(Application.streamingAssetsPath, RuntimeFolderName, PackageFolderName));
AppendIfValid(result, Path.Combine(Application.streamingAssetsPath, RuntimeFolderName));
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName, PackageFolderName));
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName));
string playerRoot = GetPlayerRootDirectory();
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName, PackageFolderName));
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName));
return result.ToArray();
}
private static void TryInstallPackage(string packagePath)
{
if (string.IsNullOrWhiteSpace(packagePath) || !File.Exists(packagePath))
{
return;
}
string installDirectory = GetInstallDirectory(packagePath);
if (string.IsNullOrWhiteSpace(installDirectory))
{
return;
}
bool needsInstall = NeedsInstall(packagePath, installDirectory);
if (!needsInstall && PreparedPackagesThisSession.Contains(packagePath))
{
return;
}
try
{
if (!needsInstall)
{
PreparedPackagesThisSession.Add(packagePath);
return;
}
string installRoot = Path.GetDirectoryName(installDirectory) ?? string.Empty;
string stagingDirectory = installDirectory + ".staging";
if (!string.IsNullOrWhiteSpace(installRoot))
{
Directory.CreateDirectory(installRoot);
}
if (Directory.Exists(stagingDirectory))
{
Directory.Delete(stagingDirectory, true);
}
Directory.CreateDirectory(stagingDirectory);
ZipFile.ExtractToDirectory(packagePath, stagingDirectory);
WriteInstallState(packagePath, stagingDirectory);
if (Directory.Exists(installDirectory))
{
Directory.Delete(installDirectory, true);
}
Directory.Move(stagingDirectory, installDirectory);
PreparedPackagesThisSession.Add(packagePath);
}
catch (Exception ex)
{
Debug.LogWarning("[DLC] Failed to install package '" + packagePath + "': " + ex.Message);
}
}
private static bool NeedsInstall(string packagePath, string installDirectory)
{
if (!Directory.Exists(installDirectory))
{
return true;
}
InstallState state = ReadInstallState(installDirectory);
if (state == null)
{
return true;
}
FileInfo info;
try
{
info = new FileInfo(packagePath);
}
catch
{
return true;
}
return !string.Equals(state.sourcePath ?? string.Empty, packagePath, StringComparison.OrdinalIgnoreCase)
|| state.sourceFileLength != info.Length
|| state.sourceWriteTicksUtc != info.LastWriteTimeUtc.Ticks;
}
private static string GetInstallDirectory(string packagePath)
{
if (string.IsNullOrWhiteSpace(packagePath))
{
return string.Empty;
}
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(packagePath) ?? "package";
string safeName = SanitizeFileName(fileNameWithoutExtension);
string hash = ComputeShortHash(packagePath);
string installRoot = Path.Combine(Application.persistentDataPath, RuntimeFolderName, InstalledFolderName);
return Path.Combine(installRoot, safeName + "_" + hash);
}
private static void WriteInstallState(string packagePath, string directory)
{
FileInfo info = new FileInfo(packagePath);
InstallState state = new InstallState
{
sourcePath = packagePath,
sourceFileLength = info.Exists ? info.Length : 0L,
sourceWriteTicksUtc = info.Exists ? info.LastWriteTimeUtc.Ticks : 0L
};
string json = JsonUtility.ToJson(state, true);
File.WriteAllText(Path.Combine(directory, StateFileName), json, Encoding.UTF8);
}
private static InstallState ReadInstallState(string directory)
{
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
string statePath = Path.Combine(directory, StateFileName);
if (!File.Exists(statePath))
{
return null;
}
try
{
string json = File.ReadAllText(statePath, Encoding.UTF8);
return JsonUtility.FromJson<InstallState>(json);
}
catch
{
return null;
}
}
private static string GetPlayerRootDirectory()
{
try
{
string dataPath = Application.dataPath;
if (string.IsNullOrWhiteSpace(dataPath))
{
return string.Empty;
}
DirectoryInfo parent = Directory.GetParent(dataPath);
return parent != null ? parent.FullName : string.Empty;
}
catch
{
return string.Empty;
}
}
private static void AppendIfValid(List<string> result, string path)
{
if (result == null || string.IsNullOrWhiteSpace(path))
{
return;
}
try
{
string fullPath = Path.GetFullPath(path);
if (!result.Contains(fullPath))
{
result.Add(fullPath);
}
}
catch
{
}
}
private static string ComputeShortHash(string value)
{
byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
using (SHA1 sha1 = SHA1.Create())
{
byte[] hash = sha1.ComputeHash(bytes);
StringBuilder builder = new StringBuilder(8);
for (int i = 0; i < 4 && i < hash.Length; i++)
{
builder.Append(hash[i].ToString("x2"));
}
return builder.ToString();
}
}
private static string SanitizeFileName(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "package";
}
char[] invalidChars = Path.GetInvalidFileNameChars();
StringBuilder builder = new StringBuilder(value.Length);
for (int i = 0; i < value.Length; i++)
{
char current = value[i];
builder.Append(Array.IndexOf(invalidChars, current) >= 0 ? '_' : current);
}
return builder.ToString().Trim();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0bf72431ab28c4640b1b960e8ca98168
@@ -19,6 +19,11 @@ public static class DlcRemoteContentService
{
private void Start()
{
if (DlcRemoteManifestSyncService.ShouldDeferInitialRefresh())
{
return;
}
if (DlcRemoteContentService.autoRefreshOnStart)
{
DlcRemoteContentService.RefreshInstalledDlc();
@@ -144,42 +149,56 @@ public static class DlcRemoteContentService
state.retainedCatalogHandles.Add(catalogHandle);
if (manifest.entry.autoDownloadDependencies)
{
string[] dependencyKeys = manifest.entry.GetDependencyKeys();
for (int i = 0; i < dependencyKeys.Length; i++)
{
string dependencyKey = dependencyKeys[i];
if (string.IsNullOrWhiteSpace(dependencyKey))
{
continue;
}
AsyncOperationHandle downloadHandle = Addressables.DownloadDependenciesAsync(dependencyKey, false);
yield return downloadHandle;
if (downloadHandle.Status == AsyncOperationStatus.Succeeded)
{
state.retainedAssetHandles.Add(downloadHandle);
}
else
{
localSuccess = false;
Debug.LogWarning("[DLC] Failed to download dependencies for key '" + dependencyKey + "'.");
Addressables.Release(downloadHandle);
}
}
}
List<dlcData> loadedDlcs = new List<dlcData>();
List<SongData> loadedSongs = new List<SongData>();
List<SongDlcContentSO> loadedSongContents = new List<SongDlcContentSO>();
List<HeroSkinSO> loadedHeroSkins = new List<HeroSkinSO>();
List<AllyHero_SO> loadedHeroes = new List<AllyHero_SO>();
// Load the lightweight DLC metadata first so we can decide ownership
// before pulling (or even downloading) any heavy content.
yield return LoadAssetsByLabels(manifest.entry.GetDlcDataLabels(), loadedDlcs, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetSongLabels(), loadedSongs, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetSongContentLabels(), loadedSongContents, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetHeroSkinLabels(), loadedHeroSkins, state.retainedAssetHandles, result => localSuccess &= result);
bool owned = IsManifestEntryOwned(manifest, loadedDlcs);
if (owned)
{
if (manifest.entry.autoDownloadDependencies)
{
string[] dependencyKeys = manifest.entry.GetDependencyKeys();
for (int i = 0; i < dependencyKeys.Length; i++)
{
string dependencyKey = dependencyKeys[i];
if (string.IsNullOrWhiteSpace(dependencyKey))
{
continue;
}
AsyncOperationHandle downloadHandle = Addressables.DownloadDependenciesAsync(dependencyKey, false);
yield return downloadHandle;
if (downloadHandle.Status == AsyncOperationStatus.Succeeded)
{
state.retainedAssetHandles.Add(downloadHandle);
}
else
{
localSuccess = false;
Debug.LogWarning("[DLC] Failed to download dependencies for key '" + dependencyKey + "'.");
Addressables.Release(downloadHandle);
}
}
}
yield return LoadAssetsByLabels(manifest.entry.GetSongLabels(), loadedSongs, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetSongContentLabels(), loadedSongContents, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetHeroSkinLabels(), loadedHeroSkins, state.retainedAssetHandles, result => localSuccess &= result);
yield return LoadAssetsByLabels(manifest.entry.GetHeroLabels(), loadedHeroes, state.retainedAssetHandles, result => localSuccess &= result);
}
else
{
Debug.Log("[DLC] Skipping content load for unowned DLC '" + manifest.entry.GetSafeDlcKey() + "'. Only metadata is registered.");
}
DlcRuntimePackage package = new DlcRuntimePackage
{
@@ -190,13 +209,54 @@ public static class DlcRemoteContentService
dlcs = loadedDlcs.ToArray(),
songs = loadedSongs.ToArray(),
songContents = loadedSongContents.ToArray(),
heroSkins = loadedHeroSkins.ToArray()
heroSkins = loadedHeroSkins.ToArray(),
heroes = loadedHeroes.ToArray()
};
state.packages.Add(package);
reportResult?.Invoke(localSuccess);
}
private static bool IsManifestEntryOwned(DlcManifestRuntimeEntry manifest, List<dlcData> loadedDlcs)
{
// If any loaded dlcData asset reports owned, the package is owned. A DLC that
// does not enforce entitlement (builtInContent / requiresOwnership == false)
// is always owned via DlcOwnershipService.
if (loadedDlcs != null)
{
bool anyEnforced = false;
for (int i = 0; i < loadedDlcs.Count; i++)
{
dlcData dlc = loadedDlcs[i];
if (dlc == null)
{
continue;
}
anyEnforced = true;
if (DlcOwnershipService.IsDlcOwned(dlc))
{
return true;
}
}
if (anyEnforced)
{
return false;
}
}
// No dlcData asset was found for this manifest entry. Fall back to the manifest
// key so a remote/local ownership flag can still gate the content.
string key = manifest != null && manifest.entry != null ? manifest.entry.GetSafeDlcKey() : null;
if (string.IsNullOrWhiteSpace(key))
{
return true;
}
return DlcOwnershipService.IsDlcOwned(key);
}
private static IEnumerator LoadAssetsByLabels<T>(
string[] labels,
List<T> output,
@@ -0,0 +1,270 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using GameServer.Client;
using UnityEngine;
using UnityEngine.Networking;
public static class DlcRemoteManifestSyncService
{
[Serializable]
private sealed class RemoteManifestEnvelope
{
public bool success;
public string message;
public RemoteManifestItem[] dlcs;
}
[Serializable]
private sealed class RemoteManifestItem
{
public string dlc_key;
public string display_name;
public string version;
public string manifest_file_name;
public string manifest_json;
public string uploaded_at;
public string updated_at;
}
private sealed class DlcRemoteManifestSyncRunner : MonoBehaviour
{
private void Start()
{
if (autoSyncOnStart)
{
SyncPublishedDlcs();
}
}
}
private const string DefaultServerUrl = "http://47.112.187.172:8080";
private const string RemoteManifestFilePrefix = "remote_";
private const string ManifestApiPath = "/api/dlcs/manifests";
private static DlcRemoteManifestSyncRunner runner;
private static bool autoSyncOnStart = true;
private static bool syncRequestedWhileBusy;
private static bool initialSyncPending = true;
public static bool IsSyncing { get; private set; }
public static event Action<bool> SyncCompleted;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void EnsureRunner()
{
if (runner != null)
{
return;
}
GameObject go = new GameObject(nameof(DlcRemoteManifestSyncService));
UnityEngine.Object.DontDestroyOnLoad(go);
runner = go.AddComponent<DlcRemoteManifestSyncRunner>();
}
public static void SetAutoSyncOnStart(bool enabled)
{
autoSyncOnStart = enabled;
if (!enabled)
{
initialSyncPending = false;
}
}
public static bool ShouldDeferInitialRefresh()
{
return autoSyncOnStart && initialSyncPending && !OnlineModeSettings.IsLocalOnlyMode;
}
public static void SyncPublishedDlcs()
{
EnsureRunner();
if (runner == null)
{
return;
}
if (IsSyncing)
{
syncRequestedWhileBusy = true;
return;
}
runner.StartCoroutine(SyncPublishedDlcsRoutine());
}
private static IEnumerator SyncPublishedDlcsRoutine()
{
if (IsSyncing)
{
yield break;
}
if (OnlineModeSettings.IsLocalOnlyMode)
{
initialSyncPending = false;
DlcRemoteContentService.RefreshInstalledDlc();
SyncCompleted?.Invoke(false);
yield break;
}
string url = BuildManifestApiUrl();
if (string.IsNullOrWhiteSpace(url))
{
initialSyncPending = false;
DlcRemoteContentService.RefreshInstalledDlc();
SyncCompleted?.Invoke(false);
yield break;
}
IsSyncing = true;
syncRequestedWhileBusy = false;
bool success = false;
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
request.timeout = 10;
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string responseText = request.downloadHandler != null ? request.downloadHandler.text : string.Empty;
success = TryApplyRemoteManifestPayload(responseText);
}
else
{
Debug.LogWarning("[DLC] Remote manifest sync failed: " + request.error);
}
}
initialSyncPending = false;
if (success)
{
DlcRemoteContentService.RefreshInstalledDlc();
}
else
{
DlcRemoteContentService.RefreshInstalledDlc();
}
IsSyncing = false;
SyncCompleted?.Invoke(success);
if (syncRequestedWhileBusy)
{
SyncPublishedDlcs();
}
}
private static bool TryApplyRemoteManifestPayload(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
RemoteManifestEnvelope envelope;
try
{
envelope = JsonUtility.FromJson<RemoteManifestEnvelope>(json);
}
catch (Exception ex)
{
Debug.LogWarning("[DLC] Failed to parse remote manifest payload: " + ex.Message);
return false;
}
if (envelope == null || !envelope.success)
{
Debug.LogWarning("[DLC] Remote manifest payload reported failure.");
return false;
}
string manifestRoot = Path.Combine(Application.persistentDataPath, "DLC", "manifests");
try
{
Directory.CreateDirectory(manifestRoot);
DeleteExistingRemoteManifestFiles(manifestRoot);
RemoteManifestItem[] items = envelope.dlcs ?? Array.Empty<RemoteManifestItem>();
for (int i = 0; i < items.Length; i++)
{
RemoteManifestItem item = items[i];
if (item == null || string.IsNullOrWhiteSpace(item.manifest_json))
{
continue;
}
string safeKey = SanitizeFileName(item.dlc_key);
if (string.IsNullOrWhiteSpace(safeKey))
{
safeKey = "package_" + i;
}
string fileName = RemoteManifestFilePrefix + safeKey + ".json";
string path = Path.Combine(manifestRoot, fileName);
File.WriteAllText(path, item.manifest_json);
}
return true;
}
catch (Exception ex)
{
Debug.LogWarning("[DLC] Failed to write remote manifests: " + ex.Message);
return false;
}
}
private static void DeleteExistingRemoteManifestFiles(string manifestRoot)
{
string[] files = Directory.GetFiles(manifestRoot, RemoteManifestFilePrefix + "*.json", SearchOption.TopDirectoryOnly);
for (int i = 0; i < files.Length; i++)
{
try
{
File.Delete(files[i]);
}
catch (Exception ex)
{
Debug.LogWarning("[DLC] Failed to delete old remote manifest '" + files[i] + "': " + ex.Message);
}
}
}
private static string BuildManifestApiUrl()
{
string baseUrl = DefaultServerUrl;
NetworkManager manager = NetworkManager.Instance;
if (manager != null && !string.IsNullOrWhiteSpace(manager.ServerUrl))
{
baseUrl = manager.ServerUrl.Trim();
}
if (string.IsNullOrWhiteSpace(baseUrl))
{
return string.Empty;
}
return baseUrl.TrimEnd('/') + ManifestApiPath;
}
private static string SanitizeFileName(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
char[] invalidChars = Path.GetInvalidFileNameChars();
string result = value.Trim();
for (int i = 0; i < invalidChars.Length; i++)
{
result = result.Replace(invalidChars[i], '_');
}
return result.Replace(' ', '_');
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08474a42ea631b04b982f1e7e67fd96a
@@ -12,6 +12,7 @@ public sealed class DlcRuntimePackage
public SongData[] songs = Array.Empty<SongData>();
public SongDlcContentSO[] songContents = Array.Empty<SongDlcContentSO>();
public HeroSkinSO[] heroSkins = Array.Empty<HeroSkinSO>();
public AllyHero_SO[] heroes = Array.Empty<AllyHero_SO>();
}
public static class DlcRuntimeRegistry
@@ -25,11 +26,14 @@ public static class DlcRuntimeRegistry
new Dictionary<string, SongDlcContentSO>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, HeroSkinSO> HeroSkinsById =
new Dictionary<string, HeroSkinSO>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<int, AllyHero_SO> HeroesById =
new Dictionary<int, AllyHero_SO>();
private static dlcData[] cachedDlcs = Array.Empty<dlcData>();
private static SongData[] cachedSongs = Array.Empty<SongData>();
private static SongDlcContentSO[] cachedSongContents = Array.Empty<SongDlcContentSO>();
private static HeroSkinSO[] cachedHeroSkins = Array.Empty<HeroSkinSO>();
private static AllyHero_SO[] cachedHeroes = Array.Empty<AllyHero_SO>();
public static event Action RuntimeContentChanged;
@@ -69,6 +73,11 @@ public static class DlcRuntimeRegistry
return cachedHeroSkins;
}
public static AllyHero_SO[] GetAllHeroes()
{
return cachedHeroes;
}
public static void ReplaceAll(IList<DlcRuntimePackage> packages)
{
ClearSilently();
@@ -97,10 +106,12 @@ public static class DlcRuntimeRegistry
SongsById.Clear();
SongContentsById.Clear();
HeroSkinsById.Clear();
HeroesById.Clear();
cachedDlcs = Array.Empty<dlcData>();
cachedSongs = Array.Empty<SongData>();
cachedSongContents = Array.Empty<SongDlcContentSO>();
cachedHeroSkins = Array.Empty<HeroSkinSO>();
cachedHeroes = Array.Empty<AllyHero_SO>();
}
private static void RegisterPackageInternal(DlcRuntimePackage package)
@@ -185,6 +196,20 @@ public static class DlcRuntimeRegistry
HeroSkinsById[skinId] = skin;
}
}
if (package.heroes != null)
{
for (int i = 0; i < package.heroes.Length; i++)
{
AllyHero_SO hero = package.heroes[i];
if (hero == null || hero.ally_heroID <= 0)
{
continue;
}
HeroesById[hero.ally_heroID] = hero;
}
}
}
private static void RebuildSnapshots()
@@ -200,5 +225,8 @@ public static class DlcRuntimeRegistry
cachedHeroSkins = new HeroSkinSO[HeroSkinsById.Count];
HeroSkinsById.Values.CopyTo(cachedHeroSkins, 0);
cachedHeroes = new AllyHero_SO[HeroesById.Count];
HeroesById.Values.CopyTo(cachedHeroes, 0);
}
}
@@ -74,7 +74,7 @@ public static class RuntimeResourcesCache
public static AllyHero_SO[] LoadAllAllyHeroes()
{
return LoadAll<AllyHero_SO>(string.Empty);
return MergeArrays(LoadAll<AllyHero_SO>(string.Empty), DlcRuntimeRegistry.GetAllHeroes());
}
public static storeItemSO[] LoadAllStoreItems()