Files
erqiwang_youle/docs/server/development-guide/01-服务端环境与框架基础.md
T
2026-07-06 17:16:05 +08:00

204 lines
12 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.
# 01 · 服务端环境与框架基础
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。
> 举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。
> **命名说明**:代码示例里的类/文件/变量名(如 `GameStateManager` 等)均为**示例、可自定**;只有平台接缝上的名字(包字段、`export`/`import` 钩子名、平台 API 如 `cls_mod.new`/`min_loadJsFile`/`sendpack_toseat`)是契约。详见 [README「命名约定」](./README.md)。
---
## 1. 运行环境:一套代码,两个运行时
子游戏代码**同时运行在两套环境**,写每一行都要同时考虑:
| 运行时 | 场景 | 模块加载方式 | 是否有 `require` |
|--------|------|--------------|------------------|
| **Node.js** | 本地、单元测试 | `require()` | 有 |
| **浏览器 / 友乐平台** | 线上部署 | 由 `mod.js` 用 `min_loadJsFile` 统一加载为**全局对象** | **没有** |
由此引出两条硬约束(详见 04):
1. **严格 ES5**:用 `var`/`function`,禁止 `let`/`const`/箭头函数/模板串/`class`/`Promise` 等;线上浏览器/友乐运行时不保证 ES6+。
2. **`require` 只能在文件开头的守卫块内**:
```js
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.js` `ReceivePack` 按 `pack.app == applist[i].appname` 分发。
- 第 2 级:`server/class/class.app.js` `ReceivePack` 按 `pack.route == modlist[i].routename` 调 `DoPack`。
- 第 3 级:`server/class/class.mod.js` `DoPack` 按 `_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. 模块注册与全局暴露
子游戏模块用框架基类创建,并自动注册进应用:
```js
// 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-子游戏接入与开发流程](./02-子游戏接入与开发流程.md) 讲:这三个文件具体怎么写、8+4 个接口各做什么、一次操作的完整数据流。