21 KiB
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#——本设计将取代该规则。
已锁定的核心决策(评审确认)
- 小游戏全面转向 C# 热更,弃用 Lua 小游戏路线;
RockPaperScissors后续用 C# 重写。 - 权威服务端模型:服务端动态加载每个小游戏的 C# 逻辑,跑权威对局。
- 小游戏代码组织为共享逻辑核心 + 两端薄层。
- 上传者仅限内部可信团队;安全从轻,以签名校验 + 版本管理为主,不上重量级进程沙箱。
- 目标平台:App(Android/iOS,HybridCLR)+ 微信小游戏(WASM)都要,传输层统一 WebSocket。
- 联机节奏:轻实时(状态同步 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 下载/版本/校验管线,避免两条管线(独立.bytesvs AB)并存。服务端的<Game>.Server.dll/<Game>.Core.dll仍是普通 .NET DLL(ALC 直接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. 脚本+美术统一打 AB:Core/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. 关键接口契约
// —— 共享核心层 (<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)。
- 传输:WSS(TLS)。
- 来源限定:只加载发布服务登记过的 manifest,不接受任意 URL。
- 不上进程沙箱 / 字节码校验(内部信任,YAGNI);第 5 节的能力接口注入天然限制小游戏可触达的 API,预留将来收紧空间。
8. 测试策略
| 层 | 测试方式 |
|---|---|
| Core(纯 C#) | 单元测试:Step 确定性、编解码往返、规则边界。两端共用 Core,测一次双端受益 |
| 协议编解码 | 往返一致性 + 跨版本兼容用例 |
| 服务端房间 | 用假网络/假玩家驱动 IGameServerRoom,含断线/超时/AI 兜底用例 |
| ALC 热更 | 集成测试:加载 v1→建房→发布 v2→新房用 v2、旧房跑完→v1 卸载、内存回收断言 |
| 端到端 | 用 RPS 跑通:匹配→对局→结算→热更换版本 |
9. 渐进落地路线(每步可独立验证)
- 框架接口与协议层:定义第 5 节接口、网络协议编解码(共享层),先不接小游戏。
- 服务端骨架:Kestrel WS 网关 + Session + 匹配 + RoomHost + ALC 加载器(先加载一个 hello 房间验证热更/卸载闭环)。
- 客户端骨架:扩展
LoadDll支持小游戏 manifest 维度 + HybridCLR 加载小游戏 DLL + 生命周期接管。 - 构建/发布工具链:一键产出两端包 + manifest + 签名 + 上传(A1 的双编译)。
- 试金石:用 C# 重写 RockPaperScissors(Core 规则 + 客户端薄层 + 服务端薄层 + AI 兜底),跑通端到端 + 热更换版本。
- 沉淀模板:把 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,保留.metaGUID)。新增小游戏一律按 §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 步集成测试中重点验证。