初始化仓库:友乐/gameabc 房卡游戏平台脚手架(client + server + docs)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:13:26 +08:00
co-authored by Claude Opus 5
commit 594820d393
655 changed files with 310861 additions and 0 deletions
@@ -0,0 +1,247 @@
# 02 · 子游戏接入与开发流程
本篇讲**怎么动手**:一个子游戏由哪三个必需文件构成、`export.js` 的 8 个接口与 `import.js` 的 4 个接口各做什么、`makewar`/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。
> 仍以麻将举例,但"三文件架构 + 8/4 接口 + 主动推送"对任意子游戏一致。
---
## 1. 文件结构
### 必需三件套(固定文件名)
```
server/games2/<你的游戏>/
├── mod.js 模块入口:创建模块、按序加载文件、定义 RPC 方法
├── export.js 对平台暴露的 8 个必需接口(+ 可选接口)
└── 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`,要与目录/约定一致。
### 2.2 按依赖顺序加载文件
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块):
```js
// 浏览器/友乐:min_loadJsFile 异步链式加载
min_loadJsFile("games2/<你的游戏>/常量与工具.js", function(){
min_loadJsFile("games2/<你的游戏>/数据结构.js", function(){
min_loadJsFile("games2/<你的游戏>/export.js", function(){
min_loadJsFile("games2/<你的游戏>/import.js", function(){
min_loadJsFile("games2/<你的游戏>/业务与rpc.js", function(){
console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成");
});});});});});
```
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。
### 2.3 定义 RPC 方法(一操作一函数,委托给 handler)
每个客户端操作对应一个 RPC 方法。**方法本身只做"接住请求并委托"**,真正逻辑放在 `rpc/handlers/*`,保持 `mod.js` 薄:
```js
mod_<你的游戏>.playCard = function(pack) {
return RpcHandler.handlePlayCard(pack); // 委托到收发包层
};
mod_<你的游戏>.declareHu = function(pack) {
return RpcHandler.handleDeclareHu(pack);
};
```
**新增一个前后端接口** = 在 `mod.js` 加一个 `mod_<游戏>.<rpc> = function(pack){...}` + 前端包里 `rpc: "<rpc>"`。**不要**在受限前端接口文件里新增接口(见 04),也**不要**用 `switch(action)` 二次路由。
---
## 3. `export.js` —— 平台调用子游戏的 8 个必需接口
平台在房间生命周期的各节点回调这 8 个接口。用工厂模式创建,文件末尾挂到 `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 = 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) { /* ... */ };
return exp;
}
};
mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动挂载
```
| 接口 | 何时被调 | 返回 | 职责 |
|------|----------|------|------|
| `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? | 处理玩家离开(如清理其托管/占位) |
> `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` 实现 8 个必需接口并在末尾挂到 `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>