新规hook

This commit is contained in:
2026-07-06 17:16:05 +08:00
parent 363f97cdbe
commit 047561675b
28 changed files with 510 additions and 1345 deletions
@@ -1,6 +1,6 @@
# 01 · 前端架构与运行环境
本篇建立**全局认知**:前端跑在什么环境、由哪几层构成、新旧两套架构如何并存、文件按什么顺序加载。读懂这一篇,后面的渲染、系统、网络才有坐标。
本篇建立**全局认知**:前端跑在什么环境、由哪几层构成、新旧两套架构如何并存、文件按什么顺序加载。
> 举例以麻将为主,但 `gameabc-framework` 是游戏中立的通用框架,本篇机制对任意子游戏一致。
@@ -9,7 +9,7 @@
## 1. 运行环境
- **部署形态**:前端是**浏览器静态资源**,由 gameabc 引擎(`js/vendor/gameabc.min.js`)驱动,绘制基于精灵(Sprite)/图层(Layer)/群组(Group)的 2D 画面。不走 npm 构建。
- **与服务端**:通过平台网络层收发 JSON 包(`{app, route, rpc, data}`),全程异步;服务端权威,前端只显示与发起操作。
- **与服务端**:通过平台网络层收发 JSON 包(`{app, route, rpc, data}`),全程异步。**服务端权威**——所有核心运算与胜负裁定以服务端为准、数据以服务端为权威;**前端只做界面展示与玩家交互**,本地保存的数据只是「用于渲染的副本」,不做权威计算(前端渲染与交互范式见 02,红线见 05)。
- **ES5 强制**:线上浏览器/平台不保证 ES6+。全部 JS 必须严格 ES5——用 `var`/`function`,对象继承用 `Object.create`,禁 `let`/`const`/箭头函数/模板字符串/`class`/解构/默认参数。
- **少量 Node 仅用于测试/工具**:`shared/` 算法可在 Node 跑单测,`scripts/` 是 Spine 数据构建脚本;线上运行时无 `require`,跨文件一律按**全局名**引用(各文件用 `var X = ...` 暴露全局,`index.html` 顺序加载)。
@@ -40,7 +40,7 @@ js/01_SubGame/ 子游戏前端
> `codes/` 内部如何分目录、如何命名文件,均由子游戏自行决定,本套文档不作规定;唯一例外是 `shared/`——它是服务端共享算法的同步副本,前端只读(见 05)。
依赖方向是**单向**的:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架**不反向依赖**任何子游戏(这正是上一步把 `EventBus` 里的麻将事件剥离出去的原因,见 03/05 的「框架中立」)。
依赖方向是**单向**的:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架**不反向依赖**任何子游戏(见 03/05 的「框架中立」)。
---
@@ -88,8 +88,6 @@ js/01_SubGame/ 子游戏前端
1. **被依赖者先加载**。例如框架 `EventBus.js` 必须在子游戏事件常量之前、事件常量又必须在任何注册这些事件的视图之前;整合所有精灵常量的入口文件必须**最后**加载。
2. **新增文件要插对位置**。新增一个常量/组件文件,必须在 `index.html` 里插到其依赖之后、使用者之前,否则运行时拿到 `undefined`。
> 示例:把玩法专属事件从框架剥到子游戏事件常量文件时,正是把它插在 `EventBus.js`(框架)之后、其使用者(视图组件)之前,引用零改动。
---
## 5. codes/ 内部组织
@@ -111,4 +109,3 @@ js/01_SubGame/ 子游戏前端
- 加载顺序即依赖,新增文件务必插对位置。
下一篇 [02-渲染与UI组件体系](./02-渲染与UI组件体系.md) 讲:精灵怎么画、资源常量怎么组织、UI 组件怎么写。
</content>
@@ -11,49 +11,34 @@
gameabc 的画面由**精灵(Sprite)**组成,按**图层(Layer)**、**群组(Group)**组织。操作精灵走一条严格分层链:
```
UI 组件
└─ SpriteManager 业务级 API:ID 范围校验 + 单位换算
└─ GameABCUtils 唯一直接调引擎原生 API 的模块(底层原子操作)
└─ gameabc.min.js 引擎
UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
→ GameABCUtils(唯一直接调引擎原生 API 的模块) → gameabc.min.js(引擎)
```
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。分层的意义:ID 校验、单位换算、引擎版本隔离都集中在边界,业务层无感。
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。ID 校验、单位换算、引擎版本隔离都集中在这层边界,业务层无感。
### SpriteManager 常用 API
| API | 作用 | 说明 |
|-----|------|------|
| `show(id)` / `hide(id)` | 显示/隐藏精灵 | 返回 `boolean`,**失败要查返回值** |
| `setFrame(id, frame)` | 切换多帧图片的帧 | 一个精灵多帧,靠切帧表现不同牌面,**胜过为每张牌建一个精灵** |
| `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)` | 图层批量 | 整个界面显隐 |
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer/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);
}
// 一精灵切帧表现不同牌面;刷新弃牌区先全 hide 再按数据 show + setFrame(帧号 = code-1)
SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
```
### 精灵 ID 二元性
精灵 ID 有两种,`SpriteManager` 透明支持,开发者无需区分:
- **数字 ID**:编辑器里预置的精灵(如 `1121`)。
- **字符串 ID**:运行时由 `SpriteCopyUtils` 动态复制出的精灵,格式 `"容器IDadd标签"`(如 `"2836add0"`)。
精灵 ID 有两种,`SpriteManager` 透明支持、无需区分:**数字 ID**(编辑器预置,如 `1121`)与**字符串 ID**(运行时由 `SpriteCopyUtils` 动态复制,格式 `"容器IDadd标签"`,如 `"2836add0"`)。
### ID 范围(必须遵守)
@@ -66,15 +51,15 @@ for (var j = 0; j < discards.length; j++) {
| 声音 | **≥ 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+`;其余段为框架保留,**不可占用**。
图层按 100 为段与框架交替分配:子游戏用 `101–200`(常规界面)、`301–400`(弹窗,示例用 302)、`501–600`、`701+`;其余段框架保留,**不可占用**。
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `SpriteManager` 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
---
## 2. 资源与布局常量三件套
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职:
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职,外加一个**整合入口**统一为一份精灵常量并**最后加载**:
| 常量类别 | 管什么 | 一句话 |
|----------|--------|--------|
@@ -82,37 +67,27 @@ ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `S
| 图片资源常量 | **图片资源 ID** | 每个精灵用哪张图、几帧、每帧什么、尺寸 |
| 布局常量 | **坐标/尺寸/偏移** | 纯数据,精灵摆在哪、多大、怎么排 |
外加一个**整合入口**把三类统一为一份精灵常量,并**最后加载**。
### 资源手动创建,常量靠注释指路
### 资源与精灵手动创建,常量靠注释指路
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在游戏编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的**完全一致**(见 §1,绝不编造)。
正因资源是"先手动建、再按 ID 引用",**常量定义必须写准注释,让人据注释就能准确创建出对应资源**:
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的完全一致(见 §1,绝不编造)。因此**常量注释必须写准,让人据注释就能准确创建对应资源**:
- **精灵结构常量**:每个精灵注明【精灵类型(图片精灵 / 文字精灵)+ 功能 + 关联的图片资源 ID + 帧数说明】。
- **图片资源常量**:每个资源注明【帧数(单帧 / N 帧,且每帧含义)+ 用途 + 尺寸】。
(声音、Spine 资源的注释要求见 03。资源 ID 范围与注释格式细则以《友乐游戏引擎精灵与资源管理接口规范》为准。)
(声音、Spine 资源的注释要求见 03。资源 ID 范围与注释格式细则以平台的精灵与资源管理接口规范为准。)
### 三者如何配合(新增一块 UI 的流程)
以“新增一个弹窗”为例:
1. **精灵结构常量**:定义精灵结构与 ID(弹窗图层 301–400 段、群组、各精灵 ID 1001–3000)。
2. **图片资源常量**:定义所需图片资源 ID(背景、行模板…),注明帧数/帧义/尺寸。
3. **布局常量**:定义坐标尺寸(列表容器、行高、各列偏移)。
4. **整合入口**:把新结构并入统一的精灵常量。
5. **写组件**:UI 代码只引用精灵常量与 `SpriteManager`,不出现任何裸数字。
以“新增一个弹窗”为例:① **精灵结构常量**定义精灵结构与 ID(弹窗图层 301–400 段、群组、各精灵 1001–3000);② **图片资源常量**定义所需图片资源 ID(背景、行模板…),注明帧数/帧义/尺寸;③ **布局常量**定义坐标尺寸(列表容器、行高、各列偏移);④ **整合入口**并入统一的精灵常量;⑤ **写组件**只引用精灵常量与 `SpriteManager`,不出现任何裸数字。
### 几条约定
- **按钮只要 1 帧普通态图**:点击的缩放/透明反馈由框架统一处理,不要为“按下态”单独配图。
- **按钮只要 1 帧普通态图**:点击的缩放/透明反馈由框架统一处理,不为“按下态”单独配图。
- **帧动画只在图片资源常量记资源 ID**,帧区间/帧间隔/循环等播放参数放动画配置(见 03),不在资源文件重复。
- **布局文件是纯数据**:无函数、无副作用。侧视角的“透视倾斜”用每张牌累积的透视偏移量表达。
- **多文件用保护性声明**:`var XxxConstants = XxxConstants || {};` 便于拆分到主界面/弹窗多个文件,加载后合并为一份。
- **多文件用保护性声明** `var XxxConstants = XxxConstants || {};`,便于拆到主界面/弹窗多个文件、加载后合并为一份。
> 模板 `gameabc-framework/templates/*.template.js` 是这三类文件的**写法蓝本**,含详细的字段注释与 `@see` 指向实现文件。派生新子游戏时复制模板再按实际资源填充。
> 模板 `gameabc-framework/templates/*.template.js` 是这三类文件的**写法蓝本**(含字段注释与 `@see` 指向实现文件)。派生新子游戏时复制模板再按实际资源填充。
---
@@ -120,36 +95,21 @@ ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `S
所有视图组件继承框架的 `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.sprites = []; this.eventListeners = {}; this.data = { /* 自有数据只放这里,见 §3 范式 */ };
this.layer = config.layer || 102;
// 创建精灵(ID 从精灵常量取,见 §2)
this.addSprite(spriteConstants.SOME_PANEL_BG);
// 注册事件(用 addEventListener,destroy 时自动清理)
this.addSprite(spriteConstants.SOME_PANEL_BG); // 精灵 ID 从常量取(见 §2)
var self = this;
this.addEventListener(EventBus.Events.GAME_STARTED, function (data) {
self._onGameStarted(data);
});
this.addEventListener(EventBus.Events.GAME_STARTED, function (d) { self._onGameStarted(d); });
this.isInitialized = true;
};
```
### 生命周期钩子
| 钩子 | 何时 | 是否覆盖 |
|------|------|----------|
| `init(config)` | 创建时 | ✅ 子类必须实现:建精灵、注册事件 |
@@ -159,46 +119,20 @@ MyView.init = function (config) {
### 事件必须走 `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()` 才清理监听。
直接 `EventBus.on(...)` 在 `destroy` 时不会自动 `off`、监听残留 → 泄漏;一律改用 `this.addEventListener(...)`:它包装处理函数(绑定 `this`、捕获异常、销毁后忽略事件)并记录下来,在 `destroy()` 时统一 `EventBus.off`。**销毁组件用 `destroy()` 而非只 `hide()`**——只有 `destroy()` 才清理监听。
### 组件数据自持 + set-refresh 范式(核心)
**前端只负责界面展示与玩家交互;所有核心运算与胜负裁定以服务端为准、数据以服务端为权威**(见 01/05)。组件本地保存的只是「用于画界面的数据副本」,据此渲染,不做权威计算。为此每个组件遵循统一范式:
**前端只负责界面展示与玩家交互;所有核心运算与胜负裁定以服务端为准、数据以服务端为权威**(见 01/05)。组件本地保存的只是「用于画界面的数据副本」,据此渲染、不做权威计算。为此每个组件遵循统一范式:
1. **数据集中在 `this.data`**:组件(及其下每个「零件 UI」)的自有数据全部收拢到 `this.data.*`,**不散落**在实例其他字段或全局;界面能否重建只取决于 `this.data`。
2. **每个 UI 都有 `setXxx` / `refreshXxx` 成对方法**:组件本身、以及组件内每个「零件 UI」(如玩家信息条、分数、手牌区、按钮组)都提供一对——
- `setXxx(...)`:**只写数据**到 `this.data`,不碰精灵;
- `refreshXxx()`:**只据 `this.data` 画界面**,不改数据。
两者职责单一、互不越界。
3. **组件有一个总 `refresh()`**:顺序调用其下所有零件的 `refreshXxx()`,把整块界面据 `this.data` 完整重画。**任何时候调用 `refresh()` 都能无歧义重建正确界面**(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。
1. **数据集中在 `this.data`**:组件及其下每个「零件 UI」的自有数据全部收拢到 `this.data.*`,**不散落**在实例其他字段或全局;界面能否重建只取决于 `this.data`。
2. **每个 UI 都有 `setXxx` / `refreshXxx` 成对方法**:`setXxx(...)` **只写数据**到 `this.data`、不碰精灵;`refreshXxx()` **只据 `this.data` 画界面**、不改数据。两者职责单一、互不越界。
3. **组件有一个总 `refresh()`**:顺序调用其下所有零件的 `refreshXxx()`,据 `this.data` 完整重画。**任何时候调 `refresh()` 都能无歧义重建正确界面**(断线重连、切 app 回来、网页刷新都复用它,不另写一套渲染)。
```js
var GameView = Object.create(BaseComponent);
GameView.init = function (config) {
this.data = { players: [], score: 0 /* ...自有数据只放这里 */ };
// ...
};
// 零件 UI:分数。set 只写数据,refresh 只据数据画
GameView.setScore = function (v) { this.data.score = v; };
GameView.refreshScore = function () { SpriteManager.setText(SCORE_ID, this.data.score); };
// 总 refresh:据 this.data 重画所有零件
GameView.refresh = function () {
this.refreshPlayers();
this.refreshScore();
// ...其余零件
};
GameView.setScore = function (v) { this.data.score = v; }; // 只写数据
GameView.refreshScore = function () { SpriteManager.setText(SCORE_ID, this.data.score); }; // 只据数据画
GameView.refresh = function () { this.refreshPlayers(); this.refreshScore(); /* ...其余零件 */ };
```
**收包的正确流程**:先 `setXxx` 把数据写进 `this.data`(必要时 `refresh` 刷新静态界面),**再**做动画/表现。动画只是体验层,其「开始/结束/出错」等生命周期回调里**只刷新界面、绝不设置核心数据**。这样即使动画卡住/播错、或开发前期还没做动画,数据、逻辑与界面依然正确、互不影响。
@@ -207,112 +141,70 @@ GameView.refresh = function () {
## 4. UIManager:注册与场景切换
`UIManager` 管理所有组件实例与**场景**(一组组件的具名快照)。
`UIManager` 管理所有组件实例与**场景**(一组组件的具名快照):`registerComponent(name, comp)` 注册组件、`registerScene(sceneName, [组件名...])` 注册场景、`switchToScene(name)` 切换(自动隐藏旧场景组件、显示新场景组件)。
```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.init(config); // 见下:注入全局 UI 常量
UIManager.registerComponent('GameView', MyView.create({ name: 'GameView', layer: 102 }));
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` 时传入配置:
全局 UI `showLoading/hideLoading`、`showMessage`、`showConfirm` 的精灵常量由**子游戏注入**——框架不读任何子游戏全局名,子游戏在 `init` 时传入(不传则跳过全局 UI;也可后续 `UIManager.configureGlobalUI(config)`):
```js
UIManager.init({
loadingUI: spriteConstants.LOADING_UI,
messageUI: spriteConstants.MESSAGE_UI,
confirmUI: spriteConstants.CONFIRM_UI
}); // 不传则跳过全局 UI;也可后续 UIManager.configureGlobalUI(config)
UIManager.init({ loadingUI: spriteConstants.LOADING_UI, messageUI: spriteConstants.MESSAGE_UI, confirmUI: spriteConstants.CONFIRM_UI });
```
**游戏专属弹窗**用“向 UIManager 挂方法”的扩展模式(在独立 JS 里 `UIManager.showCreateRoom = function(){...}`),保持框架本身中立。
**游戏专属弹窗**用“向 UIManager 挂方法”的扩展模式(独立 JS 里 `UIManager.showCreateRoom = function(){...}`),保持框架本身中立。
---
## 5. 精灵交互事件:SpriteEventController
精灵的点击/拖拽/绘制等交互,由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发——它是**游戏中立**的通用能力,玩法专属逻辑通过钩子注册,不写进框架。
平台引擎的交互事件先进受限入口 `Game_Modify.utlmousedown/mouseup/utlmousemove/utlgamemydrawbegin/gamemydraw`,这些入口**只把事件转调**给 `SpriteEventController.handle*`,由它按「精灵 ID」分发到注册的回调。
### 按精灵 ID 注册回调
精灵的点击/拖拽/绘制交互由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发——**游戏中立**,玩法专属逻辑通过钩子注册、不写进框架。平台引擎交互先进受限入口 `Game_Modify.utlmousedown/mouseup/utlmousemove/utlgamemydrawbegin/gamemydraw`,这些入口**只把事件转调**给 `SpriteEventController.handle*`,由它按「精灵 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])
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「框架中立」)。
对**每个**绘制精灵统一处理(不针对某个固定 ID)用 `registerGlobalDraw(fn)`——框架对每个 draw 事件都回调它、但**不认识**其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
### 更高层:手势识别 `SpriteGestureRecognizer`
需要识别**双击 / 滑动 / 点击**等手势时,用框架 `system/SpriteGestureRecognizer.js`(建立在 `SpriteEventController` 之上)。它把原始按下/移动/松开识别为语义手势,业务只写回调:
识别**双击 / 滑动 / 点击**等手势用框架 `system/SpriteGestureRecognizer.js`(建在 `SpriteEventController` 之上),把原始按下/移动/松开识别为语义手势,业务只写回调:
```js
var handle = SpriteGestureRecognizer.attach(spriteIds, {
onPress: function (e) {}, // 按下即时反馈(如元素站起)
onDoubleTap: function (e) {}, // 双击同一精灵
onSwipe: function (e) {}, // 沿 e.direction 滑动越阈
onTap: function (e) {} // 点击(小位移按下→松开)
onPress: fn, // 按下即时反馈(如元素站起)
onDoubleTap: fn, // 双击同一精灵
onSwipe: fn, // 沿 e.direction 滑动越阈
onTap: fn // 点击(小位移按下→松开)
}, { doubleTapInterval: 300, swipe: { direction: 'up', threshold: 50 }, minMove: 10 });
// handle.detach() / resetDoubleTap() / resetDrag()
```
阈值由子游戏注入(框架只给默认值、不反读子游戏常量);双击/上滑/点击判定全在框架,选中/出牌等业务全留子游戏。
> 范例:手牌的「双击出牌 / 上划出牌 / 点击选中取消」由子游戏的交互处理器用本识别器实现——手势识别归框架,玩法业务归子游戏。
---
## 6. 动态列表:SpriteCopyUtils 与 DynamicSpriteList
行数不定的列表(如听牌提示、战绩行)用**动态复制精灵**实现,不要为每行预置精灵。
行数不定的列表(听牌提示、战绩行)用**动态复制精灵**实现,不为每行预置精灵:
- **`SpriteCopyUtils`**(底层):从“模板精灵”复制出带 `tag` 的子精灵,返回字符串 ID;`create/remove/removeRange`。
- **`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 } }
}
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.setData(rows); // 数据驱动
list.onClick = function (type, rowIndex, rowData) { /* ... */ };
// 组件 onDestroy 中:list.destroy(); ← 必须,否则复制精灵残留
```
@@ -334,4 +226,3 @@ list.onClick = function (type, rowIndex, rowData) { /* ... */ };
| 动态列表用 `DynamicSpriteList` 并 `destroy` | 手搓 SpriteCopy 又忘清理 |
下一篇 [03-事件·动画·音频·Spine](./03-事件·动画·音频·Spine.md) 讲表现系统:事件总线、动画、音频与 Spine。
</content>
@@ -21,18 +21,13 @@
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';
E.GAME_STARTED = 'game:started'; // 通用语义事件
E.TILES_DEALT = 'mahjong:tilesDealt'; // 玩法专属事件
// ...本子游戏用到的全部事件
})();
```
> 这正是本仓库的落点:框架 `EventBus` 不预置任何事件常量、保持纯粹中立,所有事件(含通用语义)都由子游戏在其事件常量文件定义——既保证框架可被任意玩法复用,也避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
> 框架 `EventBus` 不预置任何事件常量、保持纯粹中立,避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
### 用法
@@ -60,7 +55,7 @@ EventBus.once(EventBus.Events.GAME_FINISHED, fn); // 一次性
3) 动画完成回调里只刷新静态界面(refresh)
```
好处:动画是“锦上添花”,**即使不播动画,静态界面也始终正确**(据组件 `this.data` 调 `refresh` 即可还原,见 02 组件范式 / 04 重连)。**动画的开始/结束/出错等生命周期回调里只刷新界面、绝不设置核心数据**;动画期间**绝不修改游戏数据**。这样动画卡住/播错、或开发前期没有动画时,数据、逻辑与界面依然正确、互不影响。
即使不播动画,静态界面也始终正确(据组件 `this.data` 调 `refresh` 即可还原,见 02 组件范式 / 04 重连)。**动画的开始/结束/出错等生命周期回调里只刷新界面、绝不设置核心数据**;动画期间**绝不修改游戏数据**。
### AnimationManager API(框架,通用)
@@ -117,14 +112,9 @@ AnimationManager.frame(spriteId, opt.startFrame, opt.endFrame, opt.duration, opt
- **`AudioManager`(框架)**:`playSound(file)`、`playVoice(baseId, sex)`、`playVoiceBySeat(seat, baseId)`、`playMusic/stopMusic`。约定女音 ID = 男音 ID + 偏移;**不含任何牌值/动作映射**。
- **音效资源常量(子游戏)**:集中定义音效/语音文件 ID(音效、男/女语音等分类)。新增音效**只改这里**。
- **游戏音频管理(子游戏)**:把“出牌/碰/某张牌”等概念映射到音效资源键,再调 `AudioManager`。**不写文件名/数字 ID**。
- **游戏音频管理(子游戏)**:把“出牌/碰/某张牌”等概念映射到音效资源键,再调 `AudioManager`。**不写文件名/数字 ID**。通用音效直接走框架 `AudioManager.playSound(音效资源常量.某音效)`。
```js
// 子游戏音频管理:按概念播放,内部映射到资源键并按座位性别选男/女语音
// 通用音效直接走框架:AudioManager.playSound(音效资源常量.某音效)
```
**声音文件手动准备**:音频文件由开发者**手动**按平台命名规范放入资源目录(文件名/存放目录/ID→文件名转换规则以《友乐游戏引擎精灵与资源管理接口规范》为准),代码只按 ID 引用、不生成文件。因此**音效资源常量每个 ID 必须注明【类型(背景音乐 / 音效 / 语音)+ 用途 + 时长 + 播放方式(循环 / 单次)】**,让人据注释就能准确准备对应音频。
**声音文件手动准备**:音频文件由开发者**手动**按平台命名规范放入资源目录(文件名/存放目录/ID→文件名转换规则以平台的资源管理接口规范为准),代码只按 ID 引用、不生成文件。因此**音效资源常量每个 ID 必须注明【类型(背景音乐 / 音效 / 语音)+ 用途 + 时长 + 播放方式(循环 / 单次)】**,让人据注释就能准确准备对应音频。
**DO**:新音效只改音效资源常量;业务只调游戏音频管理。**DON'T**:在游戏音频管理里写裸文件名/ID;硬编码性别映射。
@@ -181,4 +171,3 @@ Spine 骨骼资源(`json` / `atlas` / 贴图)由开发者**手动**制作并
| Spine | 概念走 Spine 动作配置,回调走 Spine 回调分发器,改资源跑脚本 | 硬编码 spineId/animName;散接回调 |
下一篇 [04-网络对接与启动编排](./04-网络对接与启动编排.md) 讲:怎么收发包、新旧架构如何对接、一局怎么启动。
</content>
@@ -2,7 +2,7 @@
本篇讲**前端怎么和服务端打通、一局怎么启动**:发包链路、收包分发、新旧架构的对接边界、成败判定、启动顺序与 controllers/managers 的职责。
> **想看前后端端到端全链路**(一个包从前端出发 → 服务端三层路由/handler → 推回前端分发的完整往返、及各步在谁的哪个文件)见服务端 [`docs/server/development-guide/03 §6`](../../server/development-guide/03-数据收发与通信协议.md#6-端到端收发全链路前后端对照)。本篇聚焦**前端这一侧**的收发细节。
> **想看前后端端到端全链路**(一个包从前端出发 → 服务端三层路由/handler → 推回前端分发的完整往返、及各步在谁的哪个文件)见服务端 [03 §6「端到端收发全链路」](../../server/development-guide/03-数据收发与通信协议.md#6-端到端收发全链路前后端对照)。本篇聚焦**前端这一侧**的收发细节。
---
@@ -19,10 +19,9 @@
### RpcHelper(自动注入平台字段)
`RpcHelper` 把每个请求自动补齐平台必需字段,业务只传业务数据:
`RpcHelper` 把每个请求自动补齐平台必需字段(`agentid / gameid / playerid / roomcode / seat ...`),业务只传业务数据:
```js
// 自动注入:agentid / gameid / playerid / roomcode / seat ...
RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路由
```
@@ -38,13 +37,11 @@ RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路
## 2. 收包:统一分发
服务端的主动推送统一格式 `{ rpc, data }`,前端有**唯一收包入口**,再按 `rpc` 分发到对应处理器。收包分发器是**纯路由表 + 分发器**:
服务端的主动推送统一格式 `{ rpc, data }`,前端有**唯一收包入口**,再按 `rpc` 分发到对应处理器。收包分发器是**纯路由表 + 分发器**(一 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 一条
};
@@ -98,12 +95,11 @@ Game_Modify.StartWar = function (_msg) {
## 4. 成败判定:只认 `data.success`
> 与服务端 [`docs/server/development-guide/03`](../../server/development-guide/03-数据收发与通信协议.md) 同一条协议。
> 与服务端 [03 数据收发与通信协议](../../server/development-guide/03-数据收发与通信协议.md) 同一条协议。
前端 RPC 没有“同步返回”,操作结果由服务端**后续主动推送**告知。判成败的唯一权威是推送 `data` 里的 **`success`**:
```js
// 处理器统一写法
function handleXxx(data) {
if (!data || !data.success) { /* 失败处理 */ return; }
// 成功逻辑
@@ -168,4 +164,3 @@ function handleXxx(data) {
| 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 |
下一篇 [05-开发规范与红线](./05-开发规范与红线.md) 汇总前端所有工程纪律。
</content>
@@ -40,7 +40,7 @@
- 资源/布局 → 子游戏精灵结构/图片资源/布局常量。
- **违例信号**:框架文件里出现 `mahjong:`、牌型、吃碰杠胡等具体玩法字样(注释举例除外,且举例应尽量中立)。
> 范例:本仓库把框架 `EventBus.js` 内**所有**预定义事件常量清空(只留空容器 `EventBus.Events={}`),全部事件(通用语义 + 玩法专属)改由子游戏的事件常量文件定义;并把通用的精灵事件控制器从子游戏抽到框架 `system/SpriteEventController.js`、剥离其中的玩法标记耦合为「全局 draw 钩子」;把 `UIManager` 的全局 UI(Loading/Message/Confirm)从「主动读子游戏精灵常量」改为「由子游戏 `init(config)` 注入」。新玩法照此扩展,框架零改动。
> 范例:框架 `EventBus.js` 预定义事件常量全部清空(只留 `EventBus.Events={}`),事件改由子游戏事件常量文件定义;通用精灵事件控制器抽到框架 `system/SpriteEventController.js` 并把玩法标记耦合改为「全局 draw 钩子」;`UIManager` 的全局 UI(Loading/Message/Confirm)由子游戏 `init(config)` 注入而非主动读子游戏常量。新玩法照此扩展,框架零改动。
---
@@ -92,7 +92,7 @@
- **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。
- `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。
- 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [`docs/server/development-guide/04 §8`](../../server/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §8 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。
---
@@ -129,5 +129,4 @@
---
至此,从架构与环境(01)、渲染与组件(02)、表现系统(03)、网络与启动(04)到工程红线(05),构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。
</content>
至此 01–05 构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。
@@ -44,21 +44,8 @@
`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)
```
- ✅ **允许(改值)**:给已有配置项赋值,如 `Game_Config.Max.PlayerCnt = 4;`、`Game_Config.Share.title = "进贤麻将";`、改 `Game_Config.Info.TextContent`(常用语)/`Game_Config.Chat.ChatLoc`(气泡坐标)等数组元素的【值】。
- ❌ **禁止(改定义 / 结构)**:新增配置项(`Game_Config.Info.MyNewField = 1;`);删除配置项(`delete Game_Config.Voice;`);改变项的类型/结构(`Game_Config.Max = [4];`);重命名平台已定义字段(如 `PlayerCnt` → `playerCount`)。
> **为什么**:平台代码(`00_Surface/*`)按**固定字段名**读取 `Game_Config.*`。增 / 删 / 改名定义会让平台读到 `undefined` 或破坏约定,引发线上故障。**配置项的集合与结构是平台契约,只有「值」属于子游戏**。
>
@@ -75,19 +62,17 @@ 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 类)。
**数据流**:平台调 `Game_Modify.X(args)` → 转发壳查 `SubGameHooks.X` → 存在则委托子游戏实现并返回其结果;不存在则执行空操作(A)/平台默认返回值(B)/模板默认 UI 渲染(D)。
**兼容性原因**:平台只认全局接口名,不关心实现在哪。老游戏不定义 `SubGameHooks`、三文件实心实现 → 照跑;新游戏用转发壳三文件 + `SubGameHooks` → 也跑。无需任何运行期判断。
@@ -108,7 +93,7 @@ Hooks 外置模式下,职责分为三层,单向向下:
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 返回平台默认值)。
3. **填充 SubGameHooks**:将 `SubGameHooks.template.js` 重命名为 `codes/SubGameHooks.js`,在其中实现本游戏需要的 hook(不需要的 hook 留空函数或删除,A 类无 hook 默认 no-op,B 类无 hook 返回平台默认值,D 类无 hook 由模板渲染可变人数默认 UI)。
4. **index.html 加载**:按既有顺序在原 `00_/01_/02_SubGame_*.js` 的位置加载新三文件,并在 `codes/` 相应位置加载 `SubGameHooks.js`(在其所依赖的 controllers/handlers 之后)。
### index.html 加载顺序要点
@@ -123,7 +108,7 @@ Hooks 外置模式下,职责分为三层,单向向下:
## 4. 转发壳范式
转发壳按「无 hook 时的默认行为」分三类,必须**逐一覆盖全部 63 个接口**(见 §6)。
转发壳按「无 hook 时的默认行为」分四类,必须**逐一覆盖全部平台接口**(见 §6)。
### A 类 — 纯子游戏行为(无 hook 即 no-op)
@@ -136,12 +121,6 @@ Game_Modify.StartWar = function (_msg) {
return SubGameHooks.StartWar(_msg);
}
};
Game_Modify.Reconnect = function (_msg) {
if (window.SubGameHooks && SubGameHooks.Reconnect) {
return SubGameHooks.Reconnect(_msg);
}
};
```
### B 类 — 有平台默认返回值(无 hook 即返回默认)
@@ -156,37 +135,23 @@ Game_Modify.getMaxPlayerCount = function (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;
};
```
`gameHallImport.*` 侧同理(如 `isInstalled` 无 hook 返回 `1`、`getLeaveLimit` 返回 `10`)。
### C 类 — 配置数据(不是 hook,留配置文件)
`Game_Config.*`(00 全部)与房型数据(`Game_Modify.Type_1/Type_2/CreateRoomData/game_config`、`Game_Modify.combat/roomDes`)属于**配置数据**而非行为接口,不进 `SubGameHooks`。
模板在 01 文件顶部设置「配置区」,由子游戏直接填值:
模板在 01 文件顶部设置「配置区」,由子游戏直接填值(与转发壳接口段物理分开):`Game_Modify.combat/roomDes/Type_1/Type_2/CreateRoomData/game_config`。
```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 = {}; // 游戏配置
```
### D 类 — 模板默认 UI 渲染(可变人数)
少数 UI 接口无 hook 时,模板不止 no-op,而是提供一套**可变人数**的默认渲染(随房间 2/3/4 人自适应各玩家位):`updatePlayerInfoUI`(玩家头像/昵称/分数)、`ShowChat`(桌面文字聊天气泡)、`gameui_play_voice` / `gameui_stop_voice`(桌面语音气泡)。
- 渲染只用平台全局数据(`Desk`/`C_Player`/`Game_Config`),并一律经框架 `SpriteManager` 操作精灵,**不引用 codes、不直接调引擎原语**。
- 座位→显示位的映射(2/3 人时精灵槽 ≠ 物理布局位)由内部助手统一解析,保证头像面板与聊天/语音气泡落在同一玩家位。
- **布局配置挂在 `Game_Modify` 下**(01 配置区,与 C 类配置数据同处):玩家信息用 `Game_Modify.PLAYER_INFO_LAYOUT`、聊天/语音气泡用 `Game_Modify.BUBBLE_LAYOUT`;子游戏可调这些坐标,或用同名 hook 完全接管该 UI。
---
@@ -203,30 +168,22 @@ Game_Modify.game_config = {}; // 游戏配置
目前仅 `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 类需给平台默认返回值 |
| `Game_Modify.*`(桌面聊天/语音气泡) | 3 | `ShowChat` / `gameui_play_voice` / `gameui_stop_voice`;D 类,模板提供可变人数默认渲染 |
覆盖原则:**A 类无 hook 即 no-op;B 类无 hook 须返回有意义的平台默认值**(如玩家数、离开上限、房间文案等),而非静默返回 `undefined`。各接口的具体分类与默认值以平台接入 spec 为权威,接入时逐一核对。
前三组共 63 项以平台接入 spec §9 为权威;桌面聊天/语音 3 项由本模板补充转发并提供可变人数默认。
覆盖原则:**A 类无 hook 即 no-op;B 类无 hook 须返回有意义的平台默认值**(如玩家数、离开上限、房间文案),**D 类无 hook 由模板按可变人数渲染默认 UI**(玩家信息/聊天/语音气泡,布局配置在 `Game_Modify`),均不应静默返回 `undefined`。各接口的具体分类与默认值以 spec 为权威,接入时逐一核对。
---
@@ -265,7 +222,7 @@ SubGameHooks.hallAppStart = function () {
| 验证项 | 方法 |
|--------|------|
| 接口不遗漏 | grep 对比三文件原定义与转发壳一一对应(63 个) |
| 接口不遗漏 | grep 对比三文件原定义与转发壳一一对应(基础 63 项 + 桌面气泡 3 项) |
| 签名不变 | 检查平台调用处参数与转发壳参数列表一致 |
| 行为等价 | 逐接口黑盒测试(开局/重连/战绩/离开等主流程) |
| 配置值完整 | `Game_Config.*` 与配置区数据均已保留 |
@@ -284,7 +241,7 @@ SubGameHooks.hallAppStart = function () {
| **配置值留配置文件** | `Game_Config.*` 和配置区数据(`Type_1/2/CreateRoomData` 等)不得塞进 `SubGameHooks`,保留在对应配置位置 |
| **hook 签名必须与平台接口一致** | 平台按位置传参,转发壳以相同参数透传给 hook,hook 签名不得偏移 |
| **二选一,不混用** | 同一接口不允许在三文件内联实现与 `SubGameHooks` 中同时存在 |
| **转发壳必须全覆盖** | 转发壳须覆盖全部 63 个接口,遗漏会导致平台调用落空(静默失败) |
| **转发壳必须全覆盖** | 转发壳须覆盖全部平台接口(基础 63 项 + 桌面聊天/语音气泡 3 项),遗漏会导致平台调用落空(静默失败) |
| **严格 ES5** | `SubGameHooks.js` 与所有 codes 文件一律 ES5,禁 `let`/`const`/箭头函数等 |
| **加载顺序正确** | `SubGameHooks.js` 须在其依赖的 controllers/handlers 之后、三文件之前加载 |
+6 -6
View File
@@ -10,8 +10,8 @@
## 这套文档写给谁
- **新接手子游戏前端的开发者**:先读 01、02 建立全局认知,再按 03、04、06 动手,05 随时回查。
- **正在开发/维护某子游戏前端的开发者**:02–06 是日常手册与红线。
- **新接手子游戏前端的开发者**:先读 01、02 建立全局认知,再按 03、04 动手,05 随时回查。
- **正在开发/维护某子游戏前端的开发者**:02–05 是日常手册与红线。
- **做代码审查的人**:05 是审查清单来源。
## 阅读顺序
@@ -24,9 +24,9 @@
| 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、模块职责、测试 |
| 06 | [06-子游戏接入模式与Hooks外置.md](./06-子游戏接入模式与Hooks外置.md) | 内联模式 vs Hooks 外置模式、三个契约文件退化为转发壳、SubGameHooks 委托、subgame-entry 模板 |
| 06 | [06-子游戏接入模式与Hooks外置.md](./06-子游戏接入模式与Hooks外置.md) | 内联模式 vs Hooks 外置模式、三契约文件可改性、退化为纯转发壳 + SubGameHooks 委托、subgame-entry 模板 |
建议第一次**从 01 顺序读到 06**;之后把 02–06 当手册随用随查。
建议第一次**从 01 顺序读到 05**;之后把 02–05 当手册随用随查。06 在接入新游戏或迁移到 Hooks 外置模式时选读。
---
@@ -73,7 +73,7 @@ BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(
## 与既有文档的关系
- 服务端的对应文档在 [`docs/server/development-guide/`](../../server/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。
- 服务端的对应文档见 [服务端开发指导文档](../../server/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。
- 子游戏前端各层可能另有局部说明文档;本套是总纲,与之不冲突时以本套的通用原则为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [`docs/games/engineering/`](../../games/engineering/):本套讲前端接入与红线,`engineering/` 讲前后端通用的设计方法论,互补阅读。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲前端接入与红线,工程通则讲前后端通用的设计方法论,互补阅读。
</content>
@@ -9,8 +9,7 @@
### 1.1 单一权威数据源(Single Source of Truth)
同一业务数据**只能有一个计算/写入的地方**,其余模块只读取、不重算。多处并行计算同一结果,必随
规则演化而分叉、互相矛盾。
同一业务数据**只能有一个计算/写入的地方**,其余模块只读取、不重算;多处并行计算必随规则演化而分叉、矛盾。
- 上游**算 + 写 + 校验**,下游**只读 + 消费**。
- 需要某数据时,**读权威源**,而不是"顺手再算一遍"。
@@ -43,18 +42,14 @@
| 编排 vs 算法 | 流程编排(controller) | 纯计算(领域算法) |
| 输入 vs 逻辑 | 收发包/参数校验 | 业务处理 |
> 例(决策 vs 机制):自动操作模块只决定"打哪张/是否碰杠胡过",**不实现**胡牌检测/听牌分析;
> 后者是领域算法,决策层只**读取其结果**。这既是关注点分离,也是职责边界。
> 例(决策 vs 机制):自动操作模块只决定"打哪张/是否碰杠胡过",**不实现**胡牌检测/听牌分析;后者是领域算法,决策层只**读取其结果**。既是关注点分离,也是职责边界。
### 1.5 对扩展开放、对修改封闭(OCP)
新增一种玩法/规则/策略/牌型,应当是**新增一段代码并注册**,而**不是**去改动已经稳定、已被测试
覆盖的核心流程。
新增一种玩法/规则/策略/牌型,应当是**新增一段代码并注册**,而**不是**去改动已经稳定、已被测试覆盖的核心流程。
- 手段:**注册表 + 策略**、**管线/中间件**、**工厂**(见 [02 篇](./02-可扩展性与配置化.md))。
- 收益:核心不动 → 回归风险小;扩展点清晰 → 新人能照葫芦画瓢。
- 例:分级决策框架——核心是"取上下文 → 跑管线 → 选结果",新增高级策略只是**写策略对象 +
注册 + 单测**三步,核心零改动。
- 例:分级决策框架核心是"取上下文 → 跑管线 → 选结果",新增高级策略只是**写策略对象 + 注册 + 单测**三步,核心零改动。
### 1.6 配置优先于硬编码
@@ -69,7 +64,7 @@
关键路径上数据缺失,**优先报错或返回 `null`**,把问题暴露在**离根因最近**的地方;不要用
`|| 0`、`|| []`、`|| ''`、双源回退把缺失悄悄填平。
- 兜底会把 bug 藏进"看似正常"的流程里,等到很远的下游才爆发,极难定位。
- 兜底会把 bug 藏进"看似正常"的流程里,到很远的下游才爆发,极难定位。
- 只有**展示层、纯 UI 兼容、非关键日志**字段,才可在明确边界内用安全默认值。
- 详见 [03 篇](./03-数据权威·错误处理·演进.md)。
@@ -77,7 +72,7 @@
## 2. 前后端参考分层
下面是一套**成熟、可直接照搬思路**的分层。层次是稳定的,层内文件如何组织由子游戏自定。
下面是一套可直接照搬思路的分层。层次是稳定的,层内文件如何组织由子游戏自定。
### 2.1 后端分层(自上而下依赖)
@@ -1,7 +1,6 @@
# 02 · 可扩展性与配置化
本篇把总则里的 **OCP(对扩展开放)** 和 **配置优先** 落成可直接套用的模式与判据,
并给出**避免过度设计**的红线——扩展性是为了"改得动",不是为了炫技。
本篇把总则的 **OCP(对扩展开放)** 和 **配置优先** 落成可套用的模式与判据,并给出**避免过度设计**的红线——扩展性是为了"改得动",不是炫技。
---
@@ -11,30 +10,25 @@
### 1.1 注册表 + 策略(Registry + Strategy)— 最常用
把"一族可替换的行为"抽象成**策略对象**,用**注册表**收集,运行时按上下文选一个执行。
新增行为 = 写一个策略 + 注册,**核心零改动**。
把"一族可替换的行为"抽象成**策略对象**,用**注册表**收集,运行时按上下文选一个执行。新增行为 = 写一个策略 + 注册,**核心零改动**。
```
Registry(注册表) ── register(strategy) ──▶ [strategyA, strategyB, ...]
│
Context(只读上下文)──▶ 选择器 select(ctx) ─────────┘──▶ 命中的策略.execute(ctx)
Registry ──register(strategy)──▶ [strategyA, strategyB, ...] ──select(ctx)──▶ 命中策略.execute(ctx)
```
- **适用**:AI 决策分级、规则变体、牌型识别族、结算规则族——"同一类事有多种做法"。
- **要点**:
- 策略只依赖**只读上下文**,不反向修改全局;上下文封装它需要的权威数据。
- 有**默认策略兜底**(保证任何输入都有结果),高级策略**按需叠加**。
- 策略只依赖**只读上下文**(封装其所需权威数据),不反向修改全局。
- 有**默认策略兜底**(任何输入都有结果),高级策略**按需叠加**。
- 策略之间**互不知道**对方,新增不影响既有。
- **例**:分级决策框架——`Context/Registry/Pipeline + 基础策略 + 占位高级策略`,默认走最低级,
高级留扩展点;新增高级策略三步(写策略对象 + register + 单测)零侵入。
- **例**:分级决策框架默认走最低级、高级留扩展点;新增高级策略三步(写策略对象 + register + 单测)零侵入。
### 1.2 管线 / 中间件(Pipeline)
把一个复杂处理拆成**有序的小步骤**,每步只做一件事、可独立增删。
```
输入 ──▶ [校验] ──▶ [规则A] ──▶ [规则B] ──▶ [收敛] ──▶ 输出
每一节点单一职责,可插拔、可测试
输入 ──▶ [校验] ──▶ [规则A] ──▶ [规则B] ──▶ [收敛] ──▶ 输出(每节点单一职责、可插拔、可测试)
```
- **适用**:决策流水线、校验链、结算的多阶段计分(比精 → 冲关 → 霸王 → 零和)。
@@ -42,7 +36,7 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 1.3 工厂(Factory)
把"根据类型创建/选择实现"的分支收敛到一处,调用方只要"我要一个 X",不关心怎么造。
把"根据类型创建/选择实现"的分支收敛到一处,调用方只说"我要一个 X",不关心怎么造。
- **适用**:胡牌检测按牌型分派、可用操作枚举、不同房型的配置构建。
- **要点**:工厂是**唯一**的创建入口,避免 `if(type==...)` 散落各处(那是并行逻辑的温床)。
@@ -54,14 +48,13 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
- **适用**:一次数据变化要驱动多个互不相关的表现(动画 + 音效 + 计分板)。
- **克制**:
- **不要**用事件做"隐式流程控制"——关键流程的因果应显式可追踪,别埋进一堆事件里。
- **不要**用事件做"隐式流程控制"——关键流程的因果应显式可追踪。
- 事件是**通知**,不是**命令**;订阅方不该反向决定发布方的流程。
- 能直接函数调用讲清的因果,就别为"解耦"硬拆成事件。
### 1.5 统一访问层(Facade over data)
对"读权威数据"提供一个**统一入口**(如 DataAccessHelper 之类),下游都走它读,不各自摸索
数据结构。好处:数据结构演化时只改访问层一处;配合"缺失显式失败",把校验集中。
对"读权威数据"提供一个**统一入口**(如 DataAccessHelper),下游都走它读,不各自摸索数据结构。好处:数据结构演化时只改访问层一处;配合"缺失显式失败",把校验集中。
---
@@ -89,23 +82,19 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 2.3 规则驱动:把玩法开关变成数据
复杂玩法的差异(人数/局数/扣卡方式/各种开关)应编码成**一份配置**,在入口处**解析一次**,
之后全流程**只读消费**:
复杂玩法的差异(人数/局数/扣卡方式/各种开关)应编码成**一份配置**,在入口处**解析一次**,之后全流程**只读消费**:
```
房型/配置编码 ──(解析器,解析一次)──▶ 规则对象(结构化、只读)
│
各模块按需读取规则对象的字段,不再各自解析原始编码、不再散落 if
房型/配置编码 ──(解析器,解析一次)──▶ 规则对象(结构化、只读)──▶ 各模块按需读字段,不再各自解析、不再散落 if
```
- **解析只此一处**:避免"多处各自解析同一编码"产生的不一致(这是 SSOT 在配置上的体现)。
- **解析只此一处**:避免"多处各自解析同一编码"产生的不一致(SSOT 在配置上的体现)。
- **规则对象只读**:下游不回写、不推断缺省;缺字段是**配置或解析的 bug**,应显式暴露。
- **新增一个玩法开关** = 编码加一位 + 解析器认它 + 消费点读它,**不改无关逻辑**。
### 2.4 配置注入优于全局魔法值
模块需要的配置**从入口传入 / 从权威配置对象读取**,而不是在模块内部埋一堆魔法值或直接摸全局。
这让模块**可测试**(注入不同配置跑用例)、**可复用**(换配置即换行为)。
模块需要的配置**从入口传入 / 从权威配置对象读取**,而不是在模块内部埋魔法值或直接摸全局。这让模块**可测试**(注入不同配置跑用例)、**可复用**(换配置即换行为)。
---
@@ -115,8 +104,8 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 3.1 什么时候**不要**加抽象
- **只有一种实现、且看不到第二种的现实需求** → 不要为"将来也许"预留策略/工厂。先写直接实现。
- **YAGNI**:没有当前需求的扩展点就是负债;等第二个变体真的出现,再**重构**成模式(那时你更懂共性)。
- **只有一种实现、且看不到第二种的现实需求** → 不要为"将来也许"预留策略/工厂,先写直接实现。
- **YAGNI**:没有当前需求的扩展点就是负债;等第二个变体真的出现,再**重构**成模式(那时更懂共性)。
- **一次性逻辑** → 不要包装成"通用框架"。通用性从**重复中提炼**,不是凭空设计。
### 3.2 判断"值不值得抽象"
@@ -131,8 +120,7 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 3.3 成熟的判据:三次法则 + 就近演进
- **三次法则**:同样的东西第 3 次出现时再抽象;第 1、2 次容忍重复。
- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),
再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。
- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。
> 一句话:**扩展性是留给"已知会变"的地方的;对"稳定不变"的地方,简单直接才是最好的设计。**
@@ -1,7 +1,6 @@
# 03 · 数据权威 · 错误处理 · 演进
本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化。
数据权威部分在 `.github/copilot/skills/data-authority-principle.md` 基础上,扩展到**前后端全景**。
本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化;数据权威部分在既有「数据权威原则」上扩展到**前后端全景**。
---
@@ -75,7 +74,7 @@
## 3. 演进与重构纪律
代码会随规则长大。让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。
代码随规则长大;让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。
### 3.1 收敛并行实现
+1 -3
View File
@@ -59,8 +59,6 @@
| 文档 | 定位 |
|------|------|
| 各端 `development-guide/` | 平台接入 + 工程红线(**能跑、合规**) |
| `docs/architecture/` | **本系统**的具体架构说明(是什么样) |
| `.github/copilot/skills/data-authority-principle.md` | 数据权威原则(本套 03 篇在其上扩展到前后端) |
| **本套 `docs/games/engineering/`** | **平台无关的通用工程与架构规范**(该怎么设计) |
| **本套 `engineering/`** | **平台无关的通用工程与架构规范**(该怎么设计) |
> 具体命名(模块名/文件名/方法名)在本套文档里多为**示例、可自定**;约束的是**做法与结构**,不是具体名字。
@@ -1,6 +1,6 @@
# 01 · 服务端环境与框架基础
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。读懂这一篇,后面的接入与协议才有坐标。
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。
> 举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。
@@ -23,14 +23,13 @@
2. **`require` 只能在文件开头的守卫块内**:
```js
// ✅ 正确:集中在顶部守卫块;运行时按全局名引用
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
// ... 之后直接用全局名 GameStateManager.xxx() ——浏览器由 mod.js 加载为同名全局
// 之后按全局名引用 GameStateManager.xxx()
```
浏览器运行时没有 `require`,任何**无守卫的、函数体内的中途 `require`** 都会抛 `ReferenceError: require is not defined`,导致功能崩溃。跨模块统一**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析,二者靠共享全局作用域下的同名 `var` 统一。
浏览器运行时没有 `require`,任何**无守卫的、函数体内的中途 `require`** 都会抛 `ReferenceError: require is not defined`。跨模块统一**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析,二者靠共享全局作用域下的同名 `var` 统一。
### 前后端是物理分离的两端
@@ -202,4 +201,3 @@ var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>",
- 房间生命周期由 export/import 接缝串起:`makewar` 开局、`get_deskinfo` 重连、`deduct_roomcard` 首局扣卡、`save_grade` 终局保存并自动回收。
下一篇 [02-子游戏接入与开发流程](./02-子游戏接入与开发流程.md) 讲:这三个文件具体怎么写、8+4 个接口各做什么、一次操作的完整数据流。
</content>
@@ -32,7 +32,7 @@ server/<游戏容器目录>/<你的游戏>/ (容器目录名由接入方
└── tests/ 单元/集成测试
```
> 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变,文件内容不同。
> 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变。
---
@@ -51,17 +51,12 @@ var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>",
### 2.2 按依赖顺序加载文件
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块):
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块)。浏览器/友乐用 `min_loadJsFile` 异步链式加载:
```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 + "] 加载完成");
});});});});});
min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){ /* ...嵌套加载 import.js、业务与rpc.js... */ });
});
```
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。
@@ -83,7 +78,6 @@ RPC 方法是**前后端交互的服务端入口**。以下是必须遵守的规
RPC 方法本身**不写业务逻辑**,只把 `pack` 转交给收发包层的 handler;真正的参数校验、业务编排、广播都在 handler 里:
```js
// mod_<你的游戏> 上挂的 RPC 方法(示例名,可自定)
mod_<你的游戏>.playCard = function(pack) {
// 就绪守卫:handler 由 min_loadJsFile 异步加载,未就绪时防御性返回(见规则四)
if (typeof RpcHandler === 'undefined' || !RpcHandler) {
@@ -91,9 +85,6 @@ mod_<你的游戏>.playCard = function(pack) {
}
return RpcHandler.handlePlayCard(pack); // 委托到收发包层,真正逻辑在这里
};
mod_<你的游戏>.declareHu = function(pack) {
return RpcHandler.handleDeclareHu(pack);
};
```
> `RpcHandler`、`handlePlayCard` 这些是**本项目的命名示例**;换成任何风格都行,关键是"薄入口 + 委托"的分层。
@@ -184,14 +175,13 @@ mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动
```js
exp.makewar = function(o_room, o_game_config) {
// 1) 创建子游戏牌桌对象,并与房间建立【双向引用】
// 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; // 此后所有业务都从这里读对局态
o_room.o_desk.data.gameState = createGameState(o_room, o_game_config);
// 3) 返回开战数据包(通常按座位差异化下发)
return {
@@ -217,26 +207,15 @@ exp.makewar = function(o_room, o_game_config) {
## 4. `import.js` —— 子游戏调用平台的 4 个接口
这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**:
这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**。每个都形如 `imp.<接口> = function(...args){ return mod_<你的游戏>.app.youle_room.export.<接口>(...args); }`,本项目 4 个:
```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);
};
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) { /* → youle_room.export.check_player(...) */ };
imp.deduct_roomcard = function(o_room) { /* → youle_room.export.deduct_roomcard(o_room) */ };
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) { /* → youle_room.export.save_grade(...) */ };
imp.finish_gametask = function(agentid, o_player, taskid, finishamount) { /* → youle_room.export.finish_gametask(...) */ };
return imp;
})();
```
@@ -306,4 +285,3 @@ mod_<你的游戏>.import = (function() {
- [ ] 改了下发结构,前端 `StartWar`/`Reconnect`/对应操作解析同步检查。
下一篇 [03-数据收发与通信协议](./03-数据收发与通信协议.md) 详解包结构、发包方式、主动推送与 `success` 成败协议。
</content>
@@ -27,26 +27,25 @@
`route` 决定包被投到**哪个模块**,是前后端必须对齐的字符串。它的权威定义与匹配链如下(均可在代码验证):
- **服务端:`routename` 的唯一定义点**是 `mod.js` 里创建模块的 `cls_mod.new(模块名, 路由名, 所属应用)` 的**第二个参数**:
- **服务端:`routename` 的唯一定义点**是 `mod.js` 里 `cls_mod.new(模块名, 路由名, 所属应用)` 的**第二个参数**:
```js
// server/games2/<你的游戏>/mod.js
// server/games2/<你的游戏>/mod.js —— 第二参 "<你的游戏>" 即 routename(route 要用的值)
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` 是 `"room"`,处理创建/加入/开战(`createRoom`、`self_join_room`、`self_makewar` 等);**你的子游戏自定义 RPC**(`playCard` 等)走**你自己的 `routename`**。两者不要混用:平台流程发 `route:"room"`,玩法操作发 `route:"<你的游戏>"`。
> 一句话:**`routename` 在服务端 `cls_mod.new` 第二参定义(自定、应用内唯一),前端发包 `route` 必须与它逐字相同,平台据此把包投到你的模块**;改名要两端同步改。
### 1.2 发包必须自带前端界面所需的全部核心数据
**前端以服务端数据为权威——只做界面展示与交互、不做权威计算**(见 [`client 05`](../../client/development-guide/05-开发规范与红线.md))。推论落到服务端:**每个下发包都必须携带前端渲染/刷新该界面所需的全部核心数据**,让前端「据包写入数据 → 直接刷新出界面」,而**不需要前端自行推算、补全或兜底**。
**前端以服务端数据为权威——只做界面展示与交互、不做权威计算**(见 [前端 05 开发规范与红线](../../client/development-guide/05-开发规范与红线.md))。推论落到服务端:**每个下发包都必须携带前端渲染/刷新该界面所需的全部核心数据**,让前端「据包写入数据 → 直接刷新出界面」,而**不需要前端自行推算、补全或兜底**。
- **界面要用的字段都要发全**:凡界面要显示、或前端 set/refresh 要用到的核心字段(出了什么牌、轮到谁、各家剩余张数、手牌/副露、分数/比分、倒计时锚点、庄家/座位、各类状态标志……)都要放进 `data`,不能让前端"猜"或本地推算权威结果。
- **漏发是服务端的缺陷,不许前端补**:前端遵循数据权威原则——权威字段缺失应显式报错/留空、不用 `|| 0`/`|| []` 兜造(见 client 05)。所以服务端漏发 = 前端界面缺数据,**修在服务端发包处,不在前端补洞**。
@@ -65,9 +64,7 @@
```js
XxxHandler.handlePlayCard = function(pack) { // 由 mod_<游戏>.playCard 委托进来
try {
// 1) 提取并校验参数:列出必填字段 + 数值型字段的类型要求
// (本项目用统一工具 ValidationHelper.extractAndValidateParams 做这件事)
// 等价于:agentid/playerid/gameid/roomcode/seat 必填,playerid/roomcode/seat 需为数值
// 1) 提取并校验参数:必填字段 + 数值型字段类型(本项目用 ValidationHelper.extractAndValidateParams)
var params = extractAndValidateParams(
pack,
['agentid', 'playerid', 'gameid', 'roomcode', 'seat', 'cardUniqueId'],
@@ -85,15 +82,10 @@ XxxHandler.handlePlayCard = function(pack) { // 由 mod_<游戏>.playCard 委
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);
}
// 4) 调试记录(若框架提供 o_desk.debug.save_receivepack)——便于复盘
// 5) 业务校验 + 执行业务:委托权威模块(handler 不内联规则,见 04)
// 如:OperationExecutor.executePlayCard(o_room, {...})
// 6) 构建响应 + 【主动推送】:ResponseBuilder 组包 → BroadcastManager 逐座位 sendpack_toseat
// 推送 data 自带 success(见 §5);return 不是下发通道(见 §4)
// 6) 构建响应 + 【主动推送】:组包 → 逐座位 sendpack_toseat;推送 data 自带 success(见 §5);
// return 不是下发通道(见 §4)
} catch (e) { /* 记录日志;必要时给该座推送失败包 */ }
};
```
@@ -126,11 +118,8 @@ for (var seat = 0; seat < o_room.seatlist.length; seat++) {
app: "youle", route: "<游戏>", rpc: "playCard",
data: deepCopy(baseData) // 公共信息
};
if (seat === actionSeat) {
msg.data.handCards = hands[seat]; // 仅本人可见手牌
} else {
msg.data.handCards = []; // 他人看不到
}
// 敏感信息只发本人:本人给真实手牌,他人给 []
msg.data.handCards = (seat === actionSeat) ? hands[seat] : [];
o_room.method.sendpack_toseat(msg, seat);
}
```
@@ -163,23 +152,13 @@ for (var seat = 0; seat < o_room.seatlist.length; seat++) {
### 正反例
```js
// ❌ 错误:推送只带 status,前端读 data.success 恒 undefined → 误判失败
o_room.method.sendpack_toseat({
app:"youle", route:"<游戏>", rpc:"setHostingState",
data: { status: 200, hosting: true } // 少了 success
}, seat);
// ❌ 推送只带 status、少了 success → 前端读 data.success 恒 undefined,误判失败
data: { status: 200, hosting: true }
// ✅ 成败语义放 success,status 仅作细分
data: { success: true, status: 200, hosting: true }
// ✅ 正确:成败语义放 success,status 仅作细分
o_room.method.sendpack_toseat({
app:"youle", route:"<游戏>", rpc:"setHostingState",
data: { success: true, status: 200, hosting: true }
}, seat);
```
```js
// 前端:只认 success
// 前端:只认 success,status/code 仅用于展示或日志
if (!data.success) { /* 失败处理 */ return; }
// data.status / data.code 仅用于展示或日志细分
```
> 典型事故:托管状态推送 `{ status: 200, ... }` 不带 `success`,前端读 `data.success` 恒为 `undefined`,于是"已进入托管却报失败"。根因就是违反了本协议第 3 条。
@@ -253,7 +232,7 @@ if (!data.success) { /* 失败处理 */ return; }
| 重连/中途加入 | `export.get_deskinfo` 的返回 | `Game_Modify.Reconnect(_deskinfo)` → 重画 |
| 对局/推送 | RPC handler 或主动推送(按 `rpc`) | 收包分发表里同名 `rpc` 的处理器 |
**改服务端下发结构 = 同步核对前端对应 `rpc` 的解析**;**新增一种推送 = 服务端选定 `rpc` + 前端在分发表加同名处理器**。任一端单方面改,另一端必按旧结构解析出错。前端侧的收发细节以 [`docs/client/development-guide/04-网络对接与启动编排`](../../client/development-guide/04-网络对接与启动编排.md) 为权威。
**改服务端下发结构 = 同步核对前端对应 `rpc` 的解析**;**新增一种推送 = 服务端选定 `rpc` + 前端在分发表加同名处理器**。任一端单方面改,另一端必按旧结构解析出错。前端侧的收发细节以 [前端 04 网络对接与启动编排](../../client/development-guide/04-网络对接与启动编排.md) 为权威。
---
@@ -274,4 +253,3 @@ if (!data.success) { /* 失败处理 */ return; }
- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。
下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。
</content>
@@ -38,14 +38,12 @@
- 跨模块运行时**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析。
```js
// ✅ 正确
// ✅ 标准形态:require 仅在顶部守卫块,函数体内直接用全局名
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
function foo() { GameStateManager.doSomething(); } // 直接用全局名
// ❌ 错误:函数体内中途 require —— 浏览器崩溃
function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
function foo() { GameStateManager.doSomething(); }
// ❌ 函数体内中途 require → 浏览器崩溃:function bar(){ var GSM = require('...'); }
```
### 双运行时全局暴露陷阱
@@ -60,6 +58,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
- **禁止猜测兜底**:不用默认值掩盖缺失数据。**关键权威字段缺失要显式报错或返回 `null`**,由上层决定是否终止,**禁止** `|| 0`、`|| []`、`|| ''`、双源回退等掩盖。
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
- **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 set-refresh 要用的字段都发全);漏发 = 前端缺数据,**修在服务端发包处、前端不补洞**(详见 [03 §1.2](./03-数据收发与通信协议.md))。
> 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
@@ -167,7 +166,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
| 范围 | 只改子游戏目录 `<容器目录>/<游戏>/` 与允许的前端范围 |
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 |
| 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 |
@@ -178,4 +177,3 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。
</content>
+3 -3
View File
@@ -52,7 +52,7 @@ o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。
- **子游戏**负责:玩法规则、对局状态、每个操作的处理与广播。两者通过 **export / import** 两组接口对接。
> 上图是**入站半程**(前端 → 服务端 handler)。完整的往返链路(含服务端 `sendpack_toseat` 推回 → 前端 `Game_Modify._ReceiveData` 按 `rpc` 分发)见 [03 §6「端到端收发全链路」](./03-数据收发与通信协议.md#6-端到端收发全链路前后端对照),前端侧细节见 [`docs/client/development-guide/04`](../../client/development-guide/04-网络对接与启动编排.md)。
> 上图是**入站半程**(前端 → 服务端 handler)。完整的往返链路(含服务端 `sendpack_toseat` 推回 → 前端 `Game_Modify._ReceiveData` 按 `rpc` 分发)见 [03 §6「端到端收发全链路」](./03-数据收发与通信协议.md#6-端到端收发全链路前后端对照),前端侧细节见 [前端 04 网络对接与启动编排](../../client/development-guide/04-网络对接与启动编排.md)。
---
@@ -91,8 +91,8 @@ o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下
## 与既有文档的关系
- 本套文档是**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
- 本套文档是平台级收发包/子游戏开发规范的**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `<游戏容器目录>/<游戏>/docs/` 为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [`docs/games/engineering/`](../../games/engineering/):本套讲"平台怎么接、红线是什么",`engineering/` 讲"该怎么设计、怎么长久演进",互补阅读。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲"平台怎么接、红线是什么",工程通则讲"该怎么设计、怎么长久演进",互补阅读。
</content>
</invoke>