Files
youle_framework/server/docs/development-guide/01-服务端环境与框架基础.md
T

11 KiB
Raw Blame History

01 · 服务端环境与框架基础

本篇建立全局认知:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。读懂这一篇,后面的接入与协议才有坐标。

举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。


1. 运行环境:一套代码,两个运行时

子游戏代码同时运行在两套环境,写每一行都要同时考虑:

运行时 场景 模块加载方式 是否有 require
Node.js 本地、单元测试 require() 有
浏览器 / 友乐平台 线上部署 由 mod.js 用 min_loadJsFile 统一加载为全局对象 没有

由此引出两条硬约束(详见 04):

  1. 严格 ES5:用 var/function,禁止 let/const/箭头函数/模板串/class/Promise 等;线上浏览器/友乐运行时不保证 ES6+。

  2. require 只能在文件开头的守卫块内:

    // ✅ 正确:集中在顶部守卫块;运行时按全局名引用
    if (typeof require !== 'undefined') {
        var GameStateManager = require('./dataStructures/GameStateManager.js');
    }
    // ... 之后直接用全局名 GameStateManager.xxx() ——浏览器由 mod.js 加载为同名全局
    

    浏览器运行时没有 require,任何无守卫的、函数体内的中途 require 都会抛 ReferenceError: require is not defined,导致功能崩溃。跨模块统一按全局名引用:Node 由守卫块 var X = require(...) 得到,浏览器由 mod.js 加载的同名全局解析,二者靠共享全局作用域下的同名 var 统一。

前后端是物理分离的两端

  • 客户端跑在用户浏览器,服务端跑在 Node。两端不是函数调用,而是 WebSocket/HTTP 收发 JSON 包,全程异步。
  • 前端代码不走 npm 构建,前后端共享代码靠文件复制同步(见 04 的 shared 同步流程)。
  • 服务器权威:凡房卡、胜负、积分、房间状态等关键数据/操作,一律以服务器为准,前端只负责显示,不能"前端说什么就是什么"。

2. 平台代码 vs 子游戏代码

server/                         ← 平台框架(除 games2/<你的游戏>/ 外,全部禁改)
├── 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_* ...
└── games2/
    └── <你的游戏>/             ← 子游戏(唯一可编辑目录)
        ├── mod.js              模块入口:创建模块、加载文件、定义 RPC 方法
        ├── export.js           子游戏对平台暴露的 8 个必需接口
        ├── import.js           子游戏对平台服务的 4 个封装接口
        └── ...                 你的玩法业务代码

一句话:平台提供"网络 + 房间 + 玩家 + 房卡 + 战绩"的地基,子游戏只往 games2/<你的游戏>/ 里填"玩法"。两边的接缝就是 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. 模块注册与全局暴露

子游戏模块用框架基类创建,并自动注册进应用:

// 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 个接口各做什么、一次操作的完整数据流。