Files
youle_framework/docs/server/development-guide/02-子游戏接入与开发流程.md
T
joywayerandClaude Opus 4.8 f2f618b965 降耗②·铺开:按试点尺度精简其余 12 篇编号正文
沿用 client 02 的保守尺度(规范条款/表格/关键代码/标题/链接全保留,
只压缩冗长代码示例、重复解说、演进历史、✅/❌ 成对代码块)逐篇精简
client 01/03/04/05/06、server 01/02/03/04、engineering 01/02/03。

- 12 篇合计 78,171 → 74,726 字符(省 3,445,~4.4%);红线密集篇(client
  05、server 04)极保守、几乎不动,符合"不丢规范优先于省字数"。
- 已核验:51 个跨文档链接目标全部存在、3 个锚点全部命中真实标题;
  server 03 §6、server 04 §8/§10/§11 等被引用小节标题逐字未改;README 未动。
- 每篇均随附规范保留清单逐条自查(并行子代理完成、逐份复核)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:52:16 +08:00

17 KiB
Raw Blame History

02 · 子游戏接入与开发流程

本篇讲怎么动手:一个子游戏由哪三个必需文件构成、export.js 的一组回调接口与 import.js 的封装接口各做什么、makewar/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。

仍以麻将举例,但"三文件架构 + export/import 接口 + 主动推送"对任意子游戏一致。

命名说明:本篇代码里的业务 rpc 名(playCard、declareHu 等)与 handler/类/文件名(RpcHandler.handlePlayCard、OperationManager、ResponseBuilder、BroadcastManager 等)均为示例、可自定(业务 rpc 名只需前后端一致);只有平台接缝上的名字(包字段、export/import 钩子名、平台 API、data.success)是契约。详见 README「命名约定」。


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 创建模块

// cls_mod.new(模块名, 路由名, 所属应用)
var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);

路由名即前端包里的 route:必须与前端包的 route 一致、且在应用内唯一;它与目录名并无绑定关系(routename 与目录同名只是约定,非框架要求)。

2.2 按依赖顺序加载文件

被依赖的先加载;export.js/import.js 通常在业务类之后加载(它们会用到业务模块)。浏览器/友乐用 min_loadJsFile 异步链式加载:

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「命名约定」)。

规则一:函数名 = 前端包的 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 里:

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_<游戏>.<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:

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 必须完成三件事,缺一不可:

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 个:

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-数据收发与通信协议 详解包结构、发包方式、主动推送与 success 成败协议。