升级 P1 主菜单样例和使用规范
This commit is contained in:
@@ -25,8 +25,15 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
private ContainerType _root;
|
private ContainerType _root;
|
||||||
private GameFlow _gameFlow;
|
private GameFlow _gameFlow;
|
||||||
private ISaveService _saveService;
|
private ISaveService _saveService;
|
||||||
|
private UIPreloadService _preloadService;
|
||||||
|
private SampleResourceBackend _sampleBackend;
|
||||||
private PlayerData _playerData;
|
private PlayerData _playerData;
|
||||||
private CancellationTokenSource _lifetime;
|
private CancellationTokenSource _lifetime;
|
||||||
|
private bool _started;
|
||||||
|
private bool _shutdown;
|
||||||
|
|
||||||
|
public int ReleasedResourceCount => _sampleBackend?.ReleaseCount ?? 0;
|
||||||
|
public bool IsShutdown => _shutdown;
|
||||||
|
|
||||||
private async void Awake()
|
private async void Awake()
|
||||||
{
|
{
|
||||||
@@ -36,35 +43,61 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
|
|
||||||
public async Task StartupAsync(CancellationToken cancellationToken)
|
public async Task StartupAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
_shutdown = false;
|
||||||
_root = new ContainerType();
|
_root = new ContainerType();
|
||||||
_gameFlow = new GameFlow();
|
_gameFlow = new GameFlow();
|
||||||
|
|
||||||
var resources = new SampleResourceService();
|
_sampleBackend = new SampleResourceBackend();
|
||||||
resources.Register(
|
_sampleBackend.Register(
|
||||||
"FlowScope/Samples/MainMenuP0/MainMenuPanel",
|
"FlowScope/Samples/MainMenuP0/MainMenuPanel",
|
||||||
ResolveMainMenuPanelPrefab().gameObject);
|
ResolveMainMenuPanelPrefab().gameObject);
|
||||||
|
var resources = new ResourceService(_sampleBackend);
|
||||||
|
_preloadService = new UIPreloadService(resources);
|
||||||
|
var uiManager = new UIManager(resources, _preloadService);
|
||||||
|
uiManager.RegisterLayer("hud", ResolveHudCanvas(), 0, PanelStrategy.Destroy);
|
||||||
|
var screenNavigator = new UIScreenNavigator(uiManager);
|
||||||
|
|
||||||
|
var migrations = new SaveMigrationRegistry();
|
||||||
|
migrations.Register(new PlayerDataV1ToV2Migration());
|
||||||
_saveService = new SaveService(
|
_saveService = new SaveService(
|
||||||
new FileSaveStorage(),
|
new FileSaveStorage(),
|
||||||
new JsonSaveSerializer());
|
new JsonSaveSerializer(2, migrations));
|
||||||
_playerData = await _saveService.LoadAsync(
|
_playerData = await _saveService.LoadAsync(
|
||||||
SaveKey,
|
SaveKey,
|
||||||
new PlayerData { Gold = new R3.ReactiveProperty<int>(defaultGold) },
|
new PlayerData { Gold = new R3.ReactiveProperty<int>(defaultGold) },
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
var uiManager = new UIManager(resources);
|
await _preloadService.PreloadAsync<MainMenuPanel>(cancellationToken);
|
||||||
uiManager.RegisterLayer("hud", ResolveHudCanvas(), 0, PanelStrategy.Cache);
|
|
||||||
|
|
||||||
var audioService = gameObject.AddComponent<AudioService>().Initialize(resources);
|
var audioService = gameObject.AddComponent<AudioService>().Initialize(resources);
|
||||||
_root.RegisterInstance(_root);
|
_root.RegisterInstance(_root);
|
||||||
_root.RegisterInstance<IConfigProvider>(
|
_root.RegisterInstance<IConfigProvider>(
|
||||||
new JsonConfigProvider(Array.Empty<JsonConfigProvider.ConfigMapping>()));
|
new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<SampleMenuConfig>(
|
||||||
|
"main_menu.json",
|
||||||
|
new InMemoryConfigSource(
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "main_menu.json", "[{\"Id\":1,\"Title\":\"Main Menu P1\"}]" }
|
||||||
|
},
|
||||||
|
"MainMenuP0"),
|
||||||
|
new JsonConfigParser())
|
||||||
|
}));
|
||||||
_root.RegisterInstance<IResourceService>(resources);
|
_root.RegisterInstance<IResourceService>(resources);
|
||||||
_root.RegisterInstance<ISaveService>(_saveService);
|
_root.RegisterInstance<ISaveService>(_saveService);
|
||||||
_root.RegisterInstance<IAudioService>(audioService);
|
_root.RegisterInstance<IAudioService>(audioService);
|
||||||
_root.RegisterInstance(uiManager);
|
_root.RegisterInstance(uiManager);
|
||||||
|
_root.RegisterInstance<IUIPreloadService>(_preloadService);
|
||||||
|
_root.RegisterInstance<IUIScreenNavigator>(screenNavigator);
|
||||||
_root.RegisterInstance(_playerData);
|
_root.RegisterInstance(_playerData);
|
||||||
_root.RegisterFactory(_ => new MainMenuFeature(uiManager, _playerData));
|
_root.RegisterFactory(_ => new MainMenuFeature(screenNavigator, _playerData));
|
||||||
|
|
||||||
await _gameFlow.StartupAsync<MainMenuFeature>(_root, cancellationToken);
|
await _gameFlow.StartupAsync<MainMenuFeature>(_root, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -80,6 +113,9 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
{
|
{
|
||||||
await _gameFlow.ShutdownAsync(cancellationToken);
|
await _gameFlow.ShutdownAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_preloadService?.ReleaseAll();
|
||||||
|
_shutdown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnApplicationQuit()
|
private async void OnApplicationQuit()
|
||||||
@@ -137,16 +173,19 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
return mainMenuPanelPrefab;
|
return mainMenuPanelPrefab;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SampleResourceService : IResourceService
|
private sealed class SampleResourceBackend : IResourceBackend
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, GameObject> _prefabs = new();
|
private readonly Dictionary<string, GameObject> _prefabs = new();
|
||||||
|
|
||||||
|
public string Name => "MainMenuP0Sample";
|
||||||
|
public int ReleaseCount { get; private set; }
|
||||||
|
|
||||||
public void Register(string key, GameObject prefab)
|
public void Register(string key, GameObject prefab)
|
||||||
{
|
{
|
||||||
_prefabs[key] = prefab;
|
_prefabs[key] = prefab;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IResourceHandle<T>> LoadAsync<T>(
|
public Task<T> LoadAsync<T>(
|
||||||
string key,
|
string key,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
where T : class
|
where T : class
|
||||||
@@ -155,36 +194,32 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
if (typeof(T) == typeof(GameObject) &&
|
if (typeof(T) == typeof(GameObject) &&
|
||||||
_prefabs.TryGetValue(key, out var prefab))
|
_prefabs.TryGetValue(key, out var prefab))
|
||||||
{
|
{
|
||||||
return Task.FromResult<IResourceHandle<T>>(
|
return Task.FromResult(prefab as T);
|
||||||
new SampleResourceHandle<T>(key, prefab as T));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException($"Sample resource is not registered: {key}");
|
throw new InvalidOperationException($"Sample resource is not registered: {key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public IResourceGroup CreateGroup()
|
public void Release<T>(string key, T asset)
|
||||||
|
where T : class
|
||||||
{
|
{
|
||||||
return new ResourceGroup();
|
ReleaseCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SampleResourceHandle<T> : IResourceHandle<T> where T : class
|
private sealed class SampleMenuConfig : IConfigRow
|
||||||
{
|
{
|
||||||
public SampleResourceHandle(string key, T asset)
|
public int Id { get; set; }
|
||||||
{
|
public string Title { get; set; }
|
||||||
Key = key;
|
}
|
||||||
Asset = asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Key { get; }
|
private sealed class PlayerDataV1ToV2Migration : ISaveMigration
|
||||||
public T Asset { get; private set; }
|
{
|
||||||
public bool IsDisposed { get; private set; }
|
public string Key => typeof(PlayerData).FullName;
|
||||||
|
public int FromVersion => 1;
|
||||||
|
public int ToVersion => 2;
|
||||||
|
|
||||||
public void Dispose()
|
public string Migrate(string json) => json;
|
||||||
{
|
|
||||||
IsDisposed = true;
|
|
||||||
Asset = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
{
|
{
|
||||||
public sealed class MainMenuFeature : FeatureBase
|
public sealed class MainMenuFeature : FeatureBase
|
||||||
{
|
{
|
||||||
private readonly UIManager _uiManager;
|
private readonly IUIScreenNavigator _screenNavigator;
|
||||||
private readonly PlayerData _playerData;
|
private readonly PlayerData _playerData;
|
||||||
private MainMenuViewModel _viewModel;
|
private MainMenuViewModel _viewModel;
|
||||||
|
|
||||||
public MainMenuFeature(UIManager uiManager, PlayerData playerData)
|
public MainMenuFeature(IUIScreenNavigator screenNavigator, PlayerData playerData)
|
||||||
{
|
{
|
||||||
_uiManager = uiManager;
|
_screenNavigator = screenNavigator;
|
||||||
_playerData = playerData;
|
_playerData = playerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ namespace FlowScope.Samples.MainMenuP0
|
|||||||
|
|
||||||
public override async Task EnterAsync(FeatureContext context)
|
public override async Task EnterAsync(FeatureContext context)
|
||||||
{
|
{
|
||||||
await _uiManager.OpenAsync<MainMenuPanel, MainMenuViewModel>(
|
await _screenNavigator.PushAsync<MainMenuPanel, MainMenuViewModel>(
|
||||||
_viewModel,
|
_viewModel,
|
||||||
context.CancellationToken);
|
context.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task ExitAsync(FeatureContext context)
|
public override Task ExitAsync(FeatureContext context)
|
||||||
{
|
{
|
||||||
_uiManager.Close<MainMenuPanel>();
|
_screenNavigator.Clear();
|
||||||
return base.ExitAsync(context);
|
return base.ExitAsync(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace FlowScope.Samples.MainMenuP0
|
namespace FlowScope.Samples.MainMenuP0
|
||||||
{
|
{
|
||||||
[UIPanel("hud", "FlowScope/Samples/MainMenuP0/MainMenuPanel", PanelStrategy.Cache)]
|
[UIPanel("hud", "FlowScope/Samples/MainMenuP0/MainMenuPanel")]
|
||||||
public sealed class MainMenuPanel : UIPanelBase<MainMenuViewModel>
|
public sealed class MainMenuPanel : UIPanelBase<MainMenuViewModel>
|
||||||
{
|
{
|
||||||
[SerializeField] private Text goldText;
|
[SerializeField] private Text goldText;
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ namespace FlowScope.Tests.PlayMode.Samples
|
|||||||
}
|
}
|
||||||
|
|
||||||
[UnityTest]
|
[UnityTest]
|
||||||
public IEnumerator Bootstrap_StartClickShutdownAndRestart_RestoresSavedGold()
|
public IEnumerator Bootstrap_StartsAndOpensVisibleMainMenuPanel()
|
||||||
{
|
{
|
||||||
DeleteSampleSave();
|
DeleteSampleSave();
|
||||||
var root = new GameObject("bootstrap-test");
|
var root = CreateInactiveBootstrap("bootstrap-visible-test", out var bootstrap);
|
||||||
root.SetActive(false);
|
|
||||||
|
|
||||||
var bootstrap = root.AddComponent<GameBootstrap>();
|
|
||||||
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
|
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
|
||||||
|
|
||||||
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||||
@@ -42,17 +40,49 @@ namespace FlowScope.Tests.PlayMode.Samples
|
|||||||
var button = panel.GetComponentInChildren<Button>(true);
|
var button = panel.GetComponentInChildren<Button>(true);
|
||||||
Assert.That(label, Is.Not.Null);
|
Assert.That(label, Is.Not.Null);
|
||||||
Assert.That(button, Is.Not.Null);
|
Assert.That(button, Is.Not.Null);
|
||||||
|
Assert.That(panel.gameObject.activeSelf, Is.True);
|
||||||
Assert.That(label.text, Is.EqualTo("0"));
|
Assert.That(label.text, Is.EqualTo("0"));
|
||||||
|
|
||||||
|
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
||||||
|
Object.DestroyImmediate(root);
|
||||||
|
DeleteSampleSave();
|
||||||
|
LogAssert.NoUnexpectedReceived();
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator ClickIncrement_UpdatesGoldTextImmediately()
|
||||||
|
{
|
||||||
|
DeleteSampleSave();
|
||||||
|
var root = CreateInactiveBootstrap("bootstrap-click-test", out var bootstrap);
|
||||||
|
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
|
||||||
|
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||||
|
var label = panel.GetComponentInChildren<Text>(true);
|
||||||
|
var button = panel.GetComponentInChildren<Button>(true);
|
||||||
|
|
||||||
button.onClick.Invoke();
|
button.onClick.Invoke();
|
||||||
|
|
||||||
Assert.That(label.text, Is.EqualTo("1"));
|
Assert.That(label.text, Is.EqualTo("1"));
|
||||||
|
|
||||||
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
||||||
Object.DestroyImmediate(root);
|
Object.DestroyImmediate(root);
|
||||||
|
DeleteSampleSave();
|
||||||
|
LogAssert.NoUnexpectedReceived();
|
||||||
|
}
|
||||||
|
|
||||||
var restartRoot = new GameObject("bootstrap-restart-test");
|
[UnityTest]
|
||||||
restartRoot.SetActive(false);
|
public IEnumerator ShutdownAndRestart_RestoresSavedGold()
|
||||||
var restartedBootstrap = restartRoot.AddComponent<GameBootstrap>();
|
{
|
||||||
|
DeleteSampleSave();
|
||||||
|
var root = CreateInactiveBootstrap("bootstrap-save-test", out var bootstrap);
|
||||||
|
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
|
||||||
|
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||||
|
var button = panel.GetComponentInChildren<Button>(true);
|
||||||
|
|
||||||
|
button.onClick.Invoke();
|
||||||
|
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
||||||
|
Object.DestroyImmediate(root);
|
||||||
|
|
||||||
|
var restartRoot = CreateInactiveBootstrap("bootstrap-restart-test", out var restartedBootstrap);
|
||||||
yield return AwaitTask(restartedBootstrap.StartupAsync(CancellationToken.None));
|
yield return AwaitTask(restartedBootstrap.StartupAsync(CancellationToken.None));
|
||||||
|
|
||||||
var restoredPanel = Object.FindObjectOfType<MainMenuPanel>(true);
|
var restoredPanel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||||
@@ -62,6 +92,27 @@ namespace FlowScope.Tests.PlayMode.Samples
|
|||||||
yield return AwaitTask(restartedBootstrap.ShutdownAsync(CancellationToken.None));
|
yield return AwaitTask(restartedBootstrap.ShutdownAsync(CancellationToken.None));
|
||||||
Object.DestroyImmediate(restartRoot);
|
Object.DestroyImmediate(restartRoot);
|
||||||
DeleteSampleSave();
|
DeleteSampleSave();
|
||||||
|
LogAssert.NoUnexpectedReceived();
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator Shutdown_ReleasesPreloadedResourcesAndSubscriptions()
|
||||||
|
{
|
||||||
|
DeleteSampleSave();
|
||||||
|
var root = CreateInactiveBootstrap("bootstrap-release-test", out var bootstrap);
|
||||||
|
yield return AwaitTask(bootstrap.StartupAsync(CancellationToken.None));
|
||||||
|
var panel = Object.FindObjectOfType<MainMenuPanel>(true);
|
||||||
|
|
||||||
|
yield return AwaitTask(bootstrap.ShutdownAsync(CancellationToken.None));
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
Assert.That(bootstrap.IsShutdown, Is.True);
|
||||||
|
Assert.That(bootstrap.ReleasedResourceCount, Is.EqualTo(1));
|
||||||
|
Assert.That(panel == null, Is.True);
|
||||||
|
|
||||||
|
Object.DestroyImmediate(root);
|
||||||
|
DeleteSampleSave();
|
||||||
|
LogAssert.NoUnexpectedReceived();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerator AwaitTask(Task task)
|
private static IEnumerator AwaitTask(Task task)
|
||||||
@@ -82,6 +133,14 @@ namespace FlowScope.Tests.PlayMode.Samples
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static GameObject CreateInactiveBootstrap(string name, out GameBootstrap bootstrap)
|
||||||
|
{
|
||||||
|
var root = new GameObject(name);
|
||||||
|
root.SetActive(false);
|
||||||
|
bootstrap = root.AddComponent<GameBootstrap>();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
private static void DeleteSampleSave()
|
private static void DeleteSampleSave()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(
|
var path = Path.Combine(
|
||||||
|
|||||||
55
docs/guides/flowscope-runtime-usage.md
Normal file
55
docs/guides/flowscope-runtime-usage.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# FlowScope Runtime 使用规范
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
Bootstrap 负责组合运行时依赖,而不是把业务流程写进场景脚本。推荐顺序是创建根 `Container`、注册 Config/Resource/Save/UI/Audio 等服务、注册首个 Feature 的工厂,最后调用 `GameFlow.StartupAsync<TInitialFeature>`。
|
||||||
|
|
||||||
|
Bootstrap 必须持有生命周期 `CancellationToken`,退出时先保存必要状态,再调用 `GameFlow.ShutdownAsync`,最后释放预加载资源、音频句柄或其他根级缓存。
|
||||||
|
|
||||||
|
## Container 注册规则
|
||||||
|
|
||||||
|
容器注册应显式、可读、可追踪。稳定服务使用 `RegisterInstance`,带运行时参数或 Feature 私有依赖使用 `RegisterFactory`。不要依赖类型扫描或名称约定来猜测注册关系。
|
||||||
|
|
||||||
|
Feature 只从自己的 scope 解析依赖。跨 Feature 的共享服务放在 root container,Feature 内部临时对象放在 Feature scope,并随 `FeatureContext` 一起释放。
|
||||||
|
|
||||||
|
## Feature 生命周期
|
||||||
|
|
||||||
|
`LoadAsync` 只准备数据、ViewModel 和轻量状态;`EnterAsync` 打开 UI、订阅输入或启动表现;`ExitAsync` 必须关闭本 Feature 打开的 UI 并释放订阅;`Dispose` 只做最后的托底清理。
|
||||||
|
|
||||||
|
不要在 `Dispose` 中启动新的异步工作。需要异步关闭的行为放在 `ExitAsync`。
|
||||||
|
|
||||||
|
## 资源所有权
|
||||||
|
|
||||||
|
P1 的资源入口是 `ResourceService`,后端由 `IResourceBackend` 适配。业务层只持有 `IResourceHandle<T>` 或 `IResourceGroup`,不直接调用 Addressables 或样例 backend。
|
||||||
|
|
||||||
|
谁创建 handle,谁负责释放。预加载资源由 `UIPreloadService` 持有,UI 实例关闭时不释放预加载 handle,Bootstrap 或上层流程退出时调用 `Release<TPanel>` 或 `ReleaseAll`。
|
||||||
|
|
||||||
|
## UI 路径和层级
|
||||||
|
|
||||||
|
Panel 类型必须声明 `UIPanelAttribute`。优先显式填写资源路径,例如 `FlowScope/Samples/MainMenuP0/MainMenuPanel`;未填写时才使用 `UI/{PanelNameWithoutSuffix}/Prefab` 约定。
|
||||||
|
|
||||||
|
`UIManager` 维护层内 LIFO。`UIScreenNavigator` 只管理它自己 push 的页面,`GoBack` 保留最后一个页面,`Clear` 清空导航器打开的页面。
|
||||||
|
|
||||||
|
## 配置 DTO 约束
|
||||||
|
|
||||||
|
配置行实现 `IConfigRow`,`Id` 在同一表内必须唯一。DTO 应保持简单字段或属性,不把运行时对象、Unity 组件、服务实例放进配置。
|
||||||
|
|
||||||
|
配置加载通过 `IConfigSource` 读取文本,通过 `IConfigParser` 解析。生产环境可接文件或远端来源,测试和样例可使用 `InMemoryConfigSource`。
|
||||||
|
|
||||||
|
## 存档版本迁移
|
||||||
|
|
||||||
|
`JsonSaveSerializer` 的 `currentVersion` 大于 1 时会写入版本 envelope。旧版裸 JSON 被视为版本 1,并通过 `SaveMigrationRegistry` 迁移到当前版本。
|
||||||
|
|
||||||
|
每个迁移实现 `ISaveMigration`,`Key` 默认使用存档 DTO 类型全名,迁移链必须逐版本连续,例如 1 -> 2 -> 3。
|
||||||
|
|
||||||
|
## Addressables 后端接入
|
||||||
|
|
||||||
|
生产资源后端使用 `FlowScope.Addressables.AddressablesResourceBackend`。项目需要在 `Packages/manifest.json` 中包含 `com.unity.addressables`,并让 Unity 完成包解析后再运行 PlayMode 全量测试。
|
||||||
|
|
||||||
|
业务代码不直接依赖 Addressables API。需要替换后端时,只调整 Bootstrap 或平台装配层的 `IResourceBackend` 注册。
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
窄单元测试优先覆盖接口契约:重复加载共享、取消不释放他人 handle、配置重复 ID、存档迁移缺链、UI LIFO 和预加载释放。
|
||||||
|
|
||||||
|
样例验收至少覆盖启动可见、按钮即时更新、关闭保存、重启恢复、资源释放。Unity Test Runner 全绿前,不要宣布生产化验收完成。
|
||||||
33
docs/guides/main-menu-p1-sample.md
Normal file
33
docs/guides/main-menu-p1-sample.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# MainMenuP0 P1 样例验收
|
||||||
|
|
||||||
|
## 打开场景
|
||||||
|
|
||||||
|
在 Unity 中打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
My project/Assets/FlowScope/Samples/MainMenuP0/Scenes/MainMenuP0.unity
|
||||||
|
```
|
||||||
|
|
||||||
|
进入 Play Mode 后,`GameBootstrap` 会注册 P1 运行时服务,预加载 `MainMenuPanel`,并启动 `MainMenuFeature`。
|
||||||
|
|
||||||
|
## 预期画面
|
||||||
|
|
||||||
|
画面中应出现主菜单面板、Gold 数字文本和一个递增按钮。若场景或 prefab 引用缺失,样例仍会创建运行时 fallback UI,但人工验收应优先检查场景和 prefab 引用是否正确。
|
||||||
|
|
||||||
|
## Gold 递增
|
||||||
|
|
||||||
|
按钮点击调用 `MainMenuViewModel.IncrementGold`,立即修改 `PlayerData.Gold`,并通过面板订阅同步刷新 Gold 文本。点击一次后,文本应从 `0` 变为 `1`。
|
||||||
|
|
||||||
|
## 保存与恢复
|
||||||
|
|
||||||
|
`ShutdownAsync` 会把 `PlayerData` 写入 `main-menu-player` 存档。再次启动样例时,`SaveService` 会读取同一 key 并恢复 Gold。
|
||||||
|
|
||||||
|
P1 样例使用 `JsonSaveSerializer` 版本 envelope;旧版裸 JSON 会通过 no-op 的 1 -> 2 迁移链继续读取。
|
||||||
|
|
||||||
|
## 资源释放
|
||||||
|
|
||||||
|
样例资源通过 `ResourceService` 和样例 `IResourceBackend` 加载。`UIPreloadService` 持有预加载 handle;Feature 退出时关闭面板实例,Bootstrap 关闭时调用 `ReleaseAll` 释放预加载资源。
|
||||||
|
|
||||||
|
## Console 输出
|
||||||
|
|
||||||
|
验收期间不应出现未预期的 Error 或 Exception。Unity 引用版本冲突警告如果只出现在外部 `dotnet build` 中,属于当前 Unity 生成项目的已知警告,不作为样例失败条件。
|
||||||
Reference in New Issue
Block a user