# 02 · 子游戏接入与开发流程 本篇讲**怎么动手**:一个子游戏由哪三个必需文件构成、`export.js` 的一组回调接口与 `import.js` 的封装接口各做什么、`makewar`/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。 > 仍以麻将举例,但"三文件架构 + export/import 接口 + 主动推送"对任意子游戏一致。 > **命名说明**:本篇代码里的业务 `rpc` 名(`playCard`、`declareHu` 等)与 handler/类/文件名(`RpcHandler.handlePlayCard`、`OperationManager`、`ResponseBuilder`、`BroadcastManager` 等)均为**示例、可自定**(业务 `rpc` 名只需前后端一致);只有平台接缝上的名字(包字段、`export`/`import` 钩子名、平台 API、`data.success`)是契约。详见 [README「命名约定」](./README.md)。 --- ## 1. 文件结构 ### 必需三件套(固定文件名) ``` server/<游戏容器目录>/<你的游戏>/ (容器目录名由接入方自定,非框架强制,见 01) ├── mod.js 模块入口:创建模块、按序加载文件、定义 RPC 方法 ├── export.js 对平台暴露的一组回调接口(平台按需回调,子游戏按需实现) └── import.js 对平台服务的封装接口(本项目 4 个) ``` ### 业务分层(复杂游戏推荐) 按职责拆分,**一个文件一个明确职责**,被依赖者先加载(见 04 的"模块职责边界"): ``` ├── game/ 对局编排:状态管理、控制器、对平台的适配器 ├── rpc/ 收发包:各 RPC handler、广播、响应构建、序列化 ├── rules/ 规则解析与规则引擎 ├── shared/ 前后端共享代码(改这里,再用脚本同步到前端) ├── utils/ 日志、错误处理等工具 └── tests/ 单元/集成测试 ``` > 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变。 --- ## 2. `mod.js` —— 模块入口 `mod.js` 做三件事:**创建模块 → 按依赖顺序加载文件 → 定义 RPC 方法**。 ### 2.1 创建模块 ```js // cls_mod.new(模块名, 路由名, 所属应用) var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app); ``` 路由名即前端包里的 `route`:**必须与前端包的 `route` 一致、且在应用内唯一**;它与目录名并无绑定关系(`routename` 与目录同名只是约定,非框架要求)。 ### 2.2 按依赖顺序加载文件 被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块)。浏览器/友乐用 `min_loadJsFile` 异步链式加载: ```js min_loadJsFile("<容器目录>/<你的游戏>/常量与工具.js", function(){ min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){ /* ...嵌套加载 import.js、业务与rpc.js... */ }); }); ``` > 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。 ### 2.3 定义 RPC 方法(规范) RPC 方法是**前后端交互的服务端入口**。以下是必须遵守的规范(**规则是契约**,示例里的具体方法名/handler 名可自定,见 [README「命名约定」](./README.md))。 #### 规则一:函数名 = 前端包的 `rpc` 字段(无二次路由) 框架的第三层路由(`class.mod.js` 的 `DoPack`)**直接用 `pack.rpc` 当函数名去 `mod` 上取方法调用**——`if (mod[pack.rpc]) mod[pack.rpc](pack)`。所以: - 你在 `mod` 上挂一个**与前端 `rpc` 同名**的方法,框架就会自动调到它;这个"同名映射"是**契约**。 - **一个操作 = 一个 RPC 方法**。**禁止**把多个操作塞进一个入口再用 `switch(action)` 二次路由——那等于绕开框架路由、退化成自建分发。 - 方法名本身(`playCard`/`declareHu`…)由你和前端约定,只要**两端字符串一致**即可;大小写/风格随子游戏。 #### 规则二:方法体只"接住并委托",逻辑放 handler(`mod.js` 保持薄) RPC 方法本身**不写业务逻辑**,只把 `pack` 转交给收发包层的 handler;真正的参数校验、业务编排、广播都在 handler 里: ```js mod_<你的游戏>.playCard = function(pack) { // 就绪守卫:handler 由 min_loadJsFile 异步加载,未就绪时防御性返回(见规则四) if (typeof RpcHandler === 'undefined' || !RpcHandler) { return { success: false, error: 'RPC处理器未就绪' }; } return RpcHandler.handlePlayCard(pack); // 委托到收发包层,真正逻辑在这里 }; ``` > `RpcHandler`、`handlePlayCard` 这些是**本项目的命名示例**;换成任何风格都行,关键是"薄入口 + 委托"的分层。 #### 规则三:`return` 不是给前端的下发通道 RPC 方法/handler 的 `return` 值**不会可靠地下发到前端**(机制见 01 §4、03 §4)。上面示例里 `return { success:false, ... }` 只是**服务端内部/防御用途**;真正让前端看到的结果(含成功/失败)**必须靠 handler 内部主动推送** `o_room.method.sendpack_toseat/sendpack_toother`,且推送 `data` 自带 `success`(见 03 §5)。 #### 规则四:注册时机——handler 就绪后再挂 RPC 方法 浏览器/友乐运行时用 `min_loadJsFile` **异步**加载各文件,RPC 方法委托的 handler 可能**晚于** `mod.js` 主体就绪。因此: - 把 RPC 方法的定义收敛到一个"**依赖加载完成后再执行**"的注册函数里(本项目为 `defineRpcMethods()`,在加载链回调末尾调用),避免在 handler 尚未加载时就引用它。 - 每个 RPC 方法开头再加一道**就绪守卫**(如上例 `typeof RpcHandler === 'undefined'`)兜底,防止极端时序下 `undefined` 崩溃。 #### 新增一个前后端接口的完整步骤 1. 服务端:在 `mod.js` 的 RPC 注册处加 `mod_<游戏>. = function(pack){ return XxxHandler.handleXxx(pack); }`; 2. 服务端:在 handler 层实现 `handleXxx`(安检 → 委托权威业务 → 主动推送,见 03 §2); 3. 前端:发包时带 `rpc: ""`(与服务端方法名一致)。 **不要**在受限前端接口文件里新增接口(见 04),也**不要**用 `switch(action)` 二次路由。 #### 本项目已定义的 RPC 方法(示例参考) `jinxianmahjong` 实际挂了这些 RPC(名字均为示例,仅示范"一操作一方法"的粒度): `playCard`、`declareHu`、`declareGang`/`mingGang`/`anGang`/`buGang`/`declareJingGang`、`declarePeng`、`declareChi`、`passAction`、`playerReady`、`gameStart`、`getRoomInfo`、`declareBaoding`、`rollDiceSelectJing`/`playerRollDice`、`setHostingState`/`cancelHostingState`/`getHostingInfo`。可见:**每个玩家动作/查询各占一个独立 RPC**,无一处用 `action` 二次分发。 --- ## 3. `export.js` —— 平台回调子游戏的接口(按需实现) 平台在房间生命周期的各节点回调子游戏 `export` 上的一组接口。**这些接口全部是"可选钩子"**:平台侧每一个调用都写成 `if (mod_game.export.<接口>) { ... }`(见 `youle/server_room/class.import.js`),**没有任何一个是框架强制必须实现的**——子游戏**按玩法需要实现其中的一部分**,未实现的钩子平台会跳过(部分钩子有平台默认行为)。用工厂模式创建,文件末尾挂到 `mod.export`: ```js var cls_<游戏>_export = cls_<游戏>_export || { new: function() { var exp = {}; exp.get_needroomcard = function(roomtype, o_game_config) { /* ... */ }; exp.get_asetcount = function(roomtype, o_game_config) { /* ... */ }; exp.get_needroomcard_joinroom = function(roomtype, o_game_config) { return 0; }; exp.makewar_playercount = function(roomtype, o_game_config) { /* ... */ }; exp.makewar = function(o_room, o_game_config) { /* ... */ }; exp.get_deskinfo = function(o_room, seat) { /* ... */ }; exp.get_disbandRoom = function(o_room) { /* ... */ }; exp.player_enter = function(o_room, seat) { /* ... */ }; exp.player_leave = function(o_room, seat) { /* ... */ }; // …按需再实现 restore_room / player_prepare / check_*_permission 等 return exp; } }; mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动挂载 ``` ### 3.0 最常用的一批接口 绝大多数房卡玩法都会实现下面这批: | 接口 | 何时被调 | 返回 | 职责 | |------|----------|------|------| | `get_needroomcard` | 创建房间 | Number | 该房型创建需要的房卡数(由你解析 `roomtype`) | | `get_asetcount` | 创建房间 | Number | 该房型总局数(小局数量) | | `get_needroomcard_joinroom` | 他人加入 | Number | 加入需要的房卡数(多数玩法返回 0) | | `makewar` | 开战 | Object | **创建 `o_desk`、初始化对局、返回开战数据包** | | `get_deskinfo` | 重连/中途加入 | Object | **返回该座位当前完整快照** | | `get_disbandRoom` | 解散达成 | Object | 返回解散数据包 | | `player_enter` | 中途进入 | Object? | 处理新玩家加入 | | `player_leave` | 中途退出 | Object? | 处理玩家离开(如清理其托管/占位) | ### 3.0.1 其它常见可选钩子(按需实现) 平台还暴露一批可选钩子,本项目实际用到的有: | 接口 | 何时被调 | 职责 | |------|----------|------| | `makewar_playercount` | 未满桌但有人准备时 | 返回"达到几人准备即自动开战"的人数(无则等满桌) | | `restore_room` | **服务器重启后恢复房间** | 用平台持久化的 `o_deskinfo` 重建对局状态 | | `player_prepare` / `createroom_needprepare` | 准备机制 | 是否需要准备、玩家点准备时的处理 | | `check_joinroom_permission` / `check_createroom_permission` | 加入/创建房间前 | 自定义准入校验 | > 平台的完整钩子清单以 `youle/server_room/class.import.js` 为准(如 `deduct_roomcard_mode`、`owner_beanpush`、`getWinnerByGameInfo` 等);用到哪个就实现哪个,**不要因为"文档列了就全实现"**。 > `roomtype` 是一个由子游戏**自定义、自解析**的房型编码(数组或数字串),各位代表局数/人数/扣卡方式/玩法开关等。它的含义只有你的子游戏知道,平台不解释它。 ### 3.1 `makewar` 是接入的核心 `makewar` 必须完成三件事,缺一不可: ```js exp.makewar = function(o_room, o_game_config) { // 1) 创建牌桌对象,与房间建立【双向引用】 if (!o_room.o_desk) { o_room.o_desk = {}; } if (!o_room.o_desk.data) { o_room.o_desk.data = {}; } o_room.o_desk.o_room = o_room; // 反向引用 // 2) 创建对局状态,挂到 o_room.o_desk.data.*(房间隔离的落点) o_room.o_desk.data.gameState = createGameState(o_room, o_game_config); // 3) 返回开战数据包(通常按座位差异化下发) return { success: true, sendtype: 1, // 差异化标记:平台逐座位下发 seatlist: [ { seat: 0, data: {/* 0号位能看到的 */} }, /* ... */ ] }; }; ``` 要点: - **此时不需要知道"谁"坐在每个位置**,只需知道有几个位置有人——身份由平台管理。 - **对局状态挂 `o_room.o_desk.data.*`**,不要放模块级单例/全局(见 04)。 - 开战包对应前端的 `Game_Modify.StartWar`:**改了 `makewar` 下发结构,必须同步改前端 StartWar 解析**(见 03)。 ### 3.2 `get_deskinfo` 处理重连 重连/中途加入时,平台带着 `seat` 来要"当前快照"。你要把该座位此刻该看到的一切(手牌仅本人可见、弃牌区、轮到谁、可用操作、倒计时、比分等)从 `o_room.o_desk.data.*` 组装返回。它对应前端的 `Game_Modify.Reconnect`。 > 关键:重连快照应与正常对局推送**复用同一套状态组装逻辑**,避免两份并行实现随规则演化而分叉(数据权威原则,见 04)。 --- ## 4. `import.js` —— 子游戏调用平台的 4 个接口 这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**。每个都形如 `imp.<接口> = function(...args){ return mod_<你的游戏>.app.youle_room.export.<接口>(...args); }`,本项目 4 个: ```js mod_<你的游戏>.import = (function() { var imp = {}; imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) { /* → youle_room.export.check_player(...) */ }; imp.deduct_roomcard = function(o_room) { /* → youle_room.export.deduct_roomcard(o_room) */ }; imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) { /* → youle_room.export.save_grade(...) */ }; imp.finish_gametask = function(agentid, o_player, taskid, finishamount) { /* → youle_room.export.finish_gametask(...) */ }; return imp; })(); ``` | 接口 | 调用时机(关键) | 作用 | |------|------------------|------| | `check_player` | **每个 RPC handler 开头** | 校验玩家位置/连接,返回 `o_room` 或 `null` | | `deduct_roomcard` | **第一小局结算时(仅一次)** | 扣房卡。注意**不是开战时扣** | | `save_grade` | **大局完全结束、战绩算好后** | 保存战绩;调用后**平台自动释放房间** | | `finish_gametask` | 完成游戏内任务时(可选) | 上报任务进度 | > 时机错误是高频 bug:`deduct_roomcard` 放到开战时会导致重复/错误扣卡;`save_grade` 在结算数据没算完就调用会保存到残缺战绩。 --- ## 5. 一次玩家操作的完整数据流 把 01 的路由和本篇的接口串起来,一次"出牌"从收包到下发如下(其它操作同构): ``` ① 客户端发包 { app:"youle", route:"<游戏>", rpc:"playCard", data:{ roomcode, seat, ... } } │ 三层路由(见 01) ▼ ② mod.<游戏>.playCard(pack) → 委托 RpcHandler.handlePlayCard(pack) │ ▼ ③ handler:参数提取 + 校验 var o_room = mod.import.check_player(...); // 校验玩家;失败直接 return if (!o_room) return; var o_desk = o_room.o_desk; if (!o_desk) return; // 状态/轮次/合法性校验(轮到该座?这张牌在手里?) │ ▼ ④ 执行业务(委托给权威业务模块,handler 不内联规则) var result = OperationManager.handleOperation(o_room, params); │ ▼ ⑤ 构建响应(按需补充摸牌、可用操作、倒计时、比分、游戏状态等) var responseData = ResponseBuilder.buildPlayCardResponse(...); │ ▼ ⑥ 差异化广播:为每个座位定制其「可见数据」并主动推送 BroadcastManager.broadcastPlayCard(o_room, responseData); → 内部对每个座位组 msg,调用 o_room.method.sendpack_toseat(msg, seat) → 前端按既有 playCard 逻辑解析(无需区分触发源) ``` 每个环节的纪律: - **③ 校验必做且失败静默 `return`**:不要给客户端回作弊提示,`check_player` 失败、状态不对、轮次不对都直接 `return`。 - **④ 不在 handler 内重造规则**:胡牌检测、听牌分析、合法操作枚举等核心算法调用权威模块,handler 只编排(见 04)。 - **⑥ 主动推送、自带成败**:下发**唯一靠 `sendpack_toseat/toother` 主动推送**;凡有成败语义的推送,`data` 必须带 `success`(见 03)。 - **服务端代替玩家操作(如 AI 托管)走的是同一条 ④⑤⑥ 链路**,只是触发源从"前端请求"变成"服务端决策",对前端透明(见 04)。 --- ## 6. 接入自检清单 新接入或改动子游戏时,逐条核对: - [ ] `mod.js` 用 `cls_mod.new` 创建,路由名与前端 `route` 一致;文件按依赖顺序加载。 - [ ] `export.js` 按玩法需要实现相应回调接口(至少含"最常用的一批",见 §3)并在末尾挂到 `mod.export`。 - [ ] `makewar` 创建了 `o_desk`、建立 `o_room.o_desk` ↔ `o_desk.o_room` 双向引用、对局态挂 `o_desk.data.*`、返回开战包。 - [ ] `get_deskinfo` 能给出与正常对局一致的当前快照(复用状态组装,不另写一份)。 - [ ] `import.js` 4 接口就位;`deduct_roomcard` 在首局结算调、`save_grade` 在终局调。 - [ ] 每个 RPC handler:先 `check_player` → 取 `o_desk` → 校验 → 委托业务 → 主动推送。 - [ ] 改了下发结构,前端 `StartWar`/`Reconnect`/对应操作解析同步检查。 下一篇 [03-数据收发与通信协议](./03-数据收发与通信协议.md) 详解包结构、发包方式、主动推送与 `success` 成败协议。