commit 910477d015ab10cd96befffcbec9c32f02070170 Author: cursor Date: Thu May 28 19:47:52 2026 +0800 feat: initial XtGameKit (hero/bag/tavern/config/db/invoke packages + design docs + et-framework-dev skill) diff --git a/.cursor/skills/et-framework-dev/SKILL.md b/.cursor/skills/et-framework-dev/SKILL.md new file mode 100644 index 0000000..3bb9cf0 --- /dev/null +++ b/.cursor/skills/et-framework-dev/SKILL.md @@ -0,0 +1,274 @@ +--- +name: et-framework-dev +description: >- + Develop ET-Framework + YIUI + Luban + Proto + HybridCLR business code in the + Survivors Unity project. Use when adding/editing any code under + `My project/Packages/cn.etetet.*`, creating new packages, adding RPC/Proto + messages, defining Entity/Component, writing GM commands, or touching + Hotfix/HotfixView/Model/ModelView assemblies. Enforces the ET source + generator rules (ET0001~ET0032), assembly-layout (asmref) discipline, Proto + workflow, EntityRef usage, and the MCP-Unity debug loop. +--- + +# ET Framework Development — Survivors Project + +ET 的 Roslyn 分析器和程序集结构非常严格,**违反规则会一次性触发几十个编译错误**。先按下面的"动手前必查"走一遍,再写代码。 + +--- + +## 动手前必查(每次都先做这 5 件事) + +``` +- [ ] 1. 看包是否激活:包目录下 Scripts/**/AssemblyReference.asmref 必须齐全, + Ignore.*.asmdef 必须保持 IGNORE constraint(否则会顶替 asmref 失效)。 +- [ ] 2. 看 Proto 是否生成:cn.etetet.proto/CodeMode/Model/ClientServer/.cs 是否存在; + 缺则跑 ET/Proto/Proto2CS 菜单。 +- [ ] 3. 看每个新类放到了正确程序集层: + - 静态工具 / 占位 DTO → Model + [EnableClass] + - Entity/Component → Model/Server 或 Model/Client + - System / Service / Handler / Helper(业务)→ Hotfix + - UI 数据绑定 / 工具 → ModelView + [EnableClass] + - YIUI 事件 / GM 命令 → HotfixView +- [ ] 4. 看是否有静态字段:Hotfix/HotfixView 程序集禁止任何非 const 字段和属性。 +- [ ] 5. 看静态工具类是否互相调用:BagNoticeHelper ↔ BagComponentSystem 这种环 + 会触发 ET0013,要拆成单向依赖。 +``` + +把这 5 条贴到 todo 里,写代码前一项一项打勾。 + +--- + +## ET 分析器铁律(高频踩坑顺序) + +| ID | 规则 | 怎么改 | +|---|---|---| +| **ET0001** | `AddChild` 的 T 必须在 `[ChildOf]` 声明里 | Entity 类加 `[ChildOf(typeof(ParentEntity))]` | +| **ET0002** | 访问别人的 Entity 字段需 `[FriendOf(typeof(T))]` | 在调用方类上加 `[FriendOf(typeof(Hero))]` 等 | +| **ET0003** | Entity 子类不能写构造函数 / 不能 `new` | 用 `AddChild`/`AddComponent` 创建 | +| **ET0004** | **Hotfix/HotfixView 程序集禁止非 const 字段和属性**(包括 `=> "..."` 的 expression-bodied property)| 字段挪到 Model 程序集 + `[EnableClass]`;属性删掉,用 `typeof(T).Name` 之类直接 inline | +| **ET0013** | 静态类间环依赖 | 让一边 inline 内嵌另一边的小逻辑,打破环 | +| **ET0015** | static 字段必须 `[StaticField]` | 加 attribute;但若同时在 Hotfix 会与 ET0004 冲突 → 必须挪走 | +| **ET0024/0025** | `EntitySystem` 必须在 `[EntitySystemOf]` 标记的 partial 类里 | `[EntitySystemOf(typeof(Hero))] partial class HeroSystem` | +| **ET0030** | NetMessage 必须从 Proto 生成 | 不要手写 Message 类 | +| **ET0031** | 禁止 `new XxxMessage()`(Proto 类) | 用 `XxxMessage.Create()` 工厂方法 | +| **ET0032** | Model/ModelView 程序集禁止非 Entity 类(除非加 `[EnableClass]`) | 静态工具类、占位 DTO 加 `[EnableClass]` | + +完整规则解读 → 见 [analyzer-rules.md](analyzer-rules.md) + +--- + +## 程序集分层硬规则 + +每个 ET 包 `cn.etetet.` **必须**在子目录放 `AssemblyReference.asmref`,否则代码不会参与编译(一个隐藏到不报错的"代码静默失踪"陷阱)。 + +``` +cn.etetet.mypkg/ +├── Ignore.ET.MyPkg.asmdef # 占位 asmdef(IGNORE constraint,不参与编译) +├── Proto/ # 仅放 .proto,由 Proto2CS 产 cs 到 cn.etetet.proto +├── Scripts/ +│ ├── Model/ +│ │ ├── Share/AssemblyReference.asmref → ET.Model +│ │ ├── Client/AssemblyReference.asmref → ET.Model +│ │ └── Server/AssemblyReference.asmref → ET.Model +│ ├── Hotfix/ +│ │ ├── Share/AssemblyReference.asmref → ET.Hotfix +│ │ ├── Client/AssemblyReference.asmref → ET.Hotfix +│ │ └── Server/AssemblyReference.asmref → ET.Hotfix +│ ├── ModelView/Client/AssemblyReference.asmref → ET.ModelView +│ └── HotfixView/Client/AssemblyReference.asmref → ET.HotfixView +``` + +`AssemblyReference.asmref` 内容固定为 `{ "reference": "ET." }`。 + +**临时屏蔽整个包**:把该包 `Scripts/**/AssemblyReference.asmref`(含 .meta)全删,保留 `Ignore.*.asmdef`。本次会话就用这招屏蔽了 `cn.etetet.gacha`。 + +**判断要放哪一层**(最常错的决策): + +| 代码类型 | 放哪 | 原因 | +|---|---|---| +| Entity / Component 定义 | `Model/Server` 或 `Model/Client` | Model 程序集只允许 Entity 派生类 | +| Entity 的 `System`(Awake/Update/Destroy)| `Hotfix/Server`(带 `[EntitySystemOf]`)| 业务逻辑层 | +| Service / Helper / Mapper / Handler | `Hotfix/Server` 或 `Hotfix/Client` | 业务工具 | +| Proto 消息类 | **不要自己写**,跑 Proto2CS | 自动生成到 `cn.etetet.proto/CodeMode/` | +| 普通占位 DTO / Luban 生成类 / Lock 对象 | `Model/Share` + `[EnableClass]` | 需要持有静态状态时必须在 Model | +| YIUI 视图组件 / UIBindCDETable 派生 | `ModelView/Client` | YIUI 框架约定 | +| YIUI 事件 handler / GM 命令 / 客户端 RPC handler | `HotfixView/Client` | 视图层业务 | + +更多分层细节 → 见 [assembly-layout.md](assembly-layout.md) + +--- + +## Proto 工作流(不跑等于不存在) + +ET 不直接编译 `.proto`,必须先跑生成器。 + +**1. 命名规则**(Proto2CS 用文件名解析 opcode): + +``` +__.proto +例:HeroOuter_C_3000.proto # C=客户端服务端通用,opcode 从 3000 开始递增 +``` + +**2. RPC 注解**(缺一个就不识别为 RPC): + +```proto +// ResponseType R2C_SummonHero +message C2G_SummonHero // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int32 configId = 3; +} + +message R2C_SummonHero // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; +} + +// 非 RPC 单向通知用这个 +message Hero_HeroInfo // IMessage +{ + HeroInfo hero = 1; +} +``` + +**3. 生成步骤**: + +``` +1. 写完 .proto,Unity 菜单 ET → Proto → Proto2CS +2. 生成结果在 cn.etetet.proto/CodeMode/Model/{Client,Server,ClientServer}/.cs +3. Assets → Refresh,让 Unity 索引到新文件 +4. recompile_scripts 或 F6 +``` + +**4. 用 MCP 一键触发**: + +``` +execute_menu_item({"menuPath": "ET/Proto/Proto2CS"}) +execute_menu_item({"menuPath": "Assets/Refresh"}) +recompile_scripts({"waitForCompletion": true}) +``` + +更多 → 见 [proto-workflow.md](proto-workflow.md) + +--- + +## EntityRef\ 使用规则 + +`EntityRef` 是 ET 的弱引用包装结构体,**编译器不会自动 unwrap**。 + +```csharp +// ❌ 编译错误 CS1061 +if (bag.ItemMap.TryGetValue(idx, out EntityRef unit)) +{ + unit.ItemId // EntityRef 没有 ItemId 字段 + unit.Dispose() // 也没有 Dispose +} + +// ✅ 用隐式转换拆包 +if (bag.ItemMap.TryGetValue(idx, out EntityRef unitRef)) +{ + ItemUnit unit = unitRef; // 触发 implicit operator T(EntityRef v) + unit.ItemId + unit.Dispose() +} + +// ✅ Linq 场景 +self.ItemMap.Values + .Select(x => (ItemUnit)x) // 显式 cast 也会调 implicit operator + .Where(x => x.ItemId == 1) +``` + +--- + +## MCP-Unity 调试循环 + +调试代码不要手动开 Unity 看日志,用 MCP 闭环。 + +``` +1. 改完代码 → CallMcpTool execute_menu_item Assets/Refresh +2. CallMcpTool recompile_scripts {"waitForCompletion": true} +3. 若 errors > 0 → 看 logs 列表,分类批量修 +4. errors == 0 → CallMcpTool execute_menu_item "Tools/Survivors/Open Init Scene And Play" +5. CallMcpTool get_console_logs {"types": ["error", "warning"], "count": 50} +``` + +**MCP 连接失败时**:不要立刻重试,先确认 Unity 编辑器没卡在编译/导入(往往是上一次 Refresh 还在跑)。等 5-10s 再发请求。 + +--- + +## 新建一个业务包的标准流程 + +参考 cn.etetet.hero / cn.etetet.bag 的结构。**写代码前先把骨架建好**。 + +``` +Task Progress: +- [ ] 1. 拷贝 cn.etetet.statesync 作为模板,改包名 +- [ ] 2. 删掉 Runtime/Model 下的 ET.Model.asmdef(保留 Ignore.*.asmdef 即可) +- [ ] 3. 保留 / 重建以下 7 个 AssemblyReference.asmref: + Scripts/Model/{Share,Client,Server}, Scripts/Hotfix/{Share,Client,Server}, + Scripts/ModelView/Client, Scripts/HotfixView/Client(按需) +- [ ] 4. package.json dependencies 加 cn.etetet.core / login / logging / console / bag 等 +- [ ] 5. Proto/_C_.proto 写消息 +- [ ] 6. ET → Proto → Proto2CS +- [ ] 7. Model 层写 Entity,配 [ChildOf]/[ComponentOf] +- [ ] 8. Hotfix 层写 System([EntitySystemOf])/ Service / Handler +- [ ] 9. recompile_scripts 验证 0 错误 +- [ ] 10. 写 GM 命令到 HotfixView/Client/GM 验证业务 +``` + +--- + +## 已知 demo 噪音(看到时直接忽略,不要去修) + +当前 `GlobalConfig.SceneName = StateSync`,走 `cn.etetet.statesync` + `cn.etetet.yiuistatesync` 这两个**仅参考** demo 包的流程。这两个错每次都会出现,**不影响业务**: + +| 错误关键字 | 触发时机 | 来源 | +|---|---|---| +| `actor id is 0` @ `MessageLocationSenderComponentSystem.cs:67` | 走 demo 大厅 → 进入地图 (EnterMapAsync) | statesync M2M 跨地图转移弱实现 | +| `cant set parent because parent iSence is null: CoroutineLockComponent` | 按停止键退出 Play | demo `LobbyPanelComponentSystem.OnEventEnterMapInvoke` 在 OnApplicationQuit 后还在 await CloseAsync | + +排查规则:先看错误栈是否在 `cn.etetet.statesync` / `cn.etetet.yiuistatesync` 内部。是的话直接跳过。 + +--- + +## 这次会话踩过的坑(不要再犯) + +1. **新包没 asmref → 代码静默失踪**。报错全在引用方报"找不到类型",而不是说"包没编译"。第一时间检查 asmref。 +2. **Proto 写好不跑 Proto2CS → 类不存在**。F6 不会触发,必须走菜单。 +3. **Hotfix 里写 `protected virtual string Module => "Net"`** → ET0004 把属性也当字段。要么挪到 Model + `[EnableClass]`,要么完全删除。 +4. **`static readonly Dictionary` 当查找表** → Hotfix 禁。改 `switch` 表达式,或挪到 Model。 +5. **`StringSplitOptions.TrimEntries`** → Unity 的 .NET Standard 2.1 不支持。用 `Split(' ', RemoveEmptyEntries).Select(x => x.Trim())`。 +6. **`new HeroInfo { ... }`** → ET0031。用 `HeroInfo.Create()` + 逐字段赋值。 +7. **`bag.ItemMap.TryGetValue(.., out EntityRef u); u.Field`** → CS1061。先 `T t = u;`。 +8. **Helper 之间互调形成环** → ET0013。让低层 helper 内联自己的小逻辑,不调上层。 +9. **登录后 GM 面板没自定义命令** → 先查 `cn.etetet./Scripts/HotfixView/Client/GM/*.cs` 是否在 HotfixView 程序集(必须有 asmref),再查 `[GM(EGMType.Common, ...)]` 标签是否正确。 +10. **MCP 调用超时** → Unity 还在 Import/Compile,先等 5-10s。 + +--- + +## 链路速记(启动到 GM 出现) + +``` +Init.unity (cn.etetet.loader) + → Entry.Start (cn.etetet.core) + → FiberInit_Main (SceneType 由 GlobalConfig.SceneName 决定) + → EntryEvent1/2/3 + → EntryEvent3_InitClient (cn.etetet.yiuistatesync, SceneType.StateSync) + → YIUIMgrComponent.Initialize() + → YIUIEventInitializeAfter + → YIUIEventInitializeAfterGMHandler (cn.etetet.yiuigm) + → GMCommandComponent.Awake → 扫 GMAttribute → 显示在 GM 面板 +``` + +要让自定义 GM 出现:cs 文件**必须**位于 `/Scripts/HotfixView/Client/...`(HotfixView 程序集),并标 `[GM(EGMType.XXX, 1, "标题", "tooltip")]`。 + +--- + +## 详细参考 + +- [analyzer-rules.md](analyzer-rules.md) — ET0001~ET0032 全规则解读 + 触发示例 + 修复模板 +- [assembly-layout.md](assembly-layout.md) — 程序集分层完整规则、asmref 模板、新建包步骤 +- [proto-workflow.md](proto-workflow.md) — Proto 命名、注解、生成、MCP 一键流程 diff --git a/.cursor/skills/et-framework-dev/analyzer-rules.md b/.cursor/skills/et-framework-dev/analyzer-rules.md new file mode 100644 index 0000000..a0f23aa --- /dev/null +++ b/.cursor/skills/et-framework-dev/analyzer-rules.md @@ -0,0 +1,248 @@ +# ET Source Generator 分析器规则 + +源码位置:`My project/Packages/cn.etetet.sourcegenerator/DotNet~/ET.SourceGenerator/Analyzer/` + +只对**新写或新改**的代码生效。下面按"踩坑频率从高到低"排序。 + +--- + +## ET0004 — Hotfix 程序集禁止非 const 字段(含属性) + +**最爱挖坑的一条**。HotfixProjectFieldDeclarationAnalyzer 把所有 **属性** 也当成"非const字段"报错。 + +```csharp +// ❌ Hotfix 程序集 +public abstract class RpcMessageHandler : MessageSessionHandler +{ + protected virtual string Module => "Net"; // ET0004: 字段: Module + protected virtual string Action => typeof(TReq).Name; // ET0004: 字段: Action +} + +// ❌ Hotfix 程序集 +public static class LogExtensions +{ + private static readonly object AuditLock = new(); // ET0004 + ET0015 +} + +// ✅ 修复 1:完全去掉属性,在 Run 里 inline +public abstract class RpcMessageHandler : MessageSessionHandler +{ + protected sealed override async ETTask Run(Session session, TReq request, TResp response) + { + string action = typeof(TReq).Name; + Log.Info($"[Net] {action} in"); + await OnRun(session, request, response); + } + protected abstract ETTask OnRun(Session session, TReq req, TResp resp); +} + +// ✅ 修复 2:挪到 Model + [EnableClass],Model 程序集不被本规则约束 +// 文件路径从 Scripts/Hotfix/Share/ 移到 Scripts/Model/Share/ +namespace ET +{ + [EnableClass] + public static class LogExtensions + { + private static readonly object AuditLock = new(); // ✅ Model 允许 + } +} +``` + +适用程序集:`ET.Hotfix`、`ET.HotfixView`、`ET.Hotfix.Test`、`ET.HotfixView.Test`。 + +--- + +## ET0002 — Entity 字段访问需 FriendOf + +```csharp +public class BagComponent : Entity, IAwake +{ + public Dictionary> ItemMap = new(); +} + +// ❌ 触发 ET0002 +public static class BagService +{ + public static bool TryUseItem(Player player, long idx) + { + var bag = player.GetComponent(); + bag.ItemMap.TryGetValue(...); // 错:跨类访问字段 + } +} + +// ✅ +[FriendOf(typeof(BagComponent))] +public static class BagService +{ + // ... +} +``` + +**属性不算字段**。`ItemUnit` 用 `{ get; set; }` 属性,外部访问无需 FriendOf。 +**子类访问父类**字段无需 FriendOf,本规则只针对**跨类访问**。 +**FriendOf 写在使用方类上**,不是被使用的类。 + +--- + +## ET0031 — 禁止 new Proto / Message 类 + +`MessageObject`(Proto 生成)走对象池,必须用工厂: + +```csharp +// ❌ +HeroInfo hero = new() { heroId = 1 }; +Hero_HeroInfo notice = new() { hero = info }; + +// ✅ +HeroInfo hero = HeroInfo.Create(); +hero.heroId = 1; + +Hero_HeroInfo notice = Hero_HeroInfo.Create(); +notice.hero = info; +``` + +--- + +## ET0032 — Model/ModelView 禁止非 Entity 类 + +Model 程序集理论上只放 Entity / Component / Proto / `struct`。普通工具类/DTO 要么挪走,要么加 `[EnableClass]`: + +```csharp +// ❌ 在 Scripts/Model/Share/ 下 +namespace ET.Server +{ + public sealed class MongoDBComponent : IDBComponent { ... } // ET0032 +} + +// ✅ +namespace ET.Server +{ + [EnableClass] + public sealed class MongoDBComponent : IDBComponent { ... } +} +``` + +实战上经常用 Model 层放 `static` 工具:因为 Hotfix 不允许静态字段,需要持有锁/字典之类的静态状态时**只能**放在 Model + `[EnableClass]`。 + +--- + +## ET0013 — 静态类间环依赖 + +```csharp +// ❌ +public static class BagComponentSystem +{ + public static void AddItem(this BagComponent self, ...) { + BagNoticeHelper.NotifyItemChange(...); + } +} + +public static class BagNoticeHelper +{ + public static void NotifyItemList(BagComponent bag) { + bag.GetAllItems(); // 调到 BagComponentSystem,形成环 + } +} + +// ✅ 让 BagNoticeHelper 内联自己的小逻辑,不再调上层 +public static class BagNoticeHelper +{ + public static void NotifyItemList(BagComponent bag) { + foreach (var unitRef in bag.ItemMap.Values) { + ItemUnit unit = unitRef; + list.items.Add(BagMapper.ToProto(unit)); + } + } +} +``` + +排查:报错信息会列出所有 `A -> B` 和 `B -> A` 的调用点。 + +--- + +## ET0015 — static 字段必须 `[StaticField]` + +```csharp +// ❌ +namespace ET +{ + public class Foo + { + private static int counter; // ET0015 + } +} + +// ✅ +namespace ET +{ + public class Foo + { + [StaticField] + private static int counter; + } +} +``` + +在 Hotfix 程序集里 ET0015 + ET0004 会同时触发,唯一出路是**挪到 Model**。 + +--- + +## ET0024 / ET0025 — EntitySystem 必须在 partial 类里 + +```csharp +// ❌ +public static class HeroSystem +{ + private static void Awake(this Hero self) { ... } // ET0024 +} + +// ✅ +[EntitySystemOf(typeof(Hero))] +[FriendOf(typeof(Hero))] +public static partial class HeroSystem +{ + [EntitySystem] + private static void Awake(this Hero self, int configId) + { + self.ConfigId = configId; + } +} +``` + +ET 用源码生成器在 partial 的另一半生成 `HeroAwakeSystem` 类。**忘记 `partial`** 是常见错误。 + +--- + +## 其他规则(出错概率低,简要列出) + +| ID | 含义 | +|---|---| +| ET0001 | `AddChild` 的 T 必须在 `[ChildOf]` 声明里 | +| ET0003 | Entity 子类不能写构造函数 | +| ET0005 | Hotfix 类必须 static | +| ET0006 | Entity 类不能写实例方法(要写成扩展方法在 System 里) | +| ET0007 | Component 必须有 `[ComponentOf]` 声明 | +| ET0008 | 同步方法里不能调 `await ETTask` | +| ET0009 | async 方法里不能丢弃 ETTask | +| ET0010 | 不允许 delegate 字段 | +| ET0011 | UniqueId 范围检查 | +| ET0012 | UniqueId 重复 | +| ET0014 | Entity 内不能用 child component | +| ET0016~0019 | ETCancellationToken 链路检查 | +| ET0020 | Entity 字段声明位置 | +| ET0021 | async 方法返回类型必须 ETTask | +| ET0022 | Client 类不能在 Server 程序集 | +| ET0023 | LSEntity float 字段检查(确定性帧同步) | +| ET0026 | FiberLog 使用规则 | +| ET0027 | EntityHashCode 检查 | +| ET0028 | EntityComponentChild 检查 | +| ET0029 | Entity 不能声明泛型类型 | +| ET0030 | NetMessage 必须从 Proto 生成 | +| ET1001 | ETSystem 方法必须在 static partial 类 | + +具体规则触发时去看对应分析器源码: + +``` +cn.etetet.sourcegenerator/DotNet~/ET.SourceGenerator/Analyzer/.cs +cn.etetet.sourcegenerator/DotNet~/ET.SourceGenerator/Config/DiagnosticIds.cs +``` diff --git a/.cursor/skills/et-framework-dev/assembly-layout.md b/.cursor/skills/et-framework-dev/assembly-layout.md new file mode 100644 index 0000000..041e51a --- /dev/null +++ b/.cursor/skills/et-framework-dev/assembly-layout.md @@ -0,0 +1,171 @@ +# ET 程序集分层与 asmref 完整规则 + +## 6 个核心程序集 + +| 程序集 | 由谁定义 | 装哪些代码 | +|---|---|---| +| `ET.Core` | `cn.etetet.core/Runtime/Core/ET.Core.asmdef` | 框架基础,不可修改 | +| `ET.Model` | `cn.etetet.statesync/Runtime/Model/ET.Model.asmdef` | Entity/Component 数据定义、Proto 消息类、占位/工具类(加 `[EnableClass]`) | +| `ET.ModelView` | `cn.etetet.statesync/Runtime/ModelView/ET.ModelView.asmdef` | YIUI 视图组件、UIBindCDETable 派生类 | +| `ET.Hotfix` | `cn.etetet.statesync/Runtime/Hotfix/ET.Hotfix.asmdef` | 业务 System / Service / Helper / Handler | +| `ET.HotfixView` | `cn.etetet.statesync/Runtime/HotfixView/ET.HotfixView.asmdef` | YIUI 事件、GM 命令、客户端 RPC handler | +| `ET.Loader` | `cn.etetet.loader/...` | 启动器 | + +> ET.Model.asmdef 的 `defineConstraints` 是 `["INITED", "IS_COMPILING || UNITY_EDITOR"]` — 项目必须先被 ET 框架"初始化"过(写入 `INITED` symbol),所有 asmref 才会激活。新克隆的工程要先走一次 ET 初始化流程。 + +--- + +## asmref:让你的包加入框架程序集 + +每个 `cn.etetet.` 包**不能**自己定义 asmdef,必须用 asmref 把代码"塞"进上面 6 个程序集。 + +### 标准目录布局 + +``` +cn.etetet.mypkg/ +├── package.json # 声明依赖 +├── Ignore.ET.MyPkg.asmdef # 占位(IGNORE constraint) +├── Ignore.ET.MyPkg.asmdef.meta +├── Proto/ # 只放 .proto +│ └── MyPkgOuter_C_4000.proto +└── Scripts/ + ├── Model/ + │ ├── Share/AssemblyReference.asmref # → ET.Model + │ ├── Client/AssemblyReference.asmref # → ET.Model + │ └── Server/AssemblyReference.asmref # → ET.Model + ├── ModelView/ + │ └── Client/AssemblyReference.asmref # → ET.ModelView + ├── Hotfix/ + │ ├── Share/AssemblyReference.asmref # → ET.Hotfix + │ ├── Client/AssemblyReference.asmref # → ET.Hotfix + │ └── Server/AssemblyReference.asmref # → ET.Hotfix + └── HotfixView/ + └── Client/AssemblyReference.asmref # → ET.HotfixView +``` + +### AssemblyReference.asmref 内容(固定) + +```json +{ "reference": "ET.Model" } +``` + +```json +{ "reference": "ET.ModelView" } +``` + +```json +{ "reference": "ET.Hotfix" } +``` + +```json +{ "reference": "ET.HotfixView" } +``` + +### Ignore..asmdef 内容(固定) + +```json +{ + "name": "Ignore.ET.MyPkg", + "rootNamespace": "", + "references": [], + "defineConstraints": ["IGNORE"], + "autoReferenced": true +} +``` + +`defineConstraints: ["IGNORE"]` 让这个 asmdef 永远不参与编译 — 它的存在只是为了占位(避免 Unity 找不到 .meta),真正生效的是各子目录里的 asmref。 + +--- + +## 哪个代码放哪一层(决策表) + +| 你想写的代码 | 放哪 | 关键约束 | +|---|---|---| +| `class Hero : Entity, IAwake` | `Scripts/Model/Server/Hero.cs` | 只能字段,不能写方法。需要 `[ChildOf(typeof(HeroComponent))]` | +| `[ComponentOf(typeof(Player))] class HeroComponent : Entity, IAwake` | `Scripts/Model/Server/HeroComponent.cs` | 同上 | +| 客户端缓存 `class HeroComponent : Entity, IAwake { Dictionary }` | `Scripts/Model/Client/HeroModels.cs` | 同名服务端类客户端可独立定义 | +| `[EntitySystemOf(typeof(Hero))] partial class HeroSystem { Awake/Update }` | `Scripts/Hotfix/Server/HeroSystem.cs` | partial,函数标 `[EntitySystem]` | +| `static class HeroService { TrySkillUp(player, hero) }` | `Scripts/Hotfix/Server/HeroService.cs` | 加 `[FriendOf(typeof(Hero))]` | +| `RpcMessageHandler` 子类 | `Scripts/Hotfix/Server/C2G_XHandler.cs` | **不能**有非 const 字段/属性 | +| `MessageHandler` 客户端 | `Scripts/Hotfix/Client/X_Handler.cs` | 同上 | +| 锁对象 / 字典 / 静态状态工具类 | `Scripts/Model/Share/` + `[EnableClass]` | 因为 Hotfix 不允许静态字段 | +| Proto 生成的 Message 类 | 不要自己写,跑 Proto2CS | 输出到 `cn.etetet.proto/CodeMode/` | +| YIUI 视图组件 `class XXXViewComponent : Entity` | `Scripts/ModelView/Client/XXX.cs` | | +| `[GM(EGMType.Common, ...)]` 命令 | `Scripts/HotfixView/Client/GM/X.cs` | 必须在 HotfixView 才被 GMAttribute 扫到 | +| `AEvent` handler | `Scripts/HotfixView/Client/` | | + +--- + +## 新建一个业务包:完整步骤 + +``` +Task Progress: +- [ ] 1. 复制 cn.etetet.statesync 整个目录为 cn.etetet. +- [ ] 2. 删除 Runtime/ 下的 ET.*.asmdef 文件(这些是核心程序集,不能在新包里) +- [ ] 3. 改 package.json 的 name / displayName / dependencies +- [ ] 4. 修改 Ignore.ET.Statesync.asmdef → Ignore.ET..asmdef,name 字段同步改 +- [ ] 5. 确保 Scripts/**/AssemblyReference.asmref 都还在(content 不需改) +- [ ] 6. 如果不需要 ModelView / HotfixView,删对应目录的 asmref(避免无意义编译) +- [ ] 7. Unity Assets/Refresh,确认 Console 无报错 +- [ ] 8. 写 Proto → 跑 Proto2CS → 写 Model → 写 Hotfix +``` + +### package.json 依赖最小集 + +```json +{ + "name": "cn.etetet.mypkg", + "displayName": "ET.MyPkg", + "version": "1.0.0", + "dependencies": { + "cn.etetet.core": "1.0.0", + "cn.etetet.login": "1.0.0", + "cn.etetet.logging": "1.0.0", + "cn.etetet.console": "1.0.0" + } +} +``` + +按需补:`cn.etetet.bag` / `cn.etetet.hero` / `cn.etetet.yiuigm` 等。 + +--- + +## 临时屏蔽 / 启用整个包 + +**屏蔽**:删除 `Scripts/**/AssemblyReference.asmref` 及对应 `.meta`,保留 `Ignore.*.asmdef`。包代码全部不参与编译。 + +**启用**:把 asmref 加回来,内容如上节模板。Unity 会自动识别。 + +本次会话用这招屏蔽了 `cn.etetet.gacha`: + +``` +删除:cn.etetet.gacha/Scripts/Hotfix/{Client,Server}/AssemblyReference.asmref +删除:cn.etetet.gacha/Scripts/Model/{Client,Server}/AssemblyReference.asmref +保留:cn.etetet.gacha/Ignore.ET.Gacha.asmdef +``` + +--- + +## 常见踩坑 + +1. **代码静默失踪**:新加的 cs 文件**所在目录上行**没有 asmref → 文件不参与任何程序集 → 调用方报"找不到类型"。检查方法: + + ``` + Glob: cn.etetet./**/*.asmref + ``` + + 每个有 cs 文件的子目录必须有 asmref 上溯。 + +2. **方向错误**:把 Helper 写到 `Scripts/Model/Server/` → ET0032。把 Entity 写到 `Scripts/Hotfix/Server/` → 各种诡异错误(Hotfix 不允许字段)。决策表见上。 + +3. **客户端代码引用了 UnityEngine**:`Scripts/Hotfix/Client/` 是 `ET.Hotfix` 程序集,**不能** `using UnityEngine`。需要 UnityEngine API 的代码必须放 `Scripts/HotfixView/Client/` 或 `Scripts/ModelView/Client/`。 + +4. **Model 程序集报 `Define` / `GlobalConfig` / `Resources` 未找到**:同样 UnityEngine 引用问题。Model 层禁止依赖 UnityEngine。需要的话挪去 ModelView 或者用反射延后调用。 + +--- + +## 跨包引用的最佳实践 + +- 在 `package.json.dependencies` 显式声明依赖。 +- **避免环依赖**:`login` 不要依赖 `bag/hero` 这种业务包;让业务包依赖 `login` 暴露的接口。如果非要双向,用 Invoke 契约(看 `cn.etetet.invoke` 包模式)。 +- 不允许 `Hotfix/Client` 调用 `Hotfix/Server` 代码(编译会过但语义错)。共享逻辑放 `Hotfix/Share`。 diff --git a/.cursor/skills/et-framework-dev/proto-workflow.md b/.cursor/skills/et-framework-dev/proto-workflow.md new file mode 100644 index 0000000..b4fd3ff --- /dev/null +++ b/.cursor/skills/et-framework-dev/proto-workflow.md @@ -0,0 +1,216 @@ +# Proto 完整工作流 + +## 1. 命名与放置 + +`.proto` 文件必须放在某个 `cn.etetet./Proto/` 目录下,且文件名遵守: + +``` +__.proto +``` + +| 字段 | 取值 | 说明 | +|---|---|---| +| `Module` | 任意(如 `HeroOuter`、`BagOuter`、`LoginInner`)| 模块标识 | +| `Direction` | `C` / `S` / `CS` | C=外网消息(客户端可见),S=内网消息(仅服务端),CS=同时 | +| `StartOpcode` | 整数 | Opcode 从这里递增分配给每条 message | + +opcode 分配规范(避免冲突): + +| 模块 | Outer 起始 | Inner 起始 | +|---|---|---| +| Login | 1000 | 20001 | +| Router | 1100 | - | +| StateSync | 11001 | 21001 | +| ActorLocation | - | 20100 | +| Hero | 3000 | - | +| Bag | 3100 | - | +| Gacha | 3200 | - | +| 业务新模块 | 4000+ | 30000+ | + +--- + +## 2. 消息注解(决定生成的 C# 类继承谁) + +### RPC 请求 / 响应 + +```proto +// ResponseType R2C_SummonHero ← 必须在 message 上一行 +message C2G_SummonHero // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; ← 推荐字段,前 2 个固定 RpcId + reqId + int32 configId = 3; +} + +message R2C_SummonHero // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; ← 前 3 个固定 RpcId / Error / Message + string Message = 3; + HeroInfo hero = 4; +} +``` + +生成结果继承 `MessageObject, ISessionRequest` 和 `MessageObject, ISessionResponse`,并通过 `[ResponseType(nameof(R2C_SummonHero))]` 自动配对。 + +### 单向通知(服务端推客户端) + +```proto +message Hero_HeroInfo // IMessage +{ + HeroInfo hero = 1; +} +``` + +### 嵌套 DTO + +```proto +message HeroInfo +{ + int64 heroId = 1; + int32 level = 2; + repeated SkillInfo skills = 3; // List +} + +message SkillInfo +{ + int32 skillId = 1; + int32 skillLevel = 2; +} +``` + +不加任何 `//` 注解的 message 默认继承 `MessageObject`,可作为 DTO 嵌入其他消息。 + +### 完整可识别的父类标记 + +Proto2CS 解析时,把 `message X // Y` 里的 `Y` 直接拼到生成类的继承列表。常见值: + +- `IRequest` / `IResponse` — 内网请求/响应 +- `ISessionRequest` / `ISessionResponse` — 客户端 ↔ Gate 的 RPC +- `IMessage` — 单向通知 +- `IActorMessage` / `IActorRequest` / `IActorResponse` — Actor 模型消息 + +--- + +## 3. 生成 + +**Unity 菜单触发**: + +``` +ET → Proto → Proto2CS +``` + +**MCP 触发**: + +``` +CallMcpTool execute_menu_item {"menuPath": "ET/Proto/Proto2CS"} +``` + +工具会: +1. 扫描所有 `cn.etetet.*/Proto/*.proto` +2. 按文件名解析 Opcode +3. 输出三份 C# 到: + - `cn.etetet.proto/CodeMode/Model/Client/.cs` + - `cn.etetet.proto/CodeMode/Model/Server/.cs` + - `cn.etetet.proto/CodeMode/Model/ClientServer/.cs` + +> CodeMode 下只有 `ClientServer/` 子目录有 `AssemblyReference.asmref`(指向 ET.Model),所以在 `CodeMode: 3 = ClientServer` 模式下,**只有 ClientServer 那份会被编译**进 ET.Model。`Client/` 和 `Server/` 是预留给纯客户端/纯服务端模式的。 + +--- + +## 4. 调用 + +### 客户端发 RPC + +```csharp +C2G_SummonHero request = C2G_SummonHero.Create(); +request.configId = 1; +R2C_SummonHero response = await clientScene + .GetComponent() + .Call(request, false) as R2C_SummonHero; +if (response.Error != 0) +{ + Log.Error(response.Message); +} +``` + +### 服务端 RPC Handler + +```csharp +// Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs +[MessageSessionHandler(SceneType.Gate)] +public class C2G_SummonHeroHandler : RpcMessageHandler +{ + protected override async ETTask OnRun( + Session session, C2G_SummonHero request, R2C_SummonHero response) + { + Player player = session.GetComponent()?.Player; + if (player == null) { + response.Error = ErrorCode.ERR_RpcFail; + return; + } + // 业务... + await ETTask.CompletedTask; + } +} +``` + +注意: +- **不要**写 `protected override string Module => "X"` 这种属性,ET0004 会报。 +- 文件放 `Scripts/Hotfix/Server/`。 + +### 服务端推单向通知 + +```csharp +Hero_HeroInfo notice = Hero_HeroInfo.Create(); +notice.hero = HeroMapper.ToProto(hero); +session.Send(notice); +``` + +### 客户端通知 Handler + +```csharp +// Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs +[MessageHandler(SceneType.Main)] +[FriendOf(typeof(HeroComponent))] +public class Hero_HeroInfoHandler : MessageHandler +{ + protected override async ETTask Run(Scene scene, Hero_HeroInfo message) + { + var comp = scene.GetComponent() ?? scene.AddComponent(); + comp.HeroMap[message.hero.heroId] = message.hero; + EventSystem.Instance.Publish(scene, new HeroUpdatedEvent { HeroId = message.hero.heroId }); + await ETTask.CompletedTask; + } +} +``` + +--- + +## 5. 常见 Proto 坑 + +1. **写完 .proto 不跑 Proto2CS → 引用方报"找不到类型"**:必须先生成。 +2. **缺 `// ISessionRequest`**:消息生成出来不继承 `ISessionRequest`,handler 的 `MessageSessionHandler` 类型约束失败 → 编译报错。 +3. **缺 `// ResponseType R2C_X`**:客户端 `await session.Call(req)` 无法推断响应类型。 +4. **文件名 opcode 重复**:和别的 proto 撞 opcode → 运行时崩。规范分配规则见上面表格。 +5. **手写 `new HeroInfo {...}`** → ET0031。改 `HeroInfo.Create()`。 +6. **修改 .proto 字段顺序 / 类型** → MemoryPack 序列化兼容性问题。线上版本要么不改,要么走版本兼容方案。 + +--- + +## 6. 一键自检 checklist + +写完一个新 .proto 后: + +``` +- [ ] 文件名格式 __.proto +- [ ] Opcode 不与其他 .proto 冲突(grep "_C_<同段号>_") +- [ ] RPC 消息有 // ResponseType +- [ ] 请求带 // ISessionRequest 或 // IRequest +- [ ] 响应带 // ISessionResponse 或 // IResponse +- [ ] 通知带 // IMessage +- [ ] 跑了 ET/Proto/Proto2CS +- [ ] 检查 cn.etetet.proto/CodeMode/Model/ClientServer/.cs 已生成 +- [ ] Assets/Refresh 让 Unity 索引到新文件 +- [ ] recompile_scripts 验证 0 错误 +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c43697 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Unity generated +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Bb]uild/ +[Bb]uilds/ +[Ll]ogs/ +[Mm]emoryCaptures/ +[Uu]ser[Ss]ettings/ + +# Visual Studio / Rider / VSCode +.vs/ +.idea/ +.vscode/ +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Secrets (explicit blacklist) +Doc/server_connect.md +**/server_connect.md + +# Mac / Win artifacts +.DS_Store +Thumbs.db diff --git a/Doc/Bag-System-Guide.md b/Doc/Bag-System-Guide.md new file mode 100644 index 0000000..9e41845 --- /dev/null +++ b/Doc/Bag-System-Guide.md @@ -0,0 +1,18 @@ +# Bag System Guide + +## 模块目标 + +- 统一 Item 存储(货币、碎片、装备、材料都走 itemId) +- 提供增减、查询、批量发奖/扣费能力 + +## 核心接口 + +- `AddItems(List)` +- `RemoveItems(List)` +- `GetItemCount(itemId)` +- `HasItems(List)` + +## 关键约束 + +- 任何资源变动都必须走 Bag 接口 +- 禁止在 Player 上新增独立货币字段 diff --git a/Doc/ET-Packages-Audit.md b/Doc/ET-Packages-Audit.md new file mode 100644 index 0000000..4048c92 --- /dev/null +++ b/Doc/ET-Packages-Audit.md @@ -0,0 +1,226 @@ +# ET 框架包能力清单(38 个) + +> **目的**:避免重复造轮子。每次设计新功能前先查这份清单,确认是否有现成包可用。 +> **更新时间**:2026-05-27 +> **范围**:`My project/Packages/` 下所有 `cn.etetet.*` + `com.etetet.*` 包 + +--- + +## 速查表 + +| 类别 | 包 | 状态 | 关键 API/类 | +|---|---|---|---| +| **核心运行时** | cn.etetet.core | 自带可用 | `Log` / `MongoHelper` / `World` / `Entry` / `Fiber` / `TimerComponent` | +| | cn.etetet.loader | 自带可用 | `CodeLoader` / `Init` / `NLogger` / `BuildHelper` | +| | cn.etetet.sourcegenerator | 自带可用 | `[EntitySystemOf]` / `[ComponentOf]` 等属性 | +| | cn.etetet.memorypack | 自带可用 | MemoryPack 序列化 | +| | cn.etetet.mathematics | 自带可用 | float3/quaternion 等 | +| | com.etetet.init | 自带可用 | MongoDB.Driver 原生 DLL + 包依赖解析 | +| **UI 框架** | cn.etetet.yiuiframework | 自带可用 | `YIUIMgrComponent` / `PanelInfo` / `YIUILoadHelper` | +| | cn.etetet.yiui | 需扩展 | 项目级业务 UI,目前仅 `CommonHeader` | +| | cn.etetet.yiuiinvoke | 自带可用 | `[YIUIInvoke]` 跨模块解耦调用 | +| | **cn.etetet.yiuigm** | **完全现成** | **`[GM]` 属性 + `IGMCommand` 接口 + GMPanel 面板** | +| | cn.etetet.yiuitips | 自带可用 | `TipsHelper` 弹窗 | +| | cn.etetet.yiuireddot | 自带可用 | 红点树 + DAG | +| | cn.etetet.yiuiloopscrollrectasync | 自带可用 | 异步无限滚动列表 | +| | cn.etetet.yiui3ddisplay | 自带可用 | UI 内嵌 3D 模型 | +| | cn.etetet.yiuieffect | 自带可用 | UI 特效(粒子/灰度) | +| | cn.etetet.yiuiyooassets | 自带可用 | YIUI ↔ YooAssets 桥接 | +| **业务样板** | cn.etetet.login | 需扩展 | `C2R_LoginHandler` / `C2G_LoginGateHandler` / `Player`(仅 Account 字段) | +| | cn.etetet.statesync | 仅参考 | MMO Demo(Unit 同步/跨 Map) | +| | cn.etetet.yiuistatesync | 仅参考 | YIUI Login/Lobby/Main Panel 集成样板 | +| **MMO 战斗(与本次无关)** | cn.etetet.unit | 不复用 | MMO 单位 Entity | +| | cn.etetet.numeric | 可借鉴 | KV 数值 + Buff 五段公式 + Watcher | +| | cn.etetet.move | 不复用 | MMO 寻路点移动 | +| | cn.etetet.ai | 不复用 | 行为机 AI | +| | cn.etetet.aoi | 不复用 | 九宫格视野同步 | +| | cn.etetet.recast | 不复用 | 3D 寻路 | +| | cn.etetet.ui | 已废弃 | ET 原生 UI(被 YIUI 替代) | +| | cn.etetet.demores | 不复用 | StateSync Demo 资源 | +| **网络** | cn.etetet.router | 自带可用 | KCP 软路由 | +| | cn.etetet.netinner | 自带可用 | 进程间内网消息 | +| | cn.etetet.http | 自带可用 | 轻量 HTTP Server | +| | cn.etetet.actorlocation | 可选 | 分布式 Actor Location | +| **协议** | **cn.etetet.proto** | **自带可用** | **`ET/Proto/Proto2CS` 工具链 + 自动 Opcode + MemoryPack** | +| **配置** | **cn.etetet.excel** | 不够用 | Excel→C#+JSON+BSON,**不支持外键/嵌套 struct** | +| | cn.etetet.startconfig | 自带可用 | 服务器拓扑配置(Process/Zone/Scene/Machine) | +| **资源/热更** | cn.etetet.yooassets | 自带可用 | YooAsset 资源管理(打包/热更/下载) | +| | cn.etetet.hybridclr | 自带可用 | HybridCLR C# 全平台热更 | +| | cn.etetet.referencecollector | 自带可用 | Prefab 引用收集器 | +| **工具/运维** | **cn.etetet.console** | **完全现成** | **`[ConsoleHandler]` 属性 + `IConsoleHandler` 接口 + stdin 命令循环** | +| | cn.etetet.watcher | 可选 | 多进程保活 | + +--- + +## 关键能力(GM/日志/DB/配置/协议) + +### GM 命令(**完全现成,双轨**) + +| 维度 | `cn.etetet.console` | `cn.etetet.yiuigm` | +|---|---|---| +| 运行端 | 服务端 stdin REPL | 客户端 YIUI 面板 | +| 扩展方式 | `[ConsoleHandler("AddItem")]` + `IConsoleHandler.Run(fiber, contex, content)` | `[GM(EGMType.Test, 1, "发放道具", "...")]` + `IGMCommand.GetParams + Run(scene, paramVo)` | +| 参数类型 | 字符串自由切分 | Enum / String / Bool / Float / Int / Long 6 种 | +| 内置命令 | `R` 热更 DLL、`C ConfigName` 热更 Reload 配置、`Robot` 压测 | 示例 `GM_Test` | +| 业务 GM 接入 | 新增 `[ConsoleHandler]` 类 | 新增 `[GM]` 类 | + +**结论**:**不需要新建 GM 框架**。每个新业务命令只需写一个 `[GM]` 类(客户端)或 `[ConsoleHandler]` 类(服务端)。 + +### 日志(自带 Log 类,需扩展) + +```csharp +// 已有: +Log.Debug(msg); Log.Info(msg); Log.Warning(msg); Log.Error(msg); +Log.Trace(msg); Log.Console(msg); + +// 已有:Fiber 级 ILog 接口可替换底层(默认 NLog 服务端 + UnityLogger 客户端) +``` + +**缺失能力**(要在 ET Log 基础上扩展): +- jsonl 审计日志(无) +- 模块标签前缀(无,需约定 `[Hero]`/`[Bag]`/`[Gacha]` 写法) +- 按天分目录(NLog 支持,需配置) +- 慢操作告警(无,需自加 AOP/手动埋点) +- reqId 全链路透传(无,需在 Handler 基类透传) +- 敏感字段脱敏(无) +- 客户端日志桥接到服务端(无) + +**结论**:**扩展非重写**。新建 `cn.etetet.audit` 或在 `cn.etetet.logging` 加扩展方法 `Log.Audit(module, action, payload)`,底层调 NLog 输出 jsonl 到 `Logs/Audit/{module}/{date}.jsonl`。 + +### DBComponent / 玩家持久化(基础设施现成,业务层缺) + +| 已有 | 缺失 | +|---|---| +| `MongoHelper.ToJson/FromJson/Clone/CloneBytes`(BSON 序列化) | `IDBComponent.Save/Query/Update/Delete` 业务封装 | +| `com.etetet.init/Plugins/MongoDB/*` 原生 DLL | 连接池/重试/慢查询 | +| `MongoRegister` 注册类型映射 | Entity 与 MongoDB Collection 的映射约定 | + +**结论**:**新建业务包 `cn.etetet.db`** 包装这层。**不需要重写 BSON**。 + +### 协议生成(完全现成) + +``` +1. 在业务包下建 Proto/*.proto +2. Unity 菜单 ET/Proto/Proto2CS +3. 自动扫描所有 cn.etetet.* 包的 Proto/ 目录 +4. 生成带 [MemoryPackable] + Opcode 的 C# 到 cn.etetet.proto/CodeMode/Model/{Client,Server,ClientServer} +``` + +**现有协议组占用**:1000-1099 Login / 1100-1199 Router / 11001-11099 StateSync C2C / 20100-20199 ActorLocation / 21001-21099 StateSync M2C / 21100-21199 StateSync M2M。 + +**ROK 占用建议**:3000-3099 Hero / 3100-3199 Bag/Equip / 3200-3299 Gacha。 + +### YIUI 框架(**深度复用,UI 几乎零造轮**) + +**核心认知**: +- 标准面板入口:`scene.YIUIRoot().OpenPanelAsync()` / `OpenPanelParamAsync(...)` / `OpenPanelWaitAsync(...)`(模态等待) +- 数据驱动:Prefab 挂 `UIDataBind*` 组件,代码改 `u_DataXxx.SetValue(...)` 自动刷新 UI +- 事件驱动:Prefab 挂 `UIEventBind*` + `[YIUIInvoke(const)]` 静态方法接线,**无需 Button.onClick.AddListener** +- 跨模块解耦:`[YIUIInvokeSystem("Key")]` + `YIUIInvokeHandler` + `YIUIInvokeSystem.Instance.Invoke(...)` +- CDETable = **C**omponent + **D**ata + **E**vent 三张表,YIUI 自动化工具一键生成 Component/System + +**15 条「YIUI 已经做了的事」速查**: + +| # | 不要自己做 | 用 YIUI 什么 | +|---|---|---| +| 1 | 面板打开/关闭/栈/Home 管理 | `scene.YIUIRoot().Open/ClosePanelAsync` | +| 2 | 按钮点击接线 | `UIEventBind*` + `[YIUIInvoke]` | +| 3 | Item/面板字段刷新样板 | `u_Data*` + `UIDataBind*` | +| 4 | 图标/贴图异步加载 | `UIDataBindImage`(内部走 YooAsset) | +| 5 | Prefab 从 AB 加载 | 打开 Panel 即可(`cn.etetet.yiuiyooassets` 桥接) | +| 6 | 确认框 / 飘字 / 模态等待 | `TipsHelper.OpenWait/OpenSync` + `TipsMessageView/TipsTextView` | +| 7 | 红点树与父子传播 | `RedDotMgr.Inst.SetCount(key, count)` + `RedDotBind`,**只写叶子,父节点自动汇总** | +| 8 | 长列表虚拟化与池化 | `YIUILoopScrollChild.SetDataRefresh(IList)` + `ReRenderer()` | +| 9 | GM/模块间零引用调用 | `YIUIInvokeSystem` + invoke 契约包 | +| 10 | 顶栏/关闭按钮 | 现成 `CommonHeader` + `YIUICloseCommon` | +| 11 | 英雄详情 3D 模型 | `YIUI3DDisplayChild.ShowAsync(resName)`,自带 RT/Camera/RawImage 池 | +| 12 | 按钮灰显/抽卡 UI 特效 | `UIDataBindGray` / `UIEffect` / `UIParticle` | +| 13 | 异步操作全屏挡点击 | `scene.YIUIMgr().BanLayerOptionForever()` | +| 14 | UI 倒计时文本 | `CountDownMgr`(在 framework) | +| 15 | 列表数据变长度不变 | `Loop.ReRenderer()` 增量刷新 | + +**仍需自己做(YIUI 未覆盖)**: + +| 项 | 原因 | +|---|---| +| `BagItemTooltip` 道具浮层 | tips 包无现成 Tooltip View,但容器可用 `TipsHelper.Open` 承载 | +| `BagItemUICommon` 通用道具组件 | 业务复合组件,用 YIUI 生成 + 上述绑定拼装 | +| 英雄列表三段分组 | Loop 只负责虚拟化,分组逻辑在 IList 数据层或 Header Item | +| 天赋树可视化 | 无图编辑器组件,需自绘 | +| `ErrorMessageHelper` 文案表 | 自建(**展示**用 `TipsTextView`) | +| 业务 invoke Handler | 框架有机制,业务 Handler 需自写 | + +**易踩坑(必读)**: + +1. **没有 `PanelHelper`**:标准写法是 `scene.YIUIRoot().OpenPanelAsync()`,与 `yiuistatesync` 登录/大厅一致,不要自建 Helper +2. **CDETable 正式名是 `UIBindCDETable`**(C+D+E = Component + Data + Event) +3. **红点只能在叶子节点 `SetCount`**,给有子节点的 Key 直接 Set 会报错 +4. **`cn.etetet.yiui` 几乎是空包**,ROK 业务 Panel 应建在 `cn.etetet.hero/bag/gacha` 内部,不期待现成业务 UI +5. **客户端数据 → UI 优先走 `UIDataBind`,不要默认走 ET EventSystem**:UIData 是"局部数据驱动",EventSystem 是"跨面板业务广播",两者职责不同 + +### 配置(ExcelExporter 不够用,引入 Luban) + +`cn.etetet.excel` 能力: +- 支持类型:基本类型 + `int[]` / `string[]` / `int[][]` +- 不支持:**外键校验、嵌套 struct、复杂引用类型**(未知类型 `throw 不支持`) +- 输出:C# Category + JSON + BSON bytes + +**对 ROK Hero/Bag 缺口**: +- `List` 通用奖励结构 → ExcelExporter 无法表达 bean 数组 +- `List` 装备属性配对 → 难表达 +- 卡池权重表多列关联 → 无外键校验 +- 天赋树前置依赖 → 无引用校验 + +**结论**:**新业务表用 Luban**(不写自定义模板,原生 API + Scene Component 包装)。**老配置(StartConfig 等)保持 ExcelExporter**。 + +--- + +## ROK Hero/Bag 移植技术选型(基于本清单) + +``` +✅ 直接复用 + - 核心运行时(core/loader/sourcegenerator/memorypack) + - 协议工具链(proto + Proto2CS) + - UI 全套(yiuiframework + 各 yiui 子包) + - GM 双轨(yiuigm + console) + - 资源热更(yooassets + hybridclr) + - 登录链路(login,扩展 Player 字段) + +🔧 扩展使用 + - Log → Audit 扩展(新建 cn.etetet.logging 或 cn.etetet.audit) + - login Player → 挂 HeroComponent / BagComponent / GachaComponent + - excel → 只维护 StartConfig 等 ET 基础设施表 + +📦 新增业务包 + - cn.etetet.db # DBComponent 业务封装(基于 MongoHelper + MongoDB.Driver) + - cn.etetet.hero # 英雄系统 + - cn.etetet.bag # 背包/装备系统 + - cn.etetet.gacha # 抽卡系统 + - cn.etetet.config # Luban Tables + ConfigComponent + +❌ 不复用(与本次无关) + - unit / move / ai / aoi / recast / numeric(OctoberStudio 走自己的局内战斗) + - statesync / yiuistatesync(MMO Demo,仅参考代码风格) + - ui(旧 UI,已被 YIUI 替代) + - demores(Demo 美术资源) +``` + +--- + +## 设计原则(基于这份清单的检查清单) + +每次新功能落地前的 3 问: + +1. **现有 ET 包是否有?** → 查上表 +2. **如果有但能力不全,是扩展还是重写?** → **优先扩展**,禁止重写已有内核 +3. **如果没有,应该建独立业务包还是塞进现有包?** → 业务功能建新包 `cn.etetet.xxx`,**禁止塞进** `core` / `login` 等基础包 + +每次重大决策都需要在 `design.md` 的 D 决策表中明确"复用了哪些已有包 + 扩展点 + 新增点"。 + +--- + +## 修订记录 + +| 日期 | 版本 | 说明 | +|---|---|---| +| 2026-05-27 | v1.0 | 初版,38 包全量调研 | diff --git a/Doc/GM-Commands.md b/Doc/GM-Commands.md new file mode 100644 index 0000000..671f4b9 --- /dev/null +++ b/Doc/GM-Commands.md @@ -0,0 +1,39 @@ +# GM Commands Guide + +## 目标 + +本项目 GM 体系采用双轨入口,并强制复用现有 ET 包能力,不额外新建 GM 框架。 + +## 双轨入口约定 + +- 服务端命令入口:`cn.etetet.console` + - 使用 `[ConsoleHandler("CommandName")]` 注册 + - 适合批量操作、压测、运维值守 +- 客户端调试入口:`cn.etetet.yiuigm` + - 使用 `[GM(...)]` + `IGMCommand` 注册 + - 仅用于 Debug 开发测试 + +## 共享实现约定 + +- GM 业务逻辑必须下沉到共享静态帮助类 `GMHelper` +- Console 与 Panel 只负责参数解析与转发,禁止各自复制业务逻辑 +- GMHelper 内部必须调用正式业务系统接口(如 `BagComponentSystem.AddItems`),禁止直接改字段绕过校验 + +## 发布与安全 + +- 客户端 `[GM]` 类必须使用 `#if ENABLE_GM` 条件编译包裹 +- Release 构建不加载 GM 入口 +- 所有 GM 操作应写审计日志(模块 `GM`) + +## 建议命名规范 + +- `GM_*`:客户端 panel 命令实现 +- `C_*`:服务端 console 命令实现 +- `GMHelper.*`:共享业务函数 + +## 待接入命令清单(第一批) + +- 资源:`AddItem` / `RemoveItem` / `ShowBag` +- 英雄:`AddHero` / `SetHeroLevel` / `ShowHeroes` +- 抽卡:`Gacha` / `ShowGachaStats` / `SimulateGacha` +- 运维:`FlushDirty` / `SetLogLevel` diff --git a/Doc/Hero-System-Guide.md b/Doc/Hero-System-Guide.md new file mode 100644 index 0000000..830c9b8 --- /dev/null +++ b/Doc/Hero-System-Guide.md @@ -0,0 +1,23 @@ +# Hero System Guide + +## 模块目标 + +- 提供英雄创建、升级、升星、技能、觉醒、天赋核心流程 +- 服务端权威,客户端仅缓存与展示 + +## 核心实体 + +- `Player` +- `HeroComponent` +- `Hero`(子实体) + +## 关键接口 + +- `C2G_SummonHero` +- `C2G_HeroAddExp` +- `C2G_HeroSkillUp` + +## 校验策略 + +- 所有消耗先走 `BagComponentSystem.HasItems/RemoveItems` +- 所有失败返回统一错误码并记 L3 diff --git a/Doc/Item-Design-Rule.md b/Doc/Item-Design-Rule.md new file mode 100644 index 0000000..785b477 --- /dev/null +++ b/Doc/Item-Design-Rule.md @@ -0,0 +1,19 @@ +# 通用 Item 设计约定 + +## 核心原则 + +- 全游戏可获得资源必须用 `itemId` 表示 +- 禁止在 `Player` 上新增 `Gold/Diamond/Food/Wood` 等独立货币字段 +- 发奖统一走 `BagComponentSystem.AddItems` +- 扣费统一走 `BagComponentSystem.RemoveItems` + +## 预留货币段 + +- `1` = 金币 +- `2` = 钻石 +- `3` = PvP 币 + +## 统一结构 + +- `RewardEntry { itemId, count }` +- `CostEntry { itemId, count }` diff --git a/Doc/Logging-Guide.md b/Doc/Logging-Guide.md new file mode 100644 index 0000000..6854b8b --- /dev/null +++ b/Doc/Logging-Guide.md @@ -0,0 +1,25 @@ +# Logging Guide + +## 模块标签前缀约定 + +- `[Hero]` +- `[Bag]` +- `[Gacha]` +- `[Equip]` +- `[DB]` +- `[Login]` +- `[GM]` +- `[Net]` + +## 层级 + +- L1:业务日志 +- L2:审计日志(jsonl) +- L3:错误日志 + +## 常用方法 + +- `LogExtensions.Module(tag, level, msg)` +- `LogExtensions.Audit(module, action, payload)` +- `LogExtensions.Slow(module, action, durationMs, ctx)` +- `LogExtensions.AuditGM(operator, action, targetPlayer, args, success)` diff --git a/Doc/Luban-Guide.md b/Doc/Luban-Guide.md new file mode 100644 index 0000000..364fe1f --- /dev/null +++ b/Doc/Luban-Guide.md @@ -0,0 +1,25 @@ +# Luban 使用说明(最小链路) + +## 目录约定 + +- Schema:`Config/Defines` +- Data:`Config/Datas` +- 生成 C#:`My project/Packages/cn.etetet.config/Scripts/Model/Share/cfg` +- 生成 Json:`My project/Bundles/Config` + +## 命令 + +在仓库根目录执行: + +`powershell -ExecutionPolicy Bypass -File "tools/luban/gen.ps1"` + +## 运行时接入 + +- 场景挂载:`ConfigComponent` +- 读取入口:`scene.Cfg()` +- 示例:`scene.Cfg().TbItem.Get(1)` + +## 注意 + +- 当前 `cfg.Tables` 为占位实现,后续应由 Luban 正式生成结果覆盖。 +- 新增表一律先补 `bean.xml`,再补数据文件。 diff --git a/Doc/ROK-Bag-Spec.md b/Doc/ROK-Bag-Spec.md new file mode 100644 index 0000000..cefb531 --- /dev/null +++ b/Doc/ROK-Bag-Spec.md @@ -0,0 +1,848 @@ +# ROK 背包/道具系统业务规则(1:1 迁移依据) + +> 来源:`E:\Game\gmd\ROK` 服务端 Lua(`ItemLogic.lua` / `Item.lua` proxy)+ 客户端 C#(`BagProxy.cs` / `BagMediator.cs` / `PlayerCmd.cs` / `ItemConfig.cs` / `ItemEnum.lua` / `Common.sproto` / `ErrorCode.cs`) +> 用途:作为 Survivors 工程 `cn.etetet.bag` 包实现的业务依据,**不直接复制源代码**,只摊开业务规则与数据结构 +> 调研时间:2026-05-27 +> 关键约定:表名加 `s_` 前缀(如 `s_Item`)的为服务端 ConfigEntity;客户端对应 `ItemDefine`、`ItemPackageDefine` 等 + +--- + +## 1. 整体业务范围 + +| # | 子模块 | 一句话定位 | +|---|---|---| +| 1 | 通用道具增/删 | `ItemLogic:addItem / delItem / delItemById / checkItemEnough`,所有业务发奖/扣道具的唯一入口 | +| 2 | 道具分类 | 服务端 `Enum.ItemType`(6 类,HEAD 不入背包);客户端 `BagItemType`(5 个 Tab) | +| 3 | 堆叠规则 | 非装备同 `itemId` 合并到同一 `itemIndex`;装备每件独立 `itemIndex`、`overlay=1` | +| 4 | 道具使用 | `Item.lua:response.ItemUse`,按 `s_Item.itemFunction` 分支 if/elseif 处理(**无独立脚本表**) | +| 5 | 资源类道具兑换 | `Item.lua:response.ItemChangeResource`,把"资源类道具"换成等额玩家货币(金币/木料/粮食/VIP/行动力) | +| 6 | 礼包/选包奖励 | `ItemLogic:getGroupPackage / giveReward / getItemPackage` + `s_ItemPackage` + `s_ItemRewardChoice` | +| 7 | 新道具/红点标记 | **服务端不存**,全部用 `PlayerPrefs` 在客户端持久化(按 `rid + itemIndex`) | +| 8 | 头像框解锁 | `Enum.ItemType.HEAD` 类型道具**不进背包**,直接走 `RoleLogic:unlockRoleHead` | +| 9 | 资源货币 | 粮/木/石/金/宝石/行动力/VIP/远征币等**作为 Role 字段**存储,不进 Item 表(详见 §5.7) | +| 10 | 同步推送 | 唯一同步协议 `Item_ItemInfo`(30101)增量推 | +| 11 | 创角初始道具 | `s_Config.initialItemType + initialItemNum` 硬编码 8 个道具 | +| 12 | 资源阈值告警 | `s_GameWarning.num` 单道具数量超阈值时 `Common.sendResourceAlarm` 给运营 | +| 13 | 日志审计 | 每次增删都 `LogLogic:itemChange(rid, iggid, itemId, changeNum, oldNum, newNum, logType, logType2)` | + +> ⚠️ ROK 工程中**没有 `ItemScript.lua` 实质实现**——同名文件只剩 3 个空函数。所有"道具使用脚本"逻辑都在 `Item.lua` proxy 的 `ItemUse` 函数里用 `if/elseif (sitem.itemFunction)` 巨型分支硬编码。 + +--- + +## 2. 道具分类 BagItemType + +### 2.1 服务端 `Enum.ItemType`(`ItemEnum.lua`) + +| 名称 | 值 | 含义 | 是否入背包 | 备注 | +|---|---|---|---|---| +| `RESOURCE` | 1 | 资源(金/木/石/粮/宝石/VIP/行动力类道具袋) | ✅ | 通过 `Item_ItemChangeResource` 兑换玩家货币字段 | +| `SPEED` | 2 | 加速(建筑/训练/研究/治疗/通用 5 类) | ✅ | 在各功能界面用,背包 Tab2 | +| `GAIN` | 3 | 增益(采集加成/护盾/反侦察/部队扩编等) | ✅ | 大多对应 City Buff,Tab3 | +| `EQUIP` | 4 | 装备(武器/头盔/胸甲/手套/裤/鞋/饰品 + 装备材料 + 图纸) | ✅ | 每件独立 itemIndex | +| `OTHER` | 5 | 其他(改名卡、迁城卡、经验书、英雄雕像、钥匙等) | ✅ | 杂项 Tab | +| `HEAD` | 6 | 头像框 | ❌ | 进 `addItem` 后**直接走 `RoleLogic:unlockRoleHead`** 返回空 syncInfo | + +**itemId 编码约定**:客户端 `BagProxy.GetItemTypeById(itemId) = (int)itemId / 100000000`,即 itemId 最高位决定 type。 +- 1xxxxxxxx → RESOURCE(如 `101010001`) +- 2xxxxxxxx → SPEED (如 `201010010`) +- 3xxxxxxxx → GAIN +- 4xxxxxxxx → EQUIP (如 `401010001` 材料、`401020001` 装备) +- 5xxxxxxxx → OTHER (如 `502030004` 改名卡、`502070001` 经验书) + +### 2.2 客户端 `BagItemType`(`BagProxy.cs`) + +| 名称 | 值 | UI Tab | 与服务端关系 | +|---|---|---|---| +| `Resource` | 1 | 资源 | == ItemType.RESOURCE | +| `Speedup` | 2 | 加速 | == ItemType.SPEED | +| `Boost` | 3 | 增益 | == ItemType.GAIN | +| `Equipment` | 4 | 装备 | == ItemType.EQUIP | +| `Other` | 5 | 其他 | == ItemType.OTHER | +| `Icon` | 6 | 不显示 | == ItemType.HEAD(实际客户端 BagPanel 只 5 个 Tab) | + +> `BagMediator` 实测**只显示 5 个 Tab**(1-5),HEAD 类型在 `for i = 1; i < 6` 循环里被略过。 + +### 2.3 `Enum.ItemSubType`(46 个细分子类型,关键值) + +`ItemEnum.lua` 中所有 subType 都是 5 位数: + +| 段位 | 含义 | 示例 | +|---|---|---| +| `101xx` | RESOURCE 子类 | 10101=VIP, 10102=GOLD, 10103=STONE, 10104=WOOD, 10105=GRAIN | +| `201xx` | SPEED 子类 | 20101=全加速, 20102=建筑, 20103=训练, 20104=研究, 20105=治疗 | +| `301xx` | GAIN 子类 | 30101-30111 各种产出/Buff | +| `401xx` | 装备材料 | 40101 羽毛 / 40102 皮革 / 40103 铁石矿 / 40104 兽骨 / 40105 水晶 / 40106 丝绸 / 40107 乌木 | +| `402xx` | 装备本体 | 40201=武器/40202=头盔/40203=胸甲/40204=手套/40205=裤/40206=鞋/40207=饰品 | +| `403xx` | 装备图纸 | 40301-40307 对应 7 个部位 | +| `501xx-509xx` | OTHER 杂项 | 50101=经验书 / 50201=改名卡 / 50202=工人招募 / 50203=迁城 / 50204=钥匙 / 50205=文明更换 / 50206=天赋重置 / 50207=建筑升级 / 50208=行动力 / 50301-50304=升星材料 / 50305=技能材料 / 50901=英雄雕像 | + +### 2.4 每类道具的堆叠/唯一性特征 + +| 类 | 堆叠 | maxStack | 唯一字段 | +|---|---|---|---| +| 装备 (`isEquipItem(subType) == true`,即 40201/40202/40203/40204/40205/40206/40207) | ❌ 每件独立 | 1(强制) | `itemIndex` + `exclusive`(专属英雄)+ `heroId`(穿戴者) | +| 其他全部 | ✅ 无限叠 | **代码中无 maxStack 限制**(`overlay` int 累加) | 只按 itemId 合并到同一 itemIndex | + +> ⚠️ **没有 maxStack 限制**:`addItem` 只在装备分支才循环创建多条;其他都是 `overlay = oldOverlay + itemNum` 直接累加。配置表 `ItemDefine` 也**没有 maxStack 字段**。 + +--- + +## 3. Item 数据模型 + +### 3.1 `.ItemInfo` sproto(`Common.sproto` L308) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `itemIndex` | integer | **背包槽位**,自增正整数,玩家维度唯一(`getFreeItemIndex` 找最小空位) | +| `uniqueIndex` | integer | 唯一索引(实测 Lua 中未使用,可能为后续扩展预留) | +| `itemId` | integer | 道具配置 ID,关联 `s_Item.ID` | +| `overlay` | integer | 叠加数量(装备恒为 1) | +| `exclusive` | integer | 装备专属标志,`0` 不专属;非 0 时表示绑定英雄 ID(影响装备分解和穿戴) | +| `heroId` | integer | 当前穿戴此装备的英雄 ID(0 = 未穿戴) | + +### 3.2 服务端存储(`MSM.d_item[rid]` snax service) + +`MSM.d_item[rid].req` 提供的操作: +- `Get(rid)` → 全部 items dict +- `Get(rid, itemIndex)` → 单条 +- `Get(rid, itemIndex, fields)` → 部分字段 +- `Set(rid, itemIndex, field, value)` → 改单字段 +- `Add(rid, itemIndex, itemInfo)` → 新增 +- `Delete(rid, itemIndex)` → 删除 + +`Enum.Item` 字段名常量:`itemIndex / itemId / overlay / exclusive / heroId`(即 ItemInfo 5 字段) + +### 3.3 客户端缓存(`BagProxy.cs`) + +``` +public Dictionary Items; // itemIndex → 道具实体 +private List m_itemList; // 顺序列表 +private Dictionary m_itemNumMap; // itemId → 累计数量(懒计算,-1 表示需重算) +private Dictionary m_materialItemInfos; // 装备材料分组 +private Dictionary m_equipItemInfos; // 装备本体分组 +private Dictionary> m_reddotRecord; // type → (itemIndex → 新增数量) +private Dictionary m_reddotTotalDic; // type → 红点总数缓存 +``` + +派生类: +- `ItemInfo` 基础:`ItemIndex / ItemID / ItemNum / ItemType / HeroID` +- `EquipItemInfo : ItemInfo` 额外:`Exclusive / Order / Group`(来自 `EquipDefine`) +- `MaterialItem : ItemInfo` 额外:`MaterialDefine` + `MaterialType`(Material / Equip / Drawing / DrawingMaterial) + +### 3.4 新道具标记存储(**纯客户端 PlayerPrefs**) + +Key 格式:`{rid}/itemIndex:{itemIndex}` → int + +| 值 | 含义 | +|---|---| +| 不存在 | 还没在客户端被看到 | +| `1` | 新道具(红点亮) | +| `-1` | 旧道具(已被标记/查看过) | +| `0` | 已清除 | + +特殊情况:装备 `heroId != 0`(已穿戴)→ 强制 `SetLocalItemToOld`,不亮红点。 + +--- + +## 4. 配置表清单 + +### 4.1 `ItemDefine`(`ItemConfig.cs`,对应服务端 `s_Item`) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键,道具 ID(同 itemId) | +| `l_nameID` | int | 道具名称语言包 ID | +| `l_tipsID` | int | tips 道具名称语言包 ID | +| `subType` | int | 子分组标签(`Enum.ItemSubType`,5 位数) | +| `type` | int | 功能分组(`Enum.ItemType`,1-6) | +| `l_typeDes` | int | 功能分组语言包 | +| `typeGroup` | int | 类型组(装备页用,1=Material 2=Equip 3=Drawing 4=DrawingMaterial) | +| `itemIcon` | string | 道具图标资源路径 | +| `lv` | int | 使用等级要求(实测大部分==0) | +| `quality` | int | 品质 1-5(白/绿/蓝/紫/橙,对应 `Enum.ItemQualityType`) | +| `batchUse` | int | 是否可批量使用(`Enum.ItemBatchUse`,0/1) | +| `l_buttonDes` | int | 按钮文字语言包(`<1` 表示无按钮) | +| `itemFunction` | int | **道具功能类型**,对应 `Enum.ItemFunctionType`(见 §4.2) | +| `data1` | int | 功能参数 1(数值,如 VIP 加多少点/兑换比例) | +| `data2` | int | 功能参数 2(关联 ID,如 `s_ItemPackage` group ID / `s_ItemRewardChoice` ID / cityBuff ID) | +| `l_desID` | int | 道具描述语言包(`string.format(text, desData1, desData2)`) | +| `desData1` | int | 描述参数 1 | +| `desData2` | int | 描述参数 2 | +| `l_topID` | int | 图标顶部信息语言包(`<1` 不显示) | +| `topData` | int | 顶部信息参数 | +| `get` | List\ | 获得途径列表,关联 `s_ItemGet` | +| `shortcutPrice` | int | 快捷使用价格(钻石购买后直接使用,用于行动力/VIP 道具) | +| `shopPrice` | int | 商城道具价格 | +| `redDotPrompt` | int | 是否要红点提示(0/1) | +| `rank` | int | 显示排序(背包内同 type 按 rank 升序,rank 相同按 itemId 升序) | + +> ⚠️ **没有 `maxStack` / `bind` / `tradeable` 字段**(与同名 IGG 老项目 `item.json` 形成对比,那个有 `MaxNum / CanTrade / IsDecompose`,但不是 ROK 用的)。 + +### 4.2 `Enum.ItemFunctionType` 枚举(**核心扩展点**) + +| 值 | 名称 | 服务端处理(`Item.lua:ItemUse`) | 客户端处理(`BagMediator.BtnOperate` + `PlayerCmd`) | +|---|---|---|---| +| 0 | NOT_USE | 直接返回 `ITEM_NOT_USE` 错误 | 无按钮 | +| 1 | OPEN_ITEMPACKAGE | 调用 `getItemPackage(data2)` 发奖(`data2` = s_ItemPackage group) | 直接发请求 | +| 2 | CHOOSE_ITEMPACKAGE | 校验 `s_ItemRewardChoice[data2][id]`,按选择 ID 发对应 reward | 弹 `BagGiftOpenView`(ShowType=1)让玩家选 | +| 3 | RECYCLE | 同 OPEN_ITEMPACKAGE(用 data2 兑换包) | 弹 `BagGiftOpenView`(ShowType=2)确认兑换 | +| 4 | CITY_BUFF | `RoleLogic:addCityBuff(data2)` 加 buff | `m_cityBuffProxy.SendUseItem(data2, 1, cb)` | +| 5 | VIP | `RoleLogic:addVip(data1 * itemNum)` | 走 VIP 满级校验后直接发 | +| 6 | ACTION_FORCE | `RoleLogic:addActionForce(data1 * itemNum)` | 直接发 | +| 10 | KINGDOM_MAP | 调 `DenseFogLogic:openNearDenseFog(pos)`,开雾失败则 `getItemPackage(data2)` 补偿 | 检测迷雾,否则弹 `OpenFogShow` 让玩家选位置;返回奖励则弹 `ItemCollection` | +| 12 | SUMMON_MONSTER | `MSM.MonsterSummonMgr.req.summonMonster(data2)` 召唤野怪 | 关闭背包,相机飞向召唤点,播放召唤特效 | +| 13 | SECONDE_QUEUE | 工人小屋:满建造队列则发 `data2` 补偿;否则 `BuildingLogic:unlockQueue(workQueueTime)` | 根据 `result.status` 分别提示成功/延长/补偿 | +| 14 | TRAIN_NUM | 预备部队增容:`itemAddTroopsCapacity = data1`,`itemAddTroopsCapacityCount += itemNum` | 若已有不同档位 `data1` 则二次确认 | +| 35 | LEAGUE_POINTS | 校验入盟后 `GuildLogic:addGuildCurrency(leaguePoints, data1*num)`,返回 `rewardInfo.leaguePoints` | 直接发 | +| 7 | (客户端独有)跳转界面 | — | `SystemOpen.IsCanOpenByUiId(data1)` + `OpenUI2(data1, data2)` | +| 17 | (客户端独有)暂未实现 | — | Tip 提示"暂未开放" | +| 30 | (客户端独有)通用迁城 | — | 弹 `MoveCity` 界面 | +| 31 | (客户端独有)随机迁城 | — | 二次确认后发 `Map_MoveCity{type=4}` | + +> 关键约束:服务端枚举值与客户端的 `BtnOperate` 分支**必须一一对应**,新增 itemFunction 时两边都要改。 + +### 4.3 其他 Item 相关 Define + +| 客户端类 | 服务端表 | 用途 | +|---|---|---| +| `ItemPackageDefine` | `s_ItemPackage` | 奖励组(按 randomGroup 分组随机,含 type/typeData/odds/number/civilization_limit/numberStep_lv/numberStep_increment/numberFloat_min/numberFloat_max) | +| `ItemRewardChoiceDefine` | `s_ItemRewardChoice` | 选包奖励配置(按 data2 关联,每条 `{id, reward}` 表示一个选项对应的 itemPackage group) | +| `ItemHeroDefine` | `s_Hero` 关联 | 英雄相关道具映射(英雄碎片 → 英雄 ID) | +| `ItemGetDefine` | `s_ItemGet` | 获得途径配置(关联 `ItemDefine.get[]`) | +| `ItemPackageShowDefine` | `s_ItemPackageShow` | 礼包展示配置 | +| `ItemPlayerHeadDefine` | `s_ItemPlayerHead` | 头像框配置 | +| `EquipDefine` | `s_Equip` | 装备本体配置(含 makeMaterial / makeMaterialNum / order / group) | +| `EquipMaterialDefine` | `s_EquipMaterial` | 装备材料配置(含 mix / split / mixCostNum 用于合成分解) | + +### 4.4 配置条目数 + +ROK 客户端使用 SQLite/Bin 序列化(`Assets/StreamingAssets/Config/Bin/Item.bin` ≈ 87KB),**没有可读的 item.json**。`gmd/Unity/Assets/Bundles/Config/item.json` 是另一个老项目残留(字段为 `MaxNum / CanTrade / FunctionID` 等,与 ROK 完全不同结构),**不属于本次调研范围**。 + +实际条目数无法从源代码直接计数,但根据 itemId 编码(5 个一级类 × 数百子分类)+ initialItemType 已出现 `502070003`、`201010013` 等量级,**估算总条目数在 200-500 范围**。 + +### 4.5 `ItemPackageType` 奖励类型枚举(`s_ItemPackage.type`) + +| 值 | 名称 | typeData 含义 | +|---|---|---| +| 0 | NONE | 空 | +| 100 | CURRENCY | `Enum.CurrencyType`(food/wood/stone/gold/denar/actionForce/vip/expeditionCoin/individualPoints/leaguePoints/activityActivePoint) | +| 200 | ITEM | `s_Item.ID`(普通道具) | +| 300 | SOLDIER | `s_Arms.ID` | +| 400 | HERO | `s_Hero.ID` | +| 500 | SUB_ITEM_TYPE | `Enum.ZeroEmptyType.SUB_ITEM_TYPE` 关联表 ID(再从子表 Random 出具体 itemId) | +| 600 | GUILD_GIFT | 联盟礼物类型 | + +### 4.6 `CurrencyType`(`OtherEnum.lua` L101,**不属于 Item,是 Role 字段**) + +| 值 | 名称 | 含义 | +|---|---|---| +| 100 | food | 粮食 | +| 101 | wood | 木材 | +| 102 | stone | 石料 | +| 103 | gold | 金币 | +| 104 | denar | 宝石(钻石) | +| 105 | actionForce | 行动力 | +| 106 | individualPoints | 联盟个人积分 | +| 107 | leaguePoints | 联盟积分 | +| 108-111 | allianceFood/Wood/Stone/Gold | 联盟资源 | +| 112+ | vip / expeditionCoin / activityActivePoint | VIP 点 / 远征币 / 活动积分 | + +--- + +## 5. 业务规则详细 + +### 5.1 添加道具 `ItemLogic:addItem(args)` + +**入参** `args` 表:`{rid, itemId, itemNum=1, exclusive=nil, noSync=nil, eventType=nil, eventArg=nil}` + +**流程**(`ItemLogic.lua` L69-164): +1. 查 `s_Item:Get(itemId)`,不存在 → `LOG_ERROR` 返回 nil +2. **特殊分支**:若 `sitemInfo.type == HEAD`,调 `RoleLogic:unlockRoleHead(rid, itemId)` 解锁头像框后**直接返回 `{}`**,不进背包 +3. **是否堆叠**: + - **装备类**(`isEquipItem(subType)` = subType 在 ARMS/HELMET/BREASTPLATE/GLOVES/PANTS/ACCESSORIES/SHOES 中)→ 跳过查找,必新增 + - **其他**:遍历 `getItem(rid)` 查同 `itemId` 的现有 itemIndex +4. **堆叠分支**:找到现有同 itemId → `overlay = oldOverlay + itemNum`,`Set(rid, itemIndex, overlay, newNum)` +5. **新建分支**: + - 非装备:`getFreeItemIndex(rid)` 取最小空 index,新建一条 `{itemId, overlay=itemNum, rid, itemIndex, exclusive, heroId=0}`,`Add(rid, itemIndex, itemInfo)` + - 装备:**循环 itemNum 次**,每次 `getFreeItemIndex` + `overlay=1` 单独建条目(**装备永远不堆叠**) +6. 同步:`syncItem(rid, nil, syncItemInfo, true)` 推 `Item_ItemInfo` +7. 日志:若 `eventType` 非空,调 `LogLogic:itemChange({rid, iggid, itemId, changeNum, oldNum, newNum, logType, logType2})` +8. 资源阈值告警:若 `s_GameWarning:Get(itemId, "num")` 存在且 `newNum > 阈值` → `Common.sendResourceAlarm(rid, itemId, newNum)` + +**返回值**:`syncItemInfo` dict(itemIndex → 新建/更新后的 ItemInfo) + +**触发新道具标记**:服务端**不主动标**,客户端 `BagProxy.UpdateItemInfo` 收到推送时根据 `m_isFirstGetItemInfo == false`(即非首次登录) + `redDotPrompt == 1` + isNewItem 或 overlay 增加,则在 `m_reddotRecord[type][itemIndex]` 写入新增数量。 + +**协议**:`Item_ItemInfo` (30101) 增量推送 + +**错误码**:无(addItem 自身只 LOG_ERROR,不返回错误码) + +--- + +### 5.2 移除道具 + +#### 5.2.1 按 itemIndex 移除:`ItemLogic:delItem(rid, itemIndex, itemNum, noSync, logType, logExtraType)` + +**流程**(`ItemLogic.lua` L167-198): +1. `getItem(rid, itemIndex)` 取道具实体 +2. **数量判定**: + - `overlay <= itemNum` → 整条删除:`MSM.d_item[rid].req.Delete(rid, itemIndex)`,`newNum = 0` + - `overlay > itemNum` → 扣减:`Set(rid, itemIndex, overlay, overlay - itemNum)` +3. 同步推送 `{[Enum.Item.overlay] = newNum}` +4. 日志:`LogLogic:itemChange({rid, iggid, logType, logType2, itemId, changeNum, oldNum, newNum})` + +**返回值**:`syncItems` dict + +> ⚠️ `delItem` **不校验** `itemNum > overlay`,传过量会直接清零(业务方需自己先 `checkItemEnough`)。 + +#### 5.2.2 按 itemId 移除:`ItemLogic:delItemById(rid, itemId, itemNum, noSync, logType, logExtraType)` + +**流程**(`ItemLogic.lua` L241-250):遍历所有 items 找第一个匹配 itemId,转发到 `delItem`。 + +> ⚠️ 业务侧约束:因为非装备同 itemId 必合并在同一 itemIndex,所以只有一条匹配;**装备类不应该用 delItemById**(每个 itemIndex 独立)。 + +#### 5.2.3 数量检查:`ItemLogic:checkItemEnough(rid, itemId, itemNum)` + +**返回**:`(boolean, overlay)`。 +**特点**:因为非装备无限叠加 + 必合并,**只检查第一条匹配的 itemId.overlay 是否 >= itemNum**。 + +**装备移除前是否校验穿戴**:`delItem` 本身**不校验** `heroId != 0`;具体校验由调用方在装备分解逻辑里处理(`Build_DecompositionEquipment` proxy handler 校验,本次未深入展开)。 + +**RPC**:无独立移除 RPC(移除都从其他业务里调用,如使用消耗、装备分解、资源兑换扣道具等) + +**错误码**:无(delItem 不报错);上层有 `ITEM_NOT_ENOUGH(8000)` `ITEM_NOT_EXIST(8002)` + +--- + +### 5.3 通用奖励/消耗结构 + +#### 5.3.1 数据结构 + +| 名称 | 结构 | +|---|---| +| 奖励项(sproto) | `.RewardItem { itemId, itemNum }` | +| 完整奖励信息 | `.RewardInfo { food, wood, stone, gold, denar, items:*RewardItem, soldiers:*SoldierInfo, groupId, actionForce, guildGifts, heros:*Heros, expeditionCoin, guildPoint, vip, leaguePoints, activityActivePoint }` | +| 消耗项(sproto) | `.Items { itemId, itemNum }`(用于商店购买/合成消耗) | + +> 注意:ROK **没有统一的 `CostInfo`**,消耗信息分散在各 RPC 的 request 里(如 `Shop_BuyShopItem.itemId/itemNum`)。 + +#### 5.3.2 批量接口 + +| 函数 | 用途 | +|---|---| +| `ItemLogic:giveReward(rid, rewards, groupId, noSync, noHeroShow, block, isBuyGift, packageNameId)` | **核心发奖入口**,遍历 RewardInfo 各字段调对应 `RoleLogic:add*` / `addItem` / `ArmyTrainLogic:addSoldiers` / `HeroLogic:addHero` | +| `ItemLogic:getItemPackage(rid, groupId, noAdd, noSync, noHeroShow, noMerge, block, isBuyGift, openNum, mergeHero, packageNameId)` | **打开礼包**:调 `getGroupPackage` 计算实际奖励,再调 `giveReward` 发放 | +| `ItemLogic:getGroupPackage(rid, groupId, noMerge, openNum, mergeHero)` | **奖励计算**:按 randomGroup 加权随机 + 等级增量 + 浮动比例,输出合并后的 reward 结构 | +| `ItemLogic:mergeReward(rawReward, addReward)` | **奖励合并**:把两个 RewardInfo 累加(货币求和,items/soldiers/heros 按 ID 合并) | +| `ItemLogic:checkItemEnough(rid, itemId, itemNum)` | 单道具数量校验 | + +#### 5.3.3 事务性 + +**ROK 没有事务**。`giveReward` 遍历各字段顺序发放,任一中途失败: +- 货币 `add*` 失败:仅 LOG_ERROR,已发的不会回滚 +- `addItem` 失败:同上 +- **业务方需保证 reward 结构合法**(数量 > 0 / itemId 存在),失败属于配置错误 + +业务实现"先检查再扣除"模式:典型如 `Shop_BuyShopItem`: +1. `checkItemEnough(rid, costItemId, costNum)` → false 返回 `ITEM_NOT_ENOUGH` +2. `delItemById(rid, costItemId, costNum)` 扣除 +3. `addItem(rid, productItemId, productNum)` 发放 + +**约束**:检查/扣除/发放之间**没有锁**(单线程 Lua 协程模型,actor 内串行执行天然安全;跨 actor 调用需 RPC 等待) + +--- + +### 5.4 道具使用 `Item.lua:response.ItemUse(msg)` + +#### 5.4.1 触发流程(客户端) + +``` +玩家点击道具 → BagMediator.RefreshItemDetail 显示按钮 + ↓ 玩家点"使用" +BagMediator.BtnOperate 按 itemFunction 分支: + ├─ itemFunction == 2 → 弹 BagGiftOpenView(选包),用户选完调 Send(itemIndex, num, selectId) + ├─ itemFunction == 3 → 弹 BagGiftOpenView(兑换确认),用户确认后 Send(itemIndex, num, 0) + ├─ itemFunction == 4 → cityBuffProxy.SendUseItem(data2) → Send(itemIndex, 1, 0) + ├─ itemFunction == 10 → 检测迷雾,否则弹 OpenFogShow 选坐标后发请求(含 pos) + ├─ itemFunction == 14 → 若已有不同档位预备部队 buff,二次确认 + ├─ itemFunction == 31 → 二次确认后发 Map_MoveCity(非 Item_ItemUse) + └─ 其他 → Send(itemIndex, num, 0) → 发 Item_ItemUse.request +``` + +`Send` 实现: + +```csharp +var sp = new Item_ItemUse.request { + itemIndex = itemIndex, + itemNum = itemNum, + id = id, // >0 时才赋值,给 CHOOSE_ITEMPACKAGE 选包用 +}; +AppFacade.GetInstance().SendSproto(sp); +``` + +#### 5.4.2 服务端校验流程 + +`Item.lua:response.ItemUse` 完整流程(L82-221): + +1. **参数校验**:`itemIndex` / `itemNum` 必填 → 否则 `ITEM_ARG_ERROR` +2. **道具存在**:`getItem(rid, itemIndex)` → 空则 `ITEM_NOT_EXIST` +3. **可用性**:`sitem.itemFunction == NOT_USE(0)` → `ITEM_NOT_USE` +4. **批量限制**:`itemNum > 1 && sitem.batchUse == NO(0)` → `ITEM_NOT_BATCH_USE` +5. **业务前置**: + - LEAGUE_POINTS:未入盟 → `ITEM_NOT_JOIN_GUILD` +6. **数量足够**:`itemInfo.overlay < itemNum` → `ITEM_NOT_ENOUGH` +7. **召唤怪物分支**:SUMMON_MONSTER 时先 `MonsterSummonMgr.req.summonMonster` 校验成功,失败 → `ITEM_SOMMON_MONSTER_FAILED` +8. **选包校验**:CHOOSE_ITEMPACKAGE 时 `s_ItemRewardChoice[data2][id]` 不存在 → `ITEM_PACKAGEID_NOT_EXIST` +9. **扣除道具**:`delItem(rid, itemIndex, itemNum, nil, USE_BAG_ITEM_COST_ITEM)` +10. **任务进度**:`TaskLogic:updateItemUseTaskSchedule(rid, nil, itemNum, sitem)` +11. **按 itemFunction 分发**: + - OPEN_ITEMPACKAGE / RECYCLE → `rewardId = data2` + - CHOOSE_ITEMPACKAGE → `rewardId = s_ItemRewardChoice[data2][id].reward` + - CITY_BUFF → `addCityBuff(data2)` 直接返回 + - VIP / ACTION_FORCE / LEAGUE_POINTS → 调 `add*` 直接返回 + - KINGDOM_MAP → `openNearDenseFog(pos)`,开雾失败则 `getItemPackage(data2)` 补偿,含 rewardInfo 返回 + - SECONDE_QUEUE → 满队列返补偿包,否则 `unlockQueue` + - TRAIN_NUM → 修改 `itemAddTroopsCapacity` + `Count`,syncSelf 推送 +12. **最终发奖**:若 `rewardId > 0` → `getItemPackage(rid, rewardId, openNum=itemNum)` 发放 +13. **响应**:`{itemId, itemNum, rewardInfo, objectIndex(召怪用), pos(召怪位置), status(队列状态)}` + +#### 5.4.3 选择数量使用 / 选择目标使用 + +| 模式 | 实现 | +|---|---| +| **批量数量** | request.itemNum 字段;服务端 `batchUse == YES` 时支持;`getItemPackage` 内部 `openNum = itemNum` 表示开 N 次抽奖 | +| **选包 ID** | request.id 字段;只在 CHOOSE_ITEMPACKAGE 时传,对应 `s_ItemRewardChoice[data2][id].reward` 之 itemPackage group | +| **选择坐标** | request.pos 字段;只用于 KINGDOM_MAP(开迷雾选位置) | +| **选择英雄** | **不支持**——经验书等给英雄加经验的道具走的是 `Hero_ExchangeHeroItem`(602)等独立 RPC,**不走 ItemUse** | + +#### 5.4.4 涉及 RPC + +| RPC | ID | request | response | +|---|---|---|---| +| `Item_ItemUse` | 852 | itemIndex, itemNum, id(选包), pos(王国地图) | itemId, itemNum, rewardInfo, status, objectIndex, pos | + +#### 5.4.5 错误码 + +| 错误码 | 值 | 含义 | +|---|---|---| +| `ITEM_NOT_ENOUGH` | 8000 | 道具不足 | +| `ITEM_ARG_ERROR` | 8001 | 参数错误 | +| `ITEM_NOT_EXIST` | 8002 | 道具不存在 | +| `ITEM_NOT_BATCH_USE` | 8004 | 不支持批量使用 | +| `ITEM_NOT_USE` | 8005 | 不可使用 | +| `ITEM_PACKAGEID_NOT_EXIST` | 8006 | 所选礼包组不存在 | +| `ITEM_KINGDOM_MAP_NO_POS` | 8007 | 王国地图未传坐标(实测代码中未使用,预留) | +| `ITEM_NOT_JOIN_GUILD` | 8008 | 未加入联盟 | +| `ITEM_SOMMON_MONSTER_FAILED` | 8009 | 召怪失败(周围乱军太多) | + +--- + +### 5.5 道具"脚本"机制(**实际上没有独立脚本表**) + +#### 5.5.1 真实实现 + +`ItemScript.lua` 仅有 3 个空函数(`requireAllScript / useItem / dropItem`),**没有任何注册机制**。所有"脚本"逻辑都是 `Item.lua:ItemUse` 函数内的 `if/elseif (sitem.itemFunction)` 巨型分支。 + +**没有的东西**: +- ❌ 没有 itemId → Lua 函数的映射表 +- ❌ 没有动态加载脚本 +- ❌ 没有 `s_ItemScript` 配置表 + +**有的东西**: +- ✅ `ItemDefine.itemFunction`(int 枚举)一一对应硬编码 if 分支 +- ✅ `ItemDefine.data1 / data2` 作为该分支的参数 + +#### 5.5.2 "脚本类型"清单(即 §4.2 ItemFunctionType 表) + +按业务能力归类: +| 业务能力 | itemFunction | 实现位置 | +|---|---|---| +| 直接发奖励组 | 1 / 3 | `getItemPackage(data2)` | +| 选包奖励 | 2 | `s_ItemRewardChoice[data2][id].reward` → `getItemPackage` | +| 时效 Buff | 4 (CITY_BUFF) | `RoleLogic:addCityBuff(data2)` | +| 加 VIP / 行动力 | 5 / 6 | `RoleLogic:addVip / addActionForce` | +| 解锁迷雾 | 10 | `DenseFogLogic:openNearDenseFog(pos)` | +| 召唤野怪 | 12 | `MonsterSummonMgr.req.summonMonster(data2)` | +| 工人小屋扩展 | 13 | `BuildingLogic:unlockQueue` | +| 预备部队增容 | 14 | 修改 Role 字段 | +| 联盟积分 | 35 | `GuildLogic:addGuildCurrency(leaguePoints)` | + +#### 5.5.3 选包奖励的客户端选择流程 + +``` +点击使用 → BagMediator.BtnOperate 检测 itemFunction == 2 + → 弹 BagGiftOpenView (ShowType=1) + → m_rewardGroupProxy.GetChoiceRewardDataByGroup(define.data2) + // 查 s_ItemRewardChoice[data2] 列表,每条 {id, reward(=itemPackage group)} + → 用户点击某选项 → m_selectId = data.id + → 点确定 → Item_ItemUse.request { itemIndex, itemNum, id = m_selectId } + → 服务端按 id 查到具体 reward group → getItemPackage 发放 +``` + +--- + +### 5.6 新道具标记 / 红点 + +#### 5.6.1 服务端/客户端分工 + +- **服务端**:**完全不维护新道具状态**,只负责增量推 `Item_ItemInfo` +- **客户端**:完全在 `BagProxy` 中维护: + - 持久化:`PlayerPrefs` 用 `{rid}/itemIndex:{itemIndex}` 存"新/旧/已清"三态 + - 内存:`m_reddotRecord[type][itemIndex] = 新增数量` + `m_reddotTotalDic[type] = 红点总数` + +#### 5.6.2 触发时机(`BagProxy.UpdateItemInfo` + `ReddotRecord`) + +1. **首次登录**(`m_isFirstGetItemInfo == true`):**全部不算新道具**(避免登录时所有道具都亮红点),并强制 `SetLocalItemToOld` 把所有 itemIndex 标为 -1 +2. **非首次**:服务端推来一条 ItemInfo,且 `ItemDefine.redDotPrompt == 1`,且: + - 新增 `itemIndex` → 红点+=overlay + - 同 `itemIndex` overlay 增加 → 红点+=增量 + - 同 `itemIndex` overlay 减少 → 移除红点记录 +3. **装备已穿戴**(`heroId != 0`):直接 `SetLocalItemToOld`,不亮 + +#### 5.6.3 清除时机 + +| 函数 | 时机 | +|---|---| +| `ClearReddotRecordByIndex(index, itemId)` | 用户在 BagPanel 选中该道具看详情时(`RefreshItemDetail` 内调) | +| `ClearReddotRecordByType(type)` | 用户切换 Bag Tab 时(`SwitchMenu` 内对原 type 调) | +| `ClearAllReddotRecord()` | BagPanel 关闭时(`BagMediator.OnRemove`) | + +> ⚠️ 设计后果:**只要打开过背包并选了道具,红点就消**。不需要服务端记账。 + +#### 5.6.4 红点数量统计 + +`GetBagReddotTotal()` = sum over type `GetBagReddotNumByType(type)` = sum over reddotRecord[type][index] + +--- + +### 5.7 资源类道具(金币 / 钻石 / 等) + +#### 5.7.1 双轨制设计(**关键决策点**) + +ROK 采取**双轨制**: + +| 维度 | 玩家货币(Role 字段) | 资源类道具(Bag Item) | +|---|---|---| +| 存储位置 | `Role.food / wood / stone / gold / denar / vip / actionForce / expeditionCoin / individualPoints / leaguePoints / activityActivePoint` | `s_Item` 配置 + 背包 Item 条目 | +| 操作接口 | `RoleLogic:addGold / addStone / addFood / addVip / addActionForce …` | `ItemLogic:addItem / delItem` | +| 同步协议 | `Role_RoleInfo` 推送 | `Item_ItemInfo` 推送 | +| 上限 | 单字段 int64,配置表内有 individualPointsLimit/alliancePointsLimit 等业务上限 | `s_GameWarning.num` 仅做告警,**无硬上限** | +| 红点 | 无 | 有(按 type 统计) | +| 例子 | 玩家身上的"500 金币"是 Role.gold 字段 | 背包里"金币袋 x3(每袋可换 1000 金币)"是 itemId 在 RESOURCE 类下的 Item | + +#### 5.7.2 资源类道具如何转成货币 + +`Item_ItemChangeResource`(851): +``` +request: { itemIndex, itemNum } +→ getItem(rid, itemIndex) 取 itemInfo +→ 校验 s_Item.type == RESOURCE 或 subType == ACTION_FORCE → 否则 ITEM_NOT_RESOURCE_ITEM +→ checkOverlay >= itemNum → 否则 ITEM_NOT_ENOUGH +→ delItem 扣除 +→ 按 subType 分支调对应 RoleLogic:add*: + - GOLD → addGold(data1 * itemNum) + - STONE → addStone + - WOOD → addWood + - GRAIN → addFood + - VIP → addVip + - ACTION_FORCE → addActionForce +→ updateItemUseTaskSchedule +response: { result, itemId, itemNum } +``` + +> ⚠️ **没有 DENAR(钻石)/ DIAMOND 这条分支**——钻石不能通过道具兑换得来(设计上钻石是付费货币)。 + +#### 5.7.3 资源大额变动审计 + +- 全部走 `LogLogic:itemChange` / `LogLogic:roleChange`(每个 RoleLogic:add* 内部都会调) +- 阈值告警:`s_GameWarning.num` 配置单 itemId 阈值 → `Common.sendResourceAlarm(rid, itemId, newNum)` 发给监控 + +--- + +### 5.8 初始资源发放 + +`ItemLogic:createRoleGiveItems(rid)`(L49-66): + +``` +读取 s_Config:Get("initialItemType") 和 ("initialItemNum") +两个 list 长度必须一致(不一致以小的为准) +循环调 addItem(rid, itemId, itemNum, eventType=GUILD_CREATE_ROLE_GAIN_ITEM) +``` + +**实际配置值**(`Server/common/config/gen/Configs.data` L13376 `s_Config[0]`): + +``` +initialItemType = { 502030004, 502010001, 502020001, 502040002, 201010010, 201010013, 502070001, 502070003 } +initialItemNum = { 8, 1, 1, 1, 30, 8, 10, 10 } + +initialFood = 100000 +initialWood = 100000 +initialStone = 0 +initialGold = 0 +initialDiamond = 300 // 钻石(denar) +initialArmsType = { 10101 } // 士兵类型 +initialArmsNum = { 1000 } // 士兵数 +initialBuff = 30105 // 初始 City Buff(治疗加速) +``` + +- 道具初始有 8 种 × 各若干(共 69 个道具实例) +- 货币初始:粮 10W、木 10W、钻 300,金/石/行动力默认 0 +- 士兵初始:10101 类型 1000 个 + +> 资源发放走 `RoleLogic:createRole`(或 `setRole` 直接赋值字段),不走 `addItem`。 + +--- + +## 6. 协议清单 + +### 6.1 RPC 协议(请求-响应) + +| 协议名 | ID | 方向 | request | response | 用途 | +|---|---|---|---|---|---| +| `Item_ItemChangeResource` | 851 | C→S | `itemIndex, itemNum` | `result, itemId, itemNum` | 使用资源类道具兑换玩家货币(金/木/石/粮/VIP/行动力) | +| `Item_ItemUse` | 852 | C→S | `itemIndex, itemNum, id, pos` | `itemId, itemNum, rewardInfo, status, objectIndex, pos` | 使用任意可用道具(按 itemFunction 分发) | + +### 6.2 推送协议(服务端 → 客户端) + +| 协议名 | ID | 方向 | request | 用途 | +|---|---|---|---|---| +| `Item_ItemInfo` | 30101 | S→C | `itemInfo : *ItemInfo(itemIndex)` | 增量推送道具变更(新增/数量变更/删除),客户端按 `itemIndex` 合并到本地 `Items` dict | + +### 6.3 间接相关协议(涉及道具但不属于 Item 模块) + +| 协议 | 用途 | +|---|---| +| `Shop_BuyShopItem (901)` | 普通商店购买,含 itemId/itemNum | +| `Shop_BuyPostItem (902)` | 驿站道具 | +| `Shop_BuyVipStore (904)` | VIP 商店购买 | +| `Shop_BuyExpeditionStore (905)` | 远征商店购买 | +| `Build_ProduceMaterial / Build_MaterialDecomposition / Build_MaterialSynthesis / Build_MakeEquipment / Build_DecompositionEquipment / Build_CheckMakeEquip` | 装备/材料生产/合成/分解 | +| `Hero_ExchangeHeroItem (605)` | 英雄碎片兑换 | +| `Role_RoleInfo (30251)` | 含 RoleInfo(货币字段 food/wood/stone/gold/denar 等) | + +--- + +## 7. 错误码清单(`ITEM_` 前缀) + +| 错误码 | 值 | 含义 | +|---|---|---| +| `ITEM_NOT_ENOUGH` | 8000 | 道具不足 | +| `ITEM_ARG_ERROR` | 8001 | 道具模块参数错误 | +| `ITEM_NOT_EXIST` | 8002 | 道具不存在 | +| `ITEM_NOT_RESOURCE_ITEM` | 8003 | 不是资源类型道具(ChangeResource 用) | +| `ITEM_NOT_BATCH_USE` | 8004 | 道具不支持批量使用 | +| `ITEM_NOT_USE` | 8005 | 道具无法使用(itemFunction=0 或前置不满足) | +| `ITEM_PACKAGEID_NOT_EXIST` | 8006 | 所选道具礼包组不存在 | +| `ITEM_KINGDOM_MAP_NO_POS` | 8007 | 使用王国地图未上传坐标(代码中预留,未实际触发) | +| `ITEM_NOT_JOIN_GUILD` | 8008 | 未加入任何联盟(用 LEAGUE_POINTS 道具时) | +| `ITEM_SOMMON_MONSTER_FAILED` | 8009 | 召唤怪物失败 | + +### 7.1 关联模块道具相关错误码(散落在其他模块前缀) + +| 错误码 | 值 | 含义 | +|---|---|---| +| `ROLE_NAME_ITEM_DENAR_NOT_ENOUGH` | 1046 | 改名道具+钻石都不足 | +| `ROLE_BUFF_ITEM_NOT_ENOUGH` | 1048 | buff 道具不足 | +| `ROLE_SPEED_ITEM_NOT_ENOUGH` | 1066 | 加速道具不足 | +| `MAP_MOVE_CITY_ITEM_NOT_ENOUGH` | 2030 | 迁城道具不足 | +| `HERO_SUMMON_ITEM_NOT_ENOUGH` | 3001 | 召唤统帅道具不足 | +| `HERO_LEVEL_ITEM_NOT_ENOUGH` | 3005 | 升级技能道具不足 | +| `HERO_EXCHANGE_ITEM_NOT_ENOUGH` | 3010 | 通用雕像不足 | +| `HERO_ITEM_ERROR` | 3015 | 升星材料类型错误 | +| `HERO_ITEM_TO_MUCH` | 3016 | 道具不足无法升星 | +| `BUILDING_ITEM_ERROR` | 6021 | 解锁建筑道具类型错误 | +| `BUILDING_SMITHY_ITEM_ERROR` | 6024 | 无法生产该道具 | +| `BUILDING_SMITHY_ITEM_NOT_ENOUGH` | 6027 | 该材料不足 | +| `BUILDING_EQUIP_ITEM_NO_ENOUGH` | 6030 | 装备道具不足 | +| `SHOP_ITEM_NOT_SELL` | 10000 | 商品没在商店售卖 | +| `SHOP_POST_ITEM_NOT_EXIST` | 10001 | 驿站道具不存在 | +| `SHOP_POST_ITEM_HAVE_BUY` | 10002 | 驿站道具已购买 | +| `SHOP_EXPEDITION_ITEM_COUNT_MAX` | 10007 | 远征商店购买超上限 | +| `GUILD_SHOP_ITEM_NOT_EXIST` | 12073 | 联盟商店商品不存在 | +| `GUILD_SHOP_ITEM_NOT_ENOUGH` | 12074 | 联盟商店商品不足 | + +> ⚠️ **`BAG_` 前缀错误码不存在**——背包功能复用 `ITEM_` 前缀。 + +--- + +## 8. UI 流程(`BagMediator` / `BagGiftOpenMediator` / `UI_Win_UseItemMediator`) + +### 8.1 BagPanel 分页 + +- **5 个 Tab**:资源(1) / 加速(2) / 增益(3) / 装备(4) / 其他(5) +- Tab 切换:`OnMenuRes/Speed/Gain/Equip/Other` → `SwitchMenu(type)` → 切 Tab 时**自动清除当前 Tab 的红点记录** +- 每个 Tab 显示:4 列 × N 行(`m_itemCol = 4`,`m_itemLineCount = ceil(count/4)`) +- 数据源:`m_bagProxy.Items` 全部过滤 `overlay > 0`,按 `type = itemId / 100000000` 分组 +- Tab 角标红点数:`m_pageReddotImgList[i]` + `GetBagReddotNumByType(i+1)` + +### 8.2 排序规则 + +`DataSort` 在每个 Tab 数据列表上: +1. 按 `ItemDefine.rank` 升序 +2. rank 相同则按 itemId 升序 + +### 8.3 道具详情 Tooltip 显示内容 + +`RefreshItemDetail` 渲染: +- 品质背景图(`m_qualityDic[quality]`,5 档) +- 道具图标(`itemDefine.itemIcon`) +- 顶部小卡描述(仅 `l_topID >= 1` 时显示,格式 `format(text, topData)`) +- 名称(`l_nameID`) +- **装备**(`typeGroup == 2`)→ 显示 `UI_Item_EquipAtt` 属性卡片 +- **非装备** → 显示 `l_desID` 格式化文本 `format(text, desData1, desData2)` +- **可批量使用**(`batchUse == 1`)→ 显示 `+/-` 数量调整 + 输入框 + Max 按钮 +- **使用按钮**:仅 `l_buttonDes >= 1` 时显示(文字来自语言包) + +### 8.4 使用流程的二次确认 + +| 情况 | 是否二次确认 | +|---|---| +| 普通礼包(itemFunction=1) | 无确认,直接发请求 | +| 选包奖励(itemFunction=2) | 弹 `BagGiftOpenView` 必选,本身就是确认界面 | +| 兑换确认(itemFunction=3) | 弹 `BagGiftOpenView` 显示前后对比 | +| 王国地图(itemFunction=10) | 选坐标本身就是确认 | +| 预备部队(itemFunction=14) | 若已有不同档位 buff 弹 `Alert` 二次确认 | +| VIP 道具(UseItemView) | 计算 overflow,超出时弹 `Alert` 二次确认 | +| 随机迁城(itemFunction=31) | 强制弹 `Alert` 二次确认 | +| 其他 | 无 | + +### 8.5 ItemUse 响应处理(`PlayerCmd.cs:Item_ItemUse.TagName`) + +1. 错误响应 → `ErrorCodeHelper.ShowErrorCodeTip` +2. 成功:`Tip.CreateTip("使用了 {道具名} x{数量}")` +3. 按 `itemFunction` 分发后续 UI: + - 10/13 → 弹 `s_itemCollection` 显示奖励 + - 12 → 相机飞向召唤点 + 召唤特效 + - 其他 → 若 `HasRewardInfo` 弹 `s_rewardGetWin` 奖励展示 + +--- + +## 9. 与 Survivors 实现的对照点 + +### 9.1 模块对应表 + +| ROK 模块 | Survivors 对应(计划) | 说明 | +|---|---|---| +| `BagProxy.cs` + 客户端缓存 | `BagComponent`(挂玩家 Entity) + `BagComponentSystem` | ET 风格的 Component/System,业务逻辑放 System | +| `ItemLogic.lua:addItem/delItem` | `BagComponentSystem.AddItems / RemoveItems` | 统一入口,但 **Survivors 设计是"所有奖励都走 Item"**,比 ROK 更激进 | +| `Item.lua:ItemUse` 巨型 if/elseif | C# `Dictionary` 注册机制 | 比 Lua 硬编码更可扩展,便于单测 | +| `s_Item` + `ItemDefine` | Luban 配置 `ItemConfig` | 字段保留 quality/type/subType/itemFunction/data1/data2,删掉 ROK 残留(`exclusive` 改名 `bindType` 等) | +| `Item_ItemInfo` 增量推送 | `M2C_ItemInfoSync`(protobuf) | 用 MemoryPack 序列化 | +| `Item_ItemUse` (852) | `C2M_UseItem` / `M2C_UseItemResponse` | RPC + 推送分离 | +| `BagPanel.prefab`(5 Tab) | `BagPanel.prefab`(YIUI,4 Tab:资源/装备/道具/材料) | Survivors 设计简化成 4 Tab | +| `BagGiftOpenView`(选包) | `GiftOpenPanel` | 同流程 | +| 红点 PlayerPrefs | ET `RedDotComponent` + 持久化到 Mongo | 服务端记账,多端同步 | +| 创角初始道具 `initialItemType/Num` | `InitialResourceConfig`(Luban) | 列表配置,启动时遍历 AddItems | +| 错误码 8000-8009 | `ErrorCode.cs` 同段位 | 直接复用 8000-8009 | + +### 9.2 关键差异决策 + +| 维度 | ROK | Survivors 设计 | +|---|---|---| +| **货币/道具是否合并** | **分离**(双轨制) | **合并**(所有可获得资源 = Item,金币也是 itemId) | +| **使用脚本机制** | 硬编码 if/elseif(itemFunction 枚举) | C# `IItemUseHandler` 注册(每个 itemFunction 一个 handler 类) | +| **新道具红点** | 客户端 PlayerPrefs | 服务端 Mongo + 客户端缓存 | +| **配置表** | SQLite + 自研 Define | Luban 生成 | +| **协议** | sproto | protobuf3 + MemoryPack | +| **持久化** | 服务端 Redis + 落 SQL | Mongo(IDBComponent) | +| **装备穿戴** | item.heroId 字段 | 同 | +| **奖励合并** | `mergeReward` Lua | C# 通用 RewardEntry 列表 | + +### 9.3 设计陷阱(迁移时易踩) + +1. **不要直接搬"双轨制"**:Survivors 设计目标是统一 Item。金币/钻石如果也作为 Item,则没有 `Item_ItemChangeResource` 这个 RPC,相关 `ItemType.RESOURCE` 子类型大量道具变成"普通礼包道具" +2. **`exclusive` 字段**:ROK 是装备专属英雄 ID,Survivors 设计中可能不需要装备绑定,可去除 +3. **`uniqueIndex` 字段**:ROK 中预留未用,Survivors 直接删掉 +4. **`maxStack` 缺失**:必须新增(防溢出 int 攻击) +5. **`itemFunction` 拆分**:ROK 的 35 个值分散且历史包袱重,Survivors 应只实现 P1-P3 核心 5-8 个 +6. **`itemId` 编码约定**:ROK 用 `itemId / 100000000` 划分 type,Survivors 应改为 ItemConfig 显式 type 字段(itemId 不再有语义) + +--- + +## 10. 实现优先级建议 + +### P1 必须(核心基础设施,无 UI) +| # | 任务 | 对应 ROK | 备注 | +|---|---|---|---| +| 1 | `ItemConfig` Luban 配置定义(含 type/subType/quality/itemFunction/data1/data2/icon/name/desc/maxStack) | `s_Item` + `ItemDefine` | 删 exclusive/uniqueIndex,加 maxStack | +| 2 | `BagComponent`(玩家身上挂) + `ItemUnit`(子 Entity,itemIndex 主键) | `MSM.d_item[rid]` | Mongo 持久化 | +| 3 | `BagComponentSystem.AddItems / RemoveItems / HasItems / GetItemNum` | `addItem / delItem / checkItemEnough` | **统一发奖/扣资源入口**,所有业务都调它 | +| 4 | `M2C_ItemInfoSync` 增量推送 | `Item_ItemInfo` (30101) | 客户端 `BagComponentClient` 合并 | +| 5 | `RewardEntry { itemId, count }` 通用结构 | `.RewardItem` | 一切奖励/消耗都用它 | +| 6 | `InitialResourceConfig` + 创角发放 | `s_Config.initialItemType/Num` | Login 时调 | +| 7 | 错误码 8000-8009 | ErrorCode.cs | 直接复用 | + +### P2 必须(道具使用 + 核心 itemFunction) +| # | 任务 | 对应 ROK | 备注 | +|---|---|---|---| +| 1 | `C2M_UseItem` Handler | `Item.lua:ItemUse` | 校验/扣道具/调 handler/返回结果 | +| 2 | `IItemUseHandler` 注册机制 + `ItemUseHandlerComponent` | 巨型 if/elseif | C# 字典查找替代硬编码 | +| 3 | `ItemUseHandler_OpenPackage`(itemFunction=1) | OPEN_ITEMPACKAGE | 调 `ItemPackageSystem.GetReward` | +| 4 | `ItemUseHandler_ChoosePackage`(itemFunction=2) | CHOOSE_ITEMPACKAGE | 含 request.id 参数 | +| 5 | `ItemPackageConfig` + `ItemPackageSystem.GetReward(groupId, openNum)` | `s_ItemPackage` + `getItemPackage` | 加权随机 | +| 6 | `ItemRewardChoiceConfig` | `s_ItemRewardChoice` | 选包配置 | +| 7 | 操作日志 `LogLogic:itemChange` 等效 | `LogLogic` | jsonl 落盘 | + +### P3 可推迟(业务扩展类 itemFunction) +| # | 任务 | 备注 | +|---|---|---| +| 1 | itemFunction=5/6 VIP/行动力(**如果 Survivors 把它们作为 Item**,则这俩 handler 不需要,直接发 itemId) | | +| 2 | itemFunction=4 CityBuff(如果 Survivors 有 buff 系统才做) | | +| 3 | itemFunction=10 解锁迷雾(Survivors 无大地图,**跳过**) | | +| 4 | itemFunction=12 召唤怪物(**跳过**) | | +| 5 | itemFunction=13/14 工人/部队(SLG 专有,**跳过**) | | +| 6 | itemFunction=30/31 迁城(**跳过**) | | +| 7 | itemFunction=35 联盟积分(无联盟,**跳过**) | | + +### P4 UI 集中实现 +| # | 任务 | 对应 ROK | +|---|---|---| +| 1 | `BagPanel`(4 Tab:资源/装备/道具/材料) | `BagView` | +| 2 | `BagItemUICommon` 通用道具显示组件 | `UI_Item_Bag` + `UI_Model_Item` | +| 3 | `BagItemTooltip` 道具详情浮层 | `RefreshItemDetail` 内的详情面板 | +| 4 | `GiftOpenPanel` 选包/兑换 UI | `BagGiftOpenView` | +| 5 | `RewardGetPanel` 奖励展示弹窗 | `s_rewardGetWin` | +| 6 | Tab 红点 | `m_pageReddotImgList` | + +### P5 后续完善 +| # | 任务 | +|---|---| +| 1 | 资源阈值告警(`GameWarning` 配置 + Alarm) | +| 2 | 大额变动审计日志 | +| 3 | 装备分解/合成(如果做装备系统) | +| 4 | 装备图纸(如果做装备系统) | + +--- + +## 附录 A:ROK 关键源代码位置索引 + +| 内容 | 路径 | +|---|---| +| 服务端 ItemLogic(addItem/delItem/giveReward/getItemPackage) | `Server/server/game_server/logic/lualib/ItemLogic.lua` | +| 服务端 Item proxy(ItemUse/ItemChangeResource RPC handler) | `Server/server/game_server/logic/service/proxy/Item.lua` | +| 服务端 Item 数据 service | `Server/server/game_server/logic/service/data/d_item.lua`(snax 服务,本次未深入) | +| 道具枚举(ItemType/SubType/FunctionType/PackageType/BatchUse) | `Server/common/lualib/enum/ItemEnum.lua` | +| CurrencyType 枚举(货币不是 Item) | `Server/common/lualib/enum/OtherEnum.lua` L101 | +| ItemScript(**空壳**,无实质内容) | `Server/server/game_server/logic/lualib/itemscript/ItemScript.lua` | +| Common.sproto(ItemInfo / RewardInfo / RewardItem) | `Server/common/protocol/Common.sproto` L308 / L381 / L362 | +| Protocol.sproto(Item_ItemUse 852 / Item_ItemChangeResource 851 / Item_ItemInfo 30101) | `Server/common/protocol/Protocol.sproto` | +| 服务端 s_Config 初始道具配置(initialItemType/Num) | `Server/common/config/gen/Configs.data` L13376(id=0 行) | +| 客户端 BagProxy(增量同步/红点/装备/材料) | `Client/Assets/Scripts/Hotfix/MVC/Proxy/BagProxy.cs` | +| 客户端 BagMediator(5 Tab + 使用按钮分支) | `Client/Assets/Scripts/Hotfix/MVC/View_Mediator/Bag/BagMediator.cs` | +| 客户端 BagGiftOpenMediator(选包/兑换 UI) | `Client/Assets/Scripts/Hotfix/MVC/View_Mediator/Bag/BagGiftOpenMediator.cs` | +| 客户端 UI_Win_UseItemMediator(行动力/VIP 快捷使用) | `Client/Assets/Scripts/Hotfix/MVC/View_Mediator/UseItem/UI_Win_UseItemMediator.cs` | +| 客户端 PlayerCmd.cs(Item_ItemUse 响应处理) | `Client/Assets/Scripts/Hotfix/MVC/CMD/PlayerCmd.cs` L38-128 | +| 客户端 ItemDefine(25 字段) | `Client/Assets/Scripts/Hotfix/Config/ItemConfig.cs` | +| 客户端 ItemPackageConfig / ItemRewardChoiceConfig 等 | `Client/Assets/Scripts/Hotfix/Config/Item*Config.cs`(7 个) | +| 错误码 ITEM_ 前缀(8000-8009) | `Client/Assets/Scripts/Hotfix/MVC/CMD/ErrorCode.cs` L255-264 | +| 客户端配置 Bin 文件 | `Client/Assets/StreamingAssets/Config/Bin/Item.bin`(87 KB) | + +## 附录 B:未深入调研的边角 + +- `s_Item.lua` 的 ConfigEntity 加载机制(只看了 stub,没看 ConfigEntity 父类) +- `MSM.d_item[rid]` snax 服务实现细节 +- `Build_*` 装备制造/合成 RPC 详细参数(涉及 EquipDefine + EquipMaterialDefine 联动) +- `Hero_ExchangeHeroItem` 等英雄相关道具用法(属于 Hero 模块) +- 钻石(denar)的付费充值入口(RechargeLogic,属于充值模块) +- 联盟礼物 `guildGifts` 在奖励组中的具体发放机制(属于 Guild 模块) +- `LogLogic:itemChange` 的具体日志格式(输出到 log_server) diff --git a/Doc/ROK-Config-Source.md b/Doc/ROK-Config-Source.md new file mode 100644 index 0000000..fc95df8 --- /dev/null +++ b/Doc/ROK-Config-Source.md @@ -0,0 +1,18 @@ +# ROK 配置源说明 + +## 数据源真相 + +- ROK 服务端配置源:`Configs.data`(Lua 大表,tabtoy 生成) +- ROK 客户端配置源:加密 SQLite + 大量 `*Define.cs` DTO +- 原始工程无可直接复用的 Excel 源文件 + +## 迁移策略 + +1. 从 `*Define.cs` 派生 Luban `bean.xml` +2. 从 `Configs.data` 解析 JSON +3. JSON 转 Luban `xlsx` +4. 使用 Luban 生成 C# + Json + +## 结论 + +本项目采用“脚本迁移 + 自动生成”,不做手工录入。 diff --git a/Doc/ROK-Equip-Spec.md b/Doc/ROK-Equip-Spec.md new file mode 100644 index 0000000..b7b2713 --- /dev/null +++ b/Doc/ROK-Equip-Spec.md @@ -0,0 +1,509 @@ +# ROK 装备系统业务规则(1:1 迁移依据) + +> 来源:`E:\Game\gmd\ROK` 服务端 Lua(`HeroLogic.lua` / `BuildingLogic.lua` / `ItemLogic.lua` / proxy `Hero.lua` / proxy `Build.lua`)+ 客户端 Mediator/View + sproto 协议 + Config DTO +> 用途:作为 Survivors 工程装备子系统实现的业务依据 +> 调研日期:2026-05-27 + +--- + +## 0. 核心结论(先看这里) + +- **装备就是 Item 的一种**(带 `heroId` 字段、不堆叠、每件独立 `itemIndex`);不需要建独立 EquipEntity,靠 ItemUnit 的 `heroId>0` 区分 +- **Hero 持有装备的方式**:Hero Entity 上有 8 个整型字段(`head`/`breastPlate`/`weapon`/`gloves`/`pants`/`accessories1`/`accessories2`/`shoes`),值是 `itemIndex` 引用回背包 +- **8 个槽位 ↔ 6 种 subType 的多对一映射**:accessories1 与 accessories2 都接 `ACCESSORIES` 子类型(共享一种装备池) +- **锻造分两段 RPC**:先 `CheckMakeEquip` 跑 50% 概率给"专属"标记 → 再 `MakeEquipment` 真正消耗材料 + 金币产出装备;专属属性写在装备 Item 的 `exclusive` 字段 +- **分解、合成(材料 mix/split)都是确定性配置查询**,不带随机 +- **错误码段**:装备穿戴用 Hero 段(3024-3027),锻造用 Building 段(6029-6031) +- **建议**:在 Survivors 不建独立 `cn.etetet.equip` 包,**作为 `cn.etetet.bag` 的子系统实现**(与 Item 共享数据底座),锻造逻辑放 `cn.etetet.bag/Equip/Forge.cs` 子目录 + +--- + +## 1. 整体业务范围 + +| 功能模块 | 实现位置(ROK 原版) | 是否 P1 必需 | +|---|---|---| +| 装备穿戴(WearEquip) | `proxy/Hero.lua:414` + `HeroLogic.lua` | P2(与英雄养成同期) | +| 装备卸下(TakeOffEquip) | `proxy/Hero.lua:486` | P2 | +| 装备战力计算(套装加成) | `HeroLogic.lua:792 getHeroEquipAttr` | P2 | +| 装备锻造(MakeEquipment) | `proxy/Build.lua:742` + `BuildingLogic` | P3 | +| 锻造预检(CheckMakeEquip) | `proxy/Build.lua:713` | P3 | +| 装备分解(DecompositionEquipment) | `proxy/Build.lua:792` | P3 | +| 材料合成(mix) | 配置 + ItemScript 模式 | P3+ | +| 材料分解(split) | 配置 + ItemScript 模式 | P3+ | +| 套装效果(2/4/6/8 件套) | `HeroLogic.lua:832` + `EquipComposeDefine` | P2 | +| 专属属性(exclusive) | `RoleLogic.exclusive` 角色标记 + 装备 Item `exclusive` 字段 | P3+(可推迟) | + +--- + +## 2. 数据模型 + +### 2.1 Hero Entity 上的装备字段 + +Hero 实体直接挂 8 个整型字段(值为 `itemIndex`,0 表示空槽): + +| 字段名 | 槽位 | 接受的 `ItemSubType` | +|---|---|---| +| `head` | 1 | `HELMET` | +| `breastPlate` | 2 | `BREASTPLATE` | +| `weapon` | 3 | `ARMS` | +| `gloves` | 4 | `GLOVES` | +| `pants` | 5 | `PANTS` | +| `accessories1` | 6 | `ACCESSORIES` | +| `accessories2` | 7 | `ACCESSORIES` | +| `shoes` | 8 | `SHOES` | + +> **注意两个细节**: +> 1. 客户端"装备索引" `equipIndex` 用 **1-8**(不是 0-7),代码里到处写 `equips[1]`...`equips[8]` +> 2. accessories 有两槽且接同一种 subType(一个英雄能同时戴两件饰品,但要分别选 6 号槽还是 7 号槽) + +### 2.2 装备 Item 字段(在 BagItem 之上扩展) + +装备 = BagItem 但多了几个语义字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `itemIndex` | int | 背包唯一索引(与普通 Item 一致) | +| `itemId` | int | 装备配置 ID(指向 EquipDefine) | +| `count` | int | 永远是 1(装备不堆叠) | +| `heroId` | int | 当前穿戴该装备的英雄 ID,0 = 未穿 | +| `exclusive` | int / bool | 是否带"专属"加成(锻造时 50% 触发) | + +> 设计推论:**装备就是 `count=1` + `heroId≥0` 的特殊 Item**,不需要独立 Entity 树。 + +--- + +## 3. 配置表清单 + +### 3.1 EquipDefine(装备基础表,主键 `itemID`) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `itemID` | int (PK) | 装备道具 ID(也是 ItemDefine 的 itemId) | +| `group` | int | 装备组(同一组可能是不同品质或不同套装的同部位) | +| `useLevel` | int | 英雄达到此等级才能穿戴 | +| `makeMaterial` | List\ | 锻造所需材料 itemId 列表 | +| `makeMaterialNum` | List\ | 对应的数量(与 makeMaterial 等长) | +| `costGold` | int | 锻造消耗金币 | +| `decomposeMaterial` | List\ | 分解返还材料 itemId 列表 | +| `decomposeMaterialNum` | List\ | 对应数量 | +| `att` | List\ | 装备属性 ID 列表(指向 EquipAttDefine.ID) | +| `attAddEx` | List\ | 服务端属性数值(**千分比**整数) | +| `attAdd` | List\ | 客户端展示用属性数值 | +| `compose` | int | 套装 ID(0 = 非套装),指向 EquipComposeDefine.ID | +| `order` | int | UI 排序 | + +### 3.2 EquipAttDefine(装备属性映射表) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int (PK) | 属性 ID(被 EquipDefine.att 引用) | +| `attNew` | enum attrType | 客户端使用的属性枚举 | +| `att` | string | 服务端属性 key 字符串(用于数值表内查名) | +| `l_nameID` | int | 语言包 ID | +| `icon` | string | 图标资源名 | +| `color` | string | 文字颜色 | + +### 3.3 EquipComposeDefine(套装效果表,主键 `ID` = EquipDefine.compose) + +四段套装加成(满 2 件 / 4 件 / 6 件 / 8 件分别叠加): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int (PK) | 套装 ID | +| `l_nameID` | int | 套装名语言包 ID | +| `compose2` | List\ | 满 2 件触发的属性 ID 列表 | +| `compose2Add` | List\ | 客户端展示数值 | +| `compose2AddEx` | List\ | 服务端千分比数值 | +| `compose4` / `compose4Add` / `compose4AddEx` | 同上 | 满 4 件 | +| `compose6` / `compose6Add` / `compose6AddEx` | 同上 | 满 6 件 | +| `compose8` / `compose8Add` / `compose8AddEx` | 同上 | 满 8 件(全套)| + +### 3.4 EquipMaterialDefine(材料表,主键 `itemID`) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `itemID` | int (PK) | 材料道具 ID | +| `order` | int | UI 排序 | +| `group` | int | 材料组(同组不同品质用于显示分类) | +| `rare` | int | 品质 | +| `add` | int | 各品质对应的数值参数 | +| `mixCostNum` | int | 合成上一级时所需本级数量(如 3 个低 → 1 个高) | +| `mix` | int | 合成后的材料 itemId(下一级) | +| `split` | int | 分解后的材料 itemId(下一级反向?或者材料碎片) | +| `splitCostCur` | int | 分解时消耗的货币 itemId | +| `splitCostCurNum` | int | 分解消耗货币数量 | +| `splitGetNum` | int | 分解一次产出 split 道具的数量 | + +### 3.5 EquipMaterialGroupDefine(材料组表) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int (PK) | 组 ID | +| `group` | int | 与 EquipMaterialDefine.group 关联 | +| `l_nameID` | int | 组名语言包 ID | +| `icon` | string | 组图标 | + +--- + +## 4. 业务规则详细 + +### 4.1 装备穿戴 `Hero_HeroWearEquip 611` + +**请求**:`{ heroId: int, itemIndex: int, equipIndex: int }`(equipIndex = 1..8) + +**服务端流程**(`proxy/Hero.lua:414`): + +``` +1. 取 ItemLogic.getItem(rid, itemIndex) → itemInfo + - 不存在 → ERR ITEM_NOT_EXIST +2. 取 EquipDefine[itemInfo.itemId] → sEquip +3. 校验 ItemSubType 与 equipIndex 槽位匹配(按 §2.1 映射表) + - 不匹配 → ERR HERO_EQUIP_SUBTYPE_ERROR (3025) +4. 校验 hero.level >= sEquip.useLevel + - 不够 → ERR HERO_EQUIP_LV_NO_ENOUGH (3024) +5. 校验目标英雄 idle(不在出征中) + - HeroLogic.checkHeroIdle(rid, heroId) → false → ERR HERO_EQUIP_NOT_IN_CITY (3026) +6. 处理"装备已被其他英雄穿戴": + if itemInfo.heroId > 0 then + beforeHero = HeroLogic.getHero(rid, itemInfo.heroId) + attr = equips[equipIndex].attr # 该槽位对应的 hero 字段 + if beforeHero[attr] > 0 then + beforeHero[attr] = 0 # 旧英雄槽位清空 + HeroLogic.setHero + syncHero(推送给客户端) + end + end +7. 处理"目标槽位已有装备": + attr = equips[equipIndex].attr + if hero[attr] > 0 then + oldEquip = ItemLogic.getItem(rid, hero[attr]) + oldEquip.heroId = 0 # 旧装备解绑 + ItemLogic.setItem + syncItem + end +8. 建立新关系: + hero[attr] = itemIndex + itemInfo.heroId = heroId + HeroLogic.setHero + syncHero + (Item 端 syncItem 已在 §6/§7 中被触发) +``` + +**关键设计点**: +- 槽位映射在 Lua 里硬编码(不在配置表):`equips[1]={HELMET, head}`...`equips[8]={SHOES, shoes}` +- ROK 这里**没用客户端先发卸下、再发穿戴**的两步流程,**服务端一个 RPC 完成"卸旧+穿新"** +- 跨英雄抢装备时,原英雄的同槽位会**自动腾出**,但原英雄是否还有别的装备保持不变 + +### 4.2 装备卸下 `Hero_TakeOffEquip 612` + +**请求**:`{ heroId: int, equipIndex: int }` + +**服务端流程**(`proxy/Hero.lua:486`): + +``` +1. 校验英雄 idle + - false → ERR HERO_EQUIP_NOT_IN_CITY +2. attr = equips[equipIndex].attr + if hero[attr] > 0 then + itemInfo = ItemLogic.getItem(rid, hero[attr]) + itemInfo.heroId = 0 + ItemLogic.setItem + syncItem + hero[attr] = 0 + HeroLogic.setHero + syncHero + end +3. response { result = true } +``` + +**关键点**:卸下后装备**回到背包未绑定状态**,不会消失。 + +### 4.3 装备战力计算 `HeroLogic.getHeroEquipAttr` + +**位置**:`HeroLogic.lua:792` + +**输入**:`(rid, heroId)` + +**输出**:`{ attrName1: value1, attrName2: value2, ... }`(属性叠加表) + +**算法**: + +``` +equips = [head, breastPlate, weapon, gloves, pants, accessories1, accessories2, shoes] +equipCompose = {} # 套装计数 { composeId: pieceCount } + +# 第一步:累加每件装备的基础属性 +for pos in equips: + if hero[pos] > 0: + item = ItemLogic.getItem(hero[pos]) + sEquip = EquipDefine[item.itemId] + if sEquip.compose > 0: + equipCompose[sEquip.compose] = (equipCompose[sEquip.compose] or 0) + 1 + for i = 1..len(sEquip.att): + attrName = EquipAttDefine[sEquip.att[i]].att + num = sEquip.attAddEx[i] # 千分比 + # 如果天赋有"装备属性提升"加成 + if heroHas TalentBonus(equipTalentPromote): + num = ceil(equipTalentPromote * num * 2) / 2 + result[attrName] += num + +# 第二步:套装加成(compose2 / compose4 / compose6 / compose8) +for composeId, count in equipCompose: + sCompose = EquipComposeDefine[composeId] + if count >= 2: 累加 sCompose.compose2 / compose2AddEx + if count >= 4: 累加 sCompose.compose4 / compose4AddEx + if count >= 6: 累加 sCompose.compose6 / compose6AddEx + if count >= 8: 累加 sCompose.compose8 / compose8AddEx + +return result +``` + +**注意**:所有数值都是**千分比整数**,在客户端展示时除以 10 显示为百分比。 + +### 4.4 锻造预检 `Build_CheckMakeEquip 417` + +**请求**:`{ itemId: int }`(要锻造的装备 ID) + +**响应**:`{ exclusive: bool }` + +**作用**:客户端按下"锻造"按钮时先调这个 RPC,决定本次是否能锻造出"专属"装备。 + +**服务端流程**(`proxy/Build.lua:713`): + +``` +1. sEquip = EquipDefine[itemId] +2. 校验金币 >= sEquip.costGold + - 否 → ERR ROLE_GOLD_NOT_ENOUGH +3. 校验材料:调用 BuildingLogic.cancleMaterial(rid, itemId) 做存量检查(不真扣) + - 否 → ERR BUILDING_EQUIP_ITEM_NO_ENOUGH (6030) +4. 随机:num = Random(1, 100) + if num > 50: + exclusive = true + RoleLogic.setRole(rid, { exclusive = true }) # 把 50% 概率结果存到玩家身上 +5. return { exclusive } +``` + +**关键点**:**专属概率 50% 写在硬编码里**(`num > 50`),不在配置表。 + +### 4.5 装备锻造 `Build_MakeEquipment 418` + +**请求**:`{ itemId: int, exclusive: int }`(exclusive 由 CheckMakeEquip 返回,客户端透传回来) + +**服务端流程**(`proxy/Build.lua:742`): + +``` +1. 二次校验 exclusive: 客户端说要带专属 → 但 RoleLogic.exclusive 必须为 true(否则作弊) + - 不一致 → ERR BUILDING_EQUIP_EXCLUSIVE_ERROR (6031) +2. 二次校验金币 / 材料(同 4.4 第 2-3 步) +3. flag, itemInfo = BuildingLogic.cancleMaterial(rid, itemId) + itemInfo 是 dict { itemId: needNum } +4. 逐项 ItemLogic.checkItemEnough → 任一不足 → ERR +5. 逐项 ItemLogic.delItemById(LogType=MAKE_EQUIP_COST_ITEM) +6. RoleLogic.addGold(-price, LogType=MAKE_EQUIP_COST_CURRENCY) +7. ItemLogic.addItem({ itemId, itemNum=1, exclusive=exclusive, LogType=MAKE_EQUIP_GAIN_ITEM }) +8. 任务统计:TaskLogic.addTaskStatisticsSum(EQUIP_QUALITY, ItemDefine.quality, +1) +9. response { itemIndex, itemInfo }(实际响应字段以 sproto 为准) +``` + +**关键设计点**: +- **没有锻造概率**,材料够 + 金币够 = 必定产出(专属属性是另一回事) +- 专属 50% 概率在**预检 RPC** 里就决定了,玩家"看脸"决定要不要消耗材料锻造 +- 装备本体是 `count=1` 的 BagItem,带 `exclusive` 字段 + +### 4.6 装备分解 `Build_DecompositionEquipment 419` + +**请求**:`{ itemIndex: int }` + +**服务端流程**(`proxy/Build.lua:792`): + +``` +1. itemInfo = ItemLogic.getItem(rid, itemIndex) + - 不存在 → ERR BUILDING_EQUIP_NO_EXIST (6029) +2. 如已穿戴:校验英雄 idle + - 否 → ERR HERO_EQUIP_NOT_IN_CITY +3. sEquip = EquipDefine[itemInfo.itemId] +4. ItemLogic.delItem(rid, itemIndex, 1, LogType=DECOMPOSITION_EQUIP_COST_ITEM) +5. 按 sEquip.decomposeMaterial[] / decomposeMaterialNum[] 数组返还材料: + for i = 1..N: + ItemLogic.addItem({ itemId=sEquip.decomposeMaterial[i], itemNum=sEquip.decomposeMaterialNum[i], LogType=DECOMPOSITION_EQUIP_GAIN_ITEM }) +6. 如已穿戴,同步对应英雄槽位清零(伪代码省略,与穿戴的反向) +``` + +**关键点**: +- 已穿戴装备**允许直接分解**(前提英雄在城内),不要求先卸下 +- 返还固定(按配置表),**无随机** + +### 4.7 材料合成 / 分解 `mix` / `split` + +**没有专属 RPC**——这两个操作通过 **道具使用 `Use_Item` 协议** 实现: + +- 客户端 UI(`UI_Win_MaterialView`)让玩家选材料 + 数量 +- 服务端按 `EquipMaterialDefine.mix` / `mixCostNum` 配置查表执行 +- 合成:消耗 N 个低级材料 → 产出 1 个高级材料 +- 分解:消耗 1 个高级材料 + `splitCostCur` 货币 → 产出 `splitGetNum` 个低级 + +**关键设计点**:复用现有 UseItem 链路,无需新协议。 + +--- + +## 5. 客户端 UI 流程 + +### 5.1 装备主面板 `UI_Win_EquipView`(武将装备视图) + +- 显示英雄的 8 个装备槽(每槽点击 → 选择装备列表) +- 显示当前套装件数 + 已激活的套装效果 +- 显示装备战力小计 +- "卸下"按钮 / "替换"流程 + +### 5.2 装备快速操作 `UI_Win_EquipQuickView` + +- 快速一键穿戴该英雄部位的"最强可用装备" + +### 5.3 锻造面板(在 Build 体系下,不在 Equip View_Mediator) + +- 选装备 ID(按 group 分类)→ 显示所需材料 + 金币 +- 按下"锻造" → 调 CheckMakeEquip → 若返回 exclusive=true,UI 突出显示 +- 玩家确认 → 调 MakeEquipment +- 成功 → `UI_IF_EquipSuccessView` 展示飘屏 + +### 5.4 材料合成 `UI_Win_MaterialView` + +- 选材料组 → 按 rare 排序展示 → 选合成 / 分解 → 走 UseItem + +### 5.5 天赋装备选择 `UI_IF_EquipTalentChooseView` + +- 在英雄升级到关键节点时弹出,让玩家选装备方向(特殊功能,可推迟) + +--- + +## 6. 协议清单 + +| 协议号 | 名称 | 方向 | 用途 | +|---|---|---|---| +| 417 | `Build_CheckMakeEquip` | C2G + Resp | 锻造前 50% 概率预检(决定本次能否带专属) | +| 418 | `Build_MakeEquipment` | C2G + Resp | 真正锻造 | +| 419 | `Build_DecompositionEquipment` | C2G + Resp | 装备分解 | +| 611 | `Hero_HeroWearEquip` | C2G + Resp | 英雄穿戴装备(含自动卸旧) | +| 612 | `Hero_TakeOffEquip` | C2G + Resp | 英雄卸下指定槽位 | +| (复用) | `Use_Item` | C2G + Resp | 材料合成 / 分解(无专属协议) | +| (推送) | `Hero_HeroInfo` | G2C | 英雄数据变更(含 8 槽位 itemIndex) | +| (推送) | `Item_ItemInfo` | G2C | 装备 Item 的 heroId 变更 | + +--- + +## 7. 错误码清单 + +| 错误码 | 名称 | 含义 | +|---|---|---| +| 3024 | `HERO_EQUIP_LV_NO_ENOUGH` | 英雄等级不足 useLevel | +| 3025 | `HERO_EQUIP_SUBTYPE_ERROR` | 装备类型与槽位不匹配 | +| 3026 | `HERO_EQUIP_NOT_IN_CITY` | 英雄不在城内(出征中无法换装) | +| 3027 | `HERO_EQUIP_ALREADY_WEAR` | 该装备已被穿戴(预留,实际流程会自动处理) | +| 6029 | `BUILDING_EQUIP_NO_EXIST` | 装备 Item 不存在(分解时) | +| 6030 | `BUILDING_EQUIP_ITEM_NO_ENOUGH` | 锻造材料 / 道具不足 | +| 6031 | `BUILDING_EQUIP_EXCLUSIVE_ERROR` | 锻造专属校验失败(CheckMakeEquip 与 MakeEquipment 不一致) | +| (复用) | `ROLE_GOLD_NOT_ENOUGH` | 金币不足 | +| (复用) | `ITEM_NOT_EXIST` | 道具不存在 | + +--- + +## 8. 与 Survivors 实现的对照点 + +### 8.1 包归属 + +**建议不建独立 `cn.etetet.equip` 包**: + +| 理由 | 说明 | +|---|---| +| 装备就是 Item 的子集 | `count=1` + `heroId≥0`,没必要建独立 Entity 树 | +| 穿戴逻辑天然依赖 Hero + Bag 两个包 | 放独立包要双向引用,反而割裂 | +| ROK 原版的"穿戴"在 HeroLogic,"锻造"在 BuildingLogic | 没有独立 EquipLogic.lua | + +**实现安排**: + +``` +cn.etetet.bag/ # 装备数据底座 +├── Scripts/Model/Server/ +│ ├── BagComponent.cs # ItemUnit 上加 heroId/exclusive 字段 +│ └── ItemUnit.cs # heroId>0 = 装备已穿戴 +└── Scripts/Hotfix/Server/ + ├── BagComponentSystem.cs # AddItem 时识别装备类型 + └── Forge/ # 锻造子目录 + ├── ForgeHelper.cs # CheckMakeEquip + MakeEquipment + Decompose + └── MaterialMixHelper.cs + +cn.etetet.hero/ # 穿戴关系挂在 Hero 上 +├── Scripts/Model/Server/ +│ └── HeroEquipSlots.cs # Hero 上加 head/.../shoes 8 字段 +└── Scripts/Hotfix/Server/ + └── HeroWearEquipHandler.cs # 转发到 BagComponentSystem + HeroComponentSystem +``` + +### 8.2 8 槽位与 Survivors 协议 + +```protobuf +message HeroEquipSlots { + int64 head = 1; + int64 breast_plate = 2; + int64 weapon = 3; + int64 gloves = 4; + int64 pants = 5; + int64 accessories1 = 6; + int64 accessories2 = 7; + int64 shoes = 8; +} + +enum EquipSubType { + EQUIP_HELMET = 0; + EQUIP_BREASTPLATE = 1; + EQUIP_ARMS = 2; + EQUIP_GLOVES = 3; + EQUIP_PANTS = 4; + EQUIP_ACCESSORIES = 5; // accessories1 + accessories2 共用 + EQUIP_SHOES = 6; +} + +// 协议号建议 3100-3199 段(与本项目 Bag 段相邻) +message C2G_HeroWearEquip { int64 hero_id = 1; int64 item_index = 2; int32 equip_index = 3; } +message R2C_HeroWearEquip { int32 error = 1; } +message C2G_HeroTakeOffEquip { int64 hero_id = 1; int32 equip_index = 1; } +message C2G_CheckMakeEquip { int32 item_id = 1; } +message R2C_CheckMakeEquip { int32 error = 1; bool exclusive = 2; } +message C2G_MakeEquipment { int32 item_id = 1; bool exclusive = 2; } +message C2G_Decompose { int64 item_index = 1; } +``` + +### 8.3 Luban 配置(替代 ROK 的 SqlCipher) + +5 张表照搬: +- `EquipConfig.xlsx` ↔ EquipDefine(field 列表见 §3.1) +- `EquipAttConfig.xlsx` ↔ EquipAttDefine +- `EquipComposeConfig.xlsx` ↔ EquipComposeDefine +- `EquipMaterialConfig.xlsx` ↔ EquipMaterialDefine +- `EquipMaterialGroupConfig.xlsx` ↔ EquipMaterialGroupDefine + +注意 `List` 字段在 Luban 用 `list,int` 类型表达;`attAddEx` 千分比整数保留 ROK 写法(不改成浮点)。 + +### 8.4 数据迁移 + +走 `tools/rok-migrate/export.py` 扩展支持 5 张 Equip 表,从 `Configs.data` 导出 JSON → 转 Luban xlsx。**禁止手输**。 + +--- + +## 9. 实现优先级 + +| 阶段 | 任务 | 备注 | +|---|---|---| +| **P2** | Hero 8 槽位字段 + 穿戴 / 卸下 / 战力计算(含套装) | 与英雄养成同期,撑起 Hero 详情面 | +| **P3** | 锻造(含 exclusive 双段 RPC)+ 分解 | 撑起背包→装备生产闭环 | +| **P3+** | 材料合成 / 分解(复用 UseItem) | 优先级低,先把装备能玩通再说 | +| **P4+** | "天赋装备选择" `UI_IF_EquipTalentChoose` | 特殊节点弹窗,非主路径 | + +--- + +## 10. 与原 openspec 的差异提醒 + +之前 `openspec/changes/port-rok-hero-bag-system/specs/equipment-system/spec.md` 与 `tasks.md` 里的几个表述需要根据本调研修正: + +| openspec 原写法 | ROK 真实情况 | 应改为 | +|---|---|---| +| "EquipItemType 4 个" | 实际 6 个 subType + 8 个槽位 | 改为 "8 槽位 / 6 subType(accessories 2 槽共享)" | +| "锻造概率(必中 / 随机)" | 锻造必中,**专属** 50% 概率(在预检 RPC 里) | 区分"装备产出"必中 vs "专属属性"50% | +| "MakeEquipment 单 RPC" | 实际是 **CheckMakeEquip + MakeEquipment 两个 RPC** | tasks 3.4 加 `C2G_CheckMakeEquip` | +| "EquipDefine 含 List" | 实际是 `att[] + attAddEx[]` 两个 List 平行 | bean schema 按平行 List 设计 | +| "套装效果 = 4 件套" | 实际是 2/4/6/8 件**逐级叠加** | bean schema 拆 4 段 | diff --git a/Doc/ROK-Hero-Spec.md b/Doc/ROK-Hero-Spec.md new file mode 100644 index 0000000..dc4ae29 --- /dev/null +++ b/Doc/ROK-Hero-Spec.md @@ -0,0 +1,1441 @@ +# ROK 英雄系统业务规则(1:1 迁移依据) + +> **来源**:`E:\Game\gmd\ROK` 服务端 Lua(snax `Hero.lua` proxy + `HeroLogic.lua` lualib)+ 客户端 C# Proxy/Mediator/Config + sproto 协议 +> +> **用途**:作为 Survivors 工程 `cn.etetet.hero` 包实现的业务依据。**所有规则均从 ROK 实代码反推**,禁止凭空发挥。重写时若与本文档冲突,以 ROK 源码为准。 +> +> **关键文件锚点**: +> +> - 服务端业务核心:`Server/server/game_server/logic/lualib/HeroLogic.lua`(923 行) +> - 服务端 RPC Handler:`Server/server/game_server/logic/service/proxy/Hero.lua`(521 行) +> - 服务端 Entity 结构:`Server/common/lualib/define/HeroDef.lua` +> - 服务端枚举:`Server/common/lualib/enum/HeroEnum.lua` +> - 服务端战力计算:`Server/common/lualib/cacle/HeroCacle.lua` +> - 协议定义:`Server/common/protocol/Protocol.sproto`(行 968–1107、2619–2627)+ `Common.sproto`(行 318–349 的 `.SkillInfo` / `.HeroInfo` / `.TalentTrees`) +> - 客户端 Proxy:`Client/Assets/Scripts/Hotfix/MVC/Proxy/HeroProxy.cs`(983 行) +> - 客户端 Notification:`Client/Assets/Scripts/Hotfix/MVC/CMD/HeroCmd.cs` +> - 客户端列表+详情:`Client/Assets/Scripts/Hotfix/MVC/View_Mediator/Captain/CaptainMediator.cs`(1350 行) +> - 客户端配置类:`Client/Assets/Scripts/Hotfix/Config/Hero*Define.cs`(10 个) +> - 客户端错误码:`Client/Assets/Scripts/Hotfix/MVC/CMD/ErrorCode.cs` 行 149–176(3000–3027) + +--- + +## 1. 整体业务范围 + +ROK 中英雄称作"统帅"(commander/captain),是 SLG 卡牌养成系统的核心。一个英雄绑定到军队上作为主将/副将出战。系统涵盖以下功能模块: + +| # | 模块 | 一句话描述 | +|---|---|---| +| 1 | 招募(Summon) | 用对应碎片道具 (`getItem` × `getItemNum`) 兑换成统帅 Entity。**不存在直接抽卡获得"英雄本体"**——所有英雄都是"碎片够数即可招募"。 | +| 2 | 经验升级(Add Exp) | 用经验书道具增加 `exp`,到阈值升 `level`,受当前星级 `starLimit` 封顶。 | +| 3 | 升星(Star Up) | 用升星材料增加 `starExp`,溢出升 `star`;带幸运双倍机制;解锁高阶技能槽。 | +| 4 | 技能升级(Skill Level Up) | 消耗英雄招募同 ID 道具(多余碎片可用于升技能);**升级目标技能是随机选择未满级的技能**之一。 | +| 5 | 觉醒(Awake) | 前 4 个技能全满级时自动解锁第 5 个技能(觉醒技能,等级固定 1)。同时提供独立 RPC 手动触发。 | +| 6 | 天赋树(Talent Tree) | 3 套独立天赋页(`talentIndex` 1/2/3),逐节点学习;提供专精(Mastery)的累加加成。 | +| 7 | 天赋页切换 / 重置 / 改名 | 道具或钻石付费切换/重置;天赋页支持自定义名称。 | +| 8 | 装备穿戴(Equip / Take Off) | 8 个固定部位(头盔/胸甲/武器/手套/裤子/饰品 1/饰品 2/鞋子);同件装备切换英雄时自动从旧英雄卸下。 | +| 9 | 雕像兑换(Exchange) | 用"通用雕像"兑换为某英雄的招募碎片,前提是技能未全满。 | +| 10 | 战力计算(Power) | 基础分 + 等级分 + 技能分 + 天赋分(含专精)+ 装备分;任意改动后调 `cacleSyncHistoryPower`。 | +| 11 | 排序与分组 | 列表按 Rare / Star / Level / Power 排序;分三段:已拥有 / 可召唤 / 未集齐。 | +| 12 | 同步到部队 | 任意英雄改动会更新 `RoleArmy` 的战斗属性(技能、天赋属性、等级),通知战斗服务器。 | +| 13 | 行动力减免 | 主将通过技能/天赋影响军队行动力消耗(`vitalityReduction`)。 | +| 14 | 限时礼包触发 | 招募新英雄、英雄升级会触发 `RechargeLogic.triggerLimitPackage`(与英雄无强耦合,仅作业务关联)。 | + +> **关键约束**:ROK **没有抽卡(gacha)作为英雄获取入口**。Tavern(酒馆)`Build_Tavern` 协议开宝箱产出的是**道具+英雄碎片**(`RewardInfo.heros: *Heros{heroId, num, isNew}`),玩家攒够碎片后**主动调 `Hero_SummonHero` 招募**。本质是"碎片养成"而非"抽英雄"。 + +--- + +## 2. Hero 数据模型 + +### 2.1 Hero Entity 字段(持久化) + +来源:`HeroDef.lua` `defaultHeroAttr` + `Common.sproto` `.HeroInfo`。**全部字段持久化到 `MSM.d_hero[rid]`,由 `req.Add / req.Set / req.Get` 接口读写**。 + +| 字段名 | 类型 | 含义 | 默认值 | 持久化 | +|---|---|---|---|---| +| `heroId` | int | 统帅配置 ID(对应 `s_Hero` 表主键) | 0 | ✓ | +| `star` | int | 当前星级 | 0(招募时置为 `sHero.initStar`) | ✓ | +| `starExp` | int | 当前星级进度(升星经验) | 0 | ✓ | +| `level` | int | 当前等级 | 0(招募时置 1) | ✓ | +| `exp` | int | 当前等级经验 | 0 | ✓ | +| `summonTime` | int | 招募时间戳(os.time) | 0 | ✓ | +| `soldierKillNum` | int | 累计士兵击杀数 | 0 | ✓ | +| `savageKillNum` | int | 累计野蛮人击杀数 | 0 | ✓ | +| `skills` | `[]SkillInfo` | 已解锁技能列表 | `{}`(招募时按星级解锁初始技能) | ✓ | +| `talentPoint` | int | 天赋点(**遗留字段,实际不使用**,见 §4.6 说明) | 0 | ✓ | +| `talentTrees` | `map` | 三套天赋页数据,key = 1/2/3 | `{}` | ✓ | +| `talentIndex` | int | 当前激活的天赋页 (1/2/3) | 1 | ✓ | +| `head` | int | 头盔位 itemIndex(背包索引) | 0 | ✓ | +| `breastPlate` | int | 胸甲位 itemIndex | 0 | ✓ | +| `weapon` | int | 武器位 itemIndex | 0 | ✓ | +| `gloves` | int | 手套位 itemIndex | 0 | ✓ | +| `pants` | int | 裤子位 itemIndex | 0 | ✓ | +| `accessories1` | int | 饰品 1 位 itemIndex | 0 | ✓ | +| `accessories2` | int | 饰品 2 位 itemIndex | 0 | ✓ | +| `shoes` | int | 鞋子位 itemIndex | 0 | ✓ | + +### 2.2 子结构 + +**`SkillInfo`**(`Common.sproto` 行 318): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `skillId` | int | 技能配置 ID(对应 `s_HeroSkill`) | +| `skillLevel` | int | 当前技能等级(1 起) | + +> 运行时 `getRoleAllHeroSkills` 会临时附加 `talent: true`(天赋衍生技能)、`deputySkill: true`(副将技能)这两个**不持久化**的标记位。 + +**`TalentTrees`**(`Common.sproto` 行 334): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `index` | int | 分页索引 (1/2/3) | +| `talentTree` | `[]int` | 已学习的天赋节点 ID 列表(`s_HeroTalentGainTree` 表 ID) | +| `name` | string | 天赋页自定义名称(玩家可改) | + +### 2.3 同一玩家可拥有英雄数量上限 + +**无显式上限**。`HeroLogic:addHero` 仅判断"是否已拥有该 heroId"——已拥有则把招募道具转为对应数量的同 ID 道具退回(碎片返还,见 §4.1)。可拥有英雄数 = 配置表 `s_Hero` 中 `listDisplay ≠ 1` 的条目数。 + +实际 `hero.json` 有 **46 条**(其中部分为 `listDisplay=1` 的怪物英雄不参与列表),玩家最多拥有的英雄数 ≈ 配置表中 `listDisplay=0` 的条数。 + +--- + +## 3. 配置表清单 + +> 来源:`Client/Assets/Scripts/Hotfix/Config/Hero*Define.cs`(C# 反序列化 DTO,与 ROK 服务端 `CFG.s_Hero*` 一一对应)+ 实际数据 `gmd\Unity\Assets\Bundles\Config\hero*.json`(46 / 300 / 6 / 49 / 261 / 811 / 16 条)。 + +### 3.1 HeroDefine(英雄基础,`hero.json` = 46 条) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 英雄 ID(主键,4 位,如 1000/1001/2007) | +| `l_nameID` | int | 名称语言包 ID | +| `civilization` | int | 所属文明 ID(对应 `s_Civilization`) | +| `l_appellationID` | int | 称号语言包 ID | +| `initStar` | int | 初始星级(招募时赋值给 `Hero.star`) | +| `star` | int | 星级上限(用于 `IsStarMaxLevel` 判断) | +| `rare` | int | 稀有度 1=普通/2=优秀/3=精英/4=史诗/5=传说 | +| `l_desID` | int | 英雄描述语言包 | +| `score` | int | 基础战力 | +| `getItem` | int | 招募所需道具 ID("招募碎片") | +| `getItemNum` | int | 招募所需道具数量(如 10) | +| `exchange` | int | 兑换通用雕像的道具 ID;0 = 不可兑换 | +| `l_heroLanguage` | `[]int` | 英雄台词语言包列表(点角色随机说话) | +| `getLimit` | int | 招募解锁的王国开服天数 | +| `skill` | `[]int` | 该英雄的 5 个技能 ID(索引 1-4 为普通技能,索引 5 为觉醒技能;可能少于 5) | +| `talent` | `[]int` | 该英雄的天赋节点 ID(3 个,对应 3 棵增益树 `gainTree`) | +| `listDisplay` | int | 列表显示标志;1=不显示(怪物英雄) | +| `heroIcon` | string | 头像 atlas 引用 | +| `heroModel` | string | Spine 模型 | +| `heroAction` | string | 序列帧动画 | +| `heroScene` | string | 英雄场景 prefab 名 | +| `heroScenePoint` | `[]float` | 召唤镜头位置(x/y/z) | +| `voiceOpening` | string | 开场白语音 | +| `voiceSelect` | string | 选中语音 | +| `voiceMove` | string | 移动语音 | + +样本(ID=1000,文明 101,5 星传说):`getItem=509010001`,`getItemNum=10`(10 个碎片可招募),`star=6`,`skill=[100001,100002,100003,100004,100005]`,`talent=[100001,100002,100003]`。 + +### 3.2 HeroLevelDefine(等级表,`hero_level.json` = 300 条) + +> 5 种稀有度(`rareGroup` 1-5)× 60 级 = 300 条,ID 公式 = `rare * 10000 + level - 1`(见 `HeroProxy.cs` 行 184)。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 = `rareGroup * 10000 + lv - 1` | +| `rareGroup` | int | 稀有度组(1-5) | +| `lv` | int | 等级(1-60) | +| `exp` | int | **升至下一级**所需经验(满级时为 0 → `IsMaxLevel` 即 `exp==0`) | +| `soldiers` | int | 该等级总带兵量 | +| `score` | int | 该等级**总战力**(直接读取而非累加) | +| `starEffectData` | int | 该等级获得天赋点 | + +### 3.3 HeroStarDefine(星级表,`hero_star.json` = 6 条) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 星级 1-6 | +| `starLimit` | int | 该星级允许的等级上限(1→10,2→20,3→30,4→40,5→50,6→60) | +| `rare5` | int | 5星英雄升下一星所需经验 | +| `rare4` | int | 4星英雄升下一星所需经验 | +| `rare3` | int | 3星英雄升下一星所需经验 | +| `rare2` | int | 2星英雄升下一星所需经验 | +| `rare1` | int | 1星英雄升下一星所需经验 | +| `starEffect` | int | 星级效果类型(1=普通;2=高阶,达到 5 星才解锁额外能力) | +| `l_starEffectID` | int | 星级效果语言包 | +| `starEffectData` | int | 星级效果参数(如天赋点:5★给 1 点,6★给 2 点) | + +> 实际数值(来自 `hero_star.json`): +> - 1★→2★:所有稀有度 500 经验 +> - 2★→3★:所有稀有度 1000 经验 +> - 3★→4★:5/4 星 6500,3/2/1 星 5000 +> - 4★→5★:5 星 25000,4 星 16000,3 星 12500,2 星 10000,1 星 8000 +> - 5★→6★:5 星 65000,4 星 48000,3 星 30000,2 星 20000,1 星 10000 +> - 6★:满级(rare1-5 均为 0) + +### 3.4 HeroStarExpDefine(升星材料表,`hero_star_exp.json` = 49 条) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 | +| `rareGroup` | int | 该材料适用的英雄稀有度(必须与英雄 rare 一致) | +| `itemID` | int | 材料道具 ID | +| `exp` | int | 每个材料给的升星经验 | +| `lucky` | int | 每个材料贡献的幸运值(用于双倍判定) | +| `sure` | int | 是否为稀有保底材料(1=是,0=否) | + +### 3.5 HeroSkillDefine(技能表,`hero_skill.json` = 261 条) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 技能 ID(4–6 位,如 100001) | +| `l_nameID` | int | 名称语言包 | +| `type` | int | 技能类型(0=被动 74 / 1=主动 40 / 2=触发 117 / 3=持续 13 / 4=觉醒 17) | +| `l_typeID` | int | 类型语言包 | +| `skillBattleID` | `[]int` | 战斗效果 ID 列表 | +| `anger` | int | 怒气需求 | +| `open` | int | 解锁条件(0=默认;其他参考 `unlockSkill`) | +| `l_openID` | int | 解锁条件语言包 | +| `awakenEnhance` | int | 觉醒后增强标志 | +| `icon` | string | 技能图标 | +| `levelUpCostItem` | int | 升级所需道具(旧字段,已被 `s_Hero.getItem` 替代) | +| `l_mesID` | int | 技能描述语言包 | +| `lvEffectDate1..4` + `l_lvEffect1..4` | `[]string` + int | 4 段升级效果描述与数值 | +| `armyEffect` | string | 战斗特效路径 | +| `sound` | string | 技能音效 | + +### 3.6 HeroSkillEffectDefine(技能效果表,`hero_skill_effect.json` = 811 条) + +> 主键查找公式:**`ID = skillId * 1000 + skillLevel`**(见 `HeroLogic.lua` 行 158、604)。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 = `skillId * 1000 + level` | +| `group` | int | 技能 ID(去除 level 部分) | +| `level` | int | 技能等级 | +| `score` | int | **该技能等级提供的总战力** | +| `attrTypeNew` | `[]attrType` | 客户端属性类型枚举数组 | +| `attrType` | `[]string` | 服务端属性名数组 | +| `attrNumber` | `[]int` | 对应属性的数值数组(与 `attrType` 同长度) | + +### 3.7 HeroSkillLevelDefine(技能升级消耗,`hero_skill_level.json` = 16 条) + +> ID 100-115,对应"全英雄的总技能等级(去除技能数)+ 100",最大支持升 16 次。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 (100-115) | +| `level` | int | 第几次升级 (1-16) | +| `costItem1` | int | 普通(1星)英雄消耗道具数 | +| `costItem2` | int | 优秀(2星)英雄消耗道具数 | +| `costItem3` | int | 精英(3星)英雄消耗道具数 | +| `costItem4` | int | 史诗(4星)英雄消耗道具数 | +| `costItem5` | int | 传说(5星)英雄消耗道具数 | + +> 关键:消耗的道具就是该英雄的招募碎片 (`sHero.getItem`),因此**多余碎片可用于技能升级**。 + +### 3.8 HeroTalentDefine(天赋根节点,每英雄 3 条) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 | +| `l_talentID` | int | 天赋名语言包 | +| `icon1` | string | 小图标 | +| `gainTree` | string | 所属增益树 ID(**注意是字符串,需 `int.Parse`**) | +| `masteryGroupID` | int | 天赋专精组 ID | +| `l_tipsID` | int | tips 语言包 | +| `type` | int | 天赋类型(用于装备专属判定 `checkHeroTalent`) | + +### 3.9 HeroTalentGainTreeDefine(天赋树节点表) + +> 每个节点对应天赋页中一个可点的格子;客户端用 `HeroTalentGainTreeDefine.ID` 来标识 `talentTree` 数组里的元素。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 节点 ID | +| `gainTree` | int | 所属树组 ID(与 `HeroTalent.gainTree` 对应) | +| `level` | int | 天赋等级 / 层数(用于前置依赖:必须先点 `level - 1` 同组节点) | +| `score` | int | 该节点战力 | +| `icon` | string | 图标 | +| `nameID` | int | 名字语言包 | +| `descID` | int | 描述语言包 | +| `battleSkillID` | int | 战斗内生效技能 ID(>0 时附加给主将) | +| `attrTypeNew` | `[]attrType` | 客户端属性枚举 | +| `attrType` | `[]string` | 服务端属性名 | +| `attrNumber` | `[]int` | 属性值 | + +### 3.10 HeroTalentMasteryDefine(天赋专精) + +> 同一 `group` + 累计点数(`needTalentPoint`)达到时激活,提供额外战力 + 属性 + 技能。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 主键 | +| `group` | int | 所属增益树组 | +| `level` | int | 专精层(按累计点数排序) | +| `score` | int | 激活战力 | +| `name` | int | 名字语言包 | +| `descID` | int | 描述语言包 | +| `needTalentPoint` | int | 激活需要的累计天赋点数 | +| `battleSkillID` | int | 战斗内生效技能 | +| `attrType` / `attrTypeNew` / `attrNumber` | 同上 | 属性加成 | + +### 3.11 HeroTalentTypeDefine(天赋类型,用于装备专属) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `ID` | int | 类型 ID | +| `l_talentID` | int | 类型名语言包 | +| `icon` | string | 图标 | +| `equipIcon` | string | 装备天赋图标 | + +### 3.12 全局/辅助配置(来自 `s_ZeroEmpty` / `s_Config`) + +| Key | 含义 | 引用位置 | +|---|---|---| +| `Enum.ZeroEmptyType.HERO_SKILL_OPEN` | `[heroId][star] = skillId`,星级解锁哪个技能 | `unlockSkill` | +| `Enum.ZeroEmptyType.TALENT` | `[gainTree][nodeId] = true`,节点归属增益树 | `checkHeroStudyTalent` | +| `Enum.ZeroEmptyType.HERO_LEVEL_TALENT` | `[rare][level] = point`,等级影响的天赋点 | `checkTalentPoint` | +| `Enum.ZeroEmptyType.HERO_STAR_TALENT` | `[star] = point`,星级影响的天赋点 | `checkTalentPoint` | +| `s_Config.talentResetItemID` | 切换/重置天赋页的免费道具 ID | `ChangeTalentIndex` / `ResetTalent` | +| `s_Config.heroNameLimit` | 天赋页名称长度 `[最小, 最大]` | `ModifyTalentName` | +| `s_Config.equipTalentPromote` | 装备专属属性加成系数 | `getHeroEquipAttr` | + +--- + +## 4. 业务规则详细(核心) + +> 标注:`P*` = `Hero.lua` proxy 的 RPC handler;`L*` = `HeroLogic.lua` 函数。 + +### 4.1 招募 / 兑换英雄 + +#### 4.1.1 英雄获取来源(**真实业务流**) + +ROK 中英雄获取链路如下,**绝非"抽卡"**: + +```text +酒馆开宝箱 (Build_Tavern, type=1银箱 / 2金箱) + ↓ 服务端随机算法 +RewardInfo { + items: [道具] (碎片 / 升星石 / 装备 / 通用资源 ...) + heros: [Heros{heroId, num, isNew}] // 实际是给"碎片数量",但走专门通道展示 +} + ↓ 玩家攒够 sHero.getItemNum (默认 10) 碎片 +玩家主动调 Hero_SummonHero(heroId) + ↓ 服务端扣碎片 + addHero +HeroLogic:addHero → 同步 Hero_HeroInfo + 触发限时礼包 + 调整城防主将 +``` + +碎片本身是个普通道具(如 `509010001`),存在背包里,可以通过 Tavern 宝箱、活动、商店、邮件等多渠道获得。**没有任何"直接给一个英雄实体"的入口**——所有英雄都必须经过 `Hero_SummonHero` 这一步。 + +`recruitLimit`:**ROK 源码无此字段**。判定逻辑仅有 `sHero.getLimit`(开服天数解锁)。 + +#### 4.1.2 重复招募处理(碎片返还) + +如果调 `Hero_SummonHero` 时该英雄已存在(`getHero` 非空且非空表): +- **服务端 RPC 层会先验失败**:`Hero.lua` `SummonHero` 行 42-46 返回 `HERO_ALREADY_EXIST (3002)`。 +- **但 `HeroLogic:addHero` 内部**还有一层兜底(行 41-52):如果已存在,把传入的本应该消耗的道具**转换成同 ID 道具加回背包**(实际是没扣到也没退到,因为 proxy 层先拒了)。这层兜底用于其他调用方(如直接发奖、活动给英雄)时的兼容。 + +#### 4.1.3 完整流程(伪代码) + +```text +function SummonHero(rid, heroId): + // 步骤 1:参数校验 + if not heroId: return ERR(HERO_ARG_ERROR) + sHero = CFG.s_Hero[heroId] + if not sHero: return ERR(CFG_ERROR) + + // 步骤 2:开服天数校验 + if getSelfNodeOpenDays() < sHero.getLimit: return ERR(HERO_OPEN_DAYS_NOT_ENOUGH) + + // 步骤 3:是否已拥有 + if HeroLogic:getHero(rid, heroId) is not empty: return ERR(HERO_ALREADY_EXIST) + + // 步骤 4:碎片是否足够 + if BagComponent.itemCount(sHero.getItem) < sHero.getItemNum: + return ERR(HERO_SUMMON_ITEM_NOT_ENOUGH) + + // 步骤 5:扣碎片 + ItemLogic:delItemById(rid, sHero.getItem, sHero.getItemNum, eventType=SUMMON_HERO_COST_ITEM) + + // 步骤 6:创建英雄实体 + HeroLogic:addHero(rid, heroId): + defHero = HeroDef:getDefaultHeroAttr() + defHero.heroId = heroId + defHero.star = sHero.initStar + defHero.level = 1 + defHero.summonTime = os.time() + HeroLogic:unlockSkill(rid, heroId, initStar, defHero) // 按初始星级解锁初始技能 + MSM.d_hero[rid].req.Add(rid, heroId, defHero) // 写库 + HeroLogic:syncHero(rid, heroId, defHero, true) // 推 Hero_HeroInfo + RoleLogic:cacleSyncHistoryPower(rid, ..., recalc=true) + BuildingLogic:changeDefendHero(rid) // 自动调整城防主将 + RechargeLogic:triggerLimitPackage(rid, NEW_HERO, sHero.rare) + ActivityRoleMgr.setActivitySchedule(rid, RECRUIT_HERO_COUNT, 1) +``` + +`unlockSkill` 步骤逻辑(`HeroLogic.lua` 行 391-406): + +```text +function unlockSkill(rid, heroId, star, heroInfo): + heroConfig = s_ZeroEmpty[HERO_SKILL_OPEN] + if heroConfig[heroId][star] exists and star != 5: + skillId = heroConfig[heroId][star] + if not already in heroInfo.skills: + heroInfo.skills.append({skillId, skillLevel=1}) +``` + +> 即配置表 `HERO_SKILL_OPEN` 定义"达到某星级解锁某技能"的映射。**第 5 星不通过此处解锁**(觉醒走另一通道)。 + +#### 4.1.4 涉及 RPC + +| 协议 | ID | 用途 | +|---|---|---| +| `Hero_SummonHero` | 601 | 客户端主动招募 | +| `Hero_HeroInfo` | 30201 | 服务端推送新增/变化的英雄(含 `noShow` 标志屏蔽 UI 弹窗) | + +#### 4.1.5 涉及错误码 + +`HERO_ARG_ERROR` (3000)、`CFG_ERROR`、`HERO_OPEN_DAYS_NOT_ENOUGH` (3000)、`HERO_ALREADY_EXIST` (3002)、`HERO_SUMMON_ITEM_NOT_ENOUGH` (3001)。 + +--- + +### 4.2 升级(经验) + +#### 4.2.1 经验来源 + +- **唯一入口** `Hero_AddHeroExp` (602):使用 `s_Item.subType` 为"经验书"类的道具,按 `desData1` × 数量加经验(`Hero.lua` 行 160)。 + +#### 4.2.2 等级计算 + +- `s_HeroLevel` 主键 = `rareGroup * 10000 + level - 1`(注意 -1 偏移,见 `HeroProxy.cs` 行 184) +- **服务端按 `rare * 10000 + level` 查 `s_HeroLevel:Get` 的 `exp` 字段,即"升到下一级所需经验"** +- 当 `s_HeroLevel.exp == 0` 时为满级(`HeroProxy.IsMaxLevel`) + +#### 4.2.3 升级时属性变化 + +ROK **不在升级时单独"刷属性"**——属性按需查表: +- 部队带兵量 = `s_HeroLevel.soldiers` +- 战力 = `s_HeroLevel.score` 直接读(非累加) +- 技能/天赋/装备的属性独立由 `getHeroAttr` 即时合并(见 §4.8) + +升级会触发: +- `RechargeLogic:triggerLimitPackage(HERO_LEVEL_UP, rare, level)` +- `updateMapArmyHero` 同步到地图部队 +- `ArmyLogic:updateArmyInfoOnHeroInfoChange` 更新军队信息 +- `ActivityRoleMgr.setActivitySchedule(HERO_LEVEL_COUNT)` +- `cacleSyncHistoryPower` 重算战力 + +#### 4.2.4 等级与星级关系 + +**升级硬封顶 = 当前星级的 `s_HeroStar.starLimit`**(如 1 星英雄最多升到 10 级)。`addHeroExp` 行 412-414: + +```text +if heroInfo.level == sHeroStar.starLimit: return false +``` + +到达上限后继续加经验:经验**清零**(行 434-436)而非保留。 + +#### 4.2.5 完整流程(伪代码) + +```text +function AddHeroExp(rid, heroId, itemId, itemNum): + heroInfo = HeroLogic:getHero(rid, heroId) + sHeroStar = CFG.s_HeroStar[heroInfo.star] + if heroInfo.level == sHeroStar.starLimit: return ERR(HERO_LEVEL_LIMIT) + if BagComponent.itemCount(itemId) < itemNum: return ERR(ITEM_NOT_ENOUGH) + + sItem = CFG.s_Item[itemId] + addExp = sItem.desData1 * itemNum + BagComponent.delItem(itemId, itemNum) + + HeroLogic:addHeroExp(rid, heroId, addExp): + sHeroStar = CFG.s_HeroStar[heroInfo.star] + if heroInfo.level == sHeroStar.starLimit: return false + sHero = CFG.s_Hero[heroId] + heroLevelId = sHero.rare * 10000 + heroInfo.level + exp = CFG.s_HeroLevel[heroLevelId].exp + + heroInfo.exp += addExp + while heroInfo.exp >= exp: + heroInfo.level += 1 + RechargeLogic:triggerLimitPackage(HERO_LEVEL_UP, sHero.rare, level) + if heroInfo.level == sHeroStar.starLimit: + heroInfo.exp = 0 + break + else: + heroInfo.exp -= exp + exp = CFG.s_HeroLevel[sHero.rare * 10000 + heroInfo.level].exp + + setHero & syncHero + if uplevel: + cacleSyncHistoryPower / updateMapArmyHero / updateArmyInfo / setActivitySchedule +``` + +#### 4.2.6 RPC + 错误码 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_AddHeroExp` | 602 | `HERO_LEVEL_LIMIT` (3011), `ITEM_NOT_ENOUGH` | + +--- + +### 4.3 升星 + +#### 4.3.1 升星材料 + +- 每次升星可一次性提交**最多 6 个**升星材料(`Hero.lua` 行 214)。 +- 每个材料对应 `s_HeroStarExp` 表一条: + - `rareGroup`:必须与英雄稀有度 (`sHero.rare`) 一致 + - `exp`:每个材料贡献的升星经验 + - `lucky`:每个材料贡献的幸运值 +- 计算公式(行 219-229): + + ```text + total_exp = Σ(material.exp × material.itemNum) + total_lucky = Σ(material.lucky × material.itemNum) + ``` + +#### 4.3.2 幸运系数 + +`Hero.lua` 行 231-233: + +```text +if total_lucky >= Random.Get(1, 100): // 即概率 = min(total_lucky, 100) % + total_exp *= 2 +``` + +> 也就是说:lucky 直接作为"双倍触发概率(%)"。理论上 lucky ≥ 100 时必触发双倍,但 `Random.Get(1,100)` 的下限是 1,因此**lucky=100 时必触发**。 + +#### 4.3.3 升星后属性变化 + +属性按需查表,参考 §4.2.3。升星仅会: +- 解锁可能的新技能(`unlockSkill(rid, heroId, newStar)`,见 §4.1.3) +- 增加可用天赋点(通过 `s_ZeroEmpty.HERO_STAR_TALENT` 累加) + +#### 4.3.4 升星上限 + +- `s_HeroStar` 总条数 = 6(从配置看 6 星即满) +- 服务端:`if not CFG.s_HeroStar:Get(heroInfo.star + 1): return ERR(HERO_STAR_MAX)` +- 6 星时 `rare1-5` 均为 0,即不需要经验也不能再升 + +#### 4.3.5 升星前置:**等级必须达到本星级上限** + +`Hero.lua` 行 185-188: + +```text +if heroInfo.level < sHeroStar.starLimit: return ERR(HERO_LEVEL_NO_ENOUGH) +``` + +#### 4.3.6 完整流程(伪代码) + +```text +function HeroStarUp(rid, heroId, items: [{itemId, itemNum}]): + heroInfo = HeroLogic:getHero(rid, heroId) + sHeroStar = CFG.s_HeroStar[heroInfo.star] + sHero = CFG.s_Hero[heroId] + + // 校验链 + if not CFG.s_HeroStar[heroInfo.star + 1]: return ERR(HERO_STAR_MAX) // 3012 + if heroInfo.level < sHeroStar.starLimit: return ERR(HERO_LEVEL_NO_ENOUGH) // 3013 + if items is empty: return ERR(HERO_NO_ITEM) // 3014 + for item in items: + if CFG.s_HeroStarExp[item.itemId].rareGroup != sHero.rare: + return ERR(HERO_ITEM_ERROR) // 3015 + if BagComponent.itemCount(item.itemId) < item.itemNum: + return ERR(ITEM_NOT_ENOUGH) + if Σ(item.itemNum) > 6: return ERR(HERO_ITEM_TO_MUCH) // 3016 + + lucky = 0; addExp = 0 + for item in items: + sHeroStarExp = CFG.s_HeroStarExp[item.itemId] + lucky += sHeroStarExp.lucky * item.itemNum + addExp += sHeroStarExp.exp * item.itemNum + BagComponent.delItem(item.itemId, item.itemNum) + + if lucky >= Random(1,100): addExp *= 2 // 双倍 + + HeroLogic:addHeroStarExp(rid, heroId, addExp): + if not CFG.s_HeroStar[heroInfo.star + 1]: return false + heroInfo.starExp += addExp + while heroInfo.starExp >= curStarReqExp: + heroInfo.star += 1 + HeroLogic:unlockSkill(rid, heroId, heroInfo.star, heroInfo) + if not next star: heroInfo.starExp = 0; break + else: heroInfo.starExp -= curStarReqExp; refresh exp threshold + cacleSyncHistoryPower / updateMapArmyHero + setActivitySchedule(HERO_STAR_LEVEL_COUNT, 1) +``` + +#### 4.3.7 RPC + 错误码 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_HeroStarUp` | 606 | `HERO_STAR_MAX` (3012), `HERO_LEVEL_NO_ENOUGH` (3013), `HERO_NO_ITEM` (3014), `HERO_ITEM_ERROR` (3015), `HERO_ITEM_TO_MUCH` (3016), `ITEM_NOT_ENOUGH` | + +--- + +### 4.4 技能升级 + +#### 4.4.1 技能槽位含义 + +- **槽位 1-4:普通技能**(被动/主动/触发/持续,看 `s_HeroSkill.type`)。这些技能通过 `unlockSkill` 按"达到某星级"解锁,技能 ID 通常为 `heroId * 100 + n`(n=1..4)。 +- **槽位 5:觉醒技能**。技能 ID 为 `heroId * 100 + 5`。仅当 1-4 技能全部满级(5 级)时自动解锁(见 §4.5)。 + +#### 4.4.2 升级消耗 + +- **道具**:`sHero.getItem`(即招募碎片)。**升技能与招募共享同一道具池**。 +- **数量**:查 `s_HeroSkillLevel`,主键计算: + + ```text + upLevelIndex = (Σ skillLevel) - skillCount + 1 // 第几次升级 + costItemId = sHero.getItem + cost = sHeroSkillLevel[upLevelIndex].costItemN // N = rare (1-5) + ``` + - 客户端等价 (`HeroProxy.cs` 行 405):`id = 100 + GetSkillAllLevel() - GetSkillCount()`(注意客户端用 100 偏移作为 ID)。 +- **金币**:**ROK 无金币消耗**(仅消耗碎片)。 + +#### 4.4.3 升级上限 + +- 单技能最大等级 = 5(`HeroProxy.IsAllSkillMax`:`GetSkillAllLevel() >= 20`,即 4 个技能 × 5 级) +- 整体上限通过 `s_HeroSkillEffect` 表是否存在 `(skillId * 1000 + nextLevel)` 来判定(`HeroLogic.lua` 行 158) + +#### 4.4.4 升级目标的随机性 ⚠️ + +**这是 ROK 一个非常重要且非直观的设计**——服务端**不允许玩家选择升哪个技能**! + +```text +function heroSkillLevelUp(rid, heroId): + skillIds = [] + for skillInfo in heroInfo.skills: + if CFG.s_HeroSkillEffect[skillInfo.skillId * 1000 + skillInfo.skillLevel + 1] exists: + skillIds.append({id: skillInfo.skillId, rate: 1000}) + // 扣道具 + BagComponent.delItem(sHero.getItem, count) + // 随机选一个可升级的技能 + skillId = Random.GetId(skillIds) // 等权重随机(rate 都是 1000) + skillLevel = ++ for that skillId + syncHero(...) + if checkHeroSkillFull(rid, heroId) AND has 5th skill: + heroAwake(rid, heroId) // 自动触发觉醒 +``` + +返回 `{skillId, skillLevel}`,客户端通过 `Hero_HeroSkillLevelUp.response` 收到这个结果,弹窗展示"恭喜你升级了 XX 技能"。 + +#### 4.4.5 RPC + 错误码 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_HeroSkillLevelUp` | 603 | `HERO_NOT_EXIST` (3003), `HERO_SKILL_MAX` (3004), `HERO_LEVEL_ITEM_NOT_ENOUGH` (3005) | + +--- + +### 4.5 觉醒 + +#### 4.5.1 觉醒条件 + +1. 该英雄存在第 5 个技能配置:`CFG.s_HeroSkill[heroId * 100 + 5]` 不为空 +2. 前 4 个技能**全部满级**:`HeroLogic:checkHeroSkillFull(rid, heroId)` 返回 true(即没有任何技能能继续升级) +3. `Hero.lua` 行 101 校验:`if not table.size(heroInfo.skills) < 4 or not checkHeroSkillFull: return ERR(HERO_SKILL_NOT_MAX)` + - **注意源码这里有运算符优先级问题**(`not table.size < 4` 实际为 `(not table.size) < 4` → `false < 4` 总成立)。实际生效条件为第二部分 `checkHeroSkillFull`。重写时按"技能数 >= 4 且全满"标准实现即可。 + +#### 4.5.2 觉醒消耗 + +**无任何消耗**——`heroAwake` 直接把第 5 技能加入 `heroInfo.skills`,level=1。 + +#### 4.5.3 觉醒效果 + +- 解锁第 5 个技能(觉醒技),等级固定 1。 +- 技能 ID 公式:`heroId * 100 + 5` +- 战力重算 + 同步部队 + +#### 4.5.4 自动 vs 手动 + +- **自动**:在 `heroSkillLevelUp` 升级技能后会判断,如果满足条件自动调 `heroAwake`,无需玩家主动操作。 +- **手动**:`Hero_HeroAwake` RPC 是冗余入口(兼容性),实际由前 4 技能升满后自动触发。 + +#### 4.5.5 完整流程 + +```text +function heroAwake(rid, heroId): + skillId = heroId * 100 + 5 + if skillId already in heroInfo.skills: return existing + heroInfo.skills.append({skillId, skillLevel=1}) + setHero & syncHero(true) + cacleSyncHistoryPower + return {skillId, skillLevel=1} +``` + +#### 4.5.6 RPC + 错误码 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_HeroAwake` | 604 | `HERO_NOT_EXIST` (3003), `HERO_NOT_AWAKE` (3006), `HERO_SKILL_NOT_MAX` (3007) | + +--- + +### 4.6 天赋树 + +#### 4.6.1 三套天赋页(TalentIndex 1/2/3) + +- 每个英雄有 **3 套独立的天赋页**,对应 `talentTrees[1]`、`talentTrees[2]`、`talentTrees[3]`。 +- 玩家可任意切换"当前激活的天赋页" (`talentIndex`),**仅激活页**的天赋影响战力和战斗属性。 +- 切换时需要消耗道具或钻石(详见 §4.6.5)。 + +> 服务端校验 `if index > 3: return ERR(HERO_TALENT_INDEX_ERROR)`。索引范围 1-3。 + +#### 4.6.2 天赋点来源 + +**`heroInfo.talentPoint` 字段已废弃**(`HeroDef.lua` 仍保留,但 `HeroLogic.lua` 中所有"消耗 talentPoint"的代码都被注释掉了,见行 62-69、428-432、488-491、618)。 + +实际"当前可用天赋点"通过**实时计算**: + +```text +function checkTalentPoint(rid, heroId, index): + heroInfo = getHero(rid, heroId) + rare = sHero.rare + + heroLevelMap = s_ZeroEmpty[HERO_LEVEL_TALENT][rare] // {[level] = point} + heroStarMap = s_ZeroEmpty[HERO_STAR_TALENT] // {[star] = point} + + talentPoint = 0 + for (level, point) in heroLevelMap: + if heroInfo.level >= level: talentPoint += point // 累加所有已达成的等级阶段 + for (star, point) in heroStarMap: + if heroInfo.star >= star: talentPoint += point // 累加所有已达成的星级阶段 + + // 减去当前天赋页已用点数 + if heroInfo.talentTrees[index]: + for _ in heroInfo.talentTrees[index].talentTree: + talentPoint -= 1 + + return talentPoint > 0 +``` + +> **关键约束**:所有 3 套天赋页**共用同一池天赋点**(每页独立计算用了多少,但池子相同)。 + +客户端等价 `HeroProxy.GetTotalTalentPoint`(行 249-271):遍历 `s_HeroLevel` 与 `s_HeroStar` 表,累加所有满足条件的 `starEffectData`。 + +#### 4.6.3 节点前置依赖 + +节点学习需满足三个条件(依次校验,见 `Hero.lua` 行 252-271): + +1. **天赋点充足** (`checkTalentPoint`),否则 `HERO_TALENT_POINT_NOT_ENOUGH` (3017)。 +2. **该英雄能学这个天赋** (`checkHeroStudyTalent`): + - 取 `sHero.talent` 对应的根天赋(3 个)→ 它们的 `gainTree` + - 看 `s_ZeroEmpty.TALENT[gainTree][id]` 是否为真 + - 否则 `HERO_CAN_NOT_STUDY_TALENT` (3018) +3. **前置节点已学** (`checkHeroStudyTalentPre`): + - 取目标节点 `level` + - 若 `level == 1`:允许(无前置) + - 否则:检查同一页 `talentTree` 中**是否存在 level = `level - 1`** 的节点 + - 否则 `HERO_TALENT_PRE_NOT_STUDY` (3019) +4. **同级互斥**(同一 level 同一页只能选一个,见 `checkHeroStudyTalentSame`): + - 若同一页已有同 `level` 的节点 → `HERO_STUDY_SAME_LEVEL_TALENT` (3020) + +#### 4.6.4 学习消耗 + +**学习单个节点无任何道具/资源消耗**——仅消耗"天赋点"(隐式记账)。 + +#### 4.6.5 切换页 vs 重置页 + +两者都用 `Hero_ChangeTalentIndex` (608) 和 `Hero_ResetTalent` (609) 两个 RPC。**关键区别**: + +- **`Hero_ChangeTalentIndex`**:切换激活页(`talentIndex`) + - 若 `index == heroInfo.talentIndex`:返回 `HERO_TALENT_INDEX_SAME` (3022) + - 消耗:`useDenar=false` 时扣 1 个 `s_Config.talentResetItemID` 道具;`useDenar=true` 时扣 `s_Item[id].shortcutPrice` 钻石 + - 失败:`ITEM_NOT_ENOUGH` / `ROLE_DENAR_NOT_ENOUGH` + +- **`Hero_ResetTalent`**:重置某页的所有已学天赋 + - **若 `index == heroInfo.talentIndex`**(重置当前页):才需要消耗道具/钻石(同上) + - **若重置非当前页**:免费 + - 操作:`heroInfo.talentTrees[index].talentTree = {}` (清空) + +#### 4.6.6 天赋页改名 + +`Hero_ModifyTalentName` (610): +- 名称长度校验:`s_Config.heroNameLimit[1] <= utf8.len(name) <= heroNameLimit[2]` +- 失败:`HERO_TALENT_NAME_LENGTH_ERROR` (3023) +- 写入:`heroInfo.talentTrees[index].name = name` + +#### 4.6.7 天赋专精(Mastery) + +每个 `gainTree` 有多个 `s_HeroTalentMastery` 阶段。属性/技能加成在 `getHeroTalentAttr` 中**根据已学节点数 (`count`)** 计算: + +```text +function getHeroTalentAttr(rid, heroId): + talentTree = heroInfo.talentTrees[heroInfo.talentIndex].talentTree + + battleSkill = {} + battleAttr = {} + talentCount = {} // {gainTree -> count} + + // 节点本身的加成 + for id in talentTree: + node = s_HeroTalentGainTree[id] + if node.battleSkillID > 0: battleSkill[node.battleSkillID] = node.battleSkillID + for i, attrName in node.attrType: battleAttr[attrName] += node.attrNumber[i] + talentCount[node.gainTree] += 1 + + // 专精的加成 + sHeroTalentMastery = CFG.s_HeroTalentMastery:Get() // 整张表 + for (gainTree, count) in talentCount: + for i from count downto 1: + mastery = sHeroTalentMastery[gainTree][i] + if mastery exists: + // 触发该层专精 + if mastery.battleSkillID > 0: battleSkill[battleSkillID] = battleSkillID + for j, attrName in mastery.attrType: battleAttr[attrName] += mastery.attrNumber[j] + + return {battleSkill, battleAttr} +``` + +> ⚠ ROK 此处 mastery 是按 `[gainTree][index]` 二级索引取,**index 含义是"按已学点数顺序排"**,并非客户端 `HeroProxy.talentScore` 用的 `needTalentPoint` 阈值——两者实现略有差异,重写时应以**服务端 Lua 为准**。 + +#### 4.6.8 完整流程(伪代码) + +```text +function TalentUp(rid, heroId, id, index): + heroInfo = HeroLogic:getHero(rid, heroId) + if not heroInfo: return ERR(HERO_NOT_EXIST) + if not checkTalentPoint(rid, heroId, index): return ERR(HERO_TALENT_POINT_NOT_ENOUGH) + if not checkHeroStudyTalent(heroId, id): return ERR(HERO_CAN_NOT_STUDY_TALENT) + if not checkHeroStudyTalentPre(rid, heroId, index, id): return ERR(HERO_TALENT_PRE_NOT_STUDY) + if not checkHeroStudyTalentSame(rid, heroId, index, id): return ERR(HERO_STUDY_SAME_LEVEL_TALENT) + + // 学习 + if not heroInfo.talentTrees[index]: + heroInfo.talentTrees[index] = {index, talentTree=[], name=""} + heroInfo.talentTrees[index].talentTree.append(id) + + setHero & syncHero(true) + TaskLogic:addTaskStatisticsSum(HERO_TALENT_NUM, 1) + cacleSyncHistoryPower + updateMapArmyHero(rid, heroId) + return {result: true} +``` + +#### 4.6.9 RPC + 错误码汇总 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_TalentUp` | 607 | `HERO_NOT_EXIST`, `HERO_TALENT_POINT_NOT_ENOUGH` (3017), `HERO_CAN_NOT_STUDY_TALENT` (3018), `HERO_TALENT_PRE_NOT_STUDY` (3019), `HERO_STUDY_SAME_LEVEL_TALENT` (3020) | +| `Hero_ChangeTalentIndex` | 608 | `HERO_NOT_EXIST`, `HERO_TALENT_INDEX_ERROR` (3021), `HERO_TALENT_INDEX_SAME` (3022), `ITEM_NOT_ENOUGH`, `ROLE_DENAR_NOT_ENOUGH` | +| `Hero_ResetTalent` | 609 | `HERO_NOT_EXIST`, `HERO_TALENT_INDEX_ERROR`, `ITEM_NOT_ENOUGH`, `ROLE_DENAR_NOT_ENOUGH` | +| `Hero_ModifyTalentName` | 610 | `HERO_TALENT_NAME_LENGTH_ERROR` (3023), `HERO_NOT_EXIST`, `HERO_TALENT_INDEX_ERROR` | + +--- + +### 4.7 装备穿戴 + +#### 4.7.1 装备槽位(8 个) + +来源:`Hero.lua` 行 430-437 + `BagProxy.cs` `EquipSubType` 枚举。 + +| equipIndex | 字段名 | EquipSubType | s_Item.subType | +|---|---|---|---| +| 1 | `head` | Head | `HELMET` | +| 2 | `breastPlate` | BreastPlate | `BREASTPLATE` | +| 3 | `weapon` | Weapon | `ARMS` | +| 4 | `gloves` | Gloves | `GLOVES` | +| 5 | `pants` | Pants | `PANTS` | +| 6 | `accessories1` | accessories1 | `ACCESSORIES` | +| 7 | `accessories2` | accessories2 | `ACCESSORIES` | +| 8 | `shoes` | shoes | `SHOES` | + +字段值是**背包道具的 `itemIndex`**(不是 `itemId`),0 = 未装备。 + +#### 4.7.2 穿戴规则 + +1. 道具必须存在 (`getItem(rid, itemIndex)` 非空) +2. `s_Item.subType` 必须与目标槽位的 `subType` 一致;否则 `HERO_EQUIP_SUBTYPE_ERROR` (3025) +3. 英雄等级必须 ≥ `s_Equip.useLevel`;否则 `HERO_EQUIP_LV_NO_ENOUGH` (3024) +4. **英雄必须处于待命状态**(不在出征/守城军队中);否则 `HERO_EQUIP_NOT_IN_CITY` (3026) + - 通过 `checkHeroIdle` 遍历 `ArmyLogic:getArmy(rid)`,检查 `mainHeroId` / `deputyHeroId` + +#### 4.7.3 同一装备切换英雄(重要) + +`Hero_HeroWearEquip` 中(`Hero.lua` 行 459-467): + +1. 若该 itemInfo 已被其他英雄装备 (`itemInfo.heroId > 0`):从旧英雄上**清空对应槽位** (`beforeHeroInfo[attr] = 0`) +2. 若目标英雄的目标槽位**已有装备**:把旧装备的 `heroId` 清 0 +3. 写入:`heroInfo[attr] = itemIndex`,`itemInfo.heroId = heroId` + +#### 4.7.4 卸下装备 + +`Hero_TakeOffEquip` (612): +- 校验英雄待命 +- `itemInfo.heroId = 0`;`heroInfo[attr] = 0` +- 同步 Hero + Item + +#### 4.7.5 与背包的联动(**字段需要在 Item Entity 加 `heroId` 反向索引**) + +- `s_Item` 是道具基础,`s_Equip` 是装备扩展(`compose` 套装组 ID、`useLevel`、`attAddEx` 等) +- `s_EquipAtt` 定义属性枚举 +- `s_EquipCompose` 定义套装:`compose2/4/6/8` 件数效果,分别提供 `compose2/4/6/8AddEx` 属性加成 +- **专属装备加成**:`itemInfo.exclusive > 0` 且 `checkHeroTalent(heroId, exclusive)` 时,属性乘 `s_Config.equipTalentPromote × 2` 并向上 0.5 取整(`HeroLogic.lua` 行 822-825) + +#### 4.7.6 RPC + 错误码 + +| RPC | ID | 错误码 | +|---|---|---| +| `Hero_HeroWearEquip` | 611 | `ITEM_NOT_EXIST`, `HERO_EQUIP_SUBTYPE_ERROR` (3025), `HERO_EQUIP_LV_NO_ENOUGH` (3024), `HERO_EQUIP_NOT_IN_CITY` (3026), `HERO_EQUIP_ALREADY_WEAR` (3027) | +| `Hero_TakeOffEquip` | 612 | `HERO_EQUIP_NOT_IN_CITY` (3026) | + +--- + +### 4.8 战力计算 + +#### 4.8.1 战力公式 + +来源:`HeroCacle.lua` 全文 + `HeroProxy.cs.skillScore/talentScore`。 + +```text +power(hero) = + sHero.score // ① 基础战力 + + s_HeroLevel[rare*10000 + level].score // ② 等级战力(直接读,非累加) + + Σ s_HeroSkillEffect[skillId*1000 + lv].score // ③ 技能战力(所有已学技能) + + Σ s_HeroTalentGainTree[id].score // ④ 天赋节点战力(仅当前激活页) + + Σ s_HeroTalentMastery[group][i].score // ⑤ 天赋专精战力(按已学节点数触发的所有阶段) + + [装备分由 getHeroEquipAttr 间接生效,但 HeroCacle 中并未独立计算"装备战力分"] +``` + +> ⚠ ROK 服务端 `HeroCacle:caclePower` **没有显式"装备分"参与战力**——装备只影响战斗时的属性,不直接进总战力。客户端 `HeroProxy.UpdatePower` 同样只算 `base + level + skill + talent`。这一点重写时需要决策:要不要把装备纳入战力。**ROK 原版不纳入。** + +#### 4.8.2 属性查询:`getHeroAttr` + +战斗时按属性名查询英雄某项属性: + +```text +function getHeroAttr(rid, heroId, attrName, notCancleTalent=false): + num = 0 + if not notCancleTalent: + num += getHeroTalentAttr(rid, heroId).battleAttr[attrName] // 天赋 + num += getHeroEquipAttr(rid, heroId).battleAttr[attrName] // 装备 + num += cancleskillAttr(rid, heroId)[attrName] // 技能 + return num +``` + +> 副将(`deputyHero`)的天赋属性**不参与计算**(`notCancleTalent=true`,见行 917)。仅主将享受天赋加成。 + +#### 4.8.3 战力变化触发的事件 + +任意会影响战力的操作完成后会调: + +```text +RoleLogic:cacleSyncHistoryPower(rid, nil, nil, recalc=true) // 重算并同步 +``` + +并对涉及到的部队同步: + +```text +updateMapArmyHero(rid, heroId, newLevel?): + 遍历角色所有部队 + 如果该 heroId 是 main/deputy + 重新算 skills + talentAttr + levels + 通过 BattleAttrLogic:syncObjectHeroChange 通知战斗服务器 + 通过 ResourceLogic:roleArmyCollectSpeedChange 更新采集速度 +``` + +--- + +### 4.9 其他次要功能 + +#### 4.9.1 英雄列表排序 + +**4 种排序**(`HeroProxy.SortType`):`Rare` / `Star` / `Level` / `Power`(还有 `Recomend` 未实现)。 + +所有比较器(`CompareHeroByXxx`,共 4 个)都遵循通用规则: + +```text +xScore = (x.data != null) ? 1 : (x.itemCount < x.config.getItemNum ? 0 : 2) +yScore = (同上) +// 排序优先级:未集齐(0) < 已拥有(1) < 可召唤(2) +if xScore != yScore: 按 xScore 排 +// 同分组内:按主排序字段(rare/star/level/power),同分用 (rare → star → level → power → summonTime) 依次比 +``` + +最后 `heros.Reverse()`(默认降序)。 + +#### 4.9.2 三段分组 + +`GetHerosBySort` 返回三个 List: + +- `own`:`hero.data != null`(已拥有) +- `summon`:`hero.itemCount >= getItemNum`(碎片够,可召唤) +- `nosummon`:`itemCount < getItemNum`(碎片不够) + +⚠ `hero.config.listDisplay == 1` 的英雄**不进任何分组**(怪物英雄,列表不显示)。 + +#### 4.9.3 列表筛选 + +ROK 客户端**没有显式的"按文明/职业筛选"功能**。仅有上述排序 + 分组。 + +#### 4.9.4 雕像兑换 + +`Hero_ExchangeHeroItem` (605):用 `sHero.exchange` 道具(通用雕像)兑换为 `sHero.getItem` 道具(具体英雄碎片)。 + +校验链: +- `sHero.exchange == Enum.Exchange.NO (0)` → `HERO_NOT_EXCHANGE` (3008) +- 该英雄已存在且**技能数 ≥4 且全满级** → `HERO_EXCHANGE_SKILL_MAX` (3009)(技能满了说明不需要再兑换碎片) +- 雕像道具不足 → `HERO_EXCHANGE_ITEM_NOT_ENOUGH` (3010) + +操作:扣 `sHero.exchange` × `itemNum` → 加 `sHero.getItem` × `itemNum`(**1:1 转换**)。 + +#### 4.9.5 红点统计 + +`HeroProxy.GetCanSummonerHeroCount` 每个英雄遍历,满足任一条件 +1: +- 已拥有 + `IsCanUpSkill`(可升技能:未全满 + 碎片够) +- 已拥有 + `level == starLimit` 且 `!IsStarMaxLevel`(可升星) +- 已拥有 + `GetCurPageRemainPoint(talentIndex) > 0`(有天赋点未用) +- 未拥有 + 碎片够(可召唤) + +--- + +## 5. 协议清单(C2G / G2C / 推送) + +> 模块号:Hero Module 601-700(请求),Hero Module 30201-30250(推送)。 + +| 协议名 | 协议号 | 方向 | request | response | 用途 | +|---|---|---|---|---|---| +| `Hero_SummonHero` | 601 | C2G | `heroId` | 无 | 招募英雄 | +| `Hero_AddHeroExp` | 602 | C2G | `heroId, itemId, itemNum` | `result, itemId, itemNum` | 使用经验书升级 | +| `Hero_HeroSkillLevelUp` | 603 | C2G | `heroId` | `skillId, skillLevel` | 升技能(**服务端随机选目标**) | +| `Hero_HeroAwake` | 604 | C2G | `heroId` | `skillId, skillLevel` | 手动觉醒(一般由技能升级自动触发) | +| `Hero_ExchangeHeroItem` | 605 | C2G | `heroId, itemNum` | `result` | 雕像兑换碎片 | +| `Hero_HeroStarUp` | 606 | C2G | `heroId, items: [{itemId, itemNum}]` | `result` | 升星(最多 6 个材料) | +| `Hero_TalentUp` | 607 | C2G | `heroId, id, index` | `result` | 学习单个天赋节点 | +| `Hero_ChangeTalentIndex` | 608 | C2G | `heroId, index, useDenar` | `result` | 切换激活的天赋页 | +| `Hero_ResetTalent` | 609 | C2G | `heroId, index, useDenar` | `result` | 重置某天赋页 | +| `Hero_ModifyTalentName` | 610 | C2G | `heroId, index, name` | `result` | 改天赋页名 | +| `Hero_HeroWearEquip` | 611 | C2G | `heroId, itemIndex, equipIndex` | `result` | 穿戴装备 | +| `Hero_TakeOffEquip` | 612 | C2G | `heroId, equipIndex` | `result` | 卸下装备 | +| `Hero_HeroInfo` | 30201 | G2C 推送 | `heroInfo: *HeroInfo(heroId), noShow: bool` | 无 | 推送英雄完整数据(新增/变化);`noShow=true` 表示酒馆场景不要弹"获得英雄"展示 | + +**关联协议(其他模块发布的 hero 相关):** + +| 协议名 | 协议号 | 方向 | 用途 | +|---|---|---|---| +| `Build_Tavern` | 407 | C2G | 酒馆开宝箱(间接获取英雄碎片) | +| `Build_DefendHero` | 410 | C2G | 设置驻防主/副将 | +| `Shop_GetLimitHeroInfo` | 907 | C2G | 限时英雄商店(碎片售卖) | + +**子结构定义**(`Common.sproto`): + +- `.SkillInfo { skillId, skillLevel }` +- `.TalentTrees { index, talentTree: *integer, name }` +- `.HeroInfo { heroId, star, starExp, level, exp, summonTime, soldierKillNum, savageKillNum, skills, talentTrees, talentIndex, head, breastPlate, weapon, gloves, pants, accessories1, accessories2, shoes }` +- `.Heros { heroId, num, isNew }`(Tavern 宝箱用,给的是碎片数 num) + +--- + +## 6. 错误码清单 + +来源:`ErrorCode.cs` 行 24–26(Role)+ 149–176(Hero)+ 224(Building)+ 473(Expedition)。 + +| 错误码 | 数值 | 含义 | +|---|---|---| +| `ROLE_HERO_NOT_EXIST` | 1004 | 角色场景下英雄不存在 | +| `ROLE_HERO_NOT_WAIT_STATUS` | 1005 | 英雄未处于待命状态 | +| `ROLE_HERO_STAR_NOT_ENOUGH` | 1006 | 英雄星级不足 | +| `HERO_OPEN_DAYS_NOT_ENOUGH` | 3000 | 招募时所在王国未到该英雄解锁天数 | +| `HERO_SUMMON_ITEM_NOT_ENOUGH` | 3001 | 招募碎片不足 | +| `HERO_ALREADY_EXIST` | 3002 | 英雄已存在 | +| `HERO_NOT_EXIST` | 3003 | 英雄不存在 | +| `HERO_SKILL_MAX` | 3004 | 技能已满级,无法升级 | +| `HERO_LEVEL_ITEM_NOT_ENOUGH` | 3005 | 升技能道具不足 | +| `HERO_NOT_AWAKE` | 3006 | 该英雄不存在觉醒技能 | +| `HERO_SKILL_NOT_MAX` | 3007 | 技能未满,无法觉醒 | +| `HERO_NOT_EXCHANGE` | 3008 | 该英雄无法使用通用雕像兑换 | +| `HERO_EXCHANGE_SKILL_MAX` | 3009 | 技能已满,无需兑换碎片 | +| `HERO_EXCHANGE_ITEM_NOT_ENOUGH` | 3010 | 通用雕像不足 | +| `HERO_LEVEL_LIMIT` | 3011 | 等级达到当前星级上限,无法升级 | +| `HERO_STAR_MAX` | 3012 | 星级已满 | +| `HERO_LEVEL_NO_ENOUGH` | 3013 | 等级未达到 starLimit,无法升星 | +| `HERO_NO_ITEM` | 3014 | 升星材料为空 | +| `HERO_ITEM_ERROR` | 3015 | 升星材料的 rareGroup 与英雄稀有度不符 | +| `HERO_ITEM_TO_MUCH` | 3016 | 单次升星材料数 > 6 | +| `HERO_TALENT_POINT_NOT_ENOUGH` | 3017 | 天赋点不足 | +| `HERO_CAN_NOT_STUDY_TALENT` | 3018 | 该英雄无法学习此天赋节点 | +| `HERO_TALENT_PRE_NOT_STUDY` | 3019 | 前置天赋未学 | +| `HERO_STUDY_SAME_LEVEL_TALENT` | 3020 | 同 level 天赋已学过 | +| `HERO_TALENT_INDEX_ERROR` | 3021 | 天赋页索引非法(>3) | +| `HERO_TALENT_INDEX_SAME` | 3022 | 切换的天赋页与当前页相同 | +| `HERO_TALENT_NAME_LENGTH_ERROR` | 3023 | 天赋页名称长度违规 | +| `HERO_EQUIP_LV_NO_ENOUGH` | 3024 | 英雄等级低于装备 useLevel | +| `HERO_EQUIP_SUBTYPE_ERROR` | 3025 | 装备 subType 与槽位不匹配 | +| `HERO_EQUIP_NOT_IN_CITY` | 3026 | 英雄不在城内(出征中) | +| `HERO_EQUIP_ALREADY_WEAR` | 3027 | 该装备已被装备(实际代码中走"自动卸旧再穿新",此码可能未触发) | +| `BUILDING_WALL_HERO_SAME` | 6019 | 驻防主副将相同 | +| `EXPEDITION_HERO_NOT_FREE` | 17004 | 远征英雄非空闲 | + +通用错误码(非 Hero 模块但 Hero 流程会复用): + +| 错误码 | 含义 | +|---|---| +| `HERO_ARG_ERROR` | 参数错误(在 SummonHero 中作为 ErrorCode 引用,源码未列出具体数值,按通用 5001 范围) | +| `CFG_ERROR` | 配置缺失 | +| `ITEM_NOT_EXIST` | 道具不存在 | +| `ITEM_NOT_ENOUGH` | 道具数量不足 | +| `ROLE_DENAR_NOT_ENOUGH` | 钻石不足 | + +--- + +## 7. UI 流程 + +> ROK 客户端 PureMVC 架构,每个 Mediator 对应一个 Panel;按业务流程列出。 + +### 7.1 入口:CaptainMediator(统帅总览) + +**Panel**:`UI.s_captain`(`CaptainMediator` + `CaptainView`) + +**注册 Notification**: +- `CmdConstant.GetNewHero` - 拿到新英雄 +- `Hero_HeroStarUp.TagName` - 升星响应 +- `CmdConstant.UpdateHero` - 英雄数据更新 +- `Hero_HeroSkillLevelUp.TagName` - 技能升级响应 +- `CmdConstant.ItemInfoChange` - 道具变化(用于刷新升技能道具显示) +- `CmdConstant.HeroListVisible` - 显示/隐藏列表 +- `CmdConstant.RefreshEquipRedPoint` - 装备红点 +- `CmdConstant.HeroSceneVisible` - 3D 场景显示 + +**关键流程**: +1. 进入面板 → `InitData` 加载 6 个 prefab:`UI_LC_Captain`、`UI_Item_CaptainHead`、`UI_Item_CaptainSummon`、`UI_Item_CaptainPartline`、`UI_Item_CaptainSkill`、`UI_LC_CaptainUnSum` +2. 从 `PlayerPrefs("Hero_SortType_{rid}", default Rare)` 读上次排序方式 +3. `HeroProxy.GetHerosBySort` → 三段分组(已拥有 / 可召唤 / 未集齐) +4. 列表用 `ListView` 渲染: + - `UI_LC_Captain` 行:每行 2 个已拥有/可召唤英雄 + - `UI_Item_CaptainPartline` 分隔行 + - `UI_LC_CaptainUnSum` 行:每行 2 个未集齐英雄 +5. 选中某英雄后: + - 加载 `m_hero.config.heroModel` Spine 动画 + - 加载 `m_hero.config.heroScene` 场景 prefab + - 播放 `voiceSelect` 语音 + +**右侧详情面板切换**(`view.m_UI_Item_CaptainData` 总览态): +- 点击 `m_btn_starUp_GameButton` → `ShowStarUp` → 检查 `level >= starLimit` → 打开升星面板 +- 点击 `StandardButton_skillup` → `ShowSkillUp` → 打开技能升级面板 +- 点击 `StandardButton_gift` → `ShowTalent` → 打开天赋树面板 +- 点击 `m_btn_changeEquip_GameButton` → 切换到装备界面(条件:英雄已拥有 + 城堡等级 ≥ `s_SystemOpen[11000].openLv`) + +### 7.2 招募流程 + +**入口**: +1. 玩家在主界面/任务引导/酒馆 → 跳转 `UI.s_captain` +2. 选中"未集齐"英雄 → 显示碎片进度,碎片不够时灰显 +3. 选中"可召唤"英雄 → 显示"招募"按钮 +4. 点击招募 → `HeroProxy.SummonHero(id)` → 发 `Hero_SummonHero` 协议 +5. 服务端推 `Hero_HeroInfo`(首个英雄会推 `noShow=true` 屏蔽展示) +6. `HeroProxy.UpdateHeroInfo` 收到后:若是新英雄(bNewHero=true && !firstGetData && !noShow)→ 发 `CmdConstant.GetNewHero` 通知 +7. `HeroCmd.Execute` 处理 `GetNewHero`: + - 关闭 `UI.s_gameTool` + - 若该英雄是文明初始英雄(`heroId == civilization.initialHero`)→ 触发首次获得引导 + - 否则打开 `UI.s_captainSummon` 展示新英雄(3D 镜头 + 名字/称号/品质/3 个天赋图标 + 文明标识) +8. 关闭召唤展示后回到主流程 + +### 7.3 经验升级流程 + +**Panel**:`UI_Win_CaptainLevelUp` (`UI_Win_CaptainLevelUpMediator`) + +1. 在详情面板 → 经验条上点 "+" → 打开经验升级窗 +2. 列出背包中所有 `subType == 经验书` 的道具 +3. 玩家选数量 + 确认 → 发 `Hero_AddHeroExp(heroId, itemId, itemNum)` +4. 服务端响应后 `HeroProxy.UpdateHeroInfo` 推送,UI 自动刷新经验/等级 + +### 7.4 升星流程 + +**Panel**:`UI_Item_CaptainStarUp`(嵌入 CaptainView 内) + +**入口**:`m_btn_starUp_GameButton` +1. 校验 `level >= sHeroStar.starLimit`(不够提示 `Tip` 文本 166056,说明"先升到 X 级才能升星") +2. 显示当前 star/starExp 进度条 + 可选材料列表(来自 `s_HeroStarExp` 按 `rareGroup == hero.rare` 过滤) +3. 玩家选若干个(≤6) → "升星"按钮 → 发 `Hero_HeroStarUp(heroId, items)` +4. 收到 `Hero_HeroStarUp.response.result == true` 后调 `m_UI_Item_CaptainStarUp.OnAddStarExpSuccess()` 播放表现 +5. 若产生升星:会单独弹 `UI_IF_CaptainStarUpSuccess` 升星成功界面 + +### 7.5 技能升级流程 + +**Panel**:`UI_Item_CaptainSkillUp`(嵌入 CaptainView 内) + +**入口**:`m_UI_Item_CaptainData.AddSkillUpButtonClickedEvent` → `ShowSkillUp` +1. 显示该英雄的 5 个技能 + 当前等级 +2. 显示升级所需碎片数 (`HeroProxy.Hero.GetSkillLevelUpCostItemNum`) + 背包余量 +3. 点击"升级技能"按钮 → 发 `Hero_HeroSkillLevelUp(heroId)` +4. 服务端响应 `{skillId, skillLevel}` → `HeroSkillLevelUpSuceess` 高亮"被升级"的那个技能(**玩家不知道会升哪个**!) +5. 若升级后达到觉醒条件:服务端自动触发 `heroAwake`,再推一次 `Hero_HeroInfo` +6. 升级成功也会弹 `UI_IF_CaptainSkillUpSuccess` 显示效果 + +### 7.6 天赋树流程 + +**Panel**:`UI_Item_CaptainTalent`(嵌入 CaptainView 内)+ 子 SubView `UI_Item_CaptainTalent_SubView` + +**入口**:`m_UI_Item_CaptainData.AddTalentButtonClickedEvent` → `ShowTalent` +1. 显示 3 个 tab(对应 talentIndex 1/2/3,可改名 `UI_Win_TalentChangeName`) +2. 当前 tab 显示一棵天赋树(3 棵 gainTree 横排,每棵有 N 层节点) +3. 显示剩余天赋点 (`HeroProxy.Hero.GetCurPageRemainPoint`) +4. 点击节点 → 校验 → 发 `Hero_TalentUp(heroId, id, index)` +5. 切换 tab → 弹 `UI_Win_TalentChangeAlert` 询问 → 确认后发 `Hero_ChangeTalentIndex`(默认用道具,可选钻石) +6. 重置当前 tab → 同样的 Alert 流程 → 发 `Hero_ResetTalent` + +### 7.7 装备穿戴流程 + +**Panel**:`UI_Item_CaptainEquip`(嵌入 CaptainView 内)+ `UI_Item_CaptainEquipUse` + `UI_Item_CaptainEquipList` + +**入口**:`m_btn_changeEquip_GameButton`(条件:英雄已拥有 + 城堡等级 ≥ 11000.openLv) +1. 8 个槽位(按 §4.7.1 顺序排列),已穿戴的显示道具图标,空位显示槽位占位图 +2. 点击某槽位 → 弹"选择装备"列表,从背包筛 `subType == 槽位 subType` 的道具 +3. 选中道具 → 发 `Hero_HeroWearEquip(heroId, itemIndex, equipIndex)` +4. 长按已穿戴的 → 弹"卸下" → 发 `Hero_TakeOffEquip(heroId, equipIndex)` + +### 7.8 雕像兑换流程 + +**Panel**:`UI_Win_CaptainItemSource`(兑换 + 道具来源面板) + +1. 玩家进入英雄详情,看到该英雄的招募碎片 → 点击 "+" → 显示获取来源 +2. 若 `sHero.exchange > 0`(可雕像兑换)→ 显示"用通用雕像兑换"入口 +3. 玩家选数量 → 发 `Hero_ExchangeHeroItem(heroId, itemNum)` +4. 服务端 1:1 转换 → 同步背包道具 + +### 7.9 酒馆开宝箱(碎片来源) + +**Panel**:`UI.s_tavernSummon`(`TavernSummonMediator` + `TavernSummonView`) + +**两种宝箱**: +- 白银宝箱(type=1):CD 时间到给免费次数,或扣 `silverBoxOpenItem` × `silverBoxOpenItemNum` 数量;不足时可用钻石(`shopPrice`) +- 黄金宝箱(type=2):同理,CD 长(`s_BuildingTavern.goldBoxCD`) + +**操作**: +- 免费开 / 单开 / 全开(≥10 个时显示) +- 协议:`Build_Tavern(type, free, count, useDenar)` +- 响应:`RewardInfo { items, heros, ... }` + `count` + `type` + +**结果展示**: +- 单开(count < 10):4 格抽奖动画,热英雄碎片插入随机位置 +- 多开(count ≥ 10):跳转 `UI.s_tavernReward` 滚动展示完整列表 + +> **酒馆只产出"碎片+道具",不直接给英雄实体!** 英雄实体一律通过 `Hero_SummonHero` 流程。 + +--- + +## 8. 与 Survivors 实现的对照点 + +### 8.1 架构对照 + +| 维度 | ROK | Survivors(迁移后) | +|---|---|---| +| 客户端框架 | PureMVC + ILRuntime/IFix | ET + YIUI + HybridCLR | +| 协议 | sproto | protobuf3 | +| 服务端 | C++ + Lua(snax proxy) | ET Server(Scene-Entity 模型) | +| 配置 | Lua `tabtoy` → `Configs.data` | Luban → JSON + 生成 C# | +| 持久化 | 自研 `MSM.d_hero[rid]` 内存 + 落库 | MongoDB(`cn.etetet.db` 封装) | + +### 8.2 PureMVC Proxy → ET Component 映射 + +| ROK | Survivors | +|---|---| +| `HeroProxy` (PureMVC) | `HeroComponent` 挂在 Player Entity 上 | +| `HeroProxy.Hero` 包装类 | `Hero` 子 Entity(继承 ET Entity) | +| `HeroProxy.UpdateHeroInfo(notification)` | `Hero_HeroInfoHandler` 服务端推送消息处理器 | +| `HeroProxy.AnalysisHero` 初始化 | `HeroComponent.Awake` 时根据所有 `HeroConfig` 创建占位 | +| `HeroProxy.GetHerosBySort` | `HeroComponent.GetHerosBySort`(保留 4 种排序) | +| `HeroCmd` Notification 分发 | ET 事件系统 `EventSystem.Publish` | + +### 8.3 sproto → protobuf 字段映射 + +| sproto type | protobuf 等价 | +|---|---| +| `integer` | `int64`(默认)或 `int32`(明确小字段) | +| `boolean` | `bool` | +| `string` | `string` | +| `*Type` (list) | `repeated Type` | +| `*Type(key)` (map by key) | `map`,**注意**:sproto 用对象字段当 key,protobuf map 是显式 KV | + +> **重要差异**: +> - sproto `*HeroInfo(heroId)` 是"以 heroId 字段聚类的数组" → protobuf 应映射为 `map` +> - sproto `*TalentTrees(index)` → `map` +> - sproto 的 tag 不需要在 protobuf 中保留,按字段顺序新分配即可,但**建议保留语义化分组** + +### 8.4 配置表对照 + +| ROK Define | Survivors Luban Bean | 备注 | +|---|---|---| +| `HeroDefine` | `HeroConfig` | 直接对齐,`getItem/getItemNum` 保留 | +| `HeroLevelDefine` | `HeroLevelConfig` | 主键 `(rareGroup, lv)` 双键 | +| `HeroStarDefine` | `HeroStarConfig` | `rare1-5` 字段保留,或重构成 `map reqExp` | +| `HeroStarExpDefine` | `HeroStarExpConfig` | 1:1 | +| `HeroSkillDefine` | `HeroSkillConfig` | 1:1 | +| `HeroSkillEffectDefine` | `HeroSkillEffectConfig` | 主键 `skillId * 1000 + level` 公式保留 | +| `HeroSkillLevelDefine` | `HeroSkillLevelConfig` | 1:1 | +| `HeroTalentDefine` | `HeroTalentConfig` | `gainTree` 由 string 改 int(Luban 类型严格) | +| `HeroTalentGainTreeDefine` | `HeroTalentNodeConfig` | **建议改名**避免单词冗长 | +| `HeroTalentMasteryDefine` | `HeroTalentMasteryConfig` | 1:1 | + +### 8.5 RPC 命名映射 + +| ROK sproto | Survivors protobuf | ResponseType | +|---|---|---| +| `Hero_SummonHero` | `C2G_SummonHero` | `G2C_SummonHero` | +| `Hero_AddHeroExp` | `C2G_HeroAddExp` | `G2C_HeroAddExp` | +| `Hero_HeroSkillLevelUp` | `C2G_HeroSkillUp` | `G2C_HeroSkillUp` | +| `Hero_HeroAwake` | `C2G_HeroAwake` | `G2C_HeroAwake` | +| `Hero_ExchangeHeroItem` | `C2G_ExchangeHeroItem` | `G2C_ExchangeHeroItem` | +| `Hero_HeroStarUp` | `C2G_HeroStarUp` | `G2C_HeroStarUp` | +| `Hero_TalentUp` | `C2G_TalentUp` | `G2C_TalentUp` | +| `Hero_ChangeTalentIndex` | `C2G_ChangeTalentIndex` | `G2C_ChangeTalentIndex` | +| `Hero_ResetTalent` | `C2G_ResetTalent` | `G2C_ResetTalent` | +| `Hero_ModifyTalentName` | `C2G_ModifyTalentName` | `G2C_ModifyTalentName` | +| `Hero_HeroWearEquip` | `C2G_HeroWearEquip` | `G2C_HeroWearEquip` | +| `Hero_TakeOffEquip` | `C2G_TakeOffEquip` | `G2C_TakeOffEquip` | +| `Hero_HeroInfo`(推送) | `G2C_HeroInfo`(推送) | - | + +### 8.6 数据存储映射 + +| ROK | Survivors | +|---|---| +| `MSM.d_hero[rid]` | MongoDB collection `Hero`(按 `playerId + heroId` 复合主键) | +| `req.Get / req.Set / req.Add` | `DBComponent.Query / .Save` | +| `syncHero` 推送 | `MessageHelper.SendToClient(playerId, new G2C_HeroInfo {...})` | +| `cacleSyncHistoryPower` | `PlayerComponent.RecalcPower()` + 推送 | + +--- + +## 9. 实现优先级建议 + +按 ROK 业务复杂度 + Survivors 现有能力评估,建议拆 4 期实现。 + +### P1 - 必做基础(招募 + 升级)⭐⭐⭐ + +> **目标**:跑通"获得碎片 → 招募 → 升级到等级上限"完整链路。 + +- [ ] `HeroConfig` + `HeroLevelConfig` + `HeroStarConfig` 三张表 Luban 化 +- [ ] `Hero` Entity 设计(仅持久化 `heroId/star/starExp/level/exp/summonTime/skills`) +- [ ] `HeroComponent` 挂在 Player Entity 上 +- [ ] RPC:`C2G_SummonHero` + `C2G_HeroAddExp` +- [ ] 服务端业务:`SummonHero` 校验链 + `addHeroExp` 循环升级 + 等级封顶 +- [ ] 推送:`G2C_HeroInfo` +- [ ] GM 命令:`gm.hero.add(heroId)` / `gm.hero.addExp(heroId, amount)` / `gm.hero.list` +- [ ] 战力计算:`base + level`(先简化,跳过技能/天赋/装备) + +### P2 - 战斗相关(升星 + 技能 + 装备)⭐⭐ + +> **目标**:英雄养成的核心数值闭环。 + +- [ ] `HeroStarExpConfig` + `HeroSkillConfig` + `HeroSkillEffectConfig` + `HeroSkillLevelConfig` +- [ ] RPC:`C2G_HeroStarUp` + `C2G_HeroSkillUp` + `C2G_HeroAwake` +- [ ] 升星:6 个材料 + 幸运双倍机制(`lucky >= Random(1,100)`) +- [ ] 升星:解锁高阶技能(`unlockSkill`) +- [ ] 技能升级:**服务端随机选目标技能** + 5 级满 + 触发觉醒 +- [ ] 觉醒:第 5 技能 ID = `heroId * 100 + 5` +- [ ] 8 槽位装备:`EquipConfig` + `EquipAttConfig` + `EquipComposeConfig` +- [ ] RPC:`C2G_HeroWearEquip` + `C2G_TakeOffEquip` +- [ ] 装备切换英雄时自动从旧英雄卸下 +- [ ] 套装属性(2/4/6/8 件) +- [ ] 战力计算:加 `skill` 分 + +### P3 - 天赋系统 ⭐⭐ + +> **目标**:高阶差异化养成。 + +- [ ] `HeroTalentConfig` + `HeroTalentGainTreeConfig` + `HeroTalentMasteryConfig` +- [ ] `s_ZeroEmpty.TALENT / HERO_LEVEL_TALENT / HERO_STAR_TALENT` 三张全局表(Survivors 可改为 `HeroTalentGlobalConfig` 单表) +- [ ] RPC:`C2G_TalentUp` + `C2G_ChangeTalentIndex` + `C2G_ResetTalent` +- [ ] 学习单节点:5 个校验链(点足 / 能学 / 前置 / 同级互斥) +- [ ] 切换页:当前页切换才收费 +- [ ] 重置页:仅当前页收费 +- [ ] 战力计算:加 `talent` 节点分 + mastery 分 + +### P4 - UI 一次性补齐 + 次要功能 ⭐ + +> **目标**:把 P1-P3 服务端业务用 YIUI 包成可玩界面,集中做不返工。 + +- [ ] `HeroListPanel`:排序(4 种)+ 三段分组 + 选中详情 +- [ ] `HeroDetailPanel`:信息展示 + 升级/升星/技能/天赋/装备入口 +- [ ] `HeroSummonResultPanel`:获得新英雄 3D 展示 +- [ ] `HeroStarUpPanel`:材料选择 + 幸运进度 +- [ ] `HeroSkillUpPanel`:5 个技能 + 升级按钮(随机机制要 tooltip 说明) +- [ ] `HeroTalentPanel`:3 个 tab + 天赋树 + 切换/重置/改名 +- [ ] `HeroEquipPanel`:8 个槽位 + 选装备子面板 +- [ ] **可推迟**:雕像兑换 (`C2G_ExchangeHeroItem`) / 天赋页改名 (`C2G_ModifyTalentName`) / 红点统计 + +### 可推迟(不阻塞) + +- 副将系统:ROK 中副将影响军队属性(不享受天赋加成),Survivors 单机肉鸽不需要"军队",可砍掉 +- 行动力减免:与 Survivors 战斗模式不兼容 +- 限时礼包触发:与付费系统耦合 +- 怪物英雄(`s_MonsterTroops`):仅用于 SLG 野怪,Survivors 不需要 +- 部队属性同步到战斗服务器:Survivors 是单机肉鸽,没有战斗服务器 +- 城防主将自动调整(`BuildingLogic:changeDefendHero`):与城建系统耦合 + +--- + +## 附录 A:实际数据样本 + +**HeroDefine 样本**(`hero.json` ID=1000): + +```json +{ + "ID": 1000, "civilization": 101, "initStar": 1, "star": 6, "rare": 5, + "score": 1100, "getItem": 509010001, "getItemNum": 10, + "exchange": 503050004, "getLimit": 0, + "skill": [100001, 100002, 100003, 100004, 100005], + "talent": [100001, 100002, 100003], + "heroIcon": "hero_head[hero_head_1000]", "heroModel": "Hero_1000_SD" +} +``` + +**HeroStarDefine 样本**(`hero_star.json` 5★): + +```json +{ + "ID": 5, "starLimit": 50, + "rare5": 65000, "rare4": 48000, "rare3": 30000, "rare2": 20000, "rare1": 10000, + "starEffect": 2, "starEffectData": 1 +} +``` + +**HeroSkillLevelDefine 样本**(`hero_skill_level.json` 第 6 次升级): + +```json +{ + "ID": 105, "level": 6, + "costItem5": 30, "costItem4": 20, "costItem3": 20, "costItem2": 10, "costItem1": 10 +} +``` + +**HeroStarExpDefine 样本**(`hero_star_exp.json` 2 星材料): + +```json +{ + "ID": 1001, "rareGroup": 2, "itemID": 503010002, + "exp": 400, "lucky": 20, "sure": 0 +} +``` + +--- + +## 附录 B:调研中发现的关键设计/坑 + +1. **`talentPoint` 字段已废弃**——`HeroDef.lua` 仍保留但 `HeroLogic.lua` 中所有相关代码已注释掉;实际天赋点通过 `checkTalentPoint` 实时计算。重写时**不要持久化这个字段**,避免逻辑混乱。 +2. **技能升级随机性**——服务端不允许玩家选升哪个技能(`Random.GetId(skillIds)` 等权重)。前端 UI 需要清楚地告知玩家"随机升级一个未满技能"。 +3. **同稀有度共享 cost**——`HeroSkillLevelDefine` 用 `costItem1-5` 区分稀有度的消耗量,5 星比 1 星贵 4-8 倍。 +4. **升星与等级互锁**——必须先升满当前星级的等级上限才能升星,升星后等级上限提升。 +5. **碎片是通用货币**——招募 + 升技能 + 雕像兑换都用同一个 `sHero.getItem` 道具,玩家不会有"碎片够多但不知道干嘛"的窘境。 +6. **战力不含装备**——`HeroCacle:caclePower` 不算装备分;装备只影响战斗时实际属性(`getHeroAttr`)。 +7. **副将不享受天赋加成**——`getHeroAttr` 对副将传 `notCancleTalent=true`。 +8. **觉醒技能 ID 公式固定**——`heroId * 100 + 5`;前 4 技能 ID 通常为 `heroId * 100 + 1..4`,但实际由 `s_Hero.skill` 数组定义,不要假设。 +9. **`skillEffect` 主键公式**——`skillId * 1000 + level`;查询不到表示已满级。 +10. **`hero_level` 主键有 -1 偏移**——客户端 `HeroProxy.cs:184` 用 `(int)level + config.rare * 10000 - 1`,服务端 Lua 直接用 `rare * 10000 + level`;**两者实际相差 1**,怀疑客户端有 bug 或者表 ID 设计就是从 0 开始。重写时应统一使用一种公式,建议沿用服务端逻辑 (`rare * 10000 + level`)。 +11. **3 套天赋页共享同一池天赋点**——而非"每页一个独立池"。重置或切换页只是清空已学列表,可用点数总数不变。 +12. **第 5 技能不通过 `unlockSkill`**——`HeroLogic.lua:394` 明确判断 `_star ~= 5`,第 5 技能(觉醒)由 `heroAwake` 单独负责。 +13. **`Build_Tavern` 是英雄碎片主入口**——但 ROK 工程同时还有 `Shop_GetLimitHeroInfo`(限时商店)和活动奖励等其他来源;碎片本质就是普通道具。 + +--- + +## 附录 C:未在 ROK 中找到 / 不存在的内容 + +1. **抽卡协议**:ROK 完全没有"抽英雄实体"的协议。`Build_Tavern` 是抽宝箱(产出碎片+道具)。 +2. **保底机制**:宝箱奖励是配置表 `s_TavernRank` 按权重随机,无显式保底。 +3. **英雄拥有数量上限**:源码无任何限制。 +4. **`HeroTalentGainTreeConfig` / `HeroTalentConfig` / `HeroTalentMasteryConfig` 的 JSON 数据**:`gmd\Unity\Assets\Bundles\Config\` 下不存在这 3 张表的 json(只有 hero/hero_level/hero_skill 等基础表)。**ROK 配置只在 `Configs.data` 加密 SQLite + 服务端 Lua 中**,需要从那里导出。 +5. **升星动画/特效配置**:未在 hero 表中找到,可能在通用 `s_Effect` 表里。 diff --git a/Doc/ROK-HeroAcquire-Spec.md b/Doc/ROK-HeroAcquire-Spec.md new file mode 100644 index 0000000..e8262bf --- /dev/null +++ b/Doc/ROK-HeroAcquire-Spec.md @@ -0,0 +1,575 @@ +# ROK 英雄获取机制业务规则(1:1 迁移依据) + +> 来源:`E:\Game\gmd\ROK` 服务端 Lua + 客户端 C# Proxy/View + sproto 协议 +> 用途:澄清 ROK 是否有抽卡 / 玩家究竟如何获得英雄,作为 Survivors 工程移植 / 设计英雄包的依据 +> 调研日期:2026-05-27 + +--- + +## 0. 核心结论(一句话) + +**ROK 不是传统抽卡游戏。** 它走的是 **「随机宝箱产出碎片 → 玩家主动用碎片兑换指定英雄」** 的两段式获取链,而不是「英雄卡池 + 权重 + 单抽十连保底」。 + +完整链路(一张图): + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ROK 英雄获取完整链路 │ +└─────────────────────────────────────────────────────────────────────┘ + +【产出端 - 随机】 【中间产物】 【兑换端 - 确定性】 + + 酒馆白银宝箱 ──┐ ┌── 英雄招募碎片 A + (Build_Tavern │ │ (HeroDefine.getItem) + silverBox) │ │ + │ │ ┌──────────────┐ + 酒馆黄金宝箱 ──┤───→ 服务端 ItemPackage 随机 ────→├──→│ Captain UI │ + (Build_Tavern │ (silverBoxItemPackage) │ │ 点击"召唤" │ + goldBox) │ (goldBoxItemPackage) │ └──────┬───────┘ + │ │ │ + 任务/活动/邮件─┤ 产出可以是: │ ▼ + /充值/商店 ─┤ • ITEM (碎片,最常见) │ Hero_SummonHero RPC + 奖励 ─┤ • HERO (直接给英雄,罕见) │ (heroId, 扣 getItemNum 个 + │ • 资源/士兵/... │ getItem 碎片,确定性获得) + 特殊:新手引导─┘ │ + 完成"攻击野蛮人" │ + → 直接给 guideHero │ + │ + ──────────────────────────────────────────────── │ + 关键:碎片产出端"似抽卡",但英雄本体的"获取"环节是 │ + 确定性兑换,没有随机、没有保底、没有十连。 │ + ──────────────────────────────────────────────── │ + │ + 雕像兑换 (Hero_ExchangeHeroItem 605) │ + 把"通用英雄雕像"道具转换为某英雄的招募碎片,──────┘ + 不是直接兑换英雄。 +``` + +**对 Survivors `cn.etetet.gacha` 包的建议(详见第 7 节)**: +- ROK 没有"抽卡",因此 1:1 迁移角度建议**改名为 `cn.etetet.summon`**(英雄碎片兑换召唤)+ **`cn.etetet.tavern`**(酒馆开箱产出碎片); +- 用户在前序对话已明确表态"还需要抽卡的逻辑用来验证产出",因此 **`cn.etetet.gacha` 作为 Survivors 独立扩展功能保留**,但应明确定位它是「在 ROK 基础之上新增」而非「移植 ROK 已有」。 + +--- + +## 1. 调研方法 + +### 1.1 走读文件 + +| 路径 | 关键发现 | +|------|---------| +| `E:\Game\gmd\ROK\Server\common\protocol\Protocol.sproto` | Hero 模块协议号段 601-700;Tavern 在 Build 模块 407 | +| `E:\Game\gmd\ROK\Server\common\protocol\Common.sproto` | `.Heros` / `.RewardInfo` 结构定义 | +| `E:\Game\gmd\ROK\Server\server\game_server\logic\lualib\HeroLogic.lua` | `addHero` 唯一入口;无 summon/recruit/exchange/draw/gacha/lottery/pool 关键字(除 `summonTime` 字段名) | +| `E:\Game\gmd\ROK\Server\server\game_server\logic\service\proxy\Hero.lua` | `response.SummonHero` / `response.ExchangeHeroItem` 等英雄相关 RPC handler | +| `E:\Game\gmd\ROK\Server\server\game_server\logic\service\proxy\Build.lua` | `response.Tavern` 酒馆开箱 RPC handler | +| `E:\Game\gmd\ROK\Server\server\game_server\logic\lualib\BuildingLogic.lua` | `openSilver` / `openGold` 宝箱开启逻辑 | +| `E:\Game\gmd\ROK\Server\server\game_server\logic\lualib\ItemLogic.lua` | `getItemPackage` 通用奖励包(含 HERO 类型节点) | +| `E:\Game\gmd\ROK\Client\Assets\Scripts\Hotfix\MVC\Proxy\HeroProxy.cs` | 客户端 `SummonHero(id)` 入口 | +| `E:\Game\gmd\ROK\Client\Assets\Scripts\Hotfix\MVC\View_Mediator\Captain\*` | 英雄列表 UI / 召唤按钮 | +| `E:\Game\gmd\ROK\Client\Assets\Scripts\Hotfix\MVC\View_Mediator\Tavern\*` | 酒馆双宝箱 UI | +| `E:\Game\gmd\ROK\Client\Assets\Scripts\Hotfix\Config\HeroConfig.cs` | HeroDefine schema | +| `E:\Game\gmd\ROK\Server\common\errorcode\ErrorCode.lua` | HERO 相关错误码 3000-3027 | + +### 1.2 关键字 grep 结果 + +在 `HeroLogic.lua` 里 grep **`summon|recruit|exchange|draw|gacha|lottery|pool|fragment`**(不区分大小写): + +``` +HeroLogic.lua:42: MSM.ActivityRoleMgr[_rid].req.setActivitySchedule( _rid, Enum.ActivityActionType.RECRUIT_HERO_COUNT, 1 ) +HeroLogic.lua:60: defHeroAttr.summonTime = os.time() +HeroLogic.lua:97: summonTime = heroInfo.summonTime, +``` + +只有三处命中,且全部都是「记录英雄被获取的时间戳」和「活动事件统计计数」,**不是抽卡/卡池/概率逻辑**。 + +在客户端 `MVC/View_Mediator/` 列目录: + +``` +Activity/ Alliance/ Bag/ Captain/ Chat/ Equip/ Hero/(仅 RareView) ... +Tavern/ ← 唯一长得像"抽卡"的目录 +``` + +`Hero/` 目录只有 `UI_Hero_RareMediator.cs` / `UI_Hero_RareView.cs`(稀有度展示组件);**真正的英雄列表与召唤入口在 `Captain/` 目录**(统帅=Captain=Hero)。 + +在 ROK 全工程 grep **`fragmentItemId|recruitLimit`**:**0 命中**。这两个字段名是 Survivors openspec proposal 自己拟的,不来自 ROK。 + +### 1.3 阳性 / 阴性结论 + +| 关键问题 | 结论 | 证据 | +|----------|------|------| +| ROK 有传统抽卡卡池(heroId 池 + 权重 + 保底)吗? | **没有** | 服务端无任何 `HeroPool` / `gacha` / `lottery` / `weight` 配置或逻辑;`Hero_SummonHero` RPC 内部是 1:1 道具兑换,无 `Random` 调用 | +| ROK 有"招募"概念吗? | **有,但名字叫"召唤 SummonHero"** | `Hero_SummonHero 601` 协议;客户端按钮 `OnSummonClick`;语言包文案使用"招募/Recruit" | +| ROK 有"碎片兑换"概念吗? | **有** | `HeroDefine.getItem` + `getItemNum` 就是"招募所需碎片 itemId + 数量";玩家凑齐后调用 `Hero_SummonHero` 扣碎片得英雄 | +| ROK 有其他英雄获取途径吗? | **有** | 新手引导直给 `guideHero`、文明初始英雄 `civilization.initialHero`、各种奖励包里直接含 HERO 类型节点(罕见)、酒馆宝箱开出英雄(罕见,主要开出的是碎片) | +| `fragmentItemId` / `recruitLimit` 在 ROK 存在吗? | **不存在** | grep 全工程 0 命中。等价字段是 `getItem`;不存在 `recruitLimit`(同一英雄一辈子只能拥有一次,已拥有再 summon 会报 `HERO_ALREADY_EXIST` 错误码) | +| ROK 的「酒馆开箱」算不算抽卡? | **算"准抽卡"** | `Build_Tavern 407` 协议,白银/黄金两种宝箱,免费次数+CD+付费三种开法,奖励池为 `silverBoxItemPackage` / `goldBoxItemPackage`(通用 ItemPackage),可输出碎片/英雄/资源/士兵 | + +--- + +## 2. 英雄获取的所有途径 + +按"玩家获得英雄实例所占比例"排序(推测): + +### 2.1 ⟨途径 1⟩ 碎片兑换召唤(主路径) + +- **名称**:英雄召唤 / Summon Hero / 招募 +- **玩家入口**:Captain UI(统帅大厅)→ 选中一个未拥有的英雄卡 → 点击「召唤」按钮(`UI_Item_CaptainData_SubView.OnSummonClick`,文件 `View_Mediator/Common/Logic/UI_Item_CaptainData_SubView.cs:61-65`) +- **RPC**:`Hero_SummonHero (601)`,参数 `{ heroId: integer }` + - 客户端发起:`HeroProxy.cs:567-572` `SummonHero(int id)` +- **服务端核心逻辑**(`server/game_server/logic/service/proxy/Hero.lua:18-58`): + +```lua +function response.SummonHero( msg ) + local rid, heroId = msg.rid, msg.heroId + + -- 1. 参数检查 + 配置检查 + if not heroId then return nil, ErrorCode.HERO_ARG_ERROR end + local sHero = CFG.s_Hero:Get( heroId ) + if not sHero or table.empty( sHero ) then return nil, ErrorCode.CFG_ERROR end + + -- 2. 王国开服天数 >= sHero.getLimit + local openDays = Common.getSelfNodeOpenDays() + if openDays < ( sHero.getLimit or 0 ) then + return nil, ErrorCode.HERO_OPEN_DAYS_NOT_ENOUGH -- 3000 + end + + -- 3. 必须没拥有过该英雄(同英雄只能召唤一次) + local heroInfo = HeroLogic:getHero( rid, heroId ) + if heroInfo and not table.empty( heroInfo ) then + return nil, ErrorCode.HERO_ALREADY_EXIST -- 3002 + end + + -- 4. 碎片道具数量足够 + if not ItemLogic:checkItemEnough( rid, sHero.getItem, sHero.getItemNum ) then + return nil, ErrorCode.HERO_SUMMON_ITEM_NOT_ENOUGH -- 3001 + end + + -- 5. 扣道具 → 加英雄(确定性,无随机) + ItemLogic:delItemById( rid, sHero.getItem, sHero.getItemNum, nil, + Enum.LogType.SUMMON_HERO_COST_ITEM ) + HeroLogic:addHero( rid, heroId ) +end +``` + +- **消耗 & 产出**: + - 消耗:`HeroDefine.getItem` 物品 ×`HeroDefine.getItemNum` 个 + - 产出:1 个完整英雄实例(初始 `star = sHero.initStar`, `level = 1`, `summonTime = os.time()`),并解锁该星级开放的技能(`unlockSkill`) +- **是否有概率 / 保底**:**无**。完全确定性。 +- **频率限制**:每个英雄一辈子只能召唤 **1** 次(数据库以 `heroId` 为唯一键存储,再次召唤同 ID 报 `HERO_ALREADY_EXIST`)。无每日/每周限制。 +- **副作用**: + - 统计 `Enum.ActivityActionType.RECRUIT_HERO_COUNT` 活动事件 +1(`HeroLogic.lua:42`) + - 触发限时礼包:`LimitTimeType.NEW_HERO`(`HeroLogic.lua:83`) + - 自动更换驻防英雄 `BuildingLogic:changeDefendHero` + - 重算战力 `RoleLogic:cacleSyncHistoryPower` + +### 2.2 ⟨途径 2⟩ 酒馆开箱产出(最主要的"碎片来源") + +- **名称**:酒馆召唤 / Tavern Summon / 开宝箱 +- **玩家入口**:城市建筑「酒馆 Tavern」点击进入,出现两种宝箱: + - **白银宝箱 (woodbox)**:每日免费次数(按酒馆等级 `s_BuildingTavern.silverBoxCnt`)+ CD(`silverBoxCD`),免费用完后用 `silverBoxOpenItem` 道具或钻石购买 + - **黄金宝箱 (goldbox)**:免费次数 1 次 + CD(`s_BuildingTavern.goldBoxCD`),其余消耗 `goldBoxOpenItem` 或钻石 +- **RPC**:`Build_Tavern (407)`,参数 `{ type, free, count, useDenar }` + - `type`:`1`=白银 / `2`=黄金(`Enum.BoxType.SILVER/GOLD`) + - `count`:批量开启数量(一般 1 或 10) +- **服务端核心逻辑**(`server/game_server/logic/service/proxy/Build.lua:297-361` + `BuildingLogic.lua:openSilver/openGold`): + +```lua +-- 简化伪码 +function response.Tavern( msg ) + local rid, type, free, count, useDenar = msg.rid, msg.type, msg.free, msg.count, msg.useDenar + + if type == Enum.BoxType.SILVER then + if free then + -- 验免费次数 + 验 CD + if roleInfo.silverFreeCount <= 0 then return ErrorCode.BUILD_SILVER_FREE_NOT_ENOHGH end + if roleInfo.openNextSilverTime > os.time() then return ErrorCode.BUILD_SILVER_FREE_TIME_ERROR end + else + -- 验道具或钻石 + if not useDenar then check item silverBoxOpenItem×silverBoxOpenItemNum*count + else check denar shopPrice*count + end + return BuildingLogic:openSilver( rid, free, count, useDenar ) + elseif type == Enum.BoxType.GOLD then + ...同上,配置项换成 goldBox* + return BuildingLogic:openGold( rid, free, count, useDenar ) + end +end + +function BuildingLogic:openSilver( _rid, _free, _count, _useDenar ) + -- 扣免费次数 / 扣道具 / 扣钻石 + ... + -- 调用通用包随机 + local groupId = CFG.s_Config:Get("silverBoxItemPackage") + local rewardInfo = ItemLogic:getItemPackage( _rid, groupId, ..., _count, true ) + -- 任务进度 + TaskLogic:addTaskStatisticsSum( _rid, TaskType.TAVERN_BOX, TaskTavernBoxType.SILVER, _count ) + return { rewardInfo = rewardInfo, count = _count, type = Enum.BoxType.SILVER } +end + +function BuildingLogic:openGold( _rid, _free, _count, __useDenar ) + ...同上 + local groupId = CFG.s_Config:Get("goldBoxItemPackage") + -- 黄金箱首抽保底:第一次开走 goldBoxFirstReward 包 + local firstOpenGold = RoleLogic:getRole( _rid, Enum.Role.firstOpenGold ) + if not firstOpenGold then + groupId = CFG.s_Config:Get("goldBoxFirstReward") + RoleLogic:setRole( _rid, Enum.Role.firstOpenGold, true ) + end + local rewardInfo = ItemLogic:getItemPackage( _rid, groupId, ..., _count, true ) + return { rewardInfo = rewardInfo, count = _count, type = Enum.BoxType.GOLD } +end +``` + +- **消耗 & 产出**: + - 消耗:免费次数 OR 银箱钥匙/金箱钥匙道具 OR 钻石(`s_Item.shopPrice`) + - 产出:`RewardInfo`(资源/道具/英雄实例/士兵的混合包),结构见 `Common.sproto:381-398`: + +```sproto +.RewardInfo { + food/wood/stone/gold/denar 0-4 : integer + items 5 : *RewardItem # 道具数组(碎片落这里) + soldiers 6 : *SoldierInfo + groupId 7 : integer + actionForce 8 : integer + guildGifts 9 : *GuildGift + heros 10 : *Heros # 英雄数组(罕见情况直接给英雄) + ... +} +.Heros { heroId 0:integer; num 1:integer; isNew 2:integer } +``` + +- **是否有概率 / 保底**:**有** + - **概率**:`silverBoxItemPackage` / `goldBoxItemPackage` 配置组,每个节点有 `type/typeData/number` 三元组,由 `ItemLogic:getItemPackage` 走通用包逻辑(`ItemLogic.lua:531-`)随机产出 + - **保底**:黄金箱**首次必出固定礼包** `goldBoxFirstReward`(`BuildingLogic.lua:1084-1088`,记录在 `Enum.Role.firstOpenGold` 玩家标志位);银箱无保底 +- **频率限制**: + - 银箱:每日免费次数受酒馆等级限制(配置 `s_BuildingTavern.silverBoxCnt`),有冷却 `silverBoxCD`;付费无限 + - 金箱:免费次数固定 1,冷却 `s_BuildingTavern.goldBoxCD`;付费无限 +- **特殊行为**: + - 服务端返回的奖励里 `heros[].isNew` 标志位区分"新英雄(1)"和"已拥有英雄重复给(0)",客户端用于展示动画 + - 当 ItemPackage 抽到 HERO 类型节点且玩家已拥有该英雄时,`HeroLogic:addHero` 自动把该英雄转化为对应的 `sHero.getItem` 碎片道具(`HeroLogic.lua:39-52`)—— 即"重复英雄自动转碎片" + - 任务追踪:`Enum.TaskType.TAVERN_BOX`(按银/金分类统计) + +### 2.3 ⟨途径 3⟩ 雕像兑换(碎片之间的转换) + +- **名称**:雕像兑换 / Exchange Hero Item +- **本质**:**不是"获得英雄",而是"道具A换成道具B"**——把通用「英雄雕像」道具换成某英雄专属的招募碎片,用于绕过抽不到某英雄碎片的尴尬。 +- **RPC**:`Hero_ExchangeHeroItem (605)`,参数 `{ heroId, itemNum }` +- **服务端逻辑**(`Hero.lua:108-135`): + +```lua +function response.ExchangeHeroItem( msg ) + local rid, heroId, itemNum = msg.rid, msg.heroId, msg.itemNum + local sHero = CFG.s_Hero:Get( heroId ) + + -- sHero.exchange = 通用雕像 itemId;0 = 该英雄不支持兑换 + if Enum.Exchange.NO == sHero.exchange then return nil, ErrorCode.HERO_NOT_EXCHANGE end + + -- 注:此处源代码逻辑略奇怪("heroInfo 不存在但 not empty"),实际有效约束是下面这一条: + -- 已拥有该英雄 + 技能已满,禁止再兑换更多碎片(防浪费) + if table.size(heroInfo.skills) >= 4 and HeroLogic:checkHeroSkillFull( rid, heroId ) then + return nil, ErrorCode.HERO_EXCHANGE_SKILL_MAX + end + + -- 验 itemNum 个通用雕像存在 + if not ItemLogic:checkItemEnough( rid, sHero.exchange, itemNum ) then + return nil, ErrorCode.HERO_EXCHANGE_ITEM_NOT_ENOUGH + end + + -- 1:1 兑换:扣 itemNum 个通用雕像,加 itemNum 个该英雄招募碎片 + ItemLogic:delItemById( rid, sHero.exchange, itemNum, nil, Enum.LogType.HERO_EXCHANGE_COST_ITEM ) + ItemLogic:addItem( { rid = rid, itemId = sHero.getItem, itemNum = itemNum, + eventType = Enum.LogType.HERO_EXCHANGE_GAIN_ITEM } ) + return { result = true } +end +``` + +- **消耗 & 产出**:`sHero.exchange` 通用雕像 ×N → `sHero.getItem` 该英雄碎片 ×N(1:1) +- **是否有概率 / 保底**:无,确定性 1:1 +- **频率限制**:玩家手动触发,无限制 +- **使用场景**:玩家长期开箱攒了一堆「通用英雄雕像」但缺某英雄的特定碎片时,定向兑换补差 + +### 2.4 ⟨途径 4⟩ 新手引导直给(一次性) + +- **名称**:完成新手"攻击野蛮人"引导奖励英雄 +- **服务端逻辑**(`Role.lua:1056-1062`): + +```lua +local guideHeroStage = CFG.s_Config:Get( "guideHeroStage" ) or 7 +if not RoleLogic:checkGuideFinish( oldNoviceGuideStep, guideHeroStage ) + and RoleLogic:checkGuideFinish( noviceGuideStep, guideHeroStage ) then + local guideHero = CFG.s_Config:Get( "guideHero" ) or 0 + if guideHero > 0 then + HeroLogic:addHero( rid, guideHero ) + end +end +``` + +- **频率**:玩家全生命周期内 1 次 +- **特殊表现**:客户端 `HeroCmd.cs:33-36` 会检测 `heroId == civilization.initialHero`,触发首获英雄剧情引导 + +### 2.5 ⟨途径 5⟩ 文明初始英雄(角色创建时) + +- 玩家选定文明(civilization)后,`CivilizationDefine.initialHero` 字段定义该文明的开局赠送英雄 +- 客户端 `HeroCmd.cs:30-37` 据此识别"首次得到的初始英雄",跳过普通的召唤动画走剧情 + +### 2.6 ⟨途径 6⟩ 通用奖励包内嵌(任务/活动/邮件/充值/商店) + +- ROK 服务端不存在"任务直接给英雄"的专属代码(`TaskLogic` 不调 `HeroLogic:addHero`、`EmailLogic` 不调、`GuildShopLogic` 不调、`ActivityLogic` 仅"统计英雄等级数"不发放) +- 但**所有这些系统的奖励都走通用 `ItemLogic:getItemPackage` → `ItemLogic:addItem`**,而通用包支持 `Enum.ItemPackageType.HERO` 节点(`ItemLogic.lua:389-410`)。所以理论上: + - 任务奖励可以配置 `HERO` 节点 → 通关后直给某英雄 + - 邮件附件可以挂 `HERO` 节点 → 客服补偿/活动派发可直给某英雄 + - 充值礼包、商店物品同理 +- 当配置成 `HERO` 类型时,重复获得已拥有英雄会自动转化为 `sHero.getItem` 碎片(见 §2.2 末尾) + +### 2.7 ⟨途径 7⟩ PMLogic / GM 命令(仅运营 / 测试) + +- `PMLogic.lua:481-486` `PMLogic:addHero(rid, heroId)` GM 后台命令 +- `PMLogic.lua:1480-1495` `PMLogic:allLevelUp` 一键解锁所有英雄 +- 玩家正式渠道不走 + +### 2.8 ⟨途径 8⟩ 公会复活 / 增援回调(功能恢复用,非常规获取) + +- `PMLogic.lua:1197/1200/1271/1274`:在公会援军 / 阵亡复活相关回调里,针对**已死亡数据被清的成员**重新补回 mainHero / deputyHero 实例 +- 实质是数据修复,不是玩家主动获取 + +--- + +## 3. 碎片机制详解 + +### 3.1 碎片 itemId 与英雄 ID 的关联 + +ROK 通过 `HeroDefine.getItem`(招募所需道具 itemId)字段在配置层绑定。**没有**"英雄 ID 自动派生碎片 ID"的规则,纯查表,多对一也允许(多个英雄可以共享同一种碎片,由配表决定)。 + +```csharp +// HeroConfig.cs (客户端 C# 镜像) ── 字段名权威源 +public class HeroDefine { + public int ID; // 英雄ID + public int initStar; // 初始星级 + public int star; // 星级上限 + public int rare; // 稀有度 1=白 2=绿 3=蓝 4=紫 5=橙 + public int score; // 基础战力 + public int getItem; // ★ 招募所需道具(碎片) itemId + public int getItemNum; // ★ 招募所需道具数量 + public int exchange; // 通用雕像兑换 itemId,0=不可 + public int getLimit; // 王国开服天数解锁门槛 + public List skill; // 技能 ID 列表 + public List talent; // 天赋 ID 列表 + public int listDisplay; // 1=不显示在英雄列表 + // (...其他模型/语音字段省略) +} +``` + +### 3.2 兑换公式 + +``` +凑齐 sHero.getItemNum 个 sHero.getItem + → 调用 Hero_SummonHero(heroId) + → 扣道具 + → 永久拥有 1 个英雄(star = sHero.initStar, level = 1) +``` + +无升星批量兑换、无折扣、无随机加成。**升星走单独的 `Hero_HeroStarUp (606)` 协议**,消耗的是 `s_HeroStarExp` 配置的星级道具,跟招募碎片是两套独立资源。 + +### 3.3 关于 `recruitLimit`(Survivors proposal 里的字段) + +**ROK 不存在该字段**。等价机制: +- 同一英雄一辈子只能 `addHero` 进表 1 次(数据库以 `heroId` 为主键) +- 第二次起 `Hero_SummonHero` 返回 `HERO_ALREADY_EXIST` 错误码 +- 如需让玩家持续获得"该英雄相关收益",ROK 的做法是 **重复获得的碎片可以继续累积在背包**: + - 多余碎片可用于升级技能(`HeroLogic.lua:204` `HERO_LEVEL_UP_COST_ITEM`) + - 多余碎片可参与雕像兑换流通 + +如果 Survivors 想保留 `recruitLimit` 概念,等价含义只能是「多少个碎片兑一次」(即 `getItemNum`),或者扩展为「同英雄可重复招募 N 次用于升星」(ROK 不是这么做的)。 + +### 3.4 多余碎片的处理 + +**保留在背包**,不自动转化。具体路径: +- 已拥有英雄 → 路径 2/6 又掉到该英雄(HERO 类型节点)→ `HeroLogic:addHero` 检测到已存在 → 自动转 `sHero.getItem` 道具(`HeroLogic.lua:43-52`) +- 已拥有英雄 → 路径 2/6 又掉到该英雄碎片(ITEM 类型节点)→ 正常进背包累加 +- 玩家技能升级或雕像兑换时主动消耗多余碎片 + +--- + +## 4. 抽卡 / 概率机制 + +### 4.1 "传统抽卡"(卡池+权重+保底+十连)—— **不存在** + +排查依据: +1. `HeroLogic.lua` grep `summon|recruit|exchange|draw|gacha|lottery|pool` 全部 3 处命中均为字段名/计数器,无任何 Random 调用 +2. `Hero_SummonHero` RPC 服务端实现(`Hero.lua:18-58`)从头到尾**没有任何 `Random.*` 函数调用**,完全是确定性查表 + 扣道具 + addHero +3. ROK 客户端 `View_Mediator/` 没有 `Summon/Gacha/Lottery/Draw` 目录;唯一相关的是 `Captain/CaptainSummon*` 但那是"获得英雄后的展示动画",不是抽卡入口 +4. 没有 `s_HeroPool` / `s_GachaPool` / `s_Lottery` / `s_DrawWeight` 之类的配置表(在 `Server/common/service/data/config/` 下全表扫过 Hero 相关只有 7 张表,全都是属性/技能/天赋/星级 表) + +### 4.2 "酒馆开箱"(已确认存在) + +如 §2.2 所述: + +| 项 | 银箱 | 金箱 | +|----|------|------| +| **奖励池配置** | `silverBoxItemPackage` | `goldBoxItemPackage`(首抽 `goldBoxFirstReward`) | +| **池构成** | ItemPackage 通用 group,节点类型包含 ITEM / CURRENCY / HERO / SOLDIER / SUB_ITEM_TYPE 等 | 同 | +| **权重** | 由 ItemPackage 节点的 `number` 字段及 group 内 weight 推断(实现在 `ItemLogic:getItemPackage`) | 同 | +| **保底** | 无 | 有 —— 玩家首次开金箱必走 `goldBoxFirstReward` 固定包,标志位 `Enum.Role.firstOpenGold` | +| **开法** | 免费次数 + 钥匙道具 + 钻石 | 同 | +| **每日免费次数** | `s_BuildingTavern[酒馆等级].silverBoxCnt` | `s_BuildingTavern[酒馆等级].goldBoxCnt`(实际固定为 1) | +| **CD** | `s_Config.silverBoxCD` | `s_BuildingTavern[酒馆等级].goldBoxCD` | +| **批量开** | 支持,count 字段传次数 | 同 | +| **客户端预览** | `TavernRankDefine` group=1 列表展示"可能开出的稀有奖励" | group=2 同 | + +**这就是 ROK 最接近"抽卡"的机制**。但产出端**主要是英雄碎片道具**(玩家攒齐再去 `Hero_SummonHero`),偶尔直接产出英雄实例。 + +### 4.3 抽卡相关错误码 + +``` +ErrorCode.lua: + BUILD_SILVER_FREE_NOT_ENOHGH 银箱免费次数不足 + BUILD_SILVER_FREE_TIME_ERROR 银箱免费 CD 未到 + BUILD_GOLD_FREE_NOT_ENOHGH 金箱免费次数不足 + ITEM_NOT_ENOUGH 钥匙不足 + ROLE_DENAR_NOT_ENOUGH 钻石不足 +``` + +--- + +## 5. 协议清单(与英雄获取相关) + +| 协议号 | 名称 | 方向 | 用途 | +|--------|------|------|------| +| `Build_Tavern` 407 | 酒馆召唤 / 开宝箱 | C2S | 开白银/黄金宝箱,**产出碎片或英雄的主路径** | +| `Hero_SummonHero` 601 | 召唤统帅 | C2S | 用碎片兑换具体英雄,**主获取路径** | +| `Hero_HeroInfo` 30201 | 统帅信息推送 | S2C | 服务端在新英雄获得 / 数值变更后下发,客户端 `HeroProxy.UpdateHeroInfo` 处理 | +| `Hero_ExchangeHeroItem` 605 | 雕像兑换 | C2S | 通用雕像 → 该英雄碎片(不是直接兑英雄) | +| `Hero_AddHeroExp` 602 | 加经验 | C2S | 不是获取,但同包 | +| `Hero_HeroSkillLevelUp` 603 | 技能升级 | C2S | 同上 | +| `Hero_HeroAwake` 604 | 觉醒 | C2S | 同上 | +| `Hero_HeroStarUp` 606 | 升星 | C2S | 同上 | +| `Hero_TalentUp/ChangeTalentIndex/ResetTalent/ModifyTalentName` 607-610 | 天赋系列 | C2S | 同上 | +| `Hero_HeroWearEquip / TakeOffEquip` 611-612 | 装备 | C2S | 同上 | + +--- + +## 6. 错误码清单(与英雄获取相关) + +``` +HERO_OPEN_DAYS_NOT_ENOUGH 3000 召唤统帅,所在王国不满足该统帅解锁天数条件 +HERO_SUMMON_ITEM_NOT_ENOUGH 3001 召唤统帅所需道具不足 +HERO_ALREADY_EXIST 3002 统帅已存在 +HERO_NOT_EXIST 3003 统帅不存在 +HERO_NOT_EXCHANGE 3008 该统帅无法使用通用雕像兑换 +HERO_EXCHANGE_SKILL_MAX 3009 统帅技能已满,无法兑换碎片 +HERO_EXCHANGE_ITEM_NOT_ENOUGH 3010 通用雕像不足 + +# 酒馆宝箱 +BUILD_SILVER_FREE_NOT_ENOHGH ... 银箱免费次数不足 +BUILD_SILVER_FREE_TIME_ERROR ... 银箱免费 CD 未到 +BUILD_GOLD_FREE_NOT_ENOHGH ... 金箱免费次数不足 +ITEM_NOT_ENOUGH ... 钥匙道具不足 +ROLE_DENAR_NOT_ENOUGH ... 钻石不足 +``` + +--- + +## 7. 与 Survivors 实现的对照点 + +### 7.1 之前 openspec proposal 中的 `cn.etetet.gacha` 包 + +**结论:名字与实际职责不匹配**。ROK 里"抽卡"实际是「酒馆开箱」+「碎片兑换」两段式,不是传统单一抽卡。建议拆分调整: + +| Survivors 包 | 对应 ROK 模块 | 职责 | +|--------------|---------------|------| +| `cn.etetet.hero` | `HeroLogic.lua` + `Hero.lua` proxy | 英雄实例存储、属性、技能、星级、天赋、装备等已规划 | +| **`cn.etetet.summon`**(建议新名) | `Hero.lua: SummonHero/ExchangeHeroItem` | **碎片兑换召唤 + 雕像兑换**,对应 RPC `Hero_SummonHero` `Hero_ExchangeHeroItem` | +| **`cn.etetet.tavern`**(建议新名,或合并到 cn.etetet.building) | `BuildingLogic.lua: openSilver/openGold` + `Build.lua: Tavern` | **酒馆宝箱开启**(碎片产出端),双宝箱+免费次数+CD+保底 | +| `cn.etetet.gacha`(用户要求保留) | **ROK 没有对应** | Survivors 独立扩展:传统卡池抽卡(用于"验证产出") | + +### 7.2 三种方案对比 + +**方案 A:严格 1:1 移植** + +1. 第一阶段先实现 `cn.etetet.summon`(碎片兑换召唤)—— 与 ROK 完全一致:每个英雄 `getItem`+`getItemNum`,玩家凑齐手动召唤 +2. 第二阶段实现 `cn.etetet.tavern`(酒馆双宝箱)—— 产出端的"准抽卡" +3. **不做** `cn.etetet.gacha`(传统抽卡),因为 ROK 没有 +4. 优点:与原版美术 / 数值 / 设计完全对齐; +5. 缺点:与用户已表达的"还需要抽卡验证产出"诉求冲突 + +**方案 B:1:1 + 独立扩展抽卡**(**推荐**) + +1. P0-P1 实现 `cn.etetet.summon` + `cn.etetet.tavern`:严格 1:1 ROK 的招募 + 酒馆开箱 +2. P3+ 新增 `cn.etetet.gacha`:作为 Survivors 独立扩展功能,与 ROK 解耦 + - 可设计成"特殊活动卡池":限时上线,权重+保底+十连 + - 抽到的可以是碎片(继续走 `cn.etetet.summon` 流程兑换)或直接英雄 +3. 优点:保留 ROK 设计的同时满足"测试抽卡逻辑"诉求;新功能不污染原 1:1 路径 +4. 缺点:工作量略大;需要清晰区分"碎片兑换召唤"与"抽卡"在 UI/UX 上的不同入口 + +**方案 C:用抽卡替代碎片兑换**(不推荐) + +1. 直接做 `cn.etetet.gacha`,跳过 ROK 的碎片兑换 +2. 优点:开发简单 +3. 缺点:偏离 ROK 设计哲学;ROK 的"碎片累积+主动召唤"是其养成长尾的核心环路,丢掉这一层会让英雄获取节奏完全不一样 + +### 7.3 命名重构建议 + +如果用户接受 `cn.etetet.summon` + `cn.etetet.tavern` + 独立的 `cn.etetet.gacha` 拆分: + +```text +原 openspec proposal 涉及调整: +- 把"cn.etetet.gacha 实现 ROK 的抽卡"修改为"cn.etetet.gacha 是 Survivors 独立扩展" +- 新增 cn.etetet.summon 包(实现 Hero_SummonHero + Hero_ExchangeHeroItem 对应 RPC) +- 在 cn.etetet.building(如已规划)下新增 Tavern 子模块,或独立成 cn.etetet.tavern +- HeroDefine 字段: + fragmentItemId → 改名为 getItem (与 ROK 一致) + fragmentNum → 改名为 getItemNum (与 ROK 一致) + recruitLimit → 删除 (ROK 不存在);或解释为"同英雄重复获得碎片继续累积" + openDaysLimit → 改名为 getLimit (与 ROK 一致) + exchangeItemId → 新增 exchange (通用雕像 itemId, 0=不可) +``` + +--- + +## 8. 给用户的建议 + +### 推荐方案:**B (1:1 + 独立扩展抽卡)** + +**理由**: +1. 严格 1:1 还原 ROK 的「碎片兑换 + 酒馆开箱」是 Survivors 这个工程立项的核心承诺(前序对话明确"严格 1:1");这两个机制是 ROK 中英雄获取的真实主流程 +2. 抽卡用户也明确要求保留("还需要抽卡的逻辑用来验证产出"),但应明确**抽卡是 Survivors 在 ROK 基础上的扩展**,不污染 1:1 路径 +3. 三个包职责清晰、可独立开发、便于后期裁剪: + - `cn.etetet.summon` —— 必做(P0) + - `cn.etetet.tavern` —— 必做(P1,因为没酒馆就没碎片来源) + - `cn.etetet.gacha` —— 可延后(P3+,作为运营活动手段) + +### 落地步骤建议 + +1. **立刻:** 更新 openspec proposal `port-rok-hero-bag-system`,把 "cn.etetet.gacha 1:1" 改为 "cn.etetet.summon + cn.etetet.tavern 1:1",新增 `cn.etetet.gacha` 作为 P3 扩展项 +2. **HeroDefine 配置:** 字段命名跟 ROK 对齐(`getItem` / `getItemNum` / `getLimit` / `exchange`),不要造 `fragmentItemId` / `recruitLimit` 等新字段 +3. **P0:** 实现 `Hero_SummonHero` 等价 RPC(C2G_SummonHero),扣 `getItem` × `getItemNum` → 加英雄实例,错误码对齐 `HERO_*` 段 +4. **P1:** 实现 Tavern 双宝箱(C2G_TavernOpen),银 / 金 + 免费次数 + CD + 保底(金箱首抽)+ 通用 ItemPackage 引用 +5. **P3+:** 独立设计 `cn.etetet.gacha` 卡池系统(权重表、保底、十连、抽卡券),可与酒馆解耦 + +### 风险提示 + +- ROK 的"碎片 → 召唤"链严重依赖通用 ItemPackage 系统(`getItemPackage`)。如果 Survivors 还没规划"通用奖励包"基础设施,需要同步规划,否则酒馆 / 任务奖励 / 商店奖励都没法落地 +- ROK 的 `civilization.initialHero` / `guideHero` 等"开局直给英雄"路径与新手引导耦合,移植时需保留以保证新玩家体验 +- 雕像兑换(`Hero_ExchangeHeroItem`)这条"碎片之间互换"路径在 ROK 中是中后期玩法,可放 P2 实现 + +--- + +## 附录:关键源码位置索引(便于后续 1:1 比对) + +| 主题 | 文件 | 行号 | +|------|------|------| +| 英雄数据新增 | `HeroLogic.lua` | 38-84 (`addHero`) | +| 召唤 RPC | `service/proxy/Hero.lua` | 17-58 (`SummonHero`) | +| 雕像兑换 RPC | `service/proxy/Hero.lua` | 108-135 (`ExchangeHeroItem`) | +| 酒馆 RPC | `service/proxy/Build.lua` | 296-361 (`response.Tavern`) | +| 银箱开启 | `BuildingLogic.lua` | 981-1031 (`openSilver`) | +| 金箱开启 | `BuildingLogic.lua` | 1049-1106 (`openGold`)(含首抽保底) | +| 通用奖励包随机产出 | `ItemLogic.lua` | 261-436 (`getItemPackage` 内 HERO/ITEM/SUB_ITEM_TYPE 分支) | +| 奖励包 → 加英雄 | `ItemLogic.lua` | 493-505 (heros 字段处理) | +| 协议 sproto | `protocol/Protocol.sproto` | 862-874 (Build_Tavern), 970-974 (Hero_SummonHero), 1013-1021 (Hero_ExchangeHeroItem) | +| 公共数据结构 | `protocol/Common.sproto` | 373-398 (`.Heros` `.RewardInfo`) | +| HeroDefine 字段 | `Client/.../Config/HeroConfig.cs` | 全文件 | +| 客户端发起召唤 | `Client/.../Proxy/HeroProxy.cs` | 567-572 (`SummonHero`) | +| 召唤按钮事件 | `Client/.../View_Mediator/Common/Logic/UI_Item_CaptainData_SubView.cs` | 61-65 (`OnSummonClick`) | +| 酒馆 UI | `Client/.../View_Mediator/Tavern/TavernSummonMediator.cs` | 全文件 | +| 错误码 | `Server/common/errorcode/ErrorCode.lua` | 149-176 (HERO_*) | diff --git a/Doc/ROK-Luban-Field-Mapping.md b/Doc/ROK-Luban-Field-Mapping.md new file mode 100644 index 0000000..53a6645 --- /dev/null +++ b/Doc/ROK-Luban-Field-Mapping.md @@ -0,0 +1,21 @@ +# ROK 字段到 Luban 字段映射(首版) + +## HeroDefine -> Hero.bean.xml + +- `id` -> `id` +- `name` -> `name` +- `rare` -> `rare` +- `skill_ids` -> `skillIds` +- `talent_ids` -> `talentIds` + +## ItemDefine -> Item.bean.xml + +- `id` -> `id` +- `name` -> `name` +- `type` -> `itemType` +- `icon` -> `icon` +- `desc` -> `desc` + +## 说明 + +- 本文档为迁移首版映射,后续以实际导出脚本和策划表校验结果迭代。 diff --git a/Doc/ROK-Spec-Index.md b/Doc/ROK-Spec-Index.md new file mode 100644 index 0000000..40695b2 --- /dev/null +++ b/Doc/ROK-Spec-Index.md @@ -0,0 +1,376 @@ +# ROK 业务规则总索引(1:1 迁移依据) + +> 来源:`E:\Game\gmd\ROK` 服务端 Lua + 客户端 C# + sproto 协议 +> 用途:移植到 Survivors 工程前的业务规则全摊牌,**实现时禁止凭空发挥** +> 调研日期:2026-05-27 + +--- + +## 0. 四份文档清单 + +| # | 文档 | 大小 | 摘要 | +|---|---|---|---| +| 1 | [ROK-Hero-Spec.md](./ROK-Hero-Spec.md) | 70K / 1362 行 | 英雄系统完整业务(招募 / 升级 / 升星 / 技能 / 觉醒 / 天赋 / 装备 / 战力 / 排序 / 同步部队) | +| 2 | [ROK-Bag-Spec.md](./ROK-Bag-Spec.md) | 48K / ~770 行 | 背包系统(道具 6 大类 / itemId 编码 / 增删使用 / 礼包 / 资源货币机制) | +| 3 | [ROK-HeroAcquire-Spec.md](./ROK-HeroAcquire-Spec.md) | 34K / 456 行 | 英雄获取链路(**ROK 无抽卡**,是宝箱+碎片+主动召唤) | +| 4 | [ROK-Equip-Spec.md](./ROK-Equip-Spec.md) | 25K | 装备系统(8 槽位 / 锻造双段 RPC / 分解 / 套装 / 材料合成) | + +--- + +## 1. 四个核心架构结论(必读) + +### 结论 1:**ROK 没有抽卡,是"宝箱产出碎片 + 主动碎片召唤"两段式** + +``` +酒馆宝箱(白银 / 黄金 / 免费 + 付费) + │ Build_Tavern 407 → 服务端 ItemPackage 随机产出 + ▼ +碎片 / 资源 / 士兵 / 极少数英雄本体 + │ + ▼ +Captain UI → 点击"召唤" → Hero_SummonHero 601 + │ 扣 HeroDefine.getItem × HeroDefine.getItemNum 个碎片 + ▼ +英雄入手(每个 heroId 一辈子只能拥有 1 次) +``` + +**对 openspec 的影响**: +- ⚠️ 原 `cn.etetet.gacha` 包**不对应 ROK 任何业务**(ROK 没抽卡) +- ✅ 用户明确表示"想要抽卡用来验证产出",所以 `cn.etetet.gacha` **作为 Survivors 独立扩展功能保留**,但定位明确为"在 ROK 基础上**新增**",不是"移植" +- ✅ 1:1 严格迁移的入口实际是 `cn.etetet.summon`(碎片召唤)+ `cn.etetet.tavern`(酒馆宝箱),二者比抽卡更接近 ROK + +### 结论 2:**资源货币(金/木/石/粮/宝石/VIP/行动力)不进背包,是 Role 字段** + +ROK 把 **可堆叠的玩家货币** 和 **进背包的道具** 分得很清楚: + +| 类别 | 存储方式 | 操作 API | 示例 | +|---|---|---|---| +| **货币** | Role Entity 字段 | `RoleLogic.addGold/Wood/Stone/Grain/Gem/Vip/Vitality` | 金币、木料、粮食、宝石、行动力、VIP | +| **资源类道具袋** | Item 入背包 | `ItemLogic.addItem` | 1xxxxxxxx 段的"100 金币袋",使用时调 `ItemChangeResource` 兑换为货币 | +| **装备 / 材料 / 经验书 / 其他** | Item 入背包 | `ItemLogic.addItem` | 4xxxxxxxx + 5xxxxxxxx 段 | +| **头像框 HEAD** | 不进背包 | `RoleLogic.unlockRoleHead` | 直接挂玩家解锁列表 | + +**对 openspec 的影响**: +- ⚠️ 之前 `bag-system/spec.md` 写的 **"通用 Item 设计原则,全部货币也用 itemId 管理"** **与 ROK 不一致** +- ✅ 1:1 迁移应该跟随 ROK:金币 / 钻石 在 PlayerComponent 上做整型字段,而不是 BagItem +- ✅ 但"统一奖励 / 消耗结构 RewardEntry { itemId, count }" 这个**抽象层可以保留**——服务端发奖时用 RewardEntry 表达,内部分发到 Role 字段或 BagComponent + +### 结论 3:**装备就是带 `heroId` 的 Item,不是独立 Entity** + +| 维度 | 真相 | +|---|---| +| 装备的存储 | 背包 ItemUnit,`count=1`,`heroId>0` 表示已穿戴 | +| 英雄持有装备 | Hero Entity 上 8 个 int 字段,存 `itemIndex`(引用背包) | +| 装备槽位数 | **8 个**(head / breastPlate / weapon / gloves / pants / accessories1 / accessories2 / shoes) | +| 装备 subType 数 | **6 种**(HELMET / BREASTPLATE / ARMS / GLOVES / PANTS / ACCESSORIES / SHOES)— accessories 两槽共享同一 subType | +| 锻造 RPC | **双段**:CheckMakeEquip (50% 专属概率) → MakeEquipment (真消耗) | +| 锻造是否随机 | **必中**(材料够 + 金币够 = 必出),随机的是"专属属性" | +| 分解 | 已穿戴可直接分解(前提英雄在城内),返还固定,**无随机** | + +**对 openspec 的影响**: +- ⚠️ 之前规划的"独立 `cn.etetet.equip` 包"过度设计;改为**装备子系统放在 `cn.etetet.bag` + `cn.etetet.hero`** 协作实现 +- ⚠️ 之前 spec 说"8 部位"但没说映射,实际是 8 槽 / 6 subType,accessories 双槽共享,需修正 +- ⚠️ 之前漏写 CheckMakeEquip 预检 RPC,需补充 + +### 结论 4:**英雄字段比 openspec 现有 spec 写的多得多** + +英雄 Entity 上的字段(部分): + +| 之前 spec 提到 | ROK 实际还有 | +|---|---| +| heroId / level / exp / star / starExp / skills / talents | + summonTime / soldierKillNum / savageKillNum / talentPoint(遗留) / talentTrees(3 套天赋页 map) / talentIndex | +| | + 8 个装备字段 | + +英雄招募的真实字段名: + +| openspec 用过的名字 | ROK 真名 | +|---|---| +| `fragmentItemId` | `getItem` | +| `recruitLimit` | **不存在**(每个 heroId 一辈子只能拥 1 次,重复 → `HERO_ALREADY_EXIST` 错误码) | + +--- + +## 2. 与 openspec 现有 spec 的差异清单(必须修正) + +以下 specs 文件需要根据本调研结果调整: + +### 2.1 `openspec/changes/port-rok-hero-bag-system/specs/hero-system/spec.md` + +| 需修正项 | 当前写法 | ROK 真相 | 建议改法 | +|---|---|---|---| +| 招募字段名 | `fragmentItemId` / `recruitLimit` | `getItem` / `getItemNum`,无 recruitLimit | 字段名跟 ROK | +| 招募上限 | "上限"概念 | 每个 heroId 拥 1 次(重复报错) | 改成"重复招募校验" | +| 战力公式 | 通用描述 | 完整公式见 [Hero-Spec §4.10] | 引用 Doc/ROK-Hero-Spec.md | +| 天赋字段 | `TalentIndex` 单值 | `talentTrees` map(1/2/3 三套)+ `talentIndex` | 加 3 套天赋页结构 | +| 觉醒触发 | 通用描述 | 前 4 技能满级自动解锁第 5 + 独立 RPC | 双触发机制 | +| 部队同步 | 未提 | 任意英雄改动 → 更新 `RoleArmy` 战斗属性 | 加同步规则 | + +### 2.2 `openspec/changes/port-rok-hero-bag-system/specs/bag-system/spec.md` + +| 需修正项 | 当前写法 | ROK 真相 | 建议改法 | +|---|---|---|---| +| **通用 Item 设计** | "所有货币都走 itemId" | ROK 金/木/石/粮/宝石/VIP 是 Role 字段 | **改成"统一奖励结构 RewardEntry,内部分发到 Role 字段 / BagComponent"** | +| 道具脚本 | "ItemScript 注册机制" | ROK 没有脚本表,是 if/elseif 硬编码 | Survivors 可以做得更好(用 IItemUseHandler 注册),但要意识到 ROK 是硬编码 | +| 新道具标记 | "服务端 + 客户端分工" | ROK 全在客户端 PlayerPrefs | 跟 ROK:客户端 SaveManager 存 | +| BagItemType | "6 大类" | ROK Tab 只显 5 个(HEAD 类不入背包) | 改成 5 Tab | +| 头像框 | 未提 | HEAD 类不进背包,直走 unlockRoleHead | 加单独说明 | +| 资源阈值告警 | 未提 | `s_GameWarning.num` 大额变动通知运营 | 可保留作为 P3 | + +### 2.3 `openspec/changes/port-rok-hero-bag-system/specs/equipment-system/spec.md` + +| 需修正项 | 当前写法 | ROK 真相 | 建议改法 | +|---|---|---|---| +| 部位数 | "8 部位 + 4 个 EquipItemType" | 8 槽 / 6 subType(accessories 双槽共享) | 修正映射 | +| 锻造 | 单 RPC | 双 RPC(CheckMakeEquip + MakeEquipment) | 加预检 RPC | +| 专属属性 | 未提 | 50% 概率,独立 exclusive 字段 | 补充 | +| 套装 | 未提 | 2/4/6/8 件**逐级叠加**加成 | 补充 EquipComposeDefine | +| 千分比数值 | 未提 | 服务端 attAddEx 用千分比整数 | 数据类型澄清 | + +### 2.4 `openspec/changes/port-rok-hero-bag-system/specs/gacha-system/spec.md` + +| 需修正项 | 当前写法 | ROK 真相 | 建议改法 | +|---|---|---|---| +| **整体定位** | "1:1 移植 ROK 抽卡" | ROK 无抽卡 | **改为"独立扩展功能(非 ROK 移植)"** | +| 类比 | 借鉴 ROK 卡池 | ROK 没卡池 | 类比常见 SLG 抽卡(COK / RoK 实际游戏)或参考 gmd 姐妹工程 | +| 优先级 | P1 | 推迟到 P3 或更后 | **降级**:先做 ROK 现有的"召唤 + 酒馆",抽卡可作为 P4+ 扩展功能 | + +### 2.5 `tasks.md` + +需要追加的任务: + +| 新增章节 | 任务 | +|---|---| +| 2.0 → 改为 2.1 | 抽卡降级到 P3+ | +| **新 2.0** | 「英雄碎片召唤 + 酒馆宝箱」(对应 ROK `Hero_SummonHero` + `Build_Tavern`) — **真正的 P1** | +| 4.1 → 替换 | 升星:星级 1-N,溢出 → 星级+1,**带幸运双倍机制** | +| 3.4 → 修正 | `C2G_CheckMakeEquip` + `C2G_MakeEquipment` 双 RPC | +| 1.11 → 修正 | 通用 Item:保留 `RewardEntry`,但**金币/钻石/资源走 Role 字段** | + +--- + +## 3. 推荐实现路径(在 ROK 真相基础上) + +### 3.1 包结构调整 + +**之前 openspec 规划**: + +``` +cn.etetet.hero # 英雄 +cn.etetet.bag # 背包 + 装备 +cn.etetet.gacha # 抽卡(ROK 无对应) +``` + +**根据调研推荐**: + +``` +cn.etetet.hero # 英雄养成(招募 / 升级 / 升星 / 技能 / 觉醒 / 天赋 / 战力) +cn.etetet.bag # 背包 + 装备(装备是带 heroId 的 Item,不独立分包) + # 锻造放 bag/Forge/ 子目录 +cn.etetet.summon # 碎片召唤 RPC(薄薄一层,主要在 hero 内复用)— 可与 cn.etetet.hero 合并 +cn.etetet.tavern # 酒馆宝箱(ROK 主要碎片来源)— P2/P3 视优先级 +cn.etetet.gacha # 抽卡(独立扩展,非 ROK 移植)— P3+ 或后期 +``` + +实际可以**合并**:把 summon 并到 hero 里、tavern 单独包(因为它是独立的 UI 入口 + Build_Tavern 协议)。 + +### 3.2 阶段重排(基于 ROK 真相) + +| 阶段 | 目标 | 端到端验证 | +|---|---|---| +| **P0** | 基础设施(DB / 日志 / GM / 配置 / Luban / 协议) | Login Demo 通 | +| **P1** | 英雄招募(碎片召唤 RPC) + 经验升级 + 通用 Item 增删 + 货币变动 | GM `AddItem 碎片 30` → `SummonHero heroId` → 英雄出现,升级 | +| **P2** | 升星 + 技能升级 + 装备穿戴 + 战力计算(套装) | GM 完整养成链路 | +| **P3** | 觉醒 + 天赋树 + 锻造(双段 RPC)+ 分解 | GM 全养成 + 装备产出闭环 | +| **P3+** | 酒馆宝箱(ROK 主要碎片来源) + 雕像兑换 | UI 之前用 GM 验证酒馆 | +| **P4** | UI 集中实现(YIUI Panel) | 全功能可视化 | +| **P4+ / 可选** | **抽卡扩展**(非 ROK 移植,独立设计) | 用户验证产出 | + +--- + +## 4. 实现时的"防走样"检查清单 + +每写一个业务函数前,**对照本索引文档 + 对应模块 spec**: + +- [ ] 数据字段名是否跟 ROK(如 `getItem` 不要写成 `fragmentItemId`) +- [ ] RPC 个数是否对(如锻造 2 个不是 1 个) +- [ ] 公式是否照 ROK(如战力公式参考 ROK-Hero-Spec §4.10) +- [ ] 错误码段位是否对(3000-3027 Hero,6029-6031 BuildEquip) +- [ ] 货币走 Role 字段 vs Item,分得清吗 +- [ ] 装备槽位映射对吗(8 槽 / 6 subType,accessories 双槽) +- [ ] 配置表字段全部翻译到 Luban 了吗(不要漏列) + +--- + +## 5. 给用户的建议 + +### 5.1 必须先做 + +1. **修正 openspec specs**:按本索引 §2 的差异表逐项修正 `hero-system/spec.md` / `bag-system/spec.md` / `equipment-system/spec.md` / `gacha-system/spec.md` +2. **调整 proposal.md**:抽卡定位明确为"独立扩展,非 ROK 移植" +3. **调整 tasks.md**:阶段重排(按本索引 §3.2),新增"召唤 + 酒馆"任务 +4. **调整 design.md D2 包组织**:把 `cn.etetet.equip` 合并到 `cn.etetet.bag` + +### 5.2 可以推迟(先不调整) + +- ROK 的`雕像兑换` / `天赋页改名` 等次要功能可放最后 +- `酒馆宝箱` 可在 P3 才补(先用 GM `AddItem 碎片 N` 模拟产出) +- 抽卡扩展先不做,P1-P3 用 ROK 原版机制(召唤)验证产出 + +### 5.3 决策点请确认 + +| 决策点 | 选项 A | 选项 B | 推荐 | +|---|---|---|---| +| 货币是否进 Item | A. 跟 ROK:金/钻是 Player 字段 | B. 统一 Item:金/钻也是 itemId | **A**(与 ROK 一致,便于 1:1 迁移) | +| 装备分包 | A. 跟 ROK:装备并入 Bag | B. 独立 `cn.etetet.equip` 包 | **A**(与 ROK 一致) | +| 抽卡优先级 | A. P1 实现(用户要求) | B. P4+ 扩展(避免干扰 1:1 迁移) | **B**(先把 ROK 跑通,抽卡作为后续创新点) | +| 召唤 + 酒馆放哪 | A. 合并到 hero 包 | B. 独立 `cn.etetet.tavern` 包 | **A 合并到 hero,酒馆 UI 时再独立** | + +确认这些决策后,我可以动手按结论修正 openspec 全部文档。 + +--- + +## 6. 用户最新决策与统一设计哲学(2026-05-27 确认) + +> 这一节是 ROK 调研之后用户拍板的设计原则。**与 ROK 部分实现细节不一致**,但作为 Survivors 工程的最终设计哲学,后续 openspec 修改必须以此为准。 + +### 6.1 决策汇总 + +| # | 议题 | 决定 | 与 ROK 关系 | +|---|---|---|---| +| D1 | 货币管理 | **通用 Item**:金/钻/木/粮/碎片/英雄道具/装备/材料**全部** itemId 管理 | ✗ 与 ROK 不一致(ROK 把货币存 Role 字段) | +| D2 | 装备分包 | **并入 `cn.etetet.bag`**(装备是带 heroId 的 Item) | ✓ 与 ROK 一致 | +| D3 | 抽卡优先级 | **推迟 P4+**,先把 ROK 召唤+酒馆跑通 | — | +| D4 | "抽卡 / 召唤" 重新定义 | **抽卡 = 酒馆开盲盒(ItemPackage 机制);召唤 = 英雄碎片 Item → 英雄 Entity 转化** | ✓ 本质与 ROK 一致,抽象更统一 | + +### 6.2 核心抽象:**"一切皆 Item,开盲盒即抽卡"** + +#### 抽象 1:通用 Item + +| 范畴 | itemId 示例段位 | 例子 | +|---|---|---| +| 货币类 Item | 1xxxxx (跟 ROK itemId 编码) | itemId=1 金币 / itemId=2 钻石 / itemId=3 木料 / itemId=4 粮食 | +| 资源道具袋 | 1xxxxxxx | 100 金币袋 / 1000 钻石袋(使用时扣道具+加货币 Item) | +| 加速 / 增益 / 杂项 | 2xxx / 3xxx / 5xxx | 训练加速卷、迁城卡、经验书 | +| 装备 / 材料 / 图纸 | 4xxx | 装备本体(`count=1`+`heroId>=0`)、材料、图纸 | +| **英雄碎片** | 类似 ROK `getItem` 编码 | 招募关羽碎片 / 招募张飞碎片 | +| **英雄 Item**(新增抽象) | 新增段位 | "直接获得 1 名关羽"道具,使用 → 转化为 Hero Entity | + +**统一接口**(服务端 `BagComponentSystem`): +```csharp +// 任何业务发奖、扣资源都走这两个接口 +AddItems(List rewards) +RemoveItems(List costs) +HasItems(List costs) +GetItemCount(itemId) +``` + +#### 抽象 2:ItemPackage(开盲盒 / 抽卡 / 礼包统一模型) + +``` +输入:消耗 CostEntry 列表 + 一个 packageId +内部:按 PackageConfig.weights 加权随机 +输出:RewardEntry 列表(itemId, count)→ 调用 BagComponentSystem.AddItems +``` + +**ItemPackage 的具体业务化身**: + +| 业务 | 实现 = 一次 ItemPackage 调用 | +|---|---| +| 酒馆白银宝箱(ROK Build_Tavern silverBox) | 消耗:免费次数 or 银币 / 输出:silverBoxItemPackage 加权产出 | +| 酒馆黄金宝箱(ROK Build_Tavern goldBox) | 消耗:钻石 / 输出:goldBoxItemPackage 加权产出 | +| 邮件附件领取 | 消耗:0 / 输出:邮件配置的 RewardEntry 列表(无随机) | +| 任务奖励 | 同上 | +| 礼包道具使用(gift box) | 消耗:1 个礼包 Item / 输出:固定 / 加权 RewardEntry | +| **未来"抽卡"扩展** | 消耗:抽卡券 or 钻石 / 输出:英雄碎片 / 英雄 Item / 资源等 | + +> **关键洞察**:上面 6 种业务**底层是同一个函数** `ItemPackageSystem.Open(rid, packageId)`,只是 UI 入口和配置不同。 + +#### 抽象 3:Item → Entity 转化("召唤"机制) + +``` +玩家持有英雄碎片 Item(如关羽碎片 ×30)或英雄 Item(直接获得 1 名关羽) + │ 调用 UseItem 协议 + ▼ +ItemUseHandler 分发(按 ItemDefine.useType / useScript) + │ + ▼ +对于"英雄碎片"类型 Item: + - 校验数量足够(>= getItemNum) + - 校验该 heroId 未被拥有 + - HeroComponentSystem.CreateHero(heroId) + - BagComponentSystem.RemoveItems(碎片 × getItemNum) +对于"英雄 Item"类型(直接给英雄): + - 校验该 heroId 未被拥有 + - HeroComponentSystem.CreateHero(heroId) + - BagComponentSystem.RemoveItems(英雄 Item × 1) +对于"装备"类型 Item:不可使用(穿戴走另外的 RPC) +对于"经验书"类型 Item:调 HeroComponentSystem.AddExp +对于"金币袋"类型 Item:扣道具 + AddItem 金币 itemId +... 等 +``` + +> **关键洞察**:ROK 的"召唤" 在 Survivors 不需要独立 RPC,本质就是 **UseItem 协议** 的一个分支。 + +### 6.3 包结构最终方案(基于 D1-D4 决策) + +``` +cn.etetet.bag # 核心:Item + RewardEntry + ItemPackage + Forge +├── Scripts/Model/Server/ +│ ├── BagComponent.cs # 玩家背包 +│ ├── ItemUnit.cs # 通用 Item 单元(含 heroId / exclusive 字段供装备用) +│ ├── ItemPackageConfig.cs # ItemPackage 配置 +│ └── CurrencyItemIds.cs # 货币 itemId 常量(1=金币 2=钻石 3=木料 ...) +├── Scripts/Hotfix/Server/ +│ ├── BagComponentSystem.cs # AddItems / RemoveItems / HasItems / GetItemCount +│ ├── ItemPackageSystem.cs # Open(packageId) 加权随机 → RewardEntry +│ ├── ItemUseHandler.cs # UseItem 分发(按 useType) +│ ├── Forge/ # 装备锻造子目录 +│ └── UseHandlers/ # 各类道具使用 Handler(IItemUseHandler) +│ ├── HeroFragmentUseHandler.cs # 英雄碎片 → 召唤 +│ ├── HeroItemUseHandler.cs # 英雄 Item → 直接获得 +│ ├── ExpBookUseHandler.cs # 经验书 → 英雄加经验 +│ └── CurrencyBagUseHandler.cs # 金币袋 → 加货币 + +cn.etetet.hero # 英雄养成(升级 / 升星 / 技能 / 觉醒 / 天赋 / 装备穿戴 / 战力) +├── 没有"招募"RPC(招募通过 UseItem 触发,统一入口) + +cn.etetet.tavern # 酒馆(ItemPackage 的具体业务应用 + UI 入口) +├── Scripts/Hotfix/Server/ +│ └── TavernSystem.cs # OpenSilverBox / OpenGoldBox(内部调 ItemPackageSystem.Open) + +(已删除:cn.etetet.gacha / cn.etetet.equip / cn.etetet.summon — 通通不需要) +``` + +### 6.4 设计哲学相比 ROK 的"升级点" + +| 维度 | ROK 原版 | Survivors 升级 | 收益 | +|---|---|---|---| +| 货币管理 | Role 字段 + Item 双轨制 | 全部 Item(itemId 段位区分) | 接口统一,避免每种货币都写一遍 add/remove | +| 道具使用 | ItemLogic 巨型 if/elseif | IItemUseHandler 注册机制 | 新增道具类型加一个 Handler 类,零修改老代码 | +| 抽卡 / 宝箱 / 礼包 | 分散在 ItemLogic / Tavern / 各业务 | 统一 ItemPackage 机制 | 一处实现,N 处复用 | +| 召唤 | 独立 Hero_SummonHero 协议 | UseItem 分支 | 协议数量减少,玩家行为路径统一 | + +### 6.5 与 openspec 现有 spec 的对应调整(待用户读完文档后执行) + +| openspec 文档 | 主要修改 | +|---|---| +| `proposal.md` | 抽卡定位 → 推迟 P4+;新增 ItemPackage 抽象说明;包结构按 6.3 重写 | +| `design.md` D2 | 包列表按 6.3;删 gacha/equip/summon | +| `design.md` 新增 D14 | ItemPackage 设计;Item→Entity 转化机制 | +| `specs/bag-system/spec.md` | 通用 Item 覆盖货币;新增 ItemPackage 章节;新增 ItemUseHandler 注册机制;装备并入此 spec(不再分文件) | +| `specs/hero-system/spec.md` | 删"招募 RPC",改为"通过 UseItem 触发";字段名跟 ROK;战力公式引用 Doc/ROK-Hero-Spec.md | +| `specs/equipment-system/spec.md` | **合并进 `specs/bag-system/spec.md` 作为子章节**("装备子系统");删独立文件 | +| `specs/gacha-system/spec.md` | **删除**,被 ItemPackage 抽象替代;后续做扩展抽卡时再起新 change | +| `tasks.md` | P0/P1 阶段重排;新增 ItemPackage 任务;新增 ItemUseHandler 注册任务;删独立 gacha/equip 任务 | + +--- + +## 7. 调研未覆盖的次要功能(按需补充) + +如有需要再单独调研: + +- `Build_Tavern` 协议详细(酒馆开宝箱完整流程) — 需要时去看 `proxy/Build.lua` `response.Tavern` +- 雕像兑换 `Hero_ExchangeHeroItem` — `proxy/Hero.lua` +- 行动力减免与技能关联 — `HeroLogic.lua` + 战斗服务器 +- 限时礼包触发 `RechargeLogic.triggerLimitPackage` — 与英雄关联但非英雄本身 +- 红点系统在客户端 — `BagProxy` PlayerPrefs 部分 diff --git a/Doc/Server-Setup.md b/Doc/Server-Setup.md new file mode 100644 index 0000000..3ce15d1 --- /dev/null +++ b/Doc/Server-Setup.md @@ -0,0 +1,19 @@ +# Server Setup + +## 1. 编译 + +- `powershell -ExecutionPolicy Bypass -File tools/build-server.ps1` + +## 2. 启动 Mongo(可选) + +- 本地测试可用 memory 模式(`StartZoneConfig.DBConnection=memory`) +- Mongo 模式示例:`mongodb://127.0.0.1` + +## 3. 启动服务 + +- Unity 中通过 Loader/ServerTools 启动,或按既有 ET 服务端流程启动 + +## 4. 常见问题 + +- 若 `ET.sln` 构建失败,优先检查缺失包/生成代码是否齐全 +- 若登录失败,先看 Gate 日志与 L3 错误日志 diff --git a/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef b/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef new file mode 100644 index 0000000..e9496d7 --- /dev/null +++ b/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.Bag", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef.meta b/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef.meta new file mode 100644 index 0000000..7167208 --- /dev/null +++ b/Packages/cn.xt.bag/Ignore.ET.Bag.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3241baae0a5b2f64b8b5b7550a29a238 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Proto.meta b/Packages/cn.xt.bag/Proto.meta new file mode 100644 index 0000000..6692a4a --- /dev/null +++ b/Packages/cn.xt.bag/Proto.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc1c586b2b7645d4884be6c841a6c749 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto b/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto new file mode 100644 index 0000000..d7488cc --- /dev/null +++ b/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; +package ET; + +message ItemInfo +{ + int64 itemIndex = 1; + int32 itemId = 2; + int64 count = 3; + int64 heroId = 4; +} + +message EquipSlot +{ + int32 slotIndex = 1; + int64 itemIndex = 2; +} + +// ResponseType R2C_UseItem +message C2G_UseItem // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int64 itemIndex = 3; + int32 count = 4; +} + +message R2C_UseItem // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + ItemInfo item = 4; +} + +// ResponseType R2C_HeroWearEquip +message C2G_HeroWearEquip // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int64 heroId = 3; + int64 itemIndex = 4; + int32 slotIndex = 5; +} + +message R2C_HeroWearEquip // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + int64 heroId = 4; + int64 itemIndex = 5; + int32 slotIndex = 6; +} + +// ResponseType R2C_TakeOffEquip +message C2G_TakeOffEquip // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int64 heroId = 3; + int32 slotIndex = 4; +} + +message R2C_TakeOffEquip // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + int64 heroId = 4; + int64 itemIndex = 5; + int32 slotIndex = 6; +} + +message Item_ItemInfo // IMessage +{ + ItemInfo item = 1; +} + +message Item_ItemInfoList // IMessage +{ + repeated ItemInfo items = 1; +} + +message Item_RemoveItem // IMessage +{ + int64 itemIndex = 1; +} diff --git a/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto.meta b/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto.meta new file mode 100644 index 0000000..3432ecb --- /dev/null +++ b/Packages/cn.xt.bag/Proto/BagOuter_C_3100.proto.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 61af3e5634c96be48a9a899ab5ecac39 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts.meta b/Packages/cn.xt.bag/Scripts.meta new file mode 100644 index 0000000..008f72e --- /dev/null +++ b/Packages/cn.xt.bag/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 75f400aa3a023c2418b2055e8e7d1788 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix.meta b/Packages/cn.xt.bag/Scripts/Hotfix.meta new file mode 100644 index 0000000..08a4811 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e1995c2e7bd5f5047a5a4071d92f93e6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Client.meta new file mode 100644 index 0000000..397675c --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6bd0a469661c562428f97a8f3c739d87 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref b/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..cfdc090 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9be6c6e10d671074f97616f40c2ebf0d +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs new file mode 100644 index 0000000..138527b --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs @@ -0,0 +1,16 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(BagComponent))] + public class Item_ItemInfoHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Item_ItemInfo message) + { + BagComponent comp = scene.GetComponent() ?? scene.AddComponent(); + comp.ItemMap[message.item.itemIndex] = message.item; + comp.NewItemSet.Add(message.item.itemIndex); + EventSystem.Instance.Publish(scene, new ItemChangedEvent { ItemIndex = message.item.itemIndex }); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs.meta new file mode 100644 index 0000000..a1c25a8 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5860a8d6e966d844682e26d1414d30a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs new file mode 100644 index 0000000..cb5dc90 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs @@ -0,0 +1,21 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(BagComponent))] + public class Item_ItemInfoListHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Item_ItemInfoList message) + { + BagComponent comp = scene.GetComponent() ?? scene.AddComponent(); + comp.ItemMap.Clear(); + comp.NewItemSet.Clear(); + foreach (ItemInfo item in message.items) + { + comp.ItemMap[item.itemIndex] = item; + } + + EventSystem.Instance.Publish(scene, new BagUpdatedEvent { Count = message.items.Count }); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs.meta new file mode 100644 index 0000000..90913e5 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_ItemInfoListHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d780f0b2ef6a3e4ca5c7d481ed155f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs new file mode 100644 index 0000000..63f27dd --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs @@ -0,0 +1,16 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(BagComponent))] + public class Item_RemoveItemHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Item_RemoveItem message) + { + BagComponent comp = scene.GetComponent() ?? scene.AddComponent(); + comp.ItemMap.Remove(message.itemIndex); + comp.NewItemSet.Remove(message.itemIndex); + EventSystem.Instance.Publish(scene, new ItemChangedEvent { ItemIndex = message.itemIndex }); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs.meta new file mode 100644 index 0000000..093ab02 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Client/Item_RemoveItemHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d74a1095280d0ab48984fe73d9766a79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server.meta new file mode 100644 index 0000000..f732afd --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 73d4efcd0d7572c4eb4e54e519825d47 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref b/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..7842a20 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e853a52d3eddc454c944b92c500f7e21 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs new file mode 100644 index 0000000..d06185c --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ET.Server +{ + [FriendOf(typeof(ItemUnit))] + [FriendOf(typeof(BagComponent))] + public static class BagComponentSystem + { + // 约定:>=10000 的道具按装备类处理(独立实例,不堆叠) + private static bool IsEquipItem(int itemId) + { + return itemId >= 10000; + } + + public static ItemUnit AddItem(this BagComponent self, int itemId, long count) + { + if (count <= 0) + { + return null; + } + + if (!IsEquipItem(itemId)) + { + ItemUnit stack = self.ItemMap.Values.Select(x => (ItemUnit)x).FirstOrDefault(x => x.ItemId == itemId && x.HeroId == 0); + if (stack != null) + { + stack.Count += count; + BagNoticeHelper.NotifyItemChange(self.GetParent(), stack); + if (count > 1000) + { + LogExtensions.Audit("Bag", "LargeResourceAdd", $"{{\"playerId\":{self.GetParent()?.Id ?? 0},\"itemId\":{itemId},\"count\":{count}}}"); + } + return stack; + } + } + + long itemIndex = self.NextItemIndex++; + ItemUnit unit = self.AddChildWithId(itemIndex, itemIndex, itemId, count, 0); + self.ItemMap[itemIndex] = unit; + BagNoticeHelper.NotifyItemChange(self.GetParent(), unit); + if (count > 1000) + { + LogExtensions.Audit("Bag", "LargeResourceAdd", $"{{\"playerId\":{self.GetParent()?.Id ?? 0},\"itemId\":{itemId},\"count\":{count}}}"); + } + return unit; + } + + public static bool RemoveItem(this BagComponent self, int itemId, long count) + { + if (count <= 0) + { + return true; + } + + if (self.GetItemCount(itemId) < count) + { + return false; + } + + long remain = count; + List units = self.ItemMap.Values + .Select(x => (ItemUnit)x) + .Where(x => x.ItemId == itemId && x.HeroId == 0) + .OrderBy(x => x.ItemIndex) + .ToList(); + foreach (ItemUnit unit in units) + { + if (remain <= 0) + { + break; + } + + long cost = remain > unit.Count ? unit.Count : remain; + unit.Count -= cost; + remain -= cost; + if (unit.Count <= 0) + { + self.RemoveItemByIndex(unit.ItemIndex); + } + else + { + BagNoticeHelper.NotifyItemChange(self.GetParent(), unit); + } + } + + return remain == 0; + } + + public static bool RemoveItemByIndex(this BagComponent self, long itemIndex) + { + if (!self.ItemMap.TryGetValue(itemIndex, out EntityRef unitRef)) + { + return false; + } + + ItemUnit unit = unitRef; + self.ItemMap.Remove(itemIndex); + BagNoticeHelper.NotifyItemRemove(self.GetParent(), itemIndex); + unit.Dispose(); + return true; + } + + public static bool SetHeroIdOnEquip(this BagComponent self, long itemIndex, long heroId) + { + if (!self.ItemMap.TryGetValue(itemIndex, out EntityRef unitRef)) + { + return false; + } + + ItemUnit unit = unitRef; + unit.HeroId = heroId; + BagNoticeHelper.NotifyItemChange(self.GetParent(), unit); + return true; + } + + public static void AddItems(this BagComponent self, List rewards) + { + if (rewards == null) + { + return; + } + + foreach (RewardEntry reward in rewards) + { + self.AddItem(reward.ItemId, reward.Count); + } + + self.GetParent()?.SetDirty(); + } + + public static bool RemoveItems(this BagComponent self, List costs) + { + if (!self.HasItems(costs)) + { + return false; + } + + foreach (CostEntry cost in costs) + { + if (!self.RemoveItem(cost.ItemId, cost.Count)) + { + return false; + } + } + + self.GetParent()?.SetDirty(); + return true; + } + + public static long GetItemCount(this BagComponent self, int itemId) + { + long total = 0; + foreach (EntityRef unitRef in self.ItemMap.Values) + { + ItemUnit unit = unitRef; + if (unit.ItemId == itemId && unit.HeroId == 0) + { + total += unit.Count; + } + } + + return total; + } + + public static bool HasItems(this BagComponent self, List costs) + { + if (costs == null) + { + return true; + } + + foreach (CostEntry cost in costs) + { + if (self.GetItemCount(cost.ItemId) < cost.Count) + { + return false; + } + } + + return true; + } + + public static List GetAllItems(this BagComponent self) + { + return self.ItemMap.Values.Select(x => (ItemUnit)x).OrderBy(x => x.ItemIndex).ToList(); + } + + public static void ClearBagKeepEquipped(this BagComponent self) + { + List removed = new(); + foreach (EntityRef unitRef in self.ItemMap.Values) + { + ItemUnit item = unitRef; + if (item.HeroId == 0) + { + removed.Add(item.ItemIndex); + } + } + + foreach (long itemIndex in removed) + { + self.RemoveItemByIndex(itemIndex); + } + self.GetParent()?.SetDirty(); + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs.meta new file mode 100644 index 0000000..831f759 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b5bc749596ed1d84ea4cb0f6df2bdc6b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs new file mode 100644 index 0000000..1cfb142 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs @@ -0,0 +1,205 @@ +using System; +using System.Linq; +using System.Text; + +namespace ET.Server +{ + [FriendOf(typeof(PlayerComponent))] + public static class BagConsoleHelper + { + public static Player ResolvePlayer(Scene root, string account) + { + PlayerComponent playerComp = root.GetComponent(); + if (playerComp == null || playerComp.dictionary.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(account)) + { + return playerComp.GetByAccount(account); + } + + foreach (EntityRef value in playerComp.dictionary.Values) + { + return value; + } + + return null; + } + + public static string[] SplitArgs(string content) + { + string[] raw = content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return raw.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray(); + } + } + + [ConsoleHandler("AddItem")] + public class C_AddItem : IConsoleHandler + { + // AddItem [account] itemId count + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = BagConsoleHelper.SplitArgs(content); + if (args.Length < 2 || args.Length > 3) + { + Log.Console("[GM] AddItem 参数错误: AddItem [account] itemId count"); + return; + } + + string account = args.Length == 3 ? args[0] : string.Empty; + int shift = args.Length == 3 ? 1 : 0; + if (!int.TryParse(args[0 + shift], out int itemId) || !long.TryParse(args[1 + shift], out long count)) + { + Log.Console("[GM] AddItem 参数错误: itemId/count 非法"); + return; + } + + Player player = BagConsoleHelper.ResolvePlayer(fiber.Root, account); + BagComponent bag = player?.GetComponent() ?? player?.AddComponent(); + if (player == null || bag == null) + { + Log.Console("[GM] AddItem 失败: 无在线玩家"); + return; + } + + ItemUnit item = bag.AddItem(itemId, count); + player.SetDirty(); + LogExtensions.AuditGM("AddItem", $"{{\"account\":\"{player.Account}\",\"itemId\":{itemId},\"count\":{count},\"itemIndex\":{item?.ItemIndex ?? 0}}}"); + Log.Console($"[GM] AddItem success account={player.Account} itemId={itemId} count={count}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("RemoveItem")] + public class C_RemoveItem : IConsoleHandler + { + // RemoveItem [account] itemId count + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = BagConsoleHelper.SplitArgs(content); + if (args.Length < 2 || args.Length > 3) + { + Log.Console("[GM] RemoveItem 参数错误: RemoveItem [account] itemId count"); + return; + } + + string account = args.Length == 3 ? args[0] : string.Empty; + int shift = args.Length == 3 ? 1 : 0; + if (!int.TryParse(args[0 + shift], out int itemId) || !long.TryParse(args[1 + shift], out long count)) + { + Log.Console("[GM] RemoveItem 参数错误: itemId/count 非法"); + return; + } + + Player player = BagConsoleHelper.ResolvePlayer(fiber.Root, account); + BagComponent bag = player?.GetComponent(); + if (player == null || bag == null || !bag.RemoveItem(itemId, count)) + { + Log.Console("[GM] RemoveItem 失败: 数量不足或玩家不存在"); + return; + } + + player.SetDirty(); + LogExtensions.AuditGM("RemoveItem", $"{{\"account\":\"{player.Account}\",\"itemId\":{itemId},\"count\":{count}}}"); + Log.Console($"[GM] RemoveItem success account={player.Account} itemId={itemId} count={count}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("ShowBag")] + public class C_ShowBag : IConsoleHandler + { + // ShowBag [account] + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Player player = BagConsoleHelper.ResolvePlayer(fiber.Root, content.Trim()); + BagComponent bag = player?.GetComponent(); + if (bag == null) + { + Log.Console("[GM] ShowBag: 背包不存在"); + return; + } + + StringBuilder sb = new(); + var items = bag.GetAllItems(); + sb.Append($"[GM] ShowBag account={player.Account} count={items.Count}"); + foreach (ItemUnit item in items) + { + sb.Append($" | idx={item.ItemIndex},itemId={item.ItemId},count={item.Count},heroId={item.HeroId}"); + } + Log.Console(sb.ToString()); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("BagClear")] + public class C_BagClear : IConsoleHandler + { + // BagClear [account] + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Player player = BagConsoleHelper.ResolvePlayer(fiber.Root, content.Trim()); + BagComponent bag = player?.GetComponent(); + if (bag == null) + { + Log.Console("[GM] BagClear: 背包不存在"); + return; + } + + bag.ClearBagKeepEquipped(); + LogExtensions.AuditGM("BagClear", $"{{\"account\":\"{player.Account}\"}}"); + Log.Console($"[GM] BagClear success account={player.Account}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("UseItem")] + public class C_UseItem : IConsoleHandler + { + // UseItem [account] itemIndex [count] + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = BagConsoleHelper.SplitArgs(content); + if (args.Length < 1 || args.Length > 3) + { + Log.Console("[GM] UseItem 参数错误: UseItem [account] itemIndex [count]"); + return; + } + + int shift = 0; + string account = string.Empty; + if (args.Length >= 2 && !long.TryParse(args[0], out _)) + { + account = args[0]; + shift = 1; + } + + if (!long.TryParse(args[shift], out long itemIndex)) + { + Log.Console("[GM] UseItem 参数错误: itemIndex 非法"); + return; + } + + int count = 1; + if (shift + 1 < args.Length) + { + int.TryParse(args[shift + 1], out count); + } + + Player player = BagConsoleHelper.ResolvePlayer(fiber.Root, account); + string reason = string.Empty; + ItemUnit item = null; + if (player == null || !BagService.TryUseItem(player, itemIndex, count, out reason, out item)) + { + Log.Console($"[GM] UseItem 失败: {reason}"); + return; + } + + LogExtensions.AuditGM("UseItem", $"{{\"account\":\"{player.Account}\",\"itemIndex\":{itemIndex},\"count\":{count},\"left\":{item?.Count ?? 0}}}"); + Log.Console($"[GM] UseItem success account={player.Account} itemIndex={itemIndex} left={item?.Count ?? 0}"); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs.meta new file mode 100644 index 0000000..3b2e0af --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagConsoleHandlers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd852d5111cbaa34c8d9eee141387ad4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs new file mode 100644 index 0000000..f31e729 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs @@ -0,0 +1,20 @@ +namespace ET.Server +{ + public static class BagMapper + { + public static ItemInfo ToProto(ItemUnit unit) + { + if (unit == null) + { + return null; + } + + ItemInfo info = ItemInfo.Create(); + info.itemIndex = unit.ItemIndex; + info.itemId = unit.ItemId; + info.count = unit.Count; + info.heroId = unit.HeroId; + return info; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs.meta new file mode 100644 index 0000000..7a4ac8f --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagMapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3424a2ac52396794593ed008ca6d8061 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs new file mode 100644 index 0000000..c3b85fd --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs @@ -0,0 +1,48 @@ +namespace ET.Server +{ + [FriendOf(typeof(BagComponent))] + public static class BagNoticeHelper + { + public static void NotifyItemChange(Player player, ItemUnit unit) + { + Session session = player?.GetComponent()?.Session; + if (session == null || unit == null) + { + return; + } + + Item_ItemInfo notice = Item_ItemInfo.Create(); + notice.item = BagMapper.ToProto(unit); + session.Send(notice); + } + + public static void NotifyItemRemove(Player player, long itemIndex) + { + Session session = player?.GetComponent()?.Session; + if (session == null) + { + return; + } + + Item_RemoveItem notice = Item_RemoveItem.Create(); + notice.itemIndex = itemIndex; + session.Send(notice); + } + + public static void NotifyItemList(Session session, BagComponent bag) + { + if (session == null || bag == null) + { + return; + } + + Item_ItemInfoList list = Item_ItemInfoList.Create(); + foreach (EntityRef unitRef in bag.ItemMap.Values) + { + ItemUnit unit = unitRef; + list.items.Add(BagMapper.ToProto(unit)); + } + session.Send(list); + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs.meta new file mode 100644 index 0000000..03fd159 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagNoticeHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e2ac59cf6b717149b67aa0c5f39fda5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs new file mode 100644 index 0000000..7837a9e --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs @@ -0,0 +1,60 @@ +namespace ET.Server +{ + [FriendOf(typeof(BagComponent))] + [FriendOf(typeof(ItemUnit))] + public static class BagService + { + private static bool IsEquipItem(int itemId) + { + return itemId >= 10000; + } + + public static bool TryUseItem(Player player, long itemIndex, int count, out string reason, out ItemUnit item) + { + reason = string.Empty; + item = null; + + BagComponent bag = player?.GetComponent(); + if (bag == null || !bag.ItemMap.TryGetValue(itemIndex, out EntityRef unitRef)) + { + reason = "item_not_found"; + return false; + } + + ItemUnit unit = unitRef; + item = unit; + if (count <= 0 || unit.Count < count) + { + reason = "count_invalid"; + return false; + } + + if (unit.HeroId > 0) + { + reason = "equip_in_use"; + return false; + } + + if (IsEquipItem(unit.ItemId)) + { + reason = "equip_can_not_use"; + return false; + } + + unit.Count -= count; + if (unit.Count <= 0) + { + bag.RemoveItemByIndex(unit.ItemIndex); + item = null; + } + else + { + BagNoticeHelper.NotifyItemChange(player, unit); + } + + player.SetDirty(); + LogExtensions.Audit("Bag", "UseItem", $"{{\"playerId\":{player.Id},\"itemIndex\":{itemIndex},\"itemId\":{unit.ItemId},\"count\":{count}}}"); + return true; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs.meta new file mode 100644 index 0000000..8e4207e --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/BagService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ceda0fb4d1ad2641ad48a6d2c7f5a51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs new file mode 100644 index 0000000..070b660 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs @@ -0,0 +1,27 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_UseItemHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_UseItem request, R2C_UseItem response) + { + Player player = session.GetComponent()?.Player; + if (player == null) + { + response.Error = ErrorCode.ERR_RpcFail; + response.Message = "player_not_found"; + return; + } + + if (!BagService.TryUseItem(player, request.itemIndex, request.count <= 0 ? 1 : request.count, out string reason, out ItemUnit item)) + { + response.Error = ErrorCode.ERR_BAG_ITEM_NOT_ENOUGH; + response.Message = reason; + return; + } + + response.item = BagMapper.ToProto(item); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs.meta new file mode 100644 index 0000000..1a77c2a --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/C2G_UseItemHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8891d333af566674ea212d9fae952a52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs new file mode 100644 index 0000000..a2a6d22 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + public static class InitialResourceHelper + { + public static void EnsureInit(Player player) + { + BagComponent bag = player.GetComponent() ?? player.AddComponent(); + if (bag.GetAllItems().Count > 0) + { + return; + } + + bag.AddItems(new List + { + new() { ItemId = 1, Count = 10000 }, // 金币 + new() { ItemId = 2, Count = 1000 }, // 钻石 + }); + + LogExtensions.Audit("Bag", "InitialResourceGrant", $"{{\"playerId\":{player.Id}}}"); + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs.meta new file mode 100644 index 0000000..36c8277 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/InitialResourceHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 623edf3200a689d44a13e189d99aa832 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs b/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs new file mode 100644 index 0000000..ee41f9f --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs @@ -0,0 +1,16 @@ +namespace ET.Server +{ + [EntitySystemOf(typeof(ItemUnit))] + [FriendOf(typeof(ItemUnit))] + public static partial class ItemUnitSystem + { + [EntitySystem] + private static void Awake(this ItemUnit self, long itemIndex, int itemId, long count, long heroId) + { + self.ItemIndex = itemIndex; + self.ItemId = itemId; + self.Count = count; + self.HeroId = heroId; + } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs.meta b/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs.meta new file mode 100644 index 0000000..0149183 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Hotfix/Server/ItemUnitSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc19d0391ad570549a010e81b4332a57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model.meta b/Packages/cn.xt.bag/Scripts/Model.meta new file mode 100644 index 0000000..500b8f7 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6678345fff2abea47b1f0dc370d57958 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Client.meta b/Packages/cn.xt.bag/Scripts/Model/Client.meta new file mode 100644 index 0000000..0cd86d5 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5ec239f9554725241a664c2138da19df +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref b/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..f5c207f --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9b4313e1576e0b54f91e291c8e1f46b2 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs b/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs new file mode 100644 index 0000000..ff4d5a7 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace ET.Client +{ + [ComponentOf(typeof(Scene))] + public class BagComponent : Entity, IAwake, IDestroy + { + public Dictionary ItemMap = new(); + public HashSet NewItemSet = new(); + } + + public struct ItemChangedEvent + { + public long ItemIndex; + } + + public struct BagUpdatedEvent + { + public int Count; + } +} diff --git a/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs.meta b/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs.meta new file mode 100644 index 0000000..0beaad0 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Client/BagModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2e52aeef56aa75a40808a996562f586c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Server.meta b/Packages/cn.xt.bag/Scripts/Model/Server.meta new file mode 100644 index 0000000..7d22a35 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2b527b81222f98d4688182d27d5cb22d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref b/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..70d7ae9 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 628be4dc2891baa45a10076767b1651e +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs b/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs new file mode 100644 index 0000000..01010be --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; + +namespace ET.Server +{ + [ComponentOf(typeof(Player))] + public class BagComponent : Entity, IAwake, IDestroy + { + [BsonElement] + public long NextItemIndex = 1; + [BsonElement] + public Dictionary> ItemMap = new(); + } +} diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs.meta b/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs.meta new file mode 100644 index 0000000..b000ff1 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/BagComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80db836d71d05ef4e95870ba5dfd0435 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs b/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs new file mode 100644 index 0000000..a4681f5 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs @@ -0,0 +1,14 @@ +namespace ET +{ + public struct RewardEntry + { + public int ItemId; + public long Count; + } + + public struct CostEntry + { + public int ItemId; + public long Count; + } +} diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs.meta b/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs.meta new file mode 100644 index 0000000..6e38124 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/CommonEntries.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d1eb783733a4ba24f800172359315927 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs b/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs new file mode 100644 index 0000000..a93c422 --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs @@ -0,0 +1,17 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace ET.Server +{ + [ChildOf(typeof(BagComponent))] + public class ItemUnit : Entity, IAwake + { + [BsonElement] + public long ItemIndex { get; set; } + [BsonElement] + public int ItemId { get; set; } + [BsonElement] + public long Count { get; set; } + [BsonElement] + public long HeroId { get; set; } + } +} diff --git a/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs.meta b/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs.meta new file mode 100644 index 0000000..cf2b6aa --- /dev/null +++ b/Packages/cn.xt.bag/Scripts/Model/Server/ItemUnit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff433e1ca41884446a40586231631e25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.bag/package.json b/Packages/cn.xt.bag/package.json new file mode 100644 index 0000000..a4595c3 --- /dev/null +++ b/Packages/cn.xt.bag/package.json @@ -0,0 +1,24 @@ +{ + "name": "cn.xt.bag", + "displayName": "ET.Bag", + "version": "0.0.1", + "unity": "2022.3", + "description": "Bag core module for universal item storage.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.bag" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.login": "3.0.0", + "cn.etetet.console": "3.0.0", + "cn.etetet.logging": "0.0.1" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.bag/package.json.meta b/Packages/cn.xt.bag/package.json.meta new file mode 100644 index 0000000..67e0118 --- /dev/null +++ b/Packages/cn.xt.bag/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3546971ab74002240a68d08da4f16950 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef b/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef new file mode 100644 index 0000000..8601737 --- /dev/null +++ b/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.BagInvoke", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef.meta b/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef.meta new file mode 100644 index 0000000..728b870 --- /dev/null +++ b/Packages/cn.xt.baginvoke/Ignore.ET.BagInvoke.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3f4992bd58d61d64ebfea5afd1144a40 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Scripts.meta b/Packages/cn.xt.baginvoke/Scripts.meta new file mode 100644 index 0000000..57e6a06 --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6a1f8ef658890ba4d8d92ef7e3d845a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Scripts/Model.meta b/Packages/cn.xt.baginvoke/Scripts/Model.meta new file mode 100644 index 0000000..0e72208 --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5581b331342b0cb419a08c9b2ff2f2ed +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Scripts/Model/Share.meta b/Packages/cn.xt.baginvoke/Scripts/Model/Share.meta new file mode 100644 index 0000000..067bbcf --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8f1e25def94fb8446ac1890e0d9caf6f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref b/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..979b19d --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6f9c66ba90104c343bbbad1d30becb4b +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs b/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs new file mode 100644 index 0000000..7cc1f90 --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs @@ -0,0 +1,23 @@ +namespace ET +{ + public static class BagInvokeContract + { + public const string AddItems = "BagInvokeContract.AddItems"; + public const string RemoveItems = "BagInvokeContract.RemoveItems"; + public const string ShowBagSnapshot = "BagInvokeContract.ShowBagSnapshot"; + } + + public struct BagInvokeAddItems + { + public long PlayerId; + public int ItemId; + public long Count; + } + + public struct BagInvokeRemoveItems + { + public long PlayerId; + public int ItemId; + public long Count; + } +} diff --git a/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs.meta b/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs.meta new file mode 100644 index 0000000..9146cdf --- /dev/null +++ b/Packages/cn.xt.baginvoke/Scripts/Model/Share/BagInvokeContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee50386f2d01d3c4690d3d5f17802353 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.baginvoke/package.json b/Packages/cn.xt.baginvoke/package.json new file mode 100644 index 0000000..3a6b088 --- /dev/null +++ b/Packages/cn.xt.baginvoke/package.json @@ -0,0 +1,22 @@ +{ + "name": "cn.xt.baginvoke", + "displayName": "ET.Bag.Invoke", + "version": "0.0.1", + "unity": "2022.3", + "description": "Cross-package invoke contracts for bag module.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.baginvoke" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.yiuiinvoke": "4.0.3" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.baginvoke/package.json.meta b/Packages/cn.xt.baginvoke/package.json.meta new file mode 100644 index 0000000..e66389a --- /dev/null +++ b/Packages/cn.xt.baginvoke/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4e566574766d2884284237a424730a2a +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Ignore.ET.Config.asmdef b/Packages/cn.xt.config/Ignore.ET.Config.asmdef new file mode 100644 index 0000000..f207758 --- /dev/null +++ b/Packages/cn.xt.config/Ignore.ET.Config.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.Config", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.config/Ignore.ET.Config.asmdef.meta b/Packages/cn.xt.config/Ignore.ET.Config.asmdef.meta new file mode 100644 index 0000000..587ba44 --- /dev/null +++ b/Packages/cn.xt.config/Ignore.ET.Config.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 739d9581d60071d44b285ab4627db193 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts.meta b/Packages/cn.xt.config/Scripts.meta new file mode 100644 index 0000000..da01670 --- /dev/null +++ b/Packages/cn.xt.config/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e94cb38ad20d5f8438b54b522de40e25 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Hotfix.meta b/Packages/cn.xt.config/Scripts/Hotfix.meta new file mode 100644 index 0000000..f096719 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d33da66b61ad2cf44915a78dd5e7acef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share.meta b/Packages/cn.xt.config/Scripts/Hotfix/Share.meta new file mode 100644 index 0000000..badbb10 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 119ed73d241d0494a97a762bf5ea10cb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref b/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..e92cc90 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 40342f8734113244381a678ba5912304 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs b/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs new file mode 100644 index 0000000..b464641 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs @@ -0,0 +1,19 @@ +namespace ET +{ + [EntitySystemOf(typeof(ConfigComponent))] + [FriendOf(typeof(ConfigComponent))] + public static partial class ConfigComponentSystem + { + [EntitySystem] + private static void Awake(this ConfigComponent self) + { + self.Tables = new cfg.Tables(); + } + + [EntitySystem] + private static void Destroy(this ConfigComponent self) + { + self.Tables = null; + } + } +} diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs.meta b/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs.meta new file mode 100644 index 0000000..dd1075e --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/ConfigComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c022c196d3fc834d9f07cda14d7f020 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs b/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs new file mode 100644 index 0000000..109993f --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs @@ -0,0 +1,10 @@ +namespace ET +{ + public static class SceneConfigExtensions + { + public static cfg.Tables Cfg(this Scene self) + { + return self.GetComponent()?.Tables; + } + } +} diff --git a/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs.meta b/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs.meta new file mode 100644 index 0000000..afd12f0 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Hotfix/Share/SceneConfigExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 137c4cb0950076e45aa385a93f07189a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model.meta b/Packages/cn.xt.config/Scripts/Model.meta new file mode 100644 index 0000000..02a9315 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7731b355a79a2a049a3ecf58426116af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model/Share.meta b/Packages/cn.xt.config/Scripts/Model/Share.meta new file mode 100644 index 0000000..3655c73 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6e08b9da9a727c94c96963e972d892be +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref b/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..3d116d8 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dfcd8b086e500114d98a78c0dedce2d7 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs b/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs new file mode 100644 index 0000000..033ddae --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs @@ -0,0 +1,10 @@ +using cfg; + +namespace ET +{ + [ComponentOf(typeof(Scene))] + public class ConfigComponent : Entity, IAwake, IDestroy + { + public Tables Tables { get; set; } + } +} diff --git a/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs.meta b/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs.meta new file mode 100644 index 0000000..61d45e7 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/ConfigComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4469784ae5a28204896eeea8aca490dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model/Share/cfg.meta b/Packages/cn.xt.config/Scripts/Model/Share/cfg.meta new file mode 100644 index 0000000..5098f43 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/cfg.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6e69ab9a5941e664cb4d312f71f9d936 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs b/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs new file mode 100644 index 0000000..d7dec51 --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using ET; + +namespace cfg +{ + [EnableClass] + public sealed class Tables + { + public TbItem TbItem { get; } = new(); + public TbHero TbHero { get; } = new(); + + public Tables() + { + } + } + + [EnableClass] + public sealed class TbItem + { + private readonly Dictionary rows = new() + { + { 1, new ItemRow { Id = 1, Name = "金币" } }, + { 2, new ItemRow { Id = 2, Name = "钻石" } }, + { 3, new ItemRow { Id = 3, Name = "PvP币" } }, + }; + + public ItemRow Get(int id) + { + this.rows.TryGetValue(id, out ItemRow row); + return row; + } + } + + [EnableClass] + public sealed class ItemRow + { + public int Id { get; set; } + public string Name { get; set; } + } + + [EnableClass] + public sealed class TbHero + { + private readonly Dictionary rows = new() + { + { 10001, new HeroRow { Id = 10001, Name = "Hero-10001" } }, + { 10002, new HeroRow { Id = 10002, Name = "Hero-10002" } }, + { 10003, new HeroRow { Id = 10003, Name = "Hero-10003" } }, + }; + + public HeroRow Get(int id) + { + this.rows.TryGetValue(id, out HeroRow row); + return row; + } + + public IEnumerable GetAll() + { + return this.rows.Values; + } + } + + [EnableClass] + public sealed class HeroRow + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs.meta b/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs.meta new file mode 100644 index 0000000..979cabc --- /dev/null +++ b/Packages/cn.xt.config/Scripts/Model/Share/cfg/Tables.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3cc66aac35ec59b45ad0b286fb2cef71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.config/package.json b/Packages/cn.xt.config/package.json new file mode 100644 index 0000000..ca76633 --- /dev/null +++ b/Packages/cn.xt.config/package.json @@ -0,0 +1,21 @@ +{ + "name": "cn.xt.config", + "displayName": "ET.Config", + "version": "0.0.1", + "unity": "2022.3", + "description": "Luban config runtime component for ET scenes.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.config" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.config/package.json.meta b/Packages/cn.xt.config/package.json.meta new file mode 100644 index 0000000..83891cd --- /dev/null +++ b/Packages/cn.xt.config/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8ea57074402a97641bd394a8451d1a66 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Ignore.ET.DB.asmdef b/Packages/cn.xt.db/Ignore.ET.DB.asmdef new file mode 100644 index 0000000..2d38a68 --- /dev/null +++ b/Packages/cn.xt.db/Ignore.ET.DB.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.DB", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.db/Ignore.ET.DB.asmdef.meta b/Packages/cn.xt.db/Ignore.ET.DB.asmdef.meta new file mode 100644 index 0000000..aa1252e --- /dev/null +++ b/Packages/cn.xt.db/Ignore.ET.DB.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eadf893d2cf06964fb9c80ca8e4add69 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts.meta b/Packages/cn.xt.db/Scripts.meta new file mode 100644 index 0000000..5462f82 --- /dev/null +++ b/Packages/cn.xt.db/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 24735c6affe88c64797e2dac48890fec +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix.meta b/Packages/cn.xt.db/Scripts/Hotfix.meta new file mode 100644 index 0000000..f6ebeb0 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ab6338db2f664014db1ce3914155bd9b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server.meta new file mode 100644 index 0000000..db65b4f --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dbaede9f464ebae4d88a94aad13268bf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref b/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..a95274e --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2678144fd82bebe43a7f5ae16374e80b +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs new file mode 100644 index 0000000..52d1a47 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace ET.Server +{ + [EntitySystemOf(typeof(DBComponent))] + [FriendOf(typeof(DBComponent))] + public static partial class DBComponentSystem + { + [EntitySystem] + private static void Awake(this DBComponent self, string dbConnection, string dbName) + { + if (string.Equals(dbConnection, "memory", StringComparison.OrdinalIgnoreCase)) + { + self.Inner = new MemoryDBComponent(); + return; + } + +#if DOTNET || UNITY_EDITOR || UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX || UNITY_STANDALONE_OSX + self.Inner = new MongoDBComponent(dbConnection, dbName); +#else + self.Inner = new MemoryDBComponent(); +#endif + } + + [EntitySystem] + private static void Awake(this DBComponent self, IDBComponent component) + { + self.Inner = component; + } + + [EntitySystem] + private static void Destroy(this DBComponent self) + { + self.Inner = null; + } + + public static ETTask Save(this DBComponent self, long entityId, T entity) where T : class + { + if (self.Inner == null) + { + throw new Exception("DBComponent not initialized."); + } + return SaveInternal(self, entityId, entity); + } + + public static ETTask QueryByEntityId(this DBComponent self, long entityId) where T : class + { + if (self.Inner == null) + { + throw new Exception("DBComponent not initialized."); + } + + return QueryByEntityIdInternal(self, entityId); + } + + public static ETTask> Query(this DBComponent self, Func predicate = null) where T : class + { + if (self.Inner == null) + { + throw new Exception("DBComponent not initialized."); + } + + return QueryInternal(self, predicate); + } + + public static ETTask Update(this DBComponent self, long entityId, T entity) where T : class + { + if (self.Inner == null) + { + throw new Exception("DBComponent not initialized."); + } + + return UpdateInternal(self, entityId, entity); + } + + public static ETTask Delete(this DBComponent self, long entityId) where T : class + { + if (self.Inner == null) + { + throw new Exception("DBComponent not initialized."); + } + + return DeleteInternal(self, entityId); + } + + private static async ETTask SaveInternal(DBComponent self, long entityId, T entity) where T : class + { + Stopwatch sw = Stopwatch.StartNew(); + await self.Inner.Save(entityId, entity); + sw.Stop(); + if (sw.ElapsedMilliseconds > 200) + { + LogExtensions.Slow("DB", $"Save<{typeof(T).Name}>", sw.ElapsedMilliseconds, $"id={entityId}"); + } + } + + private static async ETTask QueryByEntityIdInternal(DBComponent self, long entityId) where T : class + { + Stopwatch sw = Stopwatch.StartNew(); + T result = await self.Inner.QueryByEntityId(entityId); + sw.Stop(); + if (sw.ElapsedMilliseconds > 100) + { + LogExtensions.Slow("DB", $"QueryByEntityId<{typeof(T).Name}>", sw.ElapsedMilliseconds, $"id={entityId}"); + } + + return result; + } + + private static async ETTask> QueryInternal(DBComponent self, Func predicate = null) where T : class + { + Stopwatch sw = Stopwatch.StartNew(); + List result = await self.Inner.Query(predicate); + sw.Stop(); + if (sw.ElapsedMilliseconds > 100) + { + LogExtensions.Slow("DB", $"Query<{typeof(T).Name}>", sw.ElapsedMilliseconds); + } + + return result; + } + + private static async ETTask UpdateInternal(DBComponent self, long entityId, T entity) where T : class + { + Stopwatch sw = Stopwatch.StartNew(); + await self.Inner.Update(entityId, entity); + sw.Stop(); + if (sw.ElapsedMilliseconds > 200) + { + LogExtensions.Slow("DB", $"Update<{typeof(T).Name}>", sw.ElapsedMilliseconds, $"id={entityId}"); + } + } + + private static async ETTask DeleteInternal(DBComponent self, long entityId) where T : class + { + Stopwatch sw = Stopwatch.StartNew(); + await self.Inner.Delete(entityId); + sw.Stop(); + if (sw.ElapsedMilliseconds > 200) + { + LogExtensions.Slow("DB", $"Delete<{typeof(T).Name}>", sw.ElapsedMilliseconds, $"id={entityId}"); + } + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs.meta new file mode 100644 index 0000000..87cc149 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e13b040db24da2d4fa5a51a997e8f0ea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs new file mode 100644 index 0000000..0f59749 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs @@ -0,0 +1,23 @@ +namespace ET.Server +{ + public static class DBHelper + { + public static async ETTask LoadOrCreatePlayer(Player player, DBComponent dbComponent) + { + if (player == null || dbComponent == null) + { + return; + } + + Player dbPlayer = await dbComponent.QueryByEntityId(player.Id); + if (dbPlayer == null) + { + await dbComponent.Save(player.Id, player); + return; + } + + // 当前阶段仅接通登录落库链路,后续在 Hero/Bag/Gacha 落地后补完整反序列化挂载。 + player.Account = dbPlayer.Account; + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs.meta new file mode 100644 index 0000000..f98562c --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b08a3589e1959a94c9fe6e145367e83b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs new file mode 100644 index 0000000..f2dd44a --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + [EntitySystemOf(typeof(DBSaveComponent))] + [FriendOf(typeof(DBSaveComponent))] + public static partial class DBSaveComponentSystem + { + [EntitySystem] + private static void Awake(this DBSaveComponent self) + { + self.RunFlushLoop().NoContext(); + } + + [EntitySystem] + private static void Destroy(this DBSaveComponent self) + { + self.DirtyAccounts.Clear(); + } + + public static void MarkDirty(this DBSaveComponent self, string account) + { + if (string.IsNullOrEmpty(account)) + { + return; + } + + self.DirtyAccounts.Add(account); + } + + public static async ETTask FlushNow(this DBSaveComponent self, string account) + { + if (string.IsNullOrEmpty(account)) + { + return; + } + + Scene root = self.Root(); + DBComponent dbComponent = root.GetComponent(); + PlayerComponent playerComponent = root.GetComponent(); + if (dbComponent == null || playerComponent == null) + { + return; + } + + Player player = playerComponent.GetByAccount(account); + if (player == null) + { + self.DirtyAccounts.Remove(account); + return; + } + + await dbComponent.Save(player.Id, player); + self.DirtyAccounts.Remove(account); + } + + private static async ETTask RunFlushLoop(this DBSaveComponent self) + { + Scene root = self.Root(); + TimerComponent timerComponent = root.GetComponent(); + if (timerComponent == null) + { + return; + } + + while (!self.IsDisposed) + { + await timerComponent.WaitAsync(self.FlushIntervalMs); + if (self.IsDisposed) + { + break; + } + + await self.FlushDirtyPlayers(); + } + } + + private static async ETTask FlushDirtyPlayers(this DBSaveComponent self) + { + if (self.DirtyAccounts.Count == 0) + { + return; + } + + List accounts = new(self.DirtyAccounts); + foreach (string account in accounts) + { + await self.FlushNow(account); + } + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs.meta new file mode 100644 index 0000000..8bf25dd --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/DBSaveComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88b34b0cf4f55a841a0e760ed1d1391a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs b/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs new file mode 100644 index 0000000..5963415 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs @@ -0,0 +1,27 @@ +namespace ET.Server +{ + public static class EntityDirtyExtensions + { + public static void SetDirty(this Entity entity) + { + if (entity is not Player player) + { + return; + } + + Scene root = player.Root(); + if (root == null) + { + return; + } + + DBSaveComponent saveComponent = root.GetComponent(); + if (saveComponent == null) + { + return; + } + + saveComponent.MarkDirty(player.Account); + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs.meta new file mode 100644 index 0000000..d177b11 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/EntityDirtyExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b037c5f35612c7c47a7d66a685e2954a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs b/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs new file mode 100644 index 0000000..ff1c874 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs @@ -0,0 +1,22 @@ +namespace ET.Server +{ + [Event(SceneType.Gate)] + public class PlayerSessionDispose_FlushDb : AEvent + { + protected override async ETTask Run(Scene scene, PlayerSessionDispose args) + { + if (string.IsNullOrEmpty(args.Account)) + { + return; + } + + DBSaveComponent dbSaveComponent = scene.GetComponent(); + if (dbSaveComponent == null) + { + return; + } + + await dbSaveComponent.FlushNow(args.Account); + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs.meta b/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs.meta new file mode 100644 index 0000000..eca5791 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Hotfix/Server/PlayerSessionDispose_FlushDb.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb851c568098f154c9ba6d0d9033e188 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model.meta b/Packages/cn.xt.db/Scripts/Model.meta new file mode 100644 index 0000000..e01ea45 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 208d9ebc6d5fd2f4bb71f3025164b71d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server.meta b/Packages/cn.xt.db/Scripts/Model/Server.meta new file mode 100644 index 0000000..39a13dc --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ff177ec598750274aad02d200c9db94e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref b/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..c8e4756 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 98bd56f179bcc6745808de2118d421b8 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs b/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs new file mode 100644 index 0000000..0ff5e97 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs @@ -0,0 +1,8 @@ +namespace ET.Server +{ + [ComponentOf(typeof(Scene))] + public class DBComponent : Entity, IAwake, IAwake, IDestroy + { + public IDBComponent Inner; + } +} diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs.meta new file mode 100644 index 0000000..4798a78 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DBComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 70fb216278b0fee48ac84e4b2efabd2b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs b/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs new file mode 100644 index 0000000..ae0af22 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + [ComponentOf(typeof(Scene))] + public sealed class DBSaveComponent : Entity, IAwake, IDestroy + { + public readonly HashSet DirtyAccounts = new(); + + public int FlushIntervalMs = 30_000; + } +} diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs.meta new file mode 100644 index 0000000..9f0d516 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DBSaveComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4da7945932dcb7d4492182bac9f37077 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs b/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs new file mode 100644 index 0000000..e347c90 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs @@ -0,0 +1,13 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace ET.Server +{ + [EnableClass] + public sealed class DbEntityRecord + { + [BsonId] + public long Id { get; set; } + + public byte[] Payload { get; set; } + } +} diff --git a/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs.meta new file mode 100644 index 0000000..4c2e446 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/DbEntityRecord.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 352ccdcdbf9b9ca498372c57ea545bce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs b/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs new file mode 100644 index 0000000..3688873 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace ET.Server +{ + public interface IDBComponent + { + ETTask Save(long entityId, T entity) where T : class; + + ETTask QueryByEntityId(long entityId) where T : class; + + ETTask> Query(Func predicate = null) where T : class; + + ETTask Update(long entityId, T entity) where T : class; + + ETTask Delete(long entityId) where T : class; + } +} diff --git a/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs.meta new file mode 100644 index 0000000..a208372 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/IDBComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4d095f3f32e30c40bc71e630adcffd1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs b/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs new file mode 100644 index 0000000..c11c7ee --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ET.Server +{ + [EnableClass] + public sealed class MemoryDBComponent : IDBComponent + { + private readonly Dictionary> data = new(); + + public async ETTask Save(long entityId, T entity) where T : class + { + Dictionary map = this.GetBucket(); + map[entityId] = MongoHelper.Serialize(entity); + await ETTask.CompletedTask; + } + + public async ETTask QueryByEntityId(long entityId) where T : class + { + await ETTask.CompletedTask; + Dictionary map = this.GetBucket(); + if (!map.TryGetValue(entityId, out byte[] bytes)) + { + return null; + } + + return MongoHelper.Deserialize(bytes); + } + + public async ETTask> Query(Func predicate = null) where T : class + { + await ETTask.CompletedTask; + Dictionary map = this.GetBucket(); + IEnumerable entities = map.Values.Select(MongoHelper.Deserialize); + if (predicate != null) + { + entities = entities.Where(predicate); + } + + return entities.ToList(); + } + + public ETTask Update(long entityId, T entity) where T : class + { + return this.Save(entityId, entity); + } + + public async ETTask Delete(long entityId) where T : class + { + Dictionary map = this.GetBucket(); + map.Remove(entityId); + await ETTask.CompletedTask; + } + + private Dictionary GetBucket() where T : class + { + string key = typeof(T).Name; + if (!this.data.TryGetValue(key, out Dictionary bucket)) + { + bucket = new Dictionary(); + this.data[key] = bucket; + } + + return bucket; + } + } +} diff --git a/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs.meta new file mode 100644 index 0000000..ca2a160 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/MemoryDBComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b0e7637d39b6e9745b42e0b1c59cceea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs b/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs new file mode 100644 index 0000000..3f3ff25 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs @@ -0,0 +1,80 @@ +#if DOTNET || UNITY_EDITOR || UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX || UNITY_STANDALONE_OSX +using System; +using System.Collections.Generic; +using System.Linq; +using MongoDB.Driver; + +namespace ET.Server +{ + [EnableClass] + public sealed class MongoDBComponent : IDBComponent + { + private readonly IMongoDatabase database; + + public MongoDBComponent(string connectionString, string dbName) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Mongo connection string is empty.", nameof(connectionString)); + } + + if (string.IsNullOrWhiteSpace(dbName)) + { + throw new ArgumentException("Mongo DB name is empty.", nameof(dbName)); + } + + MongoClient client = new(connectionString); + this.database = client.GetDatabase(dbName); + } + + public async ETTask Save(long entityId, T entity) where T : class + { + DbEntityRecord record = new() + { + Id = entityId, + Payload = MongoHelper.Serialize(entity) + }; + + IMongoCollection collection = this.GetCollection(); + await collection.ReplaceOneAsync(x => x.Id == entityId, record, new ReplaceOptions { IsUpsert = true }); + } + + public async ETTask QueryByEntityId(long entityId) where T : class + { + IMongoCollection collection = this.GetCollection(); + DbEntityRecord record = await collection.Find(x => x.Id == entityId).FirstOrDefaultAsync(); + return record == null ? null : MongoHelper.Deserialize(record.Payload); + } + + public async ETTask> Query(Func predicate = null) where T : class + { + IMongoCollection collection = this.GetCollection(); + List records = await collection.Find(Builders.Filter.Empty).ToListAsync(); + IEnumerable entities = records.Select(x => MongoHelper.Deserialize(x.Payload)); + if (predicate != null) + { + entities = entities.Where(predicate); + } + + return entities.ToList(); + } + + public ETTask Update(long entityId, T entity) where T : class + { + return this.Save(entityId, entity); + } + + public async ETTask Delete(long entityId) where T : class + { + IMongoCollection collection = this.GetCollection(); + await collection.DeleteOneAsync(x => x.Id == entityId); + } + + private IMongoCollection GetCollection() where T : class + { + string collectionName = typeof(T).Name; + return this.database.GetCollection(collectionName); + } + } +} +#endif diff --git a/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs.meta b/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs.meta new file mode 100644 index 0000000..1ada2e3 --- /dev/null +++ b/Packages/cn.xt.db/Scripts/Model/Server/MongoDBComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ec67d73c1e87794984bd19aeff8bda8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.db/package.json b/Packages/cn.xt.db/package.json new file mode 100644 index 0000000..b0836ee --- /dev/null +++ b/Packages/cn.xt.db/package.json @@ -0,0 +1,23 @@ +{ + "name": "cn.xt.db", + "displayName": "ET.DB", + "version": "0.0.1", + "unity": "2022.3", + "description": "ET server database abstraction built on MongoHelper and MongoDB.Driver.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.db" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.login": "3.0.0", + "cn.etetet.logging": "0.0.1" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.db/package.json.meta b/Packages/cn.xt.db/package.json.meta new file mode 100644 index 0000000..4df770f --- /dev/null +++ b/Packages/cn.xt.db/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fd8b18332a5fe1845ae8312f0680821f +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef b/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef new file mode 100644 index 0000000..c6a49df --- /dev/null +++ b/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.Gacha", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef.meta b/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef.meta new file mode 100644 index 0000000..bb7ff99 --- /dev/null +++ b/Packages/cn.xt.gacha/Ignore.ET.Gacha.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2557e9070eb938344ac2c83706305fe7 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Proto.meta b/Packages/cn.xt.gacha/Proto.meta new file mode 100644 index 0000000..5225e7c --- /dev/null +++ b/Packages/cn.xt.gacha/Proto.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e67f1b4ca6176d84b82dc8e3d1d6bde5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto b/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto new file mode 100644 index 0000000..7d7cb9f --- /dev/null +++ b/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package ET; + +message GachaPoolStats +{ + int32 poolId = 1; + int32 totalCount = 2; + int32 guaranteeRemain = 3; +} + +// ResponseType R2C_GachaDraw +message C2G_GachaDraw // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int32 poolId = 3; + int32 drawType = 4; // 1=single 2=multi10 +} + +message R2C_GachaDraw // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + repeated RewardEntry rewards = 4; + repeated bool isNewHero = 5; +} + +message Gacha_GachaInfo // IMessage +{ + repeated GachaPoolStats stats = 1; +} diff --git a/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto.meta b/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto.meta new file mode 100644 index 0000000..3e74e12 --- /dev/null +++ b/Packages/cn.xt.gacha/Proto/GachaOuter_C_3200.proto.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5ef964422353df1428a2b88ac8820d0f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts.meta b/Packages/cn.xt.gacha/Scripts.meta new file mode 100644 index 0000000..944e692 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ca2550e905fc1274f897dc7efd04ffeb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix.meta b/Packages/cn.xt.gacha/Scripts/Hotfix.meta new file mode 100644 index 0000000..06b6e5c --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 100d7e805446ffe4f80e8de208b6031b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Client.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Client.meta new file mode 100644 index 0000000..ea6c923 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7942d2c89d3e67d4cbfbf922cfb2c76b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..64ee77e --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4c6c9b8a655ac764fa480bb1bf69e9a7 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs new file mode 100644 index 0000000..06ab625 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs @@ -0,0 +1,19 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(GachaComponent))] + public class Gacha_GachaInfoHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Gacha_GachaInfo message) + { + GachaComponent component = scene.GetComponent() ?? scene.AddComponent(); + foreach (GachaPoolStats stat in message.stats) + { + component.Stats[stat.poolId] = stat; + EventSystem.Instance.Publish(scene, new GachaUpdatedEvent { PoolId = stat.poolId }); + } + + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs.meta new file mode 100644 index 0000000..1db4118 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Client/Gacha_GachaInfoHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f72656e8cb5d1844a50a1f69681272e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server.meta new file mode 100644 index 0000000..b55e9bf --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c30620641e28b6341a969ff3a2f55077 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..2c59e8e --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e5db67bbdf8579f4a810cb787731e4b8 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs new file mode 100644 index 0000000..9f134fa --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + [FriendOf(typeof(GachaComponent))] + public class C2G_GachaDrawHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_GachaDraw request, R2C_GachaDraw response) + { + SessionPlayerComponent sessionPlayer = session.GetComponent(); + Player player = sessionPlayer?.Player; + if (player == null) + { + response.Error = ErrorCode.ERR_RpcFail; + response.Message = "Player not found"; + return; + } + + GachaComponent gacha = player.GetComponent() ?? player.AddComponent(); + if (!gacha.PoolStats.TryGetValue(request.poolId, out GachaPoolState state)) + { + state = new GachaPoolState(); + gacha.PoolStats[request.poolId] = state; + } + long now = TimeInfo.Instance.ServerNow(); + if (now - state.LastDrawMs < 100) + { + response.Error = ErrorCode.ERR_GACHA_RATE_LIMIT; + response.Message = "Rate limited"; + Log.Error($"[L3][Gacha] player={player.Id} poolId={request.poolId} rate-limit"); + return; + } + + BagComponent bag = player.GetComponent() ?? player.AddComponent(); + List rewards = gacha.Draw(request.poolId, (GachaDrawType)request.drawType, bag); + response.rewards.AddRange(rewards); + foreach (RewardEntry reward in rewards) + { + response.isNewHero.Add(reward.ItemId >= 10000); + } + + Gacha_GachaInfo info = Gacha_GachaInfo.Create(); + foreach (var kv in gacha.PoolStats) + { + GachaPoolStats stat = GachaPoolStats.Create(); + stat.poolId = kv.Key; + stat.totalCount = kv.Value.TotalCount; + stat.guaranteeRemain = kv.Value.GuaranteeRemain; + info.stats.Add(stat); + } + session.Send(info); + LogExtensions.Audit("Gacha", "DrawHandler", $"{{\"playerId\":{player.Id},\"poolId\":{request.poolId},\"count\":{rewards.Count}}}"); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs.meta new file mode 100644 index 0000000..2741cf3 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/C2G_GachaDrawHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 127ae5674127f104ea6838eec525c409 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs new file mode 100644 index 0000000..aece9ea --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs @@ -0,0 +1,12 @@ +using System.Security.Cryptography; + +namespace ET.Server +{ + public static class CryptoRandomGenerator + { + public static int Next(int minInclusive, int maxExclusive) + { + return RandomNumberGenerator.GetInt32(minInclusive, maxExclusive); + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs.meta new file mode 100644 index 0000000..a24e536 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/CryptoRandomGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 896c5a15337ddc148b6ab6f3a97df9e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs new file mode 100644 index 0000000..bbd1fa6 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ET.Server +{ + [FriendOf(typeof(GachaComponent))] + public static class GachaComponentSystem + { + public static List Draw(this GachaComponent self, int poolId, GachaDrawType drawType, BagComponent bag) + { + List weights = GetPoolWeights(poolId); + if (weights.Count == 0) + { + return new List(); + } + + int times = drawType == GachaDrawType.Single ? 1 : 10; + List costs = GetDrawCost(poolId, drawType); + if (!bag.HasItems(costs)) + { + throw new RpcException(ErrorCode.ERR_GACHA_COST_NOT_ENOUGH, "Gacha cost not enough"); + } + + bool removed = bag.RemoveItems(costs); + if (!removed) + { + throw new RpcException(ErrorCode.ERR_GACHA_COST_NOT_ENOUGH, "Gacha cost remove failed"); + } + + List rewards = new(times); + for (int i = 0; i < times; i++) + { + GachaPoolWeight pick = PickByWeight(weights); + rewards.Add(new RewardEntry { ItemId = pick.ItemId, Count = pick.Count }); + self.UpdateGuarantee(poolId, pick.Rare, 90); + } + + if (drawType == GachaDrawType.Multi10 && !rewards.Any(r => GetRareByItemId(r.ItemId) >= 4)) + { + self.ReplaceWithGuaranteed(rewards, weights, 4); + } + + bag.AddItems(rewards); + LogExtensions.Audit( + "Gacha", + "Draw", + $"{{\"poolId\":{poolId},\"drawType\":{(int)drawType},\"rewardsCount\":{rewards.Count},\"guaranteeRemain\":{self.GetGuaranteeRemain(poolId)}}}"); + return rewards; + } + + public static void UpdateGuarantee(this GachaComponent self, int poolId, int rare, int guaranteeCount) + { + if (!self.PoolStats.TryGetValue(poolId, out GachaPoolState state)) + { + state = new GachaPoolState { GuaranteeRemain = guaranteeCount }; + self.PoolStats[poolId] = state; + } + + state.TotalCount += 1; + state.LastDrawMs = TimeInfo.Instance.ServerNow(); + if (rare >= 5) + { + state.GuaranteeRemain = guaranteeCount; + return; + } + + state.GuaranteeRemain = Math.Max(0, state.GuaranteeRemain - 1); + if (state.GuaranteeRemain == 0) + { + state.GuaranteeRemain = guaranteeCount; + } + } + + public static void ReplaceWithGuaranteed(this GachaComponent self, List rewards, List weights, int minRare) + { + GachaPoolWeight guaranteed = weights.FirstOrDefault(x => x.Rare >= minRare); + if (guaranteed == null || rewards.Count == 0) + { + return; + } + + int index = rewards.Count - 1; + rewards[index] = new RewardEntry { ItemId = guaranteed.ItemId, Count = guaranteed.Count }; + } + + public static int GetGuaranteeRemain(this GachaComponent self, int poolId) + { + if (!self.PoolStats.TryGetValue(poolId, out GachaPoolState state)) + { + return 90; + } + return state.GuaranteeRemain; + } + + private static GachaPoolWeight PickByWeight(List weights) + { + int total = weights.Sum(x => x.Weight); + int point = CryptoRandomGenerator.Next(0, Math.Max(total, 1)); + int sum = 0; + foreach (GachaPoolWeight w in weights) + { + sum += w.Weight; + if (point < sum) + { + return w; + } + } + + return weights[weights.Count - 1]; + } + + private static List GetDrawCost(int poolId, GachaDrawType drawType) + { + // TODO: 由 Luban TbGachaPool 驱动。当前使用固定测试配置。 + return drawType == GachaDrawType.Single + ? new List { new() { ItemId = poolId == 1 ? 1 : 2, Count = 1 } } + : new List { new() { ItemId = poolId == 1 ? 1 : 2, Count = 9 } }; + } + + private static int GetRareByItemId(int itemId) + { + if (itemId >= 10000) return 5; + if (itemId >= 1000) return 4; + return 3; + } + + private static List GetPoolWeights(int poolId) + { + // TODO: 由 Luban TbGachaWeight 读取。当前提供固定测试池。 + if (poolId == 2) + { + return new List + { + new() { ItemId = 2, Count = 10, Weight = 7000, Rare = 3 }, + new() { ItemId = 1001, Count = 5, Weight = 2500, Rare = 4 }, + new() { ItemId = 10001, Count = 1, Weight = 500, Rare = 5 }, + }; + } + + return new List + { + new() { ItemId = 1, Count = 100, Weight = 7000, Rare = 3 }, + new() { ItemId = 1001, Count = 1, Weight = 2500, Rare = 4 }, + new() { ItemId = 10001, Count = 1, Weight = 500, Rare = 5 }, + }; + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs.meta new file mode 100644 index 0000000..445c67e --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f7a9a8c3aa5d494aa3368347f59599c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs new file mode 100644 index 0000000..2341054 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs @@ -0,0 +1,67 @@ +using System.Text; + +namespace ET.Server +{ + [ConsoleHandler("Gacha")] + public class C_Gacha : IConsoleHandler + { + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Log.Console($"[GM] Gacha command received: {content}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("SimulateGacha")] + public class C_SimulateGacha : IConsoleHandler + { + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] parts = content.Split(' '); + int poolId = parts.Length > 1 ? int.Parse(parts[1]) : 1; + int times = parts.Length > 2 ? int.Parse(parts[2]) : 1000; + + int r3 = 0, r4 = 0, r5 = 0; + for (int i = 0; i < times; i++) + { + int roll = CryptoRandomGenerator.Next(0, 10000); + if (roll < 7000) r3++; + else if (roll < 9500) r4++; + else r5++; + } + + Log.Console($"[GM] SimulateGacha pool={poolId} times={times} rare3={r3} rare4={r4} rare5={r5}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("ShowGachaStats")] + public class C_ShowGachaStats : IConsoleHandler + { + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Log.Console("[GM] ShowGachaStats TODO: attach player and dump gacha pool stats."); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("ResetGachaGuarantee")] + public class C_ResetGachaGuarantee : IConsoleHandler + { + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Log.Console($"[GM] ResetGachaGuarantee command: {content}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("SetGachaCount")] + public class C_SetGachaCount : IConsoleHandler + { + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Log.Console($"[GM] SetGachaCount command: {content}"); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs.meta new file mode 100644 index 0000000..c20498f --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaConsoleHandlers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c86ba723426cb84897eed30659932f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs new file mode 100644 index 0000000..505fd6a --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + public static class GachaTests + { + // 2.0.4.5 + public static void TestDistribution10000() + { + int times = 10_000; + int rare5 = 0; + for (int i = 0; i < times; i++) + { + int roll = CryptoRandomGenerator.Next(0, 10000); + if (roll >= 9500) rare5++; + } + + float ratio = rare5 / (float)times; + Log.Info($"[Test][Gacha] rare5 ratio={ratio}"); + } + + // 2.0.4.6 + public static void TestGuaranteeAt90() + { + GachaComponent gacha = new(); + BagComponent bag = new(); + bag.AddItem(2, 9999); + List last = null; + for (int i = 0; i < 90; i++) + { + last = gacha.Draw(2, GachaDrawType.Single, bag); + } + Log.Info($"[Test][Gacha] draw90 reward={last?[0].ItemId}"); + } + + // 2.0.4.7 + public static void TestNaturalReset() + { + GachaComponent gacha = new(); + gacha.UpdateGuarantee(1, 5, 90); + int remain = gacha.GetGuaranteeRemain(1); + Log.Info($"[Test][Gacha] natural reset remain={remain}"); + } + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs.meta b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs.meta new file mode 100644 index 0000000..553d395 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Hotfix/Server/GachaTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 60f914f4f4de8524c9e11c372f890bdd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model.meta b/Packages/cn.xt.gacha/Scripts/Model.meta new file mode 100644 index 0000000..ca442a4 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9d47b0d963cdab140ae1cef495a613af +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client.meta b/Packages/cn.xt.gacha/Scripts/Model/Client.meta new file mode 100644 index 0000000..d835403 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8fad09a8ee5bbe244854e2be3c0bace5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref b/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..bdfa372 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e22c2987f2d45464cba177ba2826ab9a +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs new file mode 100644 index 0000000..fc2fde7 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace ET.Client +{ + [ComponentOf(typeof(Scene))] + public class GachaComponent : Entity, IAwake, IDestroy + { + public Dictionary Stats = new(); + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs.meta b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs.meta new file mode 100644 index 0000000..577f01c --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c7577b6cd61b9e43a18a4cca8bf4ba7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs new file mode 100644 index 0000000..8cbbff9 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs @@ -0,0 +1,7 @@ +namespace ET.Client +{ + public struct GachaUpdatedEvent + { + public int PoolId; + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs.meta b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs.meta new file mode 100644 index 0000000..06440d2 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Client/GachaEvents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3ad46f515dce92e44973cdf60ac80515 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Server.meta b/Packages/cn.xt.gacha/Scripts/Model/Server.meta new file mode 100644 index 0000000..ab45a28 --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef1e3e19cba8e904790ef34c5424c247 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref b/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..66e7a5c --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c63b26473aaf3394c910bd27e6746467 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs b/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs new file mode 100644 index 0000000..491644d --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + public enum GachaDrawType + { + Single = 1, + Multi10 = 2 + } + + [EnableClass] + public sealed class GachaPoolWeight + { + public int ItemId; + public long Count; + public int Weight; + public int Rare; + } + + [EnableClass] + public sealed class GachaPoolState + { + public int TotalCount; + public int GuaranteeRemain; + public long LastDrawMs; + } + + [ComponentOf(typeof(Player))] + public class GachaComponent : Entity, IAwake, IDestroy + { + // 2.0.3.2: 持久化字段(按现有 BSON 序列化路径) + public Dictionary PoolStats = new(); + } +} diff --git a/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs.meta b/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs.meta new file mode 100644 index 0000000..6ce04ca --- /dev/null +++ b/Packages/cn.xt.gacha/Scripts/Model/Server/GachaModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 783d49123ec284845a8e3cda602755d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gacha/package.json b/Packages/cn.xt.gacha/package.json new file mode 100644 index 0000000..3851864 --- /dev/null +++ b/Packages/cn.xt.gacha/package.json @@ -0,0 +1,23 @@ +{ + "name": "cn.xt.gacha", + "displayName": "ET.Gacha", + "version": "0.0.1", + "unity": "2022.3", + "description": "Gacha gameplay module.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.gacha" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.xt.bag": "0.0.1", + "cn.etetet.logging": "0.0.1" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.gacha/package.json.meta b/Packages/cn.xt.gacha/package.json.meta new file mode 100644 index 0000000..edabc3b --- /dev/null +++ b/Packages/cn.xt.gacha/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4453462a50d43204eaa17ccb45ab7b42 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef b/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef new file mode 100644 index 0000000..42c72e1 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.GachaInvoke", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef.meta b/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef.meta new file mode 100644 index 0000000..12f8980 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Ignore.ET.GachaInvoke.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 10016b2e6768db448af688b92351b4fe +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Scripts.meta b/Packages/cn.xt.gachainvoke/Scripts.meta new file mode 100644 index 0000000..c7910df --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 36468afc91dfd89408a1e0d33927d470 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model.meta b/Packages/cn.xt.gachainvoke/Scripts/Model.meta new file mode 100644 index 0000000..8c8783d --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 33b0461cae433d44094cd8bbeb8b82bb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model/Share.meta b/Packages/cn.xt.gachainvoke/Scripts/Model/Share.meta new file mode 100644 index 0000000..0cbf063 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e5c4357e5c1fc6048ad297f6a4fa9432 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..b2a93ed --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 06c636f6fcf0956499720c0e0ec6ae5b +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs new file mode 100644 index 0000000..b54b257 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs @@ -0,0 +1,22 @@ +namespace ET +{ + public static class GachaInvokeContract + { + public const string Draw = "GachaInvokeContract.Draw"; + public const string Simulate = "GachaInvokeContract.Simulate"; + public const string ShowStats = "GachaInvokeContract.ShowStats"; + } + + public struct GachaInvokeDraw + { + public long PlayerId; + public int PoolId; + public int Times; + } + + public struct GachaInvokeSimulate + { + public int PoolId; + public int Times; + } +} diff --git a/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs.meta b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs.meta new file mode 100644 index 0000000..8a86b66 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/Scripts/Model/Share/GachaInvokeContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d236a8087be161d46a0c074ec6855c50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.gachainvoke/package.json b/Packages/cn.xt.gachainvoke/package.json new file mode 100644 index 0000000..b577687 --- /dev/null +++ b/Packages/cn.xt.gachainvoke/package.json @@ -0,0 +1,22 @@ +{ + "name": "cn.xt.gachainvoke", + "displayName": "ET.Gacha.Invoke", + "version": "0.0.1", + "unity": "2022.3", + "description": "Cross-package invoke contracts for gacha module.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.gachainvoke" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.yiuiinvoke": "4.0.3" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.gachainvoke/package.json.meta b/Packages/cn.xt.gachainvoke/package.json.meta new file mode 100644 index 0000000..bb1cc4a --- /dev/null +++ b/Packages/cn.xt.gachainvoke/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e961214630d973f4388456da7472394a +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef b/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef new file mode 100644 index 0000000..d4e9d0c --- /dev/null +++ b/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.Hero", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef.meta b/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef.meta new file mode 100644 index 0000000..9fae9b2 --- /dev/null +++ b/Packages/cn.xt.hero/Ignore.ET.Hero.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c941918b6478a6c499a4903489c05e47 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Proto.meta b/Packages/cn.xt.hero/Proto.meta new file mode 100644 index 0000000..cd254a7 --- /dev/null +++ b/Packages/cn.xt.hero/Proto.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 158b5911419f6cb48ab2299cbe72a50f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto b/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto new file mode 100644 index 0000000..fe29a37 --- /dev/null +++ b/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto @@ -0,0 +1,81 @@ +syntax = "proto3"; + +package ET; + +message SkillInfo +{ + int32 skillId = 1; + int32 skillLevel = 2; +} + +message HeroInfo +{ + int64 heroId = 1; + int32 configId = 2; + int32 level = 3; + int64 exp = 4; + int32 star = 5; + int64 starExp = 6; + repeated SkillInfo skills = 7; +} + +// ResponseType R2C_SummonHero +message C2G_SummonHero // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int32 configId = 3; +} + +message R2C_SummonHero // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + HeroInfo hero = 4; +} + +// ResponseType R2C_HeroAddExp +message C2G_HeroAddExp // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int64 heroId = 3; + int32 itemId = 4; + int32 count = 5; +} + +message R2C_HeroAddExp // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + HeroInfo hero = 4; +} + +// ResponseType R2C_HeroSkillUp +message C2G_HeroSkillUp // ISessionRequest +{ + int32 RpcId = 1; + string reqId = 2; + int64 heroId = 3; + int32 skillIndex = 4; +} + +message R2C_HeroSkillUp // ISessionResponse +{ + int32 RpcId = 1; + int32 Error = 2; + string Message = 3; + HeroInfo hero = 4; +} + +message Hero_HeroInfo // IMessage +{ + HeroInfo hero = 1; +} + +message Hero_HeroInfoList // IMessage +{ + repeated HeroInfo heroes = 1; +} diff --git a/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto.meta b/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto.meta new file mode 100644 index 0000000..62a51d1 --- /dev/null +++ b/Packages/cn.xt.hero/Proto/HeroOuter_C_3000.proto.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2ee3300305a84554f89df03998e4c112 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts.meta b/Packages/cn.xt.hero/Scripts.meta new file mode 100644 index 0000000..561c9a8 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 47963265b7c72c141a1e4db59fd1b0de +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix.meta b/Packages/cn.xt.hero/Scripts/Hotfix.meta new file mode 100644 index 0000000..5165d09 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b7225fa0818248a46b38dd6476f6cbe5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Client.meta new file mode 100644 index 0000000..2fe4e23 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 167250f1f0f603948acec876f1ea7cba +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..14239b3 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5ddced9c3f2f16846a17bda719c31fd0 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/GM.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Client/GM.meta new file mode 100644 index 0000000..d02b843 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/GM.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3840a5bac9d871c49b50ddf98af88c7c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs new file mode 100644 index 0000000..4a73e29 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs @@ -0,0 +1,15 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(HeroComponent))] + public class Hero_HeroInfoHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Hero_HeroInfo message) + { + HeroComponent component = scene.GetComponent() ?? scene.AddComponent(); + component.HeroMap[message.hero.heroId] = message.hero; + EventSystem.Instance.Publish(scene, new HeroUpdatedEvent { HeroId = message.hero.heroId }); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs.meta new file mode 100644 index 0000000..25f5c2a --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cb5054d3a0d9e04419c89109415b3d13 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs new file mode 100644 index 0000000..b5e119e --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs @@ -0,0 +1,19 @@ +namespace ET.Client +{ + [MessageHandler(SceneType.Main)] + [FriendOf(typeof(HeroComponent))] + public class Hero_HeroInfoListHandler : MessageHandler + { + protected override async ETTask Run(Scene scene, Hero_HeroInfoList message) + { + HeroComponent component = scene.GetComponent() ?? scene.AddComponent(); + component.HeroMap.Clear(); + foreach (HeroInfo hero in message.heroes) + { + component.HeroMap[hero.heroId] = hero; + } + EventSystem.Instance.Publish(scene, new HeroListUpdatedEvent { Count = message.heroes.Count }); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs.meta new file mode 100644 index 0000000..5baa4d7 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Client/Hero_HeroInfoListHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f81db9964b87314d92fa8fac5f3cea4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server.meta new file mode 100644 index 0000000..94e1aad --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5cab0d15695a7eb4b9791cb6fe01533c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref new file mode 100644 index 0000000..bad5bf6 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Hotfix" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..7d3b8bd --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 911f8d394ac35fb4dbb15d26a6ef192f +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs new file mode 100644 index 0000000..f0512fb --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs @@ -0,0 +1,30 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_HeroAddExpHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_HeroAddExp request, R2C_HeroAddExp response) + { + Player player = session.GetComponent()?.Player; + HeroComponent heroComp = player?.GetComponent(); + Hero hero = heroComp?.GetHero(request.heroId); + if (hero == null) + { + response.Error = ErrorCode.ERR_HERO_NOT_FOUND; + response.Message = "Hero not found"; + return; + } + + if (!HeroService.TryAddExp(player, hero, request.itemId, request.count, out string reason)) + { + response.Error = ErrorCode.ERR_BAG_ITEM_NOT_ENOUGH; + response.Message = reason; + return; + } + + response.hero = HeroMapper.ToProto(hero); + HeroNoticeHelper.NotifyHeroUpdate(session, hero); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs.meta new file mode 100644 index 0000000..3247bbc --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroAddExpHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd36ff3cb3f23254b95770101dcdb52a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs new file mode 100644 index 0000000..ae06e96 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs @@ -0,0 +1,34 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_HeroSkillUpHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_HeroSkillUp request, R2C_HeroSkillUp response) + { + Player player = session.GetComponent()?.Player; + HeroComponent heroComp = player?.GetComponent(); + Hero hero = heroComp?.GetHero(request.heroId); + if (hero == null) + { + response.Error = ErrorCode.ERR_HERO_NOT_FOUND; + response.Message = "Hero not found"; + return; + } + + int idx = request.skillIndex; + if (!HeroService.TrySkillUp(player, hero, idx, 1, 100, out string reason)) + { + response.Error = reason switch + { + "item_not_enough" => ErrorCode.ERR_BAG_ITEM_NOT_ENOUGH, + _ => ErrorCode.ERR_HERO_SKILL_MAX + }; + response.Message = reason; + return; + } + response.hero = HeroMapper.ToProto(hero); + HeroNoticeHelper.NotifyHeroUpdate(session, hero); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs.meta new file mode 100644 index 0000000..5fef874 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroSkillUpHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49bef3fe98ecfda469601a85ba2de1ad +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs new file mode 100644 index 0000000..6b6bc00 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs @@ -0,0 +1,36 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_HeroWearEquipHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_HeroWearEquip request, R2C_HeroWearEquip response) + { + Player player = session.GetComponent()?.Player; + if (player == null) + { + response.Error = ErrorCode.ERR_RpcFail; + response.Message = "player_not_found"; + return; + } + + if (!HeroEquipService.TryWearEquip(player, request.heroId, request.itemIndex, request.slotIndex, out string reason)) + { + response.Error = ErrorCode.ERR_EQUIP_NOT_ALLOWED; + response.Message = reason; + return; + } + + response.heroId = request.heroId; + response.itemIndex = request.itemIndex; + response.slotIndex = request.slotIndex; + + Hero hero = player.GetComponent()?.GetHero(request.heroId); + if (hero != null) + { + HeroNoticeHelper.NotifyHeroUpdate(session, hero); + } + + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs.meta new file mode 100644 index 0000000..9200658 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_HeroWearEquipHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 754f5ec965632b14fb0ea65e35b277d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs new file mode 100644 index 0000000..70cd2f5 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs @@ -0,0 +1,31 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_SummonHeroHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_SummonHero request, R2C_SummonHero response) + { + Player player = session.GetComponent()?.Player; + if (player == null) + { + response.Error = ErrorCode.ERR_RpcFail; + response.Message = "Player not found"; + return; + } + + BagComponent bag = player.GetComponent() ?? player.AddComponent(); + var costs = new System.Collections.Generic.List { new() { ItemId = request.configId + 1000, Count = 30 } }; + if (!bag.RemoveItems(costs)) + { + response.Error = ErrorCode.ERR_HERO_FRAGMENT_NOT_ENOUGH; + response.Message = "Fragment not enough"; + return; + } + + Hero hero = HeroService.SummonHero(player, request.configId); + response.hero = HeroMapper.ToProto(hero); + HeroNoticeHelper.NotifyHeroUpdate(session, hero); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs.meta new file mode 100644 index 0000000..0e31af9 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_SummonHeroHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a05bcbbe372a5f248bb3ca12d74f0e88 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs new file mode 100644 index 0000000..4e09566 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs @@ -0,0 +1,36 @@ +namespace ET.Server +{ + [MessageSessionHandler(SceneType.Gate)] + public class C2G_TakeOffEquipHandler : RpcMessageHandler + { + protected override async ETTask OnRun(Session session, C2G_TakeOffEquip request, R2C_TakeOffEquip response) + { + Player player = session.GetComponent()?.Player; + if (player == null) + { + response.Error = ErrorCode.ERR_RpcFail; + response.Message = "player_not_found"; + return; + } + + if (!HeroEquipService.TryTakeOffEquip(player, request.heroId, request.slotIndex, out long itemIndex, out string reason)) + { + response.Error = ErrorCode.ERR_EQUIP_SLOT_EMPTY; + response.Message = reason; + return; + } + + response.heroId = request.heroId; + response.itemIndex = itemIndex; + response.slotIndex = request.slotIndex; + + Hero hero = player.GetComponent()?.GetHero(request.heroId); + if (hero != null) + { + HeroNoticeHelper.NotifyHeroUpdate(session, hero); + } + + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs.meta new file mode 100644 index 0000000..7856001 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/C2G_TakeOffEquipHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21ee2b95db620eb4aac1dd0c0132d4fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs new file mode 100644 index 0000000..258e376 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ET.Server +{ + [FriendOf(typeof(HeroComponent))] + [FriendOf(typeof(Hero))] + public static class HeroComponentSystem + { + public static Hero AddHero(this HeroComponent self, int configId) + { + Hero hero = self.AddChild(configId); + self.HeroMap[hero.Id] = hero; + return hero; + } + + public static bool RemoveHero(this HeroComponent self, long heroId) + { + if (!self.HeroMap.TryGetValue(heroId, out EntityRef heroRef)) + { + return false; + } + + Hero hero = heroRef; + self.HeroMap.Remove(heroId); + hero.Dispose(); + return true; + } + + public static Hero GetHero(this HeroComponent self, long heroId) + { + self.HeroMap.TryGetValue(heroId, out EntityRef heroRef); + return heroRef; + } + + public static List GetAllHeroes(this HeroComponent self) + { + return self.HeroMap.Values.Select(x => (Hero)x).ToList(); + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs.meta new file mode 100644 index 0000000..ecab2c9 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroComponentSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c46f0307c0f746f458a204ee0b17f1fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs new file mode 100644 index 0000000..92b6231 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs @@ -0,0 +1,302 @@ +using System; +using System.Linq; +using System.Text; + +namespace ET.Server +{ + [FriendOf(typeof(PlayerComponent))] + public static class HeroConsoleHelper + { + public static Player ResolvePlayer(Scene root, string account) + { + PlayerComponent playerComp = root.GetComponent(); + if (playerComp == null || playerComp.dictionary.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(account)) + { + return playerComp.GetByAccount(account); + } + + foreach (EntityRef value in playerComp.dictionary.Values) + { + return value; + } + + return null; + } + + public static string[] SplitArgs(string content) + { + string[] raw = content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return raw.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray(); + } + } + + [ConsoleHandler("AddHero")] + public class C_AddHero : IConsoleHandler + { + // AddHero [account] configId + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + string account = args.Length > 1 ? args[0] : string.Empty; + string cfgText = args.Length > 1 ? args[1] : (args.Length == 1 ? args[0] : string.Empty); + if (!int.TryParse(cfgText, out int configId)) + { + Log.Console("[GM] AddHero 参数错误: AddHero [account] configId"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + if (player == null) + { + Log.Console("[GM] AddHero 失败: 无在线玩家"); + return; + } + + Hero hero = HeroService.SummonHero(player, configId); + LogExtensions.AuditGM("AddHero", $"{{\"account\":\"{player.Account}\",\"heroId\":{hero.Id},\"configId\":{configId}}}"); + Log.Console($"[GM] AddHero success account={player.Account} heroId={hero.Id} configId={configId}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("SetHeroLevel")] + [FriendOf(typeof(Hero))] + public class C_SetHeroLevel : IConsoleHandler + { + // SetHeroLevel [account] heroId level + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + if (args.Length < 2 || args.Length > 3) + { + Log.Console("[GM] SetHeroLevel 参数错误: SetHeroLevel [account] heroId level"); + return; + } + + string account = args.Length == 3 ? args[0] : string.Empty; + int shift = args.Length == 3 ? 1 : 0; + if (!long.TryParse(args[0 + shift], out long heroId) || !int.TryParse(args[1 + shift], out int level)) + { + Log.Console("[GM] SetHeroLevel 参数错误: heroId/level 非法"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + Hero hero = player?.GetComponent()?.GetHero(heroId); + if (hero == null) + { + Log.Console("[GM] SetHeroLevel 失败: hero 不存在"); + return; + } + + int before = hero.Level; + hero.Level = Math.Clamp(level, 1, 60); + player.SetDirty(); + LogExtensions.AuditGM("SetHeroLevel", $"{{\"account\":\"{player.Account}\",\"heroId\":{heroId},\"before\":{before},\"after\":{hero.Level}}}"); + Log.Console($"[GM] SetHeroLevel success account={player.Account} heroId={heroId} level={hero.Level}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("SetHeroSkill")] + [FriendOf(typeof(Hero))] + public class C_SetHeroSkill : IConsoleHandler + { + // SetHeroSkill [account] heroId skillIndex level + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + if (args.Length < 3 || args.Length > 4) + { + Log.Console("[GM] SetHeroSkill 参数错误: SetHeroSkill [account] heroId skillIndex level"); + return; + } + + string account = args.Length == 4 ? args[0] : string.Empty; + int shift = args.Length == 4 ? 1 : 0; + if (!long.TryParse(args[0 + shift], out long heroId) || + !int.TryParse(args[1 + shift], out int skillIndex) || + !int.TryParse(args[2 + shift], out int level)) + { + Log.Console("[GM] SetHeroSkill 参数错误: heroId/skillIndex/level 非法"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + Hero hero = player?.GetComponent()?.GetHero(heroId); + if (hero == null || skillIndex < 0 || skillIndex >= hero.Skills.Count) + { + Log.Console("[GM] SetHeroSkill 失败: hero 或 skillIndex 不存在"); + return; + } + + int before = hero.Skills[skillIndex]; + hero.Skills[skillIndex] = Math.Clamp(level, 0, 10); + player.SetDirty(); + LogExtensions.AuditGM("SetHeroSkill", $"{{\"account\":\"{player.Account}\",\"heroId\":{heroId},\"skillIndex\":{skillIndex},\"before\":{before},\"after\":{hero.Skills[skillIndex]}}}"); + Log.Console($"[GM] SetHeroSkill success account={player.Account} heroId={heroId} skillIndex={skillIndex} level={hero.Skills[skillIndex]}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("ShowHeroes")] + [FriendOf(typeof(Hero))] + public class C_ShowHeroes : IConsoleHandler + { + // ShowHeroes [account] + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, content.Trim()); + HeroComponent heroComp = player?.GetComponent(); + if (heroComp == null) + { + Log.Console("[GM] ShowHeroes: 无英雄数据"); + return; + } + + var heroes = heroComp.GetAllHeroes(); + StringBuilder sb = new(); + sb.Append($"[GM] ShowHeroes account={player.Account} count={heroes.Count}"); + foreach (Hero hero in heroes) + { + sb.Append($" | id={hero.Id},cfg={hero.ConfigId},lv={hero.Level},star={hero.Star},skills=[{string.Join(",", hero.Skills)}]"); + } + + Log.Console(sb.ToString()); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("SummonHero")] + public class C_SummonHero : IConsoleHandler + { + // SummonHero [account] configId + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + await new C_AddHero().Run(fiber, contex, content); + } + } + + [ConsoleHandler("HeroAddExp")] + [FriendOf(typeof(Hero))] + public class C_HeroAddExp : IConsoleHandler + { + // HeroAddExp [account] heroId itemId count + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + if (args.Length < 3 || args.Length > 4) + { + Log.Console("[GM] HeroAddExp 参数错误: HeroAddExp [account] heroId itemId count"); + return; + } + + string account = args.Length == 4 ? args[0] : string.Empty; + int shift = args.Length == 4 ? 1 : 0; + if (!long.TryParse(args[0 + shift], out long heroId) || + !int.TryParse(args[1 + shift], out int itemId) || + !int.TryParse(args[2 + shift], out int count)) + { + Log.Console("[GM] HeroAddExp 参数错误: heroId/itemId/count 非法"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + Hero hero = player?.GetComponent()?.GetHero(heroId); + if (hero == null) + { + Log.Console("[GM] HeroAddExp 失败: hero 不存在"); + return; + } + + string reason = string.Empty; + if (!HeroService.TryAddExp(player, hero, itemId, count, out reason)) + { + Log.Console($"[GM] HeroAddExp 失败: {reason}"); + return; + } + + LogExtensions.AuditGM("HeroAddExp", $"{{\"account\":\"{player.Account}\",\"heroId\":{heroId},\"itemId\":{itemId},\"count\":{count},\"level\":{hero.Level}}}"); + Log.Console($"[GM] HeroAddExp success account={player.Account} heroId={heroId} level={hero.Level} exp={hero.Exp}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("HeroWearEquip")] + public class C_HeroWearEquip : IConsoleHandler + { + // HeroWearEquip [account] heroId itemIndex slotIndex + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + if (args.Length < 3 || args.Length > 4) + { + Log.Console("[GM] HeroWearEquip 参数错误: HeroWearEquip [account] heroId itemIndex slotIndex"); + return; + } + + string account = args.Length == 4 ? args[0] : string.Empty; + int shift = args.Length == 4 ? 1 : 0; + if (!long.TryParse(args[0 + shift], out long heroId) || + !long.TryParse(args[1 + shift], out long itemIndex) || + !int.TryParse(args[2 + shift], out int slotIndex)) + { + Log.Console("[GM] HeroWearEquip 参数错误"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + string reason = string.Empty; + if (player == null || !HeroEquipService.TryWearEquip(player, heroId, itemIndex, slotIndex, out reason)) + { + Log.Console($"[GM] HeroWearEquip 失败: {reason}"); + return; + } + + LogExtensions.AuditGM("HeroWearEquip", $"{{\"account\":\"{player.Account}\",\"heroId\":{heroId},\"itemIndex\":{itemIndex},\"slotIndex\":{slotIndex}}}"); + Log.Console($"[GM] HeroWearEquip success account={player.Account} heroId={heroId} itemIndex={itemIndex}"); + await ETTask.CompletedTask; + } + } + + [ConsoleHandler("HeroTakeOffEquip")] + public class C_HeroTakeOffEquip : IConsoleHandler + { + // HeroTakeOffEquip [account] heroId slotIndex + public async ETTask Run(Fiber fiber, ModeContex contex, string content) + { + string[] args = HeroConsoleHelper.SplitArgs(content); + if (args.Length < 2 || args.Length > 3) + { + Log.Console("[GM] HeroTakeOffEquip 参数错误: HeroTakeOffEquip [account] heroId slotIndex"); + return; + } + + string account = args.Length == 3 ? args[0] : string.Empty; + int shift = args.Length == 3 ? 1 : 0; + if (!long.TryParse(args[0 + shift], out long heroId) || !int.TryParse(args[1 + shift], out int slotIndex)) + { + Log.Console("[GM] HeroTakeOffEquip 参数错误"); + return; + } + + Player player = HeroConsoleHelper.ResolvePlayer(fiber.Root, account); + long itemIndex = 0; + string reason = string.Empty; + if (player == null || !HeroEquipService.TryTakeOffEquip(player, heroId, slotIndex, out itemIndex, out reason)) + { + Log.Console($"[GM] HeroTakeOffEquip 失败: {reason}"); + return; + } + + LogExtensions.AuditGM("HeroTakeOffEquip", $"{{\"account\":\"{player.Account}\",\"heroId\":{heroId},\"slotIndex\":{slotIndex},\"itemIndex\":{itemIndex}}}"); + Log.Console($"[GM] HeroTakeOffEquip success account={player.Account} heroId={heroId} itemIndex={itemIndex}"); + await ETTask.CompletedTask; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs.meta new file mode 100644 index 0000000..20299b8 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroConsoleHandlers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4daf52c80d75a124cae349e844c30811 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs new file mode 100644 index 0000000..27461ac --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs @@ -0,0 +1,90 @@ +namespace ET.Server +{ + [FriendOf(typeof(BagComponent))] + [FriendOf(typeof(ItemUnit))] + [FriendOf(typeof(Hero))] + public static class HeroEquipService + { + private static bool IsEquipItem(int itemId) + { + return itemId >= 10000; + } + + public static bool TryWearEquip(Player player, long heroId, long itemIndex, int slotIndex, out string reason) + { + reason = string.Empty; + BagComponent bag = player?.GetComponent(); + Hero hero = player?.GetComponent()?.GetHero(heroId); + if (bag == null || hero == null) + { + reason = "hero_or_bag_not_found"; + return false; + } + + if (!bag.ItemMap.TryGetValue(itemIndex, out EntityRef unitRef)) + { + reason = "equip_not_found"; + return false; + } + + ItemUnit unit = unitRef; + if (!IsEquipItem(unit.ItemId)) + { + reason = "item_not_equip"; + return false; + } + + if (hero.EquipSlots.TryGetValue(slotIndex, out long oldItemIndex) && oldItemIndex > 0) + { + bag.SetHeroIdOnEquip(oldItemIndex, 0); + } + + if (unit.HeroId > 0 && unit.HeroId != heroId) + { + Hero oldHero = player.GetComponent()?.GetHero(unit.HeroId); + if (oldHero != null) + { + foreach (var pair in oldHero.EquipSlots) + { + if (pair.Value == unit.ItemIndex) + { + oldHero.EquipSlots[pair.Key] = 0; + break; + } + } + } + } + + hero.EquipSlots[slotIndex] = unit.ItemIndex; + bag.SetHeroIdOnEquip(unit.ItemIndex, heroId); + player.SetDirty(); + LogExtensions.Audit("Hero", "HeroWearEquip", $"{{\"playerId\":{player.Id},\"heroId\":{heroId},\"itemIndex\":{itemIndex},\"slotIndex\":{slotIndex}}}"); + return true; + } + + public static bool TryTakeOffEquip(Player player, long heroId, int slotIndex, out long itemIndex, out string reason) + { + itemIndex = 0; + reason = string.Empty; + BagComponent bag = player?.GetComponent(); + Hero hero = player?.GetComponent()?.GetHero(heroId); + if (bag == null || hero == null) + { + reason = "hero_or_bag_not_found"; + return false; + } + + if (!hero.EquipSlots.TryGetValue(slotIndex, out itemIndex) || itemIndex <= 0) + { + reason = "slot_empty"; + return false; + } + + hero.EquipSlots[slotIndex] = 0; + bag.SetHeroIdOnEquip(itemIndex, 0); + player.SetDirty(); + LogExtensions.Audit("Hero", "HeroTakeOffEquip", $"{{\"playerId\":{player.Id},\"heroId\":{heroId},\"itemIndex\":{itemIndex},\"slotIndex\":{slotIndex}}}"); + return true; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs.meta new file mode 100644 index 0000000..3d926a2 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroEquipService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 584ffe4591ce2114bac323db096970c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs new file mode 100644 index 0000000..84ac357 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs @@ -0,0 +1,24 @@ +namespace ET.Server +{ + public static class HeroMapper + { + public static HeroInfo ToProto(Hero hero) + { + HeroInfo info = HeroInfo.Create(); + info.heroId = hero.Id; + info.configId = hero.ConfigId; + info.level = hero.Level; + info.exp = hero.Exp; + info.star = hero.Star; + info.starExp = hero.StarExp; + for (int i = 0; i < hero.Skills.Count; i++) + { + SkillInfo skill = SkillInfo.Create(); + skill.skillId = i + 1; + skill.skillLevel = hero.Skills[i]; + info.skills.Add(skill); + } + return info; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs.meta new file mode 100644 index 0000000..99aa58d --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroMapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4af51c6777b29af4283c1445ae4ce74f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs new file mode 100644 index 0000000..0a0c292 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs @@ -0,0 +1,32 @@ +namespace ET.Server +{ + public static class HeroNoticeHelper + { + public static void NotifyHeroUpdate(Session session, Hero hero) + { + if (session == null || hero == null) + { + return; + } + + Hero_HeroInfo notice = Hero_HeroInfo.Create(); + notice.hero = HeroMapper.ToProto(hero); + session.Send(notice); + } + + public static void NotifyHeroList(Session session, HeroComponent heroComponent) + { + if (session == null || heroComponent == null) + { + return; + } + + Hero_HeroInfoList list = Hero_HeroInfoList.Create(); + foreach (Hero hero in heroComponent.GetAllHeroes()) + { + list.heroes.Add(HeroMapper.ToProto(hero)); + } + session.Send(list); + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs.meta new file mode 100644 index 0000000..1ab92aa --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroNoticeHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90c05f694d3478c4fb1fbacbb6b43e5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs new file mode 100644 index 0000000..a7ecdd6 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; + +namespace ET.Server +{ + [FriendOf(typeof(Hero))] + public static class HeroService + { + private const int HeroLevelCap = 60; + private const int HeroSkillCap = 10; + + public static Hero SummonHero(Player player, int configId) + { + HeroComponent heroComp = player.GetComponent() ?? player.AddComponent(); + Hero hero = heroComp.AddHero(configId); + player.SetDirty(); + LogExtensions.Audit("Hero", "SummonHero", $"{{\"playerId\":{player.Id},\"heroId\":{hero.Id},\"configId\":{configId}}}"); + return hero; + } + + public static bool TryAddExp(Player player, Hero hero, int itemId, int itemCount, out string reason) + { + reason = string.Empty; + BagComponent bag = player.GetComponent() ?? player.AddComponent(); + var costs = new List { new() { ItemId = itemId, Count = itemCount } }; + if (!bag.RemoveItems(costs)) + { + reason = "item_not_enough"; + return false; + } + + int beforeLevel = hero.Level; + hero.Exp += itemCount * 100; + while (hero.Exp >= hero.Level * 100 && hero.Level < HeroLevelCap) + { + hero.Exp -= hero.Level * 100; + hero.Level += 1; + } + + LogExtensions.Audit("Hero", "HeroAddExp", $"{{\"playerId\":{player.Id},\"heroId\":{hero.Id},\"itemId\":{itemId},\"count\":{itemCount},\"beforeLevel\":{beforeLevel},\"afterLevel\":{hero.Level}}}"); + TryAuditFirstMaxLevel(player, hero, beforeLevel); + player.SetDirty(); + return true; + } + + public static bool TrySkillUp(Player player, Hero hero, int skillIndex, int costItemId, int costCount, out string reason) + { + reason = string.Empty; + if (skillIndex < 0 || skillIndex >= hero.Skills.Count) + { + reason = "invalid_index"; + return false; + } + + if (hero.Skills[skillIndex] >= HeroSkillCap) + { + reason = "skill_max"; + return false; + } + + BagComponent bag = player.GetComponent() ?? player.AddComponent(); + var costs = new List { new() { ItemId = costItemId, Count = costCount } }; + if (!bag.RemoveItems(costs)) + { + reason = "item_not_enough"; + return false; + } + + int beforeLevel = hero.Skills[skillIndex]; + hero.Skills[skillIndex] += 1; + LogExtensions.Audit("Hero", "HeroSkillUp", $"{{\"playerId\":{player.Id},\"heroId\":{hero.Id},\"skillIndex\":{skillIndex},\"before\":{beforeLevel},\"after\":{hero.Skills[skillIndex]}}}"); + TryAuditFirstMaxSkill(player, hero); + player.SetDirty(); + return true; + } + + private static void TryAuditFirstMaxLevel(Player player, Hero hero, int beforeLevel) + { + if (hero.MaxLevelAudited || beforeLevel >= HeroLevelCap || hero.Level < HeroLevelCap) + { + return; + } + + hero.MaxLevelAudited = true; + LogExtensions.Audit("Hero", "HeroFirstMaxLevel", $"{{\"playerId\":{player.Id},\"heroId\":{hero.Id},\"level\":{hero.Level}}}"); + } + + private static void TryAuditFirstMaxSkill(Player player, Hero hero) + { + if (hero.MaxSkillAudited) + { + return; + } + + int skillCount = hero.Skills.Count > 4 ? 4 : hero.Skills.Count; + for (int i = 0; i < skillCount; ++i) + { + if (hero.Skills[i] < HeroSkillCap) + { + return; + } + } + + hero.MaxSkillAudited = true; + LogExtensions.Audit("Hero", "HeroFirstFullSkill", $"{{\"playerId\":{player.Id},\"heroId\":{hero.Id}}}"); + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs.meta new file mode 100644 index 0000000..b56fc62 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3bb1e0f8193b35a439e78e1d83b90dcb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs new file mode 100644 index 0000000..8a210ac --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs @@ -0,0 +1,19 @@ +namespace ET.Server +{ + [EntitySystemOf(typeof(Hero))] + [FriendOf(typeof(Hero))] + public static partial class HeroSystem + { + [EntitySystem] + private static void Awake(this Hero self, int configId) + { + self.ConfigId = configId; + self.Level = 1; + self.Exp = 0; + self.Star = 1; + self.StarExp = 0; + self.MaxLevelAudited = false; + self.MaxSkillAudited = false; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs.meta b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs.meta new file mode 100644 index 0000000..611f946 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Hotfix/Server/HeroSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff9419c8c2e3e434f80a09213390c03e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/HotfixView.meta b/Packages/cn.xt.hero/Scripts/HotfixView.meta new file mode 100644 index 0000000..f09f50f --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eb669b31c5106544ca509ff600efe31f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client.meta b/Packages/cn.xt.hero/Scripts/HotfixView/Client.meta new file mode 100644 index 0000000..fccbef0 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 081bbb0da496f4a4484bf518bd6adaf7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref new file mode 100644 index 0000000..c0c91e3 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.HotfixView" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..7c15cab --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ddc1dd8336dd6544381e599356c5d47f +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM.meta b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM.meta new file mode 100644 index 0000000..4bea424 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a8a03b517e2eaf4408c5cb3f3e8f0605 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs new file mode 100644 index 0000000..f6eb38b --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs @@ -0,0 +1,336 @@ +using System.Collections.Generic; +using System.Text; +using UnityEngine; +using UnityEngine.Networking; +using YIUIFramework; +using cfg; + +namespace ET.Client +{ + [FriendOf(typeof(ClientSenderComponent))] + [FriendOf(typeof(HeroComponent))] + [FriendOf(typeof(BagComponent))] + public static class GMHeroBagHelper + { + public static ClientSenderComponent GetSender(Scene clientScene) + { + return clientScene?.GetComponent(); + } + + public static bool CheckSender(Scene clientScene) + { + ClientSenderComponent sender = GetSender(clientScene); + if (sender == null || sender.netClientActorId.Fiber == 0) + { + Debug.LogError("[GM] 当前未登录或网络通道未建立,请先登录。"); + return false; + } + + return true; + } + + public static Dictionary BuildHeroConfigDropdown() + { + Tables tables = new(); + Dictionary options = new(); + foreach (HeroRow row in tables.TbHero.GetAll()) + { + string title = $"{row.Id} - {row.Name}"; + options[title] = row.Id.ToString(); + } + + return options; + } + } + + [GM(EGMType.Common, 1, "Hero-召唤", "RPC: C2G_SummonHero")] + public class GM_HeroSummon : IGMCommand + { + public List GetParams() + { + Dictionary options = GMHeroBagHelper.BuildHeroConfigDropdown(); + string defaultConfigId = "10001"; + foreach (KeyValuePair pair in options) + { + defaultConfigId = pair.Value; + break; + } + + return new() + { + new GMParamInfo(EGMParamType.Int, "英雄配置ID(下拉来自TbHero)", defaultConfigId) + { + OptionDic = options + }, + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + int configId = paramVo.Get(0); + C2G_SummonHero request = C2G_SummonHero.Create(); + request.configId = configId; + R2C_SummonHero response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_SummonHero; + Debug.Log($"[GM] Hero-召唤: error={response?.Error} msg={response?.Message} heroId={response?.hero?.heroId}"); + return false; + } + } + + [GM(EGMType.Common, 1, "Hero-加经验", "RPC: C2G_HeroAddExp")] + public class GM_HeroAddExpRpc : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.Long, "heroId", "0"), + new GMParamInfo(EGMParamType.Int, "itemId", "1"), + new GMParamInfo(EGMParamType.Int, "count", "1"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + C2G_HeroAddExp request = C2G_HeroAddExp.Create(); + request.heroId = paramVo.Get(0); + request.itemId = paramVo.Get(1); + request.count = paramVo.Get(2); + + R2C_HeroAddExp response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_HeroAddExp; + Debug.Log($"[GM] Hero-加经验: error={response?.Error} msg={response?.Message} lv={response?.hero?.level} exp={response?.hero?.exp}"); + return false; + } + } + + [GM(EGMType.Common, 1, "Hero-升技能", "RPC: C2G_HeroSkillUp")] + public class GM_HeroSkillUpRpc : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.Long, "heroId", "0"), + new GMParamInfo(EGMParamType.Int, "skillIndex", "0"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + C2G_HeroSkillUp request = C2G_HeroSkillUp.Create(); + request.heroId = paramVo.Get(0); + request.skillIndex = paramVo.Get(1); + + R2C_HeroSkillUp response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_HeroSkillUp; + Debug.Log($"[GM] Hero-升技能: error={response?.Error} msg={response?.Message}"); + return false; + } + } + + [GM(EGMType.Common, 1, "Bag-使用道具", "RPC: C2G_UseItem")] + public class GM_BagUseItemRpc : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.Long, "itemIndex", "0"), + new GMParamInfo(EGMParamType.Int, "count", "1"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + C2G_UseItem request = C2G_UseItem.Create(); + request.itemIndex = paramVo.Get(0); + request.count = paramVo.Get(1); + + R2C_UseItem response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_UseItem; + Debug.Log($"[GM] Bag-使用道具: error={response?.Error} msg={response?.Message}"); + return false; + } + } + + [GM(EGMType.Common, 1, "Hero-穿装备", "RPC: C2G_HeroWearEquip")] + public class GM_HeroWearEquipRpc : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.Long, "heroId", "0"), + new GMParamInfo(EGMParamType.Long, "itemIndex", "0"), + new GMParamInfo(EGMParamType.Int, "slotIndex", "1"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + C2G_HeroWearEquip request = C2G_HeroWearEquip.Create(); + request.heroId = paramVo.Get(0); + request.itemIndex = paramVo.Get(1); + request.slotIndex = paramVo.Get(2); + + R2C_HeroWearEquip response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_HeroWearEquip; + Debug.Log($"[GM] Hero-穿装备: error={response?.Error} msg={response?.Message}"); + return false; + } + } + + [GM(EGMType.Common, 1, "Hero-卸装备", "RPC: C2G_TakeOffEquip")] + public class GM_HeroTakeOffEquipRpc : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.Long, "heroId", "0"), + new GMParamInfo(EGMParamType.Int, "slotIndex", "1"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + if (!GMHeroBagHelper.CheckSender(clientScene)) + { + return false; + } + + C2G_TakeOffEquip request = C2G_TakeOffEquip.Create(); + request.heroId = paramVo.Get(0); + request.slotIndex = paramVo.Get(1); + + R2C_TakeOffEquip response = await GMHeroBagHelper.GetSender(clientScene).Call(request, false) as R2C_TakeOffEquip; + Debug.Log($"[GM] Hero-卸装备: error={response?.Error} msg={response?.Message}"); + return false; + } + } + + [GM(EGMType.Common, 1, "缓存-显示Hero列表", "打印客户端Hero缓存")] + [FriendOf(typeof(HeroComponent))] + public class GM_ShowHeroCache : IGMCommand + { + public List GetParams() + { + return new(); + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + HeroComponent heroComp = clientScene.GetComponent(); + if (heroComp == null || heroComp.HeroMap.Count == 0) + { + Debug.LogWarning("[GM] Hero缓存为空"); + return false; + } + + StringBuilder sb = new(); + sb.Append($"[GM] Hero缓存 count={heroComp.HeroMap.Count}"); + foreach (var pair in heroComp.HeroMap) + { + HeroInfo hero = pair.Value; + sb.Append($" | id={hero.heroId},cfg={hero.configId},lv={hero.level},star={hero.star}"); + } + Debug.Log(sb.ToString()); + await ETTask.CompletedTask; + return false; + } + } + + [GM(EGMType.Common, 1, "缓存-显示Bag列表", "打印客户端Bag缓存")] + [FriendOf(typeof(BagComponent))] + public class GM_ShowBagCache : IGMCommand + { + public List GetParams() + { + return new(); + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + BagComponent bagComp = clientScene.GetComponent(); + if (bagComp == null || bagComp.ItemMap.Count == 0) + { + Debug.LogWarning("[GM] Bag缓存为空"); + return false; + } + + StringBuilder sb = new(); + sb.Append($"[GM] Bag缓存 count={bagComp.ItemMap.Count}"); + foreach (var pair in bagComp.ItemMap) + { + ItemInfo item = pair.Value; + sb.Append($" | idx={item.itemIndex},itemId={item.itemId},count={item.count},heroId={item.heroId}"); + } + Debug.Log(sb.ToString()); + await ETTask.CompletedTask; + return false; + } + } + + [GM(EGMType.Common, 1, "ServerGM-执行文本", "走HttpGMHandler执行服务端GM命令")] + public class GM_ServerCommandHttp : IGMCommand + { + public List GetParams() + { + return new() + { + new GMParamInfo(EGMParamType.String, "url", "http://127.0.0.1:10002/gm"), + new GMParamInfo(EGMParamType.String, "password(可空)", ""), + new GMParamInfo(EGMParamType.String, "command", "ShowHeroes"), + }; + } + + public async ETTask Run(Scene clientScene, ParamVo paramVo) + { + string url = paramVo.Get(0); + string password = paramVo.Get(1); + string command = paramVo.Get(2); + if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(command)) + { + Debug.LogError("[GM] url / command 不能为空"); + return false; + } + + byte[] bytes = Encoding.UTF8.GetBytes(command); + using UnityWebRequest req = new(url, UnityWebRequest.kHttpVerbPOST); + req.uploadHandler = new UploadHandlerRaw(bytes); + req.downloadHandler = new DownloadHandlerBuffer(); + req.SetRequestHeader("Content-Type", "text/plain; charset=utf-8"); + if (!string.IsNullOrWhiteSpace(password)) + { + req.SetRequestHeader("X-GM-Password", password); + } + + await req.SendWebRequest(); + bool ok = req.result == UnityWebRequest.Result.Success; + string text = req.downloadHandler?.text ?? string.Empty; + Debug.Log($"[GM] ServerGM result={(ok ? "OK" : "FAIL")} code={req.responseCode} body={text}"); + return false; + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs.meta b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs.meta new file mode 100644 index 0000000..c91ea84 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/HotfixView/Client/GM/GM_Command_HeroBag.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c19dfee99c8c09d42a8e147031baddc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model.meta b/Packages/cn.xt.hero/Scripts/Model.meta new file mode 100644 index 0000000..685085c --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef3815273906a8f44ac647c9c6e9d312 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Client.meta b/Packages/cn.xt.hero/Scripts/Model/Client.meta new file mode 100644 index 0000000..7b3dd36 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Client.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ba6f42e7755112b44933387de1ab95e7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref.meta new file mode 100644 index 0000000..b363b38 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Client/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a81267a6635683e4ca87ecea2ada6e31 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs b/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs new file mode 100644 index 0000000..b4032ca --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace ET.Client +{ + [ComponentOf(typeof(Scene))] + public class HeroComponent : Entity, IAwake, IDestroy + { + public Dictionary HeroMap = new(); + } + + public struct HeroUpdatedEvent + { + public long HeroId; + } + + public struct HeroListUpdatedEvent + { + public int Count; + } +} diff --git a/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs.meta b/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs.meta new file mode 100644 index 0000000..d8bd93e --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Client/HeroModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf481cdc1e28a3a4a8b9d60009c224d0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Server.meta b/Packages/cn.xt.hero/Scripts/Model/Server.meta new file mode 100644 index 0000000..cf3dd28 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bd0b6120a17d2e54eb5a053d7521b54d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref.meta new file mode 100644 index 0000000..0838fd0 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: abd5cd15458b09641b72ceb5242358e7 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs b/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs new file mode 100644 index 0000000..8679dd4 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; + +namespace ET.Server +{ + [ChildOf(typeof(HeroComponent))] + public class Hero : Entity, IAwake + { + [BsonElement] + public int ConfigId { get; set; } + [BsonElement] + public int Level { get; set; } = 1; + [BsonElement] + public long Exp { get; set; } + [BsonElement] + public int Star { get; set; } = 1; + [BsonElement] + public long StarExp { get; set; } + [BsonElement] + public List Skills { get; set; } = new() { 1, 1, 1, 1, 0 }; + [BsonElement] + public Dictionary EquipSlots { get; set; } = new(); + [BsonElement] + public bool MaxLevelAudited { get; set; } + [BsonElement] + public bool MaxSkillAudited { get; set; } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs.meta b/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs.meta new file mode 100644 index 0000000..d5d6199 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/Hero.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f84d4383f3f5421489cda1b68111c053 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs b/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs new file mode 100644 index 0000000..d703d91 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; + +namespace ET.Server +{ + [ComponentOf(typeof(Player))] + public class HeroComponent : Entity, IAwake, IDestroy + { + [BsonElement] + public Dictionary> HeroMap = new(); + } +} diff --git a/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs.meta b/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs.meta new file mode 100644 index 0000000..9cab5a5 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Server/HeroComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1493489cb8e445544ab1a5218e5cd931 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Share.meta b/Packages/cn.xt.hero/Scripts/Model/Share.meta new file mode 100644 index 0000000..3792608 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e236246455a84944da435d68d8e14cef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref b/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..ebfd7c4 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ab8fbcafba9fcda46ab400c47afbb565 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs new file mode 100644 index 0000000..b8de7fa --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs @@ -0,0 +1,32 @@ +namespace ET +{ + [EnableClass] + public static class HeroPowerCalculator + { + public static int GetBaseScore(int rare) + { + return rare * 100; + } + + public static int GetLevelScore(int level) + { + return level * 10; + } + + public static int GetSkillScore(HeroInfo hero) + { + int total = 0; + foreach (SkillInfo s in hero.skills) + { + total += s.skillLevel * 5; + } + return total; + } + + public static int GetPower(HeroInfo hero) + { + int rare = hero.star <= 0 ? 1 : hero.star; + return GetBaseScore(rare) + GetLevelScore(hero.level) + GetSkillScore(hero); + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs.meta b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs.meta new file mode 100644 index 0000000..988a639 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5596b98fe741af24b9a186431a747f71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs new file mode 100644 index 0000000..3df2b64 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs @@ -0,0 +1,32 @@ +namespace ET +{ + [EnableClass] + public static class HeroPowerCalculatorTests + { + public static void TestPowerConsistency() + { + HeroInfo hero = HeroInfo.Create(); + hero.heroId = 1; + hero.configId = 10001; + hero.level = 10; + hero.star = 3; + + SkillInfo skill1 = SkillInfo.Create(); + skill1.skillId = 1; + skill1.skillLevel = 2; + hero.skills.Add(skill1); + + SkillInfo skill2 = SkillInfo.Create(); + skill2.skillId = 2; + skill2.skillLevel = 3; + hero.skills.Add(skill2); + + int serverPower = HeroPowerCalculator.GetPower(hero); + int clientPower = HeroPowerCalculator.GetPower(hero); + if (serverPower != clientPower) + { + throw new System.Exception($"Power mismatch: server={serverPower} client={clientPower}"); + } + } + } +} diff --git a/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs.meta b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs.meta new file mode 100644 index 0000000..8152120 --- /dev/null +++ b/Packages/cn.xt.hero/Scripts/Model/Share/HeroPowerCalculatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28f018cad47062641ab99fb4d351cda5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.hero/package.json b/Packages/cn.xt.hero/package.json new file mode 100644 index 0000000..fd0cc6d --- /dev/null +++ b/Packages/cn.xt.hero/package.json @@ -0,0 +1,27 @@ +{ + "name": "cn.xt.hero", + "displayName": "ET.Hero", + "version": "0.0.1", + "unity": "2022.3", + "description": "Hero gameplay module.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.hero" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.login": "3.0.0", + "cn.etetet.console": "3.0.0", + "cn.xt.bag": "0.0.1", + "cn.xt.config": "0.0.1", + "cn.etetet.logging": "0.0.1", + "cn.etetet.yiuigm": "3.0.4" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.hero/package.json.meta b/Packages/cn.xt.hero/package.json.meta new file mode 100644 index 0000000..be34333 --- /dev/null +++ b/Packages/cn.xt.hero/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b3b6ba680f33ae749a793758a46179f2 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef b/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef new file mode 100644 index 0000000..cb7fc16 --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Ignore.ET.HeroInvoke", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [ + "IGNORE" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef.meta b/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef.meta new file mode 100644 index 0000000..1d1d62a --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Ignore.ET.HeroInvoke.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 64654cae1a7b24746a9fbd33d2151677 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Scripts.meta b/Packages/cn.xt.heroinvoke/Scripts.meta new file mode 100644 index 0000000..863108a --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: be319ff0f4987d147820368c5e38f6ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model.meta b/Packages/cn.xt.heroinvoke/Scripts/Model.meta new file mode 100644 index 0000000..a4e251d --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9ad41bf51f98a5244a4271f2b732a89f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model/Share.meta b/Packages/cn.xt.heroinvoke/Scripts/Model/Share.meta new file mode 100644 index 0000000..24a46cb --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model/Share.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e016bf6cd80e8cc4e971cf12d849e357 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref new file mode 100644 index 0000000..919e97a --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref @@ -0,0 +1 @@ +{ "reference": "ET.Model" } \ No newline at end of file diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref.meta b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref.meta new file mode 100644 index 0000000..3de3361 --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/AssemblyReference.asmref.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 383df794798416545b1602d6e9169021 +AssemblyDefinitionReferenceImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs new file mode 100644 index 0000000..74c41cd --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs @@ -0,0 +1,22 @@ +namespace ET +{ + public static class HeroInvokeContract + { + public const string AddHero = "HeroInvokeContract.AddHero"; + public const string HeroLevelUp = "HeroInvokeContract.HeroLevelUp"; + public const string ShowHeroSnapshot = "HeroInvokeContract.ShowHeroSnapshot"; + } + + public struct HeroInvokeAddHero + { + public long PlayerId; + public int HeroConfigId; + } + + public struct HeroInvokeLevelUp + { + public long PlayerId; + public long HeroId; + public int AddLevel; + } +} diff --git a/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs.meta b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs.meta new file mode 100644 index 0000000..53d300c --- /dev/null +++ b/Packages/cn.xt.heroinvoke/Scripts/Model/Share/HeroInvokeContract.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c9903d11eaeb8904baf329fce5c32a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.xt.heroinvoke/package.json b/Packages/cn.xt.heroinvoke/package.json new file mode 100644 index 0000000..ead2dcb --- /dev/null +++ b/Packages/cn.xt.heroinvoke/package.json @@ -0,0 +1,22 @@ +{ + "name": "cn.xt.heroinvoke", + "displayName": "ET.Hero.Invoke", + "version": "0.0.1", + "unity": "2022.3", + "description": "Cross-package invoke contracts for hero module.", + "author": { + "name": "ET-Packages" + }, + "repository": { + "type": "git", + "url": "https://github.com/ET-Packages/cn.etetet.heroinvoke" + }, + "relatedPackages": {}, + "dependencies": { + "cn.etetet.core": "3.0.3", + "cn.etetet.yiuiinvoke": "4.0.3" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com/@ET-Packages" + } +} diff --git a/Packages/cn.xt.heroinvoke/package.json.meta b/Packages/cn.xt.heroinvoke/package.json.meta new file mode 100644 index 0000000..e29bdb6 --- /dev/null +++ b/Packages/cn.xt.heroinvoke/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bc15b3dc05684af4784ec14c4def3730 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8b02ba --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# XtGameKit + +基于 **ET 9.0 + YIUI** 框架的 SLG/养成业务包集合,包含:英雄系统、酒馆(抽卡)、通用背包、配置加载、数据持久化、跨模块 Invoke 契约。 +本仓库**只放自研业务包与设计文档**,**不放 ET 框架本体**(用户拉到本地后请自行从 ET 框架仓库拉取 `cn.etetet.*`)。 + +--- + +## 一、仓库目录 + +``` +XtGameKit/ +├── Packages/ # 8 个自研 cn.xt.* 包 +│ ├── cn.xt.hero/ # 英雄系统(召唤/升级/培养) +│ ├── cn.xt.heroinvoke/ # 英雄系统跨包 Invoke 契约 +│ ├── cn.xt.bag/ # 通用背包(万物皆 Item) +│ ├── cn.xt.baginvoke/ # 背包跨包 Invoke 契约 +│ ├── cn.xt.gacha/ # 酒馆(抽卡) = Tavern +│ ├── cn.xt.gachainvoke/ # 酒馆跨包 Invoke 契约 +│ ├── cn.xt.config/ # 配置加载占位(Luban 接入点) +│ └── cn.xt.db/ # 数据持久化(Mongo/Memory 双实现 + 脏写) +│ +├── Doc/ # 设计与移植规格文档 +│ ├── Hero-System-Guide.md # 英雄系统使用指南 +│ ├── Bag-System-Guide.md # 背包系统使用指南 +│ ├── GM-Commands.md # GM 指令一览(含 YIUI GM 面板) +│ ├── Item-Design-Rule.md # Universal Item 设计准则 +│ ├── Logging-Guide.md # 三层日志(Business / Audit / Error) +│ ├── Luban-Guide.md # Luban 配置接入流程 +│ ├── ROK-Hero-Spec.md # ROK Hero 1:1 业务规则规格 +│ ├── ROK-Bag-Spec.md # ROK Bag 业务规则规格 +│ ├── ROK-Equip-Spec.md # ROK 装备业务规则规格 +│ ├── ROK-HeroAcquire-Spec.md # ROK 英雄获取业务规则规格 +│ ├── ROK-Spec-Index.md # ROK 规格总入口 +│ ├── ROK-Config-Source.md # ROK 配置来源审计 +│ ├── ROK-Luban-Field-Mapping.md # ROK→Luban 字段映射 +│ ├── ET-Packages-Audit.md # ET 现有包审计(避免造轮子) +│ └── Server-Setup.md # 服务端部署 +│ +├── openspec/ +│ └── changes/ +│ └── port-rok-hero-bag-system/ # OpenSpec 提案 / 设计 / specs / tasks +│ +└── .cursor/ + └── skills/ + └── et-framework-dev/ # ET 框架开发指南(分析器规则 / 程序集分层 / Proto 工作流) +``` + +--- + +## 二、模块概览 + +| 包 | 作用 | 关键入口 | +|---|---|---| +| `cn.xt.hero` | 英雄获取/升级/属性 | `HeroComponent`, GM `Hero-召唤` | +| `cn.xt.bag` | 通用 Item 仓库(万物皆 Item) | `BagComponent.AddItem / RemoveItem` | +| `cn.xt.gacha` | 抽卡 = 酒馆 | `GachaComponent`, `C2G_GachaDraw` | +| `cn.xt.config` | Luban 配置占位 + 加载 fallback | `cfg.Tables` | +| `cn.xt.db` | 数据持久化(玩家加载/脏写/Flush) | `IDBComponent`, `MongoDBComponent`, `MemoryDBComponent`, `DBSaveComponent` | +| `cn.xt.*invoke` | 跨包调用契约 | Pure interface/types,无业务逻辑 | + +### 设计原则 + +1. **业务包不依赖框架包以外的东西**:自家包之间只允许 `cn.xt.* → cn.xt.*invoke`,禁止业务直接 ref 业务实现。 +2. **Mongo 服务端 + Memory 客户端**:`MongoDBComponent` 用 `#if DOTNET || UNITY_EDITOR || UNITY_STANDALONE_*` 包住,客户端打包自动落到 `MemoryDBComponent`。 +3. **持久化走脏写**:`DBSaveComponent.MarkDirty(player)` 标脏,固定节奏 Flush,玩家下线/进程退出时也 Flush。 +4. **GM 双轨**:服务器 `cn.etetet.console` + 客户端 `cn.etetet.yiuigm`,所有 GM 命令都两侧可用。 + +--- + +## 三、依赖(**框架包不在本仓库**) + +8 个 `cn.xt.*` 包在 `package.json` 里都依赖了 ET 框架包;你需要在自己工程的 `Packages/` 同级目录下,从 ET 官方仓库拉到这些包: + +| `cn.xt.*` 需要的依赖 | 出处 | +|---|---| +| `cn.etetet.core` (>= 3.0.3) | ET 官方 | +| `cn.etetet.login` (>= 3.0.0) | ET 官方 | +| `cn.etetet.console` (>= 3.0.0) | ET 官方 | +| `cn.etetet.logging` (>= 0.0.1) | ET 官方 | +| `cn.etetet.yiuigm` (>= 3.0.4) | ET 官方(仅 `cn.xt.hero/bag/gacha` 需要) | + +> 拉取方式:建议把 ET 官方仓库整体 clone 到 `Packages/` 同级(典型路径形如 `Packages/cn.etetet.*/`),或在 `manifest.json` 里以 `file:` / git 形式引用。 + +### 包间依赖(自家 → 自家) + +``` +cn.xt.hero ──> cn.xt.bag, cn.xt.config +cn.xt.gacha ──> cn.xt.bag +其余 cn.xt.* 包 之间无依赖(含 *invoke 几个零依赖契约包) +``` + +--- + +## 四、快速集成 + +1. 把整个 `Packages/` 目录的内容拷进新工程的 `Packages/`(保持目录名) +2. 从 ET 官方拉取 `cn.etetet.*` 框架包,放在同一 `Packages/` 下 +3. 在 `Packages/manifest.json` 里把这些包都登记进去(或用 `file:` 引用本地路径) +4. Unity 打开工程 → 等 UPM 解析 → 编译 +5. 跑工程:登录后用 `Hero-召唤` GM 命令验证 + +--- + +## 五、文档怎么读 + +- **新人入门** → `Doc/Hero-System-Guide.md` + `Doc/Bag-System-Guide.md` +- **改业务** → `Doc/ROK-Spec-Index.md` 进 `ROK-*-Spec.md` +- **接 Luban** → `Doc/Luban-Guide.md` + `Doc/ROK-Luban-Field-Mapping.md` +- **改 GM** → `Doc/GM-Commands.md` +- **改持久化 / 日志** → `Doc/Logging-Guide.md` +- **看决策史** → `openspec/changes/port-rok-hero-bag-system/proposal.md` 与 `design.md` +- **避免 ET 踩坑** → `.cursor/skills/et-framework-dev/SKILL.md` + +--- + +## 六、未推送内容(明确说明) + +- ET 框架本体 `cn.etetet.*`(请到 ET 官方 git 拉) +- 服务器密钥/密码(`server_connect.md` 已**显式排除**) +- 包名迁移工具 `tools/package-namespace-migrator.py`(属本机一次性脚本) +- 项目工程(Unity 主工程 `Assets/`、`Library/` 等) diff --git a/openspec/changes/port-rok-hero-bag-system/.openspec.yaml b/openspec/changes/port-rok-hero-bag-system/.openspec.yaml new file mode 100644 index 0000000..e7c42ac --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-27 diff --git a/openspec/changes/port-rok-hero-bag-system/design.md b/openspec/changes/port-rok-hero-bag-system/design.md new file mode 100644 index 0000000..90b2049 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/design.md @@ -0,0 +1,740 @@ +# 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 循环列表 + +**约束**: +1. ROK 源码用了 PureMVC + sproto + ILRuntime/IFix,**完全不能直接复制** +2. 协议层必须用 ET protobuf 重新定义 +3. UI 必须用 YIUI 重写 +4. 服务端必须用 ET Entity/Component/System 重写 +5. ROK Server 是 C++/Lua(9 个微服务),与 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 Server(C++/Lua),ET 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()` 下作为子 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` → 简单,但失去 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 rewards); + public static bool RemoveItems(this BagComponent self, List 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 _quickCache` 缓存常用 itemId 的数量。 + +### D5: 配置表方案 - Luban(替代 ExcelExporter) + +**选择**:**引入 Luban**(新业务表全部用 Luban;OctoberStudio 老配置保持不变) + +**为什么改变方向**: +- 本次新增的 14+ 张表大量使用 `List`、`Map>`、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().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/Query/Delete` 业务封装、连接池、慢查询、重试 + +**结论**:**新建业务包 `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**): + +```csharp +// 打开 +await scene.YIUIRoot().OpenPanelAsync(); +// 带参打开(最多 5 个参数) +await scene.YIUIRoot().OpenPanelParamAsync(heroId); +// 模态等待结果 +var result = await scene.YIUIRoot().OpenPanelWaitAsync("确认抽卡?"); +``` + +**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(scene, msg)` | 自带 `HashWait` 模态返回 | +| 操作成功飘字 | `cn.etetet.yiuitips` → `TipsHelper.OpenSync(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 +{ + protected override async ETTask Run(Session session, Hero_HeroInfo msg) + { + var heroComp = session.GetComponent().Player.GetComponent(); + heroComp.UpdateHero(msg.Hero); + // 跨面板广播(多个 Panel 订阅) + EventSystem.Instance.Publish(new HeroUpdatedEvent { HeroId = msg.Hero.HeroId }); + } +} + +// Panel 监听 +[Event] public class HeroUpdatedEvent_RefreshList : AEvent +{ + protected override async ETTask Run(Scene scene, HeroUpdatedEvent args) + { + var panel = scene.YIUIMgr().GetPanel(); + 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 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(); + 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` 基类,在 OnRun 前后自动写 L1(入口/出口 + 耗时) +- **慢操作**:包装 `DBComponent` / `MessageDispatcher` 的入口方法,超阈值自动 Log.Slow +- **reqId 透传**:在所有 C2G/C2R 协议基类加 `reqId` 字段,Handler 基类把 reqId 放到 `AsyncLocal`,供下游 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 GetParams() { ... } public async ETTask Run(Scene scene, GMParamVo vo) { ... } }` + - 仅 Debug 包启用(`#if ENABLE_GM`) + - 适合开发期手动测试 + +**GM 命令分类**: + +| 类别 | 命令示例 | 用途 | +|---|---|---| +| **资源发放** | `gm AddItem ` / `AddCurrency ` | 给自己/指定玩家发资源 | +| **英雄操作** | `AddHero ` / `SetHeroLevel ` / `SetHeroStar ` | 创建/调整英雄 | +| **背包操作** | `ClearBag` / `ShowBag` / `SetItemCount ` | 背包状态调整与查看 | +| **抽卡操作** | `Gacha ` / `ResetGachaGuarantee ` / `SetGachaCount ` | 抽卡 + 保底操作 | +| **数据查询** | `ShowHeroes` / `ShowBag` / `ShowGachaStats` / `DumpPlayer ` | 输出当前状态 jsonl | +| **数据快照** | `Snapshot ` / `Restore ` | 保存/恢复整个玩家状态 | +| **批量模拟** | `SimulateGacha ` 服务端循环 N 次抽卡输出概率统计 | 概率审计 | +| **数据校验** | `VerifyConsistency ` 校验玩家数据完整性 | 发现脏数据 | +| **服务管理** | `ReloadConfig` / `FlushDirty` / `Kick ` | 运维操作 | + +**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 Demo(Init.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 统一"的核心约定_ diff --git a/openspec/changes/port-rok-hero-bag-system/proposal.md b/openspec/changes/port-rok-hero-bag-system/proposal.md new file mode 100644 index 0000000..9338be2 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/proposal.md @@ -0,0 +1,211 @@ +# Port ROK Hero & Bag System + +## Why + +工程 (`Survivors`) 当前是基于 OctoberStudio 模板的单机肉鸽,**没有任何卡牌养成或道具背包逻辑**;只有金币 (`CurrencySave`)、角色解锁 (`CharactersSave`) 和宝箱抽能力这种轻量级 `ISave` 持久化。我们要把 ROK (`E:\Game\gmd\ROK`) 这套完整 SLG 卡牌养成(英雄招募/升级/升星/技能/觉醒/天赋/装备)+ 道具背包 + 装备锻造系统移植过来。 + +但是两个项目的技术栈完全不兼容: + +| 维度 | ROK | Survivors | +|---|---|---| +| 客户端框架 | PureMVC + ILRuntime/IFix | ET + YIUI + HybridCLR | +| 协议格式 | sproto | protobuf3 | +| 服务端 | C++ + Lua (9 微服务) | ET Server (单进程/Watcher 多进程) | +| 配置 | 自研 `*Define` + LitJson | **Luban**(新业务表)+ ET ExcelExporter(老配置兜底) | + +**直接复制源码连编译都不会通过**,依赖的 `PureMVC` / `SprotoType` / `Skyunion` / `Data` / `LitJson` 在新工程都不存在。因此本次变更的本质是:**借鉴 ROK 的业务设计(数据结构、Config 字段、UI 流程、服务端 RPC 列表),用 ET + YIUI 在 Survivors 工程里完整重写一遍**。 + +## What Changes + +### 交付策略 - **服务端先行 + 日志 + GM 验证 + UI 后置** + +- **P1-P3 阶段不做任何 YIUI UI**:所有业务通过 **GM 命令 + jsonl 日志 + 单元测试** 验证 +- **UI 集中在 P4 一次性补齐**:业务接口稳定后,UI 不会反复返工 +- **每个新 RPC Handler MUST 同时新增对应 GM 命令**(CI 强制校验) +- **三层日志体系**:L1 业务日志(30 天)+ L2 审计日志(jsonl,永久)+ L3 错误日志(90 天) +- **关键操作必写审计**:抽卡、付费、GM 操作、资源大额变动、英雄招募/升星 +- **慢操作必告警**:DB 查询 >100ms、Handler 执行 >500ms + +### 通用 Item 设计原则(贯穿所有系统) + +- **所有游戏内"可获得资源"统一视为 Item**:金币 / 钻石 / 英雄碎片 / 装备 / 材料 / 经验书 / 升星石 / 觉醒符文 / 礼包 / 图标,**全部用一个 `ItemConfig` + 一个 itemId 管理** +- **统一奖励/消耗描述结构** `RewardEntry { itemId, count }`:抽卡奖励、关卡奖励、商店售卖、邮件附件、礼包道具、英雄升级消耗、升星消耗、技能升级消耗——全部用同一个数据结构 +- **统一变更入口** `BagComponentSystem.AddItems(rewards)` / `RemoveItems(costs)`:服务端任何业务发奖/扣资源都走这两个接口 +- **客户端统一道具 UI** `BagItemUICommon.prefab`:列表/抽卡结果/奖励飘字/邮件附件/商店货品全部复用同一个图标组件,传 `(itemId, count)` 即可渲染 + +### 客户端 (ET Client + YIUI) + +- 新增 **抽卡系统** Hotfix 业务包 `cn.etetet.gacha` + - `GachaComponent` 挂在玩家根 Entity 上,记录每卡池抽数、保底计数、十连历史 + - 业务逻辑:单抽、十连、卡池切换、保底(每 X 抽必出某品质) + - 抽卡是**端到端验证最佳工具**:消耗道具 → 服务端随机 → 发放奖励到背包 → 招募英雄 → 培养——一条完整生产链 +- 新增 **英雄系统** Hotfix 业务包 `cn.etetet.hero`(或 `Assets/Scripts/Hero/`) + - `HeroComponent` 挂在玩家根 Entity 上,缓存玩家所有 `Hero` 子 Entity + - 每个 `Hero` Entity 含等级/经验/星级/天赋页/技能等级数组/装备引用 + - 业务逻辑:招募、加经验、升星、技能升级、觉醒、天赋学习/重置、穿/卸装备 + - 战力计算:基础分 + 等级分 + 技能分 + 天赋分 + 装备分 +- 新增 **背包系统** Hotfix 业务包 `cn.etetet.bag`(或 `Assets/Scripts/Bag/`) + - `BagComponent` 挂在玩家根 Entity 上 + - 6 大类道具分类缓存(Resource/Speedup/Boost/Equipment/Other/Icon) + - 业务逻辑:增/减/使用/分解/合成/锻造图纸 +- 新增 **英雄 UI**(基于 YIUI 自动化工具生成) + - `HeroListPanel` - 列表(按 SortType: Rare/Star/Level/Power)+ 三段分组(已拥有/可召唤/未集齐) + - `HeroDetailPanel` - 详情 + 升级/升星/技能/觉醒/天赋/装备入口 + - `HeroSummonPanel` - 召唤展示 + - 复用刚做好的 `CommonHeader` 顶栏 + `CommonClose` 关闭按钮 +- 新增 **背包 UI** + - `BagPanel` - 分页背包(资源/装备/道具/材料),使用 `cn.etetet.yiuiloopscrollrectasync` 循环列表 + - `BagItemTooltip` - 道具详情浮层 + - `EquipForgePanel` - 装备锻造/合成/分解面板 + - `BagItemUICommon` - **通用道具显示组件**,所有 UI 凡显示道具图标+数量都复用它 + +- 新增 **抽卡 UI** + - `GachaPanel` - 抽卡主界面(卡池列表、单抽/十连按钮、保底进度、消耗显示) + - `GachaResultPanel` - 抽卡结果展示(含十连奖励列表、新英雄特殊动画) + - `GachaPoolUICommon` - 单个卡池显示组件 + +### 服务端 (ET Server) + +- 新增 **服务端持久化基础设施 `cn.etetet.db`**(**包装现有 MongoHelper + MongoDB.Driver**,不重写) + - 现状:`cn.etetet.core/MongoHelper` 已有 BSON 序列化,`com.etetet.init/Plugins/MongoDB` 已有原生 DLL,**缺业务层封装** + - 新包提供:`IDBComponent.Save/Query/Delete` + 连接池 + 慢查询 + Memory/Mongo 双模式 + - 扩展 `cn.etetet.login/C2G_LoginGateHandler`:登录时从 DB 加载 `HeroComponent` + `BagComponent` + `GachaComponent` 到内存(**只加 5-10 行 DB 调用,不重写 Login**) + - 关键操作后异步落库(脏标记 + 定时刷盘) +- 新增 **服务端英雄/背包/抽卡业务**(仿 `HeroLogic.lua` / `ItemLogic.lua` 重写) + - `HeroComponentSystem` - 服务端真权威,校验所有英雄养成操作 + - `BagComponentSystem` - 服务端真权威,统一发奖/扣资源入口 + - `GachaComponentSystem` - 服务端真权威,加权随机 + 保底机制 + 防爆 0 + - 协议 Handler:`C2G_SummonHero` / `C2G_HeroLevelUp` / `C2G_HeroStarUp` / `C2G_HeroSkillUp` / `C2G_HeroWearEquip` / `C2G_TalentUp` / `C2G_GachaDraw` / 等约 15 个 RPC + Item 同步推送 + +### 协议层 + +- 新增 **协议定义** `Packages/<新包>/Proto/HeroOuter_C_xxxx.proto` + - 数据结构:`HeroInfo`、`ItemInfo`、`SkillInfo`、`TalentTree`、`EquipInfo` + - C2S 请求(约 15 个):`C2G_SummonHero`、`C2G_HeroAddExp`、`C2G_HeroStarUp`、`C2G_HeroSkillUp`、`C2G_HeroAwake`、`C2G_TalentUp`、`C2G_ChangeTalentIndex`、`C2G_ResetTalent`、`C2G_HeroWearEquip`、`C2G_TakeOffEquip`、`C2G_ExchangeHeroItem`、`C2G_ModifyTalentName`、`C2G_GachaDraw`、`C2G_UseItem`、`C2G_MakeEquipment` + - 服务端推送:`Hero_HeroInfo`(同步单个英雄)、`Item_ItemInfo`(同步道具变化)、`Gacha_GachaInfo`(同步抽卡进度/保底) + +### 配置表 - **改用 Luban + ROK 数据自动迁移** + +- 引入 **Luban 工具链**(替代 ET ExcelExporter 用于本次新增的所有业务表) + - 输入:`Config/Datas/*.xlsx` 数据 + `Config/Defines/*.bean.xml` schema + - 输出:`Bundles/Config/*.json`(运行时)+ `cn.etetet.config/Scripts/Model/Share/cfg/*.cs`(生成代码) + - **不写自定义模板**,用 Luban 原生 API + `ConfigComponent` 包装层接入 ET + - **老配置(OctoberStudio + ET ExcelExporter 现有测试表)保持不动** + +- **ROK 配置数据来源(不需要手输)** + - ROK 工程**无 Excel 源文件**:服务端只有 `Configs.data`(45 万行 Lua 表,`tabtoy` 生成),客户端只有加密 SQLite + 205 个 `*Define.cs` DTO + - **派生 Luban Schema**:扫描 ROK Client `*Define.cs` 字段 → 自动生成 `*.bean.xml` + - **派生 Luban 数据**:扩展姐妹工程已有的 `gmd/Tools/export_hero_json_from_configs_data.py`(已验证可解析 Hero/HeroLevel/HeroStar 等表),导出 JSON → 转 Luban `*.xlsx` + - 全程**零手工录入** + +- 新增 **英雄/物品/抽卡 Luban 配置**(基于 ROK `HeroDefine` 11 张表字段定义,重新设计为 Luban bean schema) + - `HeroConfig` - 英雄基础(ID/稀有度/初始星/技能数组/天赋数组/碎片ID/招募上限) + - `HeroLevelConfig` - 等级升级(rareGroup + lv → exp/soldiers/score) + - `HeroStarConfig` - 升星阶段(每星攻防血加成) + - `HeroStarExpConfig` - 升星材料(item + 经验值 + 幸运系数) + - `HeroSkillConfig` - 技能(5 个技能槽) + - `HeroSkillEffectConfig` - 技能等级 → 属性数值 + - `HeroSkillLevelConfig` - 技能升级消耗 + - `HeroTalentConfig` - 天赋节点 + - `HeroTalentGainTreeConfig` - 天赋树 + - `HeroTalentMasteryConfig` - 天赋专精 + - `ItemConfig` - **全道具基础**(含金币/钻石/碎片/装备/材料,全部用 itemId 索引) + - `EquipConfig` - 装备属性(8 个部位 + 4 个 EquipItemType) + - `GachaPoolConfig` - 卡池基础(id / 名称 / 单抽消耗 / 十连消耗 / 保底次数 / 保底品质) + - `GachaWeightConfig` - 卡池权重表(poolId + entryIndex → itemId + count + weight + rare) + +### Modified(不破坏现有功能) + +- **BREAKING**: 工程主入口必须改成走 `Init.unity` 场景启动 ET,再加载 OctoberStudio 主菜单 + - 当前 OctoberStudio 主菜单作为"单机模式"保留,但默认走联网模式 +- `cn.etetet.proto` 包内新增协议文件(新增不修改既有 Login/StateSync proto) +- ET Server 启动配置 `StartConfig` 不变,新增 Hero/Bag 业务由 Gate Scene 加载 + +### 不做 + +- **不做** ROK 服务器 (C++/Lua) 的对接(协议不兼容、架构不兼容) +- **不做** ROK 完整 UI 美术素材搬运,UI 用 YIUI 重新搭,先功能可用再美化 +- **不做** "雕像兑换" / "天赋页改名" 这种次要功能(一期不做,二期再说) +- **不做** 与 OctoberStudio 局内战斗的深度耦合,先把英雄/背包做出来,局内带哪个英雄出战留待后续接入 +- **不做** 抽卡的真实付费接入(一期只做"扣道具/钻石抽卡"逻辑,支付接入后续阶段) +- **不做** 抽卡概率官方公示页面(一期内部测试用,上线前补) + +## Capabilities + +### New Capabilities + +- `hero-system`: 英雄养成系统 - 英雄实体定义、招募、升级、升星、技能升级、觉醒、天赋树、属性/战力计算、装备穿戴逻辑 +- `bag-system`: 背包道具系统 - **通用 Item 数据模型(所有资源统一)**、6 大类分类管理、增减/使用/堆叠规则、新道具标记、红点;**统一奖励/消耗结构 RewardEntry** +- `equipment-system`: 装备系统 - 8 部位装备穿脱、装备锻造/合成/分解、图纸制作、装备品质排序 +- `gacha-system`: 抽卡系统 - 卡池配置、加权随机算法、单抽/十连、保底机制、抽卡历史、与背包/英雄系统的发奖联动 +- `client-server-protocol`: Hero/Bag/Gacha 客户端-服务端协议 - 15+ 个业务 RPC + 数据推送 +- `server-persistence`: MongoDB 玩家数据持久化 - DBComponent 封装、登录加载、脏标记落库 +- `observability-logging`: **三层日志体系** - 业务日志/审计日志(jsonl)/错误日志、reqId 全链路追踪、慢操作告警、客户端日志桥接 +- `gm-tools`: **GM 命令验证** - 双轨入口(服务端 Console + 客户端 Panel)、覆盖所有 RPC、概率模拟、数据快照、一致性校验、Release 包剥离 + +### Modified Capabilities + +(无 - 这是首次添加业务能力,没有现存 spec 被修改) + +## Impact + +### 新增代码(估算) + +| 模块 | 估算规模 | +|---|---| +| 客户端 Hero 业务(Model + Hotfix + YIUI Panel × 3) | ~2000 行 | +| 客户端 Bag/装备业务(Model + Hotfix + YIUI Panel × 3) | ~1800 行 | +| 客户端 Gacha 业务(Model + Hotfix) | ~500 行 | +| 服务端 Hero/Bag/Gacha 业务 + DB 集成 | ~3000 行 | +| **`cn.etetet.db` 业务封装(基于 MongoHelper)** | ~400 行 | +| **`cn.etetet.logging` 扩展(Audit/Slow/Module + reqId 透传)** | ~300 行 | +| **GM 业务命令实现(基于 yiuigm + console 现成框架)** | ~800 行 | +| **ROK 配置迁移脚本(Configs.data → Luban xlsx)** | ~300 行 Python | +| 协议 .proto + 生成 C# | ~400 行 proto + ~2000 行 gen | +| Luban schema + 配置 + 生成 C# | 14 张 bean.xml + ~1000 行 gen | +| **P4 集中 UI(YIUI Panel × 8 + UICommon × 4)** | ~3500 行 | +| **合计** | **~12500 行手写 + ~300 行 Python + ~3000 行生成** | + +### 依赖项 + +**复用现有 ET 包**(详见 `Doc/ET-Packages-Audit.md`): + +- 现有 `cn.etetet.login`:登录链路(C2R_Login / C2G_LoginGate)必须先跑通,**仅扩展 Player 不重写** +- 现有 `cn.etetet.proto`:用 `ET/Proto/Proto2CS` 工具生成 C# +- 现有 `cn.etetet.core/MongoHelper` + `com.etetet.init/Plugins/MongoDB`:**BSON + Driver 现成**,不重写 +- 现有 `cn.etetet.core/Log`:日志入口现成,**仅加扩展方法不重写** +- 现有 `cn.etetet.yiuigm`:客户端 GM 框架现成,**仅加 `[GM]` 业务类** +- 现有 `cn.etetet.console`:服务端 Console 框架现成,**仅加 `[ConsoleHandler]` 业务类** +- 现有 `cn.etetet.yiui*` 系列:用 `ET/YIUI 自动化工具` 生成 UI Component/System 代码 +- 现有 `cn.etetet.hybridclr`:Hero/Bag 业务代码全在热更层 (`ET.Hotfix`) +- 现有 `cn.etetet.yiuiloopscrollrectasync`:背包列表用循环 ScrollRect +- 现有 `cn.etetet.excel`:**仅保留给 StartConfig 等基础设施**,新业务表不用 + +**新增 ET 包**: + +- `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`:业务包 + +### 阻塞前置项(必须先完成) + +1. **客户端热更 DLL 编译** - 执行 `ET/Loader/Compile (F6)`,生成 `Packages/cn.etetet.loader/Bundles/Code/ET.*.dll.bytes` +2. **服务端 dotnet 编译** - `dotnet build ET.sln -c Debug`,生成 `Bin/ET.App.dll` +3. **跑通 Login Demo** - 确认 `C2R_Login` → `R2C_Login` → `C2G_LoginGate` 链路正常 +4. **DB 业务封装** - 新建 `cn.etetet.db` 包装 `MongoHelper + MongoDB.Driver`(不重写 BSON) +5. **日志扩展** - 新建 `cn.etetet.logging` 加 `Log.Audit/Log.Slow/Log.Module` 扩展方法(不重写 Log) +6. **ROK 配置迁移工具链** - 派生 Schema + 改写姐妹工程 Python 脚本,跑通 Hero/Item 两张表 + +### 受影响系统 / 改造点 + +- **场景加载流程**:`Init.unity` → Realm 登录 → Gate 连接 → 主菜单(融合 OctoberStudio) +- **OctoberStudio `SaveManager`**:与 ET 服务端存档**并存**,本地存档继续用于"局内进度/设置",服务端存档用于"英雄/背包/账号资产" +- **现有 `CommonHeader` / `CommonClose`**:直接复用作为新建 UI 面板的顶栏 +- **HybridCLR AOT 元数据**:新增的服务端调用泛型可能需要补 `link.xml` / AOT generic 扫描 + +### 风险 + +- ROK 业务量大,一次性做完工期长(评估 3-4 周);建议在 tasks.md 中分阶段交付(先英雄基础养成,再装备,再天赋) +- DB 落库性能/一致性:脏标记 + 定时刷盘需要谨慎,避免落库竞争 +- 协议变更后双端版本对齐:建议引入 ET 已有的协议版本号机制 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/bag-system/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/bag-system/spec.md new file mode 100644 index 0000000..0b9083f --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/bag-system/spec.md @@ -0,0 +1,188 @@ +## ADDED Requirements + +### Requirement: 通用 Item 数据模型(全游戏统一) + +系统 MUST 用统一的 `ItemConfig` 表示**所有**游戏内可获得的资源——包括但不限于金币、钻石、英雄碎片、装备、装备材料、装备图纸、经验书、升星石、觉醒符文、礼包、邮件附件、活动奖励。`ItemConfig` MUST 至少包含字段:id(itemId)、name、icon、itemType(BagItemType 大类)、subType(细分类,如 EquipItemType 或货币子类)、maxStack(堆叠上限,0 表示不限)、useType(使用行为,如 HeroExp/HeroStarExp/Gift/None)、quality(品质 1-5)、desc、sortIndex。 + +#### Scenario: 金币也是 Item +- **WHEN** 玩家获得 100 金币 +- **THEN** 服务端调用 `BagComponentSystem.AddItem(player, itemId=1 /* 金币的固定 itemId */, 100)` +- **AND** 前端 `BagComponent` 中存在 itemId=1 的 ItemInfo +- **AND** UI 通过 `BagItemUICommon.Show(itemId=1, count=100)` 渲染图标和数量 + +#### Scenario: 钻石使用同一套接口 +- **WHEN** 玩家抽卡消耗 10 钻石 +- **THEN** 服务端调用 `BagComponentSystem.RemoveItem(player, itemId=2 /* 钻石的固定 itemId */, 10)` +- **AND** 不需要任何"特殊货币处理逻辑" + +#### Scenario: 不允许新增非 Item 资源 +- **WHEN** 业务需要引入新资源类型(如新货币) +- **THEN** MUST 通过新增 ItemConfig 条目实现,不允许在 Player Entity 上添加新字段 + +### Requirement: 通用奖励/消耗结构 + +系统 MUST 提供统一的奖励/消耗描述结构 `RewardEntry { itemId, count }` 和 `CostEntry { itemId, count }`(结构相同,命名上分语义)。所有"列表型奖励/消耗"业务 MUST 使用 `repeated RewardEntry` 或 `repeated CostEntry`,包括但不限于:抽卡奖励列表、升级消耗、升星消耗、技能升级消耗、礼包道具列表、邮件附件、关卡奖励、商店售卖、活动签到奖励。 + +#### Scenario: 升级消耗用 CostEntry +- **WHEN** `HeroSkillLevelConfig` 配置技能升级消耗 +- **THEN** 字段类型 MUST 是 `repeated CostEntry`(例:`[{ itemId: 2001, count: 5 }, { itemId: 1, count: 100 }]`) +- **AND** 服务端通过 `BagComponentSystem.RemoveItems(player, costs)` 批量扣除 +- **AND** 任一项不足则全部失败,不扣任何道具 + +#### Scenario: 抽卡奖励用 RewardEntry +- **WHEN** 抽卡响应包含奖励列表 +- **THEN** 字段类型 MUST 是 `repeated RewardEntry` +- **AND** 服务端通过 `BagComponentSystem.AddItems(player, rewards)` 批量发放 + +### Requirement: 通用道具 UI 组件 + +客户端 MUST 提供 `BagItemUICommon` YIUI UICommon,输入 `(itemId, count, optional: showName, showCount, sizePreset)`,自动渲染图标、品质边框、数量、新道具标记。该组件 MUST 被所有道具相关 UI 复用:背包列表、抽卡结果、奖励飘字、邮件附件、商店货品、关卡结算、签到奖励。 + +#### Scenario: 抽卡结果显示 +- **WHEN** GachaResultPanel 渲染 10 个奖励 +- **THEN** 为每个奖励实例化一个 BagItemUICommon +- **AND** 调用 `Show(reward.itemId, reward.count)` + +#### Scenario: 邮件附件显示 +- **WHEN** 邮件面板渲染附件列表 +- **THEN** 复用相同的 BagItemUICommon +- **AND** 不需要重写道具图标渲染逻辑 + +### Requirement: Bag 数据模型 + +系统 MUST 为每个玩家提供一个 `BagComponent` 挂在玩家根 Entity 下。`BagComponent` MUST 维护一个 `ItemInfo` 列表,每个 `ItemInfo` 持有:itemIndex(全局唯一索引)、itemId(对应 `ItemConfig.id`)、count(数量/堆叠)、itemType(大类)、heroId(仅装备类有效,归属英雄)。 + +#### Scenario: 注入背包数据 +- **WHEN** 玩家登录后服务端推送 `Item_ItemInfoList { items }` +- **THEN** 客户端 `BagComponent` 清空旧 items 列表 +- **AND** 为每个 ItemInfo 创建客户端缓存 +- **AND** 按 `ItemType` 分类放入对应子缓存(resourceItems/speedupItems/...) + +#### Scenario: 增量更新单个道具 +- **WHEN** 服务端推送 `Item_ItemInfo { item }` +- **THEN** 客户端按 itemIndex 找到对应缓存 +- **AND** 若 count > 0 更新;若 count == 0 移除 +- **AND** 触发 `OnItemChanged(itemId)` 事件 + +### Requirement: 道具大类与堆叠规则 + +系统 MUST 提供 6 大类道具类型(`BagItemType`):Resource(1) / Speedup(2) / Boost(3) / Equipment(4) / Other(5) / Icon(6)。 +- 非 Equipment 类道具 MUST 按 itemId 堆叠(同 id 累加 count) +- Equipment 类道具 MUST 每个独立 itemIndex(不堆叠),即使同 itemId + +#### Scenario: Resource 类堆叠 +- **WHEN** 玩家已有 itemId=1001 数量 50 +- **AND** 服务端发放 100 个 itemId=1001 +- **THEN** 服务端 MUST 更新该道具 count 为 150(不创建新条目) + +#### Scenario: Equipment 类不堆叠 +- **WHEN** 玩家拥有 1 件 itemId=8001 装备 +- **AND** 服务端发放另 1 件相同 itemId 装备 +- **THEN** 服务端 MUST 创建新 itemIndex 条目,count=1 +- **AND** 两个装备实例独立存在 + +### Requirement: 道具数量查询 + +客户端 MUST 提供以下查询接口: +- `BagComponentExtensions.GetItemNum(itemId)` 返回某 itemId 的总数(非装备类) +- `BagComponentExtensions.GetItemIndex(itemId)` 返回单条目的 itemIndex(非装备类) +- `BagComponentExtensions.GetItemInfoListByType(BagItemType)` 返回某类下所有 ItemInfo +- `BagComponentExtensions.HasItem(itemId, requiredCount)` 判断是否足够 + +#### Scenario: 查询道具数量 +- **WHEN** 玩家拥有 itemId=2001 数量 30 +- **AND** 调用 `GetItemNum(2001)` +- **THEN** 返回 30 + +#### Scenario: 查询不存在的道具 +- **WHEN** 玩家未拥有 itemId=9999 +- **AND** 调用 `GetItemNum(9999)` +- **THEN** 返回 0(不抛异常) + +### Requirement: 道具使用 + +玩家 MUST 能消耗指定数量道具触发其效果。可使用道具由 `ItemConfig.useType` 标识。服务端 MUST 是真权威,校验所有使用操作。 + +#### Scenario: 使用经验书 +- **WHEN** 玩家发送 `C2G_UseItem { itemId, count }` +- **AND** `ItemConfig.useType == HeroExp` +- **AND** 玩家拥有 >= count +- **THEN** 服务端转发到对应处理器(如 HeroAddExp) + +#### Scenario: 使用礼包道具 +- **WHEN** 玩家使用 useType=Gift 类道具 +- **THEN** 服务端按 `ItemConfig.giftRewards` 发放奖励 +- **AND** 扣除礼包道具 +- **AND** 推送多个 `Item_ItemInfo` 同步所有变化 + +#### Scenario: 使用数量不足 +- **WHEN** 玩家请求使用 5 个道具,但只有 3 个 +- **THEN** 服务端 MUST 拒绝并返回 `ItemNotEnough` + +### Requirement: 服务端道具增减接口 + +服务端 MUST 提供以下基础接口给业务调用(如 Hero/Equipment 模块),所有变更 MUST 推送 `Item_ItemInfo`: +- `BagComponentSystem.AddItem(itemId, count)` 增加道具 +- `BagComponentSystem.RemoveItem(itemId, count)` 减少道具,返回是否成功 +- `BagComponentSystem.RemoveItemByIndex(itemIndex)` 按索引删除(用于唯一装备) +- `BagComponentSystem.SetHeroIdOnEquip(itemIndex, heroId)` 装备绑定/解绑英雄 + +#### Scenario: 增加道具触发同步 +- **WHEN** 服务端业务调用 `AddItem(itemId=1001, 50)` +- **THEN** 修改 BagComponent +- **AND** 标记脏数据待落库 +- **AND** 推送 `Item_ItemInfo { itemId=1001, count=新count }` + +#### Scenario: 扣除道具失败 +- **WHEN** 业务调用 `RemoveItem(itemId, count)` 但数量不足 +- **THEN** 返回 false +- **AND** 不修改任何数据 +- **AND** 不推送消息 + +### Requirement: 新道具标记 + +客户端 MUST 维护"新到道具"红点标记,用 ISave 本地存储(避免与服务端通信开销)。当 BagComponent 接收到新 itemId 或 count 增加时,MUST 自动标记 `isNew`。当玩家在 UI 中浏览过该道具,MUST 标记为已读。 + +#### Scenario: 获得新道具标记 +- **WHEN** 服务端推送 `Item_ItemInfo` 增加 itemId=5001(之前为 0) +- **THEN** 客户端 MUST 在本地 ISave 中标记 `isNew[5001] = true` + +#### Scenario: 浏览后清除标记 +- **WHEN** 玩家在 BagPanel 中点击查看 itemId=5001 +- **THEN** 客户端 MUST 调用 `SetLocalItemToOld(5001)` +- **AND** 红点消失 + +### Requirement: 背包红点 + +系统 MUST 提供按类型聚合的红点接口: +- `GetBagReddotNumByType(BagItemType)` 返回某类新道具数量 +- `GetBagReddotTotal()` 返回所有类型新道具总数 +- `ClearAllReddotRecord()` 清空所有红点 + +#### Scenario: 分类红点 +- **WHEN** 玩家有 3 个新 Resource 道具 + 2 个新 Equipment 道具 +- **AND** 客户端调用 `GetBagReddotNumByType(BagItemType.Resource)` +- **THEN** 返回 3 + +### Requirement: UI 列表分页与循环加载 + +背包 UI MUST 使用 `cn.etetet.yiuiloopscrollrectasync` 实现循环列表,支持万级道具不卡顿。MUST 按类型分页(Resource/Speedup/Boost/Equipment/Other 各为一页),每页内按 `ItemConfig.sortIndex` 排序。 + +#### Scenario: 切换到装备页 +- **WHEN** 玩家点击 BagPanel 上"装备"分页按钮 +- **THEN** UI 加载 `BagComponent.equipItems` 数据源 +- **AND** 通过 `LoopScrollRect` 异步刷新可见格子 + +### Requirement: 道具详情 Tooltip + +点击道具 MUST 弹出 `BagItemTooltip` 浮层,显示:图标、名称、品质边框、描述、堆叠数量、使用按钮(若可用)、来源跳转入口。 + +#### Scenario: 显示 Tooltip +- **WHEN** 玩家点击背包格子 +- **THEN** 在格子位置/右侧弹出 BagItemTooltip +- **AND** 显示对应 ItemConfig 的全部信息 + +#### Scenario: 装备 Tooltip 显示属性 +- **WHEN** 点击的是 Equipment 类道具 +- **THEN** Tooltip 显示装备属性条目(攻击/防御/血量等) +- **AND** 显示穿戴/卸下按钮 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/client-server-protocol/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/client-server-protocol/spec.md new file mode 100644 index 0000000..1393cf5 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/client-server-protocol/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Protobuf 协议定义文件 + +所有客户端-服务端 Hero/Bag/Equipment 消息 MUST 在新的 proto 文件中定义,存放路径 `Packages/cn.etetet.<新业务包>/Proto/Outer_C_.proto`。MUST 遵循 ET 框架已有命名规则:消息名 `C2G_xxx` 表示客户端→Gate,`G2C_xxx` 表示 Gate→客户端,`xxx_yyy` 表示推送消息(无 RpcId)。 + +#### Scenario: 协议文件结构 +- **WHEN** 创建 `HeroOuter_C_3000.proto` 文件 +- **THEN** 文件头部 MUST 声明 `syntax = "proto3"` 和 `package ET` +- **AND** 注释每个消息的 `ResponseType` 用于 ET Proto2CS 工具识别 +- **AND** 该文件起始消息 ID = 3000,每个 message 顺序 +1 + +### Requirement: 数据传输对象 (DTO) + +协议层 MUST 定义以下核心 DTO 结构供 RPC 和推送使用: + +- `HeroInfo`: heroId (int64), configId (int32), level, exp, star, starExp, talentIndex, skills (repeated SkillInfo), talentTrees (repeated TalentTreeInfo), equipSlots (repeated EquipSlot) +- `SkillInfo`: skillId (int32), skillLevel (int32) +- `TalentTreeInfo`: pageIndex (int32), name (string), nodes (repeated TalentNodeInfo) +- `TalentNodeInfo`: nodeId (int32), level (int32) +- `EquipSlot`: subType (int32), itemIndex (int64, 0 表示空) +- `ItemInfo`: itemIndex (int64), itemId (int32), count (int64), itemType (int32), heroId (int64) + +#### Scenario: 序列化 HeroInfo +- **WHEN** 服务端构造 HeroInfo 并通过 `MessageHelper.SendToClient` 发送 +- **THEN** ET 框架自动使用 MemoryPack 序列化 +- **AND** 客户端反序列化得到等价 HeroInfo 实例 + +### Requirement: 客户端→服务端 RPC 消息 + +协议层 MUST 包含以下 12 个 Hero/Equipment 业务 RPC(每个有 `ResponseType` 声明): + +| RPC 名 | 说明 | +|---|---| +| C2G_SummonHero | 召唤英雄 | +| C2G_HeroAddExp | 加经验升级 | +| C2G_HeroStarUp | 升星 | +| C2G_HeroSkillUp | 技能升级 | +| C2G_HeroAwake | 觉醒 | +| C2G_TalentUp | 学天赋 | +| C2G_ChangeTalentIndex | 切换天赋页 | +| C2G_ResetTalent | 重置天赋页 | +| C2G_ModifyTalentName | 修改天赋页名 | +| C2G_HeroWearEquip | 穿装备 | +| C2G_TakeOffEquip | 卸装备 | +| C2G_ExchangeHeroItem | 雕像兑换碎片 | + +Bag/Equipment 业务额外 RPC: + +| RPC 名 | 说明 | +|---|---| +| C2G_UseItem | 使用道具 | +| C2G_MakeEquipment | 锻造装备 | +| C2G_ResolveEquipment | 分解装备 | +| C2G_MixMaterial | 合成材料 | + +#### Scenario: 每个 RPC 都有错误码字段 +- **WHEN** 定义任一 `G2C_xxx` Response +- **THEN** MUST 包含 `int32 Error` 和 `string Message` 字段 +- **AND** Error 0 = 成功,非 0 = 失败码 + +### Requirement: 服务端→客户端推送消息 + +协议层 MUST 包含以下推送消息(无 RpcId、不需要回应): +- `Hero_HeroInfo { HeroInfo hero }` - 单个英雄数据变化 +- `Hero_HeroInfoList { repeated HeroInfo heroes }` - 全量英雄列表(登录后) +- `Item_ItemInfo { ItemInfo item }` - 单个道具数量变化 +- `Item_ItemInfoList { repeated ItemInfo items }` - 全量道具列表(登录后) +- `Item_RemoveItem { int64 itemIndex }` - 道具被完全移除 + +#### Scenario: 登录后全量同步 +- **WHEN** 玩家通过 `C2G_LoginGate` 登录到 Gate 成功 +- **THEN** 服务端 MUST 在 200ms 内推送 `Hero_HeroInfoList` 和 `Item_ItemInfoList` +- **AND** 客户端按顺序更新两个 Component 缓存 + +#### Scenario: 业务操作后增量推送 +- **WHEN** 服务端处理任意 Hero/Bag 业务请求成功后 +- **THEN** MUST 在响应中或之后立刻推送相关 `Hero_HeroInfo` / `Item_ItemInfo` +- **AND** 推送的数据是该操作影响的完整快照(不是 delta diff) + +### Requirement: 错误码统一定义 + +所有 Hero/Bag/Equipment 错误码 MUST 在 `Packages/cn.etetet.login/Scripts/Model/Share/ErrorCode.cs` 或新增 `HeroBagErrorCode.cs` 中集中定义为 `public const int xxx`。命名规则:`ERR__`。 + +#### Scenario: 错误码示例 +- **WHEN** 服务端拒绝因碎片不足的招募请求 +- **THEN** 返回 `error = ERR_HERO_ITEM_NOT_ENOUGH` +- **AND** 客户端按错误码查表显示本地化文案 + +### Requirement: 消息 ID 分配 + +新协议 MUST 占用尚未使用的消息 ID 段(避开 1000 Login / 1100 Router / 11001 StateSync 等已用段)。建议使用 3000-3999 段。 + +#### Scenario: ID 段不冲突 +- **WHEN** 通过 `ET/Proto/Proto2CS` 工具生成代码 +- **THEN** 工具 MUST 检查 ID 不与现有 proto 冲突 +- **AND** 若冲突则编译失败并提示 + +### Requirement: 协议代码生成 + +新增 proto 文件后 MUST 通过 ET 工具菜单 `ET/Proto/Proto2CS` 生成 C# 代码到 `Packages/cn.etetet.proto/CodeMode/Model/{Client|Server|ClientServer}/Outer_C_.cs`。生成的代码 MUST 自动注册到 ET 的 MessageDispatcher。 + +#### Scenario: 重新生成不破坏现有代码 +- **WHEN** 修改 proto 文件后再次运行 Proto2CS +- **THEN** 工具 MUST 完全覆盖旧的生成 .cs 文件 +- **AND** 不影响业务 Handler 代码(Handler 是手写的) + +### Requirement: Handler 注册 + +所有 RPC 消息 MUST 有对应的服务端 Handler 类,命名规则 `C2G_Handler`,挂 `[MessageHandler(SceneType.Gate)]` 特性。Handler 类 MUST 实现 `MessageSessionHandler` 模板。 + +#### Scenario: 消息路由 +- **WHEN** 客户端发送 `C2G_SummonHero` +- **THEN** Gate Scene 的 MessageDispatcher 路由到 `C2G_SummonHeroHandler.Run()` +- **AND** Handler 通过 `session.GetComponent().Player` 获取玩家 +- **AND** 修改业务后返回 Response diff --git a/openspec/changes/port-rok-hero-bag-system/specs/equipment-system/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/equipment-system/spec.md new file mode 100644 index 0000000..7ac3754 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/equipment-system/spec.md @@ -0,0 +1,127 @@ +## ADDED Requirements + +### Requirement: 装备子类型与部位 + +系统 MUST 提供 4 种装备 EquipItemType:Material(1) / Equip(2) / Drawing(3) / DrawingMaterial(4)。完整成品装备(Equip)MUST 占用 8 个部位中的一个,由 `EquipSubType` 枚举:Head(1) / BreastPlate(2) / Weapon(3) / Gloves(4) / Pants(5) / Accessories1(6) / Accessories2(7) / Shoes(8)。 + +#### Scenario: 装备数据结构 +- **WHEN** `ItemConfig.itemType == Equipment` +- **THEN** 必须有关联的 `EquipConfig.subType` 字段(8 个部位之一) +- **AND** 必须有 `EquipConfig.equipItemType`(Material/Equip/Drawing/DrawingMaterial) + +### Requirement: 英雄穿戴装备 + +玩家 MUST 能为指定英雄穿戴成品装备(EquipItemType == Equip)。同部位已穿装备 MUST 自动卸下并回到背包未绑定状态。 + +#### Scenario: 穿戴空槽位装备 +- **WHEN** 玩家发送 `C2G_HeroWearEquip { heroId, equipItemIndex }` +- **AND** 该装备类型为 Equip +- **AND** 英雄对应 SubType 槽位为空 +- **THEN** 服务端将该装备 itemIndex 写入 `Hero.equipSlots[SubType]` +- **AND** 设置该装备 `ItemInfo.heroId = heroId` +- **AND** 推送 `Hero_HeroInfo` + `Item_ItemInfo` + +#### Scenario: 替换已穿装备 +- **WHEN** 玩家穿戴新装备,对应槽位已有装备 +- **THEN** 服务端 MUST 先卸下旧装备(旧 ItemInfo.heroId = 0) +- **AND** 穿戴新装备 +- **AND** 推送两条 `Item_ItemInfo`(旧/新装备)+ 一条 `Hero_HeroInfo` + +#### Scenario: 穿戴非装备道具 +- **WHEN** 玩家发送 wear 请求但 itemIndex 对应的不是 Equip +- **THEN** 服务端 MUST 拒绝并返回 `NotEquipment` + +### Requirement: 卸下装备 + +玩家 MUST 能从英雄身上卸下指定部位装备。卸下后装备 MUST 回到背包未绑定状态(heroId = 0)。 + +#### Scenario: 卸下装备 +- **WHEN** 玩家发送 `C2G_TakeOffEquip { heroId, subType }` +- **AND** 该英雄对应槽位有装备 +- **THEN** 服务端清空 `Hero.equipSlots[SubType]` +- **AND** 该装备 `ItemInfo.heroId = 0` +- **AND** 推送 `Hero_HeroInfo` + `Item_ItemInfo` + +#### Scenario: 卸下空槽位 +- **WHEN** 该英雄对应槽位为空 +- **THEN** 服务端 MUST 拒绝并返回 `EquipSlotEmpty` + +### Requirement: 装备属性 + +每件装备 MUST 提供属性加成,由 `EquipConfig.attrTypes[] + attrNumbers[]` 定义。英雄装备总属性 MUST 等于所有已穿装备的属性总和。 + +#### Scenario: 计算英雄装备属性 +- **WHEN** 客户端调用 `HeroExtensions.GetEquipAttr(hero)` +- **THEN** 返回字典 `{ AttrType -> 数值 }`,键值是 8 个部位装备属性累加 + +### Requirement: 装备品质排序 + +客户端 MUST 提供装备列表排序: +- HighQuality:按品质降序 +- LowQuality:按品质升序 +- Worn:先显示已穿戴,再未穿戴 +- Exclusive:只显示英雄专属(`EquipConfig.exclusive` 字段) + +#### Scenario: 按品质排序 +- **WHEN** 客户端调用 `BagComponentExtensions.GetEquipItemsBySortType(EquipSortType.HighQuality)` +- **THEN** 返回所有 Equip 类道具 +- **AND** 按 `EquipConfig.quality` 字段降序 + +### Requirement: 装备锻造(制作成品) + +玩家 MUST 能用图纸(Drawing) + 图纸材料(DrawingMaterial)合成成品装备。每个图纸 MUST 在 `EquipConfig.requireMaterials[]` 定义所需材料和数量。 + +#### Scenario: 锻造成功 +- **WHEN** 玩家发送 `C2G_MakeEquipment { drawingItemId }` +- **AND** 玩家持有该图纸 1 个 + 所有必需材料足够 +- **THEN** 服务端扣除图纸 1 个 + 各材料数量 +- **AND** 创建新 Equip 类 ItemInfo(heroId=0) +- **AND** 推送所有相关 `Item_ItemInfo` 变更 + +#### Scenario: 材料不足 +- **WHEN** 玩家发送锻造请求但材料不够 +- **THEN** 服务端拒绝并返回 `MaterialNotEnough` +- **AND** 不消耗任何道具 + +### Requirement: 装备分解 + +玩家 MUST 能分解未穿戴的成品装备,按 `EquipConfig.resolveRewards[]` 返还材料/碎片。 + +#### Scenario: 分解成功 +- **WHEN** 玩家发送 `C2G_ResolveEquipment { itemIndex }` +- **AND** 该装备 `heroId == 0`(未穿戴) +- **THEN** 服务端删除该 itemIndex +- **AND** 按配置发放奖励道具 +- **AND** 推送 `Item_ItemInfo` 多条 + +#### Scenario: 已穿戴装备不能分解 +- **WHEN** 该装备 `heroId != 0` +- **THEN** 拒绝并返回 `EquipmentInUse` + +### Requirement: 材料合成 + +玩家 MUST 能用低级材料合成高级材料,由 `MaterialDefine.mixRules[]` 定义规则(如 5 个 1 级材料 → 1 个 2 级材料)。 + +#### Scenario: 合成材料 +- **WHEN** 玩家发送 `C2G_MixMaterial { sourceItemId, count }` +- **AND** 数量是合成倍数(如 5 / 10 / 15) +- **AND** 玩家拥有足够数量 +- **THEN** 服务端扣除源材料 +- **AND** 增加目标材料 `count / mixRatio` 个 +- **AND** 推送 `Item_ItemInfo` + +### Requirement: 锻造红点 + +客户端 MUST 提供"有装备可锻造"红点: +- `HasEquipCanBeForged()` 返回是否任一图纸的材料齐全 +- 玩家 MUST 能"忽略某图纸的红点"(本地存储),`AddForgeEquipIgnoreRedDot(drawingItemId)` + +#### Scenario: 检测可锻造红点 +- **WHEN** 玩家拥有图纸 A 和它的所有材料足够 +- **AND** 调用 `HasEquipCanBeForged()` +- **THEN** 返回 true + +#### Scenario: 忽略红点后不再提示 +- **WHEN** 玩家忽略图纸 A 的红点 +- **AND** 调用 `HasEquipCanBeForged()` 仅检测其他图纸 +- **THEN** 即使图纸 A 满足条件也不计入 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/gacha-system/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/gacha-system/spec.md new file mode 100644 index 0000000..c75dbb5 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/gacha-system/spec.md @@ -0,0 +1,192 @@ +## ADDED Requirements + +### Requirement: 卡池配置 + +系统 MUST 提供卡池配置 `GachaPoolConfig`,每个卡池含:id、name、单抽消耗(itemId + count)、十连消耗(itemId + count,通常有折扣)、保底次数(每 N 抽必出某品质)、保底品质等级。所有数值由 Excel 配置,运行时通过 `GachaPoolConfigCategory.Instance.Get(poolId)` 获取。 + +#### Scenario: 加载卡池配置 +- **WHEN** 服务端启动加载 ExcelExporter 生成的配置 +- **THEN** `GachaPoolConfigCategory` 包含所有配置的卡池 +- **AND** 每个卡池可查询单抽/十连消耗及保底参数 + +#### Scenario: 卡池关闭 +- **WHEN** `GachaPoolConfig.openTime > 现在时间` 或 `closeTime < 现在时间` +- **THEN** 服务端拒绝该卡池的抽卡请求,返回 `ERR_GACHA_POOL_CLOSED` + +### Requirement: 卡池权重表 + +系统 MUST 为每个卡池配置权重表 `GachaWeightConfig`,每条记录含:poolId、entryIndex、itemId(奖励道具)、count(数量)、weight(权重)、rare(品质 1-5)。抽卡随机算法 MUST 按权重总和归一化抽取。 + +#### Scenario: 权重归一化抽取 +- **WHEN** 卡池 A 有 3 条记录权重分别为 70 / 25 / 5(总和 100) +- **AND** 服务端生成随机数 r ∈ [0, 100) +- **THEN** r < 70 → 选第 1 条;70 ≤ r < 95 → 选第 2 条;95 ≤ r < 100 → 选第 3 条 + +#### Scenario: 权重表为空 +- **WHEN** 卡池配置中无任何 weight 记录 +- **THEN** 服务端启动时 MUST 报错并拒绝该卡池上线 + +### Requirement: 抽卡组件数据模型 + +服务端 MUST 为每个玩家维护 `GachaComponent`,记录:每个卡池的累计抽数(用于保底统计)、当前距离保底的抽数、最后一次抽卡时间。`GachaComponent` MUST 持久化到 DB。 + +#### Scenario: GachaComponent 初始化 +- **WHEN** 新玩家首次登录 +- **THEN** 创建空的 `GachaComponent`,所有 poolStats 为初始值 + +#### Scenario: GachaComponent 同步 +- **WHEN** 玩家登录后 +- **THEN** 服务端 MUST 推送 `Gacha_GachaInfo` 含所有 poolStats +- **AND** 客户端缓存到 `GachaComponent` + +### Requirement: 单抽 + +玩家 MUST 能对指定卡池发起单抽。服务端 MUST 严格按顺序执行: +1. 校验玩家拥有 `GachaPoolConfig.singleCostItem` 数量 `>= singleCostCount` +2. 扣除消耗 +3. 按权重表随机抽取 1 个奖励项 +4. 调用 `BagComponentSystem.AddItems(rewards)` 发放奖励 +5. 更新 `GachaComponent.poolStats[poolId]`(累计 +1、距保底 -1 或重置) +6. 推送 `R2C_GachaDraw { rewards }` + `Item_ItemInfo*` + `Gacha_GachaInfo` + +#### Scenario: 单抽成功 +- **WHEN** 玩家拥有 1 个钻石(卡池消耗 1 钻石) +- **AND** 发送 `C2G_GachaDraw { poolId, drawType: Single }` +- **THEN** 服务端扣 1 钻石、抽取 1 个奖励、发放到背包 +- **AND** 返回 `R2C_GachaDraw { error: 0, rewards: [(itemId, count, isNewHero)] }` +- **AND** 推送 `Item_ItemInfo`(钻石变化)+ 奖励道具 + +#### Scenario: 消耗不足 +- **WHEN** 玩家消耗道具不足 +- **THEN** 服务端拒绝并返回 `ERR_GACHA_COST_NOT_ENOUGH` +- **AND** 不扣消耗、不抽奖、不更新状态 + +### Requirement: 十连抽 + +玩家 MUST 能对指定卡池发起十连抽。服务端 MUST 一次性独立抽取 10 个奖励(每次独立抽奖,互不影响),并应用十连专属消耗(通常优惠 10%-20%)。 + +#### Scenario: 十连成功 +- **WHEN** 玩家拥有 10 个钻石(十连消耗 9 钻石优惠) +- **AND** 发送 `C2G_GachaDraw { poolId, drawType: Multi10 }` +- **THEN** 服务端扣 9 钻石 +- **AND** 独立抽取 10 个奖励(每次独立随机,可能重复也可能不重复) +- **AND** 累计抽数 +10,保底计数 -10(或触发保底) +- **AND** 返回 10 个奖励的列表 + +#### Scenario: 十连必出保底品质 +- **WHEN** 卡池配置 `multiGuaranteeRare = 4`(十连必出至少 1 个 4 星) +- **AND** 玩家发起十连 +- **AND** 自然抽取的 10 个结果中无 rare ≥ 4 +- **THEN** 服务端 MUST 把最低品质的 1 个替换为该卡池权重表中任意 rare ≥ 4 的奖励 +- **AND** 替换在响应给客户端之前完成 + +### Requirement: 保底机制 + +每个卡池 MUST 支持累计保底:玩家在该卡池累计抽到 `GachaPoolConfig.guaranteeCount` 次(如 90 次)仍未出 `GachaPoolConfig.guaranteeRare`(如 5 星)品质,**第 N 次 MUST 强制出该品质**。一旦出 ≥ 该品质,保底计数器重置。 + +#### Scenario: 触发保底 +- **WHEN** 玩家在卡池 A 已抽 89 次未出 5 星 +- **AND** 玩家发起第 90 抽 +- **AND** 自然抽取结果 rare < 5 +- **THEN** 服务端 MUST 强制把奖励替换为权重表中任意 rare ≥ 5 的项 +- **AND** 重置该玩家在卡池 A 的距离保底计数为 0 + +#### Scenario: 自然出货重置保底 +- **WHEN** 玩家在卡池 A 累计抽 30 次 +- **AND** 第 31 抽自然抽到 rare = 5 的奖励 +- **THEN** 服务端 MUST 重置该玩家在卡池 A 的"距离保底"计数为 0 +- **AND** 累计抽数继续累加(用于历史统计) + +#### Scenario: 不同卡池独立保底 +- **WHEN** 玩家在卡池 A 累计 50 次、卡池 B 累计 80 次 +- **THEN** 两个卡池的保底进度独立计算 +- **AND** 抽卡池 A 不影响卡池 B 的保底进度 + +### Requirement: 随机算法 + +服务端随机算法 MUST 使用密码学安全的随机源或种子可记录的伪随机(如 `System.Random` 配 GUID seed),且 MUST 在服务端**独立完成**,不接受客户端任何随机性输入。 + +#### Scenario: 服务端单边随机 +- **WHEN** 客户端发送 `C2G_GachaDraw { poolId, drawType }` +- **THEN** 服务端使用自身随机数生成器抽奖 +- **AND** 客户端 payload 中任何 "randSeed" / "luck" 等字段 MUST 被忽略 + +#### Scenario: 抽卡日志可审计 +- **WHEN** 任何一次抽卡完成 +- **THEN** 服务端 MUST 记录日志:玩家 Id、卡池 Id、抽取时间、消耗、奖励列表、抽数序号、保底计数 +- **AND** 日志格式便于后续 SQL 分析(防欺诈、概率审计) + +### Requirement: 抽卡奖励发放 + +抽卡奖励 MUST 通过统一接口 `BagComponentSystem.AddItems(player, rewards)` 发放。**抽卡系统自身 MUST NOT 直接操作背包**。 + +#### Scenario: 抽到道具 +- **WHEN** 抽卡结果为 itemId=2001(升星石)count=10 +- **THEN** 服务端调用 `BagComponentSystem.AddItem(player, 2001, 10)` +- **AND** `Item_ItemInfo` 自动推送给客户端 + +#### Scenario: 抽到英雄碎片 +- **WHEN** 抽卡结果为 itemId=10001(某英雄碎片)count=1 +- **AND** 该英雄碎片足够招募(满足 HeroConfig.getItemNum) +- **THEN** 服务端发放碎片到背包 +- **AND** 响应中 `isCanSummon = true` 标记,客户端展示"碎片满,可招募"动效 + +#### Scenario: 抽到完整英雄(首次) +- **WHEN** 抽卡奖励是 itemId 对应"直接发英雄"(非碎片) +- **AND** 玩家尚未拥有该英雄 +- **THEN** 服务端直接创建 Hero Entity(等价 SummonHero 流程) +- **AND** 响应中 `isNewHero = true` 标记 + +#### Scenario: 抽到完整英雄(已拥有) +- **WHEN** 抽卡奖励是"直接发英雄"但玩家已拥有 +- **THEN** 服务端 MUST 按 `ItemConfig.exchangeWhenOwned` 配置转化为碎片或其他奖励 +- **AND** 响应中标记为"转换奖励" + +### Requirement: 客户端抽卡 UI + +客户端 MUST 提供 `GachaPanel`,包含: +- 卡池切换栏(横向列表,显示所有 open 状态卡池) +- 当前卡池信息(名称、关闭时间倒计时、单抽/十连消耗) +- 保底进度条(显示"距离保底还需 N 抽") +- 单抽 / 十连按钮(点击发送 `C2G_GachaDraw`) +- 历史奖励预览(最近 N 抽的简略列表) + +#### Scenario: 切换卡池 +- **WHEN** 玩家点击卡池切换按钮 +- **THEN** UI 刷新显示新卡池的所有信息 +- **AND** 保底进度条按新卡池数据更新 + +#### Scenario: 抽卡过程动画 +- **WHEN** 玩家点击十连按钮且消耗足够 +- **THEN** UI 立刻禁用按钮防重复点击 +- **AND** 发送 `C2G_GachaDraw` +- **AND** 收到响应后播放抽卡动画(约 2-3 秒) +- **AND** 动画完毕弹出 `GachaResultPanel` 展示所有奖励 + +### Requirement: 抽卡结果展示 + +`GachaResultPanel` MUST 一次性展示一次抽卡的所有奖励(单抽 1 个、十连 10 个),按品质升序排列展示(高品质放最后突出显示)。新英雄 MUST 有特殊"亮相"动画。 + +#### Scenario: 十连结果展示 +- **WHEN** 收到十连响应含 10 个奖励 +- **THEN** UI 按 rare 升序排列展示(低品在前、高品在后) +- **AND** 含新英雄的项额外播放 `NewHeroBanner` 动画 +- **AND** 玩家点击"再抽十连"按钮 → 直接发起下一轮(不关闭面板) + +#### Scenario: 跳过动画 +- **WHEN** 玩家点击屏幕任意位置 +- **THEN** 直接快进到结果展示完毕状态 +- **AND** 跳过动画不影响奖励发放(奖励已在 `R2C_GachaDraw` 响应到达时入背包) + +### Requirement: 抽卡红点 + +系统 MUST 提供以下红点: +- 卡池新开放(首次开启或恢复开启时显示红点,进 GachaPanel 后清除) +- 免费单抽可用(部分卡池每 N 小时提供 1 次免费) +- 接近保底(剩余抽数 ≤ 5 时显示红点提示) + +#### Scenario: 接近保底红点 +- **WHEN** 玩家在卡池 A 累计 85 次未出保底 +- **AND** 卡池 A 保底阈值 90 次 +- **THEN** `GachaPanel` 上卡池 A 标签显示红点 +- **AND** 进入该卡池后红点不消失(仍持续提示) diff --git a/openspec/changes/port-rok-hero-bag-system/specs/gm-tools/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/gm-tools/spec.md new file mode 100644 index 0000000..53a3652 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/gm-tools/spec.md @@ -0,0 +1,187 @@ +## ADDED Requirements + +### Requirement: GM 双轨入口 + +系统 MUST 提供两种 GM 命令入口:服务端 GM Console(命令行 + ET Watcher 注入)和客户端 GM Panel(YIUI 简易面板)。两者共用同一套命令注册表,避免重复实现。 + +#### Scenario: 服务端 Console +- **WHEN** 运维登录服务端 shell 执行 `gm AddItem 10001 100` 或通过 ET Watcher Web 接口 +- **THEN** 命令在服务端直接执行(不需要客户端登录) +- **AND** 操作日志写入 `Logs/Audit/GM/{date}.jsonl` + +#### Scenario: 客户端 Panel +- **WHEN** Debug 包内打开 GM Panel +- **THEN** 显示所有可用命令列表(按分类分 Tab) +- **AND** 点击命令 → 弹输入框 → 发送 RPC `C2G_GmCommand` 到服务端执行 + +#### Scenario: 命令注册统一 +- **WHEN** 开发者新增 GM 命令 +- **THEN** 用 `[GmCommand("AddItem", "发放道具")]` 属性标记即可 +- **AND** 服务端 Console 和客户端 Panel 都自动出现 + +### Requirement: GM 命令必覆盖全部业务写操作 + +每个新增的服务端 RPC Handler MUST 同时新增对应的 GM 命令。**这是 P1-P3 阶段唯一的业务验证手段**。MUST 在 CI 中自动校验:每个 RPC Handler 都有至少一条对应 GM 命令的注册。 + +#### Scenario: 新增 Handler 必带 GM +- **WHEN** 开发者新增 `C2G_HeroSkillUpHandler` +- **THEN** MUST 同时新增 `gm HeroSkillUp ` 命令 +- **AND** CI 检查脚本扫描发现缺少 GM 时构建失败 + +#### Scenario: GM 命令调业务接口 +- **WHEN** `gm HeroSkillUp` 被执行 +- **THEN** MUST 调用真实的业务层 API(如 `HeroComponentSystem.SkillUp(hero, skillIndex)`) +- **AND** NOT 直接修改 Hero 字段(避免绕过业务校验导致数据不一致) + +### Requirement: GM 命令分类 + +GM 命令 MUST 按以下分类组织: + +| 类别 | 命令前缀 | 典型命令 | +|---|---|---| +| 资源发放 | `Add*` `Set*` | AddItem / AddCurrency / AddHero / SetItemCount | +| 英雄操作 | `Hero*` | HeroLevelUp / HeroStarUp / HeroSkillUp / HeroAwake / HeroWearEquip | +| 背包操作 | `Bag*` | BagClear / BagShow / BagDump | +| 抽卡操作 | `Gacha*` | Gacha / ResetGachaGuarantee / SetGachaCount | +| 数据查询 | `Show*` `Dump*` | ShowHeroes / ShowBag / ShowGachaStats / DumpPlayer | +| 数据快照 | `Snapshot*` `Restore*` | Snapshot / Restore / ListSnapshots | +| 批量模拟 | `Simulate*` | SimulateGacha / SimulateBatchLogin | +| 数据校验 | `Verify*` | VerifyConsistency / VerifyConfigRef | +| 服务管理 | (其他) | ReloadConfig / FlushDirty / Kick / SetLogLevel | + +#### Scenario: 列出所有命令 +- **WHEN** 执行 `gm help` +- **THEN** 按分类输出所有可用命令 + 简要描述 + 参数说明 + +#### Scenario: 命令搜索 +- **WHEN** 执行 `gm help Gacha` +- **THEN** 仅输出 Gacha 类别的命令 + +### Requirement: 数据查询命令输出 jsonl + +`Show*` 和 `Dump*` 类查询命令 MUST 输出 jsonl 或可解析的结构化数据(默认 jsonl,可选 `--pretty` 输出格式化 JSON),便于后续 `jq` 处理或对比。 + +#### Scenario: ShowBag 输出 +- **WHEN** 执行 `gm ShowBag playerId=1001` +- **THEN** 控制台输出形如: + ```jsonl + {"itemId":1,"name":"金币","count":12345} + {"itemId":2,"name":"钻石","count":50} + {"itemId":10001,"name":"英雄A碎片","count":3} + ``` +- **AND** 可重定向到文件用 `jq` 处理 + +#### Scenario: DumpPlayer 完整快照 +- **WHEN** 执行 `gm DumpPlayer 1001` +- **THEN** 输出完整玩家数据 JSON:Hero/Bag/Gacha/Equip/Talent +- **AND** 输出大小可能很大(数百 KB),自动写文件并提示路径 + +### Requirement: 抽卡概率模拟与审计 + +GM MUST 提供 `gm SimulateGacha ` 命令:服务端循环 N 次抽卡(不消耗真实玩家资源)输出统计数据。MUST 输出每个品质的实际命中率、保底触发次数、与配置期望误差百分比。 + +#### Scenario: 概率审计 +- **WHEN** 执行 `gm SimulateGacha 1 10000` +- **THEN** 服务端循环 10000 次抽卡(不修改玩家数据) +- **AND** 输出形如: + ``` + Pool 1 - SimulateGacha 10000 times + Rare 5: 145 hits (1.45%), expected 1.50%, diff -3.33% + Rare 4: 1212 hits (12.12%), expected 12.00%, diff +1.00% + Rare 3: 8643 hits (86.43%), expected 86.50%, diff -0.08% + Guarantee triggered: 1 times + ``` +- **AND** 误差超过 ±5% 时标红警告 + +#### Scenario: 不影响真实玩家 +- **WHEN** 模拟抽卡执行 +- **THEN** 真实玩家的 GachaComponent.PoolStats 不变 +- **AND** 不发奖励、不扣消耗 +- **AND** 不写真实审计日志(仅写 GM 模拟日志) + +### Requirement: 数据快照与回滚 + +GM MUST 提供 `Snapshot` / `Restore` 命令:保存当前玩家完整数据,后续可恢复。用于 A/B 测试不同操作链路、验证数据一致性。 + +#### Scenario: 测试场景前后对比 +- **WHEN** 执行 `gm Snapshot before_test playerId=1001` +- **AND** 执行一系列业务操作 +- **AND** 执行 `gm Snapshot after_test playerId=1001` +- **AND** 执行 `gm DiffSnapshot before_test after_test` +- **THEN** 输出两个快照的差异(jsonl 格式) +- **AND** 便于验证"某操作的副作用是否符合预期" + +#### Scenario: 一键回滚 +- **WHEN** 测试发现某操作导致数据错乱 +- **AND** 执行 `gm Restore before_test playerId=1001` +- **THEN** 玩家数据完全恢复到快照时点 +- **AND** 内存 + DB 都同步更新 + +### Requirement: 数据一致性校验 + +GM MUST 提供 `gm VerifyConsistency ` 命令:扫描玩家所有数据,校验关键不变式(invariants): +- 装备的 heroId 是否真实存在 +- 英雄的等级是否符合稀有度上限 +- 背包道具的 itemId 是否在 Config 中存在 +- 抽卡保底计数是否 ≤ 配置阈值 +- DB 数据与内存数据是否一致 + +#### Scenario: 发现脏数据 +- **WHEN** 玩家数据中存在 `Equip { heroId: 99999 }`(不存在的英雄) +- **AND** 执行 `gm VerifyConsistency 1001` +- **THEN** 输出 `[ERR] Equip itemIndex=123 references invalid heroId=99999` +- **AND** 输出修复建议 + +#### Scenario: 全服校验 +- **WHEN** 执行 `gm VerifyConsistency --all` +- **THEN** 对所有在线玩家执行校验 +- **AND** 汇总输出错误统计 + +### Requirement: GM 操作审计 + +每个 GM 命令执行 MUST 写 L2 审计日志(同 observability-logging 中的定义),含 operator(GM 操作者标识)、action(命令名)、targetPlayer(如有)、args(参数)、success(是否成功)。失败的命令也 MUST 记录。 + +#### Scenario: GM 操作审计日志 +- **WHEN** 执行 `gm AddItem 1 1000000 playerId=1001` +- **THEN** `Logs/Audit/GM/{date}.jsonl` 追加: + ```json + {"ts":"...","level":"WARN","module":"GM","operator":"admin","action":"AddItem","targetPlayer":1001,"args":{"itemId":1,"count":1000000},"success":true} + ``` +- **AND** 审计日志同步落盘,不允许丢失 + +#### Scenario: 失败也记录 +- **WHEN** GM 执行不存在的命令或参数错误 +- **THEN** 仍然记录审计日志,success=false,附 errorMessage + +### Requirement: Release 包剥离 GM 代码 + +GM 命令的注册、命令实现、客户端 Panel MUST 通过编译条件 `#if !RELEASE` 包裹,Release 构建时**完全不编译进包**。MUST 通过 CI 校验:Release IL 中不含 GM 命令字符串。 + +#### Scenario: Release 包不含 GM +- **WHEN** 用 `Build Settings: Release` 出包 +- **AND** 反编译产物(dnSpy / il2cpp dump) +- **THEN** 不存在任何 `GmCommand` 属性或注册代码 +- **AND** 不含 GM 命令名字符串(如 "AddItem") + +#### Scenario: Debug 包正常启用 +- **WHEN** 用 Debug 配置编译 +- **THEN** GM Panel 可打开 +- **AND** 所有命令可用 + +### Requirement: 阶段性使用约束 + +在 OpenSpec 当前 change(port-rok-hero-bag-system)的 P1-P3 阶段,业务验证 MUST 通过 GM 命令 + 业务日志,**禁止为了验证而临时写 YIUI Panel**。所有 P1-P3 落地的 RPC Handler MUST 有对应的 GM 命令验证场景在 tasks.md 中明确记录。 + +#### Scenario: P1 抽卡完成验证 +- **WHEN** P1 阶段 GachaComponentSystem.Draw 实现完成 +- **THEN** MUST 通过以下 GM 命令组合验证: + - `gm AddCurrency 2 100` → 给自己钻石 + - `gm Gacha 1 1` → 单抽 + - `gm ShowBag` → 检查奖励到账、钻石扣除 + - `gm SimulateGacha 1 10000` → 概率审计 +- **AND** 验证场景必须在 tasks.md 的 2.0.7 节明确列出 + +#### Scenario: 禁止临时 UI +- **WHEN** 开发者觉得"做个简单 UI 看看"更直观 +- **THEN** **不允许**,必须通过 GM 命令验证 +- **AND** UI 集中到 P4 阶段一次性做 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/hero-system/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/hero-system/spec.md new file mode 100644 index 0000000..09e3511 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/hero-system/spec.md @@ -0,0 +1,174 @@ +## ADDED Requirements + +### Requirement: Hero Entity 数据模型 + +系统 MUST 为每个英雄实例提供一个 `Hero` 实体,挂在玩家的 `HeroComponent` 下。每个 `Hero` 实体 MUST 持有以下基础字段:英雄 configId、当前等级、当前经验、当前星级、当前升星经验、招募时间、击杀数、技能等级数组(5 个槽位)、当前天赋页索引、3 套天赋树数据、8 个装备槽引用。 + +#### Scenario: 创建一个新英雄 +- **WHEN** 服务端处理 `C2G_SummonHero` 成功后 +- **THEN** 在玩家 `HeroComponent` 下创建一个新的 `Hero` Entity,等级初始为 1、经验为 0、星级为 configId 对应 HeroConfig 的 `initStar`、所有技能等级为 1、装备槽为空 +- **AND** 新 `Hero` Entity 的 Id 是分布式唯一 ID +- **AND** 服务端 MUST 推送 `Hero_HeroInfo` 给客户端 + +#### Scenario: 查询英雄数据 +- **WHEN** 客户端通过 `HeroComponent.GetHero(heroId)` 查询 +- **THEN** 返回对应 `Hero` Entity;若不存在返回 null + +### Requirement: 英雄招募 + +玩家 MUST 能通过消耗碎片道具招募一个新英雄。招募 MUST 满足以下条件:碎片数量 >= HeroConfig.getItemNum;玩家未达到当前英雄招募上限(HeroConfig.getLimit);玩家未已持有该英雄(每个英雄唯一)。 + +#### Scenario: 招募成功 +- **WHEN** 玩家碎片足够且未持有该英雄 +- **AND** 客户端发送 `C2G_SummonHero { configId }` +- **THEN** 服务端扣除 `HeroConfig.getItemNum` 数量的对应碎片 +- **AND** 创建新 `Hero` Entity +- **AND** 返回 `R2C_SummonHero { error: 0, hero: HeroInfo }` +- **AND** 推送 `Item_ItemInfo` 同步碎片数量变化 + +#### Scenario: 碎片不足招募失败 +- **WHEN** 玩家碎片数量 < HeroConfig.getItemNum +- **THEN** 服务端 MUST 拒绝并返回 `R2C_SummonHero { error: ItemNotEnough }` +- **AND** 不扣道具、不创建英雄 + +#### Scenario: 重复招募已有英雄 +- **WHEN** 玩家已持有该 configId 的英雄 +- **THEN** 服务端 MUST 拒绝并返回 `R2C_SummonHero { error: HeroAlreadyOwned }` + +### Requirement: 英雄经验升级 + +玩家 MUST 能消耗经验道具提升英雄等级。系统 MUST 强制以下约束:等级上限受当前星级约束(`Hero.level <= StarConfig.starLimit`);经验溢出 MUST 顺延到下一级;满级时溢出经验 MUST 被丢弃。 + +#### Scenario: 单次升级 +- **WHEN** 玩家发送 `C2G_HeroAddExp { heroId, itemId, count }` +- **AND** 玩家拥有足够道具,且英雄未到星级限定等级 +- **THEN** 服务端按 `ItemConfig.exp * count` 累加经验 +- **AND** 经验超过 `HeroLevelConfig.exp` 时升级(可连升) +- **AND** 推送更新后的 `Hero_HeroInfo` + `Item_ItemInfo` + +#### Scenario: 满级丢弃溢出 +- **WHEN** 英雄已达星级等级上限 +- **THEN** 服务端 MUST 拒绝升级请求或保持经验不增长,返回相应错误码 + +### Requirement: 英雄升星 + +玩家 MUST 能消耗"升星材料"道具提升英雄星级。每次提交 MUST 累加升星经验 `Hero.starExp`,达到阈值后升星,并提供"幸运暴击"机制(按 `HeroStarExpConfig.lucky` 概率多算经验)。 + +#### Scenario: 升星积累经验 +- **WHEN** 玩家发送 `C2G_HeroStarUp { heroId, itemId, count }` +- **THEN** 服务端按 `HeroStarExpConfig.exp` 计算经验值,乘以幸运系数 +- **AND** 累加到 `Hero.starExp` +- **AND** 若 `starExp >= HeroStarConfig.starExpRequired` 则 `star += 1`,`starExp -= 该阈值` + +#### Scenario: 满星拒绝 +- **WHEN** `Hero.star >= HeroConfig.star`(最大星级) +- **THEN** 服务端 MUST 拒绝升星请求并返回 `StarMaxLevel` + +### Requirement: 英雄技能升级 + +每个英雄 MUST 有 5 个技能槽(前 4 个初始解锁、第 5 个需觉醒解锁)。玩家 MUST 能消耗指定材料按等级升级单个技能;技能等级 MUST 受 `HeroSkillConfig.maxLevel` 上限约束。 + +#### Scenario: 升级第 1-4 技能 +- **WHEN** 玩家发送 `C2G_HeroSkillUp { heroId, skillIndex }` 且 skillIndex ∈ [0,3] +- **AND** 拥有 `HeroSkillLevelConfig.costItem`(按英雄稀有度选择不同 costItem 字段) +- **THEN** 服务端扣道具,技能等级 +1 +- **AND** 推送 `Hero_HeroInfo` 更新 + +#### Scenario: 升级第 5 技能要求觉醒 +- **WHEN** 玩家发送 `C2G_HeroSkillUp { heroId, skillIndex: 4 }` +- **AND** `Hero.isAwakening == false` +- **THEN** 服务端 MUST 拒绝并返回 `NeedAwakening` + +### Requirement: 英雄觉醒 + +玩家 MUST 在前 4 个技能全部满级时才能觉醒英雄。觉醒后 `Hero.isAwakening = true`,解锁第 5 个技能(initial level = 1)。 + +#### Scenario: 觉醒成功 +- **WHEN** 玩家发送 `C2G_HeroAwake { heroId }` +- **AND** Hero 前 4 个技能都达到 `HeroSkillConfig.maxLevel` +- **AND** 玩家拥有觉醒材料道具 +- **THEN** 服务端扣道具,设置 `Hero.isAwakening = true`,第 5 技能等级置 1 +- **AND** 推送 `Hero_HeroInfo` + +#### Scenario: 觉醒前置不满足 +- **WHEN** 前 4 个技能未全部满级 +- **THEN** 拒绝并返回 `SkillNotMaxBeforeAwake` + +### Requirement: 天赋树 + +每个英雄 MUST 有 3 套天赋页(pageIndex 0/1/2),玩家 MUST 能:当前页学习/升级单个天赋;切换当前页(消耗道具或钻石);重置某一页天赋(返还点数)。 + +#### Scenario: 学习天赋 +- **WHEN** 玩家发送 `C2G_TalentUp { heroId, pageIndex, talentNodeId }` +- **AND** 当前天赋页剩余点数 >= 该节点消耗 +- **AND** 满足前置节点依赖(`HeroTalentDefine` 中定义) +- **THEN** 服务端扣减剩余点数,节点等级 +1 +- **AND** 推送 `Hero_HeroInfo` 更新 talentTrees[pageIndex] + +#### Scenario: 切换天赋页 +- **WHEN** 玩家发送 `C2G_ChangeTalentIndex { heroId, targetIndex }` +- **THEN** 服务端按规则扣消耗(道具或钻石) +- **AND** 更新 `Hero.talentIndex = targetIndex` + +#### Scenario: 重置天赋 +- **WHEN** 玩家发送 `C2G_ResetTalent { heroId, pageIndex }` +- **THEN** 服务端清空该页所有节点等级 +- **AND** 返还消耗的点数(或按规则按比例返还道具) + +### Requirement: 英雄属性与战力计算 + +系统 MUST 提供英雄"总战力"计算,等于:`baseScore + levelScore + skillScore + talentScore + equipScore`。每个分项 MUST 可独立计算: +- baseScore = `HeroConfig.score` +- levelScore = `HeroLevelConfig(rareGroup, lv).score` +- skillScore = 5 个技能各自 `HeroSkillEffectConfig(skillId, level).score` 之和 +- talentScore = 已学习天赋节点 score 之和 +- equipScore = 已穿戴装备的属性折算 score(由 `equipment-system` 提供接口) + +#### Scenario: 计算单个英雄战力 +- **WHEN** 客户端调用 `HeroExtensions.GetPower(hero)` +- **THEN** 返回 5 个分项之和 + +#### Scenario: 计算所有英雄总战力 +- **WHEN** 客户端调用 `HeroComponentExtensions.GetTotalPower()` +- **THEN** 返回所有 `Hero` Entity 的战力之和 + +### Requirement: 英雄排序与筛选 + +客户端 MUST 提供英雄排序能力,支持以下排序类型:Rare(稀有度)/ Star(星级)/ Level(等级)/ Power(战力)/ Recomend(推荐分)。排序 MUST 输出三个分组:已拥有 / 可召唤(碎片够但未招募)/ 未集齐。 + +#### Scenario: 按战力排序已拥有列表 +- **WHEN** 客户端调用 `HeroComponentExtensions.GetHerosBySort(SortType.Power)` +- **THEN** 返回 `(ownedList, canSummonList, fragmentList)` 三个列表 +- **AND** ownedList 按战力降序排列 +- **AND** canSummonList 包含所有 `碎片数 >= getItemNum && !已拥有 && !达到招募上限` 的英雄 + +### Requirement: 英雄红点 + +系统 MUST 提供以下红点接口(基于 `cn.etetet.yiuireddot` 包): +- 有英雄可招募 +- 有英雄有技能可升级(道具足够升满) +- 有英雄装备可升级 +- 有英雄天赋有剩余点数 + +#### Scenario: 检测可招募英雄红点 +- **WHEN** 客户端访问 `HeroComponentExtensions.GetCanSummonHeroCount()` +- **THEN** 返回当前可立即招募的英雄数量 + +#### Scenario: 检测可升级技能红点 +- **WHEN** 客户端访问 `HeroComponentExtensions.GetCanSkillUpHeroCount()` +- **THEN** 返回当前任一英雄存在"道具足够升一级且未满级"的技能时计数 + 1 + +### Requirement: 客户端英雄数据同步 + +客户端 `HeroComponent` MUST 持有服务端推送的全部英雄数据快照。任何 `Hero_HeroInfo` 推送 MUST:若 heroId 已存在则更新;若不存在则创建新缓存。 + +#### Scenario: 全量同步(登录后) +- **WHEN** 玩家登录成功,服务端发送 `Hero_HeroInfoList { heroes }` +- **THEN** 客户端 `HeroComponent` 清空旧缓存 +- **AND** 为每个 HeroInfo 创建客户端 `Hero` Entity 缓存 +- **AND** 触发 `OnHeroListUpdated` 事件 + +#### Scenario: 增量同步 +- **WHEN** 服务端推送 `Hero_HeroInfo { hero }` +- **THEN** 客户端找到对应缓存并更新 +- **AND** 触发 `OnHeroUpdated(heroId)` 事件 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/observability-logging/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/observability-logging/spec.md new file mode 100644 index 0000000..3947f1a --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/observability-logging/spec.md @@ -0,0 +1,150 @@ +## ADDED Requirements + +### Requirement: 三层日志体系 + +系统 MUST 实现三层日志体系:L1 业务日志 / L2 审计日志 / L3 错误日志,分目录分文件输出。每层用途、保留期、格式明确分离。 + +| 层级 | 路径 | 用途 | 保留期 | +|---|---|---|---| +| L1 业务 | `Logs/Business/{yyyy-MM-dd}.log` | RPC 入口/出口、业务操作 | 30 天 | +| L2 审计 | `Logs/Audit/{module}/{yyyy-MM-dd}.jsonl` | 抽卡、付费、GM、资源变动 | 永久 | +| L3 错误 | `Logs/Error/{yyyy-MM-dd}.log` | 异常、错误码、慢查询 | 90 天 | + +#### Scenario: 日志目录分离 +- **WHEN** 服务端启动 +- **THEN** 在配置的日志根目录下自动创建 `Business/` `Audit/` `Error/` 三个子目录 +- **AND** 各层日志互不混淆 + +#### Scenario: 按天滚动 +- **WHEN** 跨天时 +- **THEN** 自动创建新日期的日志文件 +- **AND** 超过保留期的旧文件自动清理(L2 可配置归档到 OSS) + +### Requirement: 结构化 JSON 行格式(jsonl) + +L2 审计日志和关键 L1 业务日志 MUST 使用 jsonl 格式(每行一个独立 JSON 对象),便于 `grep`/`jq`/ELK 工具消费。MUST 至少包含字段:`ts`(ISO8601 时间戳)、`level`、`module`、`playerId`(如有)、`action`、`reqId`(如有)。 + +#### Scenario: 抽卡审计日志 +- **WHEN** 玩家完成一次抽卡 +- **THEN** `Logs/Audit/Gacha/{date}.jsonl` 追加一行 +- **AND** 内容形如: + ```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"} + ``` + +#### Scenario: jq 可解析 +- **WHEN** 用 `cat Logs/Audit/Gacha/2026-05-27.jsonl | jq '.rewards[].rare'` +- **THEN** 输出所有抽卡品质列表 +- **AND** 不出现解析错误 + +### Requirement: RPC Handler 强制埋点 + +每个服务端 RPC Handler MUST 在入口和出口埋点 L1 业务日志。入口日志含:playerId、actionName、关键参数;出口日志含:playerId、actionName、错误码、耗时(毫秒)。 + +#### Scenario: 入口日志 +- **WHEN** `C2G_GachaDrawHandler` 被调用 +- **THEN** L1 日志立刻输出 `[Gacha] Begin Draw playerId=1001 poolId=2 drawType=Multi10` +- **AND** 即使后续业务校验失败也必须有入口日志 + +#### Scenario: 出口日志 +- **WHEN** Handler 返回响应 +- **THEN** L1 日志输出 `[Gacha] End Draw playerId=1001 error=0 cost=23ms` +- **AND** 耗时计算覆盖 Handler 全部生命周期 + +#### Scenario: 自动 Handler 包装 +- **WHEN** 开发者新增 Handler +- **THEN** 通过基类或属性自动埋点入口/出口,不需要手写 +- **AND** 不允许某个 Handler 漏埋点(编译期/启动期自检) + +### Requirement: 关键操作必写审计日志(L2) + +下列业务操作 MUST 写 L2 审计日志:抽卡(每次/每抽)、付费、GM 操作、英雄招募/升星/觉醒、装备锻造/分解、资源大额变动(>1000 单位)、玩家登录/登出。审计日志 MUST 同步落盘(不允许丢失)。 + +#### Scenario: GM 操作审计 +- **WHEN** 任何 GM 命令被执行 +- **THEN** `Logs/Audit/GM/{date}.jsonl` 追加一行 +- **AND** 包含 `operator`、`action`、`targetPlayer`、`args` +- **AND** 即使命令失败也记录 + +#### Scenario: 资源大额变动审计 +- **WHEN** 玩家金币/钻石 / 高价值道具单次变动 ≥ 1000 +- **THEN** `Logs/Audit/Resource/{date}.jsonl` 追加一行 +- **AND** 含变动前后数量、变动原因(抽卡/购买/GM/任务等) + +#### Scenario: 抽卡审计不可丢失 +- **WHEN** 服务端突然 kill -9 +- **THEN** 已完成的抽卡审计日志 MUST 100% 完整(同步刷盘) +- **AND** 未完成的抽卡不在审计日志中 + +### Requirement: 错误码返回必写错误日志(L3) + +每次服务端 RPC Handler 返回非 0 错误码 MUST 同步写 L3 错误日志。MUST 含 playerId、errorCode、errorMessage、上下文参数。重复错误码 1 分钟内同 playerId 仅记录首条 + 计数(防止刷屏)。 + +#### Scenario: 错误码返回写日志 +- **WHEN** Handler 返回 `ERR_GACHA_COST_NOT_ENOUGH` +- **THEN** `Logs/Error/{date}.log` 输出 `[Gacha][ERR] playerId=1001 errorCode=30001 (ERR_GACHA_COST_NOT_ENOUGH) poolId=2 drawType=Multi10` +- **AND** 同时返回客户端正常的错误响应 + +#### Scenario: 重复错误降频 +- **WHEN** 同 playerId 同 errorCode 1 分钟内连续触发 100 次 +- **THEN** 仅记录首条 + 周期末计数 `repeated 99 times` +- **AND** 避免日志爆量 + +### Requirement: 模块标签 + reqId 全链路追踪 + +所有日志 MUST 携带模块标签(`[Hero]`、`[Bag]`、`[Gacha]`、`[Equip]`、`[DB]`、`[Login]`、`[GM]`、`[Net]`)。每个客户端发起的 RPC MUST 携带 reqId(uuid 或自增序列),服务端日志 MUST 全程透传该 reqId。 + +#### Scenario: 单玩家行为链路追查 +- **WHEN** 客户端发送 `C2G_GachaDraw { reqId: "abc123" }` +- **THEN** 服务端 Handler 收到后日志含 `reqId=abc123` +- **AND** 后续调 `BagComponentSystem.AddItems` 内部日志也含该 reqId +- **AND** 可通过 `grep 'reqId=abc123' Logs/**/*.log` 还原完整链路 + +#### Scenario: 模块标签过滤 +- **WHEN** 运维需要排查抽卡问题 +- **THEN** `grep -F '[Gacha]' Logs/Business/2026-05-27.log` 能精确过滤 +- **AND** 与其他模块日志完全隔离 + +### Requirement: 慢操作告警 + +服务端 MUST 监测以下慢操作并写 L3 错误日志告警:DB 查询 > 100ms、DB 写入 > 200ms、Handler 执行 > 500ms、Lua/C# Compile > 1000ms。慢操作日志 MUST 含调用栈和参数摘要。 + +#### Scenario: 慢查询告警 +- **WHEN** 一次 `DBComponent.Query` 耗时 250ms +- **THEN** L3 日志输出 `[DB][SLOW] Query Hero playerId=1001 duration=250ms exceeded threshold=100ms` +- **AND** 含调用栈(简化版) + +#### Scenario: 慢 Handler 告警 +- **WHEN** 一次 RPC Handler 执行 800ms +- **THEN** L3 日志输出 `[Net][SLOW] C2G_GachaDraw playerId=1001 duration=800ms` +- **AND** 后续可在监控大盘聚合告警 + +### Requirement: 客户端日志桥接(可选) + +客户端 MUST 提供日志桥接开关:开启时,客户端 `Log.Error` / `Log.Warning` 关键日志通过专用 RPC 上报到服务端。服务端写入 `Logs/Client/{date}.log`,含 playerId / clientVersion / 设备信息。开关由 StartConfig 控制,默认开发期开、生产期可关。 + +#### Scenario: 客户端崩溃日志上报 +- **WHEN** 客户端发生 `NullReferenceException` +- **AND** 桥接开关 ON +- **THEN** 异常信息通过 `C2G_ClientLogReport` 上报 +- **AND** 服务端 `Logs/Client/{date}.log` 追加一行 jsonl + +#### Scenario: 上报频率限制 +- **WHEN** 客户端短时间内大量错误 +- **THEN** 服务端 MUST 对每个 playerId 限速(1 秒最多 5 条) +- **AND** 超过的日志在客户端本地累计,每分钟批量上报 + +### Requirement: 配置化与开关 + +日志各项行为 MUST 通过 `StartConfig.Logging` 或独立 `LogConfig` 配置:日志根路径、各层级开关、滚动策略、保留期、客户端桥接开关、敏感字段脱敏列表。MUST 支持运行时通过 GM 命令 `gm SetLogLevel ` 调整。 + +#### Scenario: 临时调高某模块日志级别 +- **WHEN** 运维执行 `gm SetLogLevel Gacha Debug` +- **THEN** Gacha 模块日志立刻输出 Debug 级别 +- **AND** 持续到下次重启或再次设置 + +#### Scenario: 敏感字段脱敏 +- **WHEN** 配置 `MaskFields = ["phone", "email", "idCard"]` +- **AND** 业务日志中含 `phone=13800138000` +- **THEN** 输出为 `phone=138****8000` +- **AND** 不允许任何日志层级输出明文敏感信息 diff --git a/openspec/changes/port-rok-hero-bag-system/specs/server-persistence/spec.md b/openspec/changes/port-rok-hero-bag-system/specs/server-persistence/spec.md new file mode 100644 index 0000000..8ab47a4 --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/specs/server-persistence/spec.md @@ -0,0 +1,149 @@ +## ADDED Requirements + +### Requirement: MongoDB 数据库连接组件 + +服务端 MUST 在 Gate Scene 启动时初始化 `DBComponent`,封装 MongoDB.Driver 提供异步读写。连接配置从 `StartZoneConfig.DBConnection` 和 `DBName` 读取。 + +#### Scenario: 启动时建立连接 +- **WHEN** ET Server Gate Scene 启动 +- **THEN** `DBComponent` 从 StartZoneConfig 读取 mongodb 连接字符串 +- **AND** 创建 `IMongoClient` 实例 +- **AND** 测试连接成功(PING) +- **AND** 失败时记录错误日志并按 StartConfig 决定是否阻塞启动 + +#### Scenario: 集合自动创建 +- **WHEN** 首次写入某类型 Entity(如 Player) +- **THEN** `DBComponent` 自动创建对应 collection(命名 `players`、`heroes`、`bags`...) +- **AND** 创建索引(默认按 EntityId) + +### Requirement: 玩家数据加载 + +玩家登录 Gate Scene 成功后,服务端 MUST 从 MongoDB 加载该玩家的 `Player` Entity(含 `HeroComponent` + `BagComponent`)。若玩家首次登录,MUST 创建新 Player 并发放初始资源(由 `InitialResourceConfig` 配置)。 + +#### Scenario: 老玩家登录 +- **WHEN** 玩家 PlayerId=10001 通过 `C2G_LoginGate` 登录 +- **AND** DB 中存在记录 +- **THEN** 服务端从 DB 加载完整 Player Entity 到内存 +- **AND** Player.GetComponent() 包含所有英雄子 Entity +- **AND** Player.GetComponent() 包含所有道具 + +#### Scenario: 新玩家首次登录 +- **WHEN** 玩家 PlayerId 在 DB 中不存在 +- **THEN** 服务端创建 Player Entity +- **AND** 添加 HeroComponent + BagComponent +- **AND** 按 `InitialResourceConfig` 发放初始道具/英雄 +- **AND** 立即写入 DB(同步保证幂等) + +### Requirement: 数据持久化策略 + +服务端 MUST 采用 **脏标记 + 定时刷盘** 策略,避免每次业务操作直写 DB: +- 业务系统通过 `EntitySystem.SetDirty(entity)` 标记脏数据 +- `DBSaveComponentSystem` 每隔 N 秒(默认 30s)扫描所有脏 Entity 并批量落库 +- 玩家离线(session disconnected)时 MUST 立即强制刷盘 + +#### Scenario: 业务操作触发脏标记 +- **WHEN** `C2G_SummonHeroHandler` 修改 HeroComponent +- **THEN** 通过 `Player.SetDirty()` 标记 +- **AND** Handler 立即返回(不等落库) + +#### Scenario: 定时批量刷盘 +- **WHEN** DBSaveComponent 定时器触发 +- **THEN** 扫描所有标记 dirty 的 Player +- **AND** 异步 `DBComponent.Save(player)` 批量写入 +- **AND** 写入成功后清除 dirty 标记 +- **AND** 失败时保留 dirty 等下次重试 + +#### Scenario: 玩家离线立即刷盘 +- **WHEN** Gate Scene 收到 SessionDispose 事件 +- **AND** 该 Session 对应的 Player 是 dirty +- **THEN** 同步等待 `DBComponent.Save(player).Coroutine()` 完成 +- **AND** 再释放 Player Entity + +### Requirement: Entity → BSON 序列化 + +`DBComponent` MUST 使用 ET 的 MongoHelper(基于 MongoDB.Bson)序列化/反序列化 Entity。所有要持久化的 Component 字段 MUST 加 `[MongoElement]` 特性,子 Entity 通过 ET 的 Entity 关系自动遍历。 + +#### Scenario: 持久化 HeroComponent +- **WHEN** `DBComponent.Save(player)` 触发 +- **THEN** 序列化 Player 的所有 Component 包含 HeroComponent + BagComponent +- **AND** 子 Hero Entity 递归序列化 +- **AND** 写入 MongoDB `players` collection(按 PlayerId 为 \_id) + +#### Scenario: 反序列化恢复 +- **WHEN** `DBComponent.Query(playerId)` 触发 +- **THEN** 从 BSON 重建 Player Entity 树 +- **AND** 所有子 Entity(Hero、Item 等)的父子关系正确恢复 +- **AND** 通过 `Player.GetComponent().GetHero(heroId)` 能直接访问 + +### Requirement: 并发与一致性 + +同一玩家的业务请求 MUST 在 Gate Scene 串行执行(ET 单线程纤程天然保证)。但跨玩家操作(如交易、组队)MUST 通过 ET 的消息机制传递,避免直接跨纤程访问。 + +#### Scenario: 同玩家串行请求 +- **WHEN** 客户端在 1 帧内连续发送 SummonHero 和 HeroAddExp +- **THEN** Gate Scene 单线程依次处理两个请求 +- **AND** 第二个请求看到第一个的副作用(新英雄存在) + +#### Scenario: 跨玩家通过消息 +- **WHEN** 玩家 A 给玩家 B 赠送道具(未来需求) +- **THEN** 必须通过 ET 的 Actor 消息机制 +- **AND** 不允许 A 的纤程直接修改 B 的 BagComponent + +### Requirement: DB 索引 + +服务端 MUST 为常用查询字段建索引: +- `players._id` (默认按 PlayerId) +- `players.account`(玩家账号反查) +- 业务表如 `friendships`(未来需求)需自定义索引 + +#### Scenario: 索引创建 +- **WHEN** DBComponent 首次连接 DB +- **THEN** 检查并创建 `players` collection 上的必要索引 +- **AND** 通过 `IMongoCollection.Indexes.CreateOneAsync` 创建 + +### Requirement: 数据迁移版本号 + +每个持久化 Entity MUST 包含 `dataVersion (int32)` 字段,用于未来数据结构变更时支持渐进式迁移。 + +#### Scenario: 版本号读取 +- **WHEN** 加载 Player 时 dataVersion < 当前版本 +- **THEN** `DBMigrationComponent` 运行该版本到当前版本的迁移函数 +- **AND** 迁移后更新 dataVersion = 当前版本 +- **AND** 立即保存到 DB + +#### Scenario: 新建 Player 写入版本号 +- **WHEN** 创建新 Player Entity +- **THEN** 自动设置 `dataVersion = 当前最新版本` + +### Requirement: 日志与监控 + +DB 操作 MUST 记录以下日志(基于 ET 的 Log 系统): +- 慢查询:超过 100ms 的读写 +- 失败:连接断开、超时、序列化错误 +- 容量:每隔 1 小时记录 collection 文档数和大小 + +#### Scenario: 慢查询日志 +- **WHEN** `DBComponent.Save(player)` 耗时超过 100ms +- **THEN** 输出 `Log.Warning` 包含 PlayerId、耗时 +- **AND** 不影响业务流程 + +#### Scenario: 失败重试 +- **WHEN** DB 写入抛 `MongoConnectionException` +- **THEN** 框架 MUST 重试最多 3 次(指数退避) +- **AND** 仍失败则保留 dirty 标记等下次刷盘 +- **AND** 输出 `Log.Error` + +### Requirement: 开发模式无 DB 选项 + +系统 MUST 支持开发期免 MongoDB 模式:当 `StartZoneConfig.DBConnection == "memory"` 时,服务端 MUST 启用 `MemoryDBComponent`,所有"持久化"只在进程内存中(Dictionary),重启清空。生产环境 MUST 禁止使用此模式(启动时 WARNING 日志提示)。 + +#### Scenario: 内存模式启动 +- **WHEN** StartZoneConfig.DBConnection == "memory" +- **THEN** DBComponent 实例化为 `MemoryDBComponent` +- **AND** 所有读写在 `Dictionary` 内存中完成 +- **AND** 不需要本地起 mongod + +#### Scenario: 内存模式重启 +- **WHEN** 内存模式下 ET Server 重启 +- **THEN** 所有玩家数据丢失(仅开发期使用) +- **AND** 日志输出 WARNING 提示此模式不可用于生产 diff --git a/openspec/changes/port-rok-hero-bag-system/tasks.md b/openspec/changes/port-rok-hero-bag-system/tasks.md new file mode 100644 index 0000000..cf96c6a --- /dev/null +++ b/openspec/changes/port-rok-hero-bag-system/tasks.md @@ -0,0 +1,459 @@ +# Tasks: Port ROK Hero & Bag System + +> **交付策略**:P1-P3 服务端业务先行,**用 GM 命令 + 日志验证**,**不写 UI**。所有 YIUI 面板集中在 P4 一次性补齐。每个新 RPC Handler MUST 同时新增对应 GM 命令。 +> +> **复用原则**(详见 `Doc/ET-Packages-Audit.md`):38 个 ET 包已覆盖 GM / 日志 / BSON / 协议 / 资源热更 / UI / 网络。本变更**严格遵守"能复用不重写"**:日志扩展 `Log` 类、GM 直接用 `[GM]`/`[ConsoleHandler]` 属性、BSON 复用 `MongoHelper`、登录扩展 `C2G_LoginGateHandler`。仅以下能力新建业务包:`db` / `logging` / `config` / `hero` / `bag` / `gacha`。 + +## 1. P0 - 基础设施准备 + +### 1.1 客户端编译产物 +- [ ] 1.1.1 在 Unity 中执行 `ET/Loader/Compile` (F6) 生成客户端热更 DLL +- [x] 1.1.2 检查 `Packages/cn.etetet.loader/Bundles/Code/` 下存在 `ET.Model.dll.bytes` / `ET.Hotfix.dll.bytes` / `ET.ModelView.dll.bytes` / `ET.HotfixView.dll.bytes` +- [ ] 1.1.3 执行 `ET/HybridCLR/Init` 初始化 AOT 元数据(如未执行过) + +### 1.2 服务端编译产物 +- [ ] 1.2.1 执行 `dotnet build "My project/ET.sln" -c Debug` 编译服务端 +- [x] 1.2.2 检查 `My project/Bin/ET.App.dll` 存在 +- [x] 1.2.3 编写 `tools/build-server.ps1` 一键编译脚本 + +### 1.3 跑通 Login Demo +- [ ] 1.3.1 在 Unity Build Settings 中添加 `Packages/cn.etetet.loader/Scenes/Init.unity` +- [ ] 1.3.2 启动 ET Server (单进程模式):`ET/Loader/ServerTools` → "Start Server" +- [ ] 1.3.3 启动 Unity Play,验证 `C2R_Login` → `R2C_Login` → `C2G_LoginGate` → `G2C_LoginGate` 链路打通 +- [ ] 1.3.4 验证 Gate Scene 创建了 `Player` Entity 并关联 `Session` + +### 1.4 DBComponent 业务封装(**基于现成 MongoHelper + MongoDB.Driver**) +- [x] 1.4.1 **复用确认**:`cn.etetet.core/MongoHelper` 已有 BSON 序列化、`com.etetet.init/Plugins/MongoDB` 已有原生 DLL,仅缺业务层封装 +- [x] 1.4.2 新建 `Packages/cn.etetet.db/` 包目录与 package.json(依赖 cn.etetet.core) +- [x] 1.4.3 定义 `IDBComponent` 接口(Save/Query/QueryByEntityId/Update/Delete) +- [x] 1.4.4 实现 `MongoDBComponent`:用 `IMongoClient` 直连 + `MongoHelper.ToBson/FromBson` 序列化;Collection 命名约定 = `typeof(T).Name` +- [x] 1.4.5 实现 `MemoryDBComponent`(`Dictionary + MongoHelper.Clone`)开发期默认 +- [x] 1.4.6 在 Gate Scene 启动时根据 `StartConfig.DBConnection` 二选一挂载 +- [x] 1.4.7 实现 `DBSaveComponent`(脏标记 + 30 秒定时批量 Save + Player 下线强制 Flush) +- [x] 1.4.8 添加 `Entity.SetDirty()` 扩展方法(写入 dirty set) +- [ ] 1.4.9 单元测试:save → query → update → delete 闭环(内存模式 + Mongo 模式各一遍) + +### 1.5 Player 数据加载(**扩展现有 `cn.etetet.login`,不重写**) +- [x] 1.5.1 **复用确认**:`C2G_LoginGateHandler` 已有,目前只创建内存 Player;扩展点 = 在 `await CreatePlayer(...)` 后插入 DB 加载分支 +- [x] 1.5.2 在 `cn.etetet.login/Hotfix/Server/Gate/C2G_LoginGateHandler.cs` 加入 `await DBHelper.LoadOrCreatePlayer(player, dbComponent)` 调用(约 5-10 行) +- [ ] 1.5.3 新建 `Packages/cn.etetet.config/Datas/InitialResource.xlsx`(首次登录发放 RewardEntry 列表) +- [ ] 1.5.4 实现 `DBHelper.LoadOrCreatePlayer`:DB 查 → 有则反序列化挂 Hero/Bag/Gacha Component;无则新建并按 InitialResource 配置调 `BagComponentSystem.AddItems` +- [x] 1.5.5 注册 Session Dispose 事件(用 ET 现有 EventSystem),玩家离线时 `DBSaveComponent.FlushNow(playerId)` +- [ ] 1.5.6 验证:登录 → DB 中存在 → 重启服务端 → 二次登录数据完整恢复 + +### 1.6 协议工具链 +- [ ] 1.6.1 验证 `ET/Proto/Proto2CS` 工具可正常运行 +- [x] 1.6.2 在 `Packages/cn.etetet.proto/Proto/` 新建占位 `HeroOuter_C_3000.proto`(仅含 1 个 ping 消息测试) +- [ ] 1.6.3 运行 Proto2CS 验证代码生成到 `CodeMode/Model/{Client|Server|ClientServer}/HeroOuter_C_3000.cs` +- [ ] 1.6.4 添加新消息后客户端能正常发送、服务端 Handler 能接收 +- [x] 1.6.5 给所有 C2G/C2R 消息基类加入 `reqId` 字段(uuid 字符串,便于日志全链路追踪) + +### 1.7 Luban 配置工具链(**替代 ExcelExporter**) +- [ ] 1.7.1 下载 luban 工具(dotnet luban),放到 `tools/luban/` +- [x] 1.7.2 新建 `Config/Defines/Root.bean.xml` + `Config/Datas/` 目录结构 +- [x] 1.7.3 编写 `tools/luban/gen.ps1` 一键导出脚本(生成 C# 到 `Packages/cn.etetet.config/Scripts/Model/Share/cfg/`,json 到 `Bundles/Config/`) +- [ ] 1.7.4 写一张示例表 `ItemConfig.xlsx` + `Item.bean.xml`(含 itemId / itemType / name / icon / useType / maxStack / quality / desc / 通用 RewardEntry 字段示例) +- [ ] 1.7.5 跑通 gen.ps1 → 生成代码 + json 文件 +- [ ] 1.7.6 在 YooAsset `AssetBundleCollectorSetting` 加 `Bundles/Config/` 收集规则 +- [x] 1.7.7 新建 `Packages/cn.etetet.config/` 包,实现 `ConfigComponent` 挂在 Scene 上加载 Luban Tables +- [x] 1.7.8 实现 `SceneExtensions.Cfg()` 扩展方法 +- [ ] 1.7.9 服务端 + 客户端 都能调 `scene.Cfg().TbItem.Get(1)` 查到金币配置 +- [x] 1.7.10 编写 Luban 使用文档 `Doc/Luban-Guide.md` + +### 1.8 日志扩展(**基于现成 `Log` + NLog,仅加扩展方法**) +- [x] 1.8.1 **复用确认**:`cn.etetet.core/Log.cs` 已有 `Log.Debug/Info/Warning/Error/Trace`;`cn.etetet.loader` 已集成 NLog 后端;**不重写不替换** +- [x] 1.8.2 新建 `Packages/cn.etetet.logging/` 包(依赖 cn.etetet.core + cn.etetet.loader) +- [x] 1.8.3 在新包内加扩展方法:`Log.Audit(module, action, payload)` / `Log.Slow(module, action, durationMs, ctx)` / `Log.Module(tag, level, msg)`(**全部走 ET 现有 Log 入口,仅做格式化前缀**) +- [x] 1.8.4 提供 `NLog.config.template`:3 个 target 分别输出 `Logs/Business/` / `Logs/Audit/{module}/` / `Logs/Error/`(jsonl 行格式)+ rolling by day +- [x] 1.8.5 提供 `LoggingInstaller`:服务端启动时合并 template 到现有 `Packages/cn.etetet.loader/Scripts/Loader/Server/NLog.config` +- [x] 1.8.6 实现 `Log.Audit` 同步落盘(NLog `` 关闭 + `KeepFileOpen="true"` 强制 flush) +- [x] 1.8.7 约定模块标签前缀:`[Hero]` `[Bag]` `[Gacha]` `[Equip]` `[DB]` `[Login]` `[GM]` `[Net]`(**仅写文档约定,不做编译期校验**) +- [x] 1.8.8 实现 RPC Handler 基类 `RpcMessageHandler`:在 OnRun 前后自动 `Log.Info($"[{module}] {action} in: ..." )` + 耗时计算(**业务 Handler 改继承即可,老 Handler 不强制**) +- [x] 1.8.9 实现 reqId 透传:在 C2G/C2R proto 基类加 `reqId` 字段(**任务 1.6.5 已包含**)+ Handler 基类把 reqId 放到 `AsyncLocal`,扩展 `Log.Info` 自动拼前缀 +- [x] 1.8.10 实现慢操作告警:包装 `IDBComponent` 入口 + `MessageDispatcher`,超阈值(DB 查询 100ms / 写入 200ms / Handler 500ms)调 `Log.Slow` +- [x] 1.8.11 实现错误码降频:同 playerId 同 errorCode 1 分钟内仅首条 + 计数(用 `MemoryCache` 1 分钟 TTL) +- [ ] 1.8.12 用 1.9 注册 `gm SetLogLevel ` 运行时调级(写 `NLogManager.Configuration.LoggingRules`) +- [ ] 1.8.13 实现敏感字段脱敏(配置化 MaskFields,proto attribute 标记或 Json 路径配置) +- [ ] 1.8.14 实现客户端日志桥接 `C2G_ClientLogReport` + 服务端 `Logs/Client/{date}.log`(默认开发期开启,Release 关闭) +- [ ] 1.8.15 日志单测:扩展方法格式正确 / 模块过滤 / 同步落盘验证 + +### 1.9 GM 业务命令接入(**完全复用 `cn.etetet.yiuigm` + `cn.etetet.console`,零框架开发**) +- [x] 1.9.1 **复用确认**: + - `cn.etetet.yiuigm`:已有 `[GM]` 属性 + `IGMCommand` 接口 + 完整 GMPanel UI(参数 6 类型) + - `cn.etetet.console`:已有 `[ConsoleHandler]` 属性 + `IConsoleHandler` 接口 + stdin REPL + - **不新建 GM 框架**,仅按这两个属性写业务 GM 类 +- [x] 1.9.2 新建文档 `Doc/GM-Commands.md`:约定服务端 console 用 `cn.etetet.console`、客户端 panel 用 `cn.etetet.yiuigm`、共享业务实现走 `GMHelper` 静态类 +- [x] 1.9.3 新建 `cn.etetet.gachainvoke` / `cn.etetet.heroinvoke` / `cn.etetet.baginvoke` 跨包调用契约(用 `cn.etetet.yiuiinvoke`),让 GM 类不需要直接依赖业务包 +- [x] 1.9.4 在 `cn.etetet.logging` 提供 `Log.AuditGM(operator, action, targetPlayer, args, success)` 专用方法,所有 GM 业务类调用前自动审计 +- [ ] 1.9.5 用 `#if ENABLE_GM` 包裹所有 `[GM]` 业务类(参考 `cn.etetet.yiuigm/Scripts/HotfixView/Client/GM/GM_Command_Test.cs` 已有的条件编译模式) +- [x] 1.9.6 编写 CI 校验脚本 `tools/ci/check-release-no-gm.ps1`:Release IL 不含 `EGMType` 字符串 +- [x] 1.9.7 编写 CI 校验脚本 `tools/ci/check-rpc-has-gm.ps1`:扫描每个 `*Handler.cs`,确认对应 `GM_*.cs` 或 `[ConsoleHandler]` 类存在 + +### 1.10 错误码体系 +- [x] 1.10.1 新建 `HeroBagErrorCode.cs` 集中定义所有 Hero/Bag/Equipment/Gacha 错误码(数值段位约定:3xxxx Hero、4xxxx Bag、5xxxx Equip、6xxxx Gacha) +- [x] 1.10.2 客户端添加 `ErrorMessageHelper.GetMessage(int errorCode)` 本地化文案;**展示走** `cn.etetet.yiuitips` 的 `TipsHelper.OpenSync(scene, ErrorMessageHelper.GetMessage(code))`,**禁止自写 Toast** +- [x] 1.10.3 服务端 Handler 返回错误码时自动写 L3 错误日志 + +### 1.11 通用 Item 基础设施(**整个系统的基础**) +- [x] 1.11.1 在 Luban `Common.bean.xml` 定义 `RewardEntry { itemId, count }` / `CostEntry { itemId, count }` +- [x] 1.11.2 在 proto `Common_3000.proto` 定义同名 message(与 Luban 一致) +- [x] 1.11.3 在 `BagComponentSystem` 上预留 `AddItems(rewards)` / `RemoveItems(costs)` / `GetItemCount(itemId)` / `HasItems(costs)` 接口签名 +- [ ] 1.11.4 在 Luban `ItemConfig` 中提前定义货币 itemId:1=金币、2=钻石、3=PvP币(写入 Datas/ItemConfig.xlsx 首批条目) +- [x] 1.11.5 写设计文档约定:**全游戏所有可获得资源必须用 itemId 表示,禁止在 Player Entity 上加货币字段** + +### 1.12 GM Web Console(策划/产品可见)— **基于 console + http 包,最小化新增** +- [x] 1.12.1 **复用确认**:服务端 stdin REPL 已现成(`cn.etetet.console`),`cn.etetet.http` 已现成轻量 HTTP Server +- [x] 1.12.2 新建 `tools/gm-web/index.html`(input + fetch + 命令历史)作为静态资源 +- [x] 1.12.3 实现 `[HttpHandler("/gm")] HttpGMHandler`:转发 POST body 到 `ConsoleComponentSystem` 命令分发器(**直接复用现有 console 命令注册**) +- [ ] 1.12.4 简单密码校验(StartConfig 配 `GMWebPassword`) +- [x] 1.12.5 默认仅 Debug 包加载,Release 不监听端口 + +### 1.13 ROK 配置迁移工具链(**新任务:取代手工录入**) +- [x] 1.13.1 **背景沉淀**:在 `Doc/ROK-Config-Source.md` 记录数据源真相:ROK 服务端只有 `Configs.data`(Lua 表,tabtoy 生成);ROK 客户端有加密 SQLite + 205 个 `*Define.cs` DTO;**无 Excel 源文件** +- [x] 1.13.2 拉取姐妹工程 `E:/Game/gmd/Tools/export_hero_json_from_configs_data.py` 作为基础(已验证可解析 Hero/HeroLevel/HeroStar) +- [x] 1.13.3 在 `tools/rok-migrate/export.py` 扩展支持本次需要的表(Hero/HeroLevel/HeroStar/HeroSkill/HeroTalent/Item/Equip/GachaPool/GachaWeight 等) +- [x] 1.13.4 在 `tools/rok-migrate/define_to_bean.py` 写 Roslyn-free 文本扫描器:读 `ROK/Client/.../*Define.cs` → 提取字段名 + 类型 → 输出 Luban `*.bean.xml` +- [x] 1.13.5 在 `tools/rok-migrate/json_to_luban_xlsx.py` 写 JSON → Luban `*.xlsx` 转换器(首行类型 / 二行字段名 / 三行注释 / 数据行) +- [x] 1.13.6 一键脚本 `tools/rok-migrate/run.ps1`:bean.xml + xlsx 一次出齐到 `Config/Defines/` 和 `Config/Datas/` +- [ ] 1.13.7 跑通 Hero + Item 两张表端到端:`run.ps1` → `tools/luban/gen.ps1` → 服务端 `scene.Cfg().TbHero.Get(10001)` 拿到 ROK 真实数据 +- [x] 1.13.8 文档化:每张表的 ROK 源字段 ↔ Luban bean 字段映射表 + +--- + +## 2. P1 - 抽卡 + 通用 Item + 英雄基础(**纯服务端 + GM 验证,零 UI**) + +> 执行顺序调整(2026-05-27 用户确认):**先跳过 2.0 抽卡**,优先做 2.1+ 英雄/背包;抽卡并入“酒馆(Tavern)发奖”语义后再回填 2.0。 + +### 2.0 抽卡系统(**已延期:并入酒馆 Tavern 语义后再落地**) + +#### 2.0.1 抽卡 Luban 配置 +- [ ] 2.0.1.1 在 `Config/Defines/Gacha.bean.xml` 定义 `GachaPool` bean(id / name / singleCost / multiCost / guaranteeCount / guaranteeRare / multiGuaranteeRare / openTime / closeTime) +- [ ] 2.0.1.2 定义 `GachaWeightItem` bean(poolId / entryIndex / reward: RewardEntry / weight / rare) +- [ ] 2.0.1.3 创建 `Config/Datas/GachaPool.xlsx` + `GachaWeight.xlsx`,填充测试卡池:1 个普池(消耗金币)+ 1 个高级池(消耗钻石) +- [ ] 2.0.1.4 跑 gen.ps1 验证代码生成 + json 输出 + +#### 2.0.2 抽卡协议 +- [ ] 2.0.2.1 创建 `Packages/cn.etetet.gacha/Proto/GachaOuter_C_3200.proto` +- [ ] 2.0.2.2 定义 DTO:`GachaPoolStats { poolId, totalCount, guaranteeRemain }` +- [ ] 2.0.2.3 定义 RPC:`C2G_GachaDraw { poolId, drawType }` / `R2C_GachaDraw { error, rewards: repeated RewardEntry, isNewHero: repeated bool }` +- [ ] 2.0.2.4 定义推送:`Gacha_GachaInfo { stats: repeated GachaPoolStats }` +- [ ] 2.0.2.5 运行 Proto2CS 生成代码 + +#### 2.0.3 服务端 GachaComponent +- [ ] 2.0.3.1 创建 `cn.etetet.gacha/Scripts/Model/Server/GachaComponent`(挂在 Player 下) +- [ ] 2.0.3.2 含 `Dictionary PoolStats`,标记 `[MongoElement]` 持久化 +- [ ] 2.0.3.3 实现 `GachaComponentSystem.Draw(poolId, drawType)` 完整算法(消耗→随机→保底→发奖→更新统计→审计日志) +- [ ] 2.0.3.4 实现 `UpdateGuarantee(poolId, rare)` 保底计数更新 +- [ ] 2.0.3.5 实现 `ReplaceWithGuaranteed(rewards, weights, minRare)` 强制保底 +- [ ] 2.0.3.6 实现 `RandomNumberGenerator` 工具类(CSPRNG `System.Security.Cryptography.RandomNumberGenerator`),**禁止使用 UnityEngine.Random** +- [ ] 2.0.3.7 抽卡过程中**严格走** `BagComponentSystem.RemoveItems` 扣消耗 + `AddItems` 发奖励,禁止绕过 + +#### 2.0.4 服务端 Handler + 日志 +- [ ] 2.0.4.1 `C2G_GachaDrawHandler`:继承自带埋点的 RpcMessageHandler 基类(入口/出口自动 L1 日志) +- [ ] 2.0.4.2 抽卡完成后写 L2 审计日志:playerId / poolId / drawType / cost / rewards / guaranteeRemain / reqId +- [ ] 2.0.4.3 推送 R2C_GachaDraw + Gacha_GachaInfo +- [ ] 2.0.4.4 接口限速:单玩家 100ms 最多 1 次请求,超过返回 ERR_RATE_LIMIT 并写 L3 +- [ ] 2.0.4.5 服务端单元测试:模拟 10000 次抽卡,验证概率分布与配置误差 < 1% +- [ ] 2.0.4.6 服务端单元测试:连续 90 抽 1 次不出保底品质 → 第 90 次必出 +- [ ] 2.0.4.7 服务端单元测试:自然抽到保底品质 → 计数重置 + +#### 2.0.5 客户端 GachaComponent +- [ ] 2.0.5.1 创建 `cn.etetet.gacha/Scripts/Model/Client/GachaComponent` +- [ ] 2.0.5.2 实现 `Gacha_GachaInfoHandler` 同步保底进度 +- [ ] 2.0.5.3 发布 `GachaUpdatedEvent` + +#### 2.0.6 抽卡 GM 命令(**P1 唯一验证手段**) +- [ ] 2.0.6.1 `gm Gacha ` 触发抽卡(默认 1 次,可指定 N 次) +- [ ] 2.0.6.2 `gm SimulateGacha ` 服务端模拟 N 次抽卡(不修改真实玩家数据),输出概率统计 +- [ ] 2.0.6.3 `gm ShowGachaStats [playerId]` 输出玩家所有卡池的累计/保底进度 +- [ ] 2.0.6.4 `gm ResetGachaGuarantee [playerId]` 重置保底计数 +- [ ] 2.0.6.5 `gm SetGachaCount [playerId]` 直接设置抽数(测试用) + +#### 2.0.7 抽卡验收(GM + 日志) +- [ ] 2.0.7.1 `gm AddItem 2 100` → `gm Gacha 1 1` → `gm ShowBag` → 钻石 -1,奖励入包 +- [ ] 2.0.7.2 `gm Gacha 1 10` → 收到 10 个奖励,钻石按十连消耗扣除 +- [ ] 2.0.7.3 `gm SetGachaCount 1 89` → `gm Gacha 1 1` 必出保底品质(查审计日志确认) +- [ ] 2.0.7.4 自然抽到保底品质 → `gm ShowGachaStats` 显示保底计数重置为 0 +- [ ] 2.0.7.5 不同卡池保底独立:抽卡池 A 不影响卡池 B 进度 +- [ ] 2.0.7.6 `gm SimulateGacha 1 10000` 概率与配置误差 < 1% +- [ ] 2.0.7.7 `gm Snapshot before` → `gm Gacha 1 10` → 重启 → `gm DumpPlayer` 对比 → 数据完整恢复 +- [ ] 2.0.7.8 篡改 reqId / 客户端伪造字段 → 日志记录但服务端结果不受影响 +- [ ] 2.0.7.9 **端到端验证**:`gm Gacha 1 100` → 抽到英雄碎片 → `gm SummonHero` → 招募成功 + +### 2.1 英雄 Luban 配置 +- [x] 2.1.1 在 `Config/Defines/Hero.bean.xml` 定义 `Hero` bean(含 List skillIds、List talentIds、fragmentItemId、recruitLimit 等) +- [x] 2.1.2 定义 `HeroLevel` / `HeroSkill` / `HeroSkillEffect` / `HeroSkillLevel` bean +- [ ] 2.1.3 在 bean 中使用 `List` / `List` 表达消耗与奖励 +- [x] 2.1.4 填充测试数据:每张表 5 个条目 +- [ ] 2.1.5 跑 gen.ps1 验证客户端/服务端读到 `scene.Cfg().TbHero.Get(heroId)` + +### 2.2 英雄协议 +- [x] 2.2.1 创建 `Packages/cn.etetet.hero/Proto/HeroOuter_C_3000.proto` +- [x] 2.2.2 定义 DTO:`HeroInfo`、`SkillInfo` +- [x] 2.2.3 定义 RPC:`C2G_SummonHero` / `C2G_HeroAddExp` / `C2G_HeroSkillUp` 及响应 +- [x] 2.2.4 定义推送:`Hero_HeroInfo`、`Hero_HeroInfoList` +- [ ] 2.2.5 运行 Proto2CS + +### 2.3 服务端 Hero 实体与持久化 +- [x] 2.3.1 创建 `Hero` Entity(含 level/exp/star/starExp/skills/talents) +- [x] 2.3.2 创建 `HeroComponent`(挂在 Player 下) +- [x] 2.3.3 实现 Awake/Destroy + AddHero / RemoveHero / GetHero / GetAllHeroes +- [x] 2.3.4 给所有持久化字段加 `[MongoElement]` +- [ ] 2.3.5 验证:登录后服务端能加载玩家所有 Hero 实体 + +### 2.4 服务端业务 Handler + 日志 +- [x] 2.4.1 `C2G_SummonHeroHandler`:校验碎片(`BagComponentSystem.HasItems`)+ 不重复 + 上限 → AddHero + `BagComponentSystem.RemoveItems` 扣碎片 +- [x] 2.4.2 `C2G_HeroAddExpHandler`:校验道具 → 累加经验 → 触发升级 → 受星级上限约束 +- [x] 2.4.3 `C2G_HeroSkillUpHandler`:校验道具 → 技能等级 +1 → 受 maxLevel 约束 +- [x] 2.4.4 `HeroNoticeHelper.NotifyHeroUpdate(player, hero)` 封装推送 +- [x] 2.4.5 登录后自动推送 `Hero_HeroInfoList` +- [x] 2.4.6 所有 Handler 走 RpcMessageHandler 基类自动埋点 +- [x] 2.4.7 关键操作(招募、首次满级、首次满技能)写 L2 审计 + +### 2.5 客户端 Hero 缓存 +- [x] 2.5.1 创建客户端 `HeroComponent` 缓存 +- [x] 2.5.2 实现 `Hero_HeroInfoHandler` 增量更新 / `Hero_HeroInfoListHandler` 全量同步 +- [x] 2.5.3 发布 `HeroUpdatedEvent` / `HeroListUpdatedEvent` + +### 2.6 战力计算(客户端 + 服务端共享) +- [x] 2.6.1 在 `cn.etetet.hero/Scripts/Model/Share/HeroPowerCalculator.cs` 实现 GetBaseScore / GetLevelScore / GetSkillScore / GetPower +- [x] 2.6.2 单元测试:相同数据 → 客户端与服务端算出相同战力 + +### 2.7 英雄 GM 命令(**P1 唯一验证手段**) +- [x] 2.7.1 `gm AddHero ` 直接创建英雄 +- [x] 2.7.2 `gm SetHeroLevel ` 设置等级 +- [x] 2.7.3 `gm SetHeroSkill ` 设置技能等级 +- [x] 2.7.4 `gm ShowHeroes [playerId]` 输出 jsonl +- [x] 2.7.5 `gm SummonHero ` 走完整招募流程(验证碎片消耗逻辑) +- [x] 2.7.6 `gm HeroAddExp ` 走完整升级流程 + +### 2.8 P1 验收(GM + 日志,零 UI) +- [ ] 2.8.1 `gm AddItem <碎片itemId> 30` → `gm SummonHero ` → 招募成功 + 碎片扣 30 +- [ ] 2.8.2 `gm HeroAddExp 100` → 等级提升 + 经验书减少 +- [ ] 2.8.3 升级技能 → 技能等级 +1 → 战力变化(用 `gm ShowHeroes` 对比) +- [ ] 2.8.4 重启客户端 + 服务端 → `gm DumpPlayer` 输出对比 → 数据完整恢复 +- [ ] 2.8.5 审计日志:`tail Logs/Audit/Hero/2026-*.jsonl` 含完整招募/升级记录 +- [ ] 2.8.6 错误日志:尝试用 0 碎片招募 → L3 日志含错误码 + reqId + +--- + +## 3. P2 - 背包与装备穿戴(**服务端 + GM 验证,零 UI**) + +### 3.1 背包/装备 Luban 配置 +- [ ] 3.1.1 完善 `Config/Defines/Item.bean.xml`(含 BagItemType 枚举、useType 枚举) +- [ ] 3.1.2 定义 `Equip` bean(含 List 属性、equipItemType 枚举) +- [ ] 3.1.3 填充约 30 个测试道具 + 10 个测试装备到 xlsx +- [ ] 3.1.4 跑 gen.ps1 验证 + +### 3.2 背包/装备协议 +- [x] 3.2.1 创建 `Packages/cn.etetet.bag/Proto/BagOuter_C_3100.proto` +- [x] 3.2.2 定义 DTO:`ItemInfo`、`EquipSlot` +- [x] 3.2.3 定义 RPC:`C2G_UseItem` / `C2G_HeroWearEquip` / `C2G_TakeOffEquip` +- [x] 3.2.4 定义推送:`Item_ItemInfo`、`Item_ItemInfoList`、`Item_RemoveItem` +- [ ] 3.2.5 运行 Proto2CS + +### 3.3 服务端 BagComponent(**通用 Item 入口**) +- [x] 3.3.1 创建 `cn.etetet.bag/Scripts/Model/Server/BagComponent` +- [x] 3.3.2 创建 `ItemUnit` 子 Entity(含 itemIndex / itemId / count / heroId) +- [x] 3.3.3 实现 `AddItem(itemId, count)` - 自动堆叠(非装备类)/ 新建条目(装备类) +- [x] 3.3.4 实现 `RemoveItem(itemId, count)` 与 `RemoveItemByIndex(itemIndex)` +- [x] 3.3.5 实现 `SetHeroIdOnEquip(itemIndex, heroId)` +- [x] 3.3.6 实现 `AddItems(List)` / `RemoveItems(List)` / `HasItems(List)` 批量接口(事务性:任一失败全失败) +- [x] 3.3.7 实现 `GetItemCount(itemId)` 通用查询 +- [x] 3.3.8 所有变更通过 `BagNoticeHelper.NotifyItemChange` 推送 +- [x] 3.3.9 资源大额变动(>1000)写 L2 审计日志 +- [x] 3.3.10 玩家首次登录通过 `InitialResourceConfig` 调 `AddItems` 发初始金币/钻石(**走通用接口**) + +### 3.4 服务端业务 Handler +- [ ] 3.4.1 `C2G_UseItemHandler`:通用 dispatcher,按 ItemConfig.useType 分发 +- [x] 3.4.2 `C2G_HeroWearEquipHandler`:校验装备 + 卸旧穿新 + 同步 Hero + Item +- [x] 3.4.3 `C2G_TakeOffEquipHandler`:清空槽位 + 装备 heroId = 0 +- [x] 3.4.4 登录后推送 `Item_ItemInfoList` + +### 3.5 客户端 BagComponent +- [x] 3.5.1 创建客户端 `BagComponent`(按 6 大类分类缓存) +- [x] 3.5.2 实现 `Item_ItemInfoHandler` / `Item_ItemInfoListHandler` +- [ ] 3.5.3 实现新道具标记本地存储(OctoberStudio SaveManager 集成) +- [x] 3.5.4 发布 `ItemChangedEvent` / `BagUpdatedEvent` + +### 3.6 背包/装备 GM 命令 +- [x] 3.6.1 `gm AddItem ` 通用发放(已在 P0/P1 准备) +- [x] 3.6.2 `gm RemoveItem ` 通用扣除 +- [x] 3.6.3 `gm ShowBag [playerId]` 输出 jsonl +- [x] 3.6.4 `gm BagClear [playerId]` 清空背包(保留绑定装备) +- [x] 3.6.5 `gm HeroWearEquip ` 穿戴 +- [x] 3.6.6 `gm HeroTakeOffEquip ` 卸下 +- [x] 3.6.7 `gm UseItem [count]` 使用道具 + +### 3.7 P2 验收(GM + 日志) +- [ ] 3.7.1 `gm AddItem <装备itemId> 1` → `gm ShowBag` 出现 → 查属性 +- [ ] 3.7.2 `gm HeroWearEquip` → `gm ShowHeroes` 看战力变化 +- [ ] 3.7.3 `gm HeroTakeOffEquip` → 装备回背包未绑定状态 +- [ ] 3.7.4 切换英雄穿同一装备 → 自动从原英雄卸下 +- [ ] 3.7.5 重启验证装备穿戴关系持久化 +- [ ] 3.7.6 通用 Item 测试:金币、钻石、碎片、装备、材料都用 `gm AddItem` 发放,全部走 BagComponent.AddItems + +--- + +## 4. P3 - 高级养成(**服务端 + GM 验证,零 UI**) + +### 4.1 升星系统 +- [ ] 4.1.1 Luban 配置 `HeroStar` / `HeroStarExp` bean +- [ ] 4.1.2 proto `C2G_HeroStarUp` / `R2C_HeroStarUp` +- [ ] 4.1.3 服务端 Handler:扣升星材料 + 幸运计算 + 星级提升 +- [ ] 4.1.4 GM 命令:`gm HeroStarUp ` 走完整流程,`gm SetHeroStar ` 直接设置 +- [ ] 4.1.5 单元测试:连续升星,验证幸运系数概率分布 + +### 4.2 觉醒系统 +- [ ] 4.2.1 给 Hero Entity 增加 `IsAwakening` 字段 +- [ ] 4.2.2 proto `C2G_HeroAwake` +- [ ] 4.2.3 服务端 Handler:校验前 4 技能满级 + 扣觉醒材料 + 解锁第 5 技能 +- [ ] 4.2.4 GM 命令:`gm HeroAwake ` / `gm ForceAwake `(跳过校验,测试用) + +### 4.3 天赋系统 +- [ ] 4.3.1 Luban 配置 `HeroTalent` / `HeroTalentTree` / `HeroTalentMastery` bean +- [ ] 4.3.2 给 Hero Entity 增加 `TalentIndex` + 3 套 `TalentTreeData` +- [ ] 4.3.3 proto `C2G_TalentUp` / `C2G_ChangeTalentIndex` / `C2G_ResetTalent` +- [ ] 4.3.4 服务端 Handler:扣点数 + 节点等级 +1 + 前置依赖校验 +- [ ] 4.3.5 GM 命令:`gm TalentUp ` / `gm TalentReset ` / `gm ChangeTalentIndex ` + +### 4.4 装备锻造 +- [ ] 4.4.1 Luban 配置扩展:`Equip` bean 增加 `List requireMaterials` / `List resolveRewards` +- [ ] 4.4.2 proto `C2G_MakeEquipment` / `C2G_ResolveEquipment` / `C2G_MixMaterial` +- [ ] 4.4.3 服务端 Handler:扣材料 + 创建装备 / 分解装备 + 返还 +- [ ] 4.4.4 GM 命令:`gm MakeEquip ` / `gm ResolveEquip ` / `gm MixMaterial ` + +### 4.5 P3 验收 +- [ ] 4.5.1 `gm HeroStarUp` 重复使英雄升至 5 星 +- [ ] 4.5.2 前 4 技能升满 → `gm HeroAwake` 成功 → 第 5 技能可用 +- [ ] 4.5.3 `gm TalentUp` 学习天赋 → `gm ChangeTalentIndex` 切换天赋页 → `gm TalentReset` 重置 +- [ ] 4.5.4 `gm MakeEquip` 用图纸 + 材料锻造装备 → 装备出现在背包 +- [ ] 4.5.5 `gm ResolveEquip` 分解装备 → 材料返还 +- [ ] 4.5.6 全流程审计日志完整:每个操作都有对应 jsonl 记录 + +--- + +## 5. P4 - UI 集中实现(**深度复用 YIUI 全套子包,零造轮**) + +> **前提**:P0-P3 服务端业务已稳定,所有协议、错误码、推送已定型。 +> +> **强制约束**(详见 `Doc/ET-Packages-Audit.md` 中 "YIUI 框架" 一节): +> - 面板打开/关闭**只用** `scene.YIUIRoot().OpenPanelAsync()`,**禁止自建 PanelHelper** +> - 长列表**只用** `YIUILoopScrollChild.SetDataRefresh`,**禁止自写 ScrollRect 池化** +> - 按钮事件**只用** `UIEventBind*` + `[YIUIInvoke]`,**禁止 `Button.onClick.AddListener`** +> - 数据 → UI 刷新**只用** `u_Data*.SetValue` + `UIDataBind*`,**禁止手写 Refresh 样板** +> - 弹窗**只用** `TipsHelper.OpenWait/OpenSync`,**禁止自写 ConfirmDialog/Toast** +> - 红点**只在叶子节点 `SetCount`**,父节点 DAG 自动汇总 + +### 5.1 YIUI 通用组件准备 +- [ ] 5.1.1 复用现有 `CommonHeader` + `YIUICloseCommon`(不再造顶栏/关闭) +- [ ] 5.1.2 创建 `BagItemUICommon.prefab`:图标用 `UIDataBindImage` + `u_DataIcon`、数量用 `UIDataBindText` + `u_DataCount`、品质边框用 `UIDataBindSprite` + `u_DataQualityFrame`、新道具标记用 `UIDataBindActive` + `u_DataNew`、不可用用 `UIDataBindGray` + `u_DataEnabled`(`cn.etetet.yiuieffect`) +- [ ] 5.1.3 `BagItemUICommonComponentSystem.Show(itemConfigId, count, options)` 入口:内部全走 `u_Data*.SetValue`,**禁止手写 `Image.sprite = xxx`** +- [ ] 5.1.4 多尺寸预设:`BagItemUICommon_Small/Mid/Large.prefab` 三个变体(仅 RectTransform 不同),共享同一 Component 代码 +- [ ] 5.1.5 点击事件统一发布 `BagItemClickedEvent`(ET EventSystem)让宿主 Panel 处理 + +### 5.2 抽卡 UI +- [ ] 5.2.1 用 YIUI 自动化工具生成 `GachaPanel` Component + System +- [ ] 5.2.2 Prefab 用 `UIEventBindClick` + `[YIUIInvoke]` 接线(单抽/十连/切池按钮);按钮可用性用 `UIDataBindGray` + `u_DataCanDraw`(钻石不足自动灰显) +- [ ] 5.2.3 保底进度用 `UIDataBindSlider` + `u_DataGuaranteeProgress` + `UIDataBindText` + `u_DataGuaranteeText` +- [ ] 5.2.4 卡池列表用 `cn.etetet.yiuiloopscrollrectasync` 的 `YIUILoopScrollChild.SetDataRefresh(poolList)` +- [ ] 5.2.5 RPC 期间挡点击:`scene.YIUIMgr().BanLayerOptionForever()` 开 → 收响应 close 关 +- [ ] 5.2.6 抽卡 CD 用 `CountDownMgr`(`cn.etetet.yiuiframework`),**禁止自建 Timer** +- [ ] 5.2.7 `GachaResultPanel`:10 个奖励槽用 `BagItemUICommon` 渲染;新英雄高亮用 `UIEffect` 发光 + `UIParticle` 粒子(`cn.etetet.yiuieffect`) +- [ ] 5.2.8 "确认消耗"用 `TipsHelper.OpenWait(scene, "消耗 X 钻石?")` 模态等待 +- [ ] 5.2.9 抽卡完成飘字用 `TipsHelper.OpenSync(scene, "招募成功")` + +### 5.3 英雄 UI +- [ ] 5.3.1 `HeroListPanel` 长列表用 `YIUILoopScrollChild.SetDataRefresh(heroList)` + `ReRenderer` 增量刷新 +- [ ] 5.3.2 三段分组(已拥有 / 可召唤 / 未集齐):在数据层 IList 中插入 Header Item,**不改 Loop 框架** +- [ ] 5.3.3 `HeroListItem` 字段(等级/星级/战力/碎片数)全用 `u_Data*` + `UIDataBind*` +- [ ] 5.3.4 SortType 切换:`UIEventBindToggleGroup` + 重排 IList → `Loop.ReRenderer()` +- [ ] 5.3.5 `HeroDetailPanel`:3D 模型用 `YIUI3DDisplayChild.ShowAsync("HeroModel_10001")`(`cn.etetet.yiui3ddisplay`),**禁止自建 RT/Camera** +- [ ] 5.3.6 升级/升星/技能/觉醒 按钮 → `UIEventBindClick` + `[YIUIInvoke]` → RPC +- [ ] 5.3.7 召唤按钮带"消耗 N 碎片"模态:`TipsHelper.OpenWait` + +### 5.4 背包 UI +- [ ] 5.4.1 `BagPanel` 6 Tab + `YIUILoopScrollChild`(每 Tab 复用同一 Loop,切 Tab 仅换 IList) +- [ ] 5.4.2 道具 Tooltip:自建 `BagItemTooltipView`(**tips 包无现成 Tooltip View**),容器走 `TipsHelper.Open(scene, itemId)` 承载 +- [ ] 5.4.3 Tab 切换:`UIEventBindToggleGroup` 自动响应 + `Loop.SetDataRefresh(filteredList)` +- [ ] 5.4.4 点击 Item → 发布 `BagItemClickedEvent` → Panel 弹 Tooltip → "使用 / 穿戴"按钮走 RPC +- [ ] 5.4.5 操作确认用 `TipsHelper.OpenWait`;操作结果飘字用 `TipsHelper.OpenSync` + +### 5.5 装备穿戴 UI +- [ ] 5.5.1 `HeroDetailPanel` 8 装备槽:每个槽 = `BagItemUICommon` + `UIEventBindClick` +- [ ] 5.5.2 `EquipSelectPanel`:`YIUILoopScrollChild` 列表 + 按 SubType 过滤 IList +- [ ] 5.5.3 点击空槽位 → `OpenPanelParamAsync(slotIndex, subType)` +- [ ] 5.5.4 点击已穿 → `TipsHelper.OpenWait` "卸下 / 替换 / 取消"三选一 + +### 5.6 天赋 UI +- [ ] 5.6.1 `HeroTalentPanel` 天赋树可视化:**YIUI 无图编辑器组件**,需自绘(普通 RectTransform 拼节点 + LineRenderer 连线) +- [ ] 5.6.2 节点状态用 `UIDataBindActive`(已点亮)+ `UIDataBindGray`(不可点) +- [ ] 5.6.3 切换 / 重置交互:`UIEventBindClick` + `[YIUIInvoke]` + 二次确认走 `TipsHelper.OpenWait` + +### 5.7 锻造 UI +- [ ] 5.7.1 `EquipForgePanel` 3 Tab:用 `UIEventBindToggleGroup` +- [ ] 5.7.2 材料 / 产物用 `BagItemUICommon` 渲染 +- [ ] 5.7.3 锻造按钮:足够材料时正常,不足时 `UIDataBindGray` 灰显 +- [ ] 5.7.4 成功后 `TipsHelper.OpenSync` 飘字 + `UIParticle` 粒子动画 + +### 5.8 红点系统(**完全复用 `cn.etetet.yiuireddot`**) +- [ ] 5.8.1 扩展 `ERedDotKeyType` 枚举:HeroCanUpgrade / HeroCanStarUp / HeroCanAwake / TalentHasPoint / BagHasNew / BagHasUsable / GachaHasFree / EquipCanForge 等 +- [ ] 5.8.2 在 `UIRedDotConfigDAG` 编辑器配置父子关系:HeroEntry = 4 个 Hero* 之和;BagEntry = Bag* 之和;MainMenu = HeroEntry + BagEntry + GachaEntry + EquipForge +- [ ] 5.8.3 业务侧**只写叶子**:`RedDotMgr.Inst.SetCount(ERedDotKeyType.HeroCanUpgrade, count)`,**禁止给中间节点 SetCount** +- [ ] 5.8.4 Panel / Button 上挂 `RedDotBind` 选 Key,**不写代码**自动显隐 +- [ ] 5.8.5 红点检查逻辑统一封装到 `RedDotChecker.RefreshAll()`,业务变化时调用 + +### 5.9 UI 与 OctoberStudio 集成 +- [ ] 5.9.1 在 OctoberStudio Main Menu 添加"英雄"/"背包"/"抽卡"三个按钮(普通 Button,因 OctoberStudio 不是 YIUI 体系) +- [ ] 5.9.2 按钮 OnClick → `scene.YIUIRoot().OpenPanelAsync()`(**不写 PanelHelper**) +- [ ] 5.9.3 处理"未联网"状态:按钮 OnClick 前检查 Session → 未连接时 `TipsHelper.OpenSync(scene, "请先登录")` + +### 5.10 P4 验收 +- [ ] 5.10.1 全功能 UI 走查:抽卡 → 招募 → 升级 → 穿戴 → 升星 → 觉醒 → 天赋 → 锻造 +- [ ] 5.10.2 UI 操作与 GM 命令产生相同的业务结果 +- [ ] 5.10.3 性能:背包列表 1000+ 道具滑动 60 FPS(`YIUILoopScrollChild` 池化保证) +- [ ] 5.10.4 红点正确显示与清除(叶子 SetCount → 父节点自动汇总) +- [ ] 5.10.5 **代码扫描验证零造轮**(CI 脚本 `tools/ci/check-no-ui-wheel.ps1` 校验业务包内不含 `Button.onClick.AddListener` / `Image.sprite =` / `Resources.Load` / 自写 ScrollRect 池化) + +--- + +## 6. 跨阶段任务 + +### 6.1 单元测试与集成测试 +- [ ] 6.1.1 服务端 Handler 单元测试覆盖率 ≥ 60% +- [ ] 6.1.2 客户端 Component 缓存逻辑测试 +- [ ] 6.1.3 协议序列化往返测试 +- [ ] 6.1.4 DBComponent 持久化测试(内存模式 + MongoDB 模式各跑一遍) +- [ ] 6.1.5 端到端测试:登录 → 抽卡 → 招募 → 升级 → 穿戴 → 退出 → 重新登录验证 +- [ ] 6.1.6 抽卡概率回归测试:10000 次抽卡,各品质实际概率与配置误差 < 1% +- [ ] 6.1.7 抽卡保底回归测试:200 抽,保底触发次数与理论值匹配 + +### 6.2 文档 +- [x] 6.2.1 `Doc/Luban-Guide.md` Luban 使用文档 +- [x] 6.2.2 `Doc/GM-Commands.md` 所有 GM 命令列表 + 用法 +- [x] 6.2.3 `Doc/Logging-Guide.md` 日志体系说明 + grep/jq 示例 +- [x] 6.2.4 `Doc/Hero-System-Guide.md` 业务文档 +- [x] 6.2.5 `Doc/Bag-System-Guide.md` +- [x] 6.2.6 `Doc/Server-Setup.md`(如何起服 + 起 mongo + 编译) +- [x] 6.2.7 在工程根 `README.md` 中添加新业务模块入口指引 + +### 6.3 性能与监控 +- [ ] 6.3.1 验证慢操作告警实际生效(构造 100ms+ 的 DB 操作触发 L3 日志) +- [ ] 6.3.2 验证单服务器 100 个模拟玩家并发登录 + 业务操作不崩溃 +- [ ] 6.3.3 客户端背包列表 1000+ 道具滑动 60 FPS(LoopScrollRect 确保) +- [ ] 6.3.4 日志写盘性能测试:1000 QPS 业务日志 + 100 QPS 审计日志稳定 + +### 6.4 CI 校验 +- [x] 6.4.1 `tools/ci/check-release-no-gm.ps1` Release IL 不含 GM 代码 +- [x] 6.4.2 `tools/ci/check-rpc-has-gm.ps1` 每个 RPC Handler 有对应 GM 命令 +- [x] 6.4.3 `tools/ci/check-logging.ps1` 每个 Handler 必含日志埋点(继承 RpcMessageHandler 或显式声明) +- [x] 6.4.4 `tools/ci/check-no-ui-wheel.ps1` 业务包 `cn.etetet.hero/bag/gacha` 内禁止出现 `Button.onClick.AddListener` / `Image.sprite =` / `Resources.Load` / 手写 `ScrollRect` 池化等 YIUI 已覆盖能力的造轮代码 +- [x] 6.4.5 `openspec validate --strict` 在 CI 中跑 + +### 6.5 上线前检查 +- [ ] 6.5.1 所有 GM 命令仅 Debug 包启用,Release 包剥离 + IL 校验通过 +- [x] 6.5.2 错误码本地化覆盖(中文 + 备 EN) +- [x] 6.5.3 数据备份策略(MongoDB 每日自动备份脚本) +- [x] 6.5.4 服务端日志切割与归档(L2 转 OSS) +- [ ] 6.5.5 HybridCLR 在 IL2CPP 包下验证:`ET/HybridCLR/Generate/All` +- [ ] 6.5.6 概率审计:所有卡池跑 SimulateGacha 10000 次,截图保留