This commit is contained in:
huangjun
2026-08-30 22:25:23 +08:00
parent 92bf3e9097
commit 93a10a89c5
180 changed files with 15257 additions and 11604 deletions
+80
View File
@@ -0,0 +1,80 @@
# Luban 数据表组件
> 负责加载 Luban 导出的二进制配置表(.bytes),向业务提供统一访问入口。
> 相关组件:`Assets/GameMain/Scripts/Runtime/CustomComponent/Luban/LubanComponent.cs`
## 数据流
```
Datas/*.xlsx (Excel 配置源)
│ gen_cli.sh(Luban 导出,产出 代码 + 数据)
▼
┌──────────────────────┬────────────────────────────────┐
│ Assets/GameMain/ │ Assets/GameMain/Scripts/Base/Gen│
│ DataTables/tb*.bytes │ Tables.cs + Tb* + *Config bean │
└──────────────────────┴────────────────────────────────┘
│ GameEntry.Luban.LoadTables()(运行时加载)
▼
GameEntry.Luban.Get<T>(id) / GetTable<T>()
```
## 目录约定
| 路径 | 内容 |
| --- | --- |
| `数据表/Datas/` | 配置源 xlsx(`__tables__.xlsx` 注册所有表) |
| `数据表/Defines/` | Luban 类型定义(枚举、结构) |
| `数据表/luban.conf` | 导出配置(target=client,topModule=`SepCore.Definition`) |
| `数据表/gen_cli.sh` | 导出脚本;`path.txt` 指定输出根目录(`../Assets/GameMain/`) |
| `Assets/GameMain/DataTables/` | 导出数据:`tb*.bytes`(运行时用)+ `tb*.json`(调试对照用) |
| `Assets/GameMain/Scripts/Base/Gen/` | 生成代码:`Tables` 门面、`Tb*` 表类、`*Config` 数据行类、枚举 |
| `Assets/GameMain/Scripts/ThirdParty/Luban/` | Luban 运行时(`ByteBuf`、`BeanBase` 等,程序集 `Luban.Runtime`) |
| `Assets/GameMain/Scripts/Runtime/CustomComponent/Luban/` | `LubanComponent` 运行时组件 |
## 组件 API
| API | 说明 |
| --- | --- |
| `LoadTables(onSuccess, onFailure)` | 异步并发加载全部表并构建 `Tables`,完成后回调 |
| `IsReady` | `Tables` 是否已构建完成 |
| `Get<T>(int id)` | 单行查询;未找到返回 `null`;表未加载抛 `InvalidOperationException`;未注册类型抛 `NotSupportedException` |
| `GetTable<T>()` | 整表查询,返回 `IReadOnlyList<T>`(怪物池、掉落池等需要遍历的场景用) |
用法示例:
```csharp
EnemyConfig enemy = GameEntry.Luban.Get<EnemyConfig>(3001);
IReadOnlyList<ItemConfig> allItems = GameEntry.Luban.GetTable<ItemConfig>();
UIFormConfig form = GameEntry.Luban.Get<UIFormConfig>(UIFormType.DialogForm); // enum 可转 int
```
## 命名与结构约定
- 表名统一小写 `tbxxxconfig`(Excel 表名、导出文件名、`Tables` 构造参数、`TableNames` 数组四者必须一致)。
- 生成类型命名:数据行 `XxxConfig`(如 `EnemyConfig`)、表类 `TbXxxConfig`(如 `TbEnemyConfig`)、枚举直接命名(`Rarity`、`DifficultyTier`),全部在命名空间 `SepCore.Definition`。
- 主键绝大多数为 `int`;枚举主键的表(`tbrarityconfig`、`tbdifficultyconfig`)在 `TableAccessors` 注册时做显式转换,业务侧仍传 `int`。
- 单行表(`mode=one`,如 `tbglobalconfig`)无主键 `Get`,**不注册**进访问器,通过 `Tables.TbGlobalConfig.Data` 访问。
- 数据行类继承 `Luban.BeanBase`。
## 新增一张表的完整流程(4 步)
1. **建表**:在 `数据表/Datas/` 新增 `XxxConfig_xx.xlsx`,并在 `__tables__.xlsx` 注册表名与结构。
2. **导出**:运行 `数据表/gen_cli.sh`(两个 Luban 命令都要跑:先 `-c cs-bin` 生成代码,再 `-d json -d bin` 导出数据)。
3. **登记表名**:`LubanComponent.TableNames` 追加小写表名(`"tbxxxconfig"`)。
4. **注册访问器**:`LubanComponent.TableAccessors` 追加一行:
```csharp
{ typeof(XxxConfig), Accessor(tables => id => tables.TbXxxConfig.GetOrDefault(id), tables => tables.TbXxxConfig.DataList) },
// 枚举主键:Accessor(tables => id => tables.TbXxxConfig.GetOrDefault((YyyEnum)id), tables => tables.TbXxxConfig.DataList)
```
## 资源与打包约定
- 运行时按 `Assets/GameMain/DataTables/{表名}.bytes` 路径经 `GameEntry.Resource.LoadAsset` 加载,`Constant.AssetPriority.DataTableAsset` 作为优先级。
- 正式打包需资源收集包含全部 `tb*.bytes`,否则打包后加载失败。
- 加载中途某张表失败会走 `onFailure` 回调,预加载流程(`ProcedurePreload`)会停在加载界面,需排查表名/路径/资源收集。
## 与旧 DataTable 组件的关系
- 旧的 GameFramework `DataTableComponent` + txt 表 + 手写 `DR*` 行类已全部移除(2026-08 迁移)。
- `DataTableComponent` 组件本身仍挂在场景(框架内置组件,GameEntry 初始化需要),但已无任何数据加载。
- 预加载与所有消费方(Entity/Sound/UI/Scene 扩展)均走 `GameEntry.Luban`。
+147
View File
@@ -0,0 +1,147 @@
# 存档数据结构
> 定义游戏存档中需要持久化的数据。仅覆盖局外状态与已结束单局的结算记录;进行中的单局使用临时状态,不写入存档。
> 相关设计:`Docs/GameDesign/06_PrototypeScope.md`(存档相关验收项)、`Docs/GameDesign/05_LootAndProgression.md`(物品归属)。
> 代码位置:`Assets/GameMain/Scripts/Base/Definition/DataStruct/`(`SaveData`、`ItemStack`、`CharacterSave`、`LoadoutSave`、`RunRecord`),枚举复用 Luban 生成的 `RunResultType`、`DifficultyTier`。
## 设计原则
- 存档只保存局外状态:主仓库内容、角色装备、当前战备配置、已结束单局的结算记录。
- 背包、保险箱、单局进度均为进入单局时创建的临时状态,不落盘;异常关闭直接丢弃。
- 数值上限(堆叠上限、背包格数等)来自 Luban 配表(`ItemConfig.StackLimit`、`GlobalConfig.BackpackSlotCount` 等),不写死在存档中。
- 存档使用 JSON 序列化,便于调试与手动修改;`SaveData.ToJson()` 序列化,`SaveData.FromJson(string)` 反序列化并补齐缺失字段(空列表/空数组)。
## 序列化格式
枚举在 JSON 中以整数值存储:`RunResultType`(`Extracted=1`、`Defeated=2`、`TimedOut=3`、`Quit=4`)、`DifficultyTier`(`Tier1=1`、`Tier2=2`、`Tier3=3`)。
```json
{
"version": 1,
"updatedAt": 1756540000000,
"mainWarehouse": [
{ "itemId": 1001, "count": 3 },
{ "itemId": 2005, "count": 1 }
],
"characters": [
{ "characterId": 1, "weaponItemId": 1001, "armorItemId": 0 },
{ "characterId": 2, "weaponItemId": 0, "armorItemId": 0 }
],
"loadout": {
"partyCharacterIds": [1, 2],
"carriedItems": [
{ "itemId": 3002, "count": 5 }
],
"difficultyId": 1
},
"runHistory": [
{
"outcome": 1,
"difficultyId": 1,
"seed": 2026083001,
"startedAt": 1756540000000,
"endedAt": 1756541200000
}
]
}
```
## 结构定义
### SaveData(存档根)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `version` | int | 存档结构版本,当前为 1;结构变更时递增并处理迁移 |
| `updatedAt` | long | 最后写入时间,Unix 毫秒 |
| `mainWarehouse` | `ItemStack[]` | 主仓库内容,格子数上限由 `GlobalConfig.WarehouseSlotCount` 配置,可空 |
| `characters` | `CharacterSave[]` | 拥有的角色,数组顺序即角色入队顺序(速度并列时按此顺序行动) |
| `loadout` | `LoadoutSave` | 当前战备配置 |
| `runHistory` | `RunRecord[]` | 已结束单局的结算记录,可空 |
### ItemStack(物品堆叠)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `itemId` | int | 物品 ID,对应 `ItemConfig.Id` |
| `count` | int | 数量,不超过配表 `ItemConfig.StackLimit` |
所有物品容器统一使用堆叠列表存储,不记录格子在背包中的位置。
### CharacterSave(角色)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `characterId` | int | 角色 ID,对应 `CharacterConfig.Id` |
| `weaponItemId` | int | 武器栏物品 ID,0 表示空栏 |
| `armorItemId` | int | 防具栏物品 ID,0 表示空栏 |
装备穿戴在角色装备栏,不占用共享背包格子;成功撤离与任何非全员阵亡的战斗后均保持穿戴,死亡结算时随角色丢失,结算逻辑负责处理,存档本身不记录"阵亡"等局内状态。
### LoadoutSave(战备配置)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `partyCharacterIds` | int[] | 出战角色 ID 及顺序,1~4 人,顺序决定同速时的行动次序 |
| `carriedItems` | `ItemStack[]` | 携带进入地图的物品,开局时填充共享背包;首版无局内效果 |
| `difficultyId` | `DifficultyTier` | 本局难度,对应 `DifficultyConfig` 主键 |
随机数(seed)不属于战备配置:每次进入单局时输入,只写入该局对应的 `RunRecord`,不持久化到 `loadout`。
### RunRecord(结算记录)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `outcome` | `RunResultType` | 结算结果 |
| `difficultyId` | `DifficultyTier` | 本局难度 |
| `seed` | long | 本局使用的随机数 |
| `startedAt` | long | 进入单局时间,Unix 毫秒 |
| `endedAt` | long | 结算时间,Unix 毫秒 |
结算结果枚举复用 Luban 生成的 `RunResultType`:
| 值 | 说明 |
| --- | --- |
| `Extracted` | 成功撤离 |
| `Defeated` | 全员阵亡,单局失败 |
| `TimedOut` | 25 分钟上限未撤离,撤离失败 |
| `Quit` | 玩家主动退出单局,按撤离失败结算 |
`Defeated`、`TimedOut`、`Quit` 均只保留保险箱内容,仅用于结算记录的区分。
## 生命周期
- **新存档**:`characters` 由 `GlobalConfig.NewGameCharacterIds` 生成,初始装备取 `CharacterConfig.WeaponItemId` / `ArmorItemId`;`mainWarehouse`、`loadout.partyCharacterIds`、`loadout.carriedItems`、`runHistory` 为空。
- **进入单局**:根据存档中的局外状态创建仅供本局使用的临时状态(背包、保险箱、单局进度),单局进行中只修改临时状态,不写入存档。
- **正常结算**(撤离成功 / 死亡 / 超时 / 主动退出):按结算规则把物品写入主仓库或丢弃,追加一条 `RunRecord`,一次性写盘。
- **异常关闭**:临时状态丢弃,存档保持进入该局前的状态,不产生结算记录。
## 读写组件
> 代码位置:`Assets/GameMain/Scripts/Runtime/CustomComponent/Save/SaveComponent.cs`,通过 `GameEntry.Save` 访问,挂在 Launcher 场景 `Customs/Save` 物体上。
| API | 说明 |
| --- | --- |
| `Data` | 当前内存中的存档数据,未加载或未创建时为 null |
| `HasSave` | 磁盘上是否已存在存档文件 |
| `IsReady` | 存档数据是否可用 |
| `Load()` | 从磁盘读取存档;文件不存在返回 true 且 `HasSave` 为 false,解析失败返回 false |
| `CreateNewGame()` | 依据配表创建新存档数据(不写盘) |
| `Save()` | 将当前存档写入磁盘,自动更新 `updatedAt`;先写临时文件再替换,避免写入中断损坏存档 |
- 存档文件路径为 `Application.persistentDataPath` 下的 `save.json`(`SaveComponent` 的 `_fileName` 可配置)。
- 加载时若 `version` 与 `SaveData.CurrentVersion` 不一致,记录警告并继续加载,迁移逻辑后续需要时补充。
## 验收对应
对应 `Docs/GameDesign/06_PrototypeScope.md` 存档相关验收项:
- [ ] 退出并重新打开游戏后,局外主仓库、角色装备和战备配置能够从本地存档恢复。
- [ ] 已结束单局的结算数据能够写入本地存档并在重新打开游戏后读取。
- [ ] 重新打开游戏时不会恢复尚未结束的单局。
- [ ] 单局中主动退出时能够按撤离失败结算,保存保险箱内容和本局失败记录。
- [ ] 异常关闭的未结算单局不会修改局外存档,也不会生成结算记录。
## 还没决定的问题
暂无。存档的加密、校验与多存档位支持不在原型范围内,后续需要时再补充。
+64
View File
@@ -0,0 +1,64 @@
# UI 维护要点
> 汇总 SBE 项目中 UI 的代码组织、嵌套 Form 协作与常见注意事项。
> 相关代码:`Assets/GameMain/Scripts/UI/`(生成 + 手写 partial)、`Assets/GameMain/Scripts/Base/Event/UIForm/`(UI 事件)。
## 一、Form / View 的组织方式
- **自动生成**:`UIPrefabBuilder`(编辑器菜单 `Utility/UI/Build UI Prefabs`)构建 prefab;`UIAssetsTools` 根据 `UISerializationRoot`/`UISerializationItem` 上的引用生成 `XxxForm.cs` 与 `XxxView.cs`(文件头带 `// <auto-generated />`)。
- **手写逻辑**:业务代码写在同名的 `.Logic.cs` partial 里(如 `LobbyForm.Logic.cs`),与生成文件合并;**不要**修改自动生成文件。
- **View 字段**:prefab 中需要代码访问的组件在序列化项上登记变量名(`variableName`),生成到 View 的 public 字段。View 只暴露组件引用,不做业务。
- **View 引用的是子 Form 组件而非子 View**:如 `LobbyView.warehouseForm`(WarehouseForm 组件)、`WarehouseView.inventoryPanelForm` / `itemDetailsPanelForm`。通过 `子Form.View` 再取具体组件。
## 二、嵌套 Form 的能力模型
- 一个 Form 可以嵌套子 Form(prefab 实例层级:`LobbyForm → WarehouseForm → InventoryForm / ItemDetailsForm`)。
- **子 Form 只提供能力**(public 方法),**不直接读取全局数据源、不主动触发刷新**;数据由调用方传入。例:`InventoryForm.RefreshList(stacks)`、`ItemDetailsForm.Refresh(itemId)`、`WarehouseForm.Refresh(stacks)`。
- **只有顶级 Form(通过 `GameEntry.UI.OpenUIForm` 打开的)才有编排权**,负责读数据并向下分发。例:`LobbyForm.OnOpen → SwitchPage → warehouseForm.Refresh(save.mainWarehouse)`。
- 子 Form 的能力入口保持幂等、可反复调用;父 Form 切换页面时重新调用即可。
## 三、嵌套 Form 与 UGF 生命周期
- **只有被 UIComponent 打开的顶级 Form 才会被框架调用 `OnInit / OnOpen / OnClose / OnUpdate` 等生命周期方法。嵌套 Form 的这些方法永远不会执行**——不要在嵌套 Form 里依赖它们(最常见错误:把事件绑定写在 `OnInit`,结果永远不生效)。
- 嵌套 Form 的**事件绑定/订阅改为幂等式**:`bool` 标志 + `EnsureXxx()`,在能力入口(如 `Refresh`)调用,保证先于任何交互执行。
```csharp
private bool _listenersBound = false;
private void EnsureListenersBound()
{
if (_listenersBound) return;
_listenersBound = true;
View.allToggle.onValueChanged.AddListener(OnAllToggleValueChanged);
}
```
- UGF 表单实例会被**对象池复用**,幂等标志同时防止重复订阅。
- 订阅了 `GameEntry.Event` 的嵌套 Form 实例被销毁后,事件中心可能残留引用:handler 开头加 `if (this == null) return;` 防御。
## 四、子 Form 向上传递数据:事件
- 子组件(如 `WarehouseSlotItem`)**禁止直接持有并调用父 Form**(避免反向引用),需要向上层传数据时发事件:
```csharp
GameEntry.Event.Fire(this, WarehouseSlotItemClickEventArgs.Create(_slotId));
```
- 事件参数类放在 `Assets/GameMain/Scripts/Base/Event/UIForm/`,命名 `XxxEventArgs`,继承 `GameEventArgs`,使用 `ReferencePool`(`Create` 静态工厂 + `Clear` 回收):
```csharp
public class XxxEventArgs : GameEventArgs
{
public static int EventId => typeof(XxxEventArgs).GetHashCode();
public override int Id => EventId;
public static XxxEventArgs Create(...) { var a = ReferencePool.Acquire<XxxEventArgs>(); ...; return a; }
public override void Clear() { ... }
}
```
- 事件参数要携带**唯一标识**(如格子索引 `SlotId`),不要用可能重复的语义值(如 itemId 在多堆同道具时无法唯一定位)。
- 订阅方是拥有上下文的父 Form(`WarehouseForm` 监听格子点击 → 分发选中高亮与详情刷新),在能力入口幂等订阅。
- 事件从 `GameEntry.Event.Subscribe(XxxEventArgs.EventId, handler)` / `GameEntry.Event.Fire(this, args)` 走 UGF 事件总线,无需手动管理对象生命周期。
## 五、其它约定
- **固定网格 + 模板格子**:列表用 `warehouseSlotRoot`(GridLayout 容器)+ `warehouseSlotTemplate`(一个模板格)。刷新时隐藏模板、销毁其他子物体、按模板实例化;格子数等数值来自配表(`GlobalConfig.WarehouseSlotCount`),**不写死**。
- **选中态**:点击选中由事件驱动,`SetSelectedSlot(slotId)` 按唯一索引高亮;筛选后重建网格时选中自动清除。
- **格式化文本**:界面文案用 `FormatTextUI`(Inspector 填 key,文本从 `FormatText_格式化文本.xlsx` 读取,`Set(params object[])` 传参),不要硬编码在代码里。
- **导航切换**:导航用 `Toggle + ToggleGroup` 管理选中态,代码只订阅 `onValueChanged`(仅在 `isOn == true` 时处理),不再手动维护按钮状态。
- **动画**:DOTween 移动 UI 元素时,`TransformPoint` 的入参是目标 rect **pivot 局部空间**坐标;元素锚点偏移需要换算:`(anchor - pivot) * rect.size + anchoredPosition`。
- **打开流程**:Form 由 `UIFormType` 枚举 + `UIFormConfig` 表(`tbuiformconfig`)数据驱动,`GameEntry.UI.OpenUIForm(UIFormType.Xxx)`;Procedure 的 `OnLeave` 记得 `GameEntry.UI.CloseAllLoadedUIForms()`。
- **布局组件注意**:生成 prefab 的 `Content` 上挂 `GridLayoutGroup` + `ContentSizeFitter`,运行时实例化子物体即可自动排布,不要手动改尺寸。
+9
View File
@@ -0,0 +1,9 @@
# SBE 技术文档
## 文档
| 顺序 | 文档 | 用途 |
| --- | --- | --- |
| 1 | [Luban 数据表组件](01_LubanDataTable.md) | 配置表的导出、加载、访问约定与新增表流程 |
| 2 | [存档数据结构](02_SaveData.md) | 存档序列化格式、字段定义与生命周期 |
| 3 | [UI 维护要点](03_UIMaintenance.md) | Form/View 组织、嵌套 Form 协作、事件通信与常见坑 |