12 KiB
01 · 服务端环境与框架基础
本篇建立全局认知:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。
举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。
命名说明:代码示例里的类/文件/变量名(如
GameStateManager等)均为示例、可自定;只有平台接缝上的名字(包字段、export/import钩子名、平台 API 如cls_mod.new/min_loadJsFile/sendpack_toseat)是契约。详见 README「命名约定」。
1. 运行环境:一套代码,两个运行时
子游戏代码同时运行在两套环境,写每一行都要同时考虑:
| 运行时 | 场景 | 模块加载方式 | 是否有 require |
|---|---|---|---|
| Node.js | 本地、单元测试 | require() |
有 |
| 浏览器 / 友乐平台 | 线上部署 | 由 mod.js 用 min_loadJsFile 统一加载为全局对象 |
没有 |
由此引出两条硬约束(详见 04):
-
严格 ES5:用
var/function,禁止let/const/箭头函数/模板串/class/Promise等;线上浏览器/友乐运行时不保证 ES6+。 -
require只能在文件开头的守卫块内:if (typeof require !== 'undefined') { var GameStateManager = require('./dataStructures/GameStateManager.js'); } // 之后按全局名引用 GameStateManager.xxx()浏览器运行时没有
require,任何无守卫的、函数体内的中途require都会抛ReferenceError: require is not defined。跨模块统一按全局名引用:Node 由守卫块var X = require(...)得到,浏览器由mod.js加载的同名全局解析,二者靠共享全局作用域下的同名var统一。
前后端是物理分离的两端
- 客户端跑在用户浏览器,服务端跑在 Node。两端不是函数调用,而是 WebSocket/HTTP 收发 JSON 包,全程异步。
- 前端代码不走 npm 构建,前后端共享代码靠文件复制同步(见 04 的 shared 同步流程)。
- 服务器权威:凡房卡、胜负、积分、房间状态等关键数据/操作,一律以服务器为准,前端只负责显示,不能"前端说什么就是什么"。
2. 平台代码 vs 子游戏代码
server/ ← 平台框架(除子游戏容器目录外,全部禁改)
├── packet.js 总收发包入口、应用列表、最终 SendPack
├── applist.js 加载各应用(server / youle / update)
├── class/ 框架基础类
│ ├── class.app.js 应用基类 cls_app(按 route 路由到模块)
│ ├── class.mod.js 模块基类 cls_mod(按 rpc 路由到方法)
│ └── class.desk.js / ... 桌、牌等基础类
├── youle/ 友乐应用(appname = "youle")
│ ├── app.js 创建 youle_app
│ └── server_room/ 房间模块 youle_room(routename = "room")
│ ├── class.room.js 房间对象 o_room(含 seatlist、发包方法)
│ ├── class.player.js 座位/玩家对象(含 conmode/fromid)
│ ├── class.export.js 框架对外服务:check_player / deduct_roomcard / save_grade ...
│ └── class.import.js 框架回调子游戏:makewar_deskwar / get_disbandRoom / player_* ...
└── <游戏容器目录>/ ← 子游戏容器(目录名由接入方自定,非框架强制)
└── <你的游戏>/ ← 子游戏(唯一可编辑目录)
├── mod.js 模块入口:创建模块、加载文件、定义 RPC 方法
├── export.js 子游戏对平台暴露的一组回调接口(按需实现,见 02)
├── import.js 子游戏对平台服务的封装接口(本项目 4 个)
└── ... 你的玩法业务代码
容器目录名不是框架约定:容器目录名由接入方自定。框架不按目录名找子游戏——运行时由
youle_room.app[o_room.o_game.modename]按模块名定位(见 §5)。接入一个新游戏只需两步:① 在server/youle/app.js里加一行min_loadJsFile("<容器目录>/<你的游戏>/mod.js", ...)加载它;② 在mod.js里用唯一的modname/routename注册。因此容器目录换成任何不与框架冲突的名字都可以(app.js是唯一必须触碰的平台文件,属游戏注册接入点)。
一句话:平台提供"网络 + 房间 + 玩家 + 房卡 + 战绩"的地基,子游戏只往自己的目录里填"玩法"。两边的接缝就是 export / import(详见 02)。
3. 核心对象模型
理解五个对象的层级关系,就理解了框架的数据骨架:
app (youle_app) 一个应用,appname = "youle"
└── modlist[] 应用下挂的所有模块
├── youle_room 平台房间模块,routename = "room"
└── mod_<你的游戏> 你的子游戏模块,routename = "<你的游戏>"
o_room 房间对象(平台创建,一桌一个)
├── roomcode / roomtype / asetcount / battlestate ... 平台基础数据
├── seatlist[] 座位列表,长度=满桌人数
│ └── seatlist[seat] = o_player 座位上的玩家对象
│ ├── playerid / nickname / onstate
│ └── conmode / fromid 连接方式与连接ID(发包要用)
├── method.sendpack_toseat(msg, seat) 平台发包方法
├── method.sendpack_toother(msg, seat)
└── o_desk ★ 子游戏的牌桌对象(由你在 makewar 里创建并挂上)
├── o_room 反向引用回房间
└── data.* ★ 你的对局状态全挂这里(房间隔离的落点)
要点:
o_room由平台创建并持有;seatlist[seat]是玩家对象,发包所需的conmode/fromid就在它上面。o_desk由子游戏创建(在makewar中),并与o_room建立双向引用:o_room.o_desk = desk; desk.o_room = o_room;。- 对局状态一律挂在
o_room.o_desk.data.*。这是"房间隔离"的物理落点:每桌一份,互不串扰(见 04)。
举例:麻将把游戏状态放在
o_room.o_desk.data.roomAdapter.gameState,把操作队列放在o_room.o_desk.data.operationQueue。换成斗地主、跑得快也是同一套落点,只是data.*下的字段不同。
4. 三层路由:一个包如何到达你的函数
平台把客户端发来的包,按 app → route → rpc 三级精确投递到子游戏的某个函数。
{ app: "youle", route: "<你的游戏>", rpc: "playCard", data: { ... } }
| 级 | 在哪 | 依据 | 动作 |
|---|---|---|---|
| 1 | packet.js packet_face.ReceivePack |
pack.app |
在 applist 里找到 appname == pack.app 的应用,调 app.ReceivePack(pack) |
| 2 | class/class.app.js cls_app.ReceivePack |
pack.route |
在 app.modlist 里找到 routename == pack.route 的模块,调 mod.DoPack(pack) |
| 3 | class/class.mod.js cls_mod.DoPack |
pack.rpc |
若 mod[pack.rpc] 存在,调用 mod[pack.rpc](pack) |
证据:
- 第 1 级:
server/packet.jsReceivePack按pack.app == applist[i].appname分发。 - 第 2 级:
server/class/class.app.jsReceivePack按pack.route == modlist[i].routename调DoPack。 - 第 3 级:
server/class/class.mod.jsDoPack按_msg.rpc调_obj_mod[_msg.rpc](_msg)。
对开发者的含义:你在 mod.js 里写下 mod_<游戏>.playCard = function(pack){...},前端只要发 {route:"<你的游戏>", rpc:"playCard", ...},框架就会自动调到它——无需自己写路由分发,更不要在一个总入口里用 switch(action) 做二次路由(一个操作对应一个 RPC 方法)。
⚠️ 关于 DoPack 的返回值(重要)
cls_app.ReceivePack 在拿到 DoPack 的返回值后,确实会执行一次 app.SendPack(repack)。但这个回发:
- 只回给当前请求的那条连接,且不经过"按座位定向"的处理;
- 在浏览器/友乐链路下不是子游戏向前端推送状态的可靠通道。
因此本项目的铁律是:子游戏 RPC handler 一律通过 o_room.method.sendpack_toseat / sendpack_toother 主动推送来下发结果,不依赖 return。这条直接推导出 03 的"成败标志必须放在主动推送的 data.success 里"。
5. 模块注册与全局暴露
子游戏模块用框架基类创建,并自动注册进应用:
// cls_mod.new(模块名, 路由名, 所属应用)
var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
cls_mod.new 做了三件事:把模块 push 进 app.modlist、在 app 上挂 app[模块名]=mod、并通过 OutputMod 把模块暴露为全局名(global[modname])。这套"双运行时全局暴露"是浏览器/友乐能按全局名找到模块的基础。
双运行时陷阱:凡"构造函数 + 单例实例"式模块,
module.exports之后必须无条件把全局名暴露出去(不要塞进else分支),否则线上浏览器拿不到该全局,报xxx is not a function,而 Node 测试却测不出来。
export.js / import.js 在各自文件末尾把接口对象挂到 mod.export / mod.import,mod.js 只负责按顺序加载它们(见 02)。
6. 房间生命周期
一个房间从创建到回收,平台与子游戏分工如下。理解每个阶段"谁调用谁",是接入的关键:
创建房间
平台调 export.get_needroomcard / get_asetcount / get_needroomcard_joinroom
→ 决定房卡与总局数(此时还没有 o_desk,子游戏不需要知道谁坐在哪)
满桌/房主开战
平台调 export.makewar(o_room, o_game_config)
→ 子游戏创建 o_desk、初始化对局状态、返回开战数据包
→ 平台把开战包下发所有客户端(对应前端 StartWar)
→ o_room.battlestate = 1
对局进行中
客户端发 RPC → mod[rpc](pack) → 子游戏处理 → 主动推送结果
断线重连 / 中途加入
平台调 export.get_deskinfo(o_room, seat)
→ 子游戏返回该座位「当前完整快照」(对应前端 Reconnect)
每小局结算
第一小局结算时:子游戏调 import.deduct_roomcard(o_room) 扣房卡(仅此一次)
大局(整场)结束
子游戏算完最终战绩后调 import.save_grade(o_room, ...)
→ 平台保存战绩并「自动释放房间」,子游戏无需再回收房间本身
解散房间
平台调 export.get_disbandRoom(o_room) 取解散数据包下发
玩家中途进出
平台调 export.player_enter / player_leave 通知子游戏处理
子游戏自己创建的东西,自己负责回收:定时器、托管/决策状态、各类缓存等,必须能按房间定位,并在小局结束、解散、开新局时清理干净,禁止泄漏到下一局或别的房间(见 04 的房间隔离)。
7. 小结
- 一套代码两个运行时 → 强制 ES5 + 守卫块 require。
- 对象层级:
app → mod、o_room → seatlist[seat](o_player) / o_desk(.data.*)。 - 三层路由:
app(按app) → route(按route) → rpc(按rpc),框架自动投递,你只写mod.rpc 方法。 DoPack的return不是可靠下发通道 → 一律主动sendpack_toseat推送。- 房间生命周期由 export/import 接缝串起:
makewar开局、get_deskinfo重连、deduct_roomcard首局扣卡、save_grade终局保存并自动回收。
下一篇 02-子游戏接入与开发流程 讲:这三个文件具体怎么写、8+4 个接口各做什么、一次操作的完整数据流。