# VR剧院启动器项目：资源架构与集成问题解答

针对您提出的问题，我将详细解答如何实现多Story加载、资源管理和逻辑集成。

## 问题1：分离美术资源与Story数据结构

### Story数据结构设计

为了管理来自不同工程的多个Story，您需要一个统一的数据结构：

```csharp
// 在com.protothinker.vrtheater.core包中定义
[System.Serializable]
public class StoryDefinition
{
    public string storyId;           // 故事唯一标识符
    public string displayName;       // 显示名称
    public string description;       // 故事描述
    public string thumbnailAddress;  // 封面图地址
    public string version;           // 版本号
    
    // 该故事包含的场景列表
    public List<StorySceneDefinition> scenes = new List<StorySceneDefinition>();
    
    // 故事元数据（可扩展）
    public Dictionary<string, string> metadata = new Dictionary<string, string>();
}

[System.Serializable]
public class StorySceneDefinition
{
    public string sceneId;           // 场景ID
    public string displayName;       // 场景名称
    public string addressableKey;    // Addressable加载地址
    public bool isInitialScene;      // 是否是初始场景
    public List<string> dependencies = new List<string>(); // 依赖的资源组
}
```

### Story目录和管理器

创建一个StoryManager来管理多个故事：

```csharp
// 在com.protothinker.vrtheater.core包中
public class StoryManager : BaseManager
{
    private Dictionary<string, StoryDefinition> _availableStories = new Dictionary<string, StoryDefinition>();
    private StoryDefinition _currentStory;
    private ResourceManager _resourceManager;
    
    public StoryManager(ResourceManager resourceManager)
    {
        _resourceManager = resourceManager;
    }
    
    // 从本地持久化目录加载所有故事清单
    public async Task LoadStoryManifests()
    {
        string storiesDirectory = Path.Combine(Application.persistentDataPath, "Stories");
        
        if (!Directory.Exists(storiesDirectory))
        {
            Debug.Log("故事目录不存在，创建目录");
            Directory.CreateDirectory(storiesDirectory);
            return;
        }
        
        // 找到所有故事目录
        foreach (string storyDir in Directory.GetDirectories(storiesDirectory))
        {
            string manifestPath = Path.Combine(storyDir, "story.json");
            if (File.Exists(manifestPath))
            {
                try
                {
                    string json = File.ReadAllText(manifestPath);
                    StoryDefinition story = JsonUtility.FromJson<StoryDefinition>(json);
                    
                    if (!string.IsNullOrEmpty(story.storyId))
                    {
                        _availableStories[story.storyId] = story;
                        Debug.Log($"加载故事清单: {story.displayName} (ID: {story.storyId})");
                    }
                }
                catch (Exception e)
                {
                    Debug.LogError($"加载故事清单失败: {manifestPath}, 错误: {e.Message}");
                }
            }
        }
    }
    
    // 获取所有可用的故事
    public List<StoryDefinition> GetAvailableStories()
    {
        return _availableStories.Values.ToList();
    }
    
    // 加载指定的故事
    public async Task<bool> LoadStory(string storyId)
    {
        if (!_availableStories.TryGetValue(storyId, out StoryDefinition story))
        {
            Debug.LogError($"故事不存在: {storyId}");
            return false;
        }
        
        _currentStory = story;
        
        // 预加载该故事的所有依赖资源
        var initialScene = story.scenes.Find(s => s.isInitialScene);
        if (initialScene == null && story.scenes.Count > 0)
        {
            initialScene = story.scenes[0]; // 默认第一个场景
        }
        
        if (initialScene != null)
        {
            // 预下载场景依赖
            await _resourceManager.DownloadDependencies(initialScene.addressableKey);
            
            // 加载初始场景
            await _resourceManager.LoadScene(initialScene.addressableKey);
            return true;
        }
        
        Debug.LogError($"故事没有可加载的场景: {storyId}");
        return false;
    }
    
    // 切换到故事中的指定场景
    public async Task<bool> SwitchToScene(string sceneId)
    {
        if (_currentStory == null)
        {
            Debug.LogError("没有加载任何故事");
            return false;
        }
        
        var targetScene = _currentStory.scenes.Find(s => s.sceneId == sceneId);
        if (targetScene == null)
        {
            Debug.LogError($"场景不存在: {sceneId}");
            return false;
        }
        
        // 预下载场景依赖
        await _resourceManager.DownloadDependencies(targetScene.addressableKey);
        
        // 加载场景
        await _resourceManager.LoadScene(targetScene.addressableKey);
        return true;
    }
}
```

## 问题2：从本地持久化目录加载Story

要从本地持久化目录加载故事，需要处理以下几个方面：

### 1. 统一的故事安装结构

每个故事应该有统一的目录结构，例如：

```
/Stories/
  /{StoryID}/
    /AssetBundles/          # Addressable生成的AB包
    /Catalogs/              # Addressable目录文件
    story.json              # 故事清单
    thumbnail.jpg           # 封面图
```

### 2. 配置Addressables从本地加载

为了让Addressables从持久化目录加载资源，需要修改ResourceManager：

```csharp
// 在com.protothinker.vrtheater.core包中
public class ResourceManager : BaseManager
{
    // ...现有代码...
    
    // 添加自定义运行时加载路径
    public void SetCustomLoadPath(string storyId)
    {
        string customPath = Path.Combine(Application.persistentDataPath, "Stories", storyId, "AssetBundles");
        
        // 转换为URL格式
        string loadPath;
        if (Application.platform == RuntimePlatform.Android)
        {
            loadPath = $"file://{customPath}";
        }
        else
        {
            loadPath = $"file://{customPath}/";
        }
        
        // 设置自定义加载路径
        Addressables.InternalIdTransformFunc = (id) =>
        {
            if (id.StartsWith("http"))
            {
                // 将远程URL替换为本地路径
                string fileName = Path.GetFileName(id);
                return Path.Combine(loadPath, fileName);
            }
            return id;
        };
    }
    
    // 加载本地Addressable目录
    public async Task LoadLocalAddressableCatalog(string storyId)
    {
        string catalogPath = Path.Combine(Application.persistentDataPath, "Stories", storyId, "Catalogs", $"catalog_{storyId}.json");
        
        if (File.Exists(catalogPath))
        {
            try
            {
                // 加载本地目录
                var loadOperation = Addressables.LoadContentCatalogAsync(catalogPath);
                await loadOperation.Task;
                Debug.Log($"加载本地Addressable目录成功: {catalogPath}");
            }
            catch (Exception e)
            {
                Debug.LogError($"加载本地Addressable目录失败: {catalogPath}, 错误: {e.Message}");
            }
        }
        else
        {
            Debug.LogError($"本地Addressable目录不存在: {catalogPath}");
        }
    }
}
```

### 3. 从启动器加载本地Story

在Launcher中整合上述功能：

```csharp
// 在App/Launcher.cs
public class Launcher : MonoBehaviour
{
    private ResourceManager _resourceManager;
    private StoryManager _storyManager;
    
    async void Start()
    {
        // 初始化资源管理器
        _resourceManager = new ResourceManager();
        await _resourceManager.Initialize();
        
        // 初始化故事管理器
        _storyManager = new StoryManager(_resourceManager);
        await _storyManager.LoadStoryManifests();
        
        // 显示故事选择UI
        ShowStorySelectionUI();
    }
    
    // 显示故事选择界面
    private void ShowStorySelectionUI()
    {
        // 从StoryManager获取可用故事列表
        var availableStories = _storyManager.GetAvailableStories();
        
        // 创建UI显示这些故事
        // 当用户选择某个故事时，调用LoadSelectedStory方法
    }
    
    // 加载用户选择的故事
    public async Task LoadSelectedStory(string storyId)
    {
        // 显示加载界面
        ShowLoadingUI();
        
        // 设置该故事的自定义加载路径
        _resourceManager.SetCustomLoadPath(storyId);
        
        // 加载该故事的Addressable目录
        await _resourceManager.LoadLocalAddressableCatalog(storyId);
        
        // 加载故事
        bool success = await _storyManager.LoadStory(storyId);
        
        // 隐藏加载界面
        HideLoadingUI();
        
        if (!success)
        {
            // 显示错误提示
            ShowErrorMessage("无法加载所选故事，请尝试重新安装。");
        }
    }
}
```

## 问题3：利用Addressables自动管理依赖

Addressables可以自动处理资源依赖关系，简化场景加载卸载流程：

### 1. 设置Addressables依赖自动管理

在美术工程中配置Addressables时：

1. 打开Addressables Groups窗口
2. 选择"Tools" > "Group Settings"
3. 在"Content Update" > "Build Remote Catalog"选项设置为true
4. 确保"Manage Build Connection"设置为true

这样Addressables会在打包时自动分析并处理依赖关系。

### 2. 简化场景加载接口

简化ResourceManager的场景加载接口：

```csharp
// 在ResourceManager中
public async Task<SceneInstance> LoadSceneAsync(string sceneAddress, LoadSceneMode mode = LoadSceneMode.Single)
{
    // Addressables会自动处理该场景的所有依赖
    var handle = Addressables.LoadSceneAsync(sceneAddress, mode);
    SceneInstance scene = await handle.Task;
    
    // 保存场景句柄（用于后续卸载）
    _loadedSceneHandles[sceneAddress] = handle;
    
    return scene;
}

// 卸载场景
public async Task UnloadSceneAsync(string sceneAddress)
{
    if (_loadedSceneHandles.TryGetValue(sceneAddress, out var handle))
    {
        await Addressables.UnloadSceneAsync(handle).Task;
        _loadedSceneHandles.Remove(sceneAddress);
    }
}
```

## 问题4：场景打包策略（单AB vs 多AB）

针对多个场景的打包策略，建议采用以下方法：

### 推荐方案：一个故事一个组，场景单独寻址

这种方法既保证了场景的独立性，又允许复用共享资源：

1. **创建故事组结构**：
    
    - 为每个故事创建一个主组，如"Story01"
    - 在主组下创建子组，如"Story01_Scenes"和"Story01_SharedAssets"
2. **场景资源设置**：
    
    - 每个场景单独Address化，如"story01/scene01"、"story01/scene02"
    - 将共享资源放入"Story01_SharedAssets"组
3. **打包设置**：
    
    - 设置场景之间的依赖关系（通过引用）
    - Addressable会自动分析：
        - 场景自有资源（只有单个场景用到）打包到场景AB
        - 多个场景共享的资源打包到共享AB

### 具体配置步骤

1. 打开Addressables Groups窗口
    
2. 创建故事组结构：
    
    - 右键 > "Create New Group" > "Packed Assets"
    - 根据上述结构创建分组
3. 设置打包模式：
    
    - 选择组 > Inspector > "Advanced Options" > "Content Packing & Loading"
    - "Bundle Mode"选择 `PackSeparately`（场景）或 `PackTogether`（共享资源）
4. 标记资源：
    
    - 选中场景 > Inspector > "Addressable"
    - 设置地址格式：`{storyId}/{sceneId}`

## 问题5：将逻辑植入美术工程

为了让美术工程响应启动器逻辑，您有几种选择：

### 方案1：SDK插件模式（推荐）

创建一个"整合插件"，美术团队只需导入此插件：

1. **创建SDK包结构**：  
    在您的 `com.protothinker.vrtheater.sdk` 包中：

```
com.protothinker.vrtheater.sdk/
├── Runtime/
│   ├── Integration/
│   │   ├── SceneIntegrationManager.cs   # 场景整合管理器
│   │   ├── StorySceneController.cs      # 场景控制器组件
│   │   ├── StoryElementBase.cs          # 故事元素基类
│   │   ├── InteractiveObject.cs         # 交互对象基类
│   ├── Editor/
│   │   ├── Integration/
│   │   │   ├── SceneSetupWizard.cs      # 场景配置向导
│   │   │   ├── StoryExporter.cs         # 故事导出工具
```

1. **SceneIntegrationManager接口**：

```csharp
// 在SDK包中定义通用接口
public class SceneIntegrationManager : MonoBehaviour
{
    // 场景准备好时调用此事件
    public static event Action<string> OnSceneReady;
    
    // 场景特定事件
    public static event Action<string, string> OnSceneEvent;
    
    // 从启动器接收命令
    public void ReceiveCommand(string command, string parameter)
    {
        // 处理来自启动器的命令
    }
    
    // 通知启动器场景已准备好
    public void NotifySceneReady(string sceneId)
    {
        OnSceneReady?.Invoke(sceneId);
    }
    
    // 触发场景事件
    public void TriggerSceneEvent(string eventType, string eventData)
    {
        OnSceneEvent?.Invoke(eventType, eventData);
    }
}
```

1. **简化的场景控制器组件**：

```csharp
// 添加到美术场景主对象
public class StorySceneController : MonoBehaviour
{
    public string sceneId;
    
    // 从Inspector中配置场景参数
    [SerializeField] private List<StoryElementBase> storyElements;
    
    private void Start()
    {
        // 注册所有故事元素
        foreach (var element in storyElements)
        {
            element.Initialize();
        }
        
        // 通知场景已准备好
        SceneIntegrationManager.Instance.NotifySceneReady(sceneId);
    }
}
```

1. **美术团队集成步骤**：
    - 导入SDK包
    - 在场景中添加SceneIntegrationManager预制体
    - 给主场景对象添加StorySceneController组件
    - 配置sceneId和其他参数

### 方案2：自定义编辑器工具

创建一个编辑器窗口，美术团队使用它生成必要的集成代码：

```csharp
// 在SDK包的Editor目录
public class SceneSetupWizard : EditorWindow
{
    private string storyId = "story01";
    private string sceneId = "scene01";
    
    [MenuItem("VR剧院/场景配置向导")]
    public static void ShowWindow()
    {
        GetWindow<SceneSetupWizard>("场景配置向导");
    }
    
    private void OnGUI()
    {
        EditorGUILayout.LabelField("场景配置", EditorStyles.boldLabel);
        
        storyId = EditorGUILayout.TextField("故事ID", storyId);
        sceneId = EditorGUILayout.TextField("场景ID", sceneId);
        
        EditorGUILayout.Space();
        
        if (GUILayout.Button("配置当前场景"))
        {
            SetupCurrentScene();
        }
        
        if (GUILayout.Button("生成故事清单"))
        {
            GenerateStoryManifest();
        }
    }
    
    private void SetupCurrentScene()
    {
        // 为当前场景添加必要组件
        var rootObj = new GameObject("SceneController");
        var controller = rootObj.AddComponent<StorySceneController>();
        controller.sceneId = sceneId;
        
        // 添加集成管理器
        var managerObj = new GameObject("IntegrationManager");
        managerObj.AddComponent<SceneIntegrationManager>();
        
        // 设置Addressable
        var currentScene = EditorSceneManager.GetActiveScene();
        var sceneAsset = AssetDatabase.LoadAssetAtPath<SceneAsset>(currentScene.path);
        
        // 将场景标记为Addressable
        var settings = AddressableAssetSettingsDefaultObject.Settings;
        var guid = AssetDatabase.AssetPathToGUID(currentScene.path);
        var entry = settings.CreateOrMoveEntry(guid, settings.DefaultGroup);
        entry.address = $"{storyId}/{sceneId}";
        
        EditorUtility.DisplayDialog("成功", "场景已配置完成！", "确定");
    }
    
    private void GenerateStoryManifest()
    {
        // 生成story.json文件
        StoryDefinition story = new StoryDefinition();
        story.storyId = storyId;
        story.displayName = storyId;  // 可以改为更友好的名称
        
        // 查找所有相关场景
        var scenes = new List<StorySceneDefinition>();
        
        // 查找当前项目中所有被标记为Addressable的场景
        var settings = AddressableAssetSettingsDefaultObject.Settings;
        foreach (var group in settings.groups)
        {
            foreach (var entry in group.entries)
            {
                if (entry.address.StartsWith($"{storyId}/"))
                {
                    var scene = new StorySceneDefinition();
                    scene.sceneId = entry.address.Split('/')[1];
                    scene.displayName = scene.sceneId;
                    scene.addressableKey = entry.address;
                    scenes.Add(scene);
                }
            }
        }
        
        story.scenes = scenes;
        
        // 序列化为JSON
        string json = JsonUtility.ToJson(story, true);
        
        // 保存到文件
        string path = EditorUtility.SaveFilePanel("保存故事清单", "", "story.json", "json");
        if (!string.IsNullOrEmpty(path))
        {
            File.WriteAllText(path, json);
            EditorUtility.DisplayDialog("成功", $"故事清单已保存到: {path}", "确定");
        }
    }
}
```

## 完整资源集成与加载流程

将以上所有概念整合在一起，完整流程如下：

### 1. 美术工程准备

1. **导入SDK包**
2. **使用场景配置向导配置每个场景**
3. **使用Addressables设置资源分组**
4. **生成故事清单story.json**
5. **构建Addressables资源**
6. **将生成的资源包和清单文件部署到目标位置**

### 2. 启动器加载流程

1. **初始化ResourceManager和StoryManager**
2. **显示故事选择界面**
3. **用户选择故事后**：
    - 设置自定义加载路径
    - 加载故事的Addressable目录
    - 加载初始场景
4. **场景切换**：
    - 用户触发场景切换
    - StoryManager通过ResourceManager加载新场景
    - 新场景加载完成后通知启动器准备就绪

### 3. 场景间通信

1. **启动器向场景发送命令**：
    
    ```csharp
    // 获取当前场景中的集成管理器
    var integrationManager = FindObjectOfType<SceneIntegrationManager>();
    if (integrationManager != null)
    {
        integrationManager.ReceiveCommand("StartNarrative", "sequence01");
    }
    ```
    
2. **场景向启动器发送事件**：
    
    ```csharp
    // 在场景控制器中
    SceneIntegrationManager.Instance.TriggerSceneEvent("NarrativeCompleted", "sequence01");
    ```
    

## 总结建议

考虑到您的需求，我建议：

1. **采用"故事包"概念**：每个Story是一个独立单元，包含多个场景和资源
2. **采用SDK插件模式**：为美术团队提供统一的集成接口和工具
3. **场景打包策略**：
    - 每个场景独立寻址
    - 共享资源集中管理
    - 使用Addressables自动处理依赖
4. **本地加载优化**：预先从本地持久化目录加载，避免网络依赖
5. **统一的场景生命周期**：标准化场景加载、初始化和卸载流程

这种架构既能满足您动态加载多Story的需求，又能简化美术团队的工作流程，同时保持项目的可维护性和扩展性。