Files
AIC-Project/docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md
T
2026-07-10 10:24:29 +08:00

308 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# C# 热更联机小游戏架构设计
- 日期:2026-06-18
- 状态:已通过头脑风暴评审,待编写实现计划
- 适用范围:客户端(Unity)+ 服务端(.NET)统一的 C# 热更小游戏平台
## 1. 背景与目标
在现有工程基础上,建立一套**客户端与服务端都用 C# 热更**的联机小游戏架构,支持内部团队**独立制作小游戏并上传**,客户端**动态下载小游戏资源、加载并运行**。
### 现有工程现状
- 客户端:Unity + URP,已集成 **HybridCLR**C# 热更,`Client/Assets/Base/LoadDll.cs` 负责增量下载 AOT/热更 DLL + AssetBundle,已适配 android/ios/webgl/微信小游戏)+ **XLua**Lua 热更)。
- 资源分发:AssetBundle`.unity3d`+ md5 清单(`dllfiles.txt`/`files.txt`)做增量下载。
- 已有样例:`Client/Assets/MiniGames/RockPaperScissors`(石头剪刀布联机小游戏,旧版 IMGUI + Lua,服务端 `main.lua`,含匹配 + 15 秒 AI 兜底;已按 §2.4 目录规范迁入 `MiniGames/`,待 C# 重写)。
- 服务端:`Server/` 目录为空,尚未实现。
- 现有规则(`Doc/Rule/UnityProject.md`)规定小游戏逻辑用 Lua、不用 C#——本设计将**取代该规则**。
### 已锁定的核心决策(评审确认)
1. 小游戏**全面转向 C# 热更**,弃用 Lua 小游戏路线;`RockPaperScissors` 后续用 C# 重写。
2. **权威服务端**模型:服务端动态加载每个小游戏的 C# 逻辑,跑权威对局。
3. 小游戏代码组织为**共享逻辑核心 + 两端薄层**。
4. 上传者仅限**内部可信团队**;安全从轻,以签名校验 + 版本管理为主,不上重量级进程沙箱。
5. 目标平台:**AppAndroid/iOSHybridCLR+ 微信小游戏(WASM)都要**,传输层统一 **WebSocket**
6. 联机节奏:**轻实时(状态同步 10–20Hz)+ 回合制**。
### 关键技术选型(评审确认)
- **A1**:共享核心源码一份,客户端/服务端各自编译(客户端编进 HybridCLR 热更 DLL,服务端编成普通 .NET DLL)。
- **B1**:服务端单进程 + 可卸载 `AssemblyLoadContext` 动态加载小游戏逻辑(预留将来子进程隔离 B2 的接口)。
- **C1**:复用并扩展现有 `LoadDll` + md5 清单机制,叠加"小游戏 manifest"维度,不另起炉灶。
## 2. 整体分层与小游戏组成
### 2.1 三层 + 两宿主
```
┌─────────────────────────────────────────────┐
│ 框架层 (Framework, 常驻不热更) │
│ 客户端宿主: LoadDll/AB加载/网络/UI根/小游戏生命周期 │
│ 服务端宿主: 网关/匹配/房间宿主/ALC加载器/持久化 │
│ 共享: 网络协议编解码 / 小游戏SDK接口定义 │
└───────────────┬─────────────────────────────┘
│ 通过稳定接口加载/调用 (不反向依赖小游戏)
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 小游戏·共享核心 │ │ 小游戏·客户端薄层 │ │ 小游戏·服务端薄层 │
│ <Game>.Core.dll │ │ <Game>.Client.dll│ │ <Game>.Server.dll │
│ 纯C#:规则/状态/ │ │ HybridCLR加载: │ │ .NET ALC加载: │
│ 协议/校验 │ │ 渲染/UI/输入/表现 │ │ 房间/AI/持久化 │
│ 依赖→平台能力接口 │ │ 依赖→Core+客户端SDK│ │ 依赖→Core+服务端SDK│
└─────────────────┘ └─────────────────┘ └──────────────────┘
```
**依赖方向铁律**:框架 ← 小游戏(小游戏依赖框架接口,框架永不依赖具体小游戏)。Core 只依赖纯 C# + 平台能力接口,绝不引用 `UnityEngine``System.IO`、socket 等平台 API。
### 2.2 一个小游戏发布包的组成
| 内容 | 说明 | 跑在哪 |
|---|---|---|
| `<Game>.Core.dll` | 平台无关规则/状态/协议(A1:源码一份,两端各编一次) | 客户端 + 服务端 |
| `<Game>.Client.dll` | 客户端表现层(HybridCLR 热更 DLL | 客户端 |
| `<Game>.Server.dll` | 服务端权威逻辑(普通 .NET DLL,服务端侧不打 AB,原样下发 ALC) | 服务端 ALC |
| 客户端 AB `.unity3d` 若干 | **脚本与美术统一打 AB**`<Game>.Core.dll`/`<Game>.Client.dll``.bytes`(TextAsset) 打进 AB`res/` 美术/UI/音效另打 AB(§2.4) | 客户端 |
| `game.json` 元数据 | 游戏 id、版本、入口类型名、最小框架版本、人数、AI 兜底参数等 | 两端读取 |
| `manifest`(md5 清单) | 上述文件 md5 + size + 依赖,挂到现有 `LoadDll` 体系(C1 | 客户端下载用 |
> 客户端发布产物**全部是 AB`.unity3d`**:热更 DLL 与美术资源都走同一套 AB 下载/版本/校验管线,避免两条管线(独立 `.bytes` vs AB)并存。服务端的 `<Game>.Server.dll`/`<Game>.Core.dll` 仍是普通 .NET DLLALC 直接 `Assembly.LoadFrom`,不打 AB)。
### 2.3 小游戏入口契约
框架定义稳定接口,小游戏实现,框架按 `game.json` 的类型名反射实例化:
- `IGameClient`:客户端薄层实现(生命周期 + 网络消息 + 帧更新)。
- `IGameServerRoom`:服务端薄层实现(房间生命周期 + 消息 + tick)。
- `IGameLogic`Core 内):纯函数式推进状态 `Step(state, input) -> (newState, events)`,两端共用做权威计算与客户端预演。
新增一个小游戏 = 写 1 个 Core + 各 1 个薄层实现接口,框架零改动。
### 2.4 客户端源码目录规范
每个小游戏的**客户端源文件统一收敛在一个目录**下,便于团队独立维护、打包工具按目录扫描、热更资源整目录打 AB:
```
Client/Assets/MiniGames/<Game>/ # 一个小游戏一个根目录(<Game> = gameId,如 RockPaperScissors
scripts/ # 客户端脚本(C# 热更源码)
Core/ # 共享核心源码(A1:一份源码,客户端编进 HybridCLR 热更 DLL,服务端 <Compile Include> 链接同一批 .cs 编 .NET DLL
Client/ # 客户端薄层:IGameClient 实现 + 渲染/UI/输入/表现
res/ # 美术 / UI / 预制体 / 音效(按 AB 目录规则组织,打成 AssetBundle
```
约定:
- **`scripts/``res/` 都参与打 AB**。`scripts/` 编出的热更 DLL`<Game>.Core.dll`/`<Game>.Client.dll`)以 `.bytes`(TextAsset) 形式打进 AB`res/` 资源按目录打 AB。客户端发布产物全是 `.unity3d`(§2.2)。
- **依赖方向**`scripts/Core` 不得引用 `UnityEngine`(纯 C#,§2.1 铁律);`scripts/Client` 可引用 `UnityEngine` + 框架客户端 SDK + Core。
- **共享 Core 一份源码**`scripts/Core` 是 Core 的**唯一源码位置**;服务端薄层工程(`Server/games-src/<Game>/<Game>.Server`)通过 `<Compile Include="...MiniGames/<Game>/scripts/Core/**/*.cs">` 链接同一批 `.cs`,杜绝两端逻辑漂移(A1)。服务端薄层(`IGameServerRoom` 实现)属服务端源码,仍留在 `Server/games-src/`,不进客户端 `MiniGames/`
- 框架加载器(`Client/Assets/Script/xmain/MiniGame/`)是**常驻框架**,不属于任何单个小游戏,不放进 `MiniGames/`
## 3. 三条核心数据流
### 3.1 发布流程(制作 → 上传 → 上架)
```
开发者本地
├─ 写 <Game>.Core / .Client / .Server (Core/Client 源码在 Client/Assets/MiniGames/<Game>/scripts/Server 在 Server/games-src/<Game>/) [§2.4]
├─ 一键构建工具(Editor菜单):
│ 1. Core 编两次 → 热更DLL(给HybridCLR) + .NET DLL(给服务端) [A1]
│ 2. Client 编成 HybridCLR 热更 DLL
│ 3. 脚本+美术统一打 ABCore/Client 热更 DLL 转 .bytes(TextAsset) 打进 AB + res/ 按目录打 AB → 全是 .unity3d [§2.2/§2.4]
│ 4. 生成 game.json + 计算各文件 md5 → 小游戏 manifest
│ 5. 全部 → 签名(防篡改)
└─ 上传到「发布服务」(内部后台)
发布服务 ├─ 校验签名 + 最小框架版本兼容
├─ 写入版本库(灰度/正式通道), 资源/DLL 落 CDN
├─ 客户端侧: 把小游戏 manifest 并入 LoadDll 的清单维度 [C1]
└─ 服务端侧: 通知房间宿主「有新版本 <Game> vN 可用」
```
关键点:**一次构建产出客户端包和服务端包**,版本号绑定,避免两端逻辑漂移。
### 3.2 客户端:进入 → 下载 → 加载 → 运行
```
玩家点击某小游戏
→ 框架查 manifest: 对比本地 md5, 增量下载缺失/变更文件 (复用 LoadDll, 断点续传/重试)
→ 校验签名+md5
→ 加载该小游戏全部 AB(.unity3d):含脚本 AB 与美术 AB
→ 从脚本 AB 取出 Core/Client 的 .bytes(TextAsset) → HybridCLR.LoadMetadataForAOTAssembly + Assembly.Load(Core, Client)
→ 反射实例化 game.json 指定的 IGameClient 入口
→ 框架建小游戏根节点/UI根, 注入 ctx(资源加载器/网络通道/退出回调)
→ IGameClient.OnEnter() 接管; OnUpdate 每帧驱动
退出: IGameClient.OnExit() → 卸载AB → 卸载小游戏根 → (DLL常驻, 内存可控)
```
微信小游戏分支:传输强制 WebSocket,代码下载走 WX 文件系统缓存。**热更代码下载需符合微信平台政策**(见 6.6 降级策略)。
### 3.3 对局流程(权威服务端 + 轻实时状态同步)
```
客户端 服务端(网关/房间宿主)
│── 请求匹配(gameId,version) ──▶ 匹配队列(按gameId+version分桶)
│ ├─ 凑齐N人 或 15s超时→AI兜底(沿用RPS规则)
│ ├─ 房间宿主: 若未加载该版本Server DLL
│ │ → 用可卸载ALC加载 <Game>.Server.dll [B1]
│ └─ new IGameServerRoom, OnRoomStart(players)
│◀──── 房间就绪/初始状态快照 ─────┤
│ │ 服务端权威循环:
│── 玩家输入(intent) ──────────▶ OnMessage → Core.Step() 推进权威状态
│ │ OnTick(10-20Hz): 生成状态快照/增量
│◀──── 状态快照/事件广播 ────────┤ (轻实时: 客户端可用Core.Step本地预演+插值)
│ IGameClient渲染表现 │
│── ... 直到结束 ──────────────▶ OnRoomEnd → 结算落库 → 通知客户端
│◀──── 结算结果 ────────────────┤ → ALC 在无房间引用时可卸载(热更新版本)
```
**权威性保证**:胜负/随机/关键状态只认服务端 `Core.Step` 的结果;客户端 `Core.Step` 仅用于预演和插值,最终以服务端快照校正。两端跑同一份 Core,天然一致。
## 4. 服务端运行时架构
### 4.1 进程内组件(单进程,B1)
```
┌──────────────────────── 服务端主进程 (.NET 8+) ──────────────────────────┐
│ [接入层] WebSocket 网关 (Kestrel/ASP.NET Core) │
│ ├─ 连接/鉴权/心跳, 统一收发框架协议帧 │
│ └─ Session 管理(playerId ↔ 连接), 断线重连窗口 │
│ [路由层] 消息路由: 框架消息(匹配/心跳/大厅) vs 小游戏消息(转给房间) │
│ [匹配服务] 按 (gameId, version) 分桶队列 │
│ ├─ 凑齐N人建房; 15s超时→请求房间用该游戏AI兜底 │
│ └─ 不跨版本匹配(避免 Core 不一致) │
│ [房间宿主 RoomHost] ★核心 │
│ ├─ GameModuleRegistry: gameId+version → 已加载的 ALC + 入口工厂 │
│ ├─ ALC 加载器: 可卸载 AssemblyLoadContext, 按需加载 <Game>.Server.dll │
│ │ + 对应 Core.dll; 注入服务端能力(IRandom权威/ITimer/日志/存储) │
│ ├─ Room 实例池: 每对局 1 个 IGameServerRoom; 统一 Tick 调度(10-20Hz) │
│ └─ 房间结束回收; 某版本无房间引用 → 标记可卸载 │
│ [基础设施] 持久化(对局结果/玩家数据) · 日志/监控 · 配置 · 发布通知订阅 │
└──────────────────────────────────────────────────────────────────────────┘
```
### 4.2 ALC 生命周期(服务端热更核心)
```
新版本发布通知到达
→ 房间宿主创建新的可卸载 ALC, 加载 <Game>.Server.dll vN+1
→ 新匹配的房间用 vN+1; 旧房间(vN)继续用旧 ALC 跑完, 互不影响 (版本共存)
→ 旧版本所有房间结束 + 无引用 → ALC.Unload() + GC → 内存回收
约束:
- Core/Server DLL 与框架之间只通过框架程序集里的接口交互
(接口类型必须在「框架默认ALC」, 不随小游戏卸载, 否则类型无法跨界)
- 小游戏 DLL 不得静态持有跨房间/跨版本的全局状态(否则阻碍卸载)
```
**不重启热更**的关键:老对局用老逻辑跑完,新对局用新逻辑,平滑切换;绝不强制踢掉在玩的对局。
### 4.3 技术栈
| 关注点 | 选择 | 理由 |
|---|---|---|
| 运行时 | .NET 8 LTS(或更新 LTS | `AssemblyLoadContext.Unload` 成熟,性能好,跨平台 |
| 接入 | ASP.NET Core Kestrel + WebSocket | 多端统一 WS(微信小游戏只能 WS),生态成熟 |
| 协议编解码 | 共享层(框架协议)+ 各小游戏 Core 自定义消息 | 两端同一份编解码,杜绝不一致 |
| 调度 | 单线程 per-room 逻辑 + 房间分配到线程池/channel | 房间内逻辑无锁、确定性好;房间间并行 |
| 持久化 | 先用轻量(SQLite/文件)跑通,接口抽象预留 MySQL/Redis | 内部阶段不过度设计 |
### 4.4 确定性与随机
权威 `IRandom` 由服务端房间持有种子,关键随机只在服务端 `Core.Step` 产生;客户端预演用服务端下发的种子/结果校正。保证"客户端预测—服务端权威"一致,也方便回放/复盘。
## 5. 关键接口契约
```csharp
// —— 共享核心层 (<Game>.Core, 纯 C#) ——
public interface IGameLogic<TState, TInput, TEvent> {
TState CreateInitial(RoomConfig cfg, IRandom rng);
StepResult<TState, TEvent> Step(TState s, TInput input, IRandom rng); // 纯函数式权威推进
byte[] Encode(TState s); TState Decode(byte[] data); // 快照编解码(两端共用)
}
// —— 框架提供的平台能力(注入给小游戏, 隔离平台差异) ——
public interface IRandom { int Next(int max); } // 服务端权威/客户端用下发种子
public interface ITimer { float Now { get; } }
public interface ILogger { void Info(string m); /* ... */ }
public interface IStorage { /* ... */ } // 仅服务端注入
// —— 客户端薄层入口 ——
public interface IGameClient {
void OnEnter(IGameClientCtx ctx); // ctx: 资源加载/网络发送/退出
void OnNetMessage(NetMessage msg);
void OnUpdate(float dt);
void OnExit();
}
// —— 服务端薄层入口 ——
public interface IGameServerRoom {
void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx);
void OnMessage(int playerId, NetMessage msg);
void OnTick(float dt); // 10-20Hz, 内部调用 Core.Step + 广播快照
void OnRoomEnd();
}
```
> 这些接口类型**定义在常驻框架程序集**(不随小游戏 ALC 卸载),保证跨边界类型一致。
## 6. 错误处理与容错
| 场景 | 处理 |
|---|---|
| 客户端下载失败/md5 不符 | 重试 + 断点续传;超阈值回大厅报错,不进半成品小游戏(复用 LoadDll 现有逻辑) |
| 签名/版本不兼容 | 拒绝加载,提示更新框架版本 |
| 小游戏 Server 逻辑抛异常 | 房间隔离:捕获→该房间安全结束并落败/重开,不拖垮主进程;记录日志告警 |
| 玩家断线 | 重连窗口内恢复 Session;超时→AI 接管或判负(按 game.json 配置) |
| ALC 卸载失败(有泄漏引用) | 监控驻留版本数;告警并保留旧 ALC,绝不强卸导致崩溃 |
| 微信小游戏热更代码政策受限(6.6) | 合规审核通道发版 / 必要时主包内置基础小游戏,热更仅增量 |
### 6.6 微信小游戏热更风险(需重点验证)
微信小游戏平台对"下载并执行远端代码"有政策约束。落地前必须验证 HybridCLR 在 WX-WASM 环境下的热更代码下载是否合规。降级策略:基础/首发小游戏内置主包,热更仅做增量与资源更新;正式发版走微信审核通道。
## 7. 安全(内部可信,从轻)
- **签名校验**:发布产物全部签名,客户端/服务端加载前验签(防中间人篡改 DLL)。
- **传输**WSSTLS)。
- **来源限定**:只加载发布服务登记过的 manifest,不接受任意 URL。
- 不上进程沙箱 / 字节码校验(内部信任,YAGNI);第 5 节的能力接口注入天然限制小游戏可触达的 API,预留将来收紧空间。
## 8. 测试策略
| 层 | 测试方式 |
|---|---|
| Core(纯 C#) | 单元测试:`Step` 确定性、编解码往返、规则边界。两端共用 Core,测一次双端受益 |
| 协议编解码 | 往返一致性 + 跨版本兼容用例 |
| 服务端房间 | 用假网络/假玩家驱动 `IGameServerRoom`,含断线/超时/AI 兜底用例 |
| ALC 热更 | 集成测试:加载 v1→建房→发布 v2→新房用 v2、旧房跑完→v1 卸载、内存回收断言 |
| 端到端 | 用 RPS 跑通:匹配→对局→结算→热更换版本 |
## 9. 渐进落地路线(每步可独立验证)
1. **框架接口与协议层**:定义第 5 节接口、网络协议编解码(共享层),先不接小游戏。
2. **服务端骨架**Kestrel WS 网关 + Session + 匹配 + RoomHost + ALC 加载器(先加载一个 hello 房间验证热更/卸载闭环)。
3. **客户端骨架**:扩展 `LoadDll` 支持小游戏 manifest 维度 + HybridCLR 加载小游戏 DLL + 生命周期接管。
4. **构建/发布工具链**:一键产出两端包 + manifest + 签名 + 上传(A1 的双编译)。
5. **试金石:用 C# 重写 RockPaperScissors**(Core 规则 + 客户端薄层 + 服务端薄层 + AI 兜底),跑通端到端 + 热更换版本。
6. **沉淀模板**:把 RPS 抽成"新小游戏脚手架模板",验证"写一个新小游戏 = 框架零改动"。
每一步对应第 8 节测试,独立验证后再进入下一步。
## 10. 待跟进 / 风险清单
### 10.1 「脚本+美术统一打 AB」改造清单(相对已实现代码的增量)
现有实现把热更 DLL 落成**独立 md5 命名的 `.bytes`**、与美术 AB 走两条管线;本设计(§2.2/§2.4)改为**DLL 以 `.bytes` 打进 AB、与美术同走一条 AB 管线**。需改造(其余模块零改动):
- **客户端目录迁移**:已完成——`RockPaperScissors` 迁入 `Client/Assets/MiniGames/RockPaperScissors/``Art→res``script→scripts`,保留 `.meta` GUID)。新增小游戏一律按 §2.4 落在 `MiniGames/<Game>/`
- **Editor 构建(`Client/Assets/Editor/MiniGamePublishMenu.cs`**:第 3 步由「DLL→.bytes 旁路 + 美术打 AB」改为「DLL→.bytes 作为 TextAsset 与 `res/` 一起 `BuildAssetBundles`」,产物全是 `.unity3d`
- **发布布局(`Server/PublishTool/PublishLayout.cs` + `GameSpec`/`GameJsonWriter`**:客户端侧不再 `CopyBytes` 单独落 DLL `.bytes`;改为把脚本 AB 与美术 AB 一并按 md5 命名落盘并登记进 `files.txt``game.json` 用 AB 名(含脚本 AB)替代 `coreDll`/`clientDll` 字段语义。同步更新 `PublishTool.Tests`
- **客户端加载器(`Client/Assets/Script/xmain/MiniGame/`**`MiniGameAssemblyLoader``File.ReadAllBytes(<dll>.bytes)` 改为从已加载 AB 取 `TextAsset` 字节再 `Assembly.Load``MiniGameDownloader` 不再特判 `.bytes`(全部条目按 AB 增量下载);`MiniGameManifest` 解析 game.json 的 AB 字段。**须 Unity Editor/真机核对**(本环境无 Unity,无法验证 HybridCLR 从 AB 加载 DLL)。
### 10.2 其他
- 微信小游戏热更代码政策合规性(6.6)——上线前必须验证。
- 现有 `Doc/Rule/UnityProject.md` 的"小游戏用 Lua"规则需更新为 C# 路线。
- HybridCLR 在 WASM 下对小游戏热更 DLL 的 AOT 泛型/裁剪约束需实测。
- ALC 卸载的内存泄漏边界(静态字段、事件订阅、未释放的 native 句柄)需在第 2 步集成测试中重点验证。