From 43f81c892663a95a690ac85e296e56836b4f8abe Mon Sep 17 00:00:00 2001 From: basil <2428390463@qq.com> Date: Wed, 17 Jun 2026 11:57:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=20ShopController=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=20Context=20=E7=9A=84=E5=8A=9F=E8=83=BD=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20partial=20class=20=E9=87=8C=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E9=83=A8=E5=88=86=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Main/LevelUp/LevelUpController.cs | 1 + .../Presentation/Main/Shop/ShopContext.cs | 14 ++ .../Main/Shop/ShopController.BuildContext.cs | 174 ++++++++++++++ .../Shop/ShopController.BuildContext.cs.meta | 11 + .../Presentation/Main/Shop/ShopController.cs | 227 +----------------- docs/UI-5层架构设计规范.md | 134 +++++++++++ 6 files changed, 344 insertions(+), 217 deletions(-) create mode 100644 Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs create mode 100644 Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs.meta diff --git a/Assets/GameMain/Scripts/Presentation/Main/LevelUp/LevelUpController.cs b/Assets/GameMain/Scripts/Presentation/Main/LevelUp/LevelUpController.cs index 7b1f57d..1bb2b24 100644 --- a/Assets/GameMain/Scripts/Presentation/Main/LevelUp/LevelUpController.cs +++ b/Assets/GameMain/Scripts/Presentation/Main/LevelUp/LevelUpController.cs @@ -75,6 +75,7 @@ namespace SepCore.UI if (userData is LevelUpRawData rawData) { await OpenUIAsync(rawData, timeout); + return; } if (userData != null) diff --git a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopContext.cs b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopContext.cs index 2bf503d..00d5933 100644 --- a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopContext.cs +++ b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopContext.cs @@ -17,5 +17,19 @@ namespace SepCore.UI public bool NeedRefreshWeaponList; public bool NeedRefreshPropList; public bool NeedRefreshPrice; + + public ShopContext() + { + CurrentLevel = 0; + RefreshPrice = 0; + PlayerCoin = 0; + } + + public ShopContext(ShopRawData rawData) + { + CurrentLevel = rawData.CurrentLevel; + RefreshPrice = rawData.RefreshPrice; + PlayerCoin = rawData.PlayerCoin; + } } } diff --git a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs new file mode 100644 index 0000000..d6e9b99 --- /dev/null +++ b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using SepCore.DataTable; +using SepCore.Definition; +using SepCore.Entity.Weapon; +using UnityGameFramework.Runtime; + +namespace SepCore.UI +{ + public partial class ShopController + { + private async UniTask BuildContext(ShopRawData rawData) + { + if (rawData == null) + { + Log.Error("ShopFormController.BuildContext() rawData is null."); + return null; + } + + _rawData = rawData; + + List goodsItems = new List(); + foreach (var item in rawData.GoodsItems) + { + var context = await CreateGoodsItemContextAsync(item); + goodsItems.Add(context); + } + + return new ShopContext(rawData) + { + GoodsItems = goodsItems, + PropListContext = + BuildDisplayListAreaContext(DisplayListAreaType.Prop, rawData.PropItems, rawData.PropMaxCount), + WeaponListContext = BuildDisplayListAreaContext(DisplayListAreaType.Weapon, rawData.WeaponItems, + rawData.WeaponMaxCount), + NeedRefreshGoodsItems = true, + NeedRefreshPlayerCoin = true, + NeedRefreshPropList = true, + NeedRefreshWeaponList = true, + NeedRefreshPrice = true + }; + } + + private static DisplayListAreaContext BuildDisplayListAreaContext(DisplayListAreaType listType, + IReadOnlyList items, int maxCount) + { + string title = GetDisplayListTitle(listType); + if (items == null) + { + return new DisplayListAreaContext + { + Title = title, + CurrentCount = 0, + MaxCount = maxCount, + ItemContexts = System.Array.Empty() + }; + } + + DisplayItemContext[] itemContexts = new DisplayItemContext[items.Count]; + switch (listType) + { + case DisplayListAreaType.Weapon: + if (items is IReadOnlyList weapons) + { + for (int i = 0; i < weapons.Count; i++) + { + WeaponBase weapon = weapons[i]; + if (weapon == null) break; + itemContexts[i] = BuildWeaponItem(weapon); + } + } + + break; + + case DisplayListAreaType.Prop: + if (items is IReadOnlyList propItems) + { + for (int i = 0; i < propItems.Count; i++) + { + PropItem propItem = propItems[i]; + if (propItem == null) break; + itemContexts[i] = BuildPropItem(propItem); + } + } + + break; + } + + int currentCount = itemContexts.Length; + return new DisplayListAreaContext + { + Title = title, + CurrentCount = currentCount, + MaxCount = maxCount, + ItemContexts = itemContexts + }; + } + + private static string GetDisplayListTitle(DisplayListAreaType listType) + { + return listType switch + { + DisplayListAreaType.Weapon => "武器", + DisplayListAreaType.Prop => "道具", + _ => string.Empty + }; + } + + private static DisplayItemContext BuildPropItem(PropItem propItem) + { + string iconAssetName = null; + ItemRarity rarity = ItemRarity.None; + + if (propItem != null) + { + iconAssetName = propItem.IconAssetName; + rarity = propItem.Rarity; + } + + return new DisplayItemContext + { + IconAssetName = iconAssetName, + Rarity = rarity, + IsWeapon = false + }; + } + + private static DisplayItemContext BuildWeaponItem(WeaponBase weaponBase) + { + string iconAssetName = null; + ItemRarity rarity = ItemRarity.None; + + if (weaponBase != null && weaponBase.WeaponData != null) + { + iconAssetName = weaponBase.WeaponData.IconAssetName; + rarity = weaponBase.WeaponData.Rarity; + } + + return new DisplayItemContext + { + IconAssetName = iconAssetName, + Rarity = rarity, + IsWeapon = true + }; + } + + private static void AppendDisplayItemContext(DisplayListAreaContext listContext, DisplayItemContext newItem) + { + int oldCount = listContext.ItemContexts != null ? listContext.ItemContexts.Length : 0; + DisplayItemContext[] newContexts = new DisplayItemContext[oldCount + 1]; + if (oldCount > 0) + { + System.Array.Copy(listContext.ItemContexts, newContexts, oldCount); + } + + newContexts[oldCount] = newItem; + listContext.ItemContexts = newContexts; + listContext.CurrentCount = oldCount + 1; + } + + private async UniTask CreateGoodsItemContextAsync(GoodsItemRawData rawData, + float timeout = 30f) + { + var context = new GoodsItemContext(rawData); + context.Icon = context.ItemType switch + { + ItemType.Weapon => await GameEntry.SpriteCache.GetSprite(((DRWeapon)rawData.DataRow).IconAssetName), + ItemType.Prop => await GameEntry.SpriteCache.GetSprite(((DRProp)rawData.DataRow).IconAssetName), + _ => null + }; + return context; + } + } +} diff --git a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs.meta b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs.meta new file mode 100644 index 0000000..5735859 --- /dev/null +++ b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.BuildContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d035632a30d29a4db6714cdbb9a391f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.cs b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.cs index 3ec2fb5..064d25a 100644 --- a/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.cs +++ b/Assets/GameMain/Scripts/Presentation/Main/Shop/ShopController.cs @@ -1,16 +1,14 @@ -using System.Collections.Generic; using Cysharp.Threading.Tasks; using SepCore.Event; using SepCore.Definition; using SepCore.Entity.Weapon; using GameFramework.Event; -using SepCore.DataTable; using UnityEngine; using UnityGameFramework.Runtime; namespace SepCore.UI { - public class ShopController : UIControllerBase + public partial class ShopController : UIControllerBase { private ShopUseCase _useCase; private ShopRawData _rawData; @@ -47,188 +45,6 @@ namespace SepCore.UI GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormComplete); } - #region BuildContext - - private async UniTask BuildContext(ShopRawData rawData) - { - if (rawData == null) - { - Log.Error("ShopFormController.BuildContext() rawData is null."); - return null; - } - - _rawData = rawData; - - List goodsItems = new List(); - foreach (var item in rawData.GoodsItems) - { - var context = await CreateGoodsItemContextAsync(item); - goodsItems.Add(context); - } - - return new ShopContext - { - CurrentLevel = rawData.CurrentLevel, - RefreshPrice = rawData.RefreshPrice, - PlayerCoin = rawData.PlayerCoin, - GoodsItems = goodsItems, - PropListContext = - BuildDisplayListAreaContext(DisplayListAreaType.Prop, rawData.PropItems, rawData.PropMaxCount), - WeaponListContext = BuildDisplayListAreaContext(DisplayListAreaType.Weapon, rawData.WeaponItems, - rawData.WeaponMaxCount), - NeedRefreshGoodsItems = true, - NeedRefreshPlayerCoin = true, - NeedRefreshPropList = true, - NeedRefreshWeaponList = true, - NeedRefreshPrice = true - }; - } - - private static DisplayListAreaContext BuildDisplayListAreaContext(DisplayListAreaType listType, - IReadOnlyList items, - int maxCount) - { - string title = GetDisplayListTitle(listType); - if (items == null) - { - return new DisplayListAreaContext - { - Title = title, - CurrentCount = 0, - MaxCount = maxCount, - ItemContexts = System.Array.Empty() - }; - } - - DisplayItemContext[] itemContexts = new DisplayItemContext[items.Count]; - switch (listType) - { - case DisplayListAreaType.Weapon: - if (items is IReadOnlyList weapons) - { - for (int i = 0; i < weapons.Count; i++) - { - WeaponBase weapon = weapons[i]; - if (weapon == null) break; - itemContexts[i] = BuildWeaponItem(weapon); - } - } - - break; - - case DisplayListAreaType.Prop: - if (items is IReadOnlyList propItems) - { - for (int i = 0; i < propItems.Count; i++) - { - PropItem propItem = propItems[i]; - if (propItem == null) break; - itemContexts[i] = BuildPropItem(propItem); - } - } - - break; - } - - int currentCount = itemContexts.Length; - return new DisplayListAreaContext - { - Title = title, - CurrentCount = currentCount, - MaxCount = maxCount, - ItemContexts = itemContexts - }; - } - - private static string GetDisplayListTitle(DisplayListAreaType listType) - { - return listType switch - { - DisplayListAreaType.Weapon => "武器", - DisplayListAreaType.Prop => "道具", - _ => string.Empty - }; - } - - private static DisplayItemContext BuildPropItem(PropItem propItem) - { - string iconAssetName = null; - ItemRarity rarity = ItemRarity.None; - - if (propItem != null) - { - iconAssetName = propItem.IconAssetName; - rarity = propItem.Rarity; - } - - return new DisplayItemContext - { - IconAssetName = iconAssetName, - Rarity = rarity, - IsWeapon = false - }; - } - - private static DisplayItemContext BuildWeaponItem(WeaponBase weaponBase) - { - string iconAssetName = null; - ItemRarity rarity = ItemRarity.None; - - if (weaponBase != null && weaponBase.WeaponData != null) - { - iconAssetName = weaponBase.WeaponData.IconAssetName; - rarity = weaponBase.WeaponData.Rarity; - } - - return new DisplayItemContext - { - IconAssetName = iconAssetName, - Rarity = rarity, - IsWeapon = true - }; - } - - private static void AppendDisplayItemContext(DisplayListAreaContext listContext, DisplayItemContext newItem) - { - if (listContext == null) - { - Log.Error("ShopFormController.AppendDisplayItemContext() listContext is null."); - return; - } - - if (newItem == null) - { - Log.Warning("ShopFormController.AppendDisplayItemContext() newItem is null."); - return; - } - - int oldCount = listContext.ItemContexts != null ? listContext.ItemContexts.Length : 0; - DisplayItemContext[] newContexts = new DisplayItemContext[oldCount + 1]; - if (oldCount > 0) - { - System.Array.Copy(listContext.ItemContexts, newContexts, oldCount); - } - - newContexts[oldCount] = newItem; - listContext.ItemContexts = newContexts; - listContext.CurrentCount = oldCount + 1; - } - - private async UniTask CreateGoodsItemContextAsync(GoodsItemRawData rawData, - float timeout = 30f) - { - var context = new GoodsItemContext(rawData); - context.Icon = context.ItemType switch - { - ItemType.Weapon => await GameEntry.SpriteCache.GetSprite(((DRWeapon)rawData.DataRow).IconAssetName), - ItemType.Prop => await GameEntry.SpriteCache.GetSprite(((DRProp)rawData.DataRow).IconAssetName), - _ => null - }; - return context; - } - - #endregion - #region UI Methods public override async UniTask CloseUIAsync(object userData = null, float timeout = 30f) @@ -250,6 +66,7 @@ namespace SepCore.UI if (userData is ShopRawData rawData) { await OpenUIAsync(rawData, timeout); + return; } if (userData != null) @@ -285,18 +102,6 @@ namespace SepCore.UI private async UniTask RefreshGoodsItems(ShopRefreshResult result) { - if (result == null) - { - Log.Error("ShopFormController.RefreshGoodsItems() result is null."); - return; - } - - if (Context == null) - { - Log.Error("ShopFormController.RefreshGoodsItems() Context is null."); - return; - } - for (int i = 0; i < result.GoodsItems.Count; i++) { if (i < Context.GoodsItems.Count) @@ -308,8 +113,8 @@ namespace SepCore.UI if (Context.GoodsItems.Count != result.GoodsItems.Count) { - Context.GoodsItems.RemoveRange(Context.GoodsItems.Count, - Context.GoodsItems.Count - Context.GoodsItems.Count); + int count = Context.GoodsItems.Count - result.GoodsItems.Count; + Context.GoodsItems.RemoveRange(result.GoodsItems.Count, count); } Context.RefreshPrice = result.RefreshPrice; @@ -321,18 +126,6 @@ namespace SepCore.UI private void ApplyGoodsPurchased(ShopPurchaseResult result) { - if (result == null) - { - Log.Error("ShopFormController.ApplyGoodsPurchased() result is null."); - return; - } - - if (Context == null) - { - Log.Error("ShopFormController.ApplyGoodsPurchased() Context is null."); - return; - } - if (Context.GoodsItems != null && result.GoodsIndex >= 0 && result.GoodsIndex < Context.GoodsItems.Count) { Context.GoodsItems[result.GoodsIndex] = null; @@ -481,17 +274,17 @@ namespace SepCore.UI } private void ShopPurchase(object sender, GameEventArgs e) - { - OnPurchaseRequestAsync(sender, e).Forget(); - } - - private async UniTaskVoid OnPurchaseRequestAsync(object sender, GameEventArgs e) { if (e is not ShopPurchaseEventArgs args) { return; } + OnPurchaseRequestAsync(args).Forget(); + } + + private async UniTaskVoid OnPurchaseRequestAsync(ShopPurchaseEventArgs args) + { ShopPurchaseResult result = await _useCase.TryPurchaseAsync(args.GoodsIndex); if (result == null) { @@ -582,7 +375,7 @@ namespace SepCore.UI _tooltipLocked = false; } } - + #endregion } } diff --git a/docs/UI-5层架构设计规范.md b/docs/UI-5层架构设计规范.md index 29ab3b2..61d5a51 100644 --- a/docs/UI-5层架构设计规范.md +++ b/docs/UI-5层架构设计规范.md @@ -308,6 +308,140 @@ SepCore.Presentation(Controller + Context + View) - UI 专用事件命名应体现模块归属,避免语义过宽 - 同一 UI 可以按需要使用“多个精细事件”或“单一事件 + 子类型/按钮编号”的方式建模;例如当前 Dialog 使用 `DialogEventArgs + ButtonId` +### 6.4 UseCase 与编排层(Procedure)的通信方式 + +**推荐方式:接口式引用(主要方式)** + +UseCase 与编排层(Procedure)的通信采用 **依赖倒置原则(DIP)**,通过接口进行解耦。 + +#### 核心模式 + +```text +Procedure (编排层) + ↓ 实现接口 +IProcedureXXX + ↓ 注入(构造函数传 this 作为接口) +UseCase + ↓ 通过接口回调 +Procedure.ConfirmXXX(...) +``` + +#### 实现规范 + +1. **定义接口契约** + - 命名:`IProcedureXXX`(`XXX` 对应用户或 UI 名称) + - 位置:`Runtime/ProcedureInterface/` 目录 + - 内容:只包含 UI 真正需要的方法,遵循接口隔离原则(ISP) + + ```csharp + // 示例:IProcedureMenu.cs + namespace SepCore.Procedure + { + public interface IProcedureMenu + { + void ConfirmSelectRole(int roleId); + } + } + ``` + +2. **Procedure 实现接口** + - Procedure 类实现对应接口 + - 在 `OnEnter` 中创建 UseCase 并注入自身(`this`) + - 接口方法中实现流程编排(状态变更、场景切换、数据传递等) + + ```csharp + // 示例:ProcedureMenu.cs + public class ProcedureMenu : ProcedureBase, IProcedureMenu + { + private bool _startGame = false; + private int _selectedRoleId = 0; + + public void ConfirmSelectRole(int roleId) + { + _selectedRoleId = roleId; + _startGame = true; + } + + protected override void OnEnter(ProcedureOwner procedureOwner) + { + base.OnEnter(procedureOwner); + var useCase = new SelectRoleUseCase(this); // 注入接口 + GameEntry.UIRouter.BindUIUseCase(UIFormType.SelectRoleForm, useCase); + } + } + ``` + +3. **UseCase 接收接口** + - UseCase 构造函数只接收接口类型,不直接依赖具体 Procedure + - 业务完成时通过接口回调编排层 + - UseCase 内部不保存 Procedure 的具体引用 + + ```csharp + // 示例:SelectRoleUseCase.cs + public class SelectRoleUseCase : IUIUseCase + { + private readonly IProcedureMenu _procedureMenu; // 依赖接口,而非具体类 + + public SelectRoleUseCase(IProcedureMenu procedureMenu) + { + _procedureMenu = procedureMenu; + } + + public bool ConfirmSelectedRole() + { + // ... 业务校验逻辑 ... + _procedureMenu.ConfirmSelectRole(SelectedRoleId); // 通过接口回调 + return true; + } + } + ``` + +#### 接口式引用的核心优势 + +| 优势 | 说明 | +|------|------| +| **依赖倒置** | UseCase 依赖抽象接口,而非具体实现,符合 DIP 原则 | +| **接口隔离** | 接口只暴露 UI 需要的能力,Procedure 内部能力不泄露 | +| **可测试性** | 单元测试时可用 Mock 实现接口,无需启动整个 Procedure | +| **复用性** | 不同 Procedure 可实现同一接口,复用同一个 UseCase | +| **边界清晰** | 明确定义 UI 对编排层的操作权限,避免 UseCase 越权 | +| **可扩展性** | 新增回调方法时直接在接口添加,无需修改构造函数签名 | + +#### 备选方式:委托回调(快速实现) + +对于简单场景或快速原型,可使用 `Action` / `Func` 委托作为轻量替代: + +```csharp +// 委托方式示例(适用于单一回调场景) +public class LevelUpUseCase : IUIUseCase +{ + private readonly Action _onCompleted; + + public LevelUpUseCase(Player player, Action onCompleted) + { + _onCompleted = onCompleted; + } +} +``` + +**适用场景**: +- 只有 1-2 个简单回调 +- 临时功能或快速原型 +- 回调逻辑不需要复用 + +**注意**:当回调超过 2 个或语义复杂时,应升级为接口方式。 + +#### 决策矩阵 + +| 场景 | 推荐方式 | +|------|----------| +| 复杂流程,多个回调点 | 接口式引用 ✅ | +| 需要单元测试 UseCase | 接口式引用 ✅ | +| 多个 Procedure 复用同一 UI | 接口式引用 ✅ | +| 单一简单回调(如关闭通知) | 委托回调 | +| 临时功能或快速原型 | 委托回调 | +| 回调参数复杂(多参数、泛型) | 接口式引用 ✅ | + ## 7. 标准交互流程 ### 7.1 有 UseCase 的标准流程