741 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: Port ROK Hero & Bag System
## Context
工程 `Survivors` 基于 OctoberStudio Unity 模板(单机肉鸽),现在要引入 ROK 那种重养成 SLG 玩法(英雄+背包+装备)。已有的技术栈:
- **客户端**ET 框架Entity/Component/System+ YIUI UI 框架 + HybridCLR 热更 + YooAsset 资源 + ExcelExporter 配置
- **服务端**ET Serverdotnet+ 已有 Login Demo 全链路 + MongoDB Driver未启用业务
- **协议**protobuf3 + MemoryPack 序列化
- **已就绪**cn.etetet.login 登录链路、cn.etetet.proto 代码生成、cn.etetet.yiuiloopscrollrectasync 循环列表
**约束**
1. ROK 源码用了 PureMVC + sproto + ILRuntime/IFix**完全不能直接复制**
2. 协议层必须用 ET protobuf 重新定义
3. UI 必须用 YIUI 重写
4. 服务端必须用 ET Entity/Component/System 重写
5. ROK Server 是 C++/Lua9 个微服务),与 ET 协议不兼容,**放弃对接**
6. OctoberStudio 现有 `SaveManager` 本地存档系统与 ET 服务端并存
**驱动者**:单机肉鸽 + SLG 卡牌养成的混合体验需要"局外永久成长"沉淀玩家,背包/英雄是核心载体。
## Goals / Non-Goals
**Goals:**
1. 借鉴 ROK 业务设计(数据模型/Config 字段/UI 流程/服务端 RPC 列表),用 ET + YIUI 重新实现一套完整的英雄+背包+装备系统
2. 客户端-服务端真权威架构防作弊所有养成操作服务端校验DB 持久化
3. 与现有 OctoberStudio 流程无缝衔接启动顺序、场景切换、UI 风格)
4. 分阶段可交付(先英雄基础养成 → 装备 → 天赋),每个阶段独立可用
5. 协议、配置、UI 三套自动化工具链完整跑通
**Non-Goals:**
1. **不**对接 ROK ServerC++/LuaET Server 是新写的真权威
2. **不**搬运 ROK 的美术资源(图集/Spine/Sound只搬数据结构和流程
3. **不**做 ROK 的"雕像兑换"/"天赋页改名"/"建造锻造"等次要功能(一期不做)
4. **不**修改 OctoberStudio 局内战斗代码(先把养成搭好,后续接入"出战英雄"逻辑)
5. **不**实现服务端集群(玩家数量小时单进程足够,未来再分布式)
## Decisions
### D1: 服务端 vs 客户端权威架构
**选择****服务端真权威 + 客户端缓存 + 推送同步**
**为什么**
- 防作弊是 SLG 卡牌养成的基本盘
- 客户端只展示+操作,所有数值/规则在服务端校验
- ROK 设计就是这套,移植成本低
**备选**
- 纯客户端单机(无服务器)→ 后续做联网/跨设备同步会非常痛苦
- 纯无状态服务器(每请求都查 DB→ DB 压力大、响应慢
**取舍**:客户端有少量延迟(操作 → 服务端 → 推送回执 → UI 刷新),但接受。
### D2: 业务包组织方式
**选择****新增 3 个 cn.etetet.* 业务包**
- `cn.etetet.hero` - 英雄系统(含装备穿戴关联)
- `cn.etetet.bag` - 背包+装备(含锻造合成)
- `cn.etetet.gacha` - 抽卡系统(依赖 hero + bag
每个包内部按 ET 标准目录结构:
```
cn.etetet.hero/
├── package.json
├── Scripts/
│ ├── Model/Share/ # HeroInfo / SkillInfo 等 DTO
│ ├── Model/Client/ # 客户端缓存 Component
│ ├── Model/Server/ # 服务端权威 Component
│ ├── Hotfix/Client/ # 客户端 Handler + System
│ └── Hotfix/Server/ # 服务端 Handler + System
├── Proto/HeroOuter_C_3000.proto
└── ModelView/Client/YIUIComponent + YIUIGen # YIUI UI 代码(由工具生成)
```
**为什么不放在 Assets/**
- ET 包结构方便复用/导出/版本控制
- YooAsset 收集规则已默认 `Packages/cn.etetet.*` 路径
- Asmdef 隔离编译速度更快
**备选**
- 全部塞进现有 `cn.etetet.statesync` 包 → 包变巨大,不清晰
- 放在 `Assets/Scripts/` → 编译慢、与 ET 框架代码分离
### D3: Hero 实体在 ET 中的建模
**选择**:每个英雄一个 `Hero` Entity挂在 `Player.GetComponent<HeroComponent>()` 下作为子 Entity。
```
Player (Entity, _id = playerId)
├── HeroComponent
│ ├── Hero (Entity, _id = heroEntityId, ChildOf = HeroComponent)
│ │ ├── HeroSkillComponent # 技能等级数组
│ │ ├── HeroTalentComponent # 3 套天赋页
│ │ └── HeroEquipComponent # 8 个装备槽
│ └── Hero (Entity, _id = ...)
└── BagComponent
└── ItemUnit (Entity, _id = itemIndex)
```
**为什么用 Entity 而不是 POCO 类**
- 自动支持 BSON 序列化(写 DB 容易)
- 子 Entity 自动有 EntityId用作 itemIndex
- ET System 自动绑定Awake/Destroy/Update 等生命周期)
- 跟 ET 框架风格统一
**备选**
- POCO `Dictionary<long, HeroData>` → 简单,但失去 ET ECS 优势、序列化麻烦
- 一个 Hero 拆多个 Entity按子系统 → 过度设计,性能没必要
### D4: 协议 ID 段与 proto 文件划分
**选择**:占用 3000-3999 段,分三个文件:
- `HeroOuter_C_3000.proto` - 英雄业务(含装备穿脱)+ DTO
- `BagOuter_C_3100.proto` - 背包业务(含锻造合成)
- `GachaOuter_C_3200.proto` - 抽卡业务
| ID 范围 | 用途 |
|---|---|
| 3000-3099 | Hero RPC + 推送 + DTO |
| 3100-3199 | Bag/Equipment RPC + 推送 + DTO |
| 3200-3299 | Gacha RPC + 推送 + DTO |
**为什么不放一起**
- 文件太大不好维护
- 业务模块独立,便于将来拆解
**ET 框架要求**:每个 proto 文件起始 ID 用 `OpcodeRangeStart` 控制,工具自动按顺序分配。
### D4.1: 通用 Item 设计原则
**选择****所有游戏内可获得的资源统一为 Item**——金币、钻石、英雄碎片、装备、材料、经验书、升星石、觉醒符文、礼包、邮件附件,全部用同一个 `ItemConfig` + 一个 `itemId` 管理。
**核心约定**
```protobuf
// 全游戏通用,凡是"奖励"列表都用它
message RewardEntry {
int32 itemId = 1;
int64 count = 2;
}
// 与 RewardEntry 结构相同,只是语义不同(消耗)
message CostEntry {
int32 itemId = 1;
int64 count = 2;
}
```
```csharp
// 所有发奖/扣资源 MUST 走这两个统一接口
public static class BagComponentSystem
{
public static void AddItems(this BagComponent self, List<RewardEntry> rewards);
public static bool RemoveItems(this BagComponent self, List<CostEntry> costs); // 不足则全失败
public static long GetItemCount(this BagComponent self, int itemId);
}
```
**为什么**
- **数据建模简化**:玩家身上不存"金币字段、钻石字段、英雄碎片字段……",只存一个 `BagComponent`,金币也是 BagComponent 里的一条 ItemInfoitemId=1
- **业务接口收敛**:抽卡发奖、升级扣钱、商店买卖、邮件附件、关卡奖励——全部用 `AddItems`/`RemoveItems`,业务代码大量减少
- **新增资源零成本**:新增"幸运碎片"只需要在 ItemConfig.xlsx 加一行,**不需要写任何代码**
- **UI 复用最大化**`BagItemUICommon` 通过 `(itemId, count)` 渲染任何道具,所有面板都用它
**itemId 段位规划**
| itemId 段 | 用途 |
|---|---|
| 1-99 | 货币1=金币、2=钻石、3=PvP 币、…) |
| 100-999 | 通用道具(经验书、升星石、礼包) |
| 1000-9999 | 英雄碎片itemId = heroBaseId + 1000 |
| 10000-19999 | 装备物品itemId = equipBaseId |
| 20000-29999 | 装备材料/图纸 |
| 30000+ | 时装、皮肤、其他 |
**反例(要避免)**
- ❌ Player Entity 上加 `public long Gold; public long Diamond;` —— 违反通用原则
- ❌ 抽卡系统自己直接操作 BagComponent 的内部字典 —— 应该调 AddItems
- ❌ 不同业务自己实现"扣钱"逻辑 —— 必须走 RemoveItems
**取舍**:金币/钻石这种高频访问的字段查询会比独立字段稍慢(多一次字典查找),但可接受。如真有性能问题,可在 BagComponent 上加 `Dictionary<int, long> _quickCache` 缓存常用 itemId 的数量。
### D5: 配置表方案 - Luban替代 ExcelExporter
**选择****引入 Luban**(新业务表全部用 LubanOctoberStudio 老配置保持不变)
**为什么改变方向**
- 本次新增的 14+ 张表大量使用 `List<RewardEntry>``Map<int, List<WeightItem>>`、bean 继承、外键引用,**ExcelExporter 表达力不足**
- 抽卡权重表、技能效果表、天赋树前置依赖在 Luban 里是一等公民,在 ExcelExporter 里要拆多列/塞 JSON 字符串
- Luban 编译期校验外键,**消灭运行时 NullReference**
- 后续商店/邮件/活动/多语言配置膨胀时Luban 优势越来越大
**不写自定义模板,用原生 API + 一层 Component 包装**
```csharp
// Luban 原生生成(零自定义模板)
public partial class Tables
{
public TbHero TbHero { get; }
public TbItem TbItem { get; }
public TbGachaPool TbGachaPool { get; }
public TbGachaWeight TbGachaWeight { get; }
// ... 自动生成
}
// ET 集成(挂在 Scene 上)
[ComponentOf(typeof(Scene))]
public class ConfigComponent : Entity, IAwake
{
public cfg.Tables Tables;
}
public static class ConfigComponentSystem
{
[EntitySystem]
private static void Awake(this ConfigComponent self)
{
self.Tables = new cfg.Tables(name =>
JsonConfigLoader.Load($"Config/{name}.json"));
}
}
// 业务访问
public static class SceneExtensions
{
public static cfg.Tables Cfg(this Scene self)
=> self.GetComponent<ConfigComponent>().Tables;
}
// 使用
var hero = scene.Cfg().TbHero.Get(10001);
var pool = scene.Cfg().TbGachaPool.Get(poolId);
```
**为什么不写自定义模板**
- 模板维护成本高(升级 Luban 要同步改)
- 原生 API 已足够好用,多一个 `.Cfg().` 调用可接受
- 符合 ET ECS 哲学(配置作为 Scene 资源 Component
**Luban 集成方式**
- 用 dotnet luban 作为命令行工具,通过 `tools/luban/gen.ps1` 一键导出
- 输入:`Config/Datas/*.xlsx` + `Config/Defines/*.xml`
- 输出:客户端 `Bundles/Config/*.json` + `cn.etetet.config/Scripts/Model/Share/cfg/*.cs`,服务端共享同一份代码
- 客户端运行时通过 YooAsset 加载 json服务端从 `Bin/Config/` 读取
**老配置不动**OctoberStudio 现有零散表 + ET ExcelExporter 现有几张测试表保留,不强制迁移。**新业务Hero/Bag/Equipment/Gacha全部走 Luban**。
**ROK 配置数据迁移(不需要手输)**
- **数据源真相**ROK 工程**没有 Excel 源文件**。配置只有两份:
- 服务端:`E:/Game/gmd/ROK/Server/common/config/gen/Configs.data`45 万行 Lua 表,`tabtoy` 生成)
- 客户端:`E:/Game/gmd/ROK/Client/Assets/Resources/.../*.db`SqlCipher4Unity3D 加密 SQLite+ 205 个 `*Define.cs` DTO
- **现成工具**:姐妹工程 `E:/Game/gmd/Tools/export_hero_json_from_configs_data.py` 已能解析 `Configs.data` 输出 JSON已验证 Hero/HeroLevel/HeroStar 等表)
- **迁移路径**
```
Configs.data (Lua) ROK Client *Define.cs
│ │
│ 扩展 export_*.py │ 扫描字段名 + 类型
│ ┌─────────┐ │ ┌─────────┐
▼ │ Python │ ▼ │ Roslyn │
raw_*.json ◄────────────────────── *.bean.xml (Luban Schema)
│ Python (raw_*.json → Luban *.xlsx 数据填充)
Config/Datas/*.xlsx + Config/Defines/*.xml
│ dotnet luban
Bundles/Config/*.json + cfg/*.cs
```
- **优势****配置内容不丢失** + **Schema 派生自 ROK Client DTO** + **零手工录入**
**风险与缓解**
- [Luban 学习曲线] → P0 阶段做 1 张示例表ItemConfig跑通完整链路文档化
- [Luban 与 HybridCLR 兼容] → Luban 生成的代码都是 partial class + 基础类型,无 AOT 元数据问题
- [json 文件 YooAsset 收集] → 在 `AssetBundleCollectorSetting``Config/` 路径规则
**备选**
- 继续用 ExcelExporter → 复杂表难写,长期成本高
- 代码硬编码 → 数值改一次重编一次,不可接受
### D6: 服务端持久化策略 - **基于现成 MongoHelper 的业务封装**
**前置事实**
- `cn.etetet.core/Scripts/Core/Share/Serialize/MongoHelper.cs` 已提供 BSON 序列化ToJson/FromJson/Clone/CloneBytes/Register
- `com.etetet.init/Plugins/MongoDB/*` 已自带 MongoDB.Driver 原生 DLL
- **但是没有**`IDBComponent.Save<T>/Query<T>/Delete<T>` 业务封装、连接池、慢查询、重试
**结论****新建业务包 `cn.etetet.db` 包装这层**,不重写 BSON 也不引入新 ORM。
**选择****MongoDB + 脏标记 + 30 秒定时刷盘 + 离线立即刷盘**
**为什么 MongoDB**
- ET 框架原生集成(已有 MongoDB.Driver DLL + MongoHelper
- 文档型契合 Entity 结构Hero、Bag 嵌套天然存为子文档)
- 索引、查询、复制集都有现成方案
**为什么脏标记 + 定时**
- 每操作都直写 DB → 性能差、网络抖动会阻塞业务
- 全在内存 → 服务器崩了数据全丢
- 折中:操作改内存 + 标记 + 定时批量写
**风险与缓解**
- [服务器突然崩溃 → 30 秒未刷盘数据丢失] → 关键操作(充值、付费)走"实时刷盘"通道
- [DB 写入失败 → 标记保留待重试] → 监控告警 + 自动重试 3 次
- [玩家强退 → 离线刷盘耗时] → 异步等待,最大 5 秒超时
**开发期支持**:内存模式 `MemoryDBComponent`StartConfig.DBConnection = "memory",无需起 mongod
### D7: UI 架构 - **深度复用 YIUI 全套子包**UI 几乎零造轮)
**前置事实**:本工程已集成 9 个 YIUI 子包framework/yiui/yiuiinvoke/yiuitips/yiuireddot/yiuiloopscrollrectasync/yiuiyooassets/yiuieffect/yiui3ddisplay覆盖面板管理 / 数据绑定 / 事件绑定 / 弹窗 / 红点 / 长列表 / 资源加载 / UI 特效 / 3D 模型展示。详见 `Doc/ET-Packages-Audit.md` "YIUI 框架" 一节。
**选择**:使用 YIUI 框架原生 MVC 模式 + 全套子包能力,**禁止自建 PanelHelper / 长列表 / Tooltip 容器 / 红点树 / 3D 展示等已有能力**
```
HeroListPanel (Panel)
├── HeroListPanelComponent.cs # ET Entity状态层YIUI 工具生成)
├── HeroListPanelComponentSystem.cs # ET System业务层
└── HeroListPanel.prefab # YIUI UIBindCDETable 配置
├── CommonHeader (已有 UICommon复用)
├── YIUILoopScrollVertical (yiuiloopscrollrectasync)
│ └── HeroListItem (UICommonu_Data* + UIDataBind*)
└── BottomBar
```
**面板生命周期统一入口****不要自建 PanelHelper**
```csharp
// 打开
await scene.YIUIRoot().OpenPanelAsync<HeroListPanelComponent>();
// 带参打开(最多 5 个参数)
await scene.YIUIRoot().OpenPanelParamAsync<HeroDetailPanelComponent>(heroId);
// 模态等待结果
var result = await scene.YIUIRoot().OpenPanelWaitAsync<TipsMessageViewComponent>("确认抽卡?");
```
**YIUI 子包对业务的具体映射**
| ROK 业务点 | YIUI 子包/API | 备注 |
|---|---|---|
| 长列表(背包 1000+ / 英雄列表) | `cn.etetet.yiuiloopscrollrectasync``YIUILoopScrollChild.SetDataRefresh` | 数据变长度不变用 `ReRenderer()` 增量刷新 |
| 道具图标异步加载 | `cn.etetet.yiuiyooassets``UIDataBindImage` + `u_DataIcon.SetValue("ItemIcon_1001")` | YooAsset 自动加载,**不写 LoadHelper** |
| 抽卡按钮灰显 / 不可用 | `cn.etetet.yiuieffect``UIDataBindGray` + `u_DataCanDraw.SetValue(false)` | 不写 Material 置灰 |
| 抽卡结果闪光/粒子 | `cn.etetet.yiuieffect``UIEffect` + `UIParticle` | 不引第三方 VFX |
| 英雄详情 3D 模型 | `cn.etetet.yiui3ddisplay``YIUI3DDisplayChild.ShowAsync("HeroModel_10001")` | 框架管 RT/Camera/Pool |
| 抽卡 CD / 活动倒计时 | `cn.etetet.yiuiframework``CountDownMgr` | 不自建 Timer 管理器 |
| 抽卡 RPC 等待挡点击 | `cn.etetet.yiuiframework``scene.YIUIMgr().BanLayerOptionForever()` | 异步期间防双击 |
| 确认/取消弹窗 | `cn.etetet.yiuitips``TipsHelper.OpenWait<TipsMessageViewComponent>(scene, msg)` | 自带 `HashWait` 模态返回 |
| 操作成功飘字 | `cn.etetet.yiuitips``TipsHelper.OpenSync<TipsTextViewComponent>(scene, "招募成功")` | 自带淡入淡出动画 |
| 客户端错误提示(错误码) | `cn.etetet.yiuitips` + `ErrorMessageHelper.GetMessage(code)` | 走 `TipsTextView`,不写 Toast 系统 |
| 红点(英雄/背包/抽卡入口) | `cn.etetet.yiuireddot``RedDotMgr.Inst.SetCount(key, count)` + Prefab `RedDotBind` | **只写叶子节点**,父节点自动汇总 |
| 顶栏 + 关闭 | `CommonHeader` + `YIUICloseCommon`(已有) | 直接拖入新 Panel |
**UI 与服务端业务的解耦**:用 `cn.etetet.yiuiinvoke``[YIUIInvokeSystem("Key")]` 机制 + invoke 契约包D2 已定义 `cn.etetet.heroinvoke` 等),让 GM 或其他业务不直接 reference 业务包。
### D8: 客户端缓存与事件机制 - **UIData 局部驱动 + ET EventSystem 跨面板广播**
**两层职责拆分**(明确边界,避免误用):
| 层 | 用什么 | 时机 |
|---|---|---|
| **Panel/View 内部数据 → 自身 UI 刷新** | YIUI `UIDataBind*` + `u_DataXxx.SetValue(...)` | Panel 内 List Item 的图标、名字、数量等局部字段,**不广播** |
| **跨面板的业务变化广播** | ET `EventSystem.Instance.Publish(new XxxEvent)` | 英雄列表 + 英雄详情同时打开时,一处变多处响应 |
**示例**
```csharp
// 收到服务端推送
public class Hero_HeroInfoHandler : MessageHandler<Hero_HeroInfo>
{
protected override async ETTask Run(Session session, Hero_HeroInfo msg)
{
var heroComp = session.GetComponent<PlayerComponent>().Player.GetComponent<HeroComponent>();
heroComp.UpdateHero(msg.Hero);
// 跨面板广播(多个 Panel 订阅)
EventSystem.Instance.Publish(new HeroUpdatedEvent { HeroId = msg.Hero.HeroId });
}
}
// Panel 监听
[Event] public class HeroUpdatedEvent_RefreshList : AEvent<Scene, HeroUpdatedEvent>
{
protected override async ETTask Run(Scene scene, HeroUpdatedEvent args)
{
var panel = scene.YIUIMgr().GetPanel<HeroListPanelComponent>();
if (panel == null) return;
// Panel 内部通过 u_Data* 局部刷新,不再二次广播
await panel.RefreshHero(args.HeroId);
}
}
// Panel 内部刷新YIUI 数据驱动,不发事件)
public static async ETTask RefreshHero(this HeroListPanelComponent self, long heroId)
{
var hero = self.ClientScene().GetHero(heroId);
var item = self.GetItemByHeroId(heroId);
item.u_DataLevel.SetValue(hero.Level); // 局部刷新YIUI 自动绑定到 TMP_Text
item.u_DataPower.SetValue(hero.Power);
item.u_DataIcon.SetValue($"HeroIcon_{hero.HeroId}"); // 自动走 YooAsset 加载
}
```
**为什么不全用 EventSystem**
- Panel 内部 List Item 数百个字段,全发事件 → 事件爆炸 + 性能问题
- YIUI `UIDataBind` 是为局部数据刷新设计,零事件开销
**为什么不全用 UIDataBind**
- 跨面板(列表 + 详情)数据同步需要广播机制
- UIData 只在单个 Panel/Component 内有效
### D9: 与 OctoberStudio 集成
**选择****Init.unity 作为主入口**,登录后切到 OctoberStudio Main Menu
```
启动流程:
1. Init.unity (ET 启动) → Realm → Gate 登录
2. 登录成功,拉取 HeroInfoList + ItemInfoList
3. 切换到 OctoberStudio Main Menu 场景(保留原玩法入口)
4. 在 Main Menu 添加"英雄"/"背包"按钮 → 打开 YIUI Panel
```
**为什么不替换 Main Menu**
- OctoberStudio 已有完整局内战斗、宝箱、设置等流程
- 强行替换风险大
- "ET 是基础设施 + OctoberStudio 是单机玩法"双层架构
**OctoberStudio 存档**
- `CurrencySave``CharactersSave` 保留为局内/设置用途
- 英雄、装备、背包用 ET 服务端存档DB
- 二者通过适配层访问(如金币:先查 ET再降级到本地
### D9.1: 抽卡系统设计
**选择**:抽卡作为独立业务包 `cn.etetet.gacha`,但**强依赖** `bag-system`(消耗+奖励)+ `hero-system`(抽到英雄/碎片)。**抽卡是端到端验证最佳工具**,进入 P1 阶段第一个落地。
**核心算法(服务端权威)**
```csharp
public static class GachaComponentSystem
{
public static List<RewardEntry> Draw(this GachaComponent self, int poolId, GachaDrawType type)
{
var poolCfg = GachaPoolConfigCategory.Instance.Get(poolId);
var weights = GachaWeightConfigCategory.Instance.GetByPool(poolId);
var times = type == GachaDrawType.Single ? 1 : 10;
// 1. 校验消耗
var costItemId = type == GachaDrawType.Single ? poolCfg.SingleCostItem : poolCfg.MultiCostItem;
var costCount = type == GachaDrawType.Single ? poolCfg.SingleCostCount : poolCfg.MultiCostCount;
if (!bag.HasItem(costItemId, costCount)) return null; // 由调用方判错返回
// 2. 扣消耗
bag.RemoveItem(costItemId, costCount);
// 3. 随机抽取
var rewards = new List<RewardEntry>();
var totalWeight = weights.Sum(w => w.Weight);
for (int i = 0; i < times; i++)
{
int r = RandomGenerator.RandomNumber(0, totalWeight);
var pick = SelectByWeight(weights, r);
rewards.Add(new RewardEntry { ItemId = pick.ItemId, Count = pick.Count });
// 4. 更新保底
self.UpdateGuarantee(poolId, pick.Rare);
}
// 5. 十连保底兜底(必出指定品质)
if (type == GachaDrawType.Multi10 && !rewards.Any(r => GetItemRare(r.ItemId) >= poolCfg.MultiGuaranteeRare))
ReplaceLowestWithGuaranteed(rewards, weights, poolCfg.MultiGuaranteeRare);
// 6. 累计保底兜底90 抽必出)
if (self.GetGuaranteeRemain(poolId) == 0)
ReplaceWithGuaranteed(rewards, weights, poolCfg.GuaranteeRare);
// 7. 发放奖励
bag.AddItems(rewards);
// 8. 日志
GachaAuditLog.Log(self.Player.Id, poolId, rewards, self.GetPoolStats(poolId));
return rewards;
}
}
```
**保底设计**
- **每卡池独立保底**:玩家在不同卡池的保底计数互不影响
- **双重保底**:十连必出(如必有 1 个 4 星)+ 累计必出90 抽必有 5 星)
- **重置规则**:自然出 ≥ 保底品质 → 计数清零;保底强制出 → 也清零
**防作弊**
- 客户端**完全不参与**随机数生成
- 客户端 payload 中的任何"种子/运气/概率加成"字段都被忽略
- 服务端日志记录每次抽卡的完整信息,便于事后审计/玩家投诉处理
**为什么把抽卡放 P1 第一个**
- 它能**端到端验证**:消耗道具 → 服务端随机 → 发奖 → 入背包 → 用于英雄培养
- 完成抽卡,等于把 hero-system + bag-system + protocol + persistence 都跑通了
- 后续养成系统的"测试数据"也可以直接靠抽卡获得,省去 GM 命令
**风险**
- [伪随机种子可预测] → 用 `RandomNumberGenerator.GetInt32`CSPRNG或 Random + 高熵 seed
- [抽卡接口被刷] → 服务端加冷却限速 + 异常 IP/UID 自动报警
### D10: 分阶段交付计划 - **UI 后置,先服务端 + 日志 + GM 验证**
**选择****业务骨架先行、UI 集中后期补**。P1-P3 不做任何 YIUI 面板,只产出"服务端业务 + 协议 + GM 命令 + 日志"。所有 UI 集中在 P4 一次性补齐。
| 阶段 | 内容 | 验证方式 | 周期 |
|---|---|---|---|
| **P0** 基础设施 | DBComponent + 协议工具链 + Luban + **日志框架** + **GM 框架** + 跑通登录 | 单元测试 + Demo 命令 | 3-5 天 |
| **P1** 抽卡 + 通用 Item + 英雄基础 | **服务端**:通用 Item、抽卡算法、英雄实体、招募/升级/技能升级;**零 UI** | GM 命令 + 业务日志 + 单元测试 | 1-1.5 周 |
| **P2** 背包 + 装备穿戴 | **服务端**背包增减、6 大类、装备穿脱;**零 UI** | GM 命令 + 业务日志 | 1 周 |
| **P3** 高级养成 | **服务端**:升星、觉醒、天赋、锻造;**零 UI** | GM 命令 + 业务日志 | 1-2 周 |
| **P4** UI 集中实现 | YIUI 把 P1-P3 的所有面板一次性补齐 | 手动 + 自动化 UI 测试 | 2-3 周 |
**为什么改成 UI 后置**
1. **业务逻辑稳定性优先**:先把服务端真权威 + 协议 + DB 持久化跑稳,再做 UI 不会反复返工
2. **UI 不阻塞后端开发**UI 是消费者,业务接口确定了 UI 才好做,先做 UI 反而互相阻塞
3. **日志 + GM 完全可替代 UI 验证**:所有业务正确性都能用 GM 命令触发 + 日志检查,效率比手动点 UI 快 5-10 倍
4. **集中做 UI 质量更好**:分散在每个阶段做 UI 容易抄近路、视觉风格不统一;集中做能整体规划
5. **避免 UI 改动反复影响后端**:业务字段变了 UI 要改、UI 设计变了协议要改,等业务定型再做 UI 把这类返工降到 0
**风险与缓解**
- **[业务定义错了到 P4 才发现]** → 用 GM 命令 + 日志强校验,每个 RPC 都有正反例测试;具体在 `gm-tools``observability-logging` capability 中定义
- **[P4 UI 工作量大]** → P0 就把 YIUI 模板 + `CommonHeader`/`BagItemUICommon` 准备好P1-P3 也产出 UI 字段需求文档,让 P4 不需要返查
- **[策划/产品要看效果]** → 给策划提供 GM Web 控制台(基础版即可,复用 ET 现有 Watcher可视化展示玩家数据
**P1 第一个落地的还是抽卡**:抽卡仍然是端到端验证最佳工具,但**验证方式从 UI 改为 GM 命令 + jsonl 日志**。
**关键约束**P1-P3 阶段**绝对不写 YIUI Panel 代码**。任何"我需要个简单界面看看效果"的需求都通过 GM 命令 + 日志解决。
### D11: 日志策略 - **基于 ET 自带 Log 扩展**(不重写)
**前置事实**`cn.etetet.core` 已有 `Log` 静态类Debug/Info/Warning/Error/Trace/Console`cn.etetet.loader` 已集成 NLog 后端,可通过 `Fiber.Instance.Log` 接口替换实现。**我们要做的是扩展,不是新框架**。
**选择****三层日志体系(扩展 ET Log**
| 层级 | 用途 | 输出 | 保留 |
|---|---|---|---|
| **L1 业务日志** | 每个 RPC 入口/出口、参数、玩家ID、结果 | `Logs/Business/{yyyy-MM-dd}.log` | 30 天 |
| **L2 审计日志** | 抽卡、付费、GM 操作、关键资源变动 | `Logs/Audit/{module}/{yyyy-MM-dd}.jsonl` | 永久 |
| **L3 错误日志** | 异常、错误码返回、慢查询 | `Logs/Error/{yyyy-MM-dd}.log` + 实时告警 | 90 天 |
**结构化 JSON 行格式**jsonl便于 grep/jq/ELK
```json
{"ts":"2026-05-27T11:30:01.234","level":"INFO","module":"Gacha","playerId":1001,"action":"Draw","poolId":2,"drawType":"Multi10","costItem":2,"costCount":9,"rewards":[{"itemId":10001,"count":1,"rare":4},...],"guaranteeRemain":85,"reqId":"abc123"}
```
**关键约定**
1. **每个 RPC Handler 入口必埋点**playerId + actionName + params 摘要
2. **关键操作(消耗/发奖/抽卡/升星)必写 L2 审计**:单独 jsonl 不混在普通日志里,便于运营/客服/数据分析读取
3. **每个 ErrorCode 返回必写 L3 错误日志**:含 playerId / errorCode / 调用栈/ 上下文
4. **客户端日志桥接**:客户端的 `Log.Error` 关键错误可选上报到服务端(默认开启,可配置关闭)
5. **reqId 串联**:客户端发送 RPC 带 reqId服务端日志全程透传便于跨服务追查
6. **日志分模块**`[Hero] [Bag] [Gacha] [Equip] [DB] [Login]` 前缀,便于 `grep -F '[Gacha]'`
7. **慢查询告警**DB 查询 > 100ms、Handler 执行 > 500ms 自动写 L3 告警
**ET 框架集成(明确边界)**
- **保留**`Log.Debug/Info/Warning/Error` 全部入口不变,业务代码继续用
- **扩展**:新建 `cn.etetet.logging` 包,加扩展方法 `Log.Audit(module, action, payload)` / `Log.Slow(module, action, durationMs)` / `Log.Module(tag, level, msg)`
- **底层**:复用现有 NLog`Packages/cn.etetet.loader/Scripts/Loader/Server/NLog.config`),加 3 个 target 分别输出到 `Logs/Business/` `Logs/Audit/{module}/` `Logs/Error/`
- **Handler 自动埋点**:抽 `RpcMessageHandler<TReq,TResp>` 基类,在 OnRun 前后自动写 L1入口/出口 + 耗时)
- **慢操作**:包装 `DBComponent` / `MessageDispatcher` 的入口方法,超阈值自动 Log.Slow
- **reqId 透传**:在所有 C2G/C2R 协议基类加 `reqId` 字段Handler 基类把 reqId 放到 `AsyncLocal<string>`,供下游 Log 自动取
**风险与缓解**
- [日志写盘 IO 性能] → NLog 异步队列 + 批量刷盘,关键审计日志走同步落盘保证不丢
- [日志爆盘] → L1 按天滚动 + 30 天清理L3 90 天清理L2 转 OSS 永久归档
- [敏感字段泄露] → 玩家个人信息(手机号/邮箱)必须脱敏,工具自动扫描
### D12: GM 验证策略 - **直接复用 yiuigm + console 现成包**(不重写框架)
**前置事实**
- `cn.etetet.yiuigm` 已有 `[GM]` 属性 + `IGMCommand` 接口 + 完整 GMPanel UI含参数 Enum/String/Bool/Float/Int/Long 6 类)
- `cn.etetet.console` 已有 `[ConsoleHandler]` 属性 + `IConsoleHandler` 接口 + 服务端 stdin REPL启动时自动监听控制台输入
- 两套都已纳入热更编译
**结论****完全不需要新建 GM 框架**。我们要做的只是用这两个属性写业务 GM 类。
**选择****GM 命令是 P1-P3 阶段唯一的验证入口**(在 UI 出来之前),必须覆盖所有写操作 + 提供读取/快照/模拟工具
**GM 双轨(基于现成包)**
1. **服务端 GM Console**`cn.etetet.console` 现成)
- 注册方式:`[ConsoleHandler("AddItem")] public class C_AddItem : IConsoleHandler { public async ETTask Run(Fiber fiber, ModeContex contex, string content) { ... } }`
- 直接登录服务端 shell 执行
- 适合数据校验、批量操作、压测
2. **客户端 GM Panel**`cn.etetet.yiuigm` 现成)
- 注册方式:`[GM(EGMType.Test, 1, "发放道具", "给当前玩家发放指定道具")] public class GM_AddItem : IGMCommand { public List<GMParamInfo> GetParams() { ... } public async ETTask<string> Run(Scene scene, GMParamVo vo) { ... } }`
- 仅 Debug 包启用(`#if ENABLE_GM`
- 适合开发期手动测试
**GM 命令分类**
| 类别 | 命令示例 | 用途 |
|---|---|---|
| **资源发放** | `gm AddItem <itemId> <count>` / `AddCurrency <type> <count>` | 给自己/指定玩家发资源 |
| **英雄操作** | `AddHero <heroId>` / `SetHeroLevel <heroId> <lv>` / `SetHeroStar <heroId> <star>` | 创建/调整英雄 |
| **背包操作** | `ClearBag` / `ShowBag` / `SetItemCount <itemId> <count>` | 背包状态调整与查看 |
| **抽卡操作** | `Gacha <poolId> <times>` / `ResetGachaGuarantee <poolId>` / `SetGachaCount <poolId> <count>` | 抽卡 + 保底操作 |
| **数据查询** | `ShowHeroes` / `ShowBag` / `ShowGachaStats` / `DumpPlayer <playerId>` | 输出当前状态 jsonl |
| **数据快照** | `Snapshot <name>` / `Restore <name>` | 保存/恢复整个玩家状态 |
| **批量模拟** | `SimulateGacha <poolId> <times>` 服务端循环 N 次抽卡输出概率统计 | 概率审计 |
| **数据校验** | `VerifyConsistency <playerId>` 校验玩家数据完整性 | 发现脏数据 |
| **服务管理** | `ReloadConfig` / `FlushDirty` / `Kick <playerId>` | 运维操作 |
**GM 操作必写审计日志**D11 的 L2 层):
```json
{"ts":"...","level":"WARN","module":"GM","operator":"admin","action":"AddItem","targetPlayer":1001,"args":{"itemId":1,"count":1000000}}
```
**关键测试场景(用 GM 验证)**
| 场景 | GM 命令组合 | 期望日志 |
|---|---|---|
| 抽卡正确扣消耗 | `AddCurrency 2 10``Gacha 1 1``ShowBag` | Bag 钻石 -1奖励入包 |
| 抽卡保底触发 | `SetGachaCount 1 89``Gacha 1 1` 10 次 | 第一次抽到必出保底品质 |
| 抽卡概率正确 | `SimulateGacha 1 10000` | 输出概率分布,与配置误差 < 1% |
| 英雄升级正确 | `AddHero 10001` `AddItem <经验书ID> 100` 调升级 | Hero level 提升经验书减少 |
| 数据持久化 | `Snapshot before` 一系列操作 `FlushDirty` 重启 `DumpPlayer` 对比 | 数据完全一致 |
| 通用 Item 不漏 | `ShowBag` 在做了大量操作后金币/钻石/碎片都显示正确 | 没有"零钻石"等漏算 |
**风险与缓解**
- [GM 命令被滥用上线包带出] 编译条件 `#if !RELEASE` 包裹 GM 注册Release 包不含 GM 命令
- [GM 操作绕过业务校验导致数据不一致] GM 直接调 `BagComponentSystem.AddItems` 等业务接口不允许绕过
### D13: 强制复用原则 - **避免重复造轮子**
**背景**本次工程已有 38 ET 覆盖 UI / 协议 / 资源热更 / GM / 服务端 console / 网络 / 数学 / 序列化等大部分基础设施详见 `Doc/ET-Packages-Audit.md`
**强制原则**每次新功能落地前必须自检
1. **第一步查表** `Doc/ET-Packages-Audit.md` 速查表确认是否有现成 ET 包能用
2. **能扩展不重写**若现有包能力不全**优先在现有包加扩展方法或新 Component**禁止重写
3. **业务功能建新包**完全新的业务hero/bag/gacha建独立 `cn.etetet.xxx` **禁止塞进** `core` / `login` 等基础包
4. **设计文档必须标注复用**每个 D 决策必须明确"复用了哪些包 + 扩展点 + 新增点"
**本次明确的复用清单**
| 能力 | 状态 | 复用方式 |
|---|---|---|
| 日志输出 | 复用 `cn.etetet.core/Log.cs` | 新建 `cn.etetet.logging` `Log.Audit/Log.Slow/Log.Module` 扩展方法 |
| 客户端 GM 面板 | 完全复用 `cn.etetet.yiuigm` | 新增 `[GM]` 业务类即可 |
| 服务端 GM Console | 完全复用 `cn.etetet.console` | 新增 `[ConsoleHandler]` 业务类即可 |
| BSON 序列化 | 完全复用 `cn.etetet.core/MongoHelper` | 不重写 |
| MongoDB Driver | 完全复用 `com.etetet.init/Plugins/MongoDB` | 不重写 |
| 协议生成 | 完全复用 `cn.etetet.proto` + `ET/Proto/Proto2CS` 菜单 | 新增 `Proto/*.proto` 文件即可 |
| 资源热更 | 完全复用 `cn.etetet.yooassets` + `cn.etetet.hybridclr` | 不动 |
| YIUI 框架 | 完全复用 `cn.etetet.yiuiframework` 9 个子包 | 业务 Panel YIUI 自动化工具生成 |
| 登录链路 | 扩展 `cn.etetet.login` | Player 挂业务 Component不改 Account 字段 |
| ExcelExporter | **不用**保留供 StartConfig 等基础设施用 | 新业务表全部走 Luban |
**本次明确的新增清单**
| 新增包 | 职责 |
|---|---|
| `cn.etetet.db` | DBComponent 业务封装基于 MongoHelper |
| `cn.etetet.logging` | Audit/Slow/Module 扩展基于 ET Log |
| `cn.etetet.config` | Luban Tables + ConfigComponent |
| `cn.etetet.hero` | 英雄系统 |
| `cn.etetet.bag` | 背包/装备系统 |
| `cn.etetet.gacha` | 抽卡系统 |
## Risks / Trade-offs
- **[移植工作量大]** 4 阶段交付每阶段独立验收
- **[ROK 业务规则细节多]** specs.md 里列清楚 scenario写测试覆盖
- **[服务端单进程性能瓶颈]** 玩家少< 1000 同时在线时不会到瓶颈需要时再拆 Scene
- **[ET 框架学习曲线]** 团队需要熟悉 Entity/Component/System 模式 PureMVC 思路差距不小建议先吃透 Login Demo 再开干
- **[DB 字段变更迁移]** 在每个 Entity dataVersion 字段 + DBMigrationComponent 自动迁移
- **[ROK 协议是 sproto protobuf 字段细节可能丢失]** 不直接转换按业务需求重新设计 proto只参考字段语义不参考字段顺序
- **[OctoberStudio ET 双重启动复杂]** Loader 添加"开发模式快速跳过登录"开关
- **[HybridCLR AOT 元数据问题]** 业务代码全部放热更层 Hotfix泛型扫描通过 `ET/HybridCLR/Generate/All` 一键解决
- **[Luban 学习曲线]** P0 阶段做 ItemConfig 一张示例表跑通全链路 + 写文档
- **[UI 后置导致策划/老板看不到效果]** P0 起步就有 GM Web Console基于 ET Watcher日志 + 数据可视化即时反馈
- **[GM 漏覆盖某个业务点]** 每个新增 RPC Handler MUST 同时新增对应 GM 命令 spec 中强制约束
- **[Login Demo 没有密码校验]** P0 阶段补一个简单的账号密码校验hash 入库生产再升级
## Migration Plan
### P0 之前必须先完成的"基础设施"
1. 执行 `ET/Loader/Compile (F6)` 生成客户端热更 DLL
2. `dotnet build ET.sln -c Debug` 生成 `Bin/ET.App.dll`
3. 跑通 Login DemoInit.unity 输入账号 登录成功
4. **新建 `cn.etetet.db` 包**封装 `IDBComponent.Save/Query/Delete`基于 MongoHelper + MongoDB.Driver支持 Memory/MongoDB 双模式
5. **新建 `cn.etetet.logging` 包** `Log.Audit/Log.Slow/Log.Module` 扩展方法 + NLog target 配置
6. **接入 ROK 配置迁移工具链**参考 `gmd/Tools/export_hero_json_from_configs_data.py`
7. 跑通"登录 拉空 HeroInfoList 服务端日志验证暂无 UI"链路
### 部署顺序
1. 服务端先升级DB schema 兼容
2. 客户端再发版向后兼容旧服务端
### Rollback 策略
- 客户端YooAsset 资源回滚到上一版本 manifest
- 服务端dotnet build 之前打 tag回滚到上版本 commit + 重启
- 数据DB schema 通过 dataVersion 向前兼容理论上无需回滚 DB
## Open Questions
1. **Q: 玩家创建账号流程在哪做?** Login Demo 没账号注册是允许任意账号自动注册还是要单独 RegisterPanel
_倾向P0 允许任意账号自动注册(首次登录创建 Player生产再加严格注册_
2. **Q: OctoberStudio 现有金币 (CurrencySave) 和 ET 服务端金币如何统一?**
_倾向保留两份。OctoberStudio 金币用于局内(关卡内不需要联网),服务端金币用于英雄养成消耗。两者通过"局外结算时同步"_
3. **Q: 离线作战是否支持?**
_倾向P0-P3 阶段都要求联网(联网才能拉数据/校验操作)。后续可加"离线模式"用本地缓存作为兜底_
4. **Q: 多语言怎么做?**
_倾向P0-P3 阶段只支持中文。预留 L10N 接口需要时再补_
5. **Q: HeroInfo 是否需要包含战斗属性快照?**
_倾向HeroInfo 不存战斗属性(攻防血),客户端根据 level + star + skills + talents + equips 实时算。这样字段少、推送数据小_
6. **Q: 是否需要"友好名"分页(如把所有 6 大类合并成 4 个 Tab**
_倾向UI 分页用枚举 BagItemType 直接 6 个 Tab简单清晰_
7. **Q: 抽卡保底配置是否做"软保底"(概率随抽数提升)?**
_倾向P1 阶段只做"硬保底"90 抽必出),软保底(如 70 抽后概率从 0.6% 渐升到 1.5%)放到 P3 优化_
8. **Q: 抽卡历史是否需要持久化到 DB供玩家查询**
_倾向保留**最近 100 次**抽卡记录到 GachaComponent超过的自动清理。完整历史只在服务端日志_
9. **Q: 一次性发奖列表(如十连)传 10 个 Item_ItemInfo 还是合并?**
_倾向服务端在十连 R2C_GachaDraw 响应里把奖励聚合后一次发 `repeated RewardEntry rewards`**不再单独推 Item_ItemInfo**客户端从 rewards 列表自己更新本地 BagComponent_
10. **Q: 金币/钻石变化是否要走 BagComponent还是单独的 ResourceComponent**
_倾向**严格按 D4.1 通用 Item 原则**,金币/钻石作为 itemId=1/2 存在 BagComponent 里。否则就破坏了"全游戏 Item 统一"的核心约定_