39 KiB
Design: Port ROK Hero & Bag System
Context
工程 Survivors 基于 OctoberStudio Unity 模板(单机肉鸽),现在要引入 ROK 那种重养成 SLG 玩法(英雄+背包+装备)。已有的技术栈:
- 客户端:ET 框架(Entity/Component/System)+ YIUI UI 框架 + HybridCLR 热更 + YooAsset 资源 + ExcelExporter 配置
- 服务端:ET Server(dotnet)+ 已有 Login Demo 全链路 + MongoDB Driver(未启用业务)
- 协议:protobuf3 + MemoryPack 序列化
- 已就绪:cn.etetet.login 登录链路、cn.etetet.proto 代码生成、cn.etetet.yiuiloopscrollrectasync 循环列表
约束:
- ROK 源码用了 PureMVC + sproto + ILRuntime/IFix,完全不能直接复制
- 协议层必须用 ET protobuf 重新定义
- UI 必须用 YIUI 重写
- 服务端必须用 ET Entity/Component/System 重写
- ROK Server 是 C++/Lua(9 个微服务),与 ET 协议不兼容,放弃对接
- OctoberStudio 现有
SaveManager本地存档系统与 ET 服务端并存
驱动者:单机肉鸽 + SLG 卡牌养成的混合体验需要"局外永久成长"沉淀玩家,背包/英雄是核心载体。
Goals / Non-Goals
Goals:
- 借鉴 ROK 业务设计(数据模型/Config 字段/UI 流程/服务端 RPC 列表),用 ET + YIUI 重新实现一套完整的英雄+背包+装备系统
- 客户端-服务端真权威架构(防作弊),所有养成操作服务端校验,DB 持久化
- 与现有 OctoberStudio 流程无缝衔接(启动顺序、场景切换、UI 风格)
- 分阶段可交付(先英雄基础养成 → 装备 → 天赋),每个阶段独立可用
- 协议、配置、UI 三套自动化工具链完整跑通
Non-Goals:
- 不对接 ROK Server(C++/Lua),ET Server 是新写的真权威
- 不搬运 ROK 的美术资源(图集/Spine/Sound),只搬数据结构和流程
- 不做 ROK 的"雕像兑换"/"天赋页改名"/"建造锻造"等次要功能(一期不做)
- 不修改 OctoberStudio 局内战斗代码(先把养成搭好,后续接入"出战英雄"逻辑)
- 不实现服务端集群(玩家数量小时单进程足够,未来再分布式)
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- 英雄业务(含装备穿脱)+ DTOBagOuter_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 管理。
核心约定:
// 全游戏通用,凡是"奖励"列表都用它
message RewardEntry {
int32 itemId = 1;
int64 count = 2;
}
// 与 RewardEntry 结构相同,只是语义不同(消耗)
message CostEntry {
int32 itemId = 1;
int64 count = 2;
}
// 所有发奖/扣资源 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 里的一条 ItemInfo(itemId=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(新业务表全部用 Luban;OctoberStudio 老配置保持不变)
为什么改变方向:
- 本次新增的 14+ 张表大量使用
List<RewardEntry>、Map<int, List<WeightItem>>、bean 继承、外键引用,ExcelExporter 表达力不足 - 抽卡权重表、技能效果表、天赋树前置依赖在 Luban 里是一等公民,在 ExcelExporter 里要拆多列/塞 JSON 字符串
- Luban 编译期校验外键,消灭运行时 NullReference
- 后续商店/邮件/活动/多语言配置膨胀时,Luban 优势越来越大
不写自定义模板,用原生 API + 一层 Component 包装:
// 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.csDTO
- 服务端:
- 现成工具:姐妹工程
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 (UICommon,u_Data* + UIDataBind*)
└── BottomBar
面板生命周期统一入口(不要自建 PanelHelper):
// 打开
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) |
英雄列表 + 英雄详情同时打开时,一处变多处响应 |
示例:
// 收到服务端推送
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 阶段第一个落地。
核心算法(服务端权威):
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 后置:
- 业务逻辑稳定性优先:先把服务端真权威 + 协议 + DB 持久化跑稳,再做 UI 不会反复返工
- UI 不阻塞后端开发:UI 是消费者,业务接口确定了 UI 才好做,先做 UI 反而互相阻塞
- 日志 + GM 完全可替代 UI 验证:所有业务正确性都能用 GM 命令触发 + 日志检查,效率比手动点 UI 快 5-10 倍
- 集中做 UI 质量更好:分散在每个阶段做 UI 容易抄近路、视觉风格不统一;集中做能整体规划
- 避免 UI 改动反复影响后端:业务字段变了 UI 要改、UI 设计变了协议要改,等业务定型再做 UI 把这类返工降到 0
风险与缓解:
- [业务定义错了到 P4 才发现] → 用 GM 命令 + 日志强校验,每个 RPC 都有正反例测试;具体在
gm-tools与observability-loggingcapability 中定义 - [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):
{"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"}
关键约定:
- 每个 RPC Handler 入口必埋点:playerId + actionName + params 摘要
- 关键操作(消耗/发奖/抽卡/升星)必写 L2 审计:单独 jsonl 不混在普通日志里,便于运营/客服/数据分析读取
- 每个 ErrorCode 返回必写 L3 错误日志:含 playerId / errorCode / 调用栈/ 上下文
- 客户端日志桥接:客户端的
Log.Error关键错误可选上报到服务端(默认开启,可配置关闭) - reqId 串联:客户端发送 RPC 带 reqId,服务端日志全程透传,便于跨服务追查
- 日志分模块:
[Hero] [Bag] [Gacha] [Equip] [DB] [Login]前缀,便于grep -F '[Gacha]' - 慢查询告警: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 双轨(基于现成包):
- 服务端 GM Console(
cn.etetet.console现成)- 注册方式:
[ConsoleHandler("AddItem")] public class C_AddItem : IConsoleHandler { public async ETTask Run(Fiber fiber, ModeContex contex, string content) { ... } } - 直接登录服务端 shell 执行
- 适合数据校验、批量操作、压测
- 注册方式:
- 客户端 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 层):
{"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。
强制原则(每次新功能落地前必须自检):
- 第一步查表:开
Doc/ET-Packages-Audit.md速查表,确认是否有现成 ET 包能用 - 能扩展不重写:若现有包能力不全,优先在现有包加扩展方法或新 Component,禁止重写
- 业务功能建新包:完全新的业务(hero/bag/gacha)建独立
cn.etetet.xxx包,禁止塞进core/login等基础包 - 设计文档必须标注复用:每个 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 之前必须先完成的"基础设施"
- 执行
ET/Loader/Compile (F6)生成客户端热更 DLL dotnet build ET.sln -c Debug生成Bin/ET.App.dll- 跑通 Login Demo(Init.unity → 输入账号 → 登录成功)
- 新建
cn.etetet.db包,封装IDBComponent.Save/Query/Delete(基于 MongoHelper + MongoDB.Driver);支持 Memory/MongoDB 双模式 - 新建
cn.etetet.logging包,加Log.Audit/Log.Slow/Log.Module扩展方法 + NLog 三 target 配置 - 接入 ROK 配置迁移工具链(参考
gmd/Tools/export_hero_json_from_configs_data.py) - 跑通"登录 → 拉空 HeroInfoList → 服务端日志验证(暂无 UI)"链路
部署顺序
- 服务端先升级(DB schema 兼容)
- 客户端再发版(向后兼容旧服务端)
Rollback 策略
- 客户端:YooAsset 资源回滚到上一版本 manifest
- 服务端:dotnet build 之前打 tag,回滚到上版本 commit + 重启
- 数据:DB schema 通过 dataVersion 向前兼容,理论上无需回滚 DB
Open Questions
-
Q: 玩家创建账号流程在哪做? Login Demo 没账号注册,是允许任意账号自动注册,还是要单独 RegisterPanel?
倾向:P0 允许任意账号自动注册(首次登录创建 Player),生产再加严格注册 -
Q: OctoberStudio 现有金币 (CurrencySave) 和 ET 服务端金币如何统一?
倾向:保留两份。OctoberStudio 金币用于局内(关卡内不需要联网),服务端金币用于英雄养成消耗。两者通过"局外结算时同步" -
Q: 离线作战是否支持?
倾向:P0-P3 阶段都要求联网(联网才能拉数据/校验操作)。后续可加"离线模式"用本地缓存作为兜底 -
Q: 多语言怎么做?
倾向:P0-P3 阶段只支持中文。预留 L10N 接口,需要时再补 -
Q: HeroInfo 是否需要包含战斗属性快照?
倾向:HeroInfo 不存战斗属性(攻防血),客户端根据 level + star + skills + talents + equips 实时算。这样字段少、推送数据小 -
Q: 是否需要"友好名"分页(如把所有 6 大类合并成 4 个 Tab)?
倾向:UI 分页用枚举 BagItemType 直接 6 个 Tab,简单清晰 -
Q: 抽卡保底配置是否做"软保底"(概率随抽数提升)?
倾向:P1 阶段只做"硬保底"(90 抽必出),软保底(如 70 抽后概率从 0.6% 渐升到 1.5%)放到 P3 优化 -
Q: 抽卡历史是否需要持久化到 DB(供玩家查询)?
倾向:保留最近 100 次抽卡记录到 GachaComponent,超过的自动清理。完整历史只在服务端日志 -
Q: 一次性发奖列表(如十连)传 10 个 Item_ItemInfo 还是合并?
倾向:服务端在十连 R2C_GachaDraw 响应里把奖励聚合后一次发repeated RewardEntry rewards,不再单独推 Item_ItemInfo;客户端从 rewards 列表自己更新本地 BagComponent -
Q: 金币/钻石变化是否要走 BagComponent?还是单独的 ResourceComponent?
倾向:严格按 D4.1 通用 Item 原则,金币/钻石作为 itemId=1/2 存在 BagComponent 里。否则就破坏了"全游戏 Item 统一"的核心约定