From 047561675b6295aaeb1fc64894055ed62b65cfec Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 6 Jul 2026 17:16:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E8=A7=84hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/check-redlines.js | 161 ++++++++++++ .claude/hooks/remind-docs.js | 105 ++++++++ .claude/settings.json | 43 +++ .githooks/pre-commit | 5 + .gitignore | 2 + CLAUDE.md | 112 +++----- client/{ => js}/docs/Spine动画集成手册.md | 0 .../01-前端架构与运行环境.md | 9 +- .../development-guide/02-渲染与UI组件体系.md | 215 ++++----------- .../03-事件·动画·音频·Spine.md | 23 +- .../04-网络对接与启动编排.md | 13 +- .../development-guide/05-开发规范与红线.md | 7 +- .../06-子游戏接入模式与Hooks外置.md | 87 ++---- docs/client/development-guide/README.md | 12 +- docs/games/engineering/01-架构总则与分层.md | 17 +- docs/games/engineering/02-可扩展性与配置化.md | 46 ++-- .../engineering/03-数据权威·错误处理·演进.md | 5 +- docs/games/engineering/README.md | 4 +- .../01-服务端环境与框架基础.md | 8 +- .../02-子游戏接入与开发流程.md | 44 +--- .../03-数据收发与通信协议.md | 54 ++-- .../development-guide/04-开发规范与红线.md | 12 +- docs/server/development-guide/README.md | 6 +- .../01-服务端环境与框架基础.md | 201 -------------- .../02-子游戏接入与开发流程.md | 247 ------------------ .../03-数据收发与通信协议.md | 170 ------------ .../development-guide/04-开发规范与红线.md | 168 ------------ server/docs/development-guide/README.md | 79 ------ 28 files changed, 510 insertions(+), 1345 deletions(-) create mode 100644 .claude/hooks/check-redlines.js create mode 100644 .claude/hooks/remind-docs.js create mode 100644 .claude/settings.json create mode 100644 .githooks/pre-commit rename client/{ => js}/docs/Spine动画集成手册.md (100%) delete mode 100644 server/docs/development-guide/01-服务端环境与框架基础.md delete mode 100644 server/docs/development-guide/02-子游戏接入与开发流程.md delete mode 100644 server/docs/development-guide/03-数据收发与通信协议.md delete mode 100644 server/docs/development-guide/04-开发规范与红线.md delete mode 100644 server/docs/development-guide/README.md diff --git a/.claude/hooks/check-redlines.js b/.claude/hooks/check-redlines.js new file mode 100644 index 0000000..019b12c --- /dev/null +++ b/.claude/hooks/check-redlines.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。 +// +// 两种运行模式: +// 1) 默认(PreToolUse 钩子):读 stdin 的工具调用 JSON,取目标路径与将写入的内容, +// 命中红线则输出 permissionDecision:"deny" 阻止本次 Write/Edit。 +// 2) --staged(git pre-commit):扫描本次暂存的文件(工具无关,人手/别的工具改也拦), +// 命中则打印并以非零退出,阻断提交。 +// +// 覆盖的红线(皆低误报): +// A. 可编辑范围(路径级,零误报):禁改平台/第三方/受限文件。 +// B. 严格 ES5(内容级,高信号):运行时 .js 里禁箭头函数/模板串/class/let/const。 +// 本脚本自身是 Node 工具,不受被检规则约束。 + +var fs = require('fs'); +var path = require('path'); +var cp = require('child_process'); + +// ---------- 路径分类 ---------- + +function norm(p) { return String(p || '').replace(/\\/g, '/'); } + +// 归一为「带前导斜杠」的形式,使绝对路径(G:/…/server/x)与仓库相对路径(server/x)统一可匹配。 +function m(p) { return '/' + norm(p).replace(/^\/+/, ''); } + +// 禁改路径(命中即拦):平台代码、第三方、受限契约文件。 +function forbiddenReason(p) { + p = m(p); + if (/\/js\/vendor\//.test(p)) return '第三方/引擎 js/vendor/ 禁止修改'; + if (/\/js\/00_Surface\//.test(p)) return '平台代码 js/00_Surface/ 禁止修改'; + if (/\/02_SubGame_Input\.js$/.test(p)) return '受限契约文件 02_SubGame_Input.js 完全不碰'; + if (/\/server\/packet\.js$/.test(p)) return '服务端收包入口 server/packet.js 禁止修改'; + if (/\/server\/applist\.js$/.test(p)) return '服务端应用注册表 server/applist.js 禁止修改'; + if (/\/server\/class\//.test(p)) return '平台三层路由基类 server/class/ 禁止修改'; + if (/\/server\/youle\//.test(p) && !/\/server\/youle\/app\.js$/.test(p)) + return 'youle 平台代码 server/youle/ 禁止修改(仅 app.js 为接入注册点例外)'; + return null; +} + +// 是否需要做 ES5 内容检查:我们可编辑的运行时 .js。排除平台/第三方/生成物/工具/测试/文档。 +function isRuntimeJs(p) { + p = m(p); + if (!/\.js$/.test(p)) return false; + if (/\.min\.js$/.test(p)) return false; + if (/\/(node_modules|\.claude|docs|client\/generated)\//.test(p)) return false; + if (/\/js\/vendor\//.test(p) || /\/js\/00_Surface\//.test(p)) return false; + if (/(^|\/)tests?\//.test(p) || /\.(test|spec)\.js$/.test(p)) return false; // 测试可用现代语法 + return /\/client\/js\//.test(p) || /\/server\//.test(p); +} + +// ---------- ES5 内容扫描 ---------- + +// 去掉块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。 +function stripForScan(src) { + src = src.replace(/\/\*[\s\S]*?\*\//g, function (m) { + return m.replace(/[^\n]/g, ' '); + }); + return src.split('\n').map(function (line) { + var s = line.replace(/\/\/.*$/, ''); + s = s.replace(/'(?:\\.|[^'\\])*'/g, "''"); + s = s.replace(/"(?:\\.|[^"\\])*"/g, '""'); + return s; + }); +} + +var ES5_RULES = [ + { re: /=>/, msg: '箭头函数(=>)——请用 function' }, + { re: /`/, msg: '模板字符串(反引号)——请用字符串拼接' }, + { re: /\bclass\s+[A-Za-z_$]/, msg: 'class 声明——请用 Object.create 继承' }, + { re: /\b(?:let|const)\s+[A-Za-z_$[{]/, msg: 'let/const 声明——请用 var' } +]; + +// 扫描一段源码,返回违规 [{line, msg, text}] +function scanES5(src) { + var lines = stripForScan(src); + var out = []; + for (var i = 0; i < lines.length; i++) { + for (var r = 0; r < ES5_RULES.length; r++) { + if (ES5_RULES[r].re.test(lines[i])) { + out.push({ line: i + 1, msg: ES5_RULES[r].msg, text: lines[i].trim().slice(0, 80) }); + } + } + } + return out; +} + +// 对一个「路径 + 内容」做全部检查,返回违规文本数组(空=通过)。 +function checkOne(p, content) { + var problems = []; + var fr = forbiddenReason(p); + if (fr) problems.push('【可编辑范围】' + fr); + if (content != null && isRuntimeJs(p)) { + scanES5(content).forEach(function (v) { + problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text); + }); + } + return problems; +} + +// ---------- 模式二:--staged(pre-commit) ---------- + +function runStaged() { + var names; + try { + names = cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }) + .split('\n').map(function (s) { return s.trim(); }).filter(Boolean); + } catch (e) { process.exit(0); } + var all = []; + names.forEach(function (name) { + var p = norm(name); + var content = null; + if (isRuntimeJs(p)) { + try { content = cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) {} + } + var probs = checkOne(p, content); + if (probs.length) { all.push(' ' + name + '\n - ' + probs.join('\n - ')); } + }); + if (all.length) { + process.stderr.write('\n✗ 机械红线校验未通过,提交被阻止:\n' + all.join('\n') + + '\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n'); + process.exit(1); + } + process.exit(0); +} + +// ---------- 模式一:PreToolUse ---------- + +function runPreToolUse() { + var chunks = []; + process.stdin.on('data', function (c) { chunks.push(c); }); + process.stdin.on('end', function () { + var probs = []; + try { + var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + var ti = input.tool_input || {}; + var p = norm(ti.file_path || ''); + if (!p) { process.exit(0); } + // 取将写入的内容:Write=content;Edit=new_string;MultiEdit=各 new_string 拼接 + var content = null; + if (typeof ti.content === 'string') content = ti.content; + else if (typeof ti.new_string === 'string') content = ti.new_string; + else if (Array.isArray(ti.edits)) + content = ti.edits.map(function (e) { return e && e.new_string; }).filter(function (s) { return typeof s === 'string'; }).join('\n'); + probs = checkOne(p, content); + } catch (e) { process.exit(0); } + + if (probs.length) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: '机械红线校验拦截(改正后重试):\n- ' + probs.join('\n- ') + } + })); + } + process.exit(0); + }); +} + +if (process.argv.indexOf('--staged') !== -1) runStaged(); +else runPreToolUse(); diff --git a/.claude/hooks/remind-docs.js b/.claude/hooks/remind-docs.js new file mode 100644 index 0000000..6d12ed5 --- /dev/null +++ b/.claude/hooks/remind-docs.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// PreToolUse 钩子:编辑 client/** 或 server/** 时,把「必须严格遵守的完整开发规范」注入上下文。 +// +// 策略(对应用户要求:三套文档全部规范时刻遵守、新会话也一样): +// - 每个文档目录,每会话「首次」编辑相关区域时全量注入其所有 .md 全文; +// - 同会话之后再改同区域,只给简短「已注入、继续遵守」提醒,避免重复刷屏; +// - 会话级去重用 .claude/.spec-injected/ 下的哨兵文件;SessionStart 钩子会在 +// 每个新会话开始时清空该目录 → 新会话必然重新注入。 +// - 改 docs/ 本身不打扰。 +// +// 本脚本是「工具代码」,只在 Node(Claude Code 宿主)里跑,不受项目 ES5 红线约束。 + +var fs = require('fs'); +var path = require('path'); + +var HOOK_DIR = __dirname; // .../.claude/hooks +var PROJECT_DIR = path.resolve(HOOK_DIR, '..', '..'); // 项目根 +var SENTINEL_DIR = path.join(PROJECT_DIR, '.claude', '.spec-injected'); + +function slug(relDir) { return relDir.replace(/[^A-Za-z0-9]+/g, '_'); } + +function alreadyInjected(relDir) { + try { return fs.existsSync(path.join(SENTINEL_DIR, slug(relDir))); } catch (e) { return false; } +} + +function markInjected(relDir) { + try { + fs.mkdirSync(SENTINEL_DIR, { recursive: true }); + fs.writeFileSync(path.join(SENTINEL_DIR, slug(relDir)), '1'); + } catch (e) {} +} + +// 读取某目录下的编号正文 .md(按文件名排序),拼成一段带分隔标题的全文。 +// 排除 README.md:它已被 CLAUDE.md 用 @import 常驻每次会话上下文,注入里再带就是重复。 +function readDirDocs(relDir) { + var abs = path.join(PROJECT_DIR, relDir); + var files; + try { files = fs.readdirSync(abs); } catch (e) { return ''; } + var out = []; + files.filter(function (f) { return /\.md$/i.test(f) && !/^README\.md$/i.test(f); }) + .sort() + .forEach(function (f) { + try { + out.push('===== ' + relDir + '/' + f + ' =====\n' + + fs.readFileSync(path.join(abs, f), 'utf8')); + } catch (e) {} + }); + return out.join('\n\n'); +} + +var chunks = []; +process.stdin.on('data', function (c) { chunks.push(c); }); +process.stdin.on('end', function () { + var context = ''; + try { + var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + var p = String((input.tool_input && input.tool_input.file_path) || '').replace(/\\/g, '/'); + + var dirs = null, area = ''; + if (/\/docs\//.test(p)) { + dirs = null; // 改文档本身不提醒 + } else if (/\/server\//.test(p)) { + area = '服务端'; + dirs = ['docs/server/development-guide', 'docs/games/engineering']; + } else if (/\/client\/js\//.test(p) || /\/codes\/shared\//.test(p)) { + area = '客户端'; + dirs = ['docs/client/development-guide', 'docs/games/engineering']; + } + + if (dirs) { + var pending = []; + for (var i = 0; i < dirs.length; i++) { + if (!alreadyInjected(dirs[i])) { pending.push(dirs[i]); } + } + + if (pending.length) { + var full = []; + pending.forEach(function (d) { + var txt = readDirDocs(d); + if (txt) { full.push(txt); } + markInjected(d); + }); + context = + '【必须严格遵守 · ' + area + '开发规范(本会话首次注入编号正文全文)】\n' + + '你正在修改' + area + '代码。下面是本次及后续所有改动都必须时刻遵守的完整规范正文' + + '(' + dirs.join('、') + ';各目录 README 的红线速查/一页纸总则已随 CLAUDE.md 常驻上下文,此处不再重复)。' + + '逐条落实,勿凭记忆越线;dev-guide 与 engineering 冲突时以 dev-guide 硬红线为准。\n\n' + + full.join('\n\n'); + } else { + context = + '【必须严格遵守 · ' + area + '开发规范】本会话已注入完整规范(' + dirs.join('、') + + ')。继续严格遵守;细节记不清就回看已注入全文或对应编号文档,不要绕过任何红线与规范。'; + } + } + } catch (e) { + context = ''; + } + + if (context) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: context } + })); + } + process.exit(0); +}); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0b3da80 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,43 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "rm -rf \"$CLAUDE_PROJECT_DIR/.claude/.spec-injected\"" + } + ] + } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rm -rf \"$CLAUDE_PROJECT_DIR/.claude/.spec-injected\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-redlines.js\"", + "timeout": 10, + "statusMessage": "校验机械红线…" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/remind-docs.js\"", + "timeout": 15, + "statusMessage": "注入/校验开发规范…" + } + ] + } + ] + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..af113f6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。 +# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。 +# 启用(每个克隆一次性):git config core.hooksPath .githooks +exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged diff --git a/.gitignore b/.gitignore index 55ec011..9d8d489 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ yarn-error.log* # Claude Code 本地个人配置(含本机专属的权限豁免,不应共享) .claude/settings.local.json +# 会话级哨兵:记录本会话已注入过哪些规范目录,SessionStart 会清空,勿提交 +.claude/.spec-injected/ diff --git a/CLAUDE.md b/CLAUDE.md index 99fae53..40efcc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -本文件为 Claude Code(claude.ai/code)在本仓库中工作时提供指导。 +本文件为 Claude Code(claude.ai/code)在本仓库中工作时提供指导。**它是入口与强制指引**:三套文档的 README(含各自「红线速查 / 一页纸总则」)已通过 `@import` 常驻上下文(见下「文档」一节),**红线以它们为权威、始终在上下文里,本文件不再另抄一份**;规范的完整细节在 `docs/` 编号文档里,改动前先读对应文档。 ## 这是一个什么仓库 @@ -8,116 +8,70 @@ `client/js/01_SubGame/codes/` 和 `server/games` 目前都是**空的**——尚未接入任何具体子游戏。本仓库是平台层 + 可复用框架的脚手架,未来第一个子游戏会在此基础上搭建。 +本仓库是**模板项目**——后续会被克隆出去开新项目。因此所有需要长期保留的约定与知识**一律进仓库**(`docs/` 权威文档、本 CLAUDE.md、`.claude/` 钩子与 `settings.json`、`.githooks/`),**不依赖 Claude memory**(memory 按机器/会话存放、不随 `git clone` 迁移,对模板克隆无效)。 + ## 常用命令 -没有配置任何构建/lint 工具。仓库里的脚本: +没有配置任何构建/lint/测试工具。仓库里唯一的脚本: ```bash # 根据 client/assets/spine/*.json 重新生成 client/generated/spine_assets.js 与 spine_data.js client/scripts/build_spine_data.cmd # 内部调用 build_spine_data.ps1 - -# 二七王服务端单元测试(纯 node 直跑,无框架) -node server/games/erqiwang/test/run.js # 跑全部;退出码 0 全过、非 0 有失败 ``` -在 `client/assets/spine/` 增删 Spine 导出文件后运行第一条;不要手改这两个生成文件。 +在 `client/assets/spine/` 增删 Spine 导出文件后运行;不要手改这两个生成文件。 查看客户端效果需用 HTTP 方式(而非 `file://`)打开 `client/index.html`,例如 `npx http-server client -p 8080`。 -服务端子游戏代码遵循「一套代码,两个运行时」(见 `docs/server/development-guide/01` §1):友乐/浏览器无 `require`、由 `min_loadJsFile` 加载为全局;Node(本地/单元测试)用文件顶部 `if (typeof require!=='undefined')` 守卫 require、底部 `module.exports` 导出。二七王据此已可用 `node` 直接单测(见 `server/games/erqiwang/test/README.md`);`shared/`(共享算法)改动同样按两份文档「§10 测试纪律」跑 Node 单测验证。 +**启用提交前机械红线校验**(严格 ES5、可编辑范围等硬红线的 git 提交闸)——`.githooks/pre-commit` 已随仓库提交,但 `core.hooksPath` 是本地配置、不随克隆迁移,故**每个新克隆需一次性**执行: -## 架构 - -### 两套独立代码库,一份线上协议 - -- `client/` —— 浏览器端,严格 ES5,由 gameabc canvas 引擎驱动。 -- `server/` —— Node.js,严格 ES5,即 `youle` 游戏平台。 -- 两端交换的 JSON 包结构固定为 `{ app: "youle", route, rpc, data }`:`route` 决定投递到服务端哪个模块,`rpc` 决定调用该模块上的哪个方法(`mod[pack.rpc](pack)`——没有二次 `switch(action)` 分发,一个操作对应一个 RPC 方法)。**服务端把结果告知客户端的唯一可靠方式是主动推送**(`o_room.method.sendpack_toseat/toother`);`DoPack` 的返回值并不是真正的下发通道。成败判定**只**看推送包里的 `data.success`(布尔值)——绝不用 `status`/`code`。 - -### 客户端分层(`client/js/`,依赖关系即 `index.html` 里的加载顺序) - -``` -js/vendor/ 第三方与引擎(gameabc.min.js、jquery、spine-canvas)—— 禁止修改 -js/00_Surface/ 平台代码 —— 禁止修改 -js/gameabc-framework/ 可复用、游戏中立的框架(见下)—— 禁止渗入任何具体玩法的逻辑 - core/ GameABCUtils(唯一允许直接调用 gameabc/引擎原生 API 的模块)、SpriteManager(做 ID 校验的业务级精灵 API) - system/ EventBus(空的 Events{} 容器——具体玩法事件由子游戏自行追加,框架不预置)、SpriteEventController、SpriteGestureRecognizer、AnimationManager、AudioManager - ui/ BaseComponent、UIManager、SpriteCopyUtils、DynamicSpriteList、RecordView(及其默认配置)、AlignmentUtils - spine/ SpineMgr —— 必须在 gameabc.min.js 之后、gamemain.js/gameenddraw 被(重新)定义之前加载 - network/RpcHelper —— 自动给外发请求注入平台字段(agentid/gameid/playerid/roomcode/seat) - templates/ *.template.js —— 供新子游戏「复制填充」的起点模板;不会被 index.html 直接引用 -js/01_SubGame/ - 00_SubGame_Config.js / 01_SubGame_modify.js / 02_SubGame_Input.js 固定的平台契约文件(见下) - codes/ 子游戏自己的代码(尚未创建);codes/shared/ 将是 server/<游戏>/shared/ 的只读同步副本 +```bash +git config core.hooksPath .githooks ``` -依赖方向严格单向:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架不反向依赖任何子游戏。**`index.html` 里的加载顺序就是依赖关系图**——新增文件必须插在其依赖之后、使用者之前,否则运行时会拿到 `undefined`(这里没有模块系统)。 +(Claude Code 内 `.claude/` 的 PreToolUse 钩子会自动生效、无需此步;这一步只为让**提交闸**对所有工具/人手改也生效。测试脚本 `tests/`、`*.test.js`、`*.spec.js` 允许现代语法,不受严格 ES5 拦截;只有正式代码严格 ES5。) + +`shared/`(共享算法,待子游戏接入后才有)的改动应按两套文档「§10 测试纪律」的要求跑 Node 单测验证——目前尚未接入测试框架,意味着需要直接用 `node` 运行相应脚本。 + +## 可编辑范围 + +> 架构骨架(前端分层 / 服务端运作模型 / 工程七大总则)、成败协议、子游戏接入两模式等,均在下方 `@import` 常驻的三份 README「一页纸」与对应编号文档里,本文件不复述。此处只留改动前最需在手边的一张表(完整规则见各 README「红线速查」): -可编辑范围: | 路径 | 规则 | |---|---| -| `js/vendor/`、`js/00_Surface/` | 禁止修改 | +| `js/vendor/`、`js/00_Surface/`、`server/` 平台代码 | 禁止修改 | | `01_SubGame/00_SubGame_Config.js` | 仅可改**已有** `Game_Config.*` 配置项的值——禁止新增/删除/改名/改结构 | | `01_SubGame/01_SubGame_modify.js` | 仅可填顶部「配置区」(`Type_1/Type_2/CreateRoomData/combat/game_config/roomDes`);转发壳部分不碰 | | `01_SubGame/02_SubGame_Input.js` | 完全不碰 | | `js/gameabc-framework/` | 可改,但须保持游戏中立(不含具体玩法常量/逻辑) | -| `01_SubGame/codes/`(除 `shared/`) | 子游戏自由开发区 | +| `01_SubGame/codes/`(除 `shared/`)、`server/<游戏容器目录>/<游戏>/` | 子游戏自由开发区 | | `01_SubGame/codes/shared/` | 服务端 `shared/` 的只读同步副本;改服务端一侧后再同步 | -子游戏有两种接入方式(见 `docs/client/development-guide/06`):**内联模式**(旧——业务逻辑直接写进三个契约文件)或 **Hooks 外置模式**(新——三个契约文件退化为纯转发壳,查 `SubGameHooks.X` 并委托;子游戏逻辑全部放在 `codes/SubGameHooks.js` + `codes/`)。每个子游戏二选一,不能混用;Hooks 外置模式的模板位于 `gameabc-framework/templates/subgame-entry/`。 +## 文档(改动前先读——这三套是权威源) -### 服务端分层(`server/`) +本仓库开发**必须严格遵守**以下三套文档;改哪块就先读对应 README 与编号文档,而不是从代码反推约定: -``` -server/packet.js、applist.js 顶层收包入口 / 应用注册表 —— 禁止修改 -server/class/class.app.js|class.mod.js 三层路由:app(按 pack.app)→ mod(按 pack.route)→ method(按 pack.rpc) -server/youle/ "youle" 应用 - server_room/class.room.js o_room(seatlist[]、sendpack_toseat/toother) - server_room/class.player.js o_player(每座位一个,持有用于定向发包的 conmode/fromid) - server_room/class.export.js|import.js 平台自身的 export/import 服务(check_player、deduct_roomcard、save_grade……) -server/<游戏容器目录>/<游戏>/ ← 子游戏接入后**唯一**可编辑的区域(目录名并非框架强制固定;"games2" 只是早期的一种约定) - mod.js 创建模块(cls_mod.new(modname, routename, youle_app)),按依赖顺序加载文件,把 RPC 方法定义为「提参 → 委托给 handler」的薄入口 - export.js 平台按需回调的可选接口(get_needroomcard、get_asetcount、makewar、get_deskinfo、get_disbandRoom、player_enter/leave……) - import.js 对平台 4 个服务的薄封装:check_player、deduct_roomcard、save_grade、finish_gametask -``` +- `docs/client/development-guide/`(01→06)、`docs/server/development-guide/`(01→04)——项目专属的平台接入规范与红线。 +- `docs/games/engineering/`(01→03)——位于两者之上、与平台无关的工程方法论(SSOT、单向依赖、职责单一、OCP、配置优先于硬编码、显式失败优于隐式兜底)。 -每个房间的状态挂在 `o_room.o_desk.data.*` 上(在 `export.makewar` 内创建,且必须建立 `o_room.o_desk ⇄ o_desk.o_room` 双向引用)。**对局状态禁止用模块级单例/全局变量存放**——服务端在同一进程内并发跑多个房间;缓存/定时器/决策表必须以 `房间 + seat` 为 key(而非仅 `seat`),并在小局结束/解散/开新局时清理干净。 +三者互补不冲突:engineering 讲「该怎么设计、怎么长久演进」,dev-guide 讲「平台怎么接、红线是什么」;硬红线冲突时以 dev-guide 为准。 -服务端代替玩家执行的「自动」操作(AI 托管、超时等)必须走与真人操作完全相同的 handler → 广播链路,这样客户端永远不需要为它们单开一套解析分支。 +以下三份 README(含各自「红线速查 / 一页纸总则」)通过 `@import` **自动加载进每次会话的上下文**——红线以它们为权威,无需在本文件另抄一份;改动具体区域时再深入读对应编号文档: -### 文档(改动前先读) +@docs/client/development-guide/README.md +@docs/server/development-guide/README.md +@docs/games/engineering/README.md -`docs/client/development-guide/`(01→06 编号)与`docs/server/development-guide/`(01→04 编号)是本项目遵循的权威、项目专属开发指南——改哪块就读对应编号的文档,而不是从代码反推约定。`docs/games/engineering/` 是位于两者之上的、与平台无关的工程方法论层(单一权威数据源 SSOT、单向依赖、职责单一、对扩展开放对修改封闭 OCP、配置优先于硬编码、显式失败优于隐式兜底)。 +各篇「编号 ↔ 主题」见上面 `@import` 的三份 README 的「阅读顺序 / 阅读导航」表,此处不再复述。 -具体子游戏「二七王」(`server/games/erqiwang/`)另有两份权威文档,改动该子游戏前必读: +具体子游戏「二七王」(server/games/erqiwang/)另有两份权威文档,改动该子游戏前必读: -- `server/games/erqiwang/docs/design/design.md` —— **二七王玩法规则的唯一权威说明,必须严格遵守**。牌局构成、主牌顺序、叫分坐庄、出牌/跟牌/甩牌、捡分扣底、算子升级、算奖、房间选项等一切玩法规则以此为准;代码实现与该文档冲突时,属于代码缺陷,应改代码去符合规则(除非该规则项标注为「待确认」),**不得反过来改规则去迁就代码**。 -- `server/games/erqiwang/docs/protocol/packet_protocol.md` —— 二七王前后端收发包协议与包数据定义,**必须完全符合子游戏服务器代码实现(`server/games/erqiwang/*.js`)**。方向与 design.md 相反:协议文档是对既有代码行为的如实记录,两者不一致时以**代码为准**——应修改本协议文档去适配代码;代码新增/删除包或字段时,也要同步补全/删除本文档对应条目。 +- server/games/erqiwang/docs/design/design.md —— *二七王玩法规则的唯一权威说明,必须严格遵守*。牌局构成、主牌顺序、叫分坐庄、出牌/跟牌/甩牌、捡分扣底、算子升级、算奖、房间选项等一切玩法规则以此为准;代码实现与该文档冲突时,属于代码缺陷,应改代码去符合规则(除非该规则项标注为「待确认」),*不得反过来改规则去迁就代码*。 -两套文档中反复强调的硬性规则(文档记载均对应过真实事故): -- 全面严格 ES5(`var`/`function`,继承用 `Object.create`;禁止 `let/const`/箭头函数/模板字符串/`class`/解构/`Promise`)。 -- 服务端 `require` 只能写在文件开头的守卫块内(`if (typeof require !== 'undefined') { var X = require(...); }`)——禁止在函数体内中途 `require`;两个运行时之后统一按同一个全局名引用。 -- 成败判定只看推送包上的 `data.success`——绝不用 `status`/`code`,也不能写两者都判的兼容兜底。 -- 对影响发牌/庄家/手牌/计分/重连的权威数据,禁止用默认值掩盖缺失(`|| 0`、`|| []`、`|| ''`)——应直接显式报错;默认值仅允许用于纯展示/日志字段。 -- 一个职能只在一个模块实现——需要该能力时调用其所属模块,而不是在别处重新实现一份。 +- server/games/erqiwang/docs/protocol/packet_protocol.md —— 二七王前后端收发包协议与包数据定义,*必须完全符合子游戏服务器代码实现(server/games/erqiwang/\*.js**)***。方向与 design.md 相反:协议文档是对既有代码行为的如实记录,两者不一致时以*代码为准*——应修改本协议文档去适配代码;代码新增/删除包或字段时,也要同步补全/删除本文档对应条目。 -## 测试纪律 -与 `docs/client/development-guide/05` §10、`docs/server/development-guide/04` §10 一致,是本项目对测试代码的硬性要求: +## 测试与 Git -- **测试代码可以不守严格 ES5**:严格 ES5 是为了兼容线上浏览器/友乐运行时;测试只跑在 Node(本地/CI),不上线,不受此限制,可用现代语法写测试。 -- **正式代码(待测代码)禁止出现专为测试服务的逻辑**:不允许在核心业务代码里写"仅单测传 X 时……"之类的分支、专供测试用的钩子/后门、为了可测性而放宽的校验。 -- **严禁为了让测试通过而修改正式代码**:除非确认是正式代码本身有业务缺陷(这种情况按缺陷修复处理,而不是"为了兼容测试");否则正式代码的行为不因测试而改变。 -- **严禁为了测试通过率而降低测试标准**:不允许 skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"等任何掩盖手段。 -- **方向唯一**:正式代码服务生产,不服务测试——测试要适配正式代码的生产契约(真实的输入形状、真实的调用方式),而不是让正式代码迁就测试的简化输入/不规范 stub。 -- **失败先裁根因**:测试失败时先用证据(打印中间态、最小复现)裁定是【业务代码缺陷】还是【测试脚本缺陷】,禁止凭猜测下结论;业务缺陷修业务、脚本缺陷修脚本,两者都保持/恢复硬断言。 -- **用例要全面**:新增或修改测试时,正面用例(正常路径/合法输入)、反面用例(非法输入/错误路径/异常分支)、边界用例(空值、极值、临界条件、越界)三类都要覆盖,不能只测 happy path。 - -## Git 提交 - -本仓库已初始化 git(见根目录 `.gitignore`)。提交纪律与 `docs/server/development-guide/04-开发规范与红线.md` §11 一致: - -- **及时提交,不堆积**:每完成一个可独立成立的逻辑改动(一个修复/一个功能点/一次重构/一批相关文档改动)就提交,不要把多个不相关改动攒成一次大提交。 -- **可以自动提交,无需每次都问**:完成一个阶段性改动后可直接创建 commit,不必逐次向用户确认;`push` 仍按需(涉及推送远程、force-push 等仍按常规安全规范处理)。 -- **提交信息用中文**,简明说明「做了什么/为什么」,一次提交聚焦一件事,结尾保留 `Co-Authored-By` 署名行。 -- 仍遵守通用安全底线:不用 `git add -A`/`git add .` 囫囵提交(防止误收敏感文件/大文件),不跳过 hooks,不做 `reset --hard`/`push --force` 等破坏性操作。 +- **测试纪律**:细则见 client 05 §10 / server 04 §10。要点——测试代码可用现代语法(只跑 Node,不上线);**正式代码禁止为测试而加逻辑/放宽校验**,禁止为过测而改正式代码或降低测试标准(skip、软化断言、吞异常等);失败先用证据裁定「业务缺陷 vs 脚本缺陷」;正面/反面/边界用例都要覆盖。 +- **Git 提交**(细则见 server 04 §11):完成一个可独立成立的逻辑改动即可**自动提交、无需逐次确认**(`push` 按需);提交信息用中文、聚焦一件事、结尾保留 `Co-Authored-By` 署名;不用 `git add -A`/`git add .`、不跳过 hooks、不做 `reset --hard`/`push --force`。 diff --git a/client/docs/Spine动画集成手册.md b/client/js/docs/Spine动画集成手册.md similarity index 100% rename from client/docs/Spine动画集成手册.md rename to client/js/docs/Spine动画集成手册.md diff --git a/docs/client/development-guide/01-前端架构与运行环境.md b/docs/client/development-guide/01-前端架构与运行环境.md index 8082bd0..2ee431b 100644 --- a/docs/client/development-guide/01-前端架构与运行环境.md +++ b/docs/client/development-guide/01-前端架构与运行环境.md @@ -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 组件怎么写。 - diff --git a/docs/client/development-guide/02-渲染与UI组件体系.md b/docs/client/development-guide/02-渲染与UI组件体系.md index 6ee9a71..ac9cf5f 100644 --- a/docs/client/development-guide/02-渲染与UI组件体系.md +++ b/docs/client/development-guide/02-渲染与UI组件体系.md @@ -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。 - diff --git a/docs/client/development-guide/03-事件·动画·音频·Spine.md b/docs/client/development-guide/03-事件·动画·音频·Spine.md index 7ce876b..1adb5d8 100644 --- a/docs/client/development-guide/03-事件·动画·音频·Spine.md +++ b/docs/client/development-guide/03-事件·动画·音频·Spine.md @@ -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) 讲:怎么收发包、新旧架构如何对接、一局怎么启动。 - diff --git a/docs/client/development-guide/04-网络对接与启动编排.md b/docs/client/development-guide/04-网络对接与启动编排.md index 0e8d5ec..dd42571 100644 --- a/docs/client/development-guide/04-网络对接与启动编排.md +++ b/docs/client/development-guide/04-网络对接与启动编排.md @@ -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) 汇总前端所有工程纪律。 - diff --git a/docs/client/development-guide/05-开发规范与红线.md b/docs/client/development-guide/05-开发规范与红线.md index 4e2285d..b5cacbe 100644 --- a/docs/client/development-guide/05-开发规范与红线.md +++ b/docs/client/development-guide/05-开发规范与红线.md @@ -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) 查看导航与分层模型。 - +至此 01–05 构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。 diff --git a/docs/client/development-guide/06-子游戏接入模式与Hooks外置.md b/docs/client/development-guide/06-子游戏接入模式与Hooks外置.md index b0edb83..7f6e71c 100644 --- a/docs/client/development-guide/06-子游戏接入模式与Hooks外置.md +++ b/docs/client/development-guide/06-子游戏接入模式与Hooks外置.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 之后、三文件之前加载 | diff --git a/docs/client/development-guide/README.md b/docs/client/development-guide/README.md index 83104ed..c2e707e 100644 --- a/docs/client/development-guide/README.md +++ b/docs/client/development-guide/README.md @@ -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/):本套讲前端接入与红线,工程通则讲前后端通用的设计方法论,互补阅读。 diff --git a/docs/games/engineering/01-架构总则与分层.md b/docs/games/engineering/01-架构总则与分层.md index 1465557..2291d12 100644 --- a/docs/games/engineering/01-架构总则与分层.md +++ b/docs/games/engineering/01-架构总则与分层.md @@ -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 后端分层(自上而下依赖) diff --git a/docs/games/engineering/02-可扩展性与配置化.md b/docs/games/engineering/02-可扩展性与配置化.md index 17ca8d0..961be1f 100644 --- a/docs/games/engineering/02-可扩展性与配置化.md +++ b/docs/games/engineering/02-可扩展性与配置化.md @@ -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 次容忍重复。 -- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体), - 再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。 +- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。 > 一句话:**扩展性是留给"已知会变"的地方的;对"稳定不变"的地方,简单直接才是最好的设计。** diff --git a/docs/games/engineering/03-数据权威·错误处理·演进.md b/docs/games/engineering/03-数据权威·错误处理·演进.md index 10bf775..a3ecc6b 100644 --- a/docs/games/engineering/03-数据权威·错误处理·演进.md +++ b/docs/games/engineering/03-数据权威·错误处理·演进.md @@ -1,7 +1,6 @@ # 03 · 数据权威 · 错误处理 · 演进 -本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化。 -数据权威部分在 `.github/copilot/skills/data-authority-principle.md` 基础上,扩展到**前后端全景**。 +本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化;数据权威部分在既有「数据权威原则」上扩展到**前后端全景**。 --- @@ -75,7 +74,7 @@ ## 3. 演进与重构纪律 -代码会随规则长大。让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。 +代码随规则长大;让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。 ### 3.1 收敛并行实现 diff --git a/docs/games/engineering/README.md b/docs/games/engineering/README.md index 4ef8c43..640c28f 100644 --- a/docs/games/engineering/README.md +++ b/docs/games/engineering/README.md @@ -59,8 +59,6 @@ | 文档 | 定位 | |------|------| | 各端 `development-guide/` | 平台接入 + 工程红线(**能跑、合规**) | -| `docs/architecture/` | **本系统**的具体架构说明(是什么样) | -| `.github/copilot/skills/data-authority-principle.md` | 数据权威原则(本套 03 篇在其上扩展到前后端) | -| **本套 `docs/games/engineering/`** | **平台无关的通用工程与架构规范**(该怎么设计) | +| **本套 `engineering/`** | **平台无关的通用工程与架构规范**(该怎么设计) | > 具体命名(模块名/文件名/方法名)在本套文档里多为**示例、可自定**;约束的是**做法与结构**,不是具体名字。 diff --git a/docs/server/development-guide/01-服务端环境与框架基础.md b/docs/server/development-guide/01-服务端环境与框架基础.md index bad9e92..07d49e8 100644 --- a/docs/server/development-guide/01-服务端环境与框架基础.md +++ b/docs/server/development-guide/01-服务端环境与框架基础.md @@ -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 个接口各做什么、一次操作的完整数据流。 - diff --git a/docs/server/development-guide/02-子游戏接入与开发流程.md b/docs/server/development-guide/02-子游戏接入与开发流程.md index 5c58a22..cdae98e 100644 --- a/docs/server/development-guide/02-子游戏接入与开发流程.md +++ b/docs/server/development-guide/02-子游戏接入与开发流程.md @@ -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` 成败协议。 - diff --git a/docs/server/development-guide/03-数据收发与通信协议.md b/docs/server/development-guide/03-数据收发与通信协议.md index fbfe654..5e5dbc4 100644 --- a/docs/server/development-guide/03-数据收发与通信协议.md +++ b/docs/server/development-guide/03-数据收发与通信协议.md @@ -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) 汇总所有必须遵守的工程纪律。 - diff --git a/docs/server/development-guide/04-开发规范与红线.md b/docs/server/development-guide/04-开发规范与红线.md index 00e8aee..c819a6f 100644 --- a/docs/server/development-guide/04-开发规范与红线.md +++ b/docs/server/development-guide/04-开发规范与红线.md @@ -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) 查看导航与一页纸模型。 - diff --git a/docs/server/development-guide/README.md b/docs/server/development-guide/README.md index 21fc0ef..208af44 100644 --- a/docs/server/development-guide/README.md +++ b/docs/server/development-guide/README.md @@ -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/):本套讲"平台怎么接、红线是什么",工程通则讲"该怎么设计、怎么长久演进",互补阅读。 diff --git a/server/docs/development-guide/01-服务端环境与框架基础.md b/server/docs/development-guide/01-服务端环境与框架基础.md deleted file mode 100644 index 563bb98..0000000 --- a/server/docs/development-guide/01-服务端环境与框架基础.md +++ /dev/null @@ -1,201 +0,0 @@ -# 01 · 服务端环境与框架基础 - -本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。读懂这一篇,后面的接入与协议才有坐标。 - -> 举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。 - ---- - -## 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/ ← 平台框架(除 games2/<你的游戏>/ 外,全部禁改) -├── packet.js 总收发包入口、应用列表、最终 SendPack -├── applist.js 加载各应用(server / youle / update) -├── class/ 框架基础类 -│ ├── class.app.js 应用基类 cls_app(按 route 路由到模块) -│ ├── class.mod.js 模块基类 cls_mod(按 rpc 路由到方法) -│ └── class.desk.js / ... 桌、牌等基础类 -├── youle/ 友乐应用(appname = "youle") -│ ├── app.js 创建 youle_app -│ └── server_room/ 房间模块 youle_room(routename = "room") -│ ├── class.room.js 房间对象 o_room(含 seatlist、发包方法) -│ ├── class.player.js 座位/玩家对象(含 conmode/fromid) -│ ├── class.export.js 框架对外服务:check_player / deduct_roomcard / save_grade ... -│ └── class.import.js 框架回调子游戏:makewar_deskwar / get_disbandRoom / player_* ... -└── games2/ - └── <你的游戏>/ ← 子游戏(唯一可编辑目录) - ├── mod.js 模块入口:创建模块、加载文件、定义 RPC 方法 - ├── export.js 子游戏对平台暴露的 8 个必需接口 - ├── import.js 子游戏对平台服务的 4 个封装接口 - └── ... 你的玩法业务代码 -``` - -**一句话**:平台提供"网络 + 房间 + 玩家 + 房卡 + 战绩"的地基,子游戏只往 `games2/<你的游戏>/` 里填"玩法"。两边的接缝就是 **export / import**(详见 02)。 - ---- - -## 3. 核心对象模型 - -理解五个对象的层级关系,就理解了框架的数据骨架: - -``` -app (youle_app) 一个应用,appname = "youle" - └── modlist[] 应用下挂的所有模块 - ├── youle_room 平台房间模块,routename = "room" - └── mod_<你的游戏> 你的子游戏模块,routename = "<你的游戏>" - -o_room 房间对象(平台创建,一桌一个) - ├── roomcode / roomtype / asetcount / battlestate ... 平台基础数据 - ├── seatlist[] 座位列表,长度=满桌人数 - │ └── seatlist[seat] = o_player 座位上的玩家对象 - │ ├── playerid / nickname / onstate - │ └── conmode / fromid 连接方式与连接ID(发包要用) - ├── method.sendpack_toseat(msg, seat) 平台发包方法 - ├── method.sendpack_toother(msg, seat) - └── o_desk ★ 子游戏的牌桌对象(由你在 makewar 里创建并挂上) - ├── o_room 反向引用回房间 - └── data.* ★ 你的对局状态全挂这里(房间隔离的落点) -``` - -要点: - -- **`o_room` 由平台创建并持有**;`seatlist[seat]` 是玩家对象,发包所需的 `conmode`/`fromid` 就在它上面。 -- **`o_desk` 由子游戏创建**(在 `makewar` 中),并与 `o_room` 建立双向引用:`o_room.o_desk = desk; desk.o_room = o_room;`。 -- **对局状态一律挂在 `o_room.o_desk.data.*`**。这是"房间隔离"的物理落点:每桌一份,互不串扰(见 04)。 - -> 举例:麻将把游戏状态放在 `o_room.o_desk.data.roomAdapter.gameState`,把操作队列放在 `o_room.o_desk.data.operationQueue`。换成斗地主、跑得快也是同一套落点,只是 `data.*` 下的字段不同。 - ---- - -## 4. 三层路由:一个包如何到达你的函数 - -平台把客户端发来的包,按 `app → route → rpc` 三级精确投递到子游戏的某个函数。 - -``` -{ app: "youle", route: "<你的游戏>", rpc: "playCard", data: { ... } } -``` - -| 级 | 在哪 | 依据 | 动作 | -|----|------|------|------| -| 1 | `packet.js` `packet_face.ReceivePack` | `pack.app` | 在 `applist` 里找到 `appname == pack.app` 的应用,调 `app.ReceivePack(pack)` | -| 2 | `class/class.app.js` `cls_app.ReceivePack` | `pack.route` | 在 `app.modlist` 里找到 `routename == pack.route` 的模块,调 `mod.DoPack(pack)` | -| 3 | `class/class.mod.js` `cls_mod.DoPack` | `pack.rpc` | 若 `mod[pack.rpc]` 存在,调用 `mod[pack.rpc](pack)` | - -证据: -- 第 1 级:`server/packet.js` `ReceivePack` 按 `pack.app == applist[i].appname` 分发。 -- 第 2 级:`server/class/class.app.js` `ReceivePack` 按 `pack.route == modlist[i].routename` 调 `DoPack`。 -- 第 3 级:`server/class/class.mod.js` `DoPack` 按 `_msg.rpc` 调 `_obj_mod[_msg.rpc](_msg)`。 - -**对开发者的含义**:你在 `mod.js` 里写下 `mod_<游戏>.playCard = function(pack){...}`,前端只要发 `{route:"<你的游戏>", rpc:"playCard", ...}`,框架就会自动调到它——**无需自己写路由分发**,更不要在一个总入口里用 `switch(action)` 做二次路由(一个操作对应一个 RPC 方法)。 - -### ⚠️ 关于 `DoPack` 的返回值(重要) - -`cls_app.ReceivePack` 在拿到 `DoPack` 的返回值后,确实会执行一次 `app.SendPack(repack)`。但这个回发: - -- 只回给**当前请求的那条连接**,且不经过"按座位定向"的处理; -- 在浏览器/友乐链路下**不是子游戏向前端推送状态的可靠通道**。 - -因此本项目的铁律是:**子游戏 RPC handler 一律通过 `o_room.method.sendpack_toseat / sendpack_toother` 主动推送**来下发结果,**不依赖 `return`**。这条直接推导出 03 的"成败标志必须放在主动推送的 `data.success` 里"。 - ---- - -## 5. 模块注册与全局暴露 - -子游戏模块用框架基类创建,并自动注册进应用: - -```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 个接口各做什么、一次操作的完整数据流。 - diff --git a/server/docs/development-guide/02-子游戏接入与开发流程.md b/server/docs/development-guide/02-子游戏接入与开发流程.md deleted file mode 100644 index 46b8fb2..0000000 --- a/server/docs/development-guide/02-子游戏接入与开发流程.md +++ /dev/null @@ -1,247 +0,0 @@ -# 02 · 子游戏接入与开发流程 - -本篇讲**怎么动手**:一个子游戏由哪三个必需文件构成、`export.js` 的 8 个接口与 `import.js` 的 4 个接口各做什么、`makewar`/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。 - -> 仍以麻将举例,但"三文件架构 + 8/4 接口 + 主动推送"对任意子游戏一致。 - ---- - -## 1. 文件结构 - -### 必需三件套(固定文件名) - -``` -server/games2/<你的游戏>/ -├── mod.js 模块入口:创建模块、按序加载文件、定义 RPC 方法 -├── export.js 对平台暴露的 8 个必需接口(+ 可选接口) -└── 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`,要与目录/约定一致。 - -### 2.2 按依赖顺序加载文件 - -被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块): - -```js -// 浏览器/友乐:min_loadJsFile 异步链式加载 -min_loadJsFile("games2/<你的游戏>/常量与工具.js", function(){ -min_loadJsFile("games2/<你的游戏>/数据结构.js", function(){ -min_loadJsFile("games2/<你的游戏>/export.js", function(){ -min_loadJsFile("games2/<你的游戏>/import.js", function(){ -min_loadJsFile("games2/<你的游戏>/业务与rpc.js", function(){ - console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成"); -});});});});}); -``` - -> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。 - -### 2.3 定义 RPC 方法(一操作一函数,委托给 handler) - -每个客户端操作对应一个 RPC 方法。**方法本身只做"接住请求并委托"**,真正逻辑放在 `rpc/handlers/*`,保持 `mod.js` 薄: - -```js -mod_<你的游戏>.playCard = function(pack) { - return RpcHandler.handlePlayCard(pack); // 委托到收发包层 -}; -mod_<你的游戏>.declareHu = function(pack) { - return RpcHandler.handleDeclareHu(pack); -}; -``` - -**新增一个前后端接口** = 在 `mod.js` 加一个 `mod_<游戏>. = function(pack){...}` + 前端包里 `rpc: ""`。**不要**在受限前端接口文件里新增接口(见 04),也**不要**用 `switch(action)` 二次路由。 - ---- - -## 3. `export.js` —— 平台调用子游戏的 8 个必需接口 - -平台在房间生命周期的各节点回调这 8 个接口。用工厂模式创建,文件末尾挂到 `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 = 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) { /* ... */ }; - return exp; - } -}; -mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动挂载 -``` - -| 接口 | 何时被调 | 返回 | 职责 | -|------|----------|------|------| -| `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? | 处理玩家离开(如清理其托管/占位) | - -> `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` 实现 8 个必需接口并在末尾挂到 `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` 成败协议。 - diff --git a/server/docs/development-guide/03-数据收发与通信协议.md b/server/docs/development-guide/03-数据收发与通信协议.md deleted file mode 100644 index 87b9291..0000000 --- a/server/docs/development-guide/03-数据收发与通信协议.md +++ /dev/null @@ -1,170 +0,0 @@ -# 03 · 数据收发与通信协议 - -本篇是**日常手册**:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——**成败标志只认 `data.success`**。 - ---- - -## 1. 数据包结构 - -所有前后端数据包统一四字段: - -```js -{ - app: "youle", // 应用名(游戏固定 "youle") - route: "<你的游戏>", // 路由名 = 模块 routename - rpc: "playCard", // 方法名,决定调到 mod 的哪个函数 / 前端走哪个解析分支 - data: { /* 业务数据 */ } -} -``` - -- `app/route/rpc` 三字段驱动三层路由(见 01)。 -- `data` 里**必含平台参数**:`agentid`、`playerid`、`gameid`、`roomcode`、`seat` 等;数值参数收包时要 `parseInt`。 -- **一包多信息**:一个响应包应携带本次状态变更的完整信息(出了什么牌、轮到谁、各家剩余、倒计时、比分……),减少往返。 - ---- - -## 2. 收包处理的固定步骤 - -每个 RPC handler 开头都是同一套"安检流程",**不可省略、不可简化**: - -```js -mod_<游戏>.playCard = function(pack) { - // 1) 提取参数(数值 parseInt) - var agentid = pack.data.agentid; - var playerid = parseInt(pack.data.playerid); - var gameid = pack.data.gameid; - var roomcode = parseInt(pack.data.roomcode); - var seat = parseInt(pack.data.seat); - - // 2) 校验玩家与房间(必做)——失败静默 return - var o_room = mod_<游戏>.import.check_player( - agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid); - if (!o_room) return; - - // 3) 取桌对象与对局状态 - var o_desk = o_room.o_desk; - if (!o_desk) return; - - // 4) 业务校验(游戏阶段?轮到该座?操作合法?)——任一不过即 return - // 5) 执行业务(委托权威模块) - // 6) 构建并【主动推送】结果(见下文) -}; -``` - -- **`check_player` 是强制安检**:它校验座位、连接、身份,返回 `o_room` 或 `null`。返回 `null` 一律直接 `return`。 -- **校验失败静默**:不要回 "你作弊了" 之类提示,直接 `return`,避免给作弊者反馈。 -- **调试记录**(若框架提供 `o_desk.debug.save_receivepack` 等)在关键节点调用,便于复盘。 - ---- - -## 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. 前后端对接的固定接缝 - -下发结构变了,对应的前端解析接口必须同步检查(前端平台代码与受限接口文件不可随意改,见 04): - -| 场景 | 服务端产出 | 前端接收接口 | -|------|------------|--------------| -| 开战 | `export.makewar` 的返回包 | `Game_Modify.StartWar(makewar 返回值)` | -| 重连/中途加入 | `export.get_deskinfo` 的返回 | `Game_Modify.Reconnect(get_deskinfo 返回值)` | -| 对局操作 | RPC handler 的主动推送(按 `rpc` 区分) | 前端对应 `rpc` 的解析分支 | - -**改服务端下发 = 同步核对前端解析**,否则前端按旧结构解析必然出错。 - ---- - -## 7. 服务端代替玩家操作时的透明性 - -服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。) - ---- - -## 8. 小结 - -- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。 -- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。 -- **主动推送是唯一可靠下发通道**,`return` 不算。 -- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。 -- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。 - -下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。 - diff --git a/server/docs/development-guide/04-开发规范与红线.md b/server/docs/development-guide/04-开发规范与红线.md deleted file mode 100644 index 19893ae..0000000 --- a/server/docs/development-guide/04-开发规范与红线.md +++ /dev/null @@ -1,168 +0,0 @@ -# 04 · 开发规范与红线 - -本篇汇总所有**必须遵守**的工程纪律。每一条都对应过真实事故或返工,是代码审查的清单来源。改任何代码前,按相关条目自检。 - ---- - -## 1. 可编辑范围 - -### 服务端 - -- **唯一可编辑目录**:`server/games2/<你的游戏>/`。 -- **禁改**:`server/` 其余一切均为平台代码——`server/class/`、`server/config/`、`server/server/`、`server/youle/`、`server/update/`、`server/packet.js`、`server/applist.js`、`server/minhttp.js` 等。 - -### 前端 - -- **平台代码禁改**:`client/js/00_Surface/` 下全部文件。 -- **受限接口文件**(不可新增对外接口,尽量不改,确需则只在现有接口内部加逻辑): - `client/js/01_SubGame/00_SubGame_Config.js`、`01_SubGame_modify.js`、`02_SubGame_Input.js`。 -- **新增前后端交互**一律走 `mod.js` 的 `mod_<游戏>.` 机制,**不在受限文件里新增接口**。 - ---- - -## 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 文件同步流程 - -前后端存在一份共享代码,**必须**经流程修改,禁止直接编辑前端副本: - -| 角色 | 路径 | -|------|------| -| 权威源(服务端) | `server/games2/<你的游戏>/shared/` | -| 前端副本(只读) | `client/js/01_SubGame/codes/shared/` | - -1. **只改服务端** `shared/` 下文件。 -2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。 -3. **禁止直接编辑前端 `codes/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. 审查速查表 - -| 维度 | 红线 | -|------|------| -| 范围 | 只改 `games2/<游戏>/` 与允许的前端范围 | -| 语言 | 纯 ES5;`require` 仅在顶部守卫块 | -| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 | -| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补 | -| 职责 | 一职能一模块,调用不重造 | -| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 | -| 自动操作 | 复用真人链路,对前端透明 | -| Shared | 只改服务端 `shared/`,跑同步脚本 | -| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 | -| Git | 一事一提交、中文信息、及时提交 | - ---- - -至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。 - diff --git a/server/docs/development-guide/README.md b/server/docs/development-guide/README.md deleted file mode 100644 index b6fd947..0000000 --- a/server/docs/development-guide/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# 服务端 · 子游戏开发指导文档 - -> 本套文档是**友乐游戏平台**下「子游戏服务端」开发的通用指导与规范。 -> 它讲清楚三件事:**框架怎么运作**、**子游戏怎么接进去**、**开发时必须守哪些规矩**。 -> -> 文中以「麻将」一类房卡棋牌作举例,但所有结论都是**框架通用**的,不绑定任何具体玩法。 -> 新开发者按本套文档即可理解运作流程、动手接入并写出符合规范的子游戏。 - ---- - -## 这套文档写给谁 - -- **新接手子游戏服务端的开发者**:先读完 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** 两组接口对接。 - ---- - -## 红线速查(详见 04) - -> 以下每一条都有过真实事故或返工,**改代码前先对照**。 - -- **可编辑范围**:服务端只能改 `server/games2/<你的游戏>/`,`server/` 其余皆平台代码,禁改。 -- **双运行时**:代码同时跑在 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`)以本套为准。 -- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `games2/<游戏>/docs/` 为准。 - -