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

21 KiB
Raw Permalink Blame History

C# 热更联机小游戏架构设计

  • 日期:2026-06-18
  • 状态:已通过头脑风暴评审,待编写实现计划
  • 适用范围:客户端(Unity)+ 服务端(.NET)统一的 C# 热更小游戏平台

1. 背景与目标

在现有工程基础上,建立一套客户端与服务端都用 C# 热更的联机小游戏架构,支持内部团队独立制作小游戏并上传,客户端动态下载小游戏资源、加载并运行

现有工程现状

  • 客户端:Unity + URP,已集成 HybridCLRC# 热更,Client/Assets/Base/LoadDll.cs 负责增量下载 AOT/热更 DLL + AssetBundle,已适配 android/ios/webgl/微信小游戏)+ XLuaLua 热更)。
  • 资源分发: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# + 平台能力接口,绝不引用 UnityEngineSystem.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) 打进 ABres/ 美术/UI/音效另打 AB(§2.4) 客户端
game.json 元数据 游戏 id、版本、入口类型名、最小框架版本、人数、AI 兜底参数等 两端读取
manifestmd5 清单) 上述文件 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)。
  • IGameLogicCore 内):纯函数式推进状态 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/ 都参与打 ABscripts/ 编出的热更 DLL<Game>.Core.dll/<Game>.Client.dll)以 .bytes(TextAsset) 形式打进 ABres/ 资源按目录打 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. 关键接口契约

// —— 共享核心层 (<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→resscript→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.txtgame.json 用 AB 名(含脚本 AB)替代 coreDll/clientDll 字段语义。同步更新 PublishTool.Tests
  • 客户端加载器(Client/Assets/Script/xmain/MiniGame/MiniGameAssemblyLoaderFile.ReadAllBytes(<dll>.bytes) 改为从已加载 AB 取 TextAsset 字节再 Assembly.LoadMiniGameDownloader 不再特判 .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 步集成测试中重点验证。