Files
erqiwang_youle/docs/server/development-guide/02-子游戏接入与开发流程.md
T

310 lines
18 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.
# 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` 通常在业务类之后加载(它们会用到业务模块):
```js
// 浏览器/友乐:min_loadJsFile 异步链式加载
min_loadJsFile("<容器目录>/<你的游戏>/常量与工具.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/数据结构.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/import.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/业务与rpc.js", function(){
console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成");
});});});});});
```
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → 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_<你的游戏> 上挂的 RPC 方法(示例名,可自定)
mod_<你的游戏>.playCard = function(pack) {
// 就绪守卫:handler 由 min_loadJsFile 异步加载,未就绪时防御性返回(见规则四)
if (typeof RpcHandler === 'undefined' || !RpcHandler) {
return { success: false, error: 'RPC处理器未就绪' };
}
return RpcHandler.handlePlayCard(pack); // 委托到收发包层,真正逻辑在这里
};
mod_<你的游戏>.declareHu = function(pack) {
return RpcHandler.handleDeclareHu(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_<游戏>.<rpc> = function(pack){ return XxxHandler.handleXxx(pack); }`;
2. 服务端:在 handler 层实现 `handleXxx`(安检 → 委托权威业务 → 主动推送,见 03 §2);
3. 前端:发包时带 `rpc: "<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.*(房间隔离的落点)
var gameState = createGameState(o_room, o_game_config);
o_room.o_desk.data.gameState = gameState; // 此后所有业务都从这里读对局态
// 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` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**:
```js
mod_<你的游戏>.import = (function() {
var imp = {};
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) {
return mod_<你的游戏>.app.youle_room.export.check_player(
agentid, gameid, roomcode, seat, playerid, conmode, fromid);
};
imp.deduct_roomcard = function(o_room) {
return mod_<你的游戏>.app.youle_room.export.deduct_roomcard(o_room);
};
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) {
return mod_<你的游戏>.app.youle_room.export.save_grade(
o_room, o_gameinfo1, o_gameinfo2, freeroomflag);
};
imp.finish_gametask = function(agentid, o_player, taskid, finishamount) {
return mod_<你的游戏>.app.youle_room.export.finish_gametask(
agentid, o_player, taskid, finishamount);
};
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` 成败协议。
</content>