初始化仓库:友乐/gameabc 房卡游戏平台脚手架(client + server + docs)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 01 · 前端架构与运行环境
|
||||
|
||||
本篇建立**全局认知**:前端跑在什么环境、由哪几层构成、新旧两套架构如何并存、文件按什么顺序加载。读懂这一篇,后面的渲染、系统、网络才有坐标。
|
||||
|
||||
> 举例以麻将为主,但 `gameabc-framework` 是游戏中立的通用框架,本篇机制对任意子游戏一致。
|
||||
|
||||
---
|
||||
|
||||
## 1. 运行环境
|
||||
|
||||
- **部署形态**:前端是**浏览器静态资源**,由 gameabc 引擎(`js/vendor/gameabc.min.js`)驱动,绘制基于精灵(Sprite)/图层(Layer)/群组(Group)的 2D 画面。不走 npm 构建。
|
||||
- **与服务端**:通过平台网络层收发 JSON 包(`{app, route, rpc, data}`),全程异步;服务端权威,前端只显示与发起操作。
|
||||
- **ES5 强制**:线上浏览器/平台不保证 ES6+。全部 JS 必须严格 ES5——用 `var`/`function`,对象继承用 `Object.create`,禁 `let`/`const`/箭头函数/模板字符串/`class`/解构/默认参数。
|
||||
- **少量 Node 仅用于测试/工具**:`shared/` 算法可在 Node 跑单测,`scripts/` 是 Spine 数据构建脚本;线上运行时无 `require`,跨文件一律按**全局名**引用(各文件用 `var X = ...` 暴露全局,`index.html` 顺序加载)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层结构:平台 / 框架 / 子游戏
|
||||
|
||||
```
|
||||
js/vendor/ 第三方与引擎(禁改)
|
||||
gameabc.min.js gameabc 引擎原生 API
|
||||
jquery / spine-canvas.js
|
||||
|
||||
js/00_Surface/ 前端平台代码(禁改)
|
||||
Const/Data/Desk/Func/Game/GameUI/Logic/Net/Player/Utl_* ...
|
||||
|
||||
js/gameabc-framework/ ★ 通用模板框架(游戏中立,可复用)
|
||||
core/ GameABCUtils(唯一直连引擎)、SpriteManager(业务级精灵 API)
|
||||
system/ EventBus、AnimationManager、AudioManager
|
||||
ui/ BaseComponent、UIManager、SpriteCopyUtils、DynamicSpriteList
|
||||
spine/ SpineMgr
|
||||
templates/ *.template.js(派生子游戏时复制填充的模板)
|
||||
|
||||
js/01_SubGame/ 子游戏前端
|
||||
00_/01_/02_SubGame_*.js ★ 受限对接层(平台入口,尽量不改)
|
||||
codes/ ★ 子游戏实例(单向依赖框架;内部结构由子游戏自行组织)
|
||||
shared/ 前后端共享算法(服务端为权威源,同步而来,只读)
|
||||
```
|
||||
|
||||
> `codes/` 内部如何分目录、如何命名文件,均由子游戏自行决定,本套文档不作规定;唯一例外是 `shared/`——它是服务端共享算法的同步副本,前端只读(见 05)。
|
||||
|
||||
依赖方向是**单向**的:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架**不反向依赖**任何子游戏(这正是上一步把 `EventBus` 里的麻将事件剥离出去的原因,见 03/05 的「框架中立」)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新旧两套架构并存(重要)
|
||||
|
||||
前端当前**两套架构并存**,理解边界才不会改错地方:
|
||||
|
||||
| | 旧架构(受限对接层) | 新架构(gameabc-framework + codes) |
|
||||
|--|----------------------|--------------------------------------|
|
||||
| 位置 | `js/00_Surface/`、`01_SubGame/00_/01_/02_SubGame_*.js` | `js/gameabc-framework/`、`01_SubGame/codes/` |
|
||||
| 角色 | 平台框架的**固定入口**,承接平台事件 | 子游戏真正的渲染/逻辑/网络实现 |
|
||||
| 可改性 | 平台代码禁改;受限文件不新增接口、尽量不改 | 在此自由开发 |
|
||||
|
||||
**对接方式**:平台事件先进旧的 `Game_Modify.*` 入口,旧入口**只做转交**,把数据委托给新架构处理(详见 04):
|
||||
|
||||
```
|
||||
平台 → Game_Modify.appStart() → 启动初始化 启动
|
||||
平台 → Game_Modify.StartWar(_msg) → 开局处理 开局
|
||||
平台 → Game_Modify._ReceiveData() → 收包分发(按 rpc 分发) 对局推送
|
||||
平台 → Game_Modify.Reconnect(info) → 重连处理(据数据重画) 重连
|
||||
```
|
||||
|
||||
红线:**受限文件里只做“接住并转交”,业务逻辑写在新架构的 controllers/handlers 里**,不要在 `Game_Modify.*` 里堆逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 4. 加载顺序(index.html)
|
||||
|
||||
`index.html` 按严格顺序加载脚本,**顺序即依赖**。典型分段:
|
||||
|
||||
```
|
||||
阶段0 引擎与 Spine vendor/gameabc.min.js → generated/spine_* → spine/SpineMgr.js
|
||||
阶段1 框架核心 + 常量 gameabc-framework/core·system → 子游戏共享常量
|
||||
阶段1.5 子游戏事件常量 追加到 EventBus.Events
|
||||
阶段2 工具/数据结构/算法 子游戏共享算法
|
||||
阶段3 精灵/资源/布局常量 整合入口最后加载
|
||||
阶段4 框架 UI + 业务视图 gameabc-framework/ui/* → 子游戏视图组件
|
||||
阶段5 控制器/管理器/网络 子游戏消息处理/资源管理/收发包
|
||||
阶段6 旧受限对接层 01_SubGame/00_/01_/02_SubGame_*.js
|
||||
```
|
||||
|
||||
> 上表按“职责”分段,非规定 `codes/` 的目录布局;具体目录/文件由子游戏自定,只要**被依赖者先加载**即可。
|
||||
|
||||
加载顺序的两条铁律:
|
||||
1. **被依赖者先加载**。例如框架 `EventBus.js` 必须在子游戏事件常量之前、事件常量又必须在任何注册这些事件的视图之前;整合所有精灵常量的入口文件必须**最后**加载。
|
||||
2. **新增文件要插对位置**。新增一个常量/组件文件,必须在 `index.html` 里插到其依赖之后、使用者之前,否则运行时拿到 `undefined`。
|
||||
|
||||
> 示例:把玩法专属事件从框架剥到子游戏事件常量文件时,正是把它插在 `EventBus.js`(框架)之后、其使用者(视图组件)之前,引用零改动。
|
||||
|
||||
---
|
||||
|
||||
## 5. codes/ 内部组织
|
||||
|
||||
`codes/` 是子游戏的自由开发区,**目录如何划分、文件如何命名,由子游戏自行决定**,本套文档不作强制规定。
|
||||
|
||||
只需把握两条通用原则(不依赖具体目录布局):
|
||||
|
||||
- **职责分离、调用不重造**:一个职能只在一处实现,其他地方调用它,不复制近似逻辑(渲染/动画/音频/收发包/共享算法等,见 05「模块职责边界」)。
|
||||
- **`shared/` 是唯一受约束的子目录**:它是服务端共享算法的同步副本,前端**只读**,改服务端权威源再同步(见 05「shared 同步」)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 小结
|
||||
|
||||
- 前端是浏览器静态资源 + gameabc 引擎,**严格 ES5**,跨文件按全局名引用、`index.html` 顺序加载。
|
||||
- 三层:平台(禁改) / 框架(游戏中立、可复用) / 子游戏(单向依赖框架)。
|
||||
- 新旧架构并存:平台事件经旧 `Game_Modify.*` 入口**转交**到新架构 handler;受限文件只接不写。
|
||||
- 加载顺序即依赖,新增文件务必插对位置。
|
||||
|
||||
下一篇 [02-渲染与UI组件体系](./02-渲染与UI组件体系.md) 讲:精灵怎么画、资源常量怎么组织、UI 组件怎么写。
|
||||
</content>
|
||||
@@ -0,0 +1,302 @@
|
||||
# 02 · 渲染与 UI 组件体系
|
||||
|
||||
本篇讲**界面怎么搭**:精灵 ID 体系与分层调用、资源/布局常量三件套、UI 组件的生命周期、场景切换与动态列表。这是前端日常写得最多的部分。
|
||||
|
||||
> 麻将作举例(手牌、弃牌、吃碰杠按钮),但精灵/常量/组件的范式对任意子游戏一致。
|
||||
|
||||
---
|
||||
|
||||
## 1. 精灵与渲染分层
|
||||
|
||||
gameabc 的画面由**精灵(Sprite)**组成,按**图层(Layer)**、**群组(Group)**组织。操作精灵走一条严格分层链:
|
||||
|
||||
```
|
||||
UI 组件
|
||||
└─ SpriteManager 业务级 API:ID 范围校验 + 单位换算
|
||||
└─ GameABCUtils 唯一直接调引擎原生 API 的模块(底层原子操作)
|
||||
└─ gameabc.min.js 引擎
|
||||
```
|
||||
|
||||
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。分层的意义:ID 校验、单位换算、引擎版本隔离都集中在边界,业务层无感。
|
||||
|
||||
### SpriteManager 常用 API
|
||||
|
||||
| API | 作用 | 说明 |
|
||||
|-----|------|------|
|
||||
| `show(id)` / `hide(id)` | 显示/隐藏精灵 | 返回 `boolean`,**失败要查返回值** |
|
||||
| `setFrame(id, frame)` | 切换多帧图片的帧 | 一个精灵多帧,靠切帧表现不同牌面,**胜过为每张牌建一个精灵** |
|
||||
| `setPosition(id, x, y)` | 设置坐标 | |
|
||||
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
|
||||
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
|
||||
| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
|
||||
| `showGroup(gid)` / `hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
|
||||
| `showLayer(lid)` / `hideLayer(lid)` | 图层批量 | 整个界面显隐 |
|
||||
| `exists(id)` | 存在性检查 | |
|
||||
|
||||
```js
|
||||
// 显示手牌:一个精灵切帧表现不同牌面
|
||||
for (var i = 0; i < handCards.length; i++) {
|
||||
var sid = handSpriteIds[i];
|
||||
SpriteManager.show(sid);
|
||||
SpriteManager.setFrame(sid, handCards[i].code - 1); // 帧号 = code-1
|
||||
}
|
||||
// 刷新弃牌区:先全隐藏,再按数据显示
|
||||
for (var k = 0; k < maxDiscards; k++) { SpriteManager.hide(discardIds[k]); }
|
||||
for (var j = 0; j < discards.length; j++) {
|
||||
SpriteManager.show(discardIds[j]);
|
||||
SpriteManager.setFrame(discardIds[j], discards[j].code - 1);
|
||||
}
|
||||
```
|
||||
|
||||
### 精灵 ID 二元性
|
||||
|
||||
精灵 ID 有两种,`SpriteManager` 透明支持,开发者无需区分:
|
||||
|
||||
- **数字 ID**:编辑器里预置的精灵(如 `1121`)。
|
||||
- **字符串 ID**:运行时由 `SpriteCopyUtils` 动态复制出的精灵,格式 `"容器IDadd标签"`(如 `"2836add0"`)。
|
||||
|
||||
### ID 范围(必须遵守)
|
||||
|
||||
| 资源 | 子游戏可用范围 | 框架保留 |
|
||||
|------|----------------|----------|
|
||||
| 精灵 ID | **1001–3000** | 1–1000、3001+ |
|
||||
| 群组 ID | **≥ 201** | 1–200 |
|
||||
| 图文资源 | **≥ 1001** | 1–1000 |
|
||||
| 图片资源 | **≥ 501** | 1–500 |
|
||||
| 声音 | **≥ 101** | 1–100 |
|
||||
| 图层 | **101–200、301–400、501–600、701+** | 1–100、201–300、401–500、601–700 |
|
||||
|
||||
图层按 100 为段与框架交替分配:子游戏用 `101–200`(常规界面)、`301–400`(弹窗,示例用 302)、`501–600`、`701+`;其余段为框架保留,**不可占用**。
|
||||
|
||||
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `SpriteManager` 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 资源与布局常量三件套
|
||||
|
||||
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职:
|
||||
|
||||
| 常量类别 | 管什么 | 一句话 |
|
||||
|----------|--------|--------|
|
||||
| 精灵结构常量 | **精灵 ID 与结构** | 每个界面有哪些精灵、归哪个图层/群组 |
|
||||
| 图片资源常量 | **图片资源 ID** | 每个精灵用哪张图、几帧、每帧什么、尺寸 |
|
||||
| 布局常量 | **坐标/尺寸/偏移** | 纯数据,精灵摆在哪、多大、怎么排 |
|
||||
|
||||
外加一个**整合入口**把三类统一为一份精灵常量,并**最后加载**。
|
||||
|
||||
### 资源与精灵手动创建,常量靠注释指路
|
||||
|
||||
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在游戏编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的**完全一致**(见 §1,绝不编造)。
|
||||
|
||||
正因资源是"先手动建、再按 ID 引用",**常量定义必须写准注释,让人据注释就能准确创建出对应资源**:
|
||||
|
||||
- **精灵结构常量**:每个精灵注明【精灵类型(图片精灵 / 文字精灵)+ 功能 + 关联的图片资源 ID + 帧数说明】。
|
||||
- **图片资源常量**:每个资源注明【帧数(单帧 / N 帧,且每帧含义)+ 用途 + 尺寸】。
|
||||
|
||||
(声音、Spine 资源的注释要求见 03。资源 ID 范围与注释格式细则以《[友乐游戏引擎精灵与资源管理接口规范](../../../docs/important/client/友乐游戏引擎精灵与资源管理接口规范.md)》为准。)
|
||||
|
||||
### 三者如何配合(新增一块 UI 的流程)
|
||||
|
||||
以“新增一个弹窗”为例:
|
||||
|
||||
1. **精灵结构常量**:定义精灵结构与 ID(弹窗图层 301–400 段、群组、各精灵 ID 1001–3000)。
|
||||
2. **图片资源常量**:定义所需图片资源 ID(背景、行模板…),注明帧数/帧义/尺寸。
|
||||
3. **布局常量**:定义坐标尺寸(列表容器、行高、各列偏移)。
|
||||
4. **整合入口**:把新结构并入统一的精灵常量。
|
||||
5. **写组件**:UI 代码只引用精灵常量与 `SpriteManager`,不出现任何裸数字。
|
||||
|
||||
### 几条约定
|
||||
|
||||
- **按钮只要 1 帧普通态图**:点击的缩放/透明反馈由框架统一处理,不要为“按下态”单独配图。
|
||||
- **帧动画只在图片资源常量记资源 ID**,帧区间/帧间隔/循环等播放参数放动画配置(见 03),不在资源文件重复。
|
||||
- **布局文件是纯数据**:无函数、无副作用。侧视角的“透视倾斜”用每张牌累积的透视偏移量表达。
|
||||
- **多文件用保护性声明**:`var XxxConstants = XxxConstants || {};` 便于拆分到主界面/弹窗多个文件,加载后合并为一份。
|
||||
|
||||
> 模板 `gameabc-framework/templates/*.template.js` 是这三类文件的**写法蓝本**,含详细的字段注释与 `@see` 指向实现文件。派生新子游戏时复制模板再按实际资源填充。
|
||||
|
||||
---
|
||||
|
||||
## 3. UI 组件:BaseComponent
|
||||
|
||||
所有视图组件继承框架的 `BaseComponent`,获得统一的**生命周期**与**事件自动清理**。
|
||||
|
||||
### 标准结构
|
||||
|
||||
```js
|
||||
var MyView = Object.create(BaseComponent);
|
||||
|
||||
MyView.init = function (config) {
|
||||
// ⚠️ 必须在 init 内重新声明实例属性,避免原型共享污染
|
||||
this.sprites = [];
|
||||
this.eventListeners = {};
|
||||
this.isVisible = false;
|
||||
this.isInitialized = false;
|
||||
this.isDestroyed = false;
|
||||
this.name = config.name || 'MyView';
|
||||
this.layer = config.layer || 102;
|
||||
|
||||
// 创建精灵(ID 从精灵常量取,见 §2)
|
||||
this.addSprite(spriteConstants.SOME_PANEL_BG);
|
||||
|
||||
// 注册事件(用 addEventListener,destroy 时自动清理)
|
||||
var self = this;
|
||||
this.addEventListener(EventBus.Events.GAME_STARTED, function (data) {
|
||||
self._onGameStarted(data);
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
};
|
||||
```
|
||||
|
||||
### 生命周期钩子
|
||||
|
||||
| 钩子 | 何时 | 是否覆盖 |
|
||||
|------|------|----------|
|
||||
| `init(config)` | 创建时 | ✅ 子类必须实现:建精灵、注册事件 |
|
||||
| `show()` / `hide()` | 显隐 | ❌ 通常不覆盖(内部显隐图层并回调 `onShow/onHide`) |
|
||||
| `destroy()` | 销毁 | ❌ 不覆盖(自动清理事件监听、隐藏精灵,回调 `onDestroy`) |
|
||||
| `onShow/onHide/onDestroy` | 上述内部末尾 | ✅ 可选:播放动画、清定时器/动态精灵 |
|
||||
|
||||
### 事件必须走 `addEventListener`(防内存泄漏)
|
||||
|
||||
```js
|
||||
// ❌ 直接 EventBus.on:destroy 时不会自动 off,监听残留 → 泄漏
|
||||
EventBus.on(EventBus.Events.GAME_STARTED, fn);
|
||||
|
||||
// ✅ BaseComponent.addEventListener:destroy() 自动 off
|
||||
this.addEventListener(EventBus.Events.GAME_STARTED, fn);
|
||||
```
|
||||
|
||||
`BaseComponent.addEventListener` 会包装处理函数(绑定 `this`、捕获异常、销毁后忽略事件),并记录下来在 `destroy()` 时统一 `EventBus.off`。**销毁组件用 `destroy()` 而非只 `hide()`** —— 只有 `destroy()` 才清理监听。
|
||||
|
||||
---
|
||||
|
||||
## 4. UIManager:注册与场景切换
|
||||
|
||||
`UIManager` 管理所有组件实例与**场景**(一组组件的具名快照)。
|
||||
|
||||
```js
|
||||
// 启动时(在启动编排内)
|
||||
UIManager.init(); // 若有 Loading/Message/Confirm 全局 UI,则注入其精灵常量(见下)
|
||||
|
||||
var gameView = MyView.create({ name: 'GameView', layer: 102 });
|
||||
UIManager.registerComponent('GameView', gameView);
|
||||
UIManager.registerComponent('RoomView', RoomView.create({ name: 'RoomView' }));
|
||||
|
||||
// 注册场景:场景名 → 组件名列表
|
||||
UIManager.registerScene(UIManager.SCENES.GAME, ['GameView', 'SomeOtherView']);
|
||||
UIManager.registerScene(UIManager.SCENES.ROOM, ['RoomView']);
|
||||
|
||||
// 切换场景:自动隐藏旧场景组件、显示新场景组件
|
||||
UIManager.switchToScene(UIManager.SCENES.GAME);
|
||||
```
|
||||
|
||||
`UIManager` 还提供全局 UI:`showLoading/hideLoading`、`showMessage`、`showConfirm`。这三个全局 UI 的精灵常量由**子游戏注入**——框架不读取任何子游戏全局名,子游戏在 `init` 时传入配置:
|
||||
|
||||
```js
|
||||
UIManager.init({
|
||||
loadingUI: spriteConstants.LOADING_UI,
|
||||
messageUI: spriteConstants.MESSAGE_UI,
|
||||
confirmUI: spriteConstants.CONFIRM_UI
|
||||
}); // 不传则跳过全局 UI;也可后续 UIManager.configureGlobalUI(config)
|
||||
```
|
||||
|
||||
**游戏专属弹窗**用“向 UIManager 挂方法”的扩展模式(在独立 JS 里 `UIManager.showCreateRoom = function(){...}`),保持框架本身中立。
|
||||
|
||||
---
|
||||
|
||||
## 5. 精灵交互事件:SpriteEventController
|
||||
|
||||
精灵的点击/拖拽/绘制等交互,由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发——它是**游戏中立**的通用能力,玩法专属逻辑通过钩子注册,不写进框架。
|
||||
|
||||
平台引擎的交互事件先进受限入口 `Game_Modify.utlmousedown/mouseup/utlmousemove/utlgamemydrawbegin/gamemydraw`,这些入口**只把事件转调**给 `SpriteEventController.handle*`,由它按「精灵 ID」分发到注册的回调。
|
||||
|
||||
### 按精灵 ID 注册回调
|
||||
|
||||
```js
|
||||
// 在组件 init 里注册(精灵 ID 从常量取,见 §2)
|
||||
SpriteEventController.registerMouseUp(sprites.BTN_START, function (event) {
|
||||
self._onStart(); // event 含 spriteId/坐标/偏移等
|
||||
});
|
||||
SpriteEventController.registerMouseMove(sprites.HAND_AREA, function (event) {
|
||||
self._onDrag(event.offset);
|
||||
});
|
||||
// 批量:registerBatch(spriteIds, 'mouseDown', handler)
|
||||
// 注销:unregister(spriteId[, eventType])
|
||||
```
|
||||
|
||||
支持的事件类型:`mouseDown` / `mouseDownNoMove`(长按) / `mouseUp` / `mouseMove`(拖拽) / `drawBegin` / `draw`。
|
||||
|
||||
### 全局 draw 钩子
|
||||
|
||||
需要对**每个**绘制精灵统一处理(不针对某个固定 ID)的能力,用 `registerGlobalDraw` 注册——框架对每个 draw 事件都回调它,但**不认识**其业务含义,保持中立:
|
||||
|
||||
```js
|
||||
// 例:某种标记叠绘——子游戏自行挂载,框架零感知
|
||||
SpriteEventController.registerGlobalDraw(function (spriteId) {
|
||||
// 子游戏自定义的叠绘逻辑
|
||||
});
|
||||
```
|
||||
|
||||
> 这套通用精灵事件分发原先躺在子游戏 `controllers` 里、还被框架反向依赖;现已抽进框架,并把「精牌标记」这类玩法耦合改为全局 draw 钩子(见 05「框架中立」)。
|
||||
|
||||
### 更高层:手势识别 `SpriteGestureRecognizer`
|
||||
|
||||
需要识别**双击 / 滑动 / 点击**等手势时,用框架 `system/SpriteGestureRecognizer.js`(建立在 `SpriteEventController` 之上)。它把原始按下/移动/松开识别为语义手势,业务只写回调:
|
||||
|
||||
```js
|
||||
var handle = SpriteGestureRecognizer.attach(spriteIds, {
|
||||
onPress: function (e) {}, // 按下即时反馈(如元素站起)
|
||||
onDoubleTap: function (e) {}, // 双击同一精灵
|
||||
onSwipe: function (e) {}, // 沿 e.direction 滑动越阈
|
||||
onTap: function (e) {} // 点击(小位移按下→松开)
|
||||
}, { doubleTapInterval: 300, swipe: { direction: 'up', threshold: 50 }, minMove: 10 });
|
||||
// handle.detach() / resetDoubleTap() / resetDrag()
|
||||
```
|
||||
|
||||
阈值由子游戏注入(框架只给默认值、不反读子游戏常量);双击/上滑/点击判定全在框架,选中/出牌等业务全留子游戏。
|
||||
|
||||
> 范例:手牌的「双击出牌 / 上划出牌 / 点击选中取消」由子游戏的交互处理器用本识别器实现——手势识别归框架,玩法业务归子游戏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 动态列表:SpriteCopyUtils 与 DynamicSpriteList
|
||||
|
||||
行数不定的列表(如听牌提示、战绩行)用**动态复制精灵**实现,不要为每行预置精灵。
|
||||
|
||||
- **`SpriteCopyUtils`**(底层):从“模板精灵”复制出带 `tag` 的子精灵,返回字符串 ID;`create/remove/removeRange`。
|
||||
- **`DynamicSpriteList`**(高级):封装容器裁剪区、行高、每列模板、滚动与点击,开发者只 `setData` + `onClick`。
|
||||
|
||||
```js
|
||||
var list = new DynamicSpriteList({
|
||||
containerId: spriteConstants.SOME_LIST_CONTAINER,
|
||||
clipArea: { x: 0, y: 0, width: 640, height: 480 },
|
||||
rowHeight: 80,
|
||||
templates: {
|
||||
rowBg: { spriteId: 2837 },
|
||||
card: { spriteId: 2838, offset: { x: 20, y: 10 } },
|
||||
score: { spriteId: 2839, offset: { x: 120, y: 10 } }
|
||||
}
|
||||
});
|
||||
list.setData(rows); // 数据驱动
|
||||
list.onClick = function (type, rowIndex, rowData) { /* ... */ };
|
||||
// 组件 onDestroy 中:list.destroy(); ← 必须,否则复制精灵残留
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 本篇 DO / DON'T
|
||||
|
||||
| DO ✅ | DON'T ❌ |
|
||||
|------|---------|
|
||||
| UI 只调 `SpriteManager` | 直接调 `GameABCUtils`/引擎原生 API |
|
||||
| 一精灵多帧、`setFrame` 切换 | 为每种牌面建一个精灵 |
|
||||
| 精灵 ID 1001–3000、群组 ≥201、从编辑器查证 | 随意编造 ID / 超范围 ID |
|
||||
| 检查 `SpriteManager` 返回值 | 忽略 `false` 返回 |
|
||||
| 精灵/资源/坐标全进常量文件 | 在业务代码内联裸数字 |
|
||||
| 组件继承 `BaseComponent`,事件用 `addEventListener` | 自建组件类 / 直接 `EventBus.on` |
|
||||
| 销毁用 `destroy()`,`onDestroy` 清动态精灵与定时器 | 只 `hide()` 就当销毁 |
|
||||
| 动态列表用 `DynamicSpriteList` 并 `destroy` | 手搓 SpriteCopy 又忘清理 |
|
||||
|
||||
下一篇 [03-事件·动画·音频·Spine](./03-事件·动画·音频·Spine.md) 讲表现系统:事件总线、动画、音频与 Spine。
|
||||
</content>
|
||||
@@ -0,0 +1,184 @@
|
||||
# 03 · 事件 · 动画 · 音频 · Spine
|
||||
|
||||
本篇讲**表现系统**:模块间怎么用事件解耦、动画怎么发起与配置、音效/语音怎么分层、Spine 骨骼动画怎么播。四套系统的共同主线是——**框架提供中立能力,子游戏只填玩法专属的配置**。
|
||||
|
||||
---
|
||||
|
||||
## 1. EventBus:模块间事件总线
|
||||
|
||||
`EventBus` 是发布-订阅总线,用于解耦模块(控制器改完数据 `emit`,视图 `on` 后刷新),避免直接相互调用。
|
||||
|
||||
### 命名与中立原则
|
||||
|
||||
- 事件名两段式 `"模块:动作"`,统一定义在 `EventBus.Events` 常量里,**不硬编码字符串**。
|
||||
- **框架不预置任何事件常量**:`gameabc-framework/system/EventBus.js` 只提供发布-订阅机制与一个**空容器** `EventBus.Events = {}`,不内置哪怕通用语义的事件名。
|
||||
- **全部事件由子游戏集中定义**:在子游戏侧的事件常量文件把本子游戏用到的**全部**事件(通用语义 + 玩法专属)注册到 `EventBus.Events`,业务侧统一 `EventBus.Events.XXX` 引用。
|
||||
|
||||
```js
|
||||
// 子游戏事件常量文件 —— 在 EventBus 之后、使用者之前加载
|
||||
(function () {
|
||||
if (typeof EventBus === 'undefined' || !EventBus.Events) {
|
||||
throw new Error('[GameEvents] EventBus 未加载;请检查 index.html 加载顺序');
|
||||
}
|
||||
var E = EventBus.Events;
|
||||
// 通用语义事件
|
||||
E.GAME_STARTED = 'game:started';
|
||||
E.PLAYER_DISCARDED = 'player:discarded';
|
||||
E.SCENE_CHANGED = 'ui:sceneChanged';
|
||||
// 玩法专属事件
|
||||
E.TILES_DEALT = 'mahjong:tilesDealt';
|
||||
E.MELD_FORMED = 'mahjong:meldFormed';
|
||||
// ...本子游戏用到的全部事件
|
||||
})();
|
||||
```
|
||||
|
||||
> 这正是本仓库的落点:框架 `EventBus` 不预置任何事件常量、保持纯粹中立,所有事件(含通用语义)都由子游戏在其事件常量文件定义——既保证框架可被任意玩法复用,也避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
|
||||
|
||||
### 用法
|
||||
|
||||
```js
|
||||
EventBus.emit(EventBus.Events.GAME_STARTED, { playerCount: 4 }); // 发布
|
||||
this.addEventListener(EventBus.Events.GAME_STARTED, function (d) { // 订阅(组件内)
|
||||
self._onStart(d);
|
||||
});
|
||||
EventBus.once(EventBus.Events.GAME_FINISHED, fn); // 一次性
|
||||
```
|
||||
|
||||
组件内**务必用 `BaseComponent.addEventListener`** 订阅(`destroy` 自动清理,见 02),不要裸用 `EventBus.on`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 动画:AnimationManager + 配置
|
||||
|
||||
### 数据优先、表现延后(核心节奏)
|
||||
|
||||
收到服务端推送后的固定节奏:
|
||||
|
||||
```
|
||||
1) 先更新数据模型(不动 UI)
|
||||
2) 播放动画(纯视觉过渡,不改数据)
|
||||
3) 动画完成回调里刷新静态界面
|
||||
```
|
||||
|
||||
好处:动画是“锦上添花”,**即使不播动画,静态界面也始终正确**(重画函数据本地数据即可还原,见 04 重连)。**动画期间绝不修改游戏数据。**
|
||||
|
||||
### AnimationManager API(框架,通用)
|
||||
|
||||
```js
|
||||
AnimationManager.move(id, fromX, fromY, toX, toY, duration, 'easeOut', cb);
|
||||
AnimationManager.scale(id, 1.0, 1.3, 150, 'easeOut', cb);
|
||||
AnimationManager.rotate(id, 0, 360, 300, 'linear', cb);
|
||||
AnimationManager.fade(id, 1.0, 0.0, 200, 'linear', cb);
|
||||
AnimationManager.frame(id, startFrame, endFrame, duration, { loop:false, callback:cb });
|
||||
|
||||
// 串/并行队列
|
||||
var q = AnimationManager.createQueue('serial'); // 或 'parallel'
|
||||
AnimationManager.addToQueue(q, { type:'move', spriteId:id, /* ... */ });
|
||||
AnimationManager.playQueue(q, cb);
|
||||
```
|
||||
|
||||
缓动可选:`linear/easeIn/easeOut(最常用)/easeInOut/bounce/elastic`。
|
||||
|
||||
### 动画参数集中配置(子游戏)
|
||||
|
||||
**所有动画时长/帧区间/帧间隔/循环模式集中在子游戏的动画配置文件**,做到“配置即改、不改代码”。业务代码从配置读取,**禁止硬编码时长/帧数**。
|
||||
|
||||
```js
|
||||
// 动画配置(子游戏):一个动作一份配置
|
||||
SOME_ACTION: { PLAY_TYPE:'frame', RESOURCE_ID:554, START_FRAME:1, END_FRAME:35,
|
||||
FRAME_INTERVAL:50, LOOP:false, HIDE_ON_COMPLETE:true }
|
||||
|
||||
// 使用:用工具方法从配置自动算帧数/时长,再交给框架播放
|
||||
AnimationManager.frame(spriteId, opt.startFrame, opt.endFrame, opt.duration, opt);
|
||||
```
|
||||
|
||||
### 框架动画 vs 游戏动画分层
|
||||
|
||||
- **`AnimationManager`(框架)**:通用的 move/scale/fade/frame/队列,不懂具体玩法元素。
|
||||
- **游戏动画封装(子游戏)**:玩法专属封装——算各元素坐标、组织分段动画、从动画配置取参数。
|
||||
|
||||
业务调游戏动画封装的语义化方法,由它再去调 `AnimationManager`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 音频:AudioManager + 资源 + 游戏音频管理
|
||||
|
||||
三层分离,**框架不含任何玩法专属的音效 ID 映射**:
|
||||
|
||||
```
|
||||
业务代码
|
||||
└─ 游戏音频管理(子游戏:概念→键名,如 'peng'/牌值→资源键)
|
||||
├─ 音效资源常量(子游戏:键名→文件 ID)
|
||||
└─ AudioManager(框架:通用播放、按性别选语音)
|
||||
└─ Utl(引擎播放)
|
||||
```
|
||||
|
||||
### 各层职责
|
||||
|
||||
- **`AudioManager`(框架)**:`playSound(file)`、`playVoice(baseId, sex)`、`playVoiceBySeat(seat, baseId)`、`playMusic/stopMusic`。约定女音 ID = 男音 ID + 偏移;**不含任何牌值/动作映射**。
|
||||
- **音效资源常量(子游戏)**:集中定义音效/语音文件 ID(音效、男/女语音等分类)。新增音效**只改这里**。
|
||||
- **游戏音频管理(子游戏)**:把“出牌/碰/某张牌”等概念映射到音效资源键,再调 `AudioManager`。**不写文件名/数字 ID**。
|
||||
|
||||
```js
|
||||
// 子游戏音频管理:按概念播放,内部映射到资源键并按座位性别选男/女语音
|
||||
// 通用音效直接走框架:AudioManager.playSound(音效资源常量.某音效)
|
||||
```
|
||||
|
||||
**声音文件手动准备**:音频文件由开发者**手动**按平台命名规范放入资源目录(文件名/存放目录/ID→文件名转换规则以《[友乐…规范](../../../docs/important/client/友乐游戏引擎精灵与资源管理接口规范.md)》为准),代码只按 ID 引用、不生成文件。因此**音效资源常量每个 ID 必须注明【类型(背景音乐 / 音效 / 语音)+ 用途 + 时长 + 播放方式(循环 / 单次)】**,让人据注释就能准确准备对应音频。
|
||||
|
||||
**DO**:新音效只改音效资源常量;业务只调游戏音频管理。**DON'T**:在游戏音频管理里写裸文件名/ID;硬编码性别映射。
|
||||
|
||||
---
|
||||
|
||||
## 4. Spine:骨骼动画
|
||||
|
||||
Spine 用于复杂特效动画(吃碰杠胡的炫光等),集成进 gameabc 绘制循环,零侵入主循环。
|
||||
|
||||
### 三层
|
||||
|
||||
| 层 | 归属 | 职责 |
|
||||
|----|------|------|
|
||||
| 框架 | `spine/SpineMgr.js` | 加载/播放/每帧更新绘制、回调转发 |
|
||||
| 子游戏配置 | Spine 动作配置 | 概念→Spine 资源(文件 + 动画名)映射 |
|
||||
| 子游戏分发 | Spine 回调分发 | 集中接收 Spine 回调并按模块分发 |
|
||||
|
||||
### 用法
|
||||
|
||||
```js
|
||||
// Spine 动作配置(子游戏):概念 → 资源
|
||||
'chi': { spineId:'chi', animName:'play', scale:1.0 },
|
||||
'tianhu': { spineId:'tianhu', animName:'play', scale:1.0 },
|
||||
|
||||
// 播放(SpineMgr)
|
||||
SpineMgr.load(id, jsonFile, atlasFile, { scale:1.0, animation:'idle', loop:true });
|
||||
SpineMgr.setAnimation(id, 'play', false, 0);
|
||||
SpineMgr.playOnce(id, 'play', 0); // 播一次后隐藏
|
||||
SpineMgr.playQueue(id, ['p1','p2'], true);
|
||||
SpineMgr.updateAndDraw(ctx); // 每帧(引擎循环内自动)
|
||||
```
|
||||
|
||||
### 回调集中分发
|
||||
|
||||
`SpineMgr` 把动画完成/帧事件转发到全局 `gameabc_face.spine_onComplete/spine_onEvent`,由子游戏的 **Spine 回调分发器统一分发**到各模块,不要在业务里散接回调。
|
||||
|
||||
### 资源手动制作与变更流程
|
||||
|
||||
Spine 骨骼资源(`json` / `atlas` / 贴图)由开发者**手动**制作并放入资源目录,代码只按概念引用、不生成资源。改了 Spine 动作配置的资源映射后,**运行 Spine 数据构建脚本**重新生成 Spine 数据清单(`generated/spine_*`)。
|
||||
|
||||
**Spine 动作配置每个动作必须注明【概念(对应哪个玩法动作)+ 资源文件 + 动画名 + `scale` 等参数】**,让人据注释就能准确准备对应 Spine 资源。
|
||||
|
||||
**DO**:概念走 Spine 动作配置、回调走 Spine 回调分发器、改资源后跑构建脚本。**DON'T**:硬编码 `spineId/animName`;绕过分发器直接接回调。
|
||||
|
||||
---
|
||||
|
||||
## 5. 本篇 DO / DON'T 总表
|
||||
|
||||
| 系统 | DO ✅ | DON'T ❌ |
|
||||
|------|------|---------|
|
||||
| 事件 | 名字进 `EventBus.Events`;专属事件放子游戏事件常量文件 | 硬编码事件字符串;往框架塞玩法事件 |
|
||||
| 动画 | 先改数据→播动画→回调刷 UI;参数取自动画配置 | 动画期改数据;硬编码时长/帧数 |
|
||||
| 音频 | 新音效只改音效资源常量;业务只调游戏音频管理 | 游戏音频管理写裸 ID;框架含玩法映射 |
|
||||
| Spine | 概念走 Spine 动作配置,回调走 Spine 回调分发器,改资源跑脚本 | 硬编码 spineId/animName;散接回调 |
|
||||
|
||||
下一篇 [04-网络对接与启动编排](./04-网络对接与启动编排.md) 讲:怎么收发包、新旧架构如何对接、一局怎么启动。
|
||||
</content>
|
||||
@@ -0,0 +1,171 @@
|
||||
# 04 · 网络对接与启动编排
|
||||
|
||||
本篇讲**前端怎么和服务端打通、一局怎么启动**:发包链路、收包分发、新旧架构的对接边界、成败判定、启动顺序与 controllers/managers 的职责。
|
||||
|
||||
> **想看前后端端到端全链路**(一个包从前端出发 → 服务端三层路由/handler → 推回前端分发的完整往返、及各步在谁的哪个文件)见服务端 [`server/docs/development-guide/03 §6`](../../../server/docs/development-guide/03-数据收发与通信协议.md#6-端到端收发全链路前后端对照)。本篇聚焦**前端这一侧**的收发细节。
|
||||
|
||||
---
|
||||
|
||||
## 1. 发包:语义化发包封装 → RpcHelper
|
||||
|
||||
前端发起操作时**不直接拼包**,走两层封装:
|
||||
|
||||
```
|
||||
业务/控制器
|
||||
└─ 语义化发包封装(子游戏:一个操作一个语义化方法)
|
||||
└─ RpcHelper(框架基座:自动注入平台字段,调引擎发送)
|
||||
└─ Utl.sendData(app, route, rpc, data)
|
||||
```
|
||||
|
||||
### RpcHelper(自动注入平台字段)
|
||||
|
||||
`RpcHelper` 把每个请求自动补齐平台必需字段,业务只传业务数据:
|
||||
|
||||
```js
|
||||
// 自动注入:agentid / gameid / playerid / roomcode / seat ...
|
||||
RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路由
|
||||
```
|
||||
|
||||
### 语义化发包封装(子游戏)
|
||||
|
||||
子游戏为每个操作暴露一个语义化发包方法,内部补 `seat/roomcode` 等业务字段后经 `RpcHelper` 发送;业务侧只调这些语义化方法,不关心平台字段。
|
||||
|
||||
**DO**:发包一律走语义化发包封装 / `RpcHelper`。**DON'T**:在业务里直接 `Utl.sendData` 手拼包、漏注入平台字段。
|
||||
|
||||
> 平台字段缺失(如 `playerid` 为 0/空)应 fail-fast 暴露,不静默发出残缺包。
|
||||
|
||||
---
|
||||
|
||||
## 2. 收包:统一分发
|
||||
|
||||
服务端的主动推送统一格式 `{ rpc, data }`,前端有**唯一收包入口**,再按 `rpc` 分发到对应处理器。收包分发器是**纯路由表 + 分发器**:
|
||||
|
||||
```js
|
||||
// 纯路由表 + 分发器:一 rpc 一处理器
|
||||
var _handlers = {
|
||||
'someRpc': function (d) { return someHandler.handleSomeRpc(d); },
|
||||
'anotherRpc': function (d) { return anotherHandler.handleAnother(d); },
|
||||
'error': function (d) { console.error('[dispatch]', d.message); }
|
||||
// ...每个 rpc 一条
|
||||
};
|
||||
|
||||
function dispatch(msg) { // msg = { rpc, data }
|
||||
var h = _handlers[msg.rpc];
|
||||
if (h) { h(msg.data); }
|
||||
else { console.warn('[dispatch] 未处理的 rpc:', msg.rpc); }
|
||||
}
|
||||
```
|
||||
|
||||
**新增一种服务端推送** = 在路由表加一条 `rpc → 处理器` + 在对应处理器里写业务。**收包分发器只分发,不写业务逻辑。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 新旧架构对接边界
|
||||
|
||||
平台事件先进旧的受限入口 `Game_Modify.*`,旧入口**只解包、只转交**,把数据委托给新架构:
|
||||
|
||||
| 平台入口(受限文件,尽量不改) | 转交到(新架构) | 作用 |
|
||||
|--------------------------------|------------------|------|
|
||||
| `Game_Modify.appStart()` | 启动编排 | 启动初始化 |
|
||||
| `Game_Modify.StartWar(_msg)` | 开局处理 | 开局 |
|
||||
| `Game_Modify._ReceiveData(_msg)` | 收包分发 | 对局推送分发 |
|
||||
| `Game_Modify.Reconnect(info)` | 重连处理 | 断线重连/重画 |
|
||||
|
||||
### 开局解包要点(StartWar)
|
||||
|
||||
服务端 `makewar` 常用**差异化下发**(`sendtype:1` + `seatlist[]`)。`StartWar` 先按本座位取出自己的数据,再交给新架构的开局处理:
|
||||
|
||||
```js
|
||||
Game_Modify.StartWar = function (_msg) {
|
||||
var raw = _msg.data.deskwar || _msg.data;
|
||||
var gameData = raw;
|
||||
if (raw && raw.sendtype === 1 && Array.isArray(raw.seatlist)) {
|
||||
for (var i = 0; i < raw.seatlist.length; i++) {
|
||||
if (raw.seatlist[i].seat === C_Player.seat) { gameData = raw.seatlist[i].data; break; }
|
||||
}
|
||||
}
|
||||
// 委托新架构的开局处理,受限入口本身不写业务
|
||||
};
|
||||
```
|
||||
|
||||
### 重连(Reconnect)= 重画
|
||||
|
||||
重连入口拿到服务端 `get_deskinfo` 的完整快照,交由新架构的重连处理**据本地数据重建整个界面**。重连与“切 app 重画”应复用同一条重画路径——**任何时候执行重画函数都能还原正确界面**(如网页刷新)。这与「数据优先、表现延后」一脉相承:界面永远能从数据无歧义地重建。
|
||||
|
||||
**红线**:受限文件 `Game_Modify.*` 里**只接不写**,业务逻辑全部在新架构 handler 中。
|
||||
|
||||
---
|
||||
|
||||
## 4. 成败判定:只认 `data.success`
|
||||
|
||||
> 与服务端 [`server/docs/development-guide/03`](../../../server/docs/development-guide/03-数据收发与通信协议.md) 同一条协议。
|
||||
|
||||
前端 RPC 没有“同步返回”,操作结果由服务端**后续主动推送**告知。判成败的唯一权威是推送 `data` 里的 **`success`**:
|
||||
|
||||
```js
|
||||
// 处理器统一写法
|
||||
function handleXxx(data) {
|
||||
if (!data || !data.success) { /* 失败处理 */ return; }
|
||||
// 成功逻辑
|
||||
}
|
||||
```
|
||||
|
||||
- **只认 `data.success`**,不用 `status`/`code` 判成败,不写 `status` 兼容兜底。
|
||||
- 个别综合推送的 success 在**嵌套字段**(如 `gameSettleComplete` 的 `data.gameSettle.success`)——按该推送的契约取对应位置,仍以 `success` 语义为准,不改用 status。
|
||||
|
||||
---
|
||||
|
||||
## 5. 启动编排
|
||||
|
||||
一局前端的启动由一段**启动编排**一次性完成(经 `Game_Modify.appStart` 触发):
|
||||
|
||||
```
|
||||
启动编排
|
||||
1) 检查依赖 检查 EventBus/SpriteManager/UIManager/各 View 就绪
|
||||
2) 初始化系统 SpriteManager.init / AnimationManager.init / UIManager.init
|
||||
3) 注册视图 创建并注册各 View 到 UIManager
|
||||
4) 标记初始化完成
|
||||
```
|
||||
|
||||
启动后即等待平台事件:`StartWar` 开局、`_ReceiveData` 推送、`Reconnect` 重连,分别转交新架构。
|
||||
|
||||
---
|
||||
|
||||
## 6. controllers 与 managers 职责
|
||||
|
||||
| 类别 | 角色 | 典型成员 |
|
||||
|------|------|----------|
|
||||
| **controllers**(处理器) | 接收网络消息 → 改数据/UI → 必要时 `emit` 事件 | 按消息类型分工的处理器(流程、操作、结算、特殊规则等) |
|
||||
| **managers**(资源/状态) | 管资源与跨模块能力 | 音频、Spine 回调分发、玩法标记、特效管理等 |
|
||||
|
||||
> 精灵交互事件**不属于**子游戏 controllers:它由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发。子游戏在组件 `init` 里用 `SpriteEventController.registerMouseDown/registerMouseUp/registerMouseMove/...` 注册按精灵 ID 的回调;需对每个绘制精灵统一处理的能力用 `registerGlobalDraw` 注册全局 draw 钩子。平台入口 `Game_Modify.utlmousedown/mouseup/...` 只把事件转调给它。
|
||||
|
||||
调用链示例:
|
||||
|
||||
```
|
||||
平台 _ReceiveData(_msg)
|
||||
→ 收包分发(_msg)
|
||||
→ 操作处理器(data)
|
||||
├ 改数据模型
|
||||
├ 播动画(经游戏动画封装)
|
||||
├ 动画回调里刷新静态界面
|
||||
└ 播音效(经游戏音频管理)
|
||||
```
|
||||
|
||||
**红线**:业务逻辑放 controllers/managers,**不**写进受限的 `Game_Modify.*`;处理器只处理自己那类消息,需要别的能力调对应 manager,不重造。
|
||||
|
||||
---
|
||||
|
||||
## 7. 本篇 DO / DON'T
|
||||
|
||||
| DO ✅ | DON'T ❌ |
|
||||
|------|---------|
|
||||
| 发包走语义化发包封装/`RpcHelper` | 业务里直接 `Utl.sendData` 手拼包 |
|
||||
| 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 |
|
||||
| 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 |
|
||||
| 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 |
|
||||
| 重连/重画复用同一路径,据数据重建界面 | 重连单写一套与正常对局不同的渲染 |
|
||||
| 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 |
|
||||
|
||||
下一篇 [05-开发规范与红线](./05-开发规范与红线.md) 汇总前端所有工程纪律。
|
||||
</content>
|
||||
@@ -0,0 +1,130 @@
|
||||
# 05 · 开发规范与红线
|
||||
|
||||
本篇汇总前端**必须遵守**的工程纪律,是代码审查清单的来源。改任何代码前按相关条目自检。前面 01–04 是“怎么做”,本篇是“不许怎么做 / 必须怎么做”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 可编辑范围
|
||||
|
||||
| 区域 | 可改性 |
|
||||
|------|--------|
|
||||
| `js/vendor/`、`js/00_Surface/` | **禁改**(第三方/平台代码) |
|
||||
| `01_SubGame/00_SubGame_Config.js` | **仅可改值**:只给**已有** `Game_Config.*` 配置项赋值;**禁止**新增/删除/改名/改结构定义(三文件中唯一子游戏可碰者,且仅限改值)。详见 [06](./06-子游戏接入模式与Hooks外置.md) |
|
||||
| `01_SubGame/01_SubGame_modify.js` | **受限**:仅顶部「配置区」填值(`Type_*/CreateRoomData/combat/game_config/roomDes`);转发壳/平台接口**不碰、不新增**,业务走子游戏实现层。详见 06 |
|
||||
| `01_SubGame/02_SubGame_Input.js` | **不碰**:框架维护的平台接口骨架/转发壳,不改、不新增接口、不写业务。详见 06 |
|
||||
| `js/gameabc-framework/` | 可改,但须保持**游戏中立**(见 §3) |
|
||||
| `01_SubGame/codes/`(除 `shared/`) | 子游戏自由开发区 |
|
||||
| `01_SubGame/codes/shared/` | **只读**:服务端 `shared/` 的同步副本,改服务端权威源再同步(见 §7) |
|
||||
|
||||
新增前后端交互一律走服务端 `mod.js` 的 `rpc` 机制 + 前端统一发包/收包封装,**不在受限文件新增接口**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 语言标准:严格 ES5
|
||||
|
||||
- 用 `var`/`function`;对象继承用 `Object.create(Base)` + 在 `init` 内重声明实例属性。
|
||||
- **禁** `let`/`const`、箭头函数、模板字符串、解构、默认参数、展开、`class`、`for...of`。
|
||||
- 跨文件按**全局名**引用(各文件 `var X = ...` 暴露全局,`index.html` 顺序加载);新增文件必须在 `index.html` 插到依赖之后、使用者之前。
|
||||
|
||||
---
|
||||
|
||||
## 3. 框架游戏中立
|
||||
|
||||
`gameabc-framework/` 是给**任意子游戏**复用的通用框架,**不得**渗入任何具体玩法:
|
||||
|
||||
- **禁止**在框架内出现玩法逻辑、玩法专属常量(牌型、动作、玩法事件名等)。
|
||||
- 玩法专属内容一律定义在**子游戏侧**:
|
||||
- 玩法事件 → 子游戏事件常量文件,追加到 `EventBus.Events`;
|
||||
- 音效映射 → 子游戏音效资源常量 + 游戏音频管理;
|
||||
- Spine 动作 → 子游戏 Spine 动作配置;
|
||||
- 资源/布局 → 子游戏精灵结构/图片资源/布局常量。
|
||||
- **违例信号**:框架文件里出现 `mahjong:`、牌型、吃碰杠胡等具体玩法字样(注释举例除外,且举例应尽量中立)。
|
||||
|
||||
> 范例:本仓库把框架 `EventBus.js` 内**所有**预定义事件常量清空(只留空容器 `EventBus.Events={}`),全部事件(通用语义 + 玩法专属)改由子游戏的事件常量文件定义;并把通用的精灵事件控制器从子游戏抽到框架 `system/SpriteEventController.js`、剥离其中的玩法标记耦合为「全局 draw 钩子」;把 `UIManager` 的全局 UI(Loading/Message/Confirm)从「主动读子游戏精灵常量」改为「由子游戏 `init(config)` 注入」。新玩法照此扩展,框架零改动。
|
||||
|
||||
---
|
||||
|
||||
## 4. 渲染与常量
|
||||
|
||||
- **精灵只走 `SpriteManager`**:UI 代码禁止直接调 `GameABCUtils`/引擎原生 API。
|
||||
- **ID 守范围**:精灵 1001–3000、群组 ≥201、普通图层 101–200、弹窗图层 301–400(另有 501–600、701+ 备用段);框架保留 1–1000 及 3001+(精灵)等交替段,不可占用;ID 必须与编辑器一致,不编造。
|
||||
- **检查返回值**:`SpriteManager.*` 返回 `false` 即 ID/范围有误,及时暴露。
|
||||
- **常量集中、禁硬编码**:精灵 ID / 图片资源 ID / 坐标尺寸 / 动画时长帧数 / 音效 ID / 事件名,一律定义在对应常量文件,业务代码只引用:
|
||||
- 精灵结构 → 精灵结构常量(主界面/弹窗分文件)
|
||||
- 图片资源 → 图片资源常量
|
||||
- 布局坐标 → 布局常量
|
||||
- 动画参数 → 动画配置
|
||||
- 音效 ID → 音效资源常量
|
||||
- 事件名 → `EventBus.Events`(专属在子游戏事件常量文件)
|
||||
- 整合入口 → 精灵常量整合入口(**最后加载**)
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 组件生命周期与内存安全
|
||||
|
||||
- **组件继承 `BaseComponent`**,在 `init` 内重声明实例属性、建精灵、注册事件。
|
||||
- **事件用 `this.addEventListener`** 订阅(`destroy()` 自动 `off`),**禁**裸 `EventBus.on`(会泄漏)。
|
||||
- **销毁用 `destroy()`** 而非只 `hide()`;在 `onDestroy` 清理**定时器、动态复制精灵、Spine 回调登记**。
|
||||
- 动态列表用 `DynamicSpriteList` 并在销毁时 `list.destroy()`,避免复制精灵残留。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据优先、表现延后
|
||||
|
||||
- 收到推送的固定节奏:**先改数据模型 → 再播动画 → 动画回调里刷新静态界面**;动画期间**不改数据**。
|
||||
- **重画函数**据本地数据可随时重建正确界面(如网页刷新);断线重连与切 app 复用同一重画路径,不为重连单写一套渲染。
|
||||
- 开发阶段可**先不做动画**,只保证静态界面正确;动画作为后续叠加,不影响界面正确性。
|
||||
|
||||
---
|
||||
|
||||
## 7. 成败标志与收发包
|
||||
|
||||
- **成败只认 `data.success`**:`if (!data.success)` 判失败;**禁**用 `status`/`code` 判成败、**禁** `status` 兼容兜底。个别嵌套场景按该推送契约取 `success` 所在字段,语义不变。
|
||||
- **发包走语义化发包封装/`RpcHelper`**(自动注入平台字段),**收包统一分发**(一 rpc 一处理器)。
|
||||
- 业务逻辑放 controllers/managers,**不**写进受限 `Game_Modify.*`。
|
||||
|
||||
---
|
||||
|
||||
## 8. shared 同步
|
||||
|
||||
- **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。
|
||||
- `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。
|
||||
- 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。
|
||||
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [`server/docs/development-guide/04 §8`](../../../server/docs/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 模块职责边界
|
||||
|
||||
- 一个职能只在一个模块实现,其他模块**调用而非重造**:渲染找 `SpriteManager`、动画找 `AnimationManager`(及游戏动画封装)、音频找游戏音频管理、Spine 找 `SpineMgr`(及 Spine 回调分发)、发包找语义化发包封装、收包分发找收包分发器。
|
||||
- 写代码前先问“这段属于谁的职责”,属于别人就调用它,不在本模块复制一份近似实现。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试纪律
|
||||
|
||||
- 测试用于验证业务正确性;失败先**裁定根因归属**(业务缺陷 vs 测试脚本缺陷),禁止 skip/软化断言/吞异常掩盖。
|
||||
- 业务缺陷修业务并单独提交;脚本缺陷修置场并保持硬断言。
|
||||
- `shared/` 算法变更应在 Node 跑相应单测验证(前后端同源)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 审查速查表
|
||||
|
||||
| 维度 | 红线 |
|
||||
|------|------|
|
||||
| 范围 | `vendor`/`00_Surface` 禁改;受限文件不新增接口;`shared` 只读 |
|
||||
| 语言 | 严格 ES5;新文件插对加载顺序 |
|
||||
| 框架中立 | 框架无玩法逻辑/专属常量;专属内容归子游戏 |
|
||||
| 渲染 | 只走 `SpriteManager`;ID 守范围、不编造;查返回值 |
|
||||
| 常量 | 精灵/资源/坐标/动画/音效/事件全集中,禁硬编码裸值 |
|
||||
| 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 |
|
||||
| 节奏 | 先数据后表现;重画可随时还原界面 |
|
||||
| 成败 | 只认 `data.success`,禁 `status` 兜底 |
|
||||
| 收发包 | 发走语义化发包封装、收走收包分发器;业务不进 `Game_Modify` |
|
||||
| 职责 | 一职能一模块,调用不重造 |
|
||||
|
||||
---
|
||||
|
||||
至此,从架构与环境(01)、渲染与组件(02)、表现系统(03)、网络与启动(04)到工程红线(05),构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。
|
||||
</content>
|
||||
@@ -0,0 +1,301 @@
|
||||
# 06 · 子游戏接入模式与 Hooks 外置
|
||||
|
||||
> 权威设计来源:`docs/superpowers/specs/2026-06-30-子游戏接入Hooks外置模式-design.md`。
|
||||
> 本篇面向"要接入新游戏或将老游戏迁移到 Hooks 外置模式"的开发者。
|
||||
|
||||
---
|
||||
|
||||
## 1. 两种接入模式
|
||||
|
||||
平台通过三个**契约文件**调用子游戏:
|
||||
|
||||
- `client/js/01_SubGame/00_SubGame_Config.js` —— `Game_Config.*` 配置项
|
||||
- `client/js/01_SubGame/01_SubGame_modify.js` —— `Game_Modify.*` 部分接口与战绩系统
|
||||
- `client/js/01_SubGame/02_SubGame_Input.js` —— `gameHallImport.*` 与 `Game_Modify.*` 大部分接口
|
||||
|
||||
平台运行时**只按固定全局名调用**这些接口,对实现在哪里、怎么组织毫不关心。子游戏有两种方式实现这些接口:
|
||||
|
||||
| 维度 | 内联模式(老) | Hooks 外置模式(新) |
|
||||
|------|---------------|---------------------|
|
||||
| 三文件内容 | 配置值 + 业务逻辑全部写在三文件内 | 三文件是纯转发壳,仅查 hook 并委托 |
|
||||
| 业务逻辑位置 | 直接在 `01_/02_SubGame_*.js` 里 | `codes/SubGameHooks.js` + 子游戏实现层 |
|
||||
| `SubGameHooks` | 不定义 | 子游戏填充,与平台接口一一对应 |
|
||||
| 模板可复用性 | 低(新游戏要删改前任代码) | 高(三文件原样复用,只填 hook) |
|
||||
| 平台视角 | 调全局接口直接得到实现 | 调全局接口 → 转发壳 → hook |
|
||||
| 适用时机 | 已有存量代码,维持现状 | 新游戏接入、或老游戏迁移 |
|
||||
|
||||
**二选一,不混用**:一个游戏的代码库要么是「实心三文件」,要么是「转发壳三文件 + SubGameHooks」。同一接口不允许两处实现。这是**工程级选择**,平台运行期无需任何开关——转发壳的 `if (SubGameHooks.X)` 仅是实现存在性探测,不是模式判断。
|
||||
|
||||
> 现有麻将游戏使用内联模式,**本次不迁移**,三文件原样不动。
|
||||
|
||||
### 三文件可改性总表(硬规则)
|
||||
|
||||
这三个文件是**平台契约**,可改性严格受限:
|
||||
|
||||
| 文件 | 子游戏可否改 | 允许 | 禁止 |
|
||||
|------|-------------|------|------|
|
||||
| `00_SubGame_Config.js` | ✅ **仅可改值** | 给**已有** `Game_Config.*` 配置项赋本游戏需要的值 | 新增 / 删除 / 改名配置项;改变项的结构或类型;删除平台定义 |
|
||||
| `01_SubGame_modify.js` | ⚠️ **仅配置区填值,转发壳不碰** | 仅在顶部「配置区」给 `Type_1/Type_2/CreateRoomData/combat/game_config/roomDes` 赋值 | 改任何转发壳;新增接口;写业务逻辑 |
|
||||
| `02_SubGame_Input.js` | ❌ **完全不碰** | —— | 改任何转发壳;新增接口;写任何代码 |
|
||||
|
||||
子游戏的**一切业务实现**都在 `codes/SubGameHooks.js` + `codes/` 实现层,**不进这三个文件**;新增前后端交互走 `SubGameHooks` + 服务端 `mod.js` 的 rpc,**不在三文件加接口**。
|
||||
|
||||
### `00_SubGame_Config.js` —— 只改值,不改定义
|
||||
|
||||
`00` 是平台配置项的**结构定义**文件,配置项的**集合与结构由平台/框架固定**。子游戏接入只做一件事:**把已有配置项的值改成本游戏需要的**。
|
||||
|
||||
✅ **允许(改值)**:
|
||||
```js
|
||||
Game_Config.Max.PlayerCnt = 4; // 改人数
|
||||
Game_Config.Share.title = "进贤麻将"; // 改分享标题
|
||||
Game_Config.Info.TextContent = ["你好","谢谢","快点","不要","加油","收到","稍等"]; // 改常用语的【值】
|
||||
Game_Config.Chat.ChatLoc = [[35,517],[1100,315]/* ... */]; // 改聊天气泡坐标【值】
|
||||
```
|
||||
|
||||
❌ **禁止(改定义 / 结构)**:
|
||||
```js
|
||||
Game_Config.Info.MyNewField = 1; // ✗ 新增配置项(增定义)
|
||||
delete Game_Config.Voice; // ✗ 删除配置项(删定义)
|
||||
Game_Config.Max = [4]; // ✗ 改变项的类型/结构
|
||||
// ✗ 重命名平台已定义的字段(如把 PlayerCnt 改成 playerCount)
|
||||
```
|
||||
|
||||
> **为什么**:平台代码(`00_Surface/*`)按**固定字段名**读取 `Game_Config.*`。增 / 删 / 改名定义会让平台读到 `undefined` 或破坏约定,引发线上故障。**配置项的集合与结构是平台契约,只有「值」属于子游戏**。
|
||||
>
|
||||
> 对数组类配置项(如 `TextContent`、`ChatLoc`、`isLeft`),改其中元素值属「改值」(允许);但若平台对该数组**长度/索引语义有约定**(如固定 5 个聊天气泡位置、按座位索引),改变长度需以平台读取约定为准,不可随意增删。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层架构
|
||||
|
||||
Hooks 外置模式下,职责分为三层,单向向下:
|
||||
|
||||
```
|
||||
平台契约层(框架维护,子游戏不碰)
|
||||
00_SubGame_Config.js 平台配置项结构 + 子游戏填【配置值】
|
||||
01_SubGame_modify.js 平台接口骨架,每个接口是固定【转发壳】
|
||||
02_SubGame_Input.js 平台接口骨架,每个接口是固定【转发壳】
|
||||
|
|
||||
| 转发壳查 SubGameHooks.X,存在则委托,不存在则平台默认(B类)/空操作(A类)
|
||||
v
|
||||
子游戏入口层(codes/,Hooks 外置新方案核心)
|
||||
SubGameHooks{} 全局对象,接口名与平台接口一一对应;子游戏只填需要的
|
||||
|
|
||||
| SubGameHooks.StartWar = function(_msg){ /* 委托子游戏的开局处理 */ };
|
||||
v
|
||||
子游戏实现层(codes/,已有)
|
||||
各消息处理器 / 收发包 / 战绩等业务模块 / UIManager 扩展 ...
|
||||
```
|
||||
|
||||
**数据流**:平台调 `Game_Modify.X(args)` → 转发壳查 `SubGameHooks.X` → 存在则委托子游戏实现并返回其结果;不存在则执行平台默认(B 类)或空操作(A 类)。
|
||||
|
||||
**兼容性原因**:平台只认全局接口名,不关心实现在哪。老游戏不定义 `SubGameHooks`、三文件实心实现 → 照跑;新游戏用转发壳三文件 + `SubGameHooks` → 也跑。无需任何运行期判断。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新游戏:使用模板接入
|
||||
|
||||
模板文件位于 `gameabc-framework/templates/subgame-entry/`,共四个文件:
|
||||
|
||||
| 文件 | 内容 | 谁维护 |
|
||||
|------|------|--------|
|
||||
| `00_SubGame_Config.template.js` | `Game_Config.*` 全部配置项结构 + 占位/默认值 | 框架维护 |
|
||||
| `01_SubGame_modify.template.js` | 01 段接口的转发壳 + 配置区占位 | 框架维护 |
|
||||
| `02_SubGame_Input.template.js` | `gameHallImport.*` + 02 段 `Game_Modify.*` 的转发壳 | 框架维护 |
|
||||
| `SubGameHooks.template.js` | 子游戏实现骨架,列出全部可填 hook,每个给空函数 + 用途注释 | 子游戏起点 |
|
||||
|
||||
### 接入步骤
|
||||
|
||||
1. **复制四个模板文件**到新游戏目录。
|
||||
2. **三个转发壳直接用,不改**:`00/01/02_SubGame_*.template.js` 复制后重命名,原样放入 `01_SubGame/`。
|
||||
3. **填充 SubGameHooks**:将 `SubGameHooks.template.js` 重命名为 `codes/SubGameHooks.js`,在其中实现本游戏需要的 hook(不需要的 hook 留空函数或删除,A 类无 hook 默认 no-op,B 类无 hook 返回平台默认值)。
|
||||
4. **index.html 加载**:按既有顺序在原 `00_/01_/02_SubGame_*.js` 的位置加载新三文件,并在 `codes/` 相应位置加载 `SubGameHooks.js`(在其所依赖的 controllers/handlers 之后)。
|
||||
|
||||
### index.html 加载顺序要点
|
||||
|
||||
- **阶段5**:先加载 `SubGameHooks` 所依赖的控制器/管理器/网络等实现模块。
|
||||
- **`SubGameHooks.js`**:在其依赖之后、三文件之前加载。
|
||||
- **阶段6**:最后加载受限对接层三文件(转发壳,引用 `SubGameHooks`)。
|
||||
|
||||
即:**依赖模块 → `SubGameHooks` → 三文件转发壳**,顺序即依赖,不可颠倒。
|
||||
|
||||
---
|
||||
|
||||
## 4. 转发壳范式
|
||||
|
||||
转发壳按「无 hook 时的默认行为」分三类,必须**逐一覆盖全部 63 个接口**(见 §6)。
|
||||
|
||||
### A 类 — 纯子游戏行为(无 hook 即 no-op)
|
||||
|
||||
无 `SubGameHooks.X` 时什么都不做,语义是「模板未实现该功能」。
|
||||
|
||||
```js
|
||||
// A 类:无 hook 则空操作
|
||||
Game_Modify.StartWar = function (_msg) {
|
||||
if (window.SubGameHooks && SubGameHooks.StartWar) {
|
||||
return SubGameHooks.StartWar(_msg);
|
||||
}
|
||||
};
|
||||
|
||||
Game_Modify.Reconnect = function (_msg) {
|
||||
if (window.SubGameHooks && SubGameHooks.Reconnect) {
|
||||
return SubGameHooks.Reconnect(_msg);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### B 类 — 有平台默认返回值(无 hook 即返回默认)
|
||||
|
||||
平台某些接口需要有意义的返回值,无 hook 时应给出合理默认,而非静默返回 `undefined`。
|
||||
|
||||
```js
|
||||
// B 类:无 hook 返回平台默认值
|
||||
Game_Modify.getMaxPlayerCount = function (roomtype) {
|
||||
if (window.SubGameHooks && SubGameHooks.getMaxPlayerCount) {
|
||||
return SubGameHooks.getMaxPlayerCount(roomtype);
|
||||
}
|
||||
return (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
|
||||
};
|
||||
|
||||
Game_Modify.getLeaveLimit = function (roomtype) {
|
||||
if (window.SubGameHooks && SubGameHooks.getLeaveLimit) {
|
||||
return SubGameHooks.getLeaveLimit(roomtype);
|
||||
}
|
||||
return 10;
|
||||
};
|
||||
|
||||
gameHallImport.isInstalled = function () {
|
||||
if (window.SubGameHooks && SubGameHooks.isInstalled) {
|
||||
return SubGameHooks.isInstalled();
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
```
|
||||
|
||||
### C 类 — 配置数据(不是 hook,留配置文件)
|
||||
|
||||
`Game_Config.*`(00 全部)与房型数据(`Game_Modify.Type_1/Type_2/CreateRoomData/game_config`、`Game_Modify.combat/roomDes`)属于**配置数据**而非行为接口,不进 `SubGameHooks`。
|
||||
|
||||
模板在 01 文件顶部设置「配置区」,由子游戏直接填值:
|
||||
|
||||
```js
|
||||
// 01_SubGame_modify.js 顶部:配置区(子游戏填值,与转发壳接口段物理分开)
|
||||
Game_Modify.combat = null; // 战绩配置,子游戏按需赋值
|
||||
Game_Modify.roomDes = ''; // 房间描述
|
||||
Game_Modify.Type_1 = []; // 房型一级选项
|
||||
Game_Modify.Type_2 = []; // 房型二级选项
|
||||
Game_Modify.CreateRoomData = []; // 创建房间数据
|
||||
Game_Modify.game_config = {}; // 游戏配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Hook 命名约定
|
||||
|
||||
`SubGameHooks` 的 hook 名**默认与平台接口名严格一致**。
|
||||
|
||||
**跨对象同名冲突**:`gameHallImport.*` 与 `Game_Modify.*` 各有一个 `appStart` 接口,两者同名。为避免 `SubGameHooks` 上的 key 冲突,规则如下:
|
||||
|
||||
| 平台接口 | SubGameHooks key | 说明 |
|
||||
|---------|-----------------|------|
|
||||
| `Game_Modify.appStart` | `SubGameHooks.appStart` | 保持同名 |
|
||||
| `gameHallImport.appStart` | `SubGameHooks.hallAppStart` | 加 `hall` 前缀驼峰 |
|
||||
|
||||
目前仅 `appStart` 存在此冲突。`gameHallImport` 侧所有同名接口均加 `hall` 前缀以区分。
|
||||
|
||||
```js
|
||||
// SubGameHooks.js 中的写法
|
||||
SubGameHooks.appStart = function () {
|
||||
// 对应 Game_Modify.appStart:委托子游戏的启动编排
|
||||
};
|
||||
|
||||
SubGameHooks.hallAppStart = function () {
|
||||
// 对应 gameHallImport.appStart(大厅启动):委托子游戏的大厅初始化
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口覆盖要求
|
||||
|
||||
转发壳必须**逐个覆盖全部平台接口**,遗漏会导致新游戏某平台调用落空(静默失败)。接口分三组,总计 **63 个**:
|
||||
|
||||
| 来源 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| `gameHallImport.*` | 9 | 大厅相关;其中 `appStart` 对应 `SubGameHooks.hallAppStart`(加 `hall` 前缀) |
|
||||
| `Game_Modify.*`(事件/交互,01 段) | 9 | 精灵事件类默认 no-op,改用框架 `SpriteEventController` |
|
||||
| `Game_Modify.*`(生命周期/回调,02 段) | 45 | 多为 A 类 no-op,少量 B 类需给平台默认返回值 |
|
||||
|
||||
覆盖原则:**A 类无 hook 即 no-op;B 类无 hook 须返回有意义的平台默认值**(如玩家数、离开上限、房间文案等),而非静默返回 `undefined`。各接口的具体分类与默认值以平台接入 spec 为权威,接入时逐一核对。
|
||||
|
||||
---
|
||||
|
||||
## 7. 大块实现外置范式
|
||||
|
||||
> 本节为**范式说明**,供新游戏接入时参考外置思路。
|
||||
|
||||
内联模式下,一段大块业务实现(如战绩列表/滚动/回放,往往数百行、还自带 `utlmousedown/move/up/drawbegin` 等精灵事件方法)整体写在契约文件里,会导致契约文件膨胀。新游戏接入时,大块业务实现应按以下三步外置:
|
||||
|
||||
1. **移到 `codes/`**:把整块实现移到 `codes/` 下对应目录(一个或多个模块文件),声明为全局对象并在 `index.html` 按依赖顺序加载。
|
||||
2. **经 `SubGameHooks` 接入平台接入点**:相关平台接口在 `SubGameHooks` 对应 hook 里委托给该模块,契约文件不出现业务实现。
|
||||
3. **精灵交互改用 `SpriteEventController`**:界面的精灵交互改用框架 `SpriteEventController` 按精灵 ID 注册回调,而非散落的 `utl*` 方法。
|
||||
|
||||
这样契约文件中只剩干净的配置区与转发壳,该块业务完全在 `codes/` 内自治。
|
||||
|
||||
---
|
||||
|
||||
## 8. 老游戏迁移步骤与验证
|
||||
|
||||
> 迁移是**单独立项**的改动,不与其他功能混在一次提交里。
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
1. **替换接口段为转发壳**:用模板 `01/02_SubGame_*.template.js` 的转发壳替换三文件的接口实现段;保留 `Game_Config.*` 配置值和 `Game_Modify.combat/roomDes/Type_*` 配置数据不动。
|
||||
|
||||
2. **逐接口搬到 SubGameHooks**:为每一个原有接口实现在 `codes/SubGameHooks.js` 里创建对应 hook,将内联实现原样移入,逐接口**核对行为等价**。
|
||||
|
||||
3. **大块实现按 §7 外置**:战绩等大块逻辑移到 `codes/` 对应目录,通过 hook 接入平台接入点。
|
||||
|
||||
4. **验证**:
|
||||
- 全局接口名与签名不变(平台调用路径不变)。
|
||||
- 逐接口**黑盒对比**迁移前后行为,不能靠"看起来一样",须可观测地验证(页面操作/日志/单测)。
|
||||
- 连跑多次无异常。
|
||||
|
||||
### 验证要点
|
||||
|
||||
| 验证项 | 方法 |
|
||||
|--------|------|
|
||||
| 接口不遗漏 | grep 对比三文件原定义与转发壳一一对应(63 个) |
|
||||
| 签名不变 | 检查平台调用处参数与转发壳参数列表一致 |
|
||||
| 行为等价 | 逐接口黑盒测试(开局/重连/战绩/离开等主流程) |
|
||||
| 配置值完整 | `Game_Config.*` 与配置区数据均已保留 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 红线
|
||||
|
||||
使用 Hooks 外置模式时,必须严格遵守以下约束:
|
||||
|
||||
| 红线 | 说明 |
|
||||
|------|------|
|
||||
| **转发壳框架维护,子游戏不碰** | `01/02_SubGame_*.js` 转发壳由框架统一维护,子游戏不得直接修改转发壳内容 |
|
||||
| **00 只改值,不改定义** | `00_SubGame_Config.js` 只允许给**已有** `Game_Config.*` 配置项改值;**禁止**新增/删除/改名/改结构定义(平台按固定字段名读取,改定义会引发线上故障)。三文件中仅此文件子游戏可碰,且仅限改值 |
|
||||
| **01 仅配置区填值** | `01_SubGame_modify.js` 只允许在顶部「配置区」给 `Type_*/CreateRoomData/combat/game_config/roomDes` 赋值;转发壳一律不碰 |
|
||||
| **配置值留配置文件** | `Game_Config.*` 和配置区数据(`Type_1/2/CreateRoomData` 等)不得塞进 `SubGameHooks`,保留在对应配置位置 |
|
||||
| **hook 签名必须与平台接口一致** | 平台按位置传参,转发壳以相同参数透传给 hook,hook 签名不得偏移 |
|
||||
| **二选一,不混用** | 同一接口不允许在三文件内联实现与 `SubGameHooks` 中同时存在 |
|
||||
| **转发壳必须全覆盖** | 转发壳须覆盖全部 63 个接口,遗漏会导致平台调用落空(静默失败) |
|
||||
| **严格 ES5** | `SubGameHooks.js` 与所有 codes 文件一律 ES5,禁 `let`/`const`/箭头函数等 |
|
||||
| **加载顺序正确** | `SubGameHooks.js` 须在其依赖的 controllers/handlers 之后、三文件之前加载 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 小结
|
||||
|
||||
- Hooks 外置模式将子游戏逻辑从平台契约文件中**彻底剥离**,三文件退化为纯转发壳,子游戏只需填充 `SubGameHooks`。
|
||||
- 平台天然兼容两种模式——全局接口名不变,平台无感知。
|
||||
- 新游戏接入:复制四个模板文件,三转发壳不改,只填 `SubGameHooks.js`,按顺序加入 `index.html`。
|
||||
- 老游戏迁移:替换接口段为转发壳,逐接口搬实现到 `SubGameHooks`,黑盒验证行为等价。
|
||||
- 转发壳由框架维护,子游戏不碰;配置值留配置文件;hook 命名默认同接口名,`gameHallImport.appStart` 例外用 `hallAppStart`。
|
||||
|
||||
上一篇 [05-开发规范与红线](./05-开发规范与红线.md),回到 [README](./README.md) 查看导航。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 前端 · 子游戏开发指导文档
|
||||
|
||||
> 本套文档是 **gameabc 平台前端模板框架(`gameabc-framework`)** 下「子游戏前端」开发的通用指导与规范。
|
||||
> 它讲清楚四件事:**前端怎么分层运行**、**界面怎么用精灵与组件搭**、**事件/动画/音频/Spine 怎么用**、**怎么和服务端收发包并启动一局**。
|
||||
>
|
||||
> 文中以「麻将」一类房卡棋牌作举例,但 `gameabc-framework` 是**游戏中立**的通用框架,所有结论不绑定具体玩法。
|
||||
> 新开发者按本套文档即可理解前端运作、从模板派生出一个子游戏并写出符合规范的代码。
|
||||
|
||||
---
|
||||
|
||||
## 这套文档写给谁
|
||||
|
||||
- **新接手子游戏前端的开发者**:先读 01、02 建立全局认知,再按 03、04 动手,05 随时回查。
|
||||
- **正在开发/维护某子游戏前端的开发者**:02–05 是日常手册与红线。
|
||||
- **做代码审查的人**:05 是审查清单来源。
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
| 篇 | 文档 | 解决什么问题 |
|
||||
|----|------|--------------|
|
||||
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
|
||||
| 01 | [01-前端架构与运行环境.md](./01-前端架构与运行环境.md) | 双运行时/ES5、平台/框架/子游戏三层、新旧架构并存、目录与加载顺序 |
|
||||
| 02 | [02-渲染与UI组件体系.md](./02-渲染与UI组件体系.md) | 精灵 ID 体系、SpriteManager 分层、资源常量组织、BaseComponent 组件化、UIManager 场景、动态列表 |
|
||||
| 03 | [03-事件·动画·音频·Spine.md](./03-事件·动画·音频·Spine.md) | EventBus、AnimationManager+配置、AudioManager+音效资源、SpineMgr 全链路 |
|
||||
| 04 | [04-网络对接与启动编排.md](./04-网络对接与启动编排.md) | 发包链路(RpcHelper 注入平台字段)、收包统一分发、新旧架构对接边界、启动编排、处理器/管理器职责 |
|
||||
| 05 | [05-开发规范与红线.md](./05-开发规范与红线.md) | 可编辑范围、ES5、框架中立、常量集中、组件生命周期、data.success、模块职责、测试 |
|
||||
|
||||
建议第一次**从 01 顺序读到 05**;之后把 02–05 当手册随用随查。
|
||||
|
||||
---
|
||||
|
||||
## 一页纸:前端分层模型
|
||||
|
||||
```
|
||||
gameabc.min.js(引擎,js/vendor,第三方)
|
||||
▲
|
||||
GameABCUtils(core,唯一直接调引擎原生 API 的模块)
|
||||
▲
|
||||
SpriteManager(core,业务级精灵 API:ID 校验 + 单位换算) EventBus / AnimationManager / AudioManager / SpineMgr(system)
|
||||
▲ ▲
|
||||
BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(游戏中立,可复用)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
01_SubGame/codes(子游戏实例,单向依赖框架;内部结构由子游戏自行组织)
|
||||
shared 前后端共享算法(与服务端同源,脚本同步,只读)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
旧受限对接层(平台入口,尽量不改)
|
||||
00/01/02_SubGame_*.js → Game_Modify.StartWar / Reconnect / _ReceiveData / appStart
|
||||
```
|
||||
|
||||
- **框架(gameabc-framework)**:游戏中立,提供精灵/组件/事件/动画/音频/Spine 通用能力,可被任何子游戏复用。
|
||||
- **子游戏(01_SubGame/codes)**:单向依赖框架,在其内部自由组织实现(目录/文件命名由子游戏自定,仅 `shared/` 为只读同步副本)。
|
||||
- **旧受限对接层**:平台框架的固定入口(`Game_Modify.*`),把平台事件转交给新架构,**尽量不改**(详见 01/04)。
|
||||
|
||||
---
|
||||
|
||||
## 红线速查(详见 05)
|
||||
|
||||
- **可编辑范围**:前端平台代码 `js/00_Surface/` 禁改;受限接口文件 `01_SubGame/00_/01_/02_SubGame_*.js` 不新增接口、尽量不改;其余在 `gameabc-framework/`、`01_SubGame/codes/` 内开发。
|
||||
- **严格 ES5**:用 `var`/`function`/`Object.create`,禁 `let`/`const`/箭头/模板串/`class`。
|
||||
- **框架游戏中立**:`gameabc-framework/` 内**不得**出现任何具体玩法逻辑或专属常量;玩法专属的事件/资源/配置一律定义在子游戏侧(如玩法事件在子游戏的事件常量文件里追加到 `EventBus.Events`)。
|
||||
- **精灵只走 SpriteManager**:UI 代码**禁止**直接调 `GameABCUtils` 或引擎原生 API;ID 必须落在规定范围(精灵 1001–3000、群组 ≥201、图层 101–200/弹窗 301–400,另有 501–600、701+ 备用段,框架保留 1–1000 及 3001+ 等交替段)。
|
||||
- **常量集中、禁硬编码**:精灵 ID/图片资源 ID/坐标尺寸/动画时长/音效 ID 一律定义在对应常量文件,业务代码引用,**不内联裸值**。
|
||||
- **组件走 BaseComponent**:UI 组件继承 `BaseComponent`,事件用 `this.addEventListener` 注册(`destroy()` 自动清理,防泄漏);动态精灵/定时器在 `onDestroy` 清理。
|
||||
- **数据优先、表现延后**:收到推送先改数据模型,再播动画,动画回调里刷新静态界面;动画期间**不改数据**。即使不播动画,重画函数也能据本地数据还原正确界面。
|
||||
- **成败只认 `data.success`**:前端一律 `if (!data.success)` 判成败,**不**用 `status`/`code`,**不**写 `status` 兼容兜底。
|
||||
- **收发包走统一通道**:发包经统一发送封装(底层 `RpcHelper` 自动注入平台字段),收包统一分发(一 rpc 一处理器);业务逻辑放处理器,**不**写进 `Game_Modify.*`。
|
||||
- **shared 只读**:`01_SubGame/codes/shared/` 是服务端 `shared/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
|
||||
|
||||
---
|
||||
|
||||
## 与既有文档的关系
|
||||
|
||||
- 服务端的对应文档在 [`server/docs/development-guide/`](../../../server/docs/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。
|
||||
- 子游戏前端各层可能另有局部说明文档;本套是总纲,与之不冲突时以本套的通用原则为准。
|
||||
</content>
|
||||
@@ -0,0 +1,144 @@
|
||||
# 01 · 架构总则与分层
|
||||
|
||||
本篇给出七大架构总则,并落到一套**前后端参考分层**上。总则是"为什么这么设计",分层是"具体长什么样"。
|
||||
所有分层里的模块名均为**示例、可自定**,约束的是**层与依赖方向**,不是名字。
|
||||
|
||||
---
|
||||
|
||||
## 1. 七大架构总则
|
||||
|
||||
### 1.1 单一权威数据源(Single Source of Truth)
|
||||
|
||||
同一业务数据**只能有一个计算/写入的地方**,其余模块只读取、不重算。多处并行计算同一结果,必随
|
||||
规则演化而分叉、互相矛盾。
|
||||
|
||||
- 上游**算 + 写 + 校验**,下游**只读 + 消费**。
|
||||
- 需要某数据时,**读权威源**,而不是"顺手再算一遍"。
|
||||
- 详见 [03 篇 · 数据权威](./03-数据权威·错误处理·演进.md)。
|
||||
|
||||
### 1.2 单向依赖,禁止环
|
||||
|
||||
分层之间**自上而下单向依赖**:入口依赖编排、编排依赖领域、领域依赖数据/共享;**反向不依赖**。
|
||||
|
||||
- **稳定依赖原则**:越被依赖的层越应稳定(领域/共享层最稳定,入口层最易变)。
|
||||
- **禁止环形依赖**:A 依赖 B、B 又依赖 A,是"职责没分清"的信号,应抽出共同依赖或调整边界。
|
||||
- 双运行时下尤其致命:环依赖 + 中途 `require` 会在浏览器端崩溃(见 development-guide 的 require 守卫)。
|
||||
|
||||
### 1.3 职责单一、边界清晰
|
||||
|
||||
**一个职能只在一个模块实现**,别的模块需要它就**调用**,不"图方便"复制一份近似逻辑。
|
||||
|
||||
- 判据:写代码前先问"这段逻辑属于谁的职责"。属于别人的,就调用它。
|
||||
- 权威能力不满足时,**在权威模块内扩展**,不要在调用方旁路重写。
|
||||
- 反例:在 A 模块内联 B 模块核心算法的"简化版";同一职能两处并行演化。
|
||||
|
||||
### 1.4 关注点分离(Separation of Concerns)
|
||||
|
||||
把"不同变化原因"的代码分开,让每块**只因一个原因而改**:
|
||||
|
||||
| 分离维度 | 一侧 | 另一侧 |
|
||||
|----------|------|--------|
|
||||
| 决策 vs 机制 | "选哪个"(策略/决策) | "怎么做"(算法/规则/执行) |
|
||||
| 数据 vs 表现 | 状态模型(真相) | 渲染/UI(从数据重建) |
|
||||
| 编排 vs 算法 | 流程编排(controller) | 纯计算(领域算法) |
|
||||
| 输入 vs 逻辑 | 收发包/参数校验 | 业务处理 |
|
||||
|
||||
> 例(决策 vs 机制):自动操作模块只决定"打哪张/是否碰杠胡过",**不实现**胡牌检测/听牌分析;
|
||||
> 后者是领域算法,决策层只**读取其结果**。这既是关注点分离,也是职责边界。
|
||||
|
||||
### 1.5 对扩展开放、对修改封闭(OCP)
|
||||
|
||||
新增一种玩法/规则/策略/牌型,应当是**新增一段代码并注册**,而**不是**去改动已经稳定、已被测试
|
||||
覆盖的核心流程。
|
||||
|
||||
- 手段:**注册表 + 策略**、**管线/中间件**、**工厂**(见 [02 篇](./02-可扩展性与配置化.md))。
|
||||
- 收益:核心不动 → 回归风险小;扩展点清晰 → 新人能照葫芦画瓢。
|
||||
- 例:分级决策框架——核心是"取上下文 → 跑管线 → 选结果",新增高级策略只是**写策略对象 +
|
||||
注册 + 单测**三步,核心零改动。
|
||||
|
||||
### 1.6 配置优先于硬编码
|
||||
|
||||
**会变的、复用的、无语义的**值不写死在逻辑里,外提为常量/配置,用**数据驱动行为**。
|
||||
|
||||
- 分数、阈值、类型字符串、跨模块 key → 常量;成套玩法开关 → 配置对象/规则编码。
|
||||
- 让"改规则"变成"改配置",而不是"改代码 + 重测逻辑"。
|
||||
- 详见 [02 篇 · 配置化与去硬编码](./02-可扩展性与配置化.md)。
|
||||
|
||||
### 1.7 显式失败优于隐式兜底
|
||||
|
||||
关键路径上数据缺失,**优先报错或返回 `null`**,把问题暴露在**离根因最近**的地方;不要用
|
||||
`|| 0`、`|| []`、`|| ''`、双源回退把缺失悄悄填平。
|
||||
|
||||
- 兜底会把 bug 藏进"看似正常"的流程里,等到很远的下游才爆发,极难定位。
|
||||
- 只有**展示层、纯 UI 兼容、非关键日志**字段,才可在明确边界内用安全默认值。
|
||||
- 详见 [03 篇](./03-数据权威·错误处理·演进.md)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前后端参考分层
|
||||
|
||||
下面是一套**成熟、可直接照搬思路**的分层。层次是稳定的,层内文件如何组织由子游戏自定。
|
||||
|
||||
### 2.1 后端分层(自上而下依赖)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 入口层 Entry 收包薄委托:一操作一入口,只接住并转交 │ 易变
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 收发层 IO 参数校验、响应构建、序列化、差异化广播 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 编排层 Orchestrate 流程编排、状态机推进、跨模块协调(不含算法) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 领域层 Domain 规则/算法权威实现(胡牌/听牌/计分/牌型…) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 数据层 Data 对局状态、状态管理、统一访问层 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 共享层 Shared 前后端逐字相同的纯逻辑与常量 │ 最稳定
|
||||
└─────────────────────────────────────────────┘
|
||||
依赖方向:上层 → 下层(下层绝不反向依赖上层)
|
||||
```
|
||||
|
||||
- **入口层薄**:只做"接住请求 → 委托",不写业务;一操作一入口,不做二次路由。
|
||||
- **领域层是权威**:所有"能不能胡/听什么/怎么算分"的算法只此一份,别层调用。
|
||||
- **数据层是唯一状态落点**:对局状态集中管理,读写走统一访问层,避免散落。
|
||||
- **共享层最底、最稳定**:见 [03 篇 · 数据权威] 与各端 development-guide 的 shared 说明。
|
||||
|
||||
### 2.2 前端分层(自上而下依赖)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 入口层 Entry 平台受限入口:只解包转交,不写业务 │ 易变
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 分发层 Dispatch 唯一收包口,按 rpc 路由到处理器(纯路由表) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 处理层 Controllers 一类消息一处理器:改数据模型 / 触发表现 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 状态层 Model 前端状态真相(界面从它无歧义重建) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 表现层 View/Managers 渲染、动画、音频、资源(从状态派生) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 共享层 Shared 与服务端同源的纯逻辑(只读同步副本) │ 最稳定
|
||||
└─────────────────────────────────────────────┘
|
||||
表现从状态派生:任何时刻重画都能还原正确界面
|
||||
```
|
||||
|
||||
- **入口只转交**:平台受限入口不堆业务,逻辑全在处理层。
|
||||
- **收包分发只分发**:一张 `rpc → 处理器` 路由表,不在分发口写业务。
|
||||
- **数据优先、表现延后**:先落状态模型,再由表现层派生;重连=重画,与正常对局**复用同一重画路径**。
|
||||
|
||||
### 2.3 依赖方向自检
|
||||
|
||||
- 有没有"下层反过来 import/引用上层"?有 → 边界错位,重划。
|
||||
- 有没有"两个模块互相依赖"?有 → 抽公共依赖或合并职责。
|
||||
- 有没有"入口层里写了算法/规则"?有 → 下沉到领域层。
|
||||
- 有没有"表现层里存了业务真相"?有 → 上移到状态层。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本篇小结
|
||||
|
||||
- 七大总则:**SSOT、单向依赖、职责单一、关注点分离、OCP、配置优先、显式失败**。
|
||||
- 后端六层(入口/收发/编排/领域/数据/共享)、前端六层(入口/分发/处理/状态/表现/共享),**依赖单向向下**。
|
||||
- 层是稳定的,层内组织自定;判断对错的尺子始终是**依赖方向**与**职责归属**。
|
||||
|
||||
下一篇 [02-可扩展性与配置化](./02-可扩展性与配置化.md):把 OCP 与"配置优先"落成具体模式与判据。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 02 · 可扩展性与配置化
|
||||
|
||||
本篇把总则里的 **OCP(对扩展开放)** 和 **配置优先** 落成可直接套用的模式与判据,
|
||||
并给出**避免过度设计**的红线——扩展性是为了"改得动",不是为了炫技。
|
||||
|
||||
---
|
||||
|
||||
## 1. 可扩展性模式(够用就好)
|
||||
|
||||
下面几种模式覆盖子游戏绝大多数扩展需求。**先问"现在真的需要扩展吗",需要再用**(见 §3)。
|
||||
|
||||
### 1.1 注册表 + 策略(Registry + Strategy)— 最常用
|
||||
|
||||
把"一族可替换的行为"抽象成**策略对象**,用**注册表**收集,运行时按上下文选一个执行。
|
||||
新增行为 = 写一个策略 + 注册,**核心零改动**。
|
||||
|
||||
```
|
||||
Registry(注册表) ── register(strategy) ──▶ [strategyA, strategyB, ...]
|
||||
│
|
||||
Context(只读上下文)──▶ 选择器 select(ctx) ─────────┘──▶ 命中的策略.execute(ctx)
|
||||
```
|
||||
|
||||
- **适用**:AI 决策分级、规则变体、牌型识别族、结算规则族——"同一类事有多种做法"。
|
||||
- **要点**:
|
||||
- 策略只依赖**只读上下文**,不反向修改全局;上下文封装它需要的权威数据。
|
||||
- 有**默认策略兜底**(保证任何输入都有结果),高级策略**按需叠加**。
|
||||
- 策略之间**互不知道**对方,新增不影响既有。
|
||||
- **例**:分级决策框架——`Context/Registry/Pipeline + 基础策略 + 占位高级策略`,默认走最低级,
|
||||
高级留扩展点;新增高级策略三步(写策略对象 + register + 单测)零侵入。
|
||||
|
||||
### 1.2 管线 / 中间件(Pipeline)
|
||||
|
||||
把一个复杂处理拆成**有序的小步骤**,每步只做一件事、可独立增删。
|
||||
|
||||
```
|
||||
输入 ──▶ [校验] ──▶ [规则A] ──▶ [规则B] ──▶ [收敛] ──▶ 输出
|
||||
每一节点单一职责,可插拔、可测试
|
||||
```
|
||||
|
||||
- **适用**:决策流水线、校验链、结算的多阶段计分(比精 → 冲关 → 霸王 → 零和)。
|
||||
- **要点**:节点间用**明确的数据结构**传递,不靠隐式全局;任一节点可单测。
|
||||
|
||||
### 1.3 工厂(Factory)
|
||||
|
||||
把"根据类型创建/选择实现"的分支收敛到一处,调用方只要"我要一个 X",不关心怎么造。
|
||||
|
||||
- **适用**:胡牌检测按牌型分派、可用操作枚举、不同房型的配置构建。
|
||||
- **要点**:工厂是**唯一**的创建入口,避免 `if(type==...)` 散落各处(那是并行逻辑的温床)。
|
||||
- **例**:胡牌检测工厂——检测逻辑只此一份,各处调用它,不自行手搓顺子/搭子判定。
|
||||
|
||||
### 1.4 事件总线(EventBus)— 前端解耦,谨慎用
|
||||
|
||||
用发布/订阅让"状态变化"与"表现响应"解耦:处理器改完数据 `emit` 事件,表现层订阅刷新。
|
||||
|
||||
- **适用**:一次数据变化要驱动多个互不相关的表现(动画 + 音效 + 计分板)。
|
||||
- **克制**:
|
||||
- **不要**用事件做"隐式流程控制"——关键流程的因果应显式可追踪,别埋进一堆事件里。
|
||||
- 事件是**通知**,不是**命令**;订阅方不该反向决定发布方的流程。
|
||||
- 能直接函数调用讲清的因果,就别为"解耦"硬拆成事件。
|
||||
|
||||
### 1.5 统一访问层(Facade over data)
|
||||
|
||||
对"读权威数据"提供一个**统一入口**(如 DataAccessHelper 之类),下游都走它读,不各自摸索
|
||||
数据结构。好处:数据结构演化时只改访问层一处;配合"缺失显式失败",把校验集中。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置优先于硬编码
|
||||
|
||||
目标:**让"会变的东西"从代码逻辑里分离出来,用数据驱动**。改规则变成改配置,而非改逻辑 + 重测。
|
||||
|
||||
### 2.1 什么必须外提(满足任一即提取)
|
||||
|
||||
| 判据 | 说明 | 典型 |
|
||||
|------|------|------|
|
||||
| **会变** | 规则调整时可能改动 | 分数值、阈值、倍率、局数、超时 |
|
||||
| **复用** | 超过一个模块用到同一值 | 跨模块共享的 key、类型标识 |
|
||||
| **无语义** | 裸值无法自解释业务含义 | 魔法数字、状态字符串 |
|
||||
|
||||
按语义归入分层的常量文件(**分数/计分类、规则配置类、类型枚举类**……),不要一个巨型常量堆。
|
||||
|
||||
### 2.2 什么不必外提(避免过度设计)
|
||||
|
||||
- 单函数内一次性的临时值(循环初值 `0`、空数组 `[]`)。
|
||||
- 框架约定的固定串(模块加载路径等)。
|
||||
- 自解释的布尔开关、纯展示标点文字。
|
||||
|
||||
> 三问法:**会变吗?复用吗?有语义需要命名吗?** 三个都"否",就地内联,别为提取而提取。
|
||||
|
||||
### 2.3 规则驱动:把玩法开关变成数据
|
||||
|
||||
复杂玩法的差异(人数/局数/扣卡方式/各种开关)应编码成**一份配置**,在入口处**解析一次**,
|
||||
之后全流程**只读消费**:
|
||||
|
||||
```
|
||||
房型/配置编码 ──(解析器,解析一次)──▶ 规则对象(结构化、只读)
|
||||
│
|
||||
各模块按需读取规则对象的字段,不再各自解析原始编码、不再散落 if
|
||||
```
|
||||
|
||||
- **解析只此一处**:避免"多处各自解析同一编码"产生的不一致(这是 SSOT 在配置上的体现)。
|
||||
- **规则对象只读**:下游不回写、不推断缺省;缺字段是**配置或解析的 bug**,应显式暴露。
|
||||
- **新增一个玩法开关** = 编码加一位 + 解析器认它 + 消费点读它,**不改无关逻辑**。
|
||||
|
||||
### 2.4 配置注入优于全局魔法值
|
||||
|
||||
模块需要的配置**从入口传入 / 从权威配置对象读取**,而不是在模块内部埋一堆魔法值或直接摸全局。
|
||||
这让模块**可测试**(注入不同配置跑用例)、**可复用**(换配置即换行为)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 避免过度设计(同等重要的红线)
|
||||
|
||||
可扩展性有成本:抽象层、间接跳转、认知负担。**过度设计和硬编码一样有害**。
|
||||
|
||||
### 3.1 什么时候**不要**加抽象
|
||||
|
||||
- **只有一种实现、且看不到第二种的现实需求** → 不要为"将来也许"预留策略/工厂。先写直接实现。
|
||||
- **YAGNI**:没有当前需求的扩展点就是负债;等第二个变体真的出现,再**重构**成模式(那时你更懂共性)。
|
||||
- **一次性逻辑** → 不要包装成"通用框架"。通用性从**重复中提炼**,不是凭空设计。
|
||||
|
||||
### 3.2 判断"值不值得抽象"
|
||||
|
||||
| 加抽象 | 别加抽象 |
|
||||
|--------|----------|
|
||||
| 已有 ≥2 个真实变体,且还会增加 | 只有 1 个实现,扩展是想象的 |
|
||||
| 变体频繁新增(规则/策略/牌型) | 逻辑稳定,多年不变 |
|
||||
| 核心被扩展反复改动、回归频发 | 改动集中、影响面小 |
|
||||
| 抽象后核心显著变简单、变稳定 | 抽象后跳转变多、更难读 |
|
||||
|
||||
### 3.3 成熟的判据:三次法则 + 就近演进
|
||||
|
||||
- **三次法则**:同样的东西第 3 次出现时再抽象;第 1、2 次容忍重复。
|
||||
- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),
|
||||
再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。
|
||||
|
||||
> 一句话:**扩展性是留给"已知会变"的地方的;对"稳定不变"的地方,简单直接才是最好的设计。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 本篇小结
|
||||
|
||||
- 扩展四件套:**注册表+策略、管线、工厂、事件总线(谨慎)**,外加**统一访问层**;够用即止。
|
||||
- 配置化:会变/复用/无语义→外提;玩法差异→规则对象解析一次、只读消费;配置注入优于全局魔法值。
|
||||
- 反过度设计:**YAGNI + 三次法则 + 就近演进**;只有一种实现就别造框架,抽象从重复中提炼。
|
||||
|
||||
下一篇 [03-数据权威·错误处理·演进](./03-数据权威·错误处理·演进.md):把"正确性"的地基——数据权威、错误处理、演进纪律——讲透。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 03 · 数据权威 · 错误处理 · 演进
|
||||
|
||||
本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化。
|
||||
数据权威部分在 `.github/copilot/skills/data-authority-principle.md` 基础上,扩展到**前后端全景**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据权威(前后端全景)
|
||||
|
||||
### 1.1 四条铁律(承接数据权威原则)
|
||||
|
||||
1. **数据源唯一**:同一业务数据只有一个权威来源;上游算/写/校验,下游只读/消费,**不重复推断、不重复拼装**。
|
||||
2. **禁止猜测兜底**:不用默认值掩盖缺失。关键权威字段缺失要**显式报错或返回 `null`**,由上层决定是否终止;
|
||||
**禁止** `|| 0`、`|| []`、`|| ''`、双源回退等隐式掩盖。
|
||||
3. **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本应提供的数据;发现下游在修补,**把责任前移到权威源**。
|
||||
4. **边界内可默认**:只有**展示层、纯 UI 兼容、非关键日志**字段,才可在明确边界内用安全默认值。
|
||||
|
||||
### 1.2 前后端的权威划分
|
||||
|
||||
```
|
||||
权威裁定 表现/预判
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ 服务端 = 真相之源 │ 推送 │ 前端 = 从推送重建表现 │
|
||||
│ 房卡/胜负/积分/发牌/ │ ─────▶ │ 只显示、可预校验, │
|
||||
│ 手牌/状态/结算…全权威 │ │ 不"前端说了算" │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
▲ 同一套 shared 纯逻辑(逐字相同)在两端各跑一份 ▲
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **服务端权威**:一切影响胜负/计分/状态的判定以服务端为准;前端结果只是**表现与预判**。
|
||||
- **逻辑同源 ≠ 权威转移**:前后端共享同一套算法(shared)是为了**表现一致 + 预校验**,
|
||||
最终仍以服务端算的为准(见各端 development-guide 的 shared 说明)。
|
||||
- **一份数据一个方向**:产生 → 推送 → 消费,单向、可追踪;前端不把"预判结果"当权威回写。
|
||||
|
||||
### 1.3 审查信号
|
||||
|
||||
看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段——先判断它是不是**权威字段**(影响发牌/庄家/
|
||||
手牌/规则/结算/重连/状态流转):
|
||||
|
||||
- **是** → 改成权威读取(缺失显式失败)。
|
||||
- **只是展示/日志/统计** → 才可保留默认值。
|
||||
|
||||
---
|
||||
|
||||
## 2. 错误处理与可观测性
|
||||
|
||||
### 2.1 fail-fast:把错误暴露在最近处
|
||||
|
||||
- 关键路径缺前置条件(缺权威数据、状态非法),**立即中止**并给出明确错误,别带病继续。
|
||||
- **不吞异常**:`try/catch` 不是用来"让它别报错",而是用来**在正确的边界处理并记录**。空 `catch{}`
|
||||
等于把故障藏起来,是重大反模式。
|
||||
- **校验前置**:入口处集中校验参数/状态,不合法即返回;业务逻辑内部可假设前置条件已满足。
|
||||
|
||||
### 2.2 错误的分层归属
|
||||
|
||||
| 层 | 出错时怎么办 |
|
||||
|----|--------------|
|
||||
| 入口/收发层 | 参数/身份/状态校验失败 → 结束请求(对外不泄漏细节,见 development-guide 的"静默 return") |
|
||||
| 编排层 | 前置条件不满足 → 显式失败,不替下层补数据 |
|
||||
| 领域/算法层 | 输入非法 → 抛错/返回 `null`,**不猜**一个"看起来对"的结果 |
|
||||
| 数据层 | 权威字段缺失 → 显式失败,暴露数据链断点 |
|
||||
| 表现层 | 可容错降级(缺图/缺文案用占位),**不影响业务判断** |
|
||||
|
||||
### 2.3 可观测性
|
||||
|
||||
- **分级日志**:关键节点(收包、状态转换、结算、扩展点命中)留可复盘的日志;日志**说清上下文**
|
||||
(房间、座位、阶段),不是一句 "error"。
|
||||
- **可复盘**:保留收包/关键中间态记录(如调试保存收包),出问题能重放。
|
||||
- **诊断探针即用即清**:为定位问题临时加的日志/探针,**定位完成后必须删除**,不留在正式代码里。
|
||||
- **正式代码不为测试服务**:不为"让测试过"在正式代码加 fallback/守卫/冗余字段;测试构造符合
|
||||
生产契约的输入与 stub(详见 development-guide 的测试纪律)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 演进与重构纪律
|
||||
|
||||
代码会随规则长大。让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。
|
||||
|
||||
### 3.1 收敛并行实现
|
||||
|
||||
- 同一职能出现**第二份实现**(哪怕是"简化版/兼容版"),就是分叉的开始——**尽快收敛为一份权威**。
|
||||
- 收敛方向:把旁路实现删掉,改为调用权威模块;权威能力不足则**在权威模块内扩展**。
|
||||
|
||||
### 3.2 扩展点先行,而非到处开分支
|
||||
|
||||
- 需要支持新变体时,优先看**有没有现成扩展点**(策略注册/管线节点/工厂分派);有则加一项。
|
||||
- 没有且已达"三次法则"→ **重构出扩展点**,再加变体;不要在核心里再堆一个 `if`。
|
||||
|
||||
### 3.3 死代码与"有意保留的扩展点"要区分
|
||||
|
||||
- **死代码**(不可达、被替代、幻觉引用)→ 删除,减少认知负担。
|
||||
- **有意保留的扩展点**(当前不可达但为将来能力预留)→ **注释写明意图**,避免被当死代码清理,
|
||||
也避免被误当"已实现"。二者的区别必须在代码里显性表达。
|
||||
|
||||
### 3.4 改动的提交纪律
|
||||
|
||||
- **一次提交聚焦一件事**(一个修复/一个功能/一处重构/一批测试),信息写清"做了什么/为什么"。
|
||||
- 业务缺陷与测试缺陷**分开修、分开提交**;重构与功能改动不混在一个提交里。
|
||||
|
||||
---
|
||||
|
||||
## 4. 反模式清单(一眼识别)
|
||||
|
||||
| 反模式 | 为什么坏 | 正解 |
|
||||
|--------|----------|------|
|
||||
| 隐式兜底 `\|\| 0 / \|\| []` 掩盖缺失 | 把 bug 藏到远处才爆发 | 权威字段缺失显式失败/返回 null |
|
||||
| 同一数据多处各算一遍 | 必然分叉、互相矛盾 | 单一权威源,下游只读 |
|
||||
| 下游修补上游数据 | 责任错位,掩盖真问题 | 责任前移到权威源 |
|
||||
| 入口/表现层里写算法 | 职责错层,难测难复用 | 下沉到领域/状态层 |
|
||||
| 一个入口 `switch(action)` 二次路由 | 绕开分层、膨胀成上帝函数 | 一操作一入口/一 rpc 一处理器 |
|
||||
| 空 `catch{}` 吞异常 | 故障隐形 | 在正确边界处理并记录 |
|
||||
| 为"将来也许"预埋抽象 | 过度设计、徒增复杂 | YAGNI + 三次法则,就近演进 |
|
||||
| 魔法数字/字符串散落 | 改规则要全局翻找 | 外提为语义化常量/配置 |
|
||||
| 多处各自解析同一配置编码 | 解析不一致 | 解析一次 → 只读消费 |
|
||||
| 同职能第二份"简化实现" | 并行逻辑分叉 | 收敛为一份权威,调用不重造 |
|
||||
| 正式代码为测试加 fallback | 本末倒置 | 测试适配生产契约 |
|
||||
| 诊断日志/探针遗留 | 噪声与信息泄漏 | 定位后即清 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 架构审查清单
|
||||
|
||||
提交/评审前逐条自检(与 [01](./01-架构总则与分层.md)/[02](./02-可扩展性与配置化.md) 对应):
|
||||
|
||||
- [ ] **SSOT**:这段数据有没有第二处在算?下游是不是只读?
|
||||
- [ ] **依赖方向**:有没有下层依赖上层 / 环依赖 / 入口层写算法?
|
||||
- [ ] **职责边界**:这段逻辑属于本模块吗?是不是重造了别处的能力?
|
||||
- [ ] **关注点分离**:决策与算法、数据与表现分开了吗?
|
||||
- [ ] **扩展 vs 修改**:新增能力是"加+注册"还是"改核心"?该用扩展点吗?
|
||||
- [ ] **不过度设计**:这个抽象有 ≥2 个真实变体吗?还是想象的?
|
||||
- [ ] **配置化**:有会变/复用/无语义的裸值该外提吗?配置只解析一次吗?
|
||||
- [ ] **显式失败**:关键路径缺数据是报错还是兜底掩盖?
|
||||
- [ ] **错误处理**:有没有空 catch / 吞异常 / 带病继续?
|
||||
- [ ] **可观测**:关键节点有可复盘日志吗?诊断探针清了吗?
|
||||
- [ ] **演进**:有没有留下第二份并行实现 / 死代码没区分意图?
|
||||
|
||||
---
|
||||
|
||||
## 6. 本篇小结
|
||||
|
||||
- 数据权威四铁律 + 前后端权威划分:**服务端裁定、前端表现、shared 同源不转移权威**。
|
||||
- 错误处理:**fail-fast、不吞异常、分层归属、可观测、诊断即清**。
|
||||
- 演进:**收敛并行实现、扩展点先行、区分死代码与扩展点、一次一提交**。
|
||||
- 用反模式清单和审查清单把三篇的原则落到每次改动上。
|
||||
|
||||
回到 [README](./README.md) 查看导航与一页纸总则。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 子游戏前后端开发规范 · 工程与架构通则
|
||||
|
||||
> 本套文档总结一套**专业、成熟、平台无关**的子游戏开发规范。它不讲某个平台怎么接入,
|
||||
> 而是从**架构与工程**的角度,给出前后端通用的设计原则、可扩展模式、配置化实践与反模式清单,
|
||||
> 指导开发者写出**优雅、现代、易演进、少返工**的子游戏代码。
|
||||
|
||||
---
|
||||
|
||||
## 这套文档解决什么
|
||||
|
||||
平台接入细节(三层路由、export/import、收发包协议、ES5/require 等)已在各端
|
||||
`development-guide/` 中讲清;本套文档是它们之上的**工程方法论**:
|
||||
|
||||
- **怎样分层**,让依赖单向、边界清晰、改一处不牵一身;
|
||||
- **怎样扩展**,让新增玩法/规则/策略是"加代码"而非"改核心";
|
||||
- **怎样配置化**,把会变的东西从硬编码里拿出来,用数据驱动;
|
||||
- **怎样守住数据权威**,让同一份数据只有一个来源、缺失即显式失败;
|
||||
- **怎样不过度设计**,在"能扩展"与"够简单"之间拿捏。
|
||||
|
||||
> 一句话定位:**平台接入是"能不能跑通",本套规范是"跑得久、改得动、错得少"。**
|
||||
|
||||
---
|
||||
|
||||
## 阅读导航
|
||||
|
||||
| 篇 | 文档 | 解决什么 |
|
||||
|----|------|----------|
|
||||
| 00 | 本文 README | 定位、适用范围、一页纸总则、与既有文档的关系 |
|
||||
| 01 | [01-架构总则与分层.md](./01-架构总则与分层.md) | 七大架构总则;前后端参考分层;依赖方向与稳定依赖 |
|
||||
| 02 | [02-可扩展性与配置化.md](./02-可扩展性与配置化.md) | 扩展模式(注册表/策略/管线/工厂/事件)何时用;配置化与去硬编码;避免过度设计 |
|
||||
| 03 | [03-数据权威·错误处理·演进.md](./03-数据权威·错误处理·演进.md) | 数据权威(前后端);错误处理与可观测;演进与重构;反模式与审查清单 |
|
||||
|
||||
---
|
||||
|
||||
## 一页纸:七大总则
|
||||
|
||||
1. **单一权威数据源(SSOT)**:同一业务数据只有一个计算/写入处,其他只读;缺失即显式失败,不兜底掩盖。
|
||||
2. **单向依赖**:分层自上而下依赖,稳定的被依赖、易变的作依赖方;**禁止环形依赖**。
|
||||
3. **职责单一、边界清晰**:一个职能只在一个模块实现,别处**调用而非重造**。
|
||||
4. **关注点分离**:决策与机制分离、数据与表现分离、编排与算法分离。
|
||||
5. **对扩展开放、对修改封闭(OCP)**:用注册/策略/管线**加**能力,不改动已稳定的核心。
|
||||
6. **配置优先于硬编码**:会变、复用、无语义的值一律外提为常量/配置,用数据驱动行为。
|
||||
7. **显式失败优于隐式兜底**:关键路径缺数据就报错/返回 `null`,把问题暴露在最近处。
|
||||
|
||||
> 这七条互相支撑:**SSOT + 显式失败**保正确,**单向依赖 + 职责单一 + 关注点分离**保清晰,
|
||||
> **OCP + 配置化**保可演进。任何设计取舍,回到这七条对照。
|
||||
|
||||
---
|
||||
|
||||
## 适用范围与边界
|
||||
|
||||
- **适用**:子游戏自身的前后端业务代码(玩法逻辑、对局编排、收发包处理、表现层、共享算法)。
|
||||
- **不覆盖**:平台框架代码(不可改)、平台接入契约(见各端 `development-guide/`)。
|
||||
- **与硬约束的关系**:ES5、`require` 守卫、可编辑范围、成败标志 `data.success` 等**硬红线**仍以
|
||||
`development-guide/` 为准;本套是**方法论层**,与之互补不冲突。
|
||||
|
||||
## 与既有文档的关系
|
||||
|
||||
| 文档 | 定位 |
|
||||
|------|------|
|
||||
| 各端 `development-guide/` | 平台接入 + 工程红线(**能跑、合规**) |
|
||||
| `docs/architecture/` | **本系统**的具体架构说明(是什么样) |
|
||||
| `.github/copilot/skills/data-authority-principle.md` | 数据权威原则(本套 03 篇在其上扩展到前后端) |
|
||||
| **本套 `docs/engineering/`** | **平台无关的通用工程与架构规范**(该怎么设计) |
|
||||
|
||||
> 具体命名(模块名/文件名/方法名)在本套文档里多为**示例、可自定**;约束的是**做法与结构**,不是具体名字。
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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() ——浏览器由 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/ ← 平台框架(除子游戏容器目录外,全部禁改)
|
||||
├── 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 个接口各做什么、一次操作的完整数据流。
|
||||
</content>
|
||||
@@ -0,0 +1,309 @@
|
||||
# 02 · 子游戏接入与开发流程
|
||||
|
||||
本篇讲**怎么动手**:一个子游戏由哪三个必需文件构成、`export.js` 的一组回调接口与 `import.js` 的封装接口各做什么、`makewar`/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。
|
||||
|
||||
> 仍以麻将举例,但"三文件架构 + export/import 接口 + 主动推送"对任意子游戏一致。
|
||||
|
||||
> **命名说明**:本篇代码里的业务 `rpc` 名(`playCard`、`declareHu` 等)与 handler/类/文件名(`RpcHandler.handlePlayCard`、`OperationManager`、`ResponseBuilder`、`BroadcastManager` 等)均为**示例、可自定**(业务 `rpc` 名只需前后端一致);只有平台接缝上的名字(包字段、`export`/`import` 钩子名、平台 API、`data.success`)是契约。详见 [README「命名约定」](./README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 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 创建模块
|
||||
|
||||
```js
|
||||
// cls_mod.new(模块名, 路由名, 所属应用)
|
||||
var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
|
||||
```
|
||||
|
||||
路由名即前端包里的 `route`:**必须与前端包的 `route` 一致、且在应用内唯一**;它与目录名并无绑定关系(`routename` 与目录同名只是约定,非框架要求)。
|
||||
|
||||
### 2.2 按依赖顺序加载文件
|
||||
|
||||
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块):
|
||||
|
||||
```js
|
||||
// 浏览器/友乐:min_loadJsFile 异步链式加载
|
||||
min_loadJsFile("<容器目录>/<你的游戏>/常量与工具.js", function(){
|
||||
min_loadJsFile("<容器目录>/<你的游戏>/数据结构.js", function(){
|
||||
min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){
|
||||
min_loadJsFile("<容器目录>/<你的游戏>/import.js", function(){
|
||||
min_loadJsFile("<容器目录>/<你的游戏>/业务与rpc.js", function(){
|
||||
console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成");
|
||||
});});});});});
|
||||
```
|
||||
|
||||
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。
|
||||
|
||||
### 2.3 定义 RPC 方法(规范)
|
||||
|
||||
RPC 方法是**前后端交互的服务端入口**。以下是必须遵守的规范(**规则是契约**,示例里的具体方法名/handler 名可自定,见 [README「命名约定」](./README.md))。
|
||||
|
||||
#### 规则一:函数名 = 前端包的 `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 里:
|
||||
|
||||
```js
|
||||
// mod_<你的游戏> 上挂的 RPC 方法(示例名,可自定)
|
||||
mod_<你的游戏>.playCard = function(pack) {
|
||||
// 就绪守卫:handler 由 min_loadJsFile 异步加载,未就绪时防御性返回(见规则四)
|
||||
if (typeof RpcHandler === 'undefined' || !RpcHandler) {
|
||||
return { success: false, error: 'RPC处理器未就绪' };
|
||||
}
|
||||
return RpcHandler.handlePlayCard(pack); // 委托到收发包层,真正逻辑在这里
|
||||
};
|
||||
mod_<你的游戏>.declareHu = function(pack) {
|
||||
return RpcHandler.handleDeclareHu(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`:
|
||||
|
||||
```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_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` 必须完成三件事,缺一不可:
|
||||
|
||||
```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` 按玩法需要实现相应回调接口(至少含"最常用的一批",见 §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-数据收发与通信协议](./03-数据收发与通信协议.md) 详解包结构、发包方式、主动推送与 `success` 成败协议。
|
||||
</content>
|
||||
@@ -0,0 +1,264 @@
|
||||
# 03 · 数据收发与通信协议
|
||||
|
||||
本篇是**日常手册**:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——**成败标志只认 `data.success`**。
|
||||
|
||||
> **命名说明**:本篇代码里的业务 `rpc` 名(`playCard` 等)与 handler/类/函数名(`BroadcastManager`、`deepCopy` 等)均为**示例、可自定**;只有平台接缝上的名字(包四字段、平台 API `sendpack_toseat`/`sendpack_toother`、成败字段 `data.success`)是契约。详见 [README「命名约定」](./README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据包结构
|
||||
|
||||
所有前后端数据包统一四字段:
|
||||
|
||||
```js
|
||||
{
|
||||
app: "youle", // 应用名(游戏固定 "youle")
|
||||
route: "<你的游戏>", // 路由名 = 服务端 cls_mod.new 第二参 routename(见 §1.1)
|
||||
rpc: "playCard", // 方法名,决定调到 mod 的哪个函数 / 前端走哪个解析分支(见 02 §2.3)
|
||||
data: { /* 业务数据 */ }
|
||||
}
|
||||
```
|
||||
|
||||
- `app/route/rpc` 三字段驱动三层路由(见 01)。
|
||||
- `data` 里**必含平台参数**:`agentid`、`playerid`、`gameid`、`roomcode`、`seat` 等;数值参数收包时要 `parseInt`。
|
||||
- **一包多信息**:一个响应包应携带本次状态变更的完整信息(出了什么牌、轮到谁、各家剩余、倒计时、比分……),减少往返。
|
||||
|
||||
### 1.1 `route` / `routename` 从哪来、在哪定义、怎么匹配
|
||||
|
||||
`route` 决定包被投到**哪个模块**,是前后端必须对齐的字符串。它的权威定义与匹配链如下(均可在代码验证):
|
||||
|
||||
- **服务端:`routename` 的唯一定义点**是 `mod.js` 里创建模块的 `cls_mod.new(模块名, 路由名, 所属应用)` 的**第二个参数**:
|
||||
|
||||
```js
|
||||
// server/games2/<你的游戏>/mod.js
|
||||
var mod_<你的游戏> = global.mod_<你的游戏>
|
||||
|| cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
|
||||
// ▲ 第一参 modname ▲ 第二参 routename(就是 route 要用的值)
|
||||
```
|
||||
|
||||
`cls_mod.new` 把第二参存进 `mod.routename`,并把模块 `push` 进 `app.modlist`(`server/class/class.mod.js`)。
|
||||
- **它是你自定义的字符串**:由你起名,只要**在应用内唯一**即可;与目录名、与模块名 `modname`(第一参,用于全局暴露 `global[modname]`、`app[modname]`)都**无强制绑定**——本项目三者恰好都叫 `jinxianmahjong` 只是约定(见 02 §2.1、01 §5)。
|
||||
- **平台按它匹配模块**:收包时 `cls_app.ReceivePack` 用 `pack.route == modlist[i].routename` 找到模块,再 `DoPack` 进第三层按 `rpc` 调方法(`server/class/class.app.js`,见 01 §4)。
|
||||
- **前端发包的 `route` 必须与它逐字一致**:前端把该值固化为常量(本项目 `codes/game/network/RpcSender.js` 里 `var ROUTE_NAME = 'jinxianmahjong'`,经 `Utl.sendData(app, route, rpc, data)` 发出)。**两端字符串不一致 → 平台匹配不到模块,包被静默丢弃**(前端也收不到任何响应)。
|
||||
- **与平台房间模块区分**:平台自带的房间模块 `routename` 是 `"room"`,处理创建/加入/开战(`createRoom`、`self_join_room`、`self_makewar` 等);**你的子游戏自定义 RPC**(`playCard` 等)走**你自己的 `routename`**。两者不要混用:平台流程发 `route:"room"`,玩法操作发 `route:"<你的游戏>"`。
|
||||
|
||||
> 一句话:**`routename` 在服务端 `cls_mod.new` 第二参定义(自定、应用内唯一),前端发包 `route` 必须与它逐字相同,平台据此把包投到你的模块**;改名要两端同步改。
|
||||
|
||||
---
|
||||
|
||||
## 2. 收包处理的固定步骤(在 handler 里)
|
||||
|
||||
`mod` 上的 RPC 方法只是**薄入口**(`return XxxHandler.handleXxx(pack)`,见 02 §2.3);下面这套"安检流程"发生在**它委托到的 handler 内部**,**不可省略、不可简化**:
|
||||
|
||||
```js
|
||||
XxxHandler.handlePlayCard = function(pack) { // 由 mod_<游戏>.playCard 委托进来
|
||||
try {
|
||||
// 1) 提取并校验参数:列出必填字段 + 数值型字段的类型要求
|
||||
// (本项目用统一工具 ValidationHelper.extractAndValidateParams 做这件事)
|
||||
// 等价于:agentid/playerid/gameid/roomcode/seat 必填,playerid/roomcode/seat 需为数值
|
||||
var params = extractAndValidateParams(
|
||||
pack,
|
||||
['agentid', 'playerid', 'gameid', 'roomcode', 'seat', 'cardUniqueId'],
|
||||
{ playerid: 'number', roomcode: 'number', seat: 'number' });
|
||||
if (!params.success) return { success: false, error: params.error };
|
||||
var p = params.params;
|
||||
|
||||
// 2) 校验玩家与房间(必做)——失败直接 return
|
||||
// ⚠️ conmode / fromid 取自 pack 顶层(不是 pack.data),是发包定向要用的连接信息
|
||||
var o_room = mod_<游戏>.import.check_player(
|
||||
p.agentid, p.gameid, p.roomcode, p.seat, p.playerid, pack.conmode, pack.fromid);
|
||||
if (!o_room) return { success: false, error: '玩家验证失败' };
|
||||
|
||||
// 3) 取桌对象与对局状态
|
||||
var o_desk = o_room.o_desk;
|
||||
if (!o_desk) return { success: false, error: '游戏桌不存在' };
|
||||
|
||||
// 4) 调试记录(若框架提供)——便于复盘
|
||||
if (o_desk.debug && o_desk.debug.save_receivepack) {
|
||||
o_desk.debug.save_receivepack(pack, p.seat, p.playerid);
|
||||
}
|
||||
|
||||
// 5) 业务校验 + 执行业务:委托权威模块(handler 不内联规则,见 04)
|
||||
// 如:OperationExecutor.executePlayCard(o_room, {...})
|
||||
// 6) 构建响应 + 【主动推送】:ResponseBuilder 组包 → BroadcastManager 逐座位 sendpack_toseat
|
||||
// 推送 data 自带 success(见 §5);return 不是下发通道(见 §4)
|
||||
} catch (e) { /* 记录日志;必要时给该座推送失败包 */ }
|
||||
};
|
||||
```
|
||||
|
||||
- **参数提取要校验类型**:数值字段(`playerid`/`roomcode`/`seat` 等)必须转成数值再用;缺字段/类型不符即判失败。用统一工具集中做(本项目 `ValidationHelper.extractAndValidateParams`)比每处手写 `parseInt` 更不易漏。
|
||||
- **`check_player` 是强制安检**:校验座位、连接、身份,返回 `o_room` 或 `null`;`null` 一律 `return`。它的第 6/7 个参数 `pack.conmode`、`pack.fromid` 来自**包顶层**(框架收包时注入),是后续定向发包的连接凭据。
|
||||
- **校验失败不回作弊提示**:对客户端不下发"你作弊了"之类反馈,直接结束(如需可只给本座推送一个通用失败包),避免给作弊者信息。
|
||||
- **`return` 值仅服务端内部用**:例中的 `return {success:false,...}` 不会下发前端(见 §4);要让前端看到的结果一律走第 6 步主动推送。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三种发包方式
|
||||
|
||||
| 方式 | 接口 | 用于 |
|
||||
|------|------|------|
|
||||
| 点对点 | `o_room.method.sendpack_toseat(msg, seat)` | 只发给某个座位(个人状态、定向数据) |
|
||||
| 广播其他人 | `o_room.method.sendpack_toother(msg, seat)` | 发给除 `seat` 外所有人(`seat=-1` 即全发) |
|
||||
| 差异化广播 | 逐座位组 `msg` 后各自 `sendpack_toseat` | 每个玩家看到的内容不同(如手牌只发本人) |
|
||||
|
||||
平台发包内部会**从 `seatlist[seat]` 上取 `conmode`/`fromid`** 填入包,再交给底层 `SendPack` 按 TCP/HTTP 下发——**这两个连接字段不需要你手工设置**,只要座位上有在线玩家即可。
|
||||
|
||||
### 差异化广播:棋牌最常用
|
||||
|
||||
牌类游戏里"同一动作、各家可见不同",所以广播时为每个座位**深拷贝一份基础数据再定制**:
|
||||
|
||||
```js
|
||||
for (var seat = 0; seat < o_room.seatlist.length; seat++) {
|
||||
if (!o_room.seatlist[seat]) continue; // 空座跳过
|
||||
var msg = {
|
||||
app: "youle", route: "<游戏>", rpc: "playCard",
|
||||
data: deepCopy(baseData) // 公共信息
|
||||
};
|
||||
if (seat === actionSeat) {
|
||||
msg.data.handCards = hands[seat]; // 仅本人可见手牌
|
||||
} else {
|
||||
msg.data.handCards = []; // 他人看不到
|
||||
}
|
||||
o_room.method.sendpack_toseat(msg, seat);
|
||||
}
|
||||
```
|
||||
|
||||
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。
|
||||
|
||||
---
|
||||
|
||||
## 4. 主动推送是唯一可靠的下发通道
|
||||
|
||||
**前端收包的唯一通道是服务端主动推送**(`sendpack_toseat / toother` → 底层 `SendPack`)。
|
||||
|
||||
而 RPC 方法的 **`return` 值不可靠下发前端**:框架虽会对 `DoPack` 的返回值做一次回发,但它只回当前请求连接、不按座位定向,浏览器/友乐链路下不能当作状态推送通道(机制见 01 §4)。
|
||||
|
||||
**推论**:凡需要让前端看到的结果(包括操作的成功/失败),都必须放进**主动推送的 `data`** 里。
|
||||
|
||||
---
|
||||
|
||||
## 5. 成败标志协议(本项目最重要的一条)
|
||||
|
||||
> 这条覆盖并修正了早期文档里"用 HTTP 风格 `status` 码判成败"的写法。**以本协议为准。**
|
||||
|
||||
### 规则
|
||||
|
||||
1. **成败唯一权威字段是 `data.success`(boolean)**。前端一律 `if (!data.success)` 判断操作成败。
|
||||
2. **禁止用 `data.status`(如 `status === 200`)或 `data.code` 判成败**。`status`/`code` 只能作展示/日志用的细分信息,不参与成败裁定。
|
||||
3. **主动推送必须自带 `success`**:因为 `return` 不下发前端,凡有成败语义的**推送包**,其 `data` 必须显式带 `success: true/false`,不能只放 `status`。
|
||||
4. **禁止双轨/兼容兜底**:前端不得写 `status !== 200 && !success` 之类的 `status` 兜底;新增/改动的推送一律补齐 `success`,前端一律只认 `success`。
|
||||
|
||||
### 正反例
|
||||
|
||||
```js
|
||||
// ❌ 错误:推送只带 status,前端读 data.success 恒 undefined → 误判失败
|
||||
o_room.method.sendpack_toseat({
|
||||
app:"youle", route:"<游戏>", rpc:"setHostingState",
|
||||
data: { status: 200, hosting: true } // 少了 success
|
||||
}, seat);
|
||||
|
||||
// ✅ 正确:成败语义放 success,status 仅作细分
|
||||
o_room.method.sendpack_toseat({
|
||||
app:"youle", route:"<游戏>", rpc:"setHostingState",
|
||||
data: { success: true, status: 200, hosting: true }
|
||||
}, seat);
|
||||
```
|
||||
|
||||
```js
|
||||
// 前端:只认 success
|
||||
if (!data.success) { /* 失败处理 */ return; }
|
||||
// data.status / data.code 仅用于展示或日志细分
|
||||
```
|
||||
|
||||
> 典型事故:托管状态推送 `{ status: 200, ... }` 不带 `success`,前端读 `data.success` 恒为 `undefined`,于是"已进入托管却报失败"。根因就是违反了本协议第 3 条。
|
||||
|
||||
---
|
||||
|
||||
## 6. 端到端收发全链路(前后端对照)
|
||||
|
||||
本节把**一个包从前端出发、到服务端处理、再推回前端**的完整链路摊开,标出**每一步在谁的哪个文件/函数**,供前端与服务端开发共同对照。下面的锚点里,**平台/契约名**(包字段、`packet_face.ReceivePack`、`Utl.sendData`、`Game_Modify.*`、`sendpack_toseat`、`data.success`)是真实且固定的;**子游戏侧命名**(`RpcSender`、`SubGameHooks`、收包分发器、各 handler)是本项目示例、可自定(见 [README「命名约定」](./README.md))。
|
||||
|
||||
### 6.1 方向 A:玩家主动操作(请求 → 处理 → 推送)
|
||||
|
||||
```
|
||||
前端 client 服务端 server
|
||||
──────────────── ────────────────
|
||||
业务/控制器
|
||||
└ 发包封装 / RpcHelper(自动补 agentid/playerid/roomcode/seat…)
|
||||
└ Utl.sendData(app, route, rpc, data) ──WS/HTTP──▶ packet_face.ReceivePack (按 app 找应用)
|
||||
(平台 08_Utl_Output.js) └ cls_app.ReceivePack (按 route 找模块,01 §4)
|
||||
└ cls_mod.DoPack (按 rpc 找方法)
|
||||
└ mod[rpc](pack) RPC 薄入口(02 §2.3)
|
||||
└ XxxHandler.handleXxx(pack)
|
||||
check_player → 取 o_desk → 校验
|
||||
→ 委托权威业务模块(04)
|
||||
→ o_room.method.sendpack_toseat(msg, seat)
|
||||
(填 conmode/fromid)
|
||||
Game_Modify._ReceiveData(_msg) ◀──WS/HTTP── → app.SendPack → packet_face.SendPack
|
||||
(平台 02_SubGame_Input.js,受限文件) (只定向该座位的那条连接,01 §4)
|
||||
└ 转交子游戏收包分发器(SubGameHooks._ReceiveData)
|
||||
└ 按 _msg.rpc 查路由表 → 对应 rpc 处理器
|
||||
└ if(!data.success) 失败处理;否则改数据模型 → 表现(04 篇)
|
||||
```
|
||||
|
||||
- **前端出口只有一个**:所有请求经发包封装/`RpcHelper` 最终落到平台 `Utl.sendData(app, route, rpc, data)`,业务不手拼包(前端 04 §1)。
|
||||
- **服务端入口只有一个**:`packet_face.ReceivePack`,随后三层路由 `app→route→rpc` 精确投递(01 §4)。
|
||||
- **服务端出口只有一个**:`o_room.method.sendpack_toseat/sendpack_toother`——它从 `seatlist[seat]` 取 `conmode/fromid` 填入包,交 `app.SendPack` 定向下发(`class.room.js`)。**`return` 不算下发**(§4)。
|
||||
- **前端收口只有一个**:平台 `09_Net.js` 收到主动推送后调 `Game_Modify._ReceiveData(_msg)`(受限文件只转交,不写业务),再由子游戏**收包分发器按 `_msg.rpc`** 分到对应处理器(前端 04 §2)。
|
||||
|
||||
### 6.2 方向 B:服务端主动推送(无前端请求)
|
||||
|
||||
超时、AI 托管、他人操作波及本座、每小局/大局结算等,都是**服务端主动发起**、前端没有对应请求的推送。它**复用方向 A 的后半段**——同样 `sendpack_toseat/toother` → 前端 `_ReceiveData` → 按 `rpc` 分发:
|
||||
|
||||
```
|
||||
服务端某处业务(定时器/AI决策/结算)
|
||||
→ o_room.method.sendpack_toseat(msg, seat) (与真人操作完全相同的出口与包结构)
|
||||
→ 前端 _ReceiveData → 收包分发器按 rpc → 对应处理器
|
||||
```
|
||||
|
||||
> 因此前端**无法也无需区分**一个推送是"我请求的响应"还是"服务端主动发的"——两者走同一条收口、同一张分发表。服务端替玩家操作必须保持这种一致(§7、04 §7)。
|
||||
|
||||
### 6.3 谁在哪:收 / 发 / 路由一览
|
||||
|
||||
| 环节 | 前端(client) | 服务端(server) |
|
||||
|------|----------------|------------------|
|
||||
| **发包出口** | 发包封装/`RpcHelper` → `Utl.sendData(app,route,rpc,data)`(平台 `08_Utl_Output.js`) | `o_room.method.sendpack_toseat` / `sendpack_toother` → `app.SendPack` |
|
||||
| **传输** | WebSocket/HTTP(平台 `09_Net.js` / `00_minhttp.js`) | 平台 `packet.js`(`SendPack_Tcp` / `SendPack_Http`) |
|
||||
| **收包入口** | 平台 `09_Net.js` → `Game_Modify._ReceiveData(_msg)`(受限文件转交) | `packet_face.ReceivePack`(`packet.js`) |
|
||||
| **路由依据** | 子游戏收包分发器按 `_msg.rpc` → 处理器 | 三层 `app→route→rpc`(01 §4),`rpc` 直取同名 `mod` 方法 |
|
||||
| **处理** | 对应 `rpc` 处理器,先判 `data.success` | RPC handler:`check_player` → 取 `o_desk` → 校验 → 委托权威业务 |
|
||||
| **成败标志** | 只认推送 `data.success`(§5) | 推送 `data` 必自带 `success`(§5) |
|
||||
| **开局** | `Game_Modify.StartWar(_msg)`(取本座数据,02 篇/前端 04 §3) | `export.makewar` 返回包(`sendtype:1 + seatlist[]` 差异化) |
|
||||
| **重连/中途加入** | `Game_Modify.Reconnect(_deskinfo)`(据快照重画) | `export.get_deskinfo` 返回该座完整快照 |
|
||||
|
||||
### 6.4 对接接缝:改一端必核对另一端
|
||||
|
||||
`rpc` 是前后端的**共同契约字符串**:服务端推送用哪个 `rpc`,前端就必须在收包分发表里注册同名处理器;反之前端请求的 `rpc`,服务端 `mod` 上必须有同名方法。因此:
|
||||
|
||||
| 场景 | 服务端产出 | 前端接收 |
|
||||
|------|------------|----------|
|
||||
| 开战 | `export.makewar` 的返回包 | `Game_Modify.StartWar(_msg)` → 开局处理 |
|
||||
| 重连/中途加入 | `export.get_deskinfo` 的返回 | `Game_Modify.Reconnect(_deskinfo)` → 重画 |
|
||||
| 对局/推送 | RPC handler 或主动推送(按 `rpc`) | 收包分发表里同名 `rpc` 的处理器 |
|
||||
|
||||
**改服务端下发结构 = 同步核对前端对应 `rpc` 的解析**;**新增一种推送 = 服务端选定 `rpc` + 前端在分发表加同名处理器**。任一端单方面改,另一端必按旧结构解析出错。前端侧的收发细节以 [`client/docs/development-guide/04-网络对接与启动编排`](../../../client/docs/development-guide/04-网络对接与启动编排.md) 为权威。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务端代替玩家操作时的透明性
|
||||
|
||||
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)
|
||||
|
||||
---
|
||||
|
||||
## 8. 小结
|
||||
|
||||
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
|
||||
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
|
||||
- **主动推送是唯一可靠下发通道**,`return` 不算。
|
||||
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
|
||||
- **端到端一条链**(§6):前端 `Utl.sendData` → 服务端 `packet_face.ReceivePack`→三层路由→handler→`sendpack_toseat` → 前端 `Game_Modify._ReceiveData`→按 `rpc` 分发;`rpc` 是前后端共同契约,改一端必核对另一端。
|
||||
- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。
|
||||
|
||||
下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。
|
||||
</content>
|
||||
@@ -0,0 +1,181 @@
|
||||
# 04 · 开发规范与红线
|
||||
|
||||
本篇汇总所有**必须遵守**的工程纪律。每一条都对应过真实事故或返工,是代码审查的清单来源。改任何代码前,按相关条目自检。
|
||||
|
||||
> **命名说明**:本篇提到的模块/类/文件名(如 `GameStateManager`、`RoomAdapter` 等)多为**示例、可自定**,红线约束的是**做法**而非具体名字;只有平台接缝上的名字(包字段、`export`/`import` 钩子、平台 API、`data.success`)是契约。详见 [README「命名约定」](./README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 可编辑范围
|
||||
|
||||
### 服务端
|
||||
|
||||
- **唯一可编辑目录**:子游戏自己的目录 `server/<游戏容器目录>/<你的游戏>/`。容器目录名由接入方自定,**非框架强制**,换任何不与框架冲突的目录皆可(框架按 `modename` 而非目录名定位子游戏,见 01)。
|
||||
- **禁改**:`server/` 其余一切均为平台代码——`server/class/`、`server/config/`、`server/server/`、`server/youle/`、`server/update/`、`server/packet.js`、`server/applist.js`、`server/minhttp.js` 等。
|
||||
- **唯一例外**:接入新游戏时需在平台文件 `server/youle/app.js` 里加一行 `min_loadJsFile("<容器目录>/<你的游戏>/mod.js", ...)` 完成注册加载——这是必须触碰平台代码的**唯一游戏接入点**,除此之外不得改动 `server/youle/`。
|
||||
|
||||
### 前端
|
||||
|
||||
- **平台代码禁改**:`client/js/00_Surface/` 下全部文件。
|
||||
- **受限接口文件**(不可新增对外接口,尽量不改,确需则只在现有接口内部加逻辑):
|
||||
`client/js/01_SubGame/00_SubGame_Config.js`、`01_SubGame_modify.js`、`02_SubGame_Input.js`。
|
||||
- **新增前后端交互**一律走 `mod.js` 的 `mod_<游戏>.<rpc>` 机制,**不在受限文件里新增接口**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 语言标准:严格 ES5
|
||||
|
||||
- 全部 JS **必须严格符合 ES5**:用 `var`/`function`、字符串用 `+` 拼接。
|
||||
- **禁止** ES6+:`let`/`const`、箭头函数、模板字符串、解构、默认参数、展开运算符、`class`、`for...of`、`Promise`/`async`/`await`、对象简写等。
|
||||
- 原因:线上浏览器/友乐运行时不保证 ES6+ 支持。
|
||||
|
||||
---
|
||||
|
||||
## 3. 模块加载:require 双运行时守卫
|
||||
|
||||
- **所有 `require` 必须写在文件开头的 `if (typeof require !== 'undefined') { ... }` 守卫块内。**
|
||||
- **禁止函数体内 / 中途 `require`**(含"延迟 require 规避循环依赖"的写法)。浏览器/友乐运行时无 `require`,无守卫的 `require` 会抛 `ReferenceError: require is not defined`。
|
||||
- 跨模块运行时**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析。
|
||||
|
||||
```js
|
||||
// ✅ 正确
|
||||
if (typeof require !== 'undefined') {
|
||||
var GameStateManager = require('./dataStructures/GameStateManager.js');
|
||||
}
|
||||
function foo() { GameStateManager.doSomething(); } // 直接用全局名
|
||||
|
||||
// ❌ 错误:函数体内中途 require —— 浏览器崩溃
|
||||
function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
|
||||
```
|
||||
|
||||
### 双运行时全局暴露陷阱
|
||||
|
||||
"构造函数 + 单例实例"式模块,`module.exports` 之后必须**无条件**把全局名暴露出去(**不要放进 `else` 分支**),否则线上浏览器拿不到该全局,报 `xxx is not a function`,而 Node 测试测不出来。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据权威原则
|
||||
|
||||
- **数据源唯一**:同一业务数据只有一个权威来源;上游计算/写入/校验,下游只读取/消费,**不重复推断、不重复拼装**。
|
||||
- **禁止猜测兜底**:不用默认值掩盖缺失数据。**关键权威字段缺失要显式报错或返回 `null`**,由上层决定是否终止,**禁止** `|| 0`、`|| []`、`|| ''`、双源回退等掩盖。
|
||||
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
|
||||
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
|
||||
|
||||
> 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
|
||||
|
||||
---
|
||||
|
||||
## 5. 模块职责边界
|
||||
|
||||
- **一个职能只在一个模块实现**,其他模块**只调用、不重造**。
|
||||
- 需要某能力时,调用对应**权威模块**;权威能力不满足,应在权威模块内扩展,而非在调用方旁路重写。
|
||||
- **禁止**:在 A 模块内联 B 模块核心算法的"简化版";同一职能在两处各有一份实现并行演化。
|
||||
- 后果:交叉实现产生双份并行逻辑,必随规则演化分叉、互相矛盾——这是数据权威原则在"代码职责"维度的同一条红线。
|
||||
|
||||
### "自动操作"模块只做决策,不重造规则
|
||||
|
||||
服务端自动替玩家操作的模块(如 AI 托管)**只负责决策**(出哪张、是否碰/杠/吃/胡/过),**核心算法一律复用权威实现**:胡牌检测、听牌/做牌分析、合法操作枚举、牌型判定等都已在权威模块实现,决策模块只能**读取/调用其结果**,禁止自行重算。
|
||||
|
||||
- 判别:回答"能否胡/听什么/有哪些合法操作"——属核心算法,必须复用;回答"在合法选项中选哪个更好"——才属决策。
|
||||
|
||||
---
|
||||
|
||||
## 6. 房间隔离
|
||||
|
||||
服务端同进程并发多张牌桌(多个 `o_room`)。**任何随对局变化的状态都必须以房间为单位隔离**:
|
||||
|
||||
- **状态挂房间**:对局数据存 `o_room.o_desk.data.*`,由 `o_room` 携带。**禁止存在模块级单例 / 全局变量 / 静态字段**。
|
||||
- **不得只用 `seat` 作 key**:座位号仅 0–3,多房并发必碰撞。缓存、定时器表、决策状态、计数器等**必须用 `房间 + seat` 复合维度**(或每房一份实例)。
|
||||
- **定时器随房生命周期**:所有 `setTimeout`/`setInterval` 必须能按房间定位与清理;小局结束、解散房间、开新局时,**清理该房名下全部定时器与残留状态**,禁止泄漏到下一局或别房。
|
||||
- 覆盖范围:自动托管/决策表、算法中间态缓存(听牌/胡牌检测)、超时与回合定时器、待响应队列等,任一项跨房共享都会导致 A 房误改 B 房。
|
||||
|
||||
> 一句话:一切随对局变化的东西都属于某个房间,必须能用 `o_room` 唯一定位、隔离与回收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 服务端自动操作复用真人链路
|
||||
|
||||
服务端代替玩家执行的操作(AI 托管等):
|
||||
|
||||
- **必须复用真人手动操作的同一套数据包链路**:走与真人相同的服务端处理入口与广播下发路径,产生的包结构与真人**完全一致**。
|
||||
- **对前端透明**:前端只按既有"玩家操作"逻辑解析表现,**禁止为"自动操作"单开一套接收/解析/表现分支**,无需区分触发源。
|
||||
- **唯一区别在触发源**:由"前端请求"变为"服务端决策",其后数据组织、下发协议、广播路径不变。
|
||||
|
||||
---
|
||||
|
||||
## 8. Shared 文件同步流程
|
||||
|
||||
### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关
|
||||
|
||||
- **`shared/` 属于子游戏,不是平台代码**:它存放**本玩法自身**的规则、算法、常量、数据结构(本项目如胡牌检测 `WinDetectionFactory`、精牌 `JingAlgorithm`、计分 `ScoreCalculation`、牌型/比精,以及 `constants/` 下各类常量)。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。
|
||||
- **为什么要"共享"**:**同一份玩法逻辑需要在两处运行**——服务端(Node)做**权威裁定**,前端(浏览器)做**即时表现/预校验**。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"前后端必须完全一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。
|
||||
- **它与"数据权威"的关系**:`shared/` 是**逻辑同源**(两端同一套算法),不改变**数据权威**(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 `shared/` 算的为准。
|
||||
|
||||
### 同步流程:服务端权威源 → 前端只读副本
|
||||
|
||||
前后端各持一份 `shared/`,**必须**经同步流程单向更新,禁止直接编辑前端副本:
|
||||
|
||||
| 角色 | 路径 |
|
||||
|------|------|
|
||||
| 权威源(服务端,唯一可改) | `server/<游戏容器目录>/<你的游戏>/shared/` |
|
||||
| 前端副本(只读,脚本生成) | `client/js/01_SubGame/codes/shared/` |
|
||||
|
||||
1. **只改服务端** `shared/` 下文件。
|
||||
2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。
|
||||
3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。
|
||||
|
||||
> 判别一段逻辑该不该进 `shared/`:**它是不是"前后端必须算出完全相同结果"的玩法逻辑**?是(胡牌/听牌/比精/牌型/计分/规则常量)→ 放 `shared/`;只是服务端流程编排或只是前端表现 → 各自放自己那侧,不进 `shared/`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 硬编码常量准则
|
||||
|
||||
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
|
||||
|
||||
**不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试纪律
|
||||
|
||||
- **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。
|
||||
- **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。
|
||||
- **每个失败必须裁定归属**(基于证据):【业务代码缺陷】还是【测试脚本缺陷】,二选一。手段:临时诊断探针、打印中间态、最小复现;拿证据再下结论,**禁止凭猜测定性**(诊断日志定位后清理)。
|
||||
- **按归属修复**:业务缺陷 → 修业务、单独提交、写明根因;脚本缺陷 → 修置场/时序,断言保持硬断言。优先级:**确定性构造场景 > 有界重试采样 > 条件跳过**。
|
||||
- **flaky 同样是缺陷**:要么业务竞态、要么测试非确定性置场,须根治;验收标准是**连跑 ≥5 次全绿**。
|
||||
|
||||
### 测试不绑架正式代码
|
||||
|
||||
- **正式核心代码禁止存在专为测试服务的逻辑**,更禁止为"让测试通过/兼容测试"而新增或修改正式代码。
|
||||
- 方向永远是**测试适配正式代码的生产契约**,而非正式代码迁就测试的简化输入/不规范 stub。测试要构造符合生产契约的输入与 stub。
|
||||
- 违例信号:正式代码注释出现"仅兼容测试 stub""如单元测试传 X"之类,即是违例。
|
||||
|
||||
---
|
||||
|
||||
## 11. Git 提交规范
|
||||
|
||||
- **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。
|
||||
- **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。
|
||||
- **提交信息用中文**,简明说明"做了什么/为什么",一次提交聚焦一件事,结尾保留 `Co-Authored-By` 署名行。
|
||||
|
||||
---
|
||||
|
||||
## 12. 审查速查表
|
||||
|
||||
| 维度 | 红线 |
|
||||
|------|------|
|
||||
| 范围 | 只改子游戏目录 `<容器目录>/<游戏>/` 与允许的前端范围 |
|
||||
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
|
||||
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
|
||||
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补 |
|
||||
| 职责 | 一职能一模块,调用不重造 |
|
||||
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
|
||||
| 自动操作 | 复用真人链路,对前端透明 |
|
||||
| Shared | 只改服务端 `shared/`,跑同步脚本 |
|
||||
| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 |
|
||||
| Git | 一事一提交、中文信息、及时提交 |
|
||||
|
||||
---
|
||||
|
||||
至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。
|
||||
</content>
|
||||
@@ -0,0 +1,98 @@
|
||||
# 服务端 · 子游戏开发指导文档
|
||||
|
||||
> 本套文档是**友乐游戏平台**下「子游戏服务端」开发的通用指导与规范。
|
||||
> 它讲清楚三件事:**框架怎么运作**、**子游戏怎么接进去**、**开发时必须守哪些规矩**。
|
||||
>
|
||||
> 文中以「麻将」一类房卡棋牌作举例,但所有结论都是**框架通用**的,不绑定任何具体玩法。
|
||||
> 新开发者按本套文档即可理解运作流程、动手接入并写出符合规范的子游戏。
|
||||
|
||||
---
|
||||
|
||||
## 这套文档写给谁
|
||||
|
||||
- **新接手子游戏服务端的开发者**:先读完 01、02 建立全局认知,再按 03、04 动手。
|
||||
- **正在开发/维护某个子游戏的开发者**:03、04 是日常红线,改任何东西前回查。
|
||||
- **做代码审查的人**:04 是审查清单的来源。
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
| 篇 | 文档 | 解决什么问题 |
|
||||
|----|------|--------------|
|
||||
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
|
||||
| 01 | [01-服务端环境与框架基础.md](./01-服务端环境与框架基础.md) | 平台与子游戏的关系、核心对象模型、三层路由、房间生命周期 |
|
||||
| 02 | [02-子游戏接入与开发流程.md](./02-子游戏接入与开发流程.md) | 三文件架构、export/import 接口、makewar/重连、一次操作的完整数据流 |
|
||||
| 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、主动推送、成败标志协议、前后端对接点 |
|
||||
| 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威、模块职责、房间隔离、测试纪律 |
|
||||
|
||||
建议第一次**从 01 顺序读到 04**;之后把 03/04 当手册随用随查。
|
||||
|
||||
---
|
||||
|
||||
## 一页纸:核心运作模型
|
||||
|
||||
```
|
||||
客户端数据包 { app, route, rpc, data }
|
||||
│
|
||||
▼
|
||||
packet_face.ReceivePack 按 pack.app 找到「应用」
|
||||
│
|
||||
▼
|
||||
app.ReceivePack 按 pack.route 找到「模块」(子游戏)
|
||||
│
|
||||
▼
|
||||
mod.DoPack 按 pack.rpc 调到「方法」 mod[rpc](pack)
|
||||
│
|
||||
▼
|
||||
子游戏 RPC handler 校验玩家 → 取对局状态 → 执行业务 → 主动推送
|
||||
│
|
||||
▼
|
||||
o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下发客户端
|
||||
```
|
||||
|
||||
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。
|
||||
- **子游戏**负责:玩法规则、对局状态、每个操作的处理与广播。两者通过 **export / import** 两组接口对接。
|
||||
|
||||
> 上图是**入站半程**(前端 → 服务端 handler)。完整的往返链路(含服务端 `sendpack_toseat` 推回 → 前端 `Game_Modify._ReceiveData` 按 `rpc` 分发)见 [03 §6「端到端收发全链路」](./03-数据收发与通信协议.md#6-端到端收发全链路前后端对照),前端侧细节见 [`client/docs/development-guide/04`](../../../client/docs/development-guide/04-网络对接与启动编排.md)。
|
||||
|
||||
---
|
||||
|
||||
## 命名约定:哪些是框架契约,哪些只是示例
|
||||
|
||||
本套文档的代码示例里出现的**具体名字,绝大多数是举例,并非强制标准**。请区分两类:
|
||||
|
||||
- **框架契约(必须照用)**——由平台代码决定,改了就对接不上:
|
||||
- 数据包四字段 `{ app, route, rpc, data }`,其中 `app` 固定为 `"youle"`;
|
||||
- 平台真实回调的 export 钩子名、你调用的 import 接口名(如 `makewar`、`get_deskinfo`、`check_player`、`deduct_roomcard`、`save_grade`);
|
||||
- 成败字段 `data.success`;
|
||||
- 平台 API 名(`cls_mod.new`、`min_loadJsFile`、`o_room.method.sendpack_toseat` / `sendpack_toother` 等)。
|
||||
- **示例命名(可自定)**——本项目的举例,你的子游戏可按自己的风格命名:
|
||||
- 业务 `rpc` 名(如 `playCard`、`declareHu`,只需**前后端约定一致**即可);
|
||||
- handler / 类 / 文件 / 变量 / 分层目录名(如 `RpcHandler.handlePlayCard`、`OperationManager`、`ResponseBuilder`、`BroadcastManager`、`RoomAdapter`、`GameController`、`game/`、`rpc/`、`rules/` 等)。
|
||||
|
||||
> 一句话:**平台接缝上的名字是契约、要照用;接缝之内你自己代码里的名字都是示例、可自定。** 后续 01–04 的代码块同样遵循这条约定,不再逐处重复声明。
|
||||
|
||||
---
|
||||
|
||||
## 红线速查(详见 04)
|
||||
|
||||
> 以下每一条都有过真实事故或返工,**改代码前先对照**。
|
||||
|
||||
- **可编辑范围**:服务端只能改子游戏目录 `server/<游戏容器目录>/<你的游戏>/`(容器目录名由接入方自定,**非框架强制**,换任何不与框架冲突的目录皆可),`server/` 其余皆平台代码,禁改。接入新游戏还需在 `server/youle/app.js` 里加一行 `min_loadJsFile` 加载其 `mod.js`(唯一必须触碰的平台文件,属游戏注册接入点)。
|
||||
- **双运行时**:代码同时跑在 Node 与浏览器/友乐平台。必须严格 **ES5**;`require` 只能写在文件开头 `if (typeof require !== 'undefined') {}` 守卫块内,**禁止函数体内/中途 require**。
|
||||
- **成败标志唯一是 `data.success`**:前端只认 `if (!data.success)`;**主动推送的 `data` 必须自带 `success`**(mod 的 `return` 值不可靠下发前端);禁止用 `status`/`code` 判成败、禁止 `status` 兼容兜底。
|
||||
- **数据权威**:同一业务数据只有一个权威来源,下游只读不重算;权威字段缺失要**显式报错/返回 null**,禁止 `|| 0`、`|| []`、`|| ''` 兜底掩盖。
|
||||
- **模块职责边界**:一个职能只在一个模块实现,其他模块**调用而非重造**。
|
||||
- **房间隔离**:一切随对局变化的状态都挂 `o_room.o_desk.data.*`;**禁止模块级单例/全局变量存对局态**;缓存/定时器/决策表不得只用 `seat` 作 key,必须 `房间 + seat`;结束/解散/开新局时按房间清理全部定时器与残留状态。
|
||||
- **服务端自动操作复用真人链路**:服务端代替玩家做的操作(如 AI 托管)必须复用「真人操作」的同一套处理入口与广播链路,对前端透明,不为它单开一套下发分支。
|
||||
- **服务器权威**:房卡、胜负、积分、状态等关键数据与操作一律以服务器为准,前端数据仅供显示。
|
||||
- **测试纪律**:测试失败先用证据裁定「业务缺陷 vs 测试脚本缺陷」,禁止 skip/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
|
||||
|
||||
---
|
||||
|
||||
## 与既有文档的关系
|
||||
|
||||
- 平台级、面向「所有子游戏」的总纲在 `docs/important/server/`(友乐框架收发包规范、子游戏开发要求)。
|
||||
- 本套文档是其**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
|
||||
- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `<游戏容器目录>/<游戏>/docs/` 为准。
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user