39 KiB
Raw Permalink Blame History

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 管理。

核心约定

// 全游戏通用,凡是"奖励"列表都用它
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 里的一条 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 包装

// 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.data45 万行 Lua 表,tabtoy 生成)
    • 客户端:E:/Game/gmd/ROK/Client/Assets/Resources/.../*.dbSqlCipher4Unity3D 加密 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 收集] → 在 AssetBundleCollectorSettingConfig/ 路径规则

备选

  • 继续用 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 秒超时

开发期支持:内存模式 MemoryDBComponentStartConfig.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

// 打开
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.yiuiloopscrollrectasyncYIUILoopScrollChild.SetDataRefresh 数据变长度不变用 ReRenderer() 增量刷新
道具图标异步加载 cn.etetet.yiuiyooassetsUIDataBindImage + u_DataIcon.SetValue("ItemIcon_1001") YooAsset 自动加载,不写 LoadHelper
抽卡按钮灰显 / 不可用 cn.etetet.yiuieffectUIDataBindGray + u_DataCanDraw.SetValue(false) 不写 Material 置灰
抽卡结果闪光/粒子 cn.etetet.yiuieffectUIEffect + UIParticle 不引第三方 VFX
英雄详情 3D 模型 cn.etetet.yiui3ddisplayYIUI3DDisplayChild.ShowAsync("HeroModel_10001") 框架管 RT/Camera/Pool
抽卡 CD / 活动倒计时 cn.etetet.yiuiframeworkCountDownMgr 不自建 Timer 管理器
抽卡 RPC 等待挡点击 cn.etetet.yiuiframeworkscene.YIUIMgr().BanLayerOptionForever() 异步期间防双击
确认/取消弹窗 cn.etetet.yiuitipsTipsHelper.OpenWait<TipsMessageViewComponent>(scene, msg) 自带 HashWait 模态返回
操作成功飘字 cn.etetet.yiuitipsTipsHelper.OpenSync<TipsTextViewComponent>(scene, "招募成功") 自带淡入淡出动画
客户端错误提示(错误码) cn.etetet.yiuitips + ErrorMessageHelper.GetMessage(code) TipsTextView,不写 Toast 系统
红点(英雄/背包/抽卡入口) cn.etetet.yiuireddotRedDotMgr.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 存档

  • CurrencySaveCharactersSave 保留为局内/设置用途
  • 英雄、装备、背包用 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.GetInt32CSPRNG或 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-toolsobservability-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/Consolecn.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"}

关键约定

  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)
  • 底层:复用现有 NLogPackages/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 Consolecn.etetet.console 现成)
    • 注册方式:[ConsoleHandler("AddItem")] public class C_AddItem : IConsoleHandler { public async ETTask Run(Fiber fiber, ModeContex contex, string content) { ... } }
    • 直接登录服务端 shell 执行
    • 适合数据校验、批量操作、压测
  2. 客户端 GM Panelcn.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 10Gacha 1 1ShowBag Bag 钻石 -1奖励入包
抽卡保底触发 SetGachaCount 1 89Gacha 1 1 10 次 第一次抽到必出保底品质
抽卡概率正确 SimulateGacha 1 10000 输出概率分布,与配置误差 < 1%
英雄升级正确 AddHero 10001AddItem <经验书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.loggingLog.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 统一"的核心约定