feat: initial XtGameKit (hero/bag/tavern/config/db/invoke packages + design docs + et-framework-dev skill)

This commit is contained in:
cursor 2026-05-28 19:47:52 +08:00
commit 910477d015
286 changed files with 12919 additions and 0 deletions

View File

@ -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/<Name>.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>` 的 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.<name>` **必须**在子目录放 `AssemblyReference.asmref`,否则代码不会参与编译(一个隐藏到不报错的"代码静默失踪"陷阱)。
```
cn.etetet.mypkg/
├── Ignore.ET.MyPkg.asmdef # 占位 asmdefIGNORE 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.<ProgramAssembly>" }`
**临时屏蔽整个包**:把该包 `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
```
<Name>_<C|S|CS>_<startOpcode>.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. 写完 .protoUnity 菜单 ET → Proto → Proto2CS
2. 生成结果在 cn.etetet.proto/CodeMode/Model/{Client,Server,ClientServer}/<Name>.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\<T\> 使用规则
`EntityRef<T>` 是 ET 的弱引用包装结构体,**编译器不会自动 unwrap**。
```csharp
// ❌ 编译错误 CS1061
if (bag.ItemMap.TryGetValue(idx, out EntityRef<ItemUnit> unit))
{
unit.ItemId // EntityRef<ItemUnit> 没有 ItemId 字段
unit.Dispose() // 也没有 Dispose
}
// ✅ 用隐式转换拆包
if (bag.ItemMap.TryGetValue(idx, out EntityRef<ItemUnit> unitRef))
{
ItemUnit unit = unitRef; // 触发 implicit operator T(EntityRef<T> 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/<Pkg>_C_<opcode>.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<T> u); u.Field`** → CS1061。先 `T t = u;`
8. **Helper 之间互调形成环** → ET0013。让低层 helper 内联自己的小逻辑,不调上层。
9. **登录后 GM 面板没自定义命令** → 先查 `cn.etetet.<pkg>/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 文件**必须**位于 `<pkg>/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 一键流程

View File

@ -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<TReq, TResp> : MessageSessionHandler<TReq, TResp>
{
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<TReq, TResp> : MessageSessionHandler<TReq, TResp>
{
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<long, EntityRef<ItemUnit>> ItemMap = new();
}
// ❌ 触发 ET0002
public static class BagService
{
public static bool TryUseItem(Player player, long idx)
{
var bag = player.GetComponent<BagComponent>();
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>` 的 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/<RuleName>.cs
cn.etetet.sourcegenerator/DotNet~/ET.SourceGenerator/Config/DiagnosticIds.cs
```

View File

@ -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.<pkg>` 包**不能**自己定义 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.<Name>.asmdef 内容(固定)
```json
{
"name": "Ignore.ET.MyPkg",
"rootNamespace": "",
"references": [],
"defineConstraints": ["IGNORE"],
"autoReferenced": true
}
```
`defineConstraints: ["IGNORE"]` 让这个 asmdef 永远不参与编译 — 它的存在只是为了占位(避免 Unity 找不到 .meta真正生效的是各子目录里的 asmref。
---
## 哪个代码放哪一层(决策表)
| 你想写的代码 | 放哪 | 关键约束 |
|---|---|---|
| `class Hero : Entity, IAwake<int>` | `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<long, HeroInfo> }` | `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<C2G_X, R2C_X>` 子类 | `Scripts/Hotfix/Server/C2G_XHandler.cs` | **不能**有非 const 字段/属性 |
| `MessageHandler<Scene, X>` 客户端 | `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<Scene, YIUIEventInitializeAfter>` handler | `Scripts/HotfixView/Client/` | |
---
## 新建一个业务包:完整步骤
```
Task Progress:
- [ ] 1. 复制 cn.etetet.statesync 整个目录为 cn.etetet.<newpkg>
- [ ] 2. 删除 Runtime/ 下的 ET.*.asmdef 文件(这些是核心程序集,不能在新包里)
- [ ] 3. 改 package.json 的 name / displayName / dependencies
- [ ] 4. 修改 Ignore.ET.Statesync.asmdef → Ignore.ET.<NewPkg>.asmdefname 字段同步改
- [ ] 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.<pkg>/**/*.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.<pkg>invoke` 包模式)。
- 不允许 `Hotfix/Client` 调用 `Hotfix/Server` 代码(编译会过但语义错)。共享逻辑放 `Hotfix/Share`

View File

@ -0,0 +1,216 @@
# Proto 完整工作流
## 1. 命名与放置
`.proto` 文件必须放在某个 `cn.etetet.<pkg>/Proto/` 目录下,且文件名遵守:
```
<Module>_<Direction>_<StartOpcode>.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<SkillInfo>
}
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/<Name>.cs`
- `cn.etetet.proto/CodeMode/Model/Server/<Name>.cs`
- `cn.etetet.proto/CodeMode/Model/ClientServer/<Name>.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<ClientSenderComponent>()
.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<C2G_SummonHero, R2C_SummonHero>
{
protected override async ETTask OnRun(
Session session, C2G_SummonHero request, R2C_SummonHero response)
{
Player player = session.GetComponent<SessionPlayerComponent>()?.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<Scene, Hero_HeroInfo>
{
protected override async ETTask Run(Scene scene, Hero_HeroInfo message)
{
var comp = scene.GetComponent<HeroComponent>() ?? scene.AddComponent<HeroComponent>();
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<TReq, TResp>` 类型约束失败 → 编译报错。
3. **缺 `// ResponseType R2C_X`**:客户端 `await session.Call(req)` 无法推断响应类型。
4. **文件名 opcode 重复**:和别的 proto 撞 opcode → 运行时崩。规范分配规则见上面表格。
5. **手写 `new HeroInfo {...}`** → ET0031。改 `HeroInfo.Create()`
6. **修改 .proto 字段顺序 / 类型** → MemoryPack 序列化兼容性问题。线上版本要么不改,要么走版本兼容方案。
---
## 6. 一键自检 checklist
写完一个新 .proto 后:
```
- [ ] 文件名格式 <Module>_<Direction>_<Opcode>.proto
- [ ] Opcode 不与其他 .proto 冲突grep "_C_<同段号>_"
- [ ] RPC 消息有 // ResponseType
- [ ] 请求带 // ISessionRequest 或 // IRequest
- [ ] 响应带 // ISessionResponse 或 // IResponse
- [ ] 通知带 // IMessage
- [ ] 跑了 ET/Proto/Proto2CS
- [ ] 检查 cn.etetet.proto/CodeMode/Model/ClientServer/<Name>.cs 已生成
- [ ] Assets/Refresh 让 Unity 索引到新文件
- [ ] recompile_scripts 验证 0 错误
```

36
.gitignore vendored Normal file
View File

@ -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

18
Doc/Bag-System-Guide.md Normal file
View File

@ -0,0 +1,18 @@
# Bag System Guide
## 模块目标
- 统一 Item 存储(货币、碎片、装备、材料都走 itemId
- 提供增减、查询、批量发奖/扣费能力
## 核心接口
- `AddItems(List<RewardEntry>)`
- `RemoveItems(List<CostEntry>)`
- `GetItemCount(itemId)`
- `HasItems(List<CostEntry>)`
## 关键约束
- 任何资源变动都必须走 Bag 接口
- 禁止在 Player 上新增独立货币字段

226
Doc/ET-Packages-Audit.md Normal file
View File

@ -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 DemoUnit 同步/跨 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<T>/Query<T>/Update<T>/Delete<T>` 业务封装 |
| `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<T>()` / `OpenPanelParamAsync<T>(...)` / `OpenPanelWaitAsync<T>(...)`(模态等待)
- 数据驱动Prefab 挂 `UIDataBind*` 组件,代码改 `u_DataXxx.SetValue(...)` 自动刷新 UI
- 事件驱动Prefab 挂 `UIEventBind*` + `[YIUIInvoke(const)]` 静态方法接线,**无需 Button.onClick.AddListener**
- 跨模块解耦:`[YIUIInvokeSystem("Key")]` + `YIUIInvokeHandler<T>` + `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<T>()`,与 `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<RewardEntry>` 通用奖励结构 → ExcelExporter 无法表达 bean 数组
- `List<AttrEntry>` 装备属性配对 → 难表达
- 卡池权重表多列关联 → 无外键校验
- 天赋树前置依赖 → 无引用校验
**结论****新业务表用 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 / numericOctoberStudio 走自己的局内战斗)
- statesync / yiuistatesyncMMO Demo仅参考代码风格
- ui旧 UI已被 YIUI 替代)
- demoresDemo 美术资源)
```
---
## 设计原则(基于这份清单的检查清单)
每次新功能落地前的 3 问:
1. **现有 ET 包是否有?** → 查上表
2. **如果有但能力不全,是扩展还是重写?****优先扩展**,禁止重写已有内核
3. **如果没有,应该建独立业务包还是塞进现有包?** → 业务功能建新包 `cn.etetet.xxx`**禁止塞进** `core` / `login` 等基础包
每次重大决策都需要在 `design.md` 的 D 决策表中明确"复用了哪些已有包 + 扩展点 + 新增点"。
---
## 修订记录
| 日期 | 版本 | 说明 |
|---|---|---|
| 2026-05-27 | v1.0 | 初版38 包全量调研 |

39
Doc/GM-Commands.md Normal file
View File

@ -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`

23
Doc/Hero-System-Guide.md Normal file
View File

@ -0,0 +1,23 @@
# Hero System Guide
## 模块目标
- 提供英雄创建、升级、升星、技能、觉醒、天赋核心流程
- 服务端权威,客户端仅缓存与展示
## 核心实体
- `Player`
- `HeroComponent`
- `Hero`(子实体)
## 关键接口
- `C2G_SummonHero`
- `C2G_HeroAddExp`
- `C2G_HeroSkillUp`
## 校验策略
- 所有消耗先走 `BagComponentSystem.HasItems/RemoveItems`
- 所有失败返回统一错误码并记 L3

19
Doc/Item-Design-Rule.md Normal file
View File

@ -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 }`

25
Doc/Logging-Guide.md Normal file
View File

@ -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)`

25
Doc/Luban-Guide.md Normal file
View File

@ -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`,再补数据文件。

848
Doc/ROK-Bag-Spec.md Normal file
View File

@ -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 BuffTab3 |
| `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-5HEAD 类型在 `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 | 当前穿戴此装备的英雄 ID0 = 未穿戴) |
### 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<Int64, ItemInfoEntity> Items; // itemIndex → 道具实体
private List<ItemInfoEntity> m_itemList; // 顺序列表
private Dictionary<Int64, int> m_itemNumMap; // itemId → 累计数量(懒计算,-1 表示需重算)
private Dictionary<long, ItemInfo> m_materialItemInfos; // 装备材料分组
private Dictionary<long, ItemInfo> m_equipItemInfos; // 装备本体分组
private Dictionary<int, Dictionary<long, long>> m_reddotRecord; // type → (itemIndex → 新增数量)
private Dictionary<int, long> 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\<int\> | 获得途径列表,关联 `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` dictitemIndex → 新建/更新后的 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<int, IItemUseHandler>` 注册机制 | 比 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`YIUI4 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/elseifitemFunction 枚举) | C# `IItemUseHandler` 注册(每个 itemFunction 一个 handler 类) |
| **新道具红点** | 客户端 PlayerPrefs | 服务端 Mongo + 客户端缓存 |
| **配置表** | SQLite + 自研 Define | Luban 生成 |
| **协议** | sproto | protobuf3 + MemoryPack |
| **持久化** | 服务端 Redis + 落 SQL | MongoIDBComponent |
| **装备穿戴** | item.heroId 字段 | 同 |
| **奖励合并** | `mergeReward` Lua | C# 通用 RewardEntry 列表 |
### 9.3 设计陷阱(迁移时易踩)
1. **不要直接搬"双轨制"**Survivors 设计目标是统一 Item。金币/钻石如果也作为 Item则没有 `Item_ItemChangeResource` 这个 RPC相关 `ItemType.RESOURCE` 子类型大量道具变成"普通礼包道具"
2. **`exclusive` 字段**ROK 是装备专属英雄 IDSurvivors 设计中可能不需要装备绑定,可去除
3. **`uniqueIndex` 字段**ROK 中预留未用Survivors 直接删掉
4. **`maxStack` 缺失**:必须新增(防溢出 int 攻击)
5. **`itemFunction` 拆分**ROK 的 35 个值分散且历史包袱重Survivors 应只实现 P1-P3 核心 5-8 个
6. **`itemId` 编码约定**ROK 用 `itemId / 100000000` 划分 typeSurvivors 应改为 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`(子 EntityitemIndex 主键) | `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 | 装备图纸(如果做装备系统) |
---
## 附录 AROK 关键源代码位置索引
| 内容 | 路径 |
|---|---|
| 服务端 ItemLogicaddItem/delItem/giveReward/getItemPackage | `Server/server/game_server/logic/lualib/ItemLogic.lua` |
| 服务端 Item proxyItemUse/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.sprotoItemInfo / RewardInfo / RewardItem | `Server/common/protocol/Common.sproto` L308 / L381 / L362 |
| Protocol.sprotoItem_ItemUse 852 / Item_ItemChangeResource 851 / Item_ItemInfo 30101 | `Server/common/protocol/Protocol.sproto` |
| 服务端 s_Config 初始道具配置initialItemType/Num | `Server/common/config/gen/Configs.data` L13376id=0 行) |
| 客户端 BagProxy增量同步/红点/装备/材料) | `Client/Assets/Scripts/Hotfix/MVC/Proxy/BagProxy.cs` |
| 客户端 BagMediator5 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.csItem_ItemUse 响应处理) | `Client/Assets/Scripts/Hotfix/MVC/CMD/PlayerCmd.cs` L38-128 |
| 客户端 ItemDefine25 字段) | `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

18
Doc/ROK-Config-Source.md Normal file
View File

@ -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
## 结论
本项目采用“脚本迁移 + 自动生成”,不做手工录入。

509
Doc/ROK-Equip-Spec.md Normal file
View File

@ -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 | 当前穿戴该装备的英雄 ID0 = 未穿 |
| `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\<int\> | 锻造所需材料 itemId 列表 |
| `makeMaterialNum` | List\<int\> | 对应的数量(与 makeMaterial 等长) |
| `costGold` | int | 锻造消耗金币 |
| `decomposeMaterial` | List\<int\> | 分解返还材料 itemId 列表 |
| `decomposeMaterialNum` | List\<int\> | 对应数量 |
| `att` | List\<int\> | 装备属性 ID 列表(指向 EquipAttDefine.ID |
| `attAddEx` | List\<int\> | 服务端属性数值(**千分比**整数) |
| `attAdd` | List\<int\> | 客户端展示用属性数值 |
| `compose` | int | 套装 ID0 = 非套装),指向 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\<int\> | 满 2 件触发的属性 ID 列表 |
| `compose2Add` | List\<int\> | 客户端展示数值 |
| `compose2AddEx` | List\<int\> | 服务端千分比数值 |
| `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=trueUI 突出显示
- 玩家确认 → 调 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` ↔ EquipDefinefield 列表见 §3.1
- `EquipAttConfig.xlsx` ↔ EquipAttDefine
- `EquipComposeConfig.xlsx` ↔ EquipComposeDefine
- `EquipMaterialConfig.xlsx` ↔ EquipMaterialDefine
- `EquipMaterialGroupConfig.xlsx` ↔ EquipMaterialGroupDefine
注意 `List<int>` 字段在 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 subTypeaccessories 2 槽共享)" |
| "锻造概率(必中 / 随机)" | 锻造必中,**专属** 50% 概率(在预检 RPC 里) | 区分"装备产出"必中 vs "专属属性"50% |
| "MakeEquipment 单 RPC" | 实际是 **CheckMakeEquip + MakeEquipment 两个 RPC** | tasks 3.4 加 `C2G_CheckMakeEquip` |
| "EquipDefine 含 List<AttrEntry>" | 实际是 `att[] + attAddEx[]` 两个 List 平行 | bean schema 按平行 List 设计 |
| "套装效果 = 4 件套" | 实际是 2/4/6/8 件**逐级叠加** | bean schema 拆 4 段 |

1441
Doc/ROK-Hero-Spec.md Normal file

File diff suppressed because it is too large Load Diff

575
Doc/ROK-HeroAcquire-Spec.md Normal file
View File

@ -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-700Tavern 在 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 = 通用雕像 itemId0 = 该英雄不支持兑换
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` 该英雄碎片 ×N1: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; // 通用雕像兑换 itemId0=不可
public int getLimit; // 王国开服天数解锁门槛
public List<int> skill; // 技能 ID 列表
public List<int> 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. 缺点:与用户已表达的"还需要抽卡验证产出"诉求冲突
**方案 B1: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` 等价 RPCC2G_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_*) |

View File

@ -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`
## 说明
- 本文档为迁移首版映射,后续以实际导出脚本和策划表校验结果迭代。

376
Doc/ROK-Spec-Index.md Normal file
View File

@ -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 subTypeaccessories 双槽共享,需修正
- ⚠️ 之前漏写 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` map1/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 subTypeaccessories 双槽共享) | 修正映射 |
| 锻造 | 单 RPC | 双 RPCCheckMakeEquip + 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 Hero6029-6031 BuildEquip
- [ ] 货币走 Role 字段 vs Item分得清吗
- [ ] 装备槽位映射对吗8 槽 / 6 subTypeaccessories 双槽)
- [ ] 配置表字段全部翻译到 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<RewardEntry> rewards)
RemoveItems(List<CostEntry> costs)
HasItems(List<CostEntry> costs)
GetItemCount(itemId)
```
#### 抽象 2ItemPackage开盲盒 / 抽卡 / 礼包统一模型)
```
输入:消耗 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 入口和配置不同。
#### 抽象 3Item → 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/ # 各类道具使用 HandlerIItemUseHandler
│ ├── 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 双轨制 | 全部 ItemitemId 段位区分) | 接口统一,避免每种货币都写一遍 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 部分

19
Doc/Server-Setup.md Normal file
View File

@ -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 错误日志

View File

@ -0,0 +1,16 @@
{
"name": "Ignore.ET.Bag",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [
"IGNORE"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3241baae0a5b2f64b8b5b7550a29a238
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dc1c586b2b7645d4884be6c841a6c749
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 61af3e5634c96be48a9a899ab5ecac39
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 75f400aa3a023c2418b2055e8e7d1788
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e1995c2e7bd5f5047a5a4071d92f93e6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6bd0a469661c562428f97a8f3c739d87
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Hotfix" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9be6c6e10d671074f97616f40c2ebf0d
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
namespace ET.Client
{
[MessageHandler(SceneType.Main)]
[FriendOf(typeof(BagComponent))]
public class Item_ItemInfoHandler : MessageHandler<Scene, Item_ItemInfo>
{
protected override async ETTask Run(Scene scene, Item_ItemInfo message)
{
BagComponent comp = scene.GetComponent<BagComponent>() ?? scene.AddComponent<BagComponent>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5860a8d6e966d844682e26d1414d30a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,21 @@
namespace ET.Client
{
[MessageHandler(SceneType.Main)]
[FriendOf(typeof(BagComponent))]
public class Item_ItemInfoListHandler : MessageHandler<Scene, Item_ItemInfoList>
{
protected override async ETTask Run(Scene scene, Item_ItemInfoList message)
{
BagComponent comp = scene.GetComponent<BagComponent>() ?? scene.AddComponent<BagComponent>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4d780f0b2ef6a3e4ca5c7d481ed155f7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
namespace ET.Client
{
[MessageHandler(SceneType.Main)]
[FriendOf(typeof(BagComponent))]
public class Item_RemoveItemHandler : MessageHandler<Scene, Item_RemoveItem>
{
protected override async ETTask Run(Scene scene, Item_RemoveItem message)
{
BagComponent comp = scene.GetComponent<BagComponent>() ?? scene.AddComponent<BagComponent>();
comp.ItemMap.Remove(message.itemIndex);
comp.NewItemSet.Remove(message.itemIndex);
EventSystem.Instance.Publish(scene, new ItemChangedEvent { ItemIndex = message.itemIndex });
await ETTask.CompletedTask;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d74a1095280d0ab48984fe73d9766a79
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 73d4efcd0d7572c4eb4e54e519825d47
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Hotfix" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e853a52d3eddc454c944b92c500f7e21
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<Player>(), stack);
if (count > 1000)
{
LogExtensions.Audit("Bag", "LargeResourceAdd", $"{{\"playerId\":{self.GetParent<Player>()?.Id ?? 0},\"itemId\":{itemId},\"count\":{count}}}");
}
return stack;
}
}
long itemIndex = self.NextItemIndex++;
ItemUnit unit = self.AddChildWithId<ItemUnit, long, int, long, long>(itemIndex, itemIndex, itemId, count, 0);
self.ItemMap[itemIndex] = unit;
BagNoticeHelper.NotifyItemChange(self.GetParent<Player>(), unit);
if (count > 1000)
{
LogExtensions.Audit("Bag", "LargeResourceAdd", $"{{\"playerId\":{self.GetParent<Player>()?.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<ItemUnit> 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<Player>(), unit);
}
}
return remain == 0;
}
public static bool RemoveItemByIndex(this BagComponent self, long itemIndex)
{
if (!self.ItemMap.TryGetValue(itemIndex, out EntityRef<ItemUnit> unitRef))
{
return false;
}
ItemUnit unit = unitRef;
self.ItemMap.Remove(itemIndex);
BagNoticeHelper.NotifyItemRemove(self.GetParent<Player>(), itemIndex);
unit.Dispose();
return true;
}
public static bool SetHeroIdOnEquip(this BagComponent self, long itemIndex, long heroId)
{
if (!self.ItemMap.TryGetValue(itemIndex, out EntityRef<ItemUnit> unitRef))
{
return false;
}
ItemUnit unit = unitRef;
unit.HeroId = heroId;
BagNoticeHelper.NotifyItemChange(self.GetParent<Player>(), unit);
return true;
}
public static void AddItems(this BagComponent self, List<RewardEntry> rewards)
{
if (rewards == null)
{
return;
}
foreach (RewardEntry reward in rewards)
{
self.AddItem(reward.ItemId, reward.Count);
}
self.GetParent<Player>()?.SetDirty();
}
public static bool RemoveItems(this BagComponent self, List<CostEntry> costs)
{
if (!self.HasItems(costs))
{
return false;
}
foreach (CostEntry cost in costs)
{
if (!self.RemoveItem(cost.ItemId, cost.Count))
{
return false;
}
}
self.GetParent<Player>()?.SetDirty();
return true;
}
public static long GetItemCount(this BagComponent self, int itemId)
{
long total = 0;
foreach (EntityRef<ItemUnit> 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<CostEntry> 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<ItemUnit> 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<long> removed = new();
foreach (EntityRef<ItemUnit> 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<Player>()?.SetDirty();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b5bc749596ed1d84ea4cb0f6df2bdc6b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<PlayerComponent>();
if (playerComp == null || playerComp.dictionary.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(account))
{
return playerComp.GetByAccount(account);
}
foreach (EntityRef<Player> 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<BagComponent>() ?? player?.AddComponent<BagComponent>();
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<BagComponent>();
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<BagComponent>();
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<BagComponent>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd852d5111cbaa34c8d9eee141387ad4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3424a2ac52396794593ed008ca6d8061
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<PlayerSessionComponent>()?.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<PlayerSessionComponent>()?.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<ItemUnit> unitRef in bag.ItemMap.Values)
{
ItemUnit unit = unitRef;
list.items.Add(BagMapper.ToProto(unit));
}
session.Send(list);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6e2ac59cf6b717149b67aa0c5f39fda5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<BagComponent>();
if (bag == null || !bag.ItemMap.TryGetValue(itemIndex, out EntityRef<ItemUnit> 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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ceda0fb4d1ad2641ad48a6d2c7f5a51
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,27 @@
namespace ET.Server
{
[MessageSessionHandler(SceneType.Gate)]
public class C2G_UseItemHandler : RpcMessageHandler<C2G_UseItem, R2C_UseItem>
{
protected override async ETTask OnRun(Session session, C2G_UseItem request, R2C_UseItem response)
{
Player player = session.GetComponent<SessionPlayerComponent>()?.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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8891d333af566674ea212d9fae952a52
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<BagComponent>() ?? player.AddComponent<BagComponent>();
if (bag.GetAllItems().Count > 0)
{
return;
}
bag.AddItems(new List<RewardEntry>
{
new() { ItemId = 1, Count = 10000 }, // 金币
new() { ItemId = 2, Count = 1000 }, // 钻石
});
LogExtensions.Audit("Bag", "InitialResourceGrant", $"{{\"playerId\":{player.Id}}}");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 623edf3200a689d44a13e189d99aa832
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cc19d0391ad570549a010e81b4332a57
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6678345fff2abea47b1f0dc370d57958
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ec239f9554725241a664c2138da19df
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Model" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9b4313e1576e0b54f91e291c8e1f46b2
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace ET.Client
{
[ComponentOf(typeof(Scene))]
public class BagComponent : Entity, IAwake, IDestroy
{
public Dictionary<long, ItemInfo> ItemMap = new();
public HashSet<long> NewItemSet = new();
}
public struct ItemChangedEvent
{
public long ItemIndex;
}
public struct BagUpdatedEvent
{
public int Count;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2e52aeef56aa75a40808a996562f586c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2b527b81222f98d4688182d27d5cb22d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Model" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 628be4dc2891baa45a10076767b1651e
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<long, EntityRef<ItemUnit>> ItemMap = new();
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 80db836d71d05ef4e95870ba5dfd0435
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,14 @@
namespace ET
{
public struct RewardEntry
{
public int ItemId;
public long Count;
}
public struct CostEntry
{
public int ItemId;
public long Count;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1eb783733a4ba24f800172359315927
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,17 @@
using MongoDB.Bson.Serialization.Attributes;
namespace ET.Server
{
[ChildOf(typeof(BagComponent))]
public class ItemUnit : Entity, IAwake<long, int, long, long>
{
[BsonElement]
public long ItemIndex { get; set; }
[BsonElement]
public int ItemId { get; set; }
[BsonElement]
public long Count { get; set; }
[BsonElement]
public long HeroId { get; set; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ff433e1ca41884446a40586231631e25
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3546971ab74002240a68d08da4f16950
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
{
"name": "Ignore.ET.BagInvoke",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [
"IGNORE"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3f4992bd58d61d64ebfea5afd1144a40
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6a1f8ef658890ba4d8d92ef7e3d845a2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5581b331342b0cb419a08c9b2ff2f2ed
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8f1e25def94fb8446ac1890e0d9caf6f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Model" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6f9c66ba90104c343bbbad1d30becb4b
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee50386f2d01d3c4690d3d5f17802353
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4e566574766d2884284237a424730a2a
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,16 @@
{
"name": "Ignore.ET.Config",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [
"IGNORE"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 739d9581d60071d44b285ab4627db193
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e94cb38ad20d5f8438b54b522de40e25
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d33da66b61ad2cf44915a78dd5e7acef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 119ed73d241d0494a97a762bf5ea10cb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Hotfix" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 40342f8734113244381a678ba5912304
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9c022c196d3fc834d9f07cda14d7f020
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,10 @@
namespace ET
{
public static class SceneConfigExtensions
{
public static cfg.Tables Cfg(this Scene self)
{
return self.GetComponent<ConfigComponent>()?.Tables;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 137c4cb0950076e45aa385a93f07189a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7731b355a79a2a049a3ecf58426116af
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6e08b9da9a727c94c96963e972d892be
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1 @@
{ "reference": "ET.Model" }

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dfcd8b086e500114d98a78c0dedce2d7
AssemblyDefinitionReferenceImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,10 @@
using cfg;
namespace ET
{
[ComponentOf(typeof(Scene))]
public class ConfigComponent : Entity, IAwake, IDestroy
{
public Tables Tables { get; set; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4469784ae5a28204896eeea8aca490dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More