feat: support asset bundle

This commit is contained in:
2026-05-15 16:45:58 +08:00
parent 8a0ad07a2a
commit 2cec127e31
27 changed files with 390 additions and 8 deletions
+3
View File
@@ -59,3 +59,6 @@ crashlytics-build.properties
# Temporary auto-generated Android Assets
/Assets/StreamingAssets/aa.meta
/Assets/StreamingAssets/aa/*
/Assets/StreamingAssets/
/Assets/StreamingAssets.meta
+2
View File
@@ -1,5 +1,7 @@
fileFormatVersion: 2
guid: ad7387f41068f465fbabd034439fbd89
labels:
- Audio
folderAsset: yes
DefaultImporter:
externalObjects: {}
+2
View File
@@ -1,5 +1,7 @@
fileFormatVersion: 2
guid: 07dda0761dca5405ebf092d473b8e0e3
labels:
- Audio
folderAsset: yes
DefaultImporter:
externalObjects: {}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0d356a0e07bd4566b4e303a87d7e3545
timeCreated: 1778817283
@@ -0,0 +1,28 @@
using System;
using UnityEngine;
namespace OCES
{
public class AssetBundleAssetProvider : IAssetProvider
{
readonly AssetBundle m_assetBundle;
public AssetBundleAssetProvider(string bundleFilePath)
{
this.m_assetBundle = AssetBundle.LoadFromFile(bundleFilePath);
if (this.m_assetBundle is null)
{
Debug.LogError($"[OCES] 无法加载: {bundleFilePath}");
}
}
public T Load<T>(string path) where T : UnityEngine.Object
{
return this.m_assetBundle.LoadAsset<T>(path);
}
public ResourceRequest LoadAsync<T>(string path) where T : UnityEngine.Object
{
return this.m_assetBundle.LoadAssetAsync<T>(path);
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 12e6fce897c74d9fbb347666d13123d1
timeCreated: 1778817328
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 94c95f233df548289406144c77e05d0b
timeCreated: 1778828249
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
namespace OCES.Editor
{
public static class AssetBundleBuilder
{
static string BuildBundle(string bundleName, string outputDir, BuildTarget buildTarget, params string[] sourceDirs)
{
// 1. 收集所有文件(递归)
List<string> assetNames = new();
List<string> addressableNames = new();
foreach (string dir in sourceDirs)
{
if (!Directory.Exists(dir))
{
Debug.LogWarning($"[AssetBundleBuilder] 目录不存在,跳过: {dir}");
continue;
}
string[] files = Directory.GetFiles(dir, "*.*", SearchOption.AllDirectories);
foreach (string file in files)
{
if (file.EndsWith(".meta")) continue;
if (file.EndsWith(".DS_Store")) continue;
if (file.StartsWith("._")) continue;
if (file.EndsWith(".pkf")) continue;
string assetPath = file.Replace("\\", "/");
assetNames.Add(assetPath);
string relative = assetPath.Replace("Assets/Resources/", "");
string withoutExt = Path.ChangeExtension(relative, null);
addressableNames.Add(withoutExt);
}
}
if (assetNames.Count == 0)
{
return $"[AssetBundleBuilder] {bundleName}: 无文件";
}
Directory.CreateDirectory(outputDir);
// 2. 构建
AssetBundleBuild build = new()
{
assetBundleName = bundleName + ".ab",
assetNames = assetNames.ToArray(),
addressableNames = addressableNames.ToArray()
};
AssetBundleManifest manifest = BuildPipeline.BuildAssetBundles(
outputDir,
new[] { build },
BuildAssetBundleOptions.None,
buildTarget);
return !manifest ? $"[{bundleName}.ab] 构建失败"
: $"[AssetBundleBuilder] {bundleName}.ab 构建完成,包含 {assetNames.Count} 个资源";
}
internal static string BuildBundles(
bool buildAudio, string audioBundlePath,
bool buildHaptic, string hapticBundlePath,
BuildTarget buildTarget)
{
string log = "";
if (buildAudio)
log += BuildBundleFromPath(audioBundlePath, buildTarget,
"Assets/Resources/Audios",
"Assets/Resources/AudioData") + "\n";
if (buildHaptic)
log += BuildBundleFromPath(hapticBundlePath, buildTarget,
"Assets/Resources/Haptics",
"Assets/Resources/HapticData") + "\n";
AssetDatabase.Refresh();
return log.TrimEnd('\n');
}
/// <summary>
/// 根据配置中的相对路径构建单个 Bundle。
/// </summary>
/// <param name="relativePath">配置中的路径,如 "Bundles/audios.ab"</param>
/// <param name="target"></param>
/// <param name="sourceDirs"></param>
public static string BuildBundleFromPath(
string relativePath, BuildTarget target, params string[] sourceDirs)
{
// 解析路径 → 输出目录 + Bundle 名
string fullPath = Path.Combine(Application.streamingAssetsPath, relativePath);
string outputDir = Path.GetDirectoryName(fullPath);
string bundleName = Path.GetFileNameWithoutExtension(fullPath);
return string.IsNullOrEmpty(bundleName)
? $"[AssetBundleBuilder] 无效路径: {relativePath}"
: BuildBundle(bundleName, outputDir, target, sourceDirs);
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c370f95cedb246969a5831a5bda067c2
timeCreated: 1778828259
@@ -0,0 +1,131 @@
using System.Linq;
using OCES.Audio;
using OCES.Haptic;
using UnityEngine;
using UnityEditor;
namespace OCES.Editor
{
public class AssetBundleBuilderWindow : EditorWindow
{
bool m_buildAudio = true;
bool m_buildHaptic = true;
int m_targetIndex;
string m_audioBundleSourcePath;
string m_hapticBundleSourcePath;
string m_log = "";
Vector2 m_scrollPos;
// Settings 资源路径
const string k_audioSettingsPath = "Assets/Settings/AudioExtendSettings.asset";
const string k_hapticSettingsPath = "Assets/Settings/HapticSettings.asset";
static readonly (BuildTarget target, string label)[] s_platforms =
{
(BuildTarget.Android, "Android"),
(BuildTarget.iOS, "iOS"),
(BuildTarget.StandaloneOSX, "macOS"),
(BuildTarget.StandaloneWindows64, "Windows (x64)"),
(BuildTarget.StandaloneLinux64, "Linux (x64)"),
};
static readonly string[] s_platformLabels = s_platforms.Select(p => p.label).ToArray();
[MenuItem("Tools/OCES/Asset Bundle Builder")]
public static void ShowWindow()
{
AssetBundleBuilderWindow window = GetWindow<AssetBundleBuilderWindow>(
false, "Bundle Builder", true);
window.minSize = new Vector2(360, 400);
window.Show();
}
void OnEnable()
{
// 默认选中当前 Player 平台
BuildTarget current = EditorUserBuildSettings.activeBuildTarget;
for (int i = 0; i < s_platforms.Length; i++)
{
if (s_platforms[i].target != current)
continue;
this.m_targetIndex = i;
return;
}
// 兜底:Android
this.m_targetIndex = 13;
}
void OnGUI()
{
GUILayout.Space(10);
// ── Bundle 选择 ──
EditorGUILayout.LabelField("Bundles to Build", EditorStyles.boldLabel);
this.m_buildAudio = EditorGUILayout.ToggleLeft("Audio Bundle (audios.ab)", this.m_buildAudio);
this.m_buildHaptic = EditorGUILayout.ToggleLeft("Haptic Bundle (haptic.ab)", this.m_buildHaptic);
GUILayout.Space(10);
// ── 平台选择 ──
EditorGUILayout.LabelField("Target Platform", EditorStyles.boldLabel);
this.m_targetIndex = EditorGUILayout.Popup(this.m_targetIndex, s_platformLabels);
// ── 输出路径预览 ──
AudioExtendSettings audioSettings =
AssetDatabase.LoadAssetAtPath<AudioExtendSettings>(k_audioSettingsPath);
HapticSettings hapticSettings =
AssetDatabase.LoadAssetAtPath<HapticSettings>(k_hapticSettingsPath);
if (audioSettings && this.m_buildAudio)
EditorGUILayout.LabelField(
$" Audio → {Application.streamingAssetsPath}/{audioSettings.audioBundlePath}",
EditorStyles.miniLabel);
if (hapticSettings && this.m_buildHaptic)
EditorGUILayout.LabelField(
$" Haptic → {Application.streamingAssetsPath}/{hapticSettings.hapticBundlePath}",
EditorStyles.miniLabel);
if (!audioSettings && !hapticSettings)
EditorGUILayout.HelpBox(
$"未找到 Settings 资产。\n 请初始化对应系统。",
MessageType.Warning);
GUILayout.Space(10);
// ── 构建按钮 ──
bool canBuild = (this.m_buildAudio || this.m_buildHaptic)
&& (audioSettings || !this.m_buildAudio)
&& (hapticSettings || !this.m_buildHaptic);
EditorGUI.BeginDisabledGroup(!canBuild);
if (GUILayout.Button("Build", GUILayout.Height(32)))
{
string result = AssetBundleBuilder.BuildBundles(this.m_buildAudio,
audioSettings ? audioSettings.audioBundlePath : "", this.m_buildHaptic,
hapticSettings != null ? hapticSettings.hapticBundlePath : "",
s_platforms[this.m_targetIndex].target);
this.m_log += result + "\n";
}
EditorGUI.EndDisabledGroup();
GUILayout.Space(10);
// ── 日志区 ──
EditorGUILayout.LabelField("Build Log", EditorStyles.boldLabel);
this.m_scrollPos = EditorGUILayout.BeginScrollView(this.m_scrollPos,
GUILayout.ExpandHeight(true));
EditorGUILayout.TextArea(this.m_log,
GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
// 底部清空日志按钮
if (string.IsNullOrEmpty(this.m_log))
return;
GUILayout.Space(4);
if (GUILayout.Button("Clear Log", GUILayout.Width(80)))
this.m_log = "";
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f327fedaa18441e2bf347cf46810592d
timeCreated: 1778829708
@@ -0,0 +1,11 @@
using System;
using UnityEngine;
namespace OCES
{
public interface IAssetProvider
{
T Load<T>(string path) where T : UnityEngine.Object;
ResourceRequest LoadAsync<T>(string path) where T : UnityEngine.Object;
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 35a140a1c75444b49ad470726512b817
timeCreated: 1778815286
@@ -2,6 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using JetBrains.Annotations;
using OCES.Audio;
using UnityEngine;
using Object = UnityEngine.Object;
@@ -11,6 +12,12 @@ namespace OCES
{
readonly Dictionary<string, Object> m_cachedObjects = new();
readonly Dictionary<string, List<Action<Object>>> m_pendingCallbacks = new();
IAssetProvider m_assetProvider;
public void SetProvider(IAssetProvider assetProvider)
{
this.m_assetProvider = assetProvider;
}
[CanBeNull]
internal T LoadSync<T>(string path) where T : Object
@@ -19,8 +26,18 @@ namespace OCES
{
return cachedObject as T;
}
T newObject;
if (this.m_assetProvider is not null)
{
newObject = this.m_assetProvider.Load<T>(path);
}
else
{
newObject = Resources.Load<T>(path);
Debug.LogWarning($"[ResourceLoader] No IAssetProvider set, falling back to Resources.Load for '{path}'");
}
T newObject = Resources.Load<T>(path);
this.m_cachedObjects.Add(path, newObject);
return newObject;
}
@@ -40,7 +57,17 @@ namespace OCES
}
this.m_pendingCallbacks[path] = new List<Action<Object>> { obj => onComplete?.Invoke(obj as T) };
ResourceRequest newRequest = Resources.LoadAsync<T>(path);
ResourceRequest newRequest;
if (this.m_assetProvider is not null)
{
newRequest = this.m_assetProvider.LoadAsync<T>(path);
}
else
{
newRequest = Resources.LoadAsync<T>(path);
Debug.LogWarning($"[ResourceLoader] No IAssetProvider set, falling back to Resources.Load for '{path}'");
}
StartCoroutine(HandleAsyncLoadCompletion(path, newRequest));
}
@@ -0,0 +1,17 @@
using System;
using UnityEngine;
namespace OCES
{
public class ResourcesAssetProvider : IAssetProvider
{
public T Load<T>(string path) where T : UnityEngine.Object
{
return Resources.Load<T>(path);
}
public ResourceRequest LoadAsync<T>(string path) where T : UnityEngine.Object
{
return Resources.LoadAsync<T>(path);
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e5c672cc770b4032bfcd888f36766b45
timeCreated: 1778817303
@@ -12,7 +12,7 @@ namespace OCES.Audio
public class AudioExtendSettings : ScriptableObject
{
// ========== Resource Paths ==========
[Header("Resource Paths")]
[Header("Resources")]
[Tooltip("Resources 子目录:音频配置文件(.bytes")]
public string audioConfigPath = "AudioData";
@@ -22,6 +22,9 @@ namespace OCES.Audio
[Tooltip("Resources 路径:AudioMixer 资产")]
public string audioMixerPath = "Audios/Master";
public bool useAssetBundle = false;
public string audioBundlePath = "Bundles/audios.ab";
// ========== AudioMixer Group Paths ==========
[Header("AudioMixer Groups")]
public string sfxGroupPath = "Master/Regular/SFX";
@@ -288,6 +288,17 @@ namespace OCES.Audio
DontDestroyOnLoad(gameObject);
this.ResourceLoader = gameObject.AddComponent<ResourceLoader>();
if (AudioExtendSettings.Instance.useAssetBundle)
{
string bundlePath = Path.Combine(Application.streamingAssetsPath,
AudioExtendSettings.Instance.audioBundlePath);
this.ResourceLoader.SetProvider(new AssetBundleAssetProvider(bundlePath));
}
else
{
this.ResourceLoader.SetProvider(new ResourcesAssetProvider());
}
string audioConfigPath = AudioExtendSettings.Instance.audioConfigPath;
@@ -388,8 +399,6 @@ namespace OCES.Audio
AudioObject childContainer = this.m_audioObjects.GetMappingResult(switchContainer.Id, currentStateValue);
return childContainer ?? this.m_audioObjects.GetDefaultSwitchOrFallback(switchContainer);
}
}
public static class AudioConfigLoader
@@ -47,7 +47,7 @@ namespace OCES.Audio.Editor
};
}
[MenuItem("Tools/Audio/Create AudioExtendSettings Asset")]
[MenuItem("Tools/OCES/Audio/Create AudioExtendSettings Asset")]
static void CreateDefaultAsset()
{
if (!AssetDatabase.IsValidFolder("Assets/Settings"))
@@ -14,7 +14,7 @@ namespace OCES.Audio
get { return Path.Combine(Application.dataPath, "Resources", AudioExtendSettings.Instance.audioConfigPath); }
}
[MenuItem("Tools/Audio/Apply Audio Import Settings")]
[MenuItem("Tools/OCES/Audio/Apply Audio Import Settings")]
public static void RunFromMenu()
{
if (EditorUtility.DisplayDialog(
@@ -47,7 +47,7 @@ namespace OCES.Haptic.Editor
};
}
[MenuItem("Tools/Haptic/Create Haptic Settings Asset")]
[MenuItem("Tools/OCES/Haptic/Create Haptic Settings Asset")]
static void CreateDefaultAsset()
{
if (!AssetDatabase.IsValidFolder("Assets/Settings"))
@@ -15,6 +15,9 @@ namespace OCES.Haptic
[Tooltip("Resources 子目录:触感资源文件(.haptic")]
public string hapticResourcePath = "Haptics/";
public bool useAssetBundle = false;
public string hapticBundlePath = "Bundles/haptics.ab";
public static HapticSettings Instance { get; internal set; }
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Lofelt.NiceVibrations;
using UnityEngine;
@@ -117,6 +118,16 @@ namespace OCES.Haptic
DontDestroyOnLoad(gameObject);
this.ResourceLoader = gameObject.AddComponent<ResourceLoader>();
if (HapticSettings.Instance.useAssetBundle)
{
string bundlePath = Path.Combine(Application.streamingAssetsPath,
HapticSettings.Instance.hapticBundlePath);
this.ResourceLoader.SetProvider(new AssetBundleAssetProvider(bundlePath));
}
else
{
this.ResourceLoader.SetProvider(new ResourcesAssetProvider());
}
this.m_hapticObjects = HapticConfigLoader.Load<HapticObjectConfig>(HapticSettings.Instance.hapticConfigPath + "HapticObject");
this.IsHapticSupported = DeviceCapabilities.isVersionSupported;
@@ -15,6 +15,8 @@ MonoBehaviour:
audioConfigPath: AudioData
audioResourcePath: Audios
audioMixerPath: Audios/Master
useAssetBundle: 1
audioBundlePath: Bundles/audios.ab
sfxGroupPath: Master/Regular/SFX
voiceGroupPath: Master/Regular/Voice
accentSfxGroupPath: Master/SFX_Accent
+2
View File
@@ -14,3 +14,5 @@ MonoBehaviour:
m_EditorClassIdentifier:
hapticConfigPath: HapticData/
hapticResourcePath: Haptics/
useAssetBundle: 0
hapticBundlePath: Bundles/haptics.ab
+3
View File
@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=lofelt/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Lofelt/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>