Compare commits

...
10 Commits
Author SHA1 Message Date
joywayer 2ce936e7ec 删除hook,框架改动 2026-08-19 08:14:48 +08:00
joywayerandClaude Opus 4.8 96971f3072 文档红线细化:新增服务端请求合法性验证(§8)+悲观UI/输入-渲染解耦,收紧 shared 与常量准入门槛
- 服务端 04:新增「操作请求合法性验证(不可信客户端)」章节——五层验证栈
  (座位鉴权/阶段门/操作可用性/幂等去重/参数校验)、统一裁决网关默认拒绝、
  availableActions 兼任决策依据与准入白名单、出牌回合门防死代码;后续章节顺延重编号
- 前端 04/05:悲观 UI——点击只发请求包、对局状态表现收包后更新,收包处理器触发源无关,
  AI 托管/他人广播共用同一更新路径;响应/掷骰交互按钮隐藏为受控乐观清除例外
- shared 准入门槛(前后端 04/05 §9/§8):进 shared 的门槛是「前后端都真正用到且必须
  逐字一致」而非「它是玩法逻辑」;前端只展示+悲观UI ⇒ 计算类逻辑不进 shared,默认留服务端
- 硬编码常量准则(服务端 04 §10):魔法字符串默认常量化(单一来源);常量放 shared 亦按
  同一准入门槛判定,仅前后端都用到才进 shared/constants

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 04:12:27 +08:00
joywayerandClaude Opus 4.8 46f65de7ed 前端规范新增两条:常量化 id(含群组/Spine)+ 显隐走 showXxx/hideXxx
- 强化「常量集中、禁硬编码」(05 §4 / README 红线速查 / 05 §11 审查表):
  明确 UI 代码禁止任何硬编码 id/裸值,覆盖精灵/群组/图层/图片/声音/Spine
  等全部 id 与资源,一律用对应常量;补 Spine→Spine 动作配置映射。
- 新增「显隐走 showXxx/hideXxx 接口」(02 §3 范式第4条 / 05 §9 红线 /
  README 红线速查 / 05 §11 审查表):UI 组件及零件的精灵/群组显隐必须由组件
  暴露的 showXxx/hideXxx 控制,禁止别处直接用其精灵/群组 id 去 show/hide,
  否则绕过组件、状态分散、重连刷新不一致(与 UI 组件专职一脉相承)。
- 显隐条同进 README 红线速查(@import 常驻层);未改任何小节标题,
  链接/锚点审计通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:21:11 +08:00
joywayerandClaude Opus 4.8 b59a6f9ca5 前端规范新增两条:重连即重画(两场景)+ UI 组件专职
- 重连处理规范(04 §3.3 详解 / 05 §6 红线 / README 红线速查 / 05 §11 审查表):
  前端必须同时处理「断线重连」与「硬刷新·页面重载」两种恢复场景;本质是
  恢复数据 → 逐个调各 UI 组件 setXxx/refreshXxx 恢复数据与界面状态,复用
  同一条重画路径,绝不为重连单写一套渲染。
- UI 组件专职(05 §9 红线 / README 红线速查 / 05 §11 审查表):每个界面的
  数据与渲染只由其对应 UI 组件实现;别的模块要改/刷该界面一律调组件公开
  接口,禁止在别处重复实现重叠或类似的界面逻辑(否则多份状态源,重连/刷新
  必然不一致)。
- 两条同时进 client README 红线速查(@import 常驻层),使其每次会话在上下文。
- 未改任何小节标题;链接/锚点审计通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:52:39 +08:00
joywayerandClaude Opus 4.8 abc6281ec0 回迁工具:check-redlines 增量模式(--staged-diff)+ 存量扫描(--scan-all)+ setup 脚本
为「老项目回迁这套强制体系」补三件套,核心是不搞大爆炸清理:

- check-redlines.js:新增 --staged-diff(只查本次新增行,老代码存量违规不阻塞,
  新代码从此强制合规)与 --scan-all(可编辑正式代码 ES5 存量违规,只报不挡)。
- .githooks/pre-commit:按本地 git 配置 redlines.stagedmode 选严格(整文件)/增量(新增行)。
- setup-redlines.sh:一键激活+自检——迁移清单、node/权限、set core.hooksPath、
  存量扫描;`--diff` 一键切增量模式供老项目用。
- 已测:scan-all 报本仓库 0 存量违规;增量模式放行含老违规文件的干净新增行、
  拦新增违规行;严格模式整文件拦;setup 幂等且模板保持严格。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:19:27 +08:00
joywayerandClaude Opus 4.8 c67a9f9a26 CLAUDE.md:记录模板/克隆约定与 pre-commit 启用步骤(不依赖 memory)
本仓库是模板、会被克隆开新项目,Claude memory 不随克隆迁移,故持久约定
一律进仓库:

- 「这是一个什么仓库」补明:模板项目,长期知识进 docs/CLAUDE.md/.claude/
  .githooks,不依赖 memory。
- 「常用命令」补 pre-commit 启用步骤:core.hooksPath 是本地配置、不随克隆走,
  每个新克隆需一次性 `git config core.hooksPath .githooks`;并注明测试脚本
  (tests/、*.test.js、*.spec.js)允许现代语法、只有正式代码严格 ES5。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:24:00 +08:00
joywayerandClaude Opus 4.8 1d9bc049f1 硬约束:机械红线校验(PreToolUse 阻断 + git pre-commit)+ PostCompact 重注入
把可机器判定的硬红线从"投喂"升级为"硬拦截",并补上事后/工具无关/压缩三个缺口:

- .claude/hooks/check-redlines.js:校验「可编辑范围」(路径级,禁改平台/
  vendor/00_Surface/02_Input/server 平台,app.js 例外) 与「严格 ES5」(运行时
  .js 禁箭头/模板串/class/let/const,去注释与字符串后高信号匹配)。双模式:
  PreToolUse 读工具入参、命中即 deny 阻止写入;--staged 扫暂存文件、命中即
  非零退出阻断提交。
- settings.json:PreToolUse 增挂 check-redlines(在 remind 之前);新增
  PostCompact 钩子清空 .spec-injected,使压缩后下次编辑重新全量注入规范。
- .githooks/pre-commit + core.hooksPath=.githooks:提交边界硬闸,工具无关
  (Claude Code/别的编辑器/人手改都过)。
- 已校准:81 个现有运行时 js 零 ES5 误报(可编辑的 framework 25 文件全过),
  平台文件正确判为禁改;真违规(箭头/模板/const/class/改 vendor/改 class)全拦、
  合法 ES5 与注释里的 => 全放行;pre-commit 实测拦住 ES5 违规提交。

判断类规范(职责单一/数据权威设计/房间隔离)机器判不了,不在本层,仍靠投喂
+(可选)改后复审 agent。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:25:59 +08:00
joywayerandClaude Opus 4.8 d2ea3218b4 CLAUDE.md:删与 @import README 重复的架构速览与编号主题表
三份 README 已 @import 常驻上下文,其「一页纸模型 / 阅读顺序表」比
CLAUDE.md 的复述更全,故删冗余、只留指路:

- 删「架构速览」骨架四条(协议/单向依赖/三层路由/接入两模式)——
  内容在 README 一页纸与编号正文(如接入两模式在 client 06 正文+红线表)
- 删「编号↔主题」对照表——README 阅读顺序/导航表已列
- 可编辑范围表保留(最高价值 always-on 操作护栏),升为独立小节并加指路
- 仓库简介 / 常用命令 / 强制声明+@import / Git 自动提交许可等 CLAUDE 专属内容全留

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:02:30 +08:00
joywayerandClaude Opus 4.8 f2f618b965 降耗②·铺开:按试点尺度精简其余 12 篇编号正文
沿用 client 02 的保守尺度(规范条款/表格/关键代码/标题/链接全保留,
只压缩冗长代码示例、重复解说、演进历史、✅/❌ 成对代码块)逐篇精简
client 01/03/04/05/06、server 01/02/03/04、engineering 01/02/03。

- 12 篇合计 78,171 → 74,726 字符(省 3,445,~4.4%);红线密集篇(client
  05、server 04)极保守、几乎不动,符合"不丢规范优先于省字数"。
- 已核验:51 个跨文档链接目标全部存在、3 个锚点全部命中真实标题;
  server 03 §6、server 04 §8/§10/§11 等被引用小节标题逐字未改;README 未动。
- 每篇均随附规范保留清单逐条自查(并行子代理完成、逐份复核)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:52:16 +08:00
joywayerandClaude Opus 4.8 7b43d3ee44 降耗②·试点:精简 client 02(规范零丢失,−15%)
保守精简:31 条规范、三张参考表(API/ID范围/生命周期)、set-refresh
核心范式全部保留;只压缩冗长代码示例、把 ✅/❌ 代码块转文字、删两处
演进历史/范例背景注。10,631 → 8,967 字符(−1,664,~15%)。

作为其余 12 篇编号正文精简的尺度基准。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:44:59 +08:00
57 changed files with 20275 additions and 4881 deletions
-105
View File
@@ -1,105 +0,0 @@
#!/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);
});
-27
View File
@@ -1,27 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"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/remind-docs.js\"",
"timeout": 15,
"statusMessage": "注入/校验开发规范…"
}
]
}
]
}
}
+19 -22
View File
@@ -1,12 +1,14 @@
# CLAUDE.md
本文件为 Claude Code(claude.ai/code)在本仓库中工作时提供指导。**它是入口与强制指引**:三套文档的 README(含各自「红线速查 / 一页纸总则」)已通过 `@import` 常驻上下文(见下「文档」一节),**红线以它们为权威、始终在上下文里,本文件不再另抄一份**;规范的完整细节在 `docs/` 编号文档里,改动前先读对应文档。
本文件为 Claude Code(claude.ai/code)在本仓库中工作时提供指导。**它是入口与强制指引**:三套文档的「红线速查 / 一页纸总则」单页已通过 `@import` 常驻上下文(见下「文档」一节),**红线以它们为权威、始终在上下文里,本文件不再另抄一份**;规范的完整细节在 `docs/` 编号文档里,改动前先读对应文档。
## 这是一个什么仓库
一个友乐/gameabc 房卡类小游戏平台:浏览器端由私有的 `gameabc.min.js` 2D Canvas 引擎驱动,服务端是一个 Node.js 游戏服务器平台(`youle` 应用),双方通过 WebSocket/HTTP 收发 JSON 包通信。项目根目录**没有 `package.json`、没有 npm 构建/lint/测试流水线**——这是纯静态 JS:客户端靠 `<script>` 标签加载,服务端靠平台自带的 `min_loadJsFile`/`require` 加载。
`client/js/01_SubGame/codes/` 和 `server/games` 目前都是**空的**——尚未接入任何具体子游戏。本仓库是平台层 + 可复用框架的脚手架,未来第一个子游戏会在此基础上搭建。
本仓库是平台层 + 可复用框架的脚手架,尚未接入任何具体子游戏,未来第一个子游戏会在此基础上搭建。
本仓库是**模板项目**——后续会被克隆出去开新项目。因此所有需要长期保留的约定与知识**一律进仓库**(`docs/` 权威文档、本 CLAUDE.md、`.claude/` 钩子与 `settings.json`、`.githooks/`),**不依赖 Claude memory**(memory 按机器/会话存放、不随 `git clone` 迁移,对模板克隆无效)。
## 常用命令
@@ -21,18 +23,19 @@ client/scripts/build_spine_data.cmd # 内部调用 build_spine_data.ps1
查看客户端效果需用 HTTP 方式(而非 `file://`)打开 `client/index.html`,例如 `npx http-server client -p 8080`。
**启用提交前机械红线校验**(严格 ES5、可编辑范围等硬红线的 git 提交闸)——`.githooks/pre-commit` 已随仓库提交,但 `core.hooksPath` 是本地配置、不随克隆迁移,故**每个新克隆需一次性**执行:
```bash
git config core.hooksPath .githooks
```
(Claude Code 内 `.claude/` 的 PreToolUse 钩子会自动生效、无需此步;这一步只为让**提交闸**对所有工具/人手改也生效。测试脚本 `tests/`、`*.test.js`、`*.spec.js` 允许现代语法,不受严格 ES5 拦截;只有正式代码严格 ES5。)
`shared/`(共享算法,待子游戏接入后才有)的改动应按两套文档「§10 测试纪律」的要求跑 Node 单测验证——目前尚未接入测试框架,意味着需要直接用 `node` 运行相应脚本。
## 架构速览
## 可编辑范围
> 完整的分层、模块清单与数据流以各 README 及编号文档为权威(client 01 / server 01);此处只留改动前必须在上下文里的骨架。
- **两套代码库、一份协议**:`client/`(浏览器,严格 ES5,gameabc 引擎)与 `server/`(Node.js,严格 ES5,youle 平台)通过 `{ app: "youle", route, rpc, data }` JSON 包通信——`route`→模块、`rpc`→方法(`mod[pack.rpc](pack)`,一操作一 RPC,无二次 `switch(action)`)。**服务端只靠主动推送**(`o_room.method.sendpack_toseat/toother`)告知结果,`DoPack` 返回值不是下发通道。
- **客户端依赖严格单向**:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`;`index.html` 的加载顺序就是依赖图,新增文件须插在依赖之后、使用者之前(无模块系统,否则拿到 `undefined`)。框架必须**游戏中立**,禁止渗入任何具体玩法逻辑/常量。
- **服务端三层路由** app→mod→method;每房间状态挂 `o_room.o_desk.data.*`(`export.makewar` 内创建,双向引用 `o_room.o_desk ⇄ o_desk.o_room`),按房间隔离。子游戏只在 `server/<游戏容器目录>/<游戏>/` 内开发。
- **子游戏接入二选一、不混用**:内联模式(逻辑写进三个契约文件)或 Hooks 外置模式(三契约文件退化为转发壳,逻辑放 `codes/SubGameHooks.js` + `codes/`,模板见 `gameabc-framework/templates/subgame-entry/`)。详见 client 06。
**可编辑范围**(完整规则见各 README「可编辑范围 / 红线速查」):
> 架构骨架(前端分层 / 服务端运作模型 / 工程七大总则)、成败协议、子游戏接入两模式等,均在下方 `@import` 常驻的三份 README「一页纸」与对应编号文档里,本文件不复述。此处只留改动前最需在手边的一张表(完整规则见各 README「红线速查」):
| 路径 | 规则 |
|---|---|
@@ -53,19 +56,13 @@ client/scripts/build_spine_data.cmd # 内部调用 build_spine_data.ps1
三者互补不冲突:engineering 讲「该怎么设计、怎么长久演进」,dev-guide 讲「平台怎么接、红线是什么」;硬红线冲突时以 dev-guide 为准。
以下三份 README(含各自「红线速查 / 一页纸总则」)通过 `@import` **自动加载进每次会话的上下文**——红线以它们为权威,无需在本文件另抄一份;改动具体区域时再深入读对应编号文档:
以下三份「红线速查 / 一页纸总则」通过 `@import` **自动加载进每次会话的上下文**——红线以它们为权威,无需在本文件另抄一份;改动具体区域时再深入读对应编号文档:
@docs/client/development-guide/README.md
@docs/server/development-guide/README.md
@docs/games/engineering/README.md
@docs/client/development-guide/红线速查.md
@docs/server/development-guide/红线速查.md
@docs/games/engineering/一页纸总则.md
各编号对应主题(改哪块查哪篇):
| 文档 | 编号与主题 |
|---|---|
| `client/development-guide/` | 01 前端架构与运行环境 · 02 渲染与UI组件体系 · 03 事件·动画·音频·Spine · 04 网络对接与启动编排 · 05 开发规范与红线 · 06 子游戏接入模式与Hooks外置 |
| `server/development-guide/` | 01 服务端环境与框架基础 · 02 子游戏接入与开发流程 · 03 数据收发与通信协议 · 04 开发规范与红线 |
| `games/engineering/` | 01 架构总则与分层 · 02 可扩展性与配置化 · 03 数据权威·错误处理·演进 |
各套文档的「编号 ↔ 主题」导航表、面向人的阅读顺序与定位说明留在各自 README(`docs/client/development-guide/README.md`、`docs/server/development-guide/README.md`、`docs/games/engineering/README.md`)——那些是查阅时才需要的内容,不常驻上下文,需要时直接读。
## 测试与 Git
+236 -116
View File
@@ -1,145 +1,265 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-Control" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover" />
<title>erqiwang</title>
<style>
body {
padding-top: constant(safe-area-inset-top);
padding-left: constant(safe-area-inset-left);
<meta charset="utf-8">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-Control" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover"/>
<title>jxmahjong</title>
<style>
body {
padding-top: constant(safe-area-inset-top);
padding-left: constant(safe-area-inset-left);
padding-right: constant(safe-area-inset-right);
padding-bottom: constant(safe-area-inset-bottom);
}
</style>
}
</style>
</head>
<body style='position:fixed;bottom:0;'>
<div id="ifastgame2" style="position:absolute;left:0px;top:0px;z-index:10">
<canvas id="bg1" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
<div id="ifastgame2" style="position:absolute;left:0px;top:0px;z-index:10">
<canvas id="bg1" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
</div>
<div id="ifastgame_4" style="position:absolute;left:0px;top:0px;z-index:1">
<canvas id="ifastgame_bg" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
<div id="ifastgame_4" style="position:absolute;left:0px;top:0px;z-index:1">
<canvas id="ifastgame_bg" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
</div>
<div id="ifastgame3" style="position:absolute;left:0px;top:0px;z-index:11">
<canvas id="bg2" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
<div id="ifastgame3" style="position:absolute;left:0px;top:0px;z-index:11">
<canvas id="bg2" width="1" height="1">
<p>Your browser does not support the canvas element.</p>
</canvas>
</div>
<div id="ifastgame" style="position:absolute;left:0px;top:0px;z-index:2">
<canvas id="canvas" width="6000" height="3000">
<p>Your browser does not support the canvas element.</p>
</canvas>
<canvas id="canvas" width="6000" height="3000">
<p>Your browser does not support the canvas element.</p>
</canvas>
</div>
<!-- Spine WebGL 叠加层:z-index 3 夹在主画布(2)与 bg1(10)之间,
精确复刻"Spine 画在主画布内部、被 bg1/bg2 遮挡"的既有层级关系。
pointer-events:none 保证不抢主画布的触摸输入。-->
<div id="ifastgame_spine" style="position:absolute;left:0px;top:0px;z-index:3;pointer-events:none">
<canvas id="spine_gl" width="1" height="1"></canvas>
</div>
<style type="text/css">
* { margin: 0; padding: 0; }
html, body { height: 100%; width: 100%;
overflow-x:hidden;overflow-y:hidden;
backgroundColor:"rab(255,0,0)";
}
canvas { display: block; }
</style>
<script>
<style type="text/css">
* {
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: hidden;
backgroundColor: "rab(255,0,0)";
}
canvas {
display: block;
}
</style>
<script>
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function () { document.documentElement.removeChild(WVJBIframe) }, 0)
}
setupWebViewJavascriptBridge(function (bridge) { });
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}
setupWebViewJavascriptBridge(function(bridge){});
</script>
<script src="http://pv.sohu.com/cityjson?ie=utf-8"></script>
<!-- <script>
returnCitySN={
ip:'127.1.1.1',
province:'',
city:''
};
</script> -->
<script src="http://47.98.203.17:8085/cityjson" ></script>
<script type="text/javascript" src="js/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="js/jweixin-1.2.0.js"></script>
<script language='javascript'>var gameabc_face = gameabc_face || {};</script>
<script language='javascript'>gameabc_face.path = "assets/bmp";</script>
<!-- Spine Canvas Runtime -->
<script type="text/javascript" src="js/vendor/spine-canvas.js"></script>
<script language='javascript'>var gameabc_face = gameabc_face||{};</script>
<script language='javascript'>gameabc_face.path="assets/bmp";</script>
<!-- Spine 运行时按需加载。
spine-canvas.js 与 spine-webgl.js 都以 var spine = (() => {...})() 暴露
同一个全局名 spine,同时加载会互相覆盖,故只能二选一。
强制切换(验证用):在本段之前设 window.__spineForceBackend = 'canvas' 或 'webgl'。-->
<script type="text/javascript">
(function(){
var forced = window.__spineForceBackend;
var useWebGL;
var reason;
if (forced === "webgl" || forced === "canvas") {
useWebGL = (forced === "webgl");
reason = "被 window.__spineForceBackend 强制指定为 " + forced;
} else if (location.protocol === "file:") {
// file:// 下 <img> 会被标记 tainted,texImage2D 拒绝上传
// (Canvas2D 的 drawImage 则允许,故 canvas 后端仍可工作)。
// 项目文档要求 HTTP 环境调试;此处兜住误用 file:// 的情形,
// 回退 canvas 保证特效可见,而不是抛一片 DOMException。
useWebGL = false;
reason = "file:// 协议下 WebGL 无法上传纹理,强制回退 canvas";
} else {
useWebGL = false;
reason = "WebGL 探测失败(getContext 返回空)";
try {
var probe = document.createElement("canvas");
var gl = probe.getContext("webgl") || probe.getContext("experimental-webgl");
if (gl) {
useWebGL = true;
reason = "WebGL 探测通过";
// 释放探测用 context:浏览器对并发 WebGL context 数量有限制,
// 泄漏会导致叠加层拿不到 context。
var lose = gl.getExtension("WEBGL_lose_context");
if (lose) lose.loseContext();
}
} catch (err) {
useWebGL = false;
reason = "WebGL 探测抛异常: " + err.message;
}
}
if (typeof console !== "undefined" && console.log) {
console.log("[Spine] 运行时选择: " + (useWebGL ? "spine-webgl.js" : "spine-canvas.js") + "(" + reason + ")");
}
document.write('<script type="text/javascript" src="js/vendor/spine-'
+ (useWebGL ? "webgl" : "canvas") + '.js"><\/script>');
})();
</script>
<script type="text/javascript" src="js/vendor/gameabc.min.js"></script>
<!-- Spine 资源清单:列出需要预加载的 Spine 资源基础名 -->
<script type="text/javascript" src="generated/spine_assets.js"></script>
<!-- Spine 文本数据嵌入(解决 file:// 协议 CORS 问题) -->
<script type="text/javascript" src="generated/spine_data.js"></script>
<script type="text/javascript" src="app_data.js"></script>
<script type="text/javascript" src="app_battery.js"></script>
<script type="text/javascript" src="app_network.js"></script>
<script type="text/javascript" src="app_gamesname.js"></script>
<script type="text/javascript" src="js/gamemain.js"></script>
<script type="text/javascript" src="js/00_Surface/02_Const.js"></script>
<script type="text/javascript" src="js/00_Surface/04_Data.js"></script>
<script type="text/javascript" src="version.js"></script>
<script type="text/javascript" src="js/00_Surface/08_Utl_Output.js"></script>
<script type="text/javascript" src="js/00_Surface/07_Desk.js"></script>
<script type="text/javascript" src="js/00_Surface/05_Func.js"></script>
<script type="text/javascript" src="js/00_Surface/10_Game.js"></script>
<script type="text/javascript" src="js/00_Surface/11_GameUI.js"></script>
<script type="text/javascript" src="js/00_Surface/12_Logic.js"></script>
<script type="text/javascript" src="js/00_Surface/00_minhttp.js"></script>
<script type="text/javascript" src="js/00_Surface/09_Net.js"></script>
<script type="text/javascript" src="js/00_Surface/06_Player.js"></script>
<script type="text/javascript" src="js/00_Surface/03_Banwords.js"></script>
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
<script type="text/javascript" src="js/01_SubGame/02_SubGame_Input.js"></script>
<!----------------------------------------------------------------------------->
<!-- gameabc-framework:通用框架层(core → system → ui),需在 gamemain.js 之前加载 -->
<script type="text/javascript" src="js/gameabc-framework/core/GameABCUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/core/SpriteManager.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/EventBus.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/SpriteEventController.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/SpriteGestureRecognizer.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/AnimationManager.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/AudioManager.js"></script>
<script type="text/javascript" src="js/gameabc-framework/network/RpcHelper.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/AlignmentUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/BaseComponent.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/SpriteCopyUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/DynamicSpriteList.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/UIManager.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/RecordViewDefaultConfig.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/RecordView.js"></script>
<!-- Spine 动画管理器:须在 gameabc.min.js 之后、gamemain.js 之前加载 -->
<!-- Spine 渲染后端:必须在 SpineMgr.js 之前加载(SpineMgr 末尾会立即执行预加载块)-->
<script type="text/javascript" src="js/gameabc-framework/spine/SpineOverlayTransform.js"></script>
<script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendCanvas.js"></script>
<script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendWebGL.js"></script>
<!-- SpineMgr: 自动初始化+自动渲染,必须在 gamemain.js 之前加载 -->
<script type="text/javascript" src="js/gameabc-framework/spine/SpineMgr.js"></script>
<script type="text/javascript" src="output/gameabc_data.min.js"></script>
<script type="text/javascript" src="app_data.js"></script>
<script type="text/javascript" src="app_battery.js"></script>
<script type="text/javascript" src="app_network.js"></script>
<script type="text/javascript" src="app_gamesname.js"></script>
<script type="text/javascript" src="js/gamemain.js"></script>
<script type="text/javascript" src="js/00_Surface/02_Const.js"></script>
<script type="text/javascript" src="js/00_Surface/04_Data.js"></script>
<script type="text/javascript" src="version.js"></script>
<script type="text/javascript" src="js/00_Surface/08_Utl_Output.js"></script>
<script type="text/javascript" src="js/00_Surface/07_Desk.js"></script>
<script type="text/javascript" src="js/00_Surface/05_Func.js"></script>
<script type="text/javascript" src="js/00_Surface/10_Game.js"></script>
<script type="text/javascript" src="js/00_Surface/11_GameUI.js"></script>
<script type="text/javascript" src="js/00_Surface/12_Logic.js"></script>
<script type="text/javascript" src="js/00_Surface/00_minhttp.js"></script>
<script type="text/javascript" src="js/00_Surface/09_Net.js"></script>
<script type="text/javascript" src="js/00_Surface/06_Player.js"></script>
<script type="text/javascript" src="js/00_Surface/03_Banwords.js"></script>
<!-- 阶段0: 强制工具类(必须最先加载) -->
<script type="text/javascript" src="js/gameabc-framework/core/GameABCUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/network/RpcHelper.js"></script>
<script type="text/javascript" src="js/gameabc-framework/system/EventBus.js"></script>
<!-- 精灵事件控制器(框架 system):须早于任何使用精灵交互的组件/回放/JingMark 加载 -->
<script type="text/javascript" src="js/gameabc-framework/system/SpriteEventController.js"></script>
<!-- 精灵手势识别器(框架 system):双击/滑动/点击判定,建立在 SpriteEventController 之上 -->
<script type="text/javascript" src="js/gameabc-framework/system/SpriteGestureRecognizer.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/AlignmentUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/SpriteCopyUtils.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/DynamicSpriteList.js"></script>
<!-- UI组件 -->
<script type="text/javascript" src="js/gameabc-framework/core/SpriteManager.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/BaseComponent.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/UIManager.js"></script>
<!-- 通用战绩页组件(依赖 DynamicSpriteList/SpriteEventController/SpriteManager,均已在前加载) -->
<script type="text/javascript" src="js/gameabc-framework/ui/RecordViewDefaultConfig.js"></script>
<script type="text/javascript" src="js/gameabc-framework/ui/RecordView.js"></script>
<!-- 动画系统 (Phase 5) -->
<script type="text/javascript" src="js/gameabc-framework/system/AnimationManager.js"></script>
<!-- 音频系统 -->
<script type="text/javascript" src="js/gameabc-framework/system/AudioManager.js"></script>
<!-- 子游戏 Hooks 实现(转发壳三文件转发到此;须在依赖的 controllers/handlers 之后、三文件之前)-->
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
<script type="text/javascript" src="js/01_SubGame/02_SubGame_Input.js"></script>
<!----------------------------------------------------------------------------->
<script type="text/javascript" src="output/gameabc_data.min.js"></script>
</body>
</html>
</html>
+4 -2
View File
@@ -142,7 +142,7 @@ GameData.hallConfigName = ["enablePay",
'wareHouse',//是否开启仓库功能0->不开启1->开启保险箱不开启赠送2->开启赠送不开启保险箱3->开启星星赠送(开启保险箱)4->开启房卡赠送(开启保险箱)5->开启星星房卡赠送(开启保险箱)
'betaText',//版本号后的beta显示内容
'launchImgUrl',//启动页图片地址
'shareType:',//分享朋友圈类型0->链接分享 1->图片分享
'shareType',//分享朋友圈类型0->链接分享 1->图片分享
'shareImgUrl',//分享朋友圈图片链接
'launchWaitTime',//启动等待时间
'shareBgUrl',//分享背景图片
@@ -151,7 +151,8 @@ GameData.hallConfigName = ["enablePay",
'channelKey',//支付key
'payCSS',//支付css
'payJS',//支付js
'openVideo'
'openVideo',
'showVisitor'//是否显示游客登陆
];//是否开启视频];
GameData.hallConfig = {
enablePay:0,
@@ -185,6 +186,7 @@ GameData.hallConfig = {
payCSS:"",//支付css
payJS:"",//支付js
openVideo:0,//是否开启视频
showVisitor:0//是否显示游客登陆
};
GameData.starName = "星星";
GameData.roomCardName = "房卡";
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -17,6 +17,12 @@ function Player(seat){
//this.offline = 0;//玩家是否离线0->否 1->离线
this.canexit=1;//是否可以直接离开
this.onstate=0;//0:在线1:离线2:通话中
/*
{"address":"江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)","city":"南昌市",
"cityCode":"0791","country":"中国","district":"青云谱区","latitude":28.623546,"longitude":115.900333,
"province":"江西省","street":"施尧路"}
*/
//定位信息 牌桌外
this.addr = null;
this.invitecode = "";//邀请码
this.isStart = false;//能否点击按钮开始游戏
@@ -31,6 +37,14 @@ function Player(seat){
this.charm = undefined;
this.sign = "";//签名
this.tel = "";//绑定手机号
/*
{"address":"江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)","city":"南昌市",
"cityCode":"0791","country":"中国","district":"青云谱区","latitude":28.623546,"longitude":115.900333,
"province":"江西省","street":"施尧路"}
*/
//定位信息 牌桌内
this.location = null;
}
//玩家信息初始化
if(typeof(Player.prototype.Init) == "undefined"){
@@ -67,6 +81,7 @@ if(typeof(Player.prototype.Init) == "undefined"){
this.charm = undefined;
this.sign = "";//签名
this.tel = "";//绑定手机号
};
}
//玩家微信信息初始化
@@ -107,6 +122,7 @@ if(typeof(Player.prototype.SetMyInfo) == "undefined"){
this.charm = object.charm;
this.sign = object.sign;
this.tel = object.tel;
//this.setTel();
//this.bankpwd = object.bank;//是否设置仓库密码 this.bankpwd = 0;//是否设置仓库密码
//this.paycode = _paycode;
@@ -121,7 +137,7 @@ if(typeof(Player.prototype.SetLocationInfo) == "undefined"){
}else{
set_self(200, 7, "获取定位失败!", 0, 0);
}
this.location = _locationinfo;
};
}
@@ -279,6 +295,7 @@ if(typeof(Player.prototype.SetDeskInfo) == "undefined"){
}else{
this.paycode = "";
}
this.location = _data.location;
this.charm = _data.charm;
this.sign = _data.sign;
};
+2
View File
@@ -421,6 +421,8 @@ Desk.login=function(_msg){//登录
Func.createRoom();
}
Game_Modify.Reconnect(_msg.data.deskinfo);
}else{
Game_Modify.ReconnectNoMakewar();
}
//}
}else{
+36 -30
View File
@@ -545,43 +545,49 @@ Utl.openVideo = function (_playerList, _seat, _roomcode, roomtype) {
set_self(152, 37, 0, 0, 0);
set_self(153, 37, 0, 0, 0);
set_self(107, 37, 0, 0, 0);
for (var i = 0; i < Desk.PlayerList.length; i++) {
if (Desk.PlayerList[i].nickname != "") {
var ind = Logic.ChangeToStatus(C_Player.seat, i);
var text = Func.subString(Desk.PlayerList[i].nickname, Game_Config.Info.Mainnickname, true);
set_self(406 + ind, 7, text, 0, 0);
if (ind == 0) {
if (Game_Config.Info.myPositionDefault) {
set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
// if (Game_Modify.updatePlayerInfoUI) {
// for (var i = 0; i < Desk.PlayerList.length; i++) {
// Game_Modify.updatePlayerInfoUI(i);
// }
// } else {
for (var i = 0; i < Desk.PlayerList.length; i++) {
if (Desk.PlayerList[i].nickname != "") {
var ind = Logic.ChangeToStatus(C_Player.seat, i);
var text = Func.subString(Desk.PlayerList[i].nickname, Game_Config.Info.Mainnickname, true);
set_self(406 + ind, 7, text, 0, 0);
if (ind == 0) {
if (Game_Config.Info.myPositionDefault) {
set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
} else {
set_self(406 + ind, 18, Game_Config.Info.myPosition - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
}
} else {
set_self(406 + ind, 18, Game_Config.Info.myPosition - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
if (Game_Config.Info.otherPositionDefault) {
set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
} else {
set_self(406 + ind, 18, Game_Config.Info.position[ind - 1] - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
}
}
} else {
if (Game_Config.Info.otherPositionDefault) {
set_self(406 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
} else {
set_self(406 + ind, 18, Game_Config.Info.position[ind - 1] - text.gblen() * Game_Config.Info.textwidth_1 / 2, 0, 0);
}
}
set_self(436 + ind, 7, 0, 0, 0);
if (ind == 0) {
if (Game_Config.Info.myPositionDefault) {
set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0);
set_self(436 + ind, 7, 0, 0, 0);
if (ind == 0) {
if (Game_Config.Info.myPositionDefault) {
set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0);
} else {
set_self(436 + ind, 18, Game_Config.Info.myPosition - Game_Config.Info.textwidth_2 / 2, 0, 0);
}
} else {
set_self(436 + ind, 18, Game_Config.Info.myPosition - Game_Config.Info.textwidth_2 / 2, 0, 0);
}
} else {
if (Game_Config.Info.otherPositionDefault) {
set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0);
} else {
set_self(436 + ind, 18, Game_Config.Info.position[ind - 1] - Game_Config.Info.textwidth_2 / 2, 0, 0);
if (Game_Config.Info.otherPositionDefault) {
set_self(436 + ind, 18, get_self(376 + ind, 18, 0, 0, 0) + get_self(376 + ind, 20, 0, 0, 0) / 2 - Game_Config.Info.textwidth_2 / 2, 0, 0);
} else {
set_self(436 + ind, 18, Game_Config.Info.position[ind - 1] - Game_Config.Info.textwidth_2 / 2, 0, 0);
}
}
Func.up_imgurl(116 + ind, Desk.PlayerList[i].avatar);
set_group(43 + ind, 37, 1, 0, 0);
}
Func.up_imgurl(116 + ind, Desk.PlayerList[i].avatar);
set_group(43 + ind, 37, 1, 0, 0);
}
}
// }
play_ani123(1, 156, 35, 255, 100, 1000, 0, 0, 1);
Utl.playMusic();
set_self(3141, 37, 0, 0, 0);
+5 -2
View File
@@ -3315,7 +3315,7 @@ GameUI.JumpWxAuth = function() { //跳转微信授权
}
}
}
if (GameData.versionState == 1) { //审核版本
set_self(405, 37, 0, 0, 0);
//set_self(3,18,113,0,0);
@@ -3332,7 +3332,10 @@ GameUI.JumpWxAuth = function() { //跳转微信授权
//set_self(3,19,382,0,0);
//set_self(3,20,400,0,0);
//set_self(3,21,116,0,0);
if(GameData.hallConfig.showVisitor){
set_self(3,37,0,0,0);
set_self(177, 37, 1, 0, 0);
}
}
if (GameData.hallConfig.qqLogin) {
set_self(106, 37, 1, 0, 0);
+28 -27
View File
@@ -337,35 +337,35 @@ Logic.AppStart=function(){
// 解决:覆盖全局字体渲染函数,统一使用更粗的中文友好字体
// 保存原始的 Canvas fillText 方法
var originalFillText = CanvasRenderingContext2D.prototype.fillText;
var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText;
// var originalFillText = CanvasRenderingContext2D.prototype.fillText;
// var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText;
// 覆盖 fillText 方法,在绘制前统一字体设置
CanvasRenderingContext2D.prototype.fillText = function() {
// 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体
if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
// 提取字体大小
var sizeMatch = this.font.match(/(\d+)px/);
if (sizeMatch) {
var size = sizeMatch[1];
// 使用 500 字重(中等粗细)+ 中文友好字体
this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
}
}
return originalFillText.apply(this, arguments);
};
// // 覆盖 fillText 方法,在绘制前统一字体设置
// CanvasRenderingContext2D.prototype.fillText = function() {
// // 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体
// if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
// // 提取字体大小
// var sizeMatch = this.font.match(/(\d+)px/);
// if (sizeMatch) {
// var size = sizeMatch[1];
// // 使用 500 字重(中等粗细)+ 中文友好字体
// this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
// }
// }
// return originalFillText.apply(this, arguments);
// };
// 同样覆盖 strokeText 方法
CanvasRenderingContext2D.prototype.strokeText = function() {
if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
var sizeMatch = this.font.match(/(\d+)px/);
if (sizeMatch) {
var size = sizeMatch[1];
this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
}
}
return originalStrokeText.apply(this, arguments);
};
// // 同样覆盖 strokeText 方法
// CanvasRenderingContext2D.prototype.strokeText = function() {
// if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
// var sizeMatch = this.font.match(/(\d+)px/);
// if (sizeMatch) {
// var size = sizeMatch[1];
// this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
// }
// }
// return originalStrokeText.apply(this, arguments);
// };
// console.log('[字体优化] Canvas 文字渲染已优化,中英文粗细统一');
// ============================================================================
@@ -1796,6 +1796,7 @@ Logic.setConfigInfo = function(){
}
for(var i=0;i<GameData.hallConfigName.length;i++){
var val = get_paravalue(GameData.serverConfig,GameData.hallConfigName[i]);
// console.log(GameData.hallConfigName[i] + val);
if(val != null){
GameData.hallConfig[GameData.hallConfigName[i]] = val;
}
+5 -5
View File
@@ -13,8 +13,8 @@ Game_Config.Debugger={//调试配置
visitorLogin : true,//隐藏式游客登录
visiblePay:true,//审核通过后是否显示支付按钮
serverType:1,//0->正式服务器 1->本地服务器 http://ylyxservice1.0791ts.cn/config/update_json.txt
gameserver:"https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt"+"?"+ifast_random(100000)
// gameserver:"https://tsgames.daoqi88.cn/config_test/test_update_jsonv2.txt"+"?"+ifast_random(100000)
// gameserver:"https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt"+"?"+ifast_random(100000)
gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000)
//gameserver:"https://gameotherwork.ld2sw.cn/update_json/update_json_haiwai.txt"+"?"+ifast_random(100000)
// gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000)
@@ -46,13 +46,13 @@ Game_Config.Info = {
myPosition:130,//自己信息对齐点(根据游戏界面自行修改)
position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始
//TextContent:["1","2","3","4","5","6","7"],//常用语内容
TextContent:["你好","2","3","4","5","6","7"],//常用语内容
TextContent:["不要吵了不要吵了,专心玩游戏吧。","不要走,决战到天亮!","大家好,很高兴见到各位。","各位真是不好意思,我要离开一会儿。","快点吧,我等的花儿都谢了!","再见了,我会想念大家的。","怎么又断线了,网络怎么这么差啊!"],//常用语内容
TextContentMp3:["","","","","","",""],//常用语对应音效
};
Game_Config.Share={//分享参数
appdownload:"",//下载链接(无需配置,从服务器获取)
title:"Test",//(分享标题)
description:"hello world",//分享描述
title:"进贤麻将",//(分享标题)
description:"欢乐无限",//分享描述
gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里
gameDescription:""//游戏中分享描述
};
+5
View File
@@ -93,6 +93,11 @@ Game_Modify.Reconnect = function (_deskinfo) {
if (window.SubGameHooks && SubGameHooks.Reconnect) return SubGameHooks.Reconnect(_deskinfo);
};
// A 类:未开战重连,无 hook 即 no-op
Game_Modify.ReconnectNoMakewar = function () {
if (window.SubGameHooks && SubGameHooks.ReconnectNoMakewar) return SubGameHooks.ReconnectNoMakewar();
};
// A 类:关闭所有声音,无 hook 即 no-op
Game_Modify.stopAllSounds = function () {
if (window.SubGameHooks && SubGameHooks.stopAllSounds) return SubGameHooks.stopAllSounds();
+1
View File
@@ -0,0 +1 @@
//精灵事件单元...
+38 -21
View File
@@ -2,7 +2,10 @@
> 本手册针对 **gameabc 引擎** 的 Spine 项目,说明如何在 Canvas 2D 游戏中加载、
> 控制和管理 Spine 骨骼动画。
> 运行时版本:**spine-canvas 4.2** | 引擎:**gameabc**
> 运行时版本:**Spine 4.2**,`spine-webgl` 与 `spine-canvas` 二选一 —— `index.html`
> 会先探测 WebGL 能力,只加载其中一套(两者暴露同一个全局名 `spine`,同时加载会互相覆盖);
> WebGL 可用时走叠加层渲染,否则回落 Canvas 2D。上层调用方式两者完全一致。
> 引擎:**gameabc**
---
@@ -27,7 +30,8 @@
Projects/Spine/
├── index.html ← 入口 HTML
├── js/
│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库)
│ ├── spine-webgl.js ← Spine WebGL 运行时 (第三方库,探测后二选一)
│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库,探测后二选一)
│ ├── gameabc.min.js ← gameabc 游戏引擎
│ ├── SpineMgr.js ← ★ Spine 动画管理器(独立文件,自动挂钩渲染)
│ ├── gamemain.js ← 游戏主逻辑(无需修改)
@@ -48,7 +52,7 @@ Projects/Spine/
| 文件 | 作用 | 需要修改 |
|------|------|----------|
| `js/spine-canvas.js` | Spine 4.2 Canvas 渲染运行时 | ✗ 不要修改 |
| `js/spine-webgl.js` / `js/spine-canvas.js` | Spine 4.2 运行时,`index.html` 探测后**只加载一套** | ✗ 不要修改 |
| `js/SpineMgr.js` | Spine 动画管理器,自动初始化+自动渲染 | ✗ 不需要修改 |
| `js/gamemain.js` | 游戏主逻辑(保持原样) | ✗ 不需要修改 |
| `js/Spine_Event.js` | Spine 动画完成/自定义事件回调 | ✓ 处理动画事件 |
@@ -593,15 +597,19 @@ gameabc_face.mouseup = function(gameid, spid_down, downx, downy, spid_up, upx, u
gameabc_face.spineMgr.setAnimation("hero", "idle", true);
};
// ⚠️ 不要直读 spineMgr._entries[...]:entry 表是 SpineMgr 的内部状态,
// 调用方自己维护业务坐标,并用 ensureEntry 保证 entry 存在即可。
var heroX = 640, heroY = 360;
gameabc_face.mousemove = function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
// 通过拖拽移动角色
var mgr = gameabc_face.spineMgr;
var entry = mgr._entries["hero"];
if (entry) {
mgr.setPosition("hero", entry.x + offmovex, entry.y + offmovey);
// 根据移动方向翻转
mgr.setFlip("hero", offmovex < 0, false);
}
mgr.ensureEntry("hero", "hero.json", "hero.atlas", { scale: 1 });
heroX += offmovex;
heroY += offmovey;
mgr.setPosition("hero", heroX, heroY);
// 根据移动方向翻转
mgr.setFlip("hero", offmovex < 0, false);
};
```
@@ -742,7 +750,7 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
3. **打开浏览器控制台(F12)看报错**
- 404 错误:文件路径有误
- JSON 解析错误:`.json` 文件格式异常
- `spine is not defined`:`spine-canvas.js` 未正确加载
- `spine is not defined`:`spine-webgl.js` / `spine-canvas.js` 均未加载(探测脚本未执行或路径错)
4. **坐标是否在可见范围内?**
- 项目设计尺寸为 1280×720,检查 `x` 和 `y` 是否在此范围
@@ -762,11 +770,17 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
- 检查 `gameabc_Project` 中的 `fps` 设置(默认 30)
- SpineMgr 内部是用 `Date.now()` 计算真实时间差的,不依赖帧率
- 如果需要倍速播放,修改 `state.timeScale`:
- 如果需要倍速播放,用 `setAnimation` 的返回值(Spine 的 TrackEntry)改 `timeScale`:
```javascript
var entry = gameabc_face.spineMgr._entries["hero"];
entry.state.timeScale = 2.0; // 2 倍速
var mgr = gameabc_face.spineMgr;
mgr.ensureEntry("hero", "hero.json", "hero.atlas", { scale: 1 });
var track = mgr.setAnimation("hero", "run", true);
if (track) { track.timeScale = 2.0; } // 2 倍速
// 资源尚未就绪时 setAnimation 会把命令排队并返回 null,下一帧就绪后自动执行
```
⚠️ 不要直读 `spineMgr._entries[...]` 拿 `state` —— entry 表是 SpineMgr 的内部状态,
其生命周期(何时 load / 何时因 scale 变化重建)由 SpineMgr 独占管理,
外部持有的引用随时可能失效(如 WebGL context 恢复后整表重建)。
### Q4: 切换动画时有跳帧
@@ -778,9 +792,9 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
- 确认没有同一个 id 加载两次
- 检查 `ctx.save()` / `ctx.restore()` 是否配对(SpineMgr 内部已处理)
### Q6: spine-canvas.js 版本与 Spine 编辑器版本不匹配
### Q6: Spine 运行时版本与 Spine 编辑器版本不匹配
- **spine-canvas.js 4.2** 需搭配 **Spine 编辑器 4.2.x** 导出的数据
- **spine-webgl / spine-canvas 4.2** 需搭配 **Spine 编辑器 4.2.x** 导出的数据
- 如果使用 Spine 4.1 编辑器,请下载对应版本的运行时:
```
https://unpkg.com/@esotericsoftware/spine-canvas@4.1/dist/iife/spine-canvas.js
@@ -793,13 +807,16 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
`index.html` 中的 script 标签加载顺序至关重要:
```
1. spine-canvas.js ← 先加载 Spine 运行时 (定义 window.spine)
1. spine-webgl.js 或 spine-canvas.js
← 先加载 Spine 运行时 (定义 window.spine),探测后只加载一套
2. gameabc.min.js ← 再加载游戏引擎
3. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染
4. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可)
5. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face)
6. Project1_Event.js ← 精灵事件
7. gameabc_data.min.js ← 项目配置数据 (引擎初始化)
3. SpineOverlayTransform.js / SpineBackendCanvas.js / SpineBackendWebGL.js
← 渲染后端,必须在 SpineMgr.js 之前(SpineMgr 末尾会立即执行预加载块)
4. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染
5. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可)
6. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face)
7. Project1_Event.js ← 精灵事件
8. gameabc_data.min.js ← 项目配置数据 (引擎初始化)
```
> **不能调换顺序**,`SpineMgr.js` 必须在 `gamemain.js` 之前加载,
@@ -832,7 +832,7 @@ var GameABCUtils = (function() {
};
// console.log('📐 注册裁剪区域: 容器=' + containerId +
// ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')');
// ', 区域=(' + x + ',' + y + ',' + w + ',' + h + ')');
return true;
},
@@ -283,7 +283,7 @@ var SpriteManager = (function() {
return true;
}
console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)');
// console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)');
// 检查依赖
if (typeof GameABCUtils === 'undefined') {
@@ -292,8 +292,8 @@ var SpriteManager = (function() {
}
_isInitialized = true;
console.log('✅ SpriteManager: 初始化完成');
console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性');
// console.log('✅ SpriteManager: 初始化完成');
// console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性');
return true;
},
@@ -95,21 +95,21 @@ var RpcHelper = {
}
// 5. 调试信息输出
if (debug || Game_Config.Debugger.isDebugger) {
console.log('RpcHelper发送RPC:', {
app: app,
route: route,
rpc: rpc,
platform_data: {
agentid: data.agentid,
gameid: data.gameid,
playerid: data.playerid,
roomcode: data.roomcode
},
game_data: gameData,
custom_data: customData
});
}
// if (debug || Game_Config.Debugger.isDebugger) {
// console.log('RpcHelper发送RPC:', {
// app: app,
// route: route,
// rpc: rpc,
// platform_data: {
// agentid: data.agentid,
// gameid: data.gameid,
// playerid: data.playerid,
// roomcode: data.roomcode
// },
// game_data: gameData,
// custom_data: customData
// });
// }
// 6. 通过框架发送RPC请求
Utl.sendData(app, route, rpc, data);
@@ -0,0 +1,69 @@
// ============================================================
// SpineBackendCanvas —— Canvas 2D 渲染后端
// 由 SpineMgr 在 WebGL 不可用时使用。逻辑迁自 SpineMgr.updateAndDraw,行为不变。
//
// 已知性能特征:开启 triangleRendering 后,spine-canvas 对每个三角形都执行一次
// clip() 加一次整张图集的 drawImage。mesh 密集的骨架(如 qixingshisanlan 2784
// 三角形)在 iOS WKWebView 上会因 clip 叠加非轴对齐变换失去 GPU 快路径而卡顿。
// 故本后端仅作回退,主路径见 SpineBackendWebGL。
// ============================================================
(function(){
var SpineBackendCanvas = {
name: "canvas",
_renderer: null,
_ctx: null,
// 是否可用:spine 运行时须提供 Canvas 2D 的 SkeletonRenderer
isAvailable: function() {
return !!(window.spine && window.spine.SkeletonRenderer && !window.spine.SceneRenderer);
},
createAssetManager: function(basePath) {
return new spine.AssetManager(basePath);
},
// Canvas 后端:骨骼位置归零,实际位置由 drawEntry 的 ctx.translate 控制。
// 由 SpineMgr 在 updateWorldTransform 之前调用 —— 之后再改 skeleton.x/y 不会重算
// 世界变换,本帧不生效。
placeEntry: function(entry) {
entry.skeleton.x = 0;
entry.skeleton.y = 0;
},
// 每帧开始:取 gameabc 的绘制上下文
beginFrame: function() {
var ctx = gameabc_face.dc;
if (!ctx) return false;
this._ctx = ctx;
if (!this._renderer) {
this._renderer = new spine.SkeletonRenderer(ctx);
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件
}
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
this._renderer.ctx = ctx;
return true;
},
drawEntry: function(entry) {
var ctx = this._ctx;
ctx.save();
ctx.translate(entry.x, entry.y);
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale)
this._renderer.draw(entry.skeleton);
ctx.restore();
},
endFrame: function() {
this._ctx = null;
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineBackendCanvas;
}
window.SpineBackendCanvas = SpineBackendCanvas;
})();
@@ -0,0 +1,315 @@
// ============================================================
// SpineBackendWebGL —— WebGL 渲染后端(主路径)
//
// 在主画布之上叠加一张透明 canvas 绘制 Spine。相比 Canvas 2D 后端,mesh 密集的
// 骨架从"每三角形一次 clip + 整图 drawImage"变为一次 draw call。
//
// 坐标标定:不逆向混淆的 gameabc.min.js,而是每帧从 ctx.getTransform() 读取
// 权威变换矩阵,交由 SpineOverlayTransform 换算成 MVP。
//
// 真机实测确认的完整链路(gameabc_face.dc 属于一张离屏画布,并非 DOM 中可见的
// #canvas,两者是完全不同的两个对象):
// Spine 坐标 --ctx.getTransform()--> 离屏画布像素(gameabc_face.dc.canvas,
// 实测 backing 1280x720,不在 DOM,getBoundingClientRect() 恒为 0)
// --gameabc 内部 blit--> 显示画布 #canvas(DOM 中可见,实测 backing 1912x880)
// --CSS--> 屏幕(实测 rect 956x440)
//
// 因此本文件区分两个几何来源,各司其职、不可合并成一个:
// - NDC 分母(buildMvp 的 canvasW/canvasH):取 gameabc_face.dc.canvas 的
// width/height —— Spine 坐标是喂给 dc 的,分母必须与其同源。
// - 叠加层的定位/CSS 尺寸/drawing buffer:取显示画布 #canvas 的
// getBoundingClientRect() 与 width/height(backing) —— 叠加层要盖在屏幕上
// 可见的画面上,且 drawing buffer 尺寸须与主画布有效分辨率一致。
// 历史教训:曾把两者当成同一个对象 —— 用 #canvas 的尺寸做 NDC 分母则缩放错
// (X/Y 倍数不同、宽高比歪);后又改成全部从 dc.canvas 取,结果叠加层去对齐
// 离屏画布拿到的 rect(0,0,0,0),_syncSize 每帧提前 return,特效完全不显示。
// ============================================================
(function(){
// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现),
// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。
function spineLog(msg) {
if (typeof console !== "undefined" && console.log) {
console.log(msg);
}
}
function spineWarn(msg) {
if (typeof console !== "undefined" && console.warn) {
console.warn(msg);
}
}
var SpineBackendWebGL = {
name: "webgl",
_canvas: null,
_gl: null,
_context: null, // spine.ManagedWebGLRenderingContext
_shader: null,
_batcher: null,
_renderer: null,
_mvp: null, // 复用的 16 元素数组,避免每帧分配
_loggedCanvasIdentity: false, // 权威画布诊断日志只打一次,避免每帧刷屏
_contextLost: false,
_hadVisible: false, // 上一帧是否有可见 entry(用于空闲帧跳过)
_assetManager: null,
_lossHandlersBound: false, // context lost 监听只绑一次
// 是否可用:spine 运行时须是 webgl 版(提供 SceneRenderer 等 GL 专有类)
isAvailable: function() {
return !!(window.spine && window.spine.SceneRenderer && window.SpineOverlayTransform);
},
// (内部) 惰性初始化 GL 资源。失败返回 false,SpineMgr 侧不再绘制。
_ensureGL: function() {
if (this._gl) return true;
this._canvas = document.getElementById("spine_gl");
if (!this._canvas) {
spineWarn("[SpineBackendWebGL] 缺少 spine_gl 元素");
return false;
}
var gl = null;
try {
// alpha:true 让叠加层透出下方主画布;premultipliedAlpha 交由 spine 按图集 pma 标记处理
var opts = { alpha: true, premultipliedAlpha: true, antialias: true, depth: false, stencil: false };
gl = this._canvas.getContext("webgl", opts) || this._canvas.getContext("experimental-webgl", opts);
} catch (err) {
spineWarn("[SpineBackendWebGL] getContext 异常: " + err.message);
return false;
}
if (!gl) {
spineWarn("[SpineBackendWebGL] 无法取得 WebGL context");
return false;
}
this._gl = gl;
this._bindContextLossHandlers();
// context 恢复时复用同一个 ManagedWebGLRenderingContext:
// canvas.getContext() 恢复后返回的是同一个 WebGLRenderingContext 对象,
// 重新 new 会让新纹理注册到新 managed context,而 _assetManager 仍挂在旧的上,
// 造成新旧分叉。shader / batcher / renderer 持有失效的 GPU 资源,则必须重建。
if (!this._context) {
this._context = new spine.ManagedWebGLRenderingContext(gl);
}
// 复用 _context 的代价:Shader / Mesh 构造时都会 context.addRestorable(this),
// 只有 dispose() 才 removeRestorable。每次 context 恢复都会重走本段,
// 旧对象若不释放就永久堆在 restorables 里(旧 shader 程序 + batcher 顶点/索引缓冲),
// 恢复次数越多占用越大。context 已丢失时 gl.deleteShader/deleteBuffer 按规范是 no-op,
// 安全;仍加 try/catch 防御,释放失败不能挡住重建。
// (SkeletonRenderer 不持有 GL 资源、无 dispose,不需处理。)
if (this._shader) {
try {
this._shader.dispose();
} catch (errShader) {
spineWarn("[SpineBackendWebGL] 释放旧 shader 失败(已忽略): " + errShader.message);
}
this._shader = null;
}
if (this._batcher) {
try {
this._batcher.dispose();
} catch (errBatcher) {
spineWarn("[SpineBackendWebGL] 释放旧 batcher 失败(已忽略): " + errBatcher.message);
}
this._batcher = null;
}
this._shader = spine.Shader.newTwoColoredTextured(this._context);
this._batcher = new spine.PolygonBatcher(this._context);
// 中和 blend 模式:spine-canvas 的 drawTriangles 只跟踪 slot.data.blendMode 却从不应用
// (该文件全文无 globalCompositeOperation),即迁移前所有槽都是按 source-over 画的。
// spine-webgl 的 SkeletonRenderer 会真的调 batcher.setBlendMode(),在"清成透明黑"的
// 叠加层上,screen 槽会输出非预乘颜色导致过亮/白边,multiply 槽会因 srcRgb=DST_COLOR
// 而 RGB 恒 0 变成纯黑剪影(本项目 start 有 9 个 screen 槽,而 game_start 每局必播)。
// additive 在叠加层上恰好等价于 source-over,无差异。
// 本次迁移只求性能、不引入视觉变化,故在此中和,保持 begin() 设定的 Normal 混合
// (SkeletonRenderer.premultipliedAlpha 默认 false,与 PolygonBatcher 构造时的
// SRC_ALPHA / ONE_MINUS_SRC_ALPHA 默认值一致,即"未中和时 Normal 槽会设成的那一组")。
// 是否放开真实 blend,留作将来一次独立的、经美术确认的改动。
// ⚠️ 必须紧跟每一次 new PolygonBatcher —— context 恢复会重建 batcher,写在别处即失效。
this._batcher.setBlendMode = function() {};
this._renderer = new spine.SkeletonRenderer(this._context);
this._mvp = new Array(16);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
return true;
},
// (内部) WebGL context lost / restored。
// iOS 在内存压力或切后台返回时会真实触发;不处理的表现是"特效突然全部消失且不再恢复"。
// ⚠️ 只能绑一次:context restored 后 _gl 被置 null,_ensureGL 会重新走一遍,
// 若不加此闩,每恢复一次就多挂一对监听器,重复触发且泄漏。
_bindContextLossHandlers: function() {
if (this._lossHandlersBound) return;
this._lossHandlersBound = true;
var self = this;
this._canvas.addEventListener("webglcontextlost", function(ev){
ev.preventDefault(); // 必须 preventDefault,否则不会触发 restored
self._contextLost = true;
spineWarn("[SpineBackendWebGL] context lost");
}, false);
this._canvas.addEventListener("webglcontextrestored", function(){
spineLog("[SpineBackendWebGL] context restored,重建 GL 资源与纹理");
self._contextLost = false;
self._gl = null; // 迫使 _ensureGL 重建 shader/batcher/renderer
self._hadVisible = false; // 复位空闲帧状态机,避免恢复后多发一次 clear
if (self._onContextRestored) self._onContextRestored();
}, false);
},
// 由 SpineMgr 注入的纹理重建回调(Task 7 接入)
_onContextRestored: null,
createAssetManager: function(basePath) {
if (!this._ensureGL()) {
// GL 不可用时不静默降级成"看似成功",直接抛出让 SpineMgr.init 失败,
// 由 index.html 的探测脚本保证不会走到这里。
throw new Error("[SpineBackendWebGL] GL 初始化失败,无法创建 AssetManager");
}
this._assetManager = new spine.AssetManager(this._context, basePath);
return this._assetManager;
},
// WebGL 后端:位移并入骨骼,使所有 entry 共用同一 MVP,可批量绘制。
// y 取负是因为 MVP 中含 Spine Y-up → 画布 Y-down 的翻转(见 SpineOverlayTransform 推导)。
placeEntry: function(entry) {
entry.skeleton.x = entry.x;
entry.skeleton.y = -entry.y;
},
beginFrame: function(visibleCount) {
if (this._contextLost) return false;
if (!this._ensureGL()) return false;
// 绘制缓冲画布(NDC 分母来源):Spine 坐标是喂给 dc 的,分母必须与其同源,
// 只能每帧从 dc 派生(createAssetManager 阶段 dc 可能还不存在,故不能在
// _ensureGL 里固定取一次)。数据源唯一,取不到即报错跳帧,禁止 || 兜底。
var ctx = gameabc_face.dc;
if (!ctx) return false;
var drawCanvas = ctx.canvas;
if (!drawCanvas) {
spineWarn("[SpineBackendWebGL] gameabc_face.dc.canvas 不存在(非标准 CanvasRenderingContext2D 实现),本帧跳过");
return false;
}
// 显示画布(叠加层定位/CSS 尺寸/drawing buffer 来源):index.html 中
// <div id="ifastgame"><canvas id="canvas"> 是 gameabc 平台约定的固定
// 显示画布契约,并非按 id 猜——但仍须校验存在性/在 DOM/rect 有效,
// 任一项不满足都不能静默继续绘制。
var displayCanvas = (typeof document !== "undefined" && document.getElementById) ? document.getElementById("canvas") : null;
if (!displayCanvas) {
spineWarn("[SpineBackendWebGL] 找不到显示画布 document.getElementById(\"canvas\"),本帧跳过");
return false;
}
if (!displayCanvas.parentNode) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 不在 DOM 中,本帧跳过");
return false;
}
var displayRect = displayCanvas.getBoundingClientRect();
if (!displayRect || displayRect.width <= 0 || displayRect.height <= 0) {
spineWarn("[SpineBackendWebGL] 显示画布 #canvas 的 rect 尺寸无效,本帧跳过");
return false;
}
this._logCanvasIdentityOnce(drawCanvas, displayCanvas, displayRect);
// 空闲帧跳过:麻将大部分时间无特效在播,不能因叠加层存在就每帧空 clear 全屏。
// 仅在从"有"变"无"的那一帧 clear 一次收尾。
if (visibleCount === 0) {
if (!this._hadVisible) return false;
this._hadVisible = false;
this._syncSize(displayCanvas);
this._gl.clearColor(0, 0, 0, 0);
this._gl.clear(this._gl.COLOR_BUFFER_BIT);
return false;
}
this._hadVisible = true;
if (!this._syncSize(displayCanvas)) return false;
var m = this._readTransform(ctx);
SpineOverlayTransform.buildMvp(m, drawCanvas.width, drawCanvas.height, this._mvp);
var gl = this._gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
this._shader.bind();
this._shader.setUniformi(spine.Shader.SAMPLER, 0);
this._shader.setUniform4x4f(spine.Shader.MVP_MATRIX, this._mvp);
this._batcher.begin(this._shader);
return true;
},
// (内部) 读取 gameabc 绘制上下文的权威变换矩阵。
// getTransform() 自 iOS Safari 11 起支持,远低于本项目已验证的 WebView 基线;
// 取不到时退化为单位矩阵(此时叠加层按绘制缓冲画布 dc.canvas 的 backing 尺寸直接映射)。
_readTransform: function(ctx) {
if (typeof ctx.getTransform === "function") {
var t = ctx.getTransform();
return { a: t.a, b: t.b, c: t.c, d: t.d, e: t.e, f: t.f };
}
return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
},
// (内部) 一次性诊断日志:同时记录两个几何来源的身份与数值,便于后续核对
// ——绘制缓冲画布(dc.canvas,NDC 分母)与显示画布(#canvas,叠加层定位/尺寸)
// 是否为同一个对象、各自的 backing 与显示画布的 rect。
_logCanvasIdentityOnce: function(drawCanvas, displayCanvas, displayRect) {
if (this._loggedCanvasIdentity) return;
this._loggedCanvasIdentity = true;
var drawIdDesc = drawCanvas.id ? ("#" + drawCanvas.id) : "(无 id)";
spineLog("[SpineBackendWebGL] 绘制缓冲画布(gameabc_face.dc.canvas) = " + drawIdDesc +
",backing=" + drawCanvas.width + "x" + drawCanvas.height +
";显示画布(#canvas) backing=" + displayCanvas.width + "x" + displayCanvas.height +
",rect=(" + displayRect.left + "," + displayRect.top + "," + displayRect.width + "," + displayRect.height + ")" +
",两者是否同一对象=" + (drawCanvas === displayCanvas));
},
// (内部) 每帧把叠加层的位置与尺寸镜像到显示画布(#canvas)的实际显示区域。
// drawing buffer 尺寸直接取显示画布的 backing(width/height),不再用
// rect × devicePixelRatio 计算——与主画布有效分辨率一致即可,用 DPR 会开出
// 比主画布 backing 还大的 buffer,白白多占显存。
_syncSize: function(displayCanvas) {
var rect = displayCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
var host = this._canvas.parentNode;
host.style.left = rect.left + "px";
host.style.top = rect.top + "px";
this._canvas.style.width = rect.width + "px";
this._canvas.style.height = rect.height + "px";
var bw = displayCanvas.width;
var bh = displayCanvas.height;
if (bw <= 0 || bh <= 0) return false;
if (this._canvas.width !== bw || this._canvas.height !== bh) {
this._canvas.width = bw;
this._canvas.height = bh;
}
this._gl.viewport(0, 0, bw, bh);
return true;
},
drawEntry: function(entry) {
this._renderer.draw(this._batcher, entry.skeleton);
},
endFrame: function() {
this._batcher.end();
this._shader.unbind();
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineBackendWebGL;
}
window.SpineBackendWebGL = SpineBackendWebGL;
})();
+233 -45
View File
@@ -5,12 +5,25 @@
// ============================================================
(function(){
// gameabc 的全局 logmessage 默认走 mode 0,是空操作(见 gameabc.min.js 的实现),
// 诊断日志必须直接走 console 才能在浏览器与真机 WebView 里看到。
function spineLog(msg) {
if (typeof console !== "undefined" && console.log) {
console.log(msg);
}
}
function spineWarn(msg) {
if (typeof console !== "undefined" && console.warn) {
console.warn(msg);
}
}
var SpineMgr = {
// --- 内部状态 ---
_entries: {}, // {id: EntryObject}
_assetManager: null,
_renderer: null, // spine.SkeletonRenderer (Canvas 2D)
_backend: null, // 渲染后端(SpineBackendWebGL / SpineBackendCanvas)
_lastTime: 0, // 上一帧时间戳(ms)
_inited: false,
_loadedPaths: {}, // 已请求加载的资源路径,避免重复请求
@@ -27,15 +40,50 @@ var SpineMgr = {
// ----------------------------------------------------------
init: function(basePath) {
if (!window.spine) {
logmessage("[SpineMgr] spine-canvas.js 未加载,请检查引用");
spineWarn("[SpineMgr] spine 运行时未加载,请检查 index.html 的探测脚本");
return;
}
this._backend = this._selectBackend();
if (!this._backend) {
spineWarn("[SpineMgr] 无可用渲染后端");
return;
}
spineLog("[SpineMgr] 渲染后端: " + this._backend.name);
this._basePath = basePath || this._basePath;
this._assetManager = new spine.AssetManager(this._basePath);
// 挂接 context lost 恢复回调(仅 WebGL 后端有此机制)
var self = this;
if (this._backend.name === "webgl") {
this._backend._onContextRestored = function() {
self._rebuildAfterContextRestored();
};
}
// 后端初始化失败必须显式记录并让 _inited 保持 false,但不能让异常向上抛:
// init 由文件末尾的预加载块经 _ensureInit() 调用,异常会中断整个 IIFE,
// 导致后面的 gameenddraw 拦截根本不安装 —— Spine 从此永不绘制且无迹可循。
// _inited 保持 false 时,各 API 走既有的 if (!this._inited) return 静默路径。
try {
this._assetManager = this._backend.createAssetManager(this._basePath);
} catch (err) {
spineWarn("[SpineMgr] 后端初始化失败: " + err.message);
this._backend = null;
return;
}
this._lastTime = Date.now();
this._inited = true;
},
// (内部) 选定渲染后端。index.html 的探测脚本已决定加载哪套 spine 运行时,
// 此处按运行时实际提供的能力对应上后端实现。
_selectBackend: function() {
if (window.SpineBackendWebGL && SpineBackendWebGL.isAvailable()) {
return SpineBackendWebGL;
}
if (window.SpineBackendCanvas && SpineBackendCanvas.isAvailable()) {
return SpineBackendCanvas;
}
return null;
},
// (内部) 确保已初始化
_ensureInit: function() {
if (!this._inited) {
@@ -43,6 +91,42 @@ var SpineMgr = {
}
},
// ----------------------------------------------------------
// (内部) _preloadAll()
// 按 gameabc_face.spineAssets 清单,把全部 Spine 资源注入当前 AssetManager 并发起加载。
// ⚠️ 必须整批注入,不能只补"当前已存在的 entry":
// 规避 file:// 下 XHR CORS 用的 rawDataUris 是 Downloader 实例私有的
// (AssetManagerBase 构造时默认 new Downloader()),换一个新 AssetManager
// 就等于换了一份空 rawDataUris。若只补已存在的 entry,context 恢复之后
// 首次触发的新特效(如天胡)会走真实 XHR 被 CORS 拦掉,_tryBuild 里 require()
// 抛异常 → ready 恒 false → 每帧重试且永不显示。
// 返回本次注册的资源组数。
// ----------------------------------------------------------
_preloadAll: function() {
var list = gameabc_face.spineAssets;
if (!list || list.length === 0) return 0;
var textData = gameabc_face.spineTextData || {};
for (var i = 0; i < list.length; i++) {
var name = list[i];
var jsonKey = name + ".json";
var atlasKey = name + ".atlas";
// 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取
if (textData[jsonKey]) {
this._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]);
}
if (textData[atlasKey]) {
this._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]);
}
this._assetManager.loadText(jsonKey);
this._loadedPaths[jsonKey] = true;
this._assetManager.loadTextureAtlas(atlasKey);
this._loadedPaths[atlasKey] = true;
}
spineLog("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
return list.length;
},
// (内部) 自动加载:根据 id 约定文件名 id.json / id.atlas
// 主流 SkeletonJson.scale 方案:从 _scaleMap 读取目标缩放,传入 load() 在解析时烘焙
_autoLoad: function(id) {
@@ -101,16 +185,45 @@ var SpineMgr = {
visible: true,
ready: false,
_hideOnComplete: false, // 播放完成后是否自动隐藏
_hideAfterCompletes: 0 // 剩余多少次 complete 后触发隐藏
_hideAfterCompletes: 0, // 剩余多少次 complete 后触发隐藏
_buildFailLogged: false // 构建失败日志只记一次(_tryBuild 每帧调用)
};
},
// ----------------------------------------------------------
// ensureEntry(id, jsonFile, atlasFile, option)
// 确保 id 对应的 entry 存在并达到 option 指定的状态。
// entry 生命周期归 SpineMgr 管,调用方无需掌握"不在就 load、在就 setScale"的内部规则,
// 也无需从 id 推导文件名(entryId 与资源文件名不一定同名,如 ting_0 -> ting.json)。
// ----------------------------------------------------------
ensureEntry: function(id, jsonFile, atlasFile, option) {
var e = this._entries[id];
if (!e) {
this.load(id, jsonFile, atlasFile, option);
return;
}
if (option && option.scale !== undefined) {
this.setScale(id, option.scale);
}
},
// ----------------------------------------------------------
// isReady() 后端是否已就绪、能真正播放 Spine
// 探测阶段的 canvas 兜底只覆盖"WebGL 不可用";若探测通过但 GL 初始化失败
// (叠加层 DOM 缺失 / 并发 context 耗尽 / shader 编译失败),此时 canvas 运行时
// 并未加载、回不去,调用方应据此走各自既有的帧动画降级路径,
// 而不是让 Spine 静默失效却以为播成功了。
// ----------------------------------------------------------
isReady: function() {
return !!this._inited;
},
// ----------------------------------------------------------
// (内部) 资源加载完成后实例化骨骼
// 同一 jsonFile+scale 的 SkeletonData 只解析一次,多个 entry 共享
// ----------------------------------------------------------
_buildEntry: function(entry) {
logmessage("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale);
spineLog("[SpineMgr] _buildEntry: " + entry._id + " skelJson.scale=" + entry.scale);
var cacheKey = entry.jsonFile + "_s" + entry.scale;
var skelData = this._skelDataCache[cacheKey];
if (!skelData) {
@@ -122,9 +235,9 @@ var SpineMgr = {
this._assetManager.require(entry.jsonFile)
);
this._skelDataCache[cacheKey] = skelData;
logmessage("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
spineLog("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
} else {
logmessage("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
spineLog("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
}
entry.skeleton = new spine.Skeleton(skelData);
@@ -179,10 +292,15 @@ var SpineMgr = {
e._id = id;
try {
this._buildEntry(e);
logmessage("[SpineMgr] " + id + " 构建完成");
spineLog("[SpineMgr] " + id + " 构建完成");
this._flushCmds(id);
} catch(err) {
logmessage("[SpineMgr] " + id + " 构建失败: " + err.message);
// 重试本身必须保留(资源可能只是还没到齐),但日志只记一次:
// 本方法每帧调用,持续失败时会以约 60 条/秒刷屏并淹没其他日志。
if (!e._buildFailLogged) {
e._buildFailLogged = true;
spineWarn("[SpineMgr] " + id + " 构建失败(仅记一次): " + err.message);
}
e.ready = false;
}
}
@@ -190,6 +308,79 @@ var SpineMgr = {
return true;
},
// ----------------------------------------------------------
// (内部) WebGL context 恢复后重建全部 GPU 资源
// context lost 会让 AssetManager 中缓存的 GLTexture 与 shader 一并作废,
// 必须重新加载资源并重建所有 entry,否则表现为"特效再也不显示"。
// ----------------------------------------------------------
_rebuildAfterContextRestored: function() {
spineLog("[SpineMgr] 重建 Spine 资源(context restored)");
// 记录当前 entry 的声明式状态,重建后按原样恢复
var saved = [];
for (var id in this._entries) {
var e = this._entries[id];
saved.push({
id: id,
jsonFile: e.jsonFile,
atlasFile: e.atlasFile,
x: e.x,
y: e.y,
scale: e.scale,
skin: e.skin,
mixDur: e.mixDur
});
}
// 丢弃全部缓存:SkeletonData 持有已作废的 GLTexture 引用,不能复用
this._entries = {};
this._pendingCmds = {};
this._skelDataCache = {};
this._loadedPaths = {};
// 先释放旧 AssetManager 再换新的:GLTexture 构造时会 context.addRestorable(this),
// 只有 dispose() 才 removeRestorable。本后端刻意复用同一个 ManagedWebGLRenderingContext
// (见 SpineBackendWebGL._ensureGL 注释),若不释放,每恢复一次就把 23 页图集的
// GLTexture 永久留在 restorables 里(解码位图约 137MB)—— 而 context lost 的诱因
// 正是内存压力,泄漏会把 lost/restore 变成越恢复越糟的死循环。
// context 已丢失时 gl.deleteTexture 等按规范是 no-op,安全;仍加 try/catch 防御,
// 释放失败不能挡住后面的重建。
if (this._assetManager) {
try {
this._assetManager.dispose();
} catch (errDispose) {
spineWarn("[SpineMgr] 释放旧 AssetManager 失败(已忽略): " + errDispose.message);
}
this._assetManager = null;
}
// 本方法在 webglcontextrestored 事件回调中执行,异常会逸出到事件循环,
// 故与 init 同样显式捕获:重建失败就停在不可用状态并留下日志。
try {
this._assetManager = this._backend.createAssetManager(this._basePath);
} catch (err) {
spineWarn("[SpineMgr] context 恢复后重建 AssetManager 失败: " + err.message);
this._inited = false;
return;
}
// 整批重注入 rawDataURI 并重新发起加载:新 AssetManager = 新 Downloader = 空 rawDataUris,
// 只补 saved 里的资源会让恢复后首次触发的新特效在 file:// 下被 CORS 拦死(见 _preloadAll 注释)。
// _preloadAll 会把全部资源写进 _loadedPaths,故下面按 saved 重建 entry 时
// load() 的 if (!this._loadedPaths[...]) 判断会正确跳过重复请求。
this._preloadAll();
for (var i = 0; i < saved.length; i++) {
var s = saved[i];
this.load(s.id, s.jsonFile, s.atlasFile, {
x: s.x, y: s.y, scale: s.scale, skin: s.skin, mixDuration: s.mixDur
});
// 重建后默认不可见:正在播的动画已随 context 丢失,
// 强行续播会出现半截动画,交由下一次业务触发重新播放。
this._entries[s.id].visible = false;
}
},
// ----------------------------------------------------------
// updateAndDraw(ctx) 每帧自动调用
// ctx: gameabc_face.dc (Canvas 2D Context)
@@ -203,29 +394,33 @@ var SpineMgr = {
this._lastTime = now;
if (dt <= 0 || dt > 0.5) dt = 1/30;
if (!this._renderer) {
this._renderer = new spine.SkeletonRenderer(ctx);
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件
}
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
this._renderer.ctx = ctx;
// 先推进动画状态:即使本帧无法绘制(后端未就绪 / context lost),
// 动画时间轴与 complete 事件也必须照常推进,否则回调链会卡死。
var visibleList = [];
for (var id in this._entries) {
var e = this._entries[id];
if (!e.ready || !e.visible) continue;
e.state.update(dt);
e.state.apply(e.skeleton);
// 骨骼位置归零,由 ctx.translate 控制实际位置
e.skeleton.x = 0;
e.skeleton.y = 0;
// 骨骼位置由后端决定:Canvas 后端归零(用 ctx.translate 定位),
// WebGL 后端写入 x/-y(位移并入骨骼,使所有 entry 共用同一 MVP)。
// 必须在 updateWorldTransform 之前设置,否则不生效于本帧。
this._backend.placeEntry(e);
e.skeleton.updateWorldTransform(spine.Physics.update);
visibleList.push(e);
}
ctx.save();
ctx.translate(e.x, e.y);
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale)
this._renderer.draw(e.skeleton);
ctx.restore();
if (!this._backend.beginFrame(visibleList.length)) return;
// endFrame 必须配对执行:WebGL 后端的 PolygonBatcher 只在 endFrame 里 end(),
// 若 drawEntry 抛出而漏了 end(),batcher.isDrawing 会永久停在 true,
// 下一帧 begin() 即抛 "PolygonBatch is already drawing" 并冒到 gameenddraw —— Spine 永久黑屏。
try {
for (var i = 0; i < visibleList.length; i++) {
this._backend.drawEntry(visibleList[i]);
}
} finally {
this._backend.endFrame();
}
},
@@ -281,10 +476,13 @@ var SpineMgr = {
return;
}
if (e.scale === sx) return; // 已是目标 scale,无需重建
// 目标 scale 与已烘焙 scale 不同 → 清除旧 entry,下次 _autoLoad 将以新 scale 重建
// 目标 scale 与已烘焙 scale 不同 → 清除旧 entry 并以新 scale 重建。
// 文件名必须取自 entry(load 时记录的权威值):entryId 与资源文件名不一定同名
// (如 entryId=ting_0 对应 ting.json),按 id 推导会去加载不存在的文件。
var savedX = e.x, savedY = e.y;
var savedJson = e.jsonFile, savedAtlas = e.atlasFile;
this.remove(id);
this.load(id, id + ".json", id + ".atlas", { scale: sx, x: savedX, y: savedY });
this.load(id, savedJson, savedAtlas, { scale: sx, x: savedX, y: savedY });
},
setFlip: function(id, flipX, flipY) {
@@ -423,26 +621,16 @@ gameabc_face.spineMgr = SpineMgr;
// 彻底避免 file:// 协议下 XHR CORS 拦截问题
if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) {
SpineMgr._ensureInit();
var list = gameabc_face.spineAssets;
var textData = gameabc_face.spineTextData || {};
for (var i = 0; i < list.length; i++) {
var name = list[i];
var jsonKey = name + ".json";
var atlasKey = name + ".atlas";
// 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取
if (textData[jsonKey]) {
SpineMgr._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]);
}
if (textData[atlasKey]) {
SpineMgr._assetManager.setRawDataURI(atlasKey, "data:," + textData[atlasKey]);
}
SpineMgr._assetManager.loadText(jsonKey);
SpineMgr._loadedPaths[jsonKey] = true;
SpineMgr._assetManager.loadTextureAtlas(atlasKey);
SpineMgr._loadedPaths[atlasKey] = true;
if (!SpineMgr._inited) {
// 后端未就绪:跳过预加载,但必须继续往下走完 gameenddraw 拦截的安装。
// 此处不能 return —— 预加载块在 IIFE 函数体内,return 会退出整个 IIFE,
// 反而导致拦截装不上,正是本守卫要避免的后果。
// 回归锁:codes/test/node/spineMgr.gameEndDrawGuard.test.js
spineWarn("[SpineMgr] 后端未就绪,跳过 Spine 资源预加载");
} else {
// 与 context 恢复后的重注入共用同一份实现,避免两处清单分叉
SpineMgr._preloadAll();
}
logmessage("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
}
// ★ 用 defineProperty 拦截 gameenddraw 赋值
@@ -0,0 +1,88 @@
// ============================================================
// SpineOverlayTransform —— WebGL 叠加层坐标标定(纯函数)
//
// 职责:把 gameabc 主画布的权威变换矩阵换算成 WebGL 的 MVP 矩阵。
// 无副作用、无 DOM 依赖,可独立单测。
//
// 为什么不逆向 gameabc.min.js:那是混淆代码(变量名全打乱),读不出它内部如何
// 映射坐标。而 Spine 绘制发生在 gameenddraw(gameabc 绘制流程末尾),此刻 ctx
// 上已带着 gameabc 设好的全局变换 —— 直接向 ctx 要矩阵即可,权威、且 gameabc
// 后续改缩放/响应旋转时自动跟随。
//
// ⚠️ 真机实测确认:Spine 实际画在 gameabc_face.dc.canvas 这张离屏画布上
// (backing 约 1280x720,不在 DOM),gameabc 再把它 blit 到 DOM 中可见的显示
// 画布 #canvas(backing 约 1912x880),最后经 CSS 缩放到屏幕。本函数的
// canvasW/canvasH 必须传绘制缓冲画布(dc.canvas)的 backing 尺寸,因为
// Spine 坐标正是喂给这张画布的,分母须与其同源;叠加层自身的定位/CSS 尺寸/
// drawing buffer 则是另一个来源(显示画布 #canvas),由调用方
// SpineBackendWebGL 负责,不在本函数职责内。
//
// 推导(Spine 世界坐标 Y-up,画布 Y-down):
// 位移由调用方经 skeleton.x = e.x; skeleton.y = -e.y 承担,本矩阵只处理
// scale(1,-1) 与投影,等价于 Canvas 后端的 ctx.translate(e.x,e.y)+ctx.scale(1,-1)。
//
// 1) Spine 世界 → 逻辑坐标: Lx = vx, Ly = -vy
// 2) 逻辑 → 画布像素: Px = a*Lx + c*Ly + e, Py = b*Lx + d*Ly + f
// 3) 画布像素 → NDC: x = 2*Px/W - 1, y = 1 - 2*Py/H
//
// 合并:
// x_ndc = (2a/W)*vx + (-2c/W)*vy + (2e/W - 1)
// y_ndc = (-2b/H)*vx + ( 2d/H)*vy + (1 - 2f/H)
//
// 注意:叠加层 drawing buffer 尺寸在推导中被约掉(gl.viewport 覆盖整个 buffer,
// NDC 自动铺满),故本矩阵只依赖 ctx 变换与绘制缓冲画布 backing 尺寸。因此所有
// entry 共用同一个 MVP,可一次 batcher.begin/end 批量绘制。
// ============================================================
(function(){
var SpineOverlayTransform = {
// ----------------------------------------------------------
// buildMvp(m, canvasW, canvasH, out)
// m : {a,b,c,d,e,f} —— ctx.getTransform() 的六元素
// canvasW : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 宽(实测约 1280)
// canvasH : 绘制缓冲画布(gameabc_face.dc.canvas)的 backing 高(实测约 720)
// out : 可选,长度 16 的复用数组(避免每帧分配)
// 返回列主序 4x4 矩阵
// ----------------------------------------------------------
buildMvp: function(m, canvasW, canvasH, out) {
var r = out || new Array(16);
var sx = 2 / canvasW;
var sy = 2 / canvasH;
// 第 0 列:vx 的系数
r[0] = sx * m.a;
r[1] = -sy * m.b;
r[2] = 0;
r[3] = 0;
// 第 1 列:vy 的系数
r[4] = -sx * m.c;
r[5] = sy * m.d;
r[6] = 0;
r[7] = 0;
// 第 2 列:z 保持
r[8] = 0;
r[9] = 0;
r[10] = 1;
r[11] = 0;
// 第 3 列:平移
r[12] = sx * m.e - 1;
r[13] = 1 - sy * m.f;
r[14] = 0;
r[15] = 1;
return r;
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = SpineOverlayTransform;
}
// 友乐运行时有 module 无 require 且共享全局作用域,须无条件暴露全局名
window.SpineOverlayTransform = SpineOverlayTransform;
})();
@@ -810,7 +810,7 @@ var AnimationManager = AnimationManager || {};
}
_initialized = true;
console.log('[AnimationManager] Initialized (v2.0.0 optimized)');
// console.log('[AnimationManager] Initialized (v2.0.0 optimized)');
};
/**
@@ -164,7 +164,7 @@ var AudioManager = AudioManager || {};
return;
}
_initialized = true;
console.log('[AudioManager] Initialized (v4.0.0)');
// console.log('[AudioManager] Initialized (v4.0.0)');
};
// ============================================================================
@@ -80,7 +80,7 @@ var EventBus = {
};
this._listeners[eventName].push(listener);
console.log('✅ EventBus: 订阅事件', eventName);
// console.log('✅ EventBus: 订阅事件', eventName);
// 返回取消订阅函数
var self = this;
@@ -121,7 +121,7 @@ var EventBus = {
// 如果没有指定callback,取消该事件的所有监听器
if (!callback) {
delete this._listeners[eventName];
console.log('✅ EventBus: 取消所有', eventName, '监听器');
// console.log('✅ EventBus: 取消所有', eventName, '监听器');
return true;
}
@@ -130,7 +130,7 @@ var EventBus = {
for (var i = listeners.length - 1; i >= 0; i--) {
if (listeners[i].callback === callback) {
listeners.splice(i, 1);
console.log('✅ EventBus: 取消', eventName, '监听器');
// console.log('✅ EventBus: 取消', eventName, '监听器');
return true;
}
}
@@ -152,7 +152,7 @@ var EventBus = {
var listeners = this._listeners[eventName].slice(); // 复制数组,避免迭代中修改
var count = 0;
console.log('📡 EventBus: 触发事件', eventName, data);
// console.log('📡 EventBus: 触发事件', eventName, data);
for (var i = 0; i < listeners.length; i++) {
try {
@@ -204,7 +204,7 @@ var EventBus = {
*/
clear: function() {
this._listeners = {};
console.log('✅ EventBus: 已清空所有监听器');
// console.log('✅ EventBus: 已清空所有监听器');
},
/**
@@ -214,7 +214,7 @@ var EventBus = {
clearEvent: function(eventName) {
if (this._listeners[eventName]) {
delete this._listeners[eventName];
console.log('✅ EventBus: 已清空', eventName, '的所有监听器');
// console.log('✅ EventBus: 已清空', eventName, '的所有监听器');
}
}
};
@@ -79,7 +79,7 @@ UIBootstrap.init = function () {
return true;
}
console.log('[UIBootstrap] 开始初始化 UI 系统...');
// console.log('[UIBootstrap] 开始初始化 UI 系统...');
try {
if (!this._checkDependencies()) { return false; }
@@ -102,7 +102,7 @@ UIBootstrap.init = function () {
this._registerViews();
this.initialized = true;
console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件');
// console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件');
return true;
} catch (err) {
console.error('[UIBootstrap] 初始化失败:', err);
@@ -1,36 +1,38 @@
// ============================================================================
// 00_SubGame_Config.template.js —— 平台配置项模板(结构由平台定义,子游戏填【值】)
// 这是配置文件(C 类,非转发壳)。复制后按本游戏界面/玩法调整各项值。
// 严格 ES5。
// 游戏配置 (Game Configuration)
// ============================================================================
var Game_Config = Game_Config || {};
var Game_Config = Game_Config||{};//相关配置
Game_Config.Debugger={//调试配置
Game_Config.Debugger={//调试配置
isDebugger : true,// debugger模式下会将所有收发的包输出到控制台(正式发布改为false)
AutoLogin : true,//debugger模式下是否需要记住登录状态自动登录(正式发布改为true)
isSubmitError : false,//是否需要服务器收集错误信息调试时可根据需要(正式发布改为true)
visitorLogin : true,//隐藏式游客登录
isSubmitError : false,//是否需要服务器收集错误信息调试时可根据需要(正式发布改为true)
visitorLogin : true,//隐藏式游客登录
visiblePay:true,//审核通过后是否显示支付按钮
serverType:1,//0->正式服务器 1->本地服务器 http://ylyxservice1.0791ts.cn/config/update_json.txt
gameserver:""//子游戏填自己的更新地址
};
gameserver:"https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt"+"?"+ifast_random(100000)
// gameserver:"https://tsgames.daoqi88.cn/config_test/test_update_jsonv2.txt"+"?"+ifast_random(100000)
//gameserver:"https://gameotherwork.ld2sw.cn/update_json/update_json_haiwai.txt"+"?"+ifast_random(100000)
// gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000)
};
Game_Config.Max = {
SumOfRoomtype:3,//创建时房间类型roomtype数组长度
SumOfRoomtype:3,//创建时房间类型roomtype数组长度
ShowChat:2000,//聊天停留时间
PlayerCnt:4,//房间最大人数(按房间最大人数设置)
PlayerCnt:4,//房间最大人数
group:500,//游戏最大群组号
showtime:10,//加载等待最少时间
reconnecttime:30000,//唤醒游戏间隔(超过间隔断线重连)
showtime:10,//加载等待最少时间
reconnecttime:30000,//唤醒游戏间隔(超过间隔断线重连)
Mainnickname:10,//主界面玩家昵称显示长度(字符长度)
Infonickname:16//个人信息显示长度(字符长度)
Infonickname:16//个人信息显示长度(字符长度)
};
Game_Config.Combat = {
height:80,//战绩页单行高度高度
up_y:190,//战绩页裁剪y坐标
height:80,//战绩页单行高度高度
up_y:190,//战绩页裁剪y坐标
bannerheight:60,//战绩横幅高度
bannery:80,//战绩横幅Y左边(相对48精灵)
btnbg:50
@@ -42,46 +44,46 @@ Game_Config.Info = {
otherPositionDefault:true,//其他主界面玩家信息(昵称、积分)是否采用默认对齐方式对齐居中点为头像中点
myPositionDefault:true,//其他主界面玩家信息(昵称、积分)是否采用默认对齐方式对齐居中点为头像中点
myPosition:130,//自己信息对齐点(根据游戏界面自行修改)
position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始
position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始
//TextContent:["1","2","3","4","5","6","7"],//常用语内容
TextContent:["你好","谢谢","再来","快点","不要","加油",":)"],//常用语内容
TextContentMp3:["","","","","","",""],//常用语对应音效
TextContent:["你好","2","3","4","5","6","7"],//常用语内容
TextContentMp3:["","","","","","",""],//常用语对应音效
};
Game_Config.Share={//分享参数
appdownload:"",//下载链接(无需配置,从服务器获取)
title:"游戏名",//(分享标题)
description:"",//分享描述
Game_Config.Share={//分享参数
appdownload:"",//下载链接(无需配置,从服务器获取)
title:"Test",//(分享标题)
description:"hello world",//分享描述
gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里
gameDescription:""//游戏中分享描述
gameDescription:""//游戏中分享描述
};
Game_Config.Chat={//游戏内聊天配置信息
LimitLength:40,//聊天最大长度(字节长度)
textwidth:12.5,//聊天显示文字的宽度(未改动聊天显示文字大小无需改动无需修改)
ChatDis:[30,12],//聊天气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔
isLeft:[1,0,0,1,1],//聊天气泡是否为以左边为基准线(坐标/基准按各游戏界面调整)
ChatLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//聊天气泡的基准点位置(注意是基准点位置!)(坐标/基准按各游戏界面调整)
isLeft:[1,0,0,1,1],//聊天气泡是否为以左边为基准线
ChatLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//聊天气泡的基准点位置(注意是基准点位置!)
};
Game_Config.Voice={//游戏内聊天配置信息
VoiceTime:600,//播放语音动画的时间一般情况无需无需修改
VoiceDis:[30,12],//语音气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔
isLeft:[1,0,0,1,1],//气泡是否为以左边为基准线(坐标/基准按各游戏界面调整)
VoiceLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//气泡的基准点位置(注意是基准点位置!)(坐标/基准按各游戏界面调整)
isLeft:[1,0,0,1,1],//气泡是否为以左边为基准线
VoiceLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//气泡的基准点位置(注意是基准点位置!)
};
Game_Config.Setting={
Ads1:"本游戏仅供娱乐休闲使用,\n道具及虚拟货币不可兑换\n现金或实物,不代表任何\n真实财富价值。",
//Ads2:"\n 或客服号:\n",
board:"",//从后台获取设置无效
Ads1:"本游戏仅为娱乐休\n闲使用,道具所有\n 游戏通用\n\n游戏问题请联系微\n 信公众号:\n",
//Ads2:"\n 或客服号:\n",
board:"",//从后台获取设置无效
//info:[" 客服信息","QQ:","",""],//客服QQ提醒
info:[" 客服信息","QQ:","手机:","微信:"],//客服QQ提醒
board_blength:24,//通知页面一行最大字节数(无需设置)
charge:"充值提示",//充值提示
info:[" 客服信息","QQ:","手机:","微信:"],//客服QQ提醒
board_blength:24,//通知页面一行最大字节数(无需设置)
charge:"关注友乐微信公众号充值",//充值提示
};
Game_Config.Protocol={
x:270,//协议图片初始x坐标
x:270,//协议图片初始x坐标
y:70,//协议图片初始y坐标
h:560,//协议图片显示高度
w:729//协议图片显示宽度
h:560,//协议图片显示高度
w:729//协议图片显示宽度
};
Game_Config.Help={//帮助
x:300,//帮助图片初始x坐标
@@ -89,27 +91,27 @@ Game_Config.Help={//帮助
w:729,//帮助图片显示高度
h:450//帮助图片显示宽度
};
Game_Config.Notice={//滚动公告
x1:0,//无需设置
x2:0,//无需设置
y:0,//无需设置
Game_Config.Notice={//滚动公告
x1:0,//无需设置
x2:0,//无需设置
y:0,//无需设置
h:0,//无需设置
//以上参数用来截取滚动公告的显示,截取位置是与滚动公告底大小位置一致
speed:0.1,//滚动公告的滚动速度
width:15//滚动公告的内容字体的字节长度(汉字是两个字节)
//以上参数用来截取滚动公告的显示,截取位置是与滚动公告底大小位置一致
speed:0.1,//滚动公告的滚动速度
width:15//滚动公告的内容字体的字节长度(汉字是两个字节)
};
Game_Config.Feedback = {//反馈配置
maxLen:250 //反馈内容最大长度
Game_Config.Feedback = {//反馈配置
maxLen:250 //反馈内容最大长度
};
Game_Config.shakeList ={//摇一摇事件的回调ID、需要添加时在此处添加回调事件写在Utl_Input.js中的Utl.shakeEvent()中
Game_Config.shakeList ={//摇一摇事件的回调ID、需要添加时在此处添加回调事件写在Utl_Input.js中的Utl.shakeEvent()中
nil:0,
startwar:1
};
Game_Config.soundList ={//声音资源名
MenuSceneMusic:"",//大厅界面背景音
MainSceneMusic:""//游戏主界面背景音
Game_Config.soundList ={//声音资源名
MenuSceneMusic:"",//大厅界面背景音
MainSceneMusic:""//游戏主界面背景音
};
Game_Config.ClickButton = {//需要设置点击音效的按钮(只有有弹窗的按钮设置此音效有效、按钮已经设置好、子游戏不允许修改)
Game_Config.ClickButton = {//需要设置点击音效的按钮(只有有弹窗的按钮设置此音效有效、按钮已经设置好、子游戏不允许修改)
src_1:"",//点击时播放的声音资源文件( 不需要播放则不填,下同)
src_2:""//弹窗时播放的音效
}
@@ -120,11 +122,28 @@ Game_Config.loginButton = {//登录按钮信息
x3:488,//审核版本游客登录按钮x
};
Game_Config.sysConfig = {
mainMenuButton:false,//是否进入主菜单界面隐藏四个按钮
mainScenePlayerInfo:false,//是否隐藏主界面玩家信息
mainSceneButton:false,//是否隐藏主界面按钮
shareRoom:false,//接收到星星场是否屏蔽平台框架显示
changeSeat:false,//是否屏蔽平台自动刷新换座后界面
hideNotice:false,//隐藏公告栏
vipInfinite:false,//是否为百人场
mainMenuButton:false,//是否进入主菜单界面隐藏四个按钮
mainScenePlayerInfo:false,//是否隐藏主界面玩家信息
mainSceneButton:false,//是否隐藏主界面按钮
shareRoom:false,//接收到星星场是否屏蔽平台框架显示
changeSeat:false,//是否屏蔽平台自动刷新换座后界面
hideNotice:false,//隐藏公告栏
vipInfinite:false,//是否为百人场
}
@@ -46,6 +46,52 @@ Game_Modify.CreateRoomData = {
Type_2: 0
};
// ---------- 玩家信息/头像默认布局(供 02 转发壳 updatePlayerInfoUI 的默认实现使用)----------
// 02_SubGame_Input.js 的 updatePlayerInfoUI 在【无 SubGameHooks 实现】时提供默认头像等 UI 渲染;
// 转发壳不得引用 codes,故把默认实现所需布局放在此处(Game_Modify 下),与子游戏 codes 内
// GameView.updatePlayerInfoUI 使用的布局逻辑保持一致。子游戏若自定义头像 UI,则用 hook 覆盖、此配置不生效。
// POSITIONS[configIndex]:ind0=南(自己)/ind1=东/ind2=北/ind3=西;渲染精灵 346+ind 底框、376+ind 头像框、
// 406+ind 昵称、436+ind 分数、116+ind 头像图、群组 43+ind。坐标基于设计分辨率 1280x720。
Game_Modify.PLAYER_INFO_LAYOUT = {
POSITIONS: {
0: { FRAME_X: 6, FRAME_Y: 592, FRAME_WIDTH: 90, FRAME_HEIGHT: 115,
AVATAR: { OFFSET_X: 5, OFFSET_Y: 5, WIDTH: 80, HEIGHT: 80,
NAME_OFFSET_X: -400, NAME_OFFSET_Y: 888, SCORE_OFFSET_X: 0, SCORE_OFFSET_Y: 88 },
NAME_CENTER_ALIGN: true },
1: { FRAME_X: 1186, FRAME_Y: 85, FRAME_WIDTH: 90, FRAME_HEIGHT: 115,
AVATAR: { OFFSET_X: 5, OFFSET_Y: 5, WIDTH: 80, HEIGHT: 80,
NAME_OFFSET_X: 905, NAME_OFFSET_Y: -888, SCORE_OFFSET_X: 0, SCORE_OFFSET_Y: 88 },
NAME_CENTER_ALIGN: true },
2: { FRAME_X: 1006, FRAME_Y: 5, FRAME_WIDTH: 90, FRAME_HEIGHT: 115,
AVATAR: { OFFSET_X: 5, OFFSET_Y: 5, WIDTH: 80, HEIGHT: 80,
NAME_OFFSET_X: 905, NAME_OFFSET_Y: -888, SCORE_OFFSET_X: 0, SCORE_OFFSET_Y: 88 },
NAME_CENTER_ALIGN: true },
3: { FRAME_X: 7, FRAME_Y: 59, FRAME_WIDTH: 90, FRAME_HEIGHT: 115,
AVATAR: { OFFSET_X: 5, OFFSET_Y: 5, WIDTH: 80, HEIGHT: 80,
NAME_OFFSET_X: -605, NAME_OFFSET_Y: -888, SCORE_OFFSET_X: 0, SCORE_OFFSET_Y: 88 },
NAME_CENTER_ALIGN: true }
},
TEXT_STYLE: { NAME_FONT_SIZE: 14, NAME_MAX_LENGTH: 6, SCORE_FONT_SIZE: 12, CHAR_WIDTH: 9 }
};
// ---------- 桌面聊天/语音气泡默认布局(供 02 转发壳 ShowChat/gameui_play_voice/gameui_stop_voice 使用)----------
// 平台默认按固定显示位序取 Game_Config.Chat/Voice 的固定坐标(固定人数模式);此处提供可变人数版:
// 按 updatePlayerInfoUI 的 configIndex(物理位) 定位,使气泡随人数(2/3/4)贴合各玩家。
// 仅定义每个物理位(0南/1东/2北/3西)的气泡框左上角锚点 X/Y 与朝向 IS_LEFT;气泡内边距/字宽/显示时长等
// 尺寸复用平台 Game_Config.Chat/Voice/Max(与人数无关)。精灵:聊天 背景552+ind/文字582+ind,
// 语音 背景612+ind/内容642+ind。IS_LEFT=true 气泡向右展开(用 IMG_LEFT)、false 向左展开(用 IMG_RIGHT)。
// 坐标基于 1280x720,锚点参考 PLAYER_INFO_LAYOUT 各位底框,可按实际界面微调。
Game_Modify.BUBBLE_LAYOUT = {
IMG_LEFT: 83, // 左向气泡背景图资源ID(气泡向右展开)
IMG_RIGHT: 86, // 右向气泡背景图资源ID(气泡向左展开)
POSITIONS: {
0: { X: 100, Y: 540, IS_LEFT: true }, // 南(自己)
1: { X: 1176, Y: 120, IS_LEFT: false }, // 东
2: { X: 970, Y: 20, IS_LEFT: false }, // 北
3: { X: 120, Y: 70, IS_LEFT: true } // 西
}
};
// ---------- 接口转发壳(A 类:无 hook 即 no-op) ----------
Game_Modify.utlmousedown = function (gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6) {
@@ -1,7 +1,7 @@
// ============================================================================
// 02_SubGame_Input.template.js —— 平台输入/回调接口「转发壳」模板(框架维护,子游戏不碰)
// 02_SubGame_Input.js —— 平台输入/回调接口「转发壳」(框架维护,子游戏不碰)
// 平台按全局名调用 gameHallImport.* / Game_Modify.*;本文件统一转发到 SubGameHooks 同名 hook。
// 子游戏只需在 codes/ 的 SubGameHooks 里实现需要的接口(见 SubGameHooks.template.js)。
// 子游戏只需在 codes/ 的 SubGameHooks 里实现需要的接口(见 codes/SubGameHooks.js)。
// 严格 ES5。接口清单与默认值依据 spec §9。
// ============================================================================
@@ -293,8 +293,210 @@ Game_Modify.getMaxPlayerCount = function (roomtype) {
return (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
};
// B 类:更新玩家信息 UI,无 hook 返回 false(让平台默认处理)
// 内部辅助(非平台接口):把相对显示位序 relPos 映射为 { ind:精灵槽, configIndex:物理布局位 }。
// 2/3 人时 ind≠configIndex;无效返回 null。updatePlayerInfoUI 与桌面气泡默认实现共用此映射,
// 保证头像面板与聊天/语音气泡落在同一玩家位。
Game_Modify._resolveDisplaySlot = function (relPos, playerCount) {
var ind = -1, configIndex = -1;
if (playerCount === 2) {
if (relPos === 0) { ind = 0; configIndex = 0; }
else if (relPos === 1) { ind = 1; configIndex = 2; }
} else if (playerCount === 3) {
if (relPos === 0) { ind = 0; configIndex = 0; }
else if (relPos === 1) { ind = 1; configIndex = 1; }
else if (relPos === 2) { ind = 2; configIndex = 3; }
} else {
if (relPos === 0) { ind = 0; configIndex = 0; }
else if (relPos === 1) { ind = 1; configIndex = 1; }
else if (relPos === 2) { ind = 2; configIndex = 2; }
else if (relPos === 3) { ind = 3; configIndex = 3; }
}
if (ind === -1) return null;
return { ind: ind, configIndex: configIndex };
};
// B 类:更新指定座位的玩家信息 UI(昵称/分数/头像)。
// 有 hook 则委托子游戏;无 hook 时由本模板提供默认头像等 UI 的显示逻辑,其逻辑与子游戏
// codes 内 GameView.updatePlayerInfoUI 保持一致,布局取自 Game_Modify.PLAYER_INFO_LAYOUT(见 01)。
// 精灵操作一律经框架 SpriteManager(setPosition/setSize/setText/show|hideGroup),不直接调
// set_self/set_group 等引擎原语;头像加载走平台 Func.up_imgurl(非精灵原语,SpriteManager 无对应能力)。
// 不引用任何 codes 模块。平台 GameUI.updatePlayerInfoUI 会对每个座位逐一调用本接口,故按单座位渲染。
Game_Modify.updatePlayerInfoUI = function (targetSeat) {
if (window.SubGameHooks && SubGameHooks.updatePlayerInfoUI) return SubGameHooks.updatePlayerInfoUI(targetSeat);
return false;
// ---- 模板默认:与 GameView.updatePlayerInfoUI 一致的单座位渲染 ----
if (typeof Desk === 'undefined' || typeof C_Player === 'undefined' || typeof SpriteManager === 'undefined') return false;
var layout = Game_Modify.PLAYER_INFO_LAYOUT;
if (!layout || !layout.POSITIONS) return false;
if (!Desk.PlayerList || targetSeat < 0 || targetSeat >= Desk.PlayerList.length) return false;
var selfSeat = (typeof C_Player.seat === 'number') ? C_Player.seat : 0;
var playerCount = (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
var relPos = (targetSeat - selfSeat + playerCount) % playerCount;
// 显示槽 ind 与布局位 configIndex(2/3 人时两者不同)
var slot = Game_Modify._resolveDisplaySlot(relPos, playerCount);
if (!slot) return false;
var ind = slot.ind, configIndex = slot.configIndex;
var player = Desk.PlayerList[targetSeat];
// 空座位:隐藏该位玩家信息群组
if (!player || !player.playerid || player.playerid === -1) {
SpriteManager.hideGroup(43 + ind);
return true;
}
var posConfig = layout.POSITIONS[configIndex];
if (!posConfig) return false;
// 底框(346+ind)
if (posConfig.FRAME_X !== undefined) {
SpriteManager.setPosition(346 + ind, posConfig.FRAME_X, posConfig.FRAME_Y);
SpriteManager.setSize(346 + ind, posConfig.FRAME_WIDTH, posConfig.FRAME_HEIGHT);
}
// 头像框(376+ind):基于底框 + 相对偏移
var avatarX = posConfig.FRAME_X + posConfig.AVATAR.OFFSET_X;
var avatarY = posConfig.FRAME_Y + posConfig.AVATAR.OFFSET_Y;
var avatarWidth = posConfig.AVATAR.WIDTH;
var avatarHeight = posConfig.AVATAR.HEIGHT;
var nameOffsetX = posConfig.AVATAR.NAME_OFFSET_X || 0;
var nameOffsetY = posConfig.AVATAR.NAME_OFFSET_Y || 0;
var scoreOffsetX = posConfig.AVATAR.SCORE_OFFSET_X || 0;
var scoreOffsetY = posConfig.AVATAR.SCORE_OFFSET_Y || 0;
SpriteManager.setPosition(376 + ind, avatarX, avatarY);
SpriteManager.setSize(376 + ind, avatarWidth, avatarHeight);
var textStyle = layout.TEXT_STYLE || {};
var maxLen = textStyle.NAME_MAX_LENGTH || 8;
var charWidth = textStyle.CHAR_WIDTH || 7;
// 昵称(406+ind)
var nickname = Func.subString(player.nickname || '', maxLen, true);
SpriteManager.setText(406 + ind, nickname);
var nameX = avatarX + nameOffsetX;
if (posConfig.NAME_CENTER_ALIGN) {
var textWidth = (typeof nickname.gblen === 'function') ? nickname.gblen() * charWidth : nickname.length * charWidth;
nameX = avatarX + avatarWidth / 2 - textWidth / 2;
}
SpriteManager.setPosition(406 + ind, nameX, avatarY + nameOffsetY);
// 分数(436+ind)
var score = (typeof player.score === 'number') ? player.score : 0;
SpriteManager.setText(436 + ind, score);
var scoreX = avatarX + scoreOffsetX;
if (posConfig.NAME_CENTER_ALIGN) {
var scoreWidth = String(score).length * charWidth;
scoreX = avatarX + avatarWidth / 2 - scoreWidth / 2;
}
SpriteManager.setPosition(436 + ind, scoreX, avatarY + scoreOffsetY);
// 头像图(116+ind):Func.up_imgurl 为平台头像加载接口(非引擎精灵原语)
if (player.avatar && typeof Func !== 'undefined' && typeof Func.up_imgurl === 'function') {
Func.up_imgurl(116 + ind, player.avatar);
}
// 显示该座位玩家信息群组
SpriteManager.showGroup(43 + ind);
return true;
};
// 桌面文字聊天气泡:有 hook 委托子游戏;无 hook 时按【可变人数】默认渲染,使气泡贴合各玩家。
// seat 为绝对座位;经 SpriteManager 操作精灵(背景552+ind/文字582+ind),位置取自 Game_Modify.BUBBLE_LAYOUT
// 的物理位配置,尺寸(内边距/字宽/显示时长)复用 Game_Config.Chat/Max;不直接调引擎原语、不引用 codes。
Game_Modify.ShowChat = function (seat, text) {
if (window.SubGameHooks && SubGameHooks.ShowChat) return SubGameHooks.ShowChat(seat, text);
GameUI.CloseChat();
if (typeof Desk === 'undefined' || typeof C_Player === 'undefined' || typeof SpriteManager === 'undefined') return;
var layout = Game_Modify.BUBBLE_LAYOUT;
if (!layout || !layout.POSITIONS) return;
var playerCount = (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
var relPos = (seat - C_Player.seat + playerCount) % playerCount;
var slot = Game_Modify._resolveDisplaySlot(relPos, playerCount);
if (!slot) return;
var pos = layout.POSITIONS[slot.configIndex];
if (!pos) return;
var ind = slot.ind;
text = String(text);
var chat = Game_Config.Chat || {};
var dis_x = (chat.ChatDis && chat.ChatDis[0]) || 0;
var dis_y = (chat.ChatDis && chat.ChatDis[1]) || 0;
var text_w = chat.textwidth || 14;
var text_len = (typeof text.gblen === 'function') ? text.gblen() : text.length;
var bgW = text_len * text_w + dis_x * 2;
SpriteManager.setText(582 + ind, text);
if (pos.IS_LEFT) {
SpriteManager.setPosition(552 + ind, pos.X, pos.Y);
SpriteManager.setPosition(582 + ind, pos.X + dis_x, pos.Y + dis_y);
SpriteManager.setImage(552 + ind, layout.IMG_LEFT);
} else {
SpriteManager.setPosition(552 + ind, pos.X - bgW, pos.Y);
SpriteManager.setPosition(582 + ind, pos.X - text_len * text_w - dis_x, pos.Y + dis_y);
SpriteManager.setImage(552 + ind, layout.IMG_RIGHT);
}
SpriteManager.setWidth(552 + ind, bgW);
SpriteManager.show(552 + ind);
SpriteManager.show(582 + ind);
// 自动隐藏:复用平台配置的显示时长(观感与固定人数版一致),按槽位去重定时器
var dur = (Game_Config.Max && Game_Config.Max.ShowChat) || 3000;
Game_Modify._chatHideTimers = Game_Modify._chatHideTimers || {};
if (Game_Modify._chatHideTimers[ind]) { clearTimeout(Game_Modify._chatHideTimers[ind]); }
Game_Modify._chatHideTimers[ind] = setTimeout(function () {
SpriteManager.hide(552 + ind);
SpriteManager.hide(582 + ind);
Game_Modify._chatHideTimers[ind] = null;
}, dur);
};
// 桌面语音气泡(播放):有 hook 委托子游戏;无 hook 时按【可变人数】默认渲染。
// uiIndex 已是相对显示位序(relPos);经 SpriteManager 操作精灵(背景612+ind/内容642+ind),
// 位置取自 Game_Modify.BUBBLE_LAYOUT,气泡宽度依据内容精灵当前宽度;尺寸复用 Game_Config.Voice。
Game_Modify.gameui_play_voice = function (uiIndex) {
if (window.SubGameHooks && SubGameHooks.gameui_play_voice) return SubGameHooks.gameui_play_voice(uiIndex);
if (typeof SpriteManager === 'undefined') return;
var layout = Game_Modify.BUBBLE_LAYOUT;
if (!layout || !layout.POSITIONS) return;
var playerCount = (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
var relPos = ((Number(uiIndex) % playerCount) + playerCount) % playerCount;
var slot = Game_Modify._resolveDisplaySlot(relPos, playerCount);
if (!slot) return;
var pos = layout.POSITIONS[slot.configIndex];
if (!pos) return;
var ind = slot.ind;
var voice = Game_Config.Voice || {};
var dis_x = (voice.VoiceDis && voice.VoiceDis[0]) || 0;
var dis_y = (voice.VoiceDis && voice.VoiceDis[1]) || 0;
var contentSize = SpriteManager.getSize(642 + ind);
var text_len = (contentSize && typeof contentSize.width === 'number') ? contentSize.width : 0;
var bgW = text_len + dis_x * 2;
if (pos.IS_LEFT) {
SpriteManager.setPosition(612 + ind, pos.X, pos.Y);
SpriteManager.setPosition(642 + ind, pos.X + dis_x, pos.Y + dis_y);
SpriteManager.setImage(612 + ind, layout.IMG_LEFT);
} else {
SpriteManager.setPosition(612 + ind, pos.X - bgW, pos.Y);
SpriteManager.setPosition(642 + ind, pos.X - text_len - dis_x, pos.Y + dis_y);
SpriteManager.setImage(612 + ind, layout.IMG_RIGHT);
}
SpriteManager.setWidth(612 + ind, bgW);
SpriteManager.show(612 + ind);
SpriteManager.show(642 + ind);
play_ani(1, 642 + ind, 43, 1, 3, 0, Game_Config.Voice.VoiceTime, 0, 0, 0, 0, 0, 0);
};
// 桌面语音气泡(停止):有 hook 委托子游戏;无 hook 时隐藏对应槽位的语音气泡。
Game_Modify.gameui_stop_voice = function (uiIndex) {
if (window.SubGameHooks && SubGameHooks.gameui_stop_voice) return SubGameHooks.gameui_stop_voice(uiIndex);
if (typeof SpriteManager === 'undefined') return;
var playerCount = (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4;
var relPos = ((Number(uiIndex) % playerCount) + playerCount) % playerCount;
var slot = Game_Modify._resolveDisplaySlot(relPos, playerCount);
if (!slot) return;
var ind = slot.ind;
SpriteManager.hide(612 + ind);
SpriteManager.hide(642 + ind);
};
+14 -14
View File
@@ -241,7 +241,7 @@ var BaseComponent = (function() {
this.isDestroyed = false;
this.isInitialized = true;
console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')');
// // console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 调用子类的init方法
try {
@@ -274,7 +274,7 @@ var BaseComponent = (function() {
return;
}
console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" 的所有精灵 (数量: ' + this.sprites.length + ')');
// console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" 的所有精灵 (数量: ' + this.sprites.length + ')');
for (var i = 0; i < this.sprites.length; i++) {
var spriteId = this.sprites[i];
@@ -293,7 +293,7 @@ var BaseComponent = (function() {
*/
init: function(config) {
// 子类覆盖此方法
console.log('📝 BaseComponent.init: 请在子类中覆盖此方法');
// console.log('📝 BaseComponent.init: 请在子类中覆盖此方法');
},
/**
@@ -312,11 +312,11 @@ var BaseComponent = (function() {
}
if (this.isVisible) {
console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name);
// console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name);
return true;
}
console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')');
// console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 显示图层
SpriteManager.showGroup(this.layer);
@@ -344,11 +344,11 @@ var BaseComponent = (function() {
}
if (!this.isVisible) {
console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name);
// console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name);
return true;
}
console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')');
// console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 隐藏图层
SpriteManager.hideGroup(this.layer);
@@ -375,7 +375,7 @@ var BaseComponent = (function() {
return false;
}
console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"');
// console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"');
// 移除所有事件监听
this._removeAllEventListeners();
@@ -447,7 +447,7 @@ var BaseComponent = (function() {
// 记录监听器
this.eventListeners[eventName] = wrappedHandler;
console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName);
// console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName);
return true;
},
@@ -467,7 +467,7 @@ var BaseComponent = (function() {
EventBus.off(eventName, handler);
delete this.eventListeners[eventName];
console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName);
// console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName);
return true;
},
@@ -483,7 +483,7 @@ var BaseComponent = (function() {
return;
}
console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName);
// console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName);
EventBus.emit(eventName, data);
},
@@ -511,7 +511,7 @@ var BaseComponent = (function() {
}
this.sprites.push(spriteId);
// console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId);
// // console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId);
return true;
},
@@ -529,7 +529,7 @@ var BaseComponent = (function() {
}
this.sprites.splice(index, 1);
console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId);
// console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId);
return true;
},
@@ -596,7 +596,7 @@ var BaseComponent = (function() {
*/
printState: function() {
var state = this.getState();
console.log('📊 组件状态:', JSON.stringify(state, null, 2));
// console.log('📊 组件状态:', JSON.stringify(state, null, 2));
}
};
@@ -84,7 +84,21 @@
* scrollSensitivity: 5, // 移动5px以上才触发滚动
* bounceSpeed: 1 // 回弹速度
* });
*
*
* 【横向列表(左右滑动)】
* var themeList = new DynamicSpriteList({
* containerId: sprites.LIST_CONTAINER,
* clipArea: { x: 340, y: 240, width: 600, height: 160 },
* orientation: 'horizontal', // 默认 'vertical'
* itemWidth: 262, // 主轴步长(项宽 + 项间距),与纵向 rowHeight 对称
* renderRow: function (ctx) {
* // 横向下 ctx.addSprite 的 x 会叠加该项的主轴基准,y 原样
* var sid = ctx.addSprite('preview', TPL_ID, 0, 0);
* }
* });
* // 事件转发:横向传 offmovex(纵向传 offmovey)
* themeList.handleMouseMove(event.spriteId, event.offset.x);
*
* 【设置数据(触发渲染)】
* // 数据数组中每个元素对应一行
* // setData 会清除旧行、重新创建新行
@@ -176,6 +190,26 @@
* }
* }
*/
/**
* 主轴/交叉轴的引擎操作码与 clipArea 字段映射
* 前缀 DSL_ 是必要的:友乐运行时所有 <script> 共享同一全局作用域,
* 通用名(如 LIST_AXIS)会与其它文件互相覆盖。
* 操作码:18=左右移动 19=上下移动 20=设宽 21=设高
* (docs/important/client/精灵操作详细指南.md:38-41)
*/
var DSL_LIST_AXIS = {
vertical: {
posOp: 19, sizeOp: 21, crossPosOp: 18, crossSizeOp: 20,
clipPos: 'y', clipSize: 'height', crossClipPos: 'x', crossClipSize: 'width',
stepField: 'rowHeight'
},
horizontal: {
posOp: 18, sizeOp: 20, crossPosOp: 19, crossSizeOp: 21,
clipPos: 'x', clipSize: 'width', crossClipPos: 'y', crossClipSize: 'height',
stepField: 'itemWidth'
}
};
function DynamicSpriteList(config) {
// ========================================================================
// 参数验证
@@ -189,8 +223,11 @@ function DynamicSpriteList(config) {
if (!config.clipArea) {
throw new Error('DynamicSpriteList: clipArea is required');
}
if (typeof config.rowHeight !== 'number' || config.rowHeight <= 0) {
throw new Error('DynamicSpriteList: rowHeight must be a positive number');
var orientation = (config.orientation === 'horizontal') ? 'horizontal' : 'vertical';
var axis = DSL_LIST_AXIS[orientation];
var step = config[axis.stepField];
if (typeof step !== 'number' || step <= 0) {
throw new Error('DynamicSpriteList: ' + axis.stepField + ' must be a positive number');
}
var hasTemplates = config.templates && typeof config.templates === 'object';
var hasRenderRow = typeof config.renderRow === 'function';
@@ -220,11 +257,31 @@ function DynamicSpriteList(config) {
};
/**
* 每行高度
* 排列方向:'vertical'(默认) | 'horizontal'
* @type {string}
*/
this.orientation = orientation;
/**
* 轴映射(操作码 + clipArea 取用字段),内部所有方向相关操作一律经此表
* @type {Object}
* @private
*/
this._axis = axis;
/**
* 主轴步长:纵向=行高(rowHeight),横向=项宽含间距(itemWidth)
* @type {number}
* @private
*/
this._step = step;
/**
* 每行高度(纵向语义保留字段;横向下等于主轴步长)
* @type {number}
*/
this.rowHeight = config.rowHeight;
this.rowHeight = step;
/**
* 模板精灵配置
* @type {Object}
@@ -431,31 +488,31 @@ DynamicSpriteList.prototype.destroy = function() {
/**
* 滚动到指定位置
*
* @param {number} y - 目标Y坐标
* @param {number} offset - 目标主轴偏移量(纵向=Y方向,横向=X方向)
* @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollTo = function(y, animated) {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var targetY = this.clipArea.y - y;
// 边界限制
var contentHeight = this._data.length * this.rowHeight;
var minY = this.clipArea.y + this.clipArea.height - contentHeight;
if (targetY < minY) {
targetY = minY;
}
if (targetY > this.clipArea.y) {
targetY = this.clipArea.y;
}
DynamicSpriteList.prototype.scrollTo = function(offset, animated) {
var axis = this._axis;
var clipStart = this.clipArea[axis.clipPos];
var clipSize = this.clipArea[axis.clipSize];
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
var target = clipStart - offset;
// 边界钳制(双向):先钳末端,再钳起点。
// 内容不足一屏时 min > clipStart,两条钳制叠加后恒为起点——这正是期望行为。
var contentSize = this._data.length * this._step;
var min = clipStart + clipSize - contentSize;
if (target < min) { target = min; }
if (target > clipStart) { target = clipStart; }
if (animated !== false) {
play_ani(1, this.containerId, 19, currentY, targetY, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
play_ani(1, this.containerId, axis.posOp, current, target, 0,
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
} else {
set_self(this.containerId, 19, targetY, 0, 0, 0);
set_self(this.containerId, axis.posOp, target, 0, 0, 0);
}
return this;
};
@@ -476,8 +533,8 @@ DynamicSpriteList.prototype.scrollToTop = function(animated) {
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollToBottom = function(animated) {
var contentHeight = this._data.length * this.rowHeight;
var maxScroll = contentHeight - this.clipArea.height;
var contentSize = this._data.length * this._step;
var maxScroll = contentSize - this.clipArea[this._axis.clipSize];
if (maxScroll < 0) {
maxScroll = 0;
}
@@ -492,7 +549,7 @@ DynamicSpriteList.prototype.scrollToBottom = function(animated) {
* @returns {DynamicSpriteList} 返回自身,支持链式调用
*/
DynamicSpriteList.prototype.scrollToRow = function(rowIndex, animated) {
var y = rowIndex * this.rowHeight;
var y = rowIndex * this._step;
return this.scrollTo(y, animated);
};
@@ -531,36 +588,35 @@ DynamicSpriteList.prototype.handleMouseDown = function(spid, x, y) {
* 需要在 utlmousemove 回调中调用
*
* @param {number} spid - 移动的精灵ID
* @param {number} offsetY - Y方向移动偏移量
* @param {number} offsetMain - 主轴方向移动偏移量(纵向传 Y 方向偏移,横向传 X 方向偏移)
* @returns {boolean} 如果事件被处理返回 true
*
*
* @example
* // 在 SpriteEventController 或 Game_Modify 中
* // 在 SpriteEventController 或 Game_Modify 中(纵向列表传 offmovey,横向列表传 offmovex)
* utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
* if (myList.handleMouseMove(spid, offmovey)) {
* if (myList.handleMouseMove(spid, offmovey)) { // 横向列表改传 offmovex
* return; // 事件已处理
* }
* // 其他处理...
* }
*/
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetY) {
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetMain) {
if (!this.enableScroll) {
return false;
}
if (spid === this.containerId) {
if (Math.abs(offsetY) > this.scrollSensitivity) {
// 增量移动容器
set_self(this.containerId, 19, offsetY, 1, 0, 0);
if (Math.abs(offsetMain) > this.scrollSensitivity) {
var axis = this._axis;
// 增量移动容器(第4参 1 = 相对增量)
set_self(this.containerId, axis.posOp, offsetMain, 1, 0, 0);
this._isSliding = true;
// 触发滚动回调
if (typeof this.onScroll === 'function') {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var scrollY = this.clipArea.y - currentY;
var contentHeight = this._data.length * this.rowHeight;
var maxScrollY = contentHeight - this.clipArea.height;
this.onScroll(scrollY, maxScrollY > 0 ? maxScrollY : 0);
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
var scrolled = this.clipArea[axis.clipPos] - current;
var maxScroll = this._data.length * this._step - this.clipArea[axis.clipSize];
this.onScroll(scrolled, maxScroll > 0 ? maxScroll : 0);
}
}
return true;
@@ -661,29 +717,30 @@ DynamicSpriteList.prototype._addSpriteInternal = function (name, templateSpriteI
* @private
*/
DynamicSpriteList.prototype._render = function() {
set_self(this.containerId, 18, this.clipArea.x, 0, 0);
set_self(this.containerId, 19, this.clipArea.y, 0, 0);
var contentHeight = this._data.length * this.rowHeight;
set_self(this.containerId, 21, contentHeight, 0, 0);
var axis = this._axis;
set_self(this.containerId, axis.posOp, this.clipArea[axis.clipPos], 0, 0);
set_self(this.containerId, axis.crossPosOp, this.clipArea[axis.crossClipPos], 0, 0);
var contentSize = this._data.length * this._step;
set_self(this.containerId, axis.sizeOp, contentSize, 0, 0);
if (this.templates) {
var tnames = Object.keys(this.templates);
if (tnames.length > 0) {
var w = get_self(this.templates[tnames[0]].spriteId, 20, 0, 0, 0);
set_self(this.containerId, 20, w, 0, 0);
// 交叉轴尺寸取首个模板的对应尺寸(纵向=宽 op20,横向=高 op21)
var crossFromTpl = get_self(this.templates[tnames[0]].spriteId, axis.crossSizeOp, 0, 0, 0);
set_self(this.containerId, axis.crossSizeOp, crossFromTpl, 0, 0);
}
} else {
// renderRow 模式:容器宽度取裁剪区宽,保证整行可视宽都在容器 hit 区(点击/拖动检测)内。
// 否则容器保持编辑器预置窄宽度,超出部分点击/拖动报的 spid≠containerId 而失效(对账 bak set_self(fSpid,20,bg宽))。
set_self(this.containerId, 20, this.clipArea.width, 0, 0);
// renderRow 模式:交叉轴尺寸取裁剪区对应边,保证整个可视区都在容器 hit 区(点击/拖动检测)内
set_self(this.containerId, axis.crossSizeOp, this.clipArea[axis.crossClipSize], 0, 0);
}
for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) {
var rowData = this._data[rowIndex];
var rowY = rowIndex * this.rowHeight;
var rowBase = rowIndex * this._step;
if (this.renderRow) {
this.renderRow(this._makeRowContext(rowIndex, rowData, rowY));
this.renderRow(this._makeRowContext(rowIndex, rowData, rowBase));
} else {
var names = Object.keys(this.templates);
for (var i = 0; i < names.length; i++) {
@@ -691,7 +748,9 @@ DynamicSpriteList.prototype._render = function() {
var t = this.templates[name];
var ox = (t.offset && t.offset.x) || 0;
var oy = (t.offset && t.offset.y) || 0;
var sid = this._addSpriteInternal(name, t.spriteId, ox, rowY + oy, rowIndex, t.clickable !== false);
var px = (this.orientation === 'horizontal') ? (rowBase + ox) : ox;
var py = (this.orientation === 'horizontal') ? oy : (rowBase + oy);
var sid = this._addSpriteInternal(name, t.spriteId, px, py, rowIndex, t.clickable !== false);
if (t.textProperty && rowData[t.textProperty] !== undefined) {
set_self(sid, 7, rowData[t.textProperty], 0, 0);
}
@@ -708,19 +767,27 @@ DynamicSpriteList.prototype._render = function() {
* 创建行上下文对象(传给 renderRow 回调)
* @param {number} rowIndex - 行索引
* @param {Object} rowData - 行数据
* @param {number} rowBaseY - 行基准 Y 坐标
* @param {number} rowBase - 行基准坐标(纵向=Y,横向=X)
* @returns {Object} 行上下文
* @private
*/
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBaseY) {
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBase) {
var self = this;
var isH = (this.orientation === 'horizontal');
return {
rowIndex: rowIndex,
rowData: rowData,
rowBaseY: rowBaseY,
rowBase: rowBase,
// 保留 rowBaseY:renderRow 回调的既有字段协议,不得破坏。
// 当前生产消费方为 RecordView.js(唯一使用本组件的调用方);
// 回归测试 DynamicSpriteList.test.js 对该字段有硬断言。
rowBaseY: rowBase,
orientation: this.orientation,
containerId: this.containerId,
addSprite: function (name, templateSpriteId, x, y) {
return self._addSpriteInternal(name, templateSpriteId, x, rowBaseY + y, rowIndex, true);
var px = isH ? (rowBase + x) : x;
var py = isH ? y : (rowBase + y);
return self._addSpriteInternal(name, templateSpriteId, px, py, rowIndex, true);
}
};
};
@@ -730,38 +797,39 @@ DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBa
* @private
*/
DynamicSpriteList.prototype._handleBounce = function() {
var currentY = get_self(this.containerId, 19, 0, 0, 0);
var contentHeight = get_self(this.containerId, 21, 0, 0, 0);
var clipY = this.clipArea.y;
var clipH = this.clipArea.height;
var targetY = currentY;
var axis = this._axis;
var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
var contentSize = get_self(this.containerId, axis.sizeOp, 0, 0, 0);
var clipStart = this.clipArea[axis.clipPos];
var clipSize = this.clipArea[axis.clipSize];
var target = current;
var needBounce = false;
if (contentHeight <= clipH) {
// 内容不足一屏,回弹到顶部
if (currentY !== clipY) {
targetY = clipY;
if (contentSize <= clipSize) {
// 内容不足一屏,回弹到起点
if (current !== clipStart) {
target = clipStart;
needBounce = true;
}
} else {
if (currentY > clipY) {
// 超出顶部
targetY = clipY;
if (current > clipStart) {
// 越过起点
target = clipStart;
needBounce = true;
} else {
var minY = clipY + clipH - contentHeight;
if (currentY < minY) {
// 超出底部
targetY = minY;
var min = clipStart + clipSize - contentSize;
if (current < min) {
// 越过末端
target = min;
needBounce = true;
}
}
}
if (needBounce) {
play_ani(1, this.containerId, 19, currentY, targetY, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
play_ani(1, this.containerId, axis.posOp, current, target, 0,
Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
}
};
@@ -772,6 +840,16 @@ DynamicSpriteList.prototype._handleBounce = function() {
* @private
*/
DynamicSpriteList.prototype._handleClick = function(x, y) {
// 裁剪区外的点击一律不算数。
// 原因:引擎的 set_clip 只裁【绘制】、不裁【命中】,而 _render 会把容器的主轴尺寸
// 撑到内容全长(远超可视区)。于是滑出可视区、屏幕上已经看不见的那些项,其矩形
// 仍在容器内,ifast_check_add 照样判为命中 —— 用户点到列表旁的空白带,却选中了
// 一个根本看不见的项。这里按 clipArea 兜住,横纵通用。
var a = this.clipArea;
if (x < a.x || x >= a.x + a.width || y < a.y || y >= a.y + a.height) {
return;
}
var clickedTag = ifast_check_add(this.containerId, x, y);
if (clickedTag === -99999999) { return; }
var rec = this._tagIndex[clickedTag];
+18 -3
View File
@@ -63,6 +63,10 @@ var RecordView = {
if (typeof gradeinfo[i].gameinfo1 === 'string') {
gradeinfo[i].gameinfo1 = JSON.parse(gradeinfo[i].gameinfo1);
}
// roomtype 归一:平台战绩记录里 roomtype 可能被多做了一层 JSON 序列化,
// 形如 '"11221000100003"'。下游权威 RoomConfigUtils.parse 严格要求 14 位裸字符串
// (故意不做多格式兼容),故在此收包边界剥掉多余的 JSON 引号层。
gradeinfo[i].roomtype = this._normalizeRoomtype(gradeinfo[i].roomtype);
if (i === 0) { this._gradeIdx1 = gradeinfo[i].idx; }
if (i === gradeinfo.length - 1) { this._gradeIdx2 = gradeinfo[i].idx; }
}
@@ -70,6 +74,17 @@ var RecordView = {
this.open();
},
// 剥掉 roomtype 上多余的 JSON 序列化层:'"11221000100003"' → '11221000100003'。
// 多层引号(极端情况)循环剥;非字符串或无引号原样返回;JSON.parse 失败即停止不吞坏值。
_normalizeRoomtype: function (rt) {
var v = rt, guard = 0;
while (typeof v === 'string' && v.length >= 2 && v.charAt(0) === '"' && guard < 5) {
try { v = JSON.parse(v); } catch (e) { break; }
guard++;
}
return v;
},
// 翻页/类型 → 发获取战绩请求(平台通用协议)。类型切换 _requestGrade(type,null);翻页 _requestGrade(_,direction)。
_requestGrade: function (arg1, direction) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
@@ -77,9 +92,9 @@ var RecordView = {
this._type = arg1; // 类型切换
data.type = arg1;
} else {
data.type = this._type; // 翻页:type 用当前,direction + gradeidx
data.type = this._type; // 翻页:type 用当前,direction + idx
data.direction = direction;
data.gradeidx = (direction === 1) ? this._gradeIdx1 : this._gradeIdx2;
data.idx = (direction === 1) ? this._gradeIdx1 : this._gradeIdx2;
}
if (typeof Net !== 'undefined' && Net.Send_get_player_grade1) { Net.Send_get_player_grade1(data); }
},
@@ -89,7 +104,7 @@ var RecordView = {
_requestReplayData: function (gameIndex) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
if (this._gradeData && this._gradeData[gameIndex] && typeof this._gradeData[gameIndex].idx !== 'undefined') {
data.gradeidx = this._gradeData[gameIndex].idx;
data.idx = this._gradeData[gameIndex].idx;
}
if (typeof Net !== 'undefined' && Net.Send_get_player_grade2) { Net.Send_get_player_grade2(data); }
},
+15 -15
View File
@@ -145,7 +145,7 @@ var UIManager = (function () {
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_ICON);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT);
SpriteManager.hideGroup(LOADING_UI.GROUP_ID);
console.log('[UIManager] Loading UI 初始化 (Layer ' + LOADING_UI.LAYER + ')');
// console.log('[UIManager] Loading UI 初始化 (Layer ' + LOADING_UI.LAYER + ')');
}
/** @private */
@@ -153,7 +153,7 @@ var UIManager = (function () {
SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND);
SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT);
SpriteManager.hideGroup(MESSAGE_UI.GROUP_ID);
console.log('[UIManager] Message UI 初始化 (Layer ' + MESSAGE_UI.LAYER + ')');
// console.log('[UIManager] Message UI 初始化 (Layer ' + MESSAGE_UI.LAYER + ')');
}
/** @private */
@@ -164,7 +164,7 @@ var UIManager = (function () {
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL);
SpriteManager.hideGroup(CONFIRM_UI.GROUP_ID);
console.log('[UIManager] Confirm UI 初始化 (Layer ' + CONFIRM_UI.LAYER + ')');
// console.log('[UIManager] Confirm UI 初始化 (Layer ' + CONFIRM_UI.LAYER + ')');
}
/**
@@ -227,7 +227,7 @@ var UIManager = (function () {
return true;
}
console.log('[UIManager] 初始化 (v1.0.0)');
// console.log('[UIManager] 初始化 (v1.0.0)');
try {
_components = {};
@@ -239,7 +239,7 @@ var UIManager = (function () {
_isInitialized = true;
this.initialized = true;
console.log('[UIManager] 初始化完成');
// console.log('[UIManager] 初始化完成');
return true;
} catch (err) {
console.error('[UIManager] 初始化失败:', err);
@@ -279,7 +279,7 @@ var UIManager = (function () {
console.warn('[UIManager] registerComponent: 覆盖已存在组件:', name);
}
_components[name] = component;
console.log('[UIManager] 注册组件 "' + name + '"');
// console.log('[UIManager] 注册组件 "' + name + '"');
return true;
},
@@ -305,7 +305,7 @@ var UIManager = (function () {
var c = _components[name];
if (c.isVisible) { c.hide(); }
delete _components[name];
console.log('[UIManager] 注销组件 "' + name + '"');
// console.log('[UIManager] 注销组件 "' + name + '"');
return true;
},
@@ -321,7 +321,7 @@ var UIManager = (function () {
}
_components[name].destroy();
delete _components[name];
console.log('[UIManager] 销毁组件 "' + name + '"');
// console.log('[UIManager] 销毁组件 "' + name + '"');
return true;
},
@@ -371,7 +371,7 @@ var UIManager = (function () {
}
}
_scenes[sceneName] = componentNames;
console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
// console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
return true;
},
@@ -390,7 +390,7 @@ var UIManager = (function () {
return true;
}
console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
// console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
_hideCurrentScene();
_showScene(sceneName);
@@ -447,7 +447,7 @@ var UIManager = (function () {
* @param {string} message
*/
showToast: function (message) {
console.log('[UIManager] Toast:', message);
// console.log('[UIManager] Toast:', message);
},
/**
@@ -458,7 +458,7 @@ var UIManager = (function () {
*/
showEffectToast: function (tag, title, description) {
var msg = description ? (title + ':' + description) : title;
console.log('[UIManager] EffectToast [' + tag + ']:', msg);
// console.log('[UIManager] EffectToast [' + tag + ']:', msg);
this.showToast(msg);
},
@@ -575,14 +575,14 @@ var UIManager = (function () {
* 打印状态(调试用)
*/
printState: function () {
console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
// console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
},
/**
* 销毁 UIManager(销毁所有组件并重置状态)
*/
destroy: function () {
console.log('[UIManager] 开始销毁');
// console.log('[UIManager] 开始销毁');
for (var name in _components) {
if (_components.hasOwnProperty(name)) { this.destroyComponent(name); }
}
@@ -594,7 +594,7 @@ var UIManager = (function () {
_currentScene = null;
_isInitialized = false;
this.initialized = false;
console.log('[UIManager] 销毁完成');
// console.log('[UIManager] 销毁完成');
}
};
+125
View File
@@ -0,0 +1,125 @@
# Spine 运行时 vendor 补丁记录
> ⚠️ **升级 `spine-canvas.js` / `spine-webgl.js` 前必读。**
> 这两个文件**不是官方发行版原样**,含本地补丁。直接用官方文件覆盖会导致
> `file://` 协议下资源加载全部失败(表现:所有 Spine 特效不显示)。
## 基线版本
| 文件 | npm 包 | 基线版本 | 字节数 | sha256 |
|---|---|---|---|---|
| `spine-canvas.js` | `@esotericsoftware/spine-canvas` | **4.2.113**(与 4.2.114 官方产物完全相同) | 455670 | `a19ad49c42490d4f41dbc805b0b91d72ccca4236957b2cdab8e57483c3aeb45c` |
| `spine-webgl.js` | `@esotericsoftware/spine-webgl` | **4.2.113** | 553987 | `0c2a0d8cb833297168bac7bbb7821d6ebe87aa11c3708831ca6741468b3d4d25` |
骨架资源导出版本为 **4.2.43**(见 `client/assets/spine/*.json` 的 `skeleton.spine` 字段),
运行时须保持在 **4.2.x** 系列。
官方产物获取地址:
```
https://cdn.jsdelivr.net/npm/@esotericsoftware/spine-canvas@4.2.113/dist/iife/spine-canvas.js
https://cdn.jsdelivr.net/npm/@esotericsoftware/spine-webgl@4.2.113/dist/iife/spine-webgl.js
```
官方原始产物 sha256(用于确认下载无误):
- spine-canvas 4.2.113/114:`b706c28a…`(4.2.43 版,仅作对照,非本项目基线)
- spine-webgl 4.2.113:`e737ac4e7caa9fae…`(打补丁前)
---
## 补丁清单
### 补丁 1 —— `file://` 下不设 `crossOrigin`
**应用于**:`spine-canvas.js`、`spine-webgl.js`
**位置**:`AssetManager.loadTexture` 中构造 `Image` 处(canvas 版约 5798 行 / webgl 版约 5838 行)。
**原因**:`file://` 协议下设置 `image.crossOrigin = "anonymous"` 会使图片被判为跨域而加载失败。
这是 PNG 图集能在 `file://` 下加载的关键。
```js
// 官方
image.crossOrigin = "anonymous";
// 本地
if (typeof location === "undefined" || location.protocol !== "file:")
image.crossOrigin = "anonymous";
```
⚠️ 只改 `loadTexture` 中的 `image`。webgl 版另有 `logoImage` / `spinnerImage`
(约 14004 / 14008 行,属 loading spinner),**不要动**。
---
### 补丁 2 —— 删除 `toLoad === 0` 提前 resolve 分支
**应用于**:仅 `spine-canvas.js`(**`spine-webgl.js` 未移植,保留官方逻辑**)
**位置**:`AssetManager.loadTextureAtlas`(canvas 版官方约 5826-5830 行)。
```js
// 官方(webgl 版保留此段)
if (toLoad === 0) {
this.success(success, path, atlas);
resolve(atlas);
return;
}
```
**行为差异**:官方在 `atlas.pages.length === 0` 时立即 resolve;删除后该情形下 Promise
永不 resolve,`isLoadingComplete()` 恒为 false,全部 Spine 都不会显示。
**未移植到 webgl 版的理由**:本项目 20 个 atlas 文件实测**全部至少 1 页**(共 23 页),
该分支在本项目不可达,删与不删行为完全相同;而官方逻辑在边界情形下更安全。
**注**:此补丁在 `spine-canvas.js` 中随 commit `1f48f4ea`(2026-04-20)打包引入,
提交信息未提及,**原始意图在仓库中无记录可考**。上述为行为分析,非意图推定。
---
### 补丁 3 —— `rawDataUri` 判定改为 `startsWith("data:")`
**应用于**:`spine-canvas.js`(仅 `downloadText`)、`spine-webgl.js`(`downloadText` + `downloadBinary`)
**位置**:`Downloader.downloadText` / `Downloader.downloadBinary`
(canvas 版约 6070 / 6096 行,webgl 版约 6110 / 6136 行)。
```js
// 官方
if (rawDataUri && !rawDataUri.includes(".")) {
// 本地
if (rawDataUri && rawDataUri.startsWith("data:")) {
```
**原因**:`SpineMgr.js` 用 `setRawDataURI(key, "data:," + textData[key])` 注入嵌入的
json/atlas 文本(见 `client/generated/spine_data.js`),以规避 `file://` 下 XHR 的 CORS 拦截。
而 json 文本中必然含小数点(坐标、版本号等),官方那个「不含点才算 data URI」的判定会误判为
非 data URI,导致回退到 XHR 请求而失败。
**两版差异说明**:`spine-canvas.js` 只改了 `downloadText`,因为本项目只用 `loadText`
(json/atlas 均为文本),不走二进制 `.skel` 路径。`spine-webgl.js` 两处都改了 ——
该判定本身是官方的缺陷逻辑,改为 `startsWith("data:")` 在两处均为纯正确性提升、无副作用。
---
## 升级流程
1. 从上述 CDN 取目标版本的官方 iife 产物,记录其原始 sha256。
2. 逐条比对本文件的补丁清单,确认每处补丁点在新版中仍存在且语境未变。
3. 移植补丁时**必须校验匹配次数**(补丁 1 应命中 1 处、补丁 3 应命中 2 处),
匹配数不符即中止并重新核对,不得盲替。
4. 验证产物:`node --check` 语法检查;用 vm 加载并确认关键 API 齐备
(`AssetManager` / `SkeletonJson` / `AtlasAttachmentLoader` / `Skeleton` /
`AnimationState` / `AnimationStateData` / `Physics` / `TextureAtlas`,
webgl 版另需 `SceneRenderer` / `PolygonBatcher` / `GLTexture` /
`ManagedWebGLRenderingContext`)。
5. 更新本文件的版本号与 sha256。
6. 真机验证 `file://` 下资源加载正常(补丁 1、3 的实际验收点)。
## 已知约束
两个文件都以 `var spine = (() => {...})()` 形式暴露**同一个全局名 `spine`**。
**同时加载两者会互相覆盖**,后加载的胜出。因此运行时只能加载其中一个,
或在两次加载之间抢存引用。相关设计见
`docs/superpowers/specs/2026-08-08-spine-webgl-migration-design.md`。
+59 -20
View File
@@ -1,3 +1,44 @@
/* ============================================================================
* GAMEABC_TEXT —— 文字渲染统一层(本地补丁,见 js/vendor/README-gameabc-patches.md)
*
* 取代原先写在 js/00_Surface/12_Logic.js 里对 CanvasRenderingContext2D.prototype
* 的猴子补丁,把「字体族 / 字重 / 测宽 / 竖直锚点」四件事收敛到引擎内一处。
*
* 1) 字体族与字重:原写法 'lighter Npx Arial',Arial 无中文字形,中文走回退字体又被
* weight=100 拉细,导致中英粗细不一;另有三处写成 'Npx bold san-serif'(拼写错误,
* 应为 sans-serif),实际落到默认衬线字体。统一为 500 字重 + 中文友好字体族。
*
* 2) 测宽同源:ifast_measureText 必须与实际绘制用同一 font 串,否则依赖测宽做居中 /
* 换行的地方会有约 3% 的横向偏差。现在两者都走 GAMEABC_TEXT.font()。
*
* 3) 竖直锚点:Canvas 的 textBaseline="top" 锚点 = 基线上移「首个可用字体的 ascent」,
* 但 WebKit(iOS 全部浏览器 + WKWebView)取 CoreText 原始 hhea ascent,
* Blink(PC Chrome/Edge、安卓 WebView)取归一化 typo ascent —— 同字同号相差 0.125em;
* iOS 上首个可用字体是 PingFang SC(ascent 1.06em)会把差距放大到 0.20em,
* 表现为「iOS 文字整体偏下」。alphabetic 是唯一不依赖度量解释的锚点,
* 因此这里改为固定用 alphabetic,由本层自算基线位置。
*
* TOP_TO_BASELINE = 0.845 是实测值:让 WebKit 的渲染结果与改造前 Blink 的外观
* 在 24 / 40 / 64px 三档下逐像素对齐(最大偏差 1px),即「iOS 向网页/安卓看齐」,
* 而不是三端一起位移。调整它会整体上下移动所有文字。
* ========================================================================== */
var GAMEABC_TEXT = {
FAMILY: '"Microsoft YaHei", "PingFang SC", Arial, sans-serif',
WEIGHT: '500',
TOP_TO_BASELINE: 0.845,
font: function(size) {
return this.WEIGHT + ' ' + size + 'px ' + this.FAMILY;
},
/* 绘制前调用:设好字体并把锚点固定为 alphabetic */
prepare: function(ctx, size) {
ctx.font = this.font(size);
ctx.textBaseline = 'alphabetic';
},
/* 把原先「以文字块顶部为锚点的 y」换算成 alphabetic 基线的 y */
baseline: function(y, size) {
return y + this.TOP_TO_BASELINE * size;
}
};
var gameabc_senderror = 0;
var gameabc = function() {
function s(SIt2, L$qig3, Qf4) {
@@ -455,7 +496,7 @@ function dfwtime200() {
return 0;
}
;var dc = ifast_getdc();
dc.font = 'lighter ' + obj.h + 'px Arial';
dc.font = GAMEABC_TEXT.font(obj.h);
if (!caption) {
caption = obj.caption;
}
@@ -2120,10 +2161,9 @@ function GameObjectManager() {
var yyyy;
var uu = gameabc_face.loglist.length - 1;
for (yyyy = uu; yyyy >= 0; yyyy--) {
this.backBufferContext2D.textBaseline = "top";
GAMEABC_TEXT.prepare(this.backBufferContext2D, 32);
this.backBufferContext2D.fillStyle = "red";
this.backBufferContext2D.font = 32 + 'px' + ' bold san-serif';
this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, 80 + (uu - yyyy) * 32);
this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, GAMEABC_TEXT.baseline(80 + (uu - yyyy) * 32, 32));
}
;
}
@@ -2135,7 +2175,10 @@ function GameObjectManager() {
}
;if (gameabc_face.obj.caption != "") {
this.backBufferContext2D.fillStyle = "white";
this.backBufferContext2D.font = parseInt(24 * this.backBuffer.width / 480) + "px" + ' bold san-serif';
/* 这一处原本就没有设过 textBaseline,实际用的是默认 alphabetic;
这里显式写死以免依赖上一次绘制在 context 上的残留状态,y 保持不变。 */
this.backBufferContext2D.font = GAMEABC_TEXT.font(parseInt(24 * this.backBuffer.width / 480));
this.backBufferContext2D.textBaseline = 'alphabetic';
this.backBufferContext2D.fillText(gameabc_face.obj.caption, parseInt(this.backBuffer.width / 2) - 50, parseInt(this.backBuffer.height / 2) + 100);
}
;if (gameabc_face.checkmustdraw() == 0) {
@@ -2641,17 +2684,16 @@ function ifast_float(f, r) {
;
}
;function ifast_mydrawtext(spid, recid, sp_x, sp_y, sp_w, sp_h) {
gameabc_face.dc.textBaseline = "top";
GAMEABC_TEXT.prepare(gameabc_face.dc, sp_h);
gameabc_face.dc.fillStyle = gameabc_GameTxt.GameTxtList[recid].Color;
gameabc_face.dc.font = sp_h + 'px' + ' bold san-serif';
var caption = gameabc_GameTxt.GameTxtList[recid].Text;
if (spid <= 0) {
gameabc_face.dc.fillText(caption, sp_x, sp_y, sp_w);
gameabc_face.dc.fillText(caption, sp_x, GAMEABC_TEXT.baseline(sp_y, sp_h), sp_w);
return;
}
;var obj = ifast_getobj(spid);
if (typeof (obj) == "object") {
gameabc_face.dc.fillText(caption, obj.x + sp_x, obj.y + sp_y, sp_w);
gameabc_face.dc.fillText(caption, obj.x + sp_x, GAMEABC_TEXT.baseline(obj.y + sp_y, sp_h), sp_w);
}
;
}
@@ -5290,9 +5332,8 @@ function gameabc_imgtxt() {
}
;gameabc_save(this, context);
gameabc_charge(this, context);
context.textBaseline = "top";
GAMEABC_TEXT.prepare(context, this.h);
context.fillStyle = gameabc_GameTxt.GameTxtList[this.frame].Color;
context.font = 'lighter ' + this.h + 'px Arial';
this.caption = gameabc_GameTxt.GameTxtList[this.frame].Text;
if (this.uidata.GameTxtStyle == 0) {
gameabc_drawtext = this.caption;
@@ -5300,18 +5341,17 @@ function gameabc_imgtxt() {
gameabc_drawtext = gameabc_password.substring(0, this.caption.length + 1);
}
;if (this.w > 0) {
context.fillText(gameabc_drawtext, this.x - xScroll, this.y - yScroll, this.w);
context.fillText(gameabc_drawtext, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h), this.w);
} else {
context.fillText(gameabc_drawtext, this.x - xScroll, this.y - yScroll);
context.fillText(gameabc_drawtext, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h));
}
;gameabc_restore(this, context);
}
}
;gameabc_imgtxt.prototype = new VisualGameObject;
function draw2(obj, context, ctx) {
context.textBaseline = "top";
var h = obj.h * iui.ratio;
context.font = 'lighter ' + h + 'px Arial';
GAMEABC_TEXT.prepare(context, h);
var w = context.measureText(obj.caption).width;
context.clearRect(0, 0, w, h);
if (obj.uidata.BackColorA != 0) {
@@ -5319,7 +5359,7 @@ function draw2(obj, context, ctx) {
context.fillRect(0, 0, w, h);
}
;context.fillStyle = obj.uidata.FontColor;
context.fillText(obj.caption, 0, 0);
context.fillText(obj.caption, 0, GAMEABC_TEXT.baseline(0, h));
ctx.drawImage(iui.test, 0, 0, w, h, obj.x, obj.y, w / iui.ratio, obj.h);
}
;function gameabc_txt() {
@@ -5340,13 +5380,12 @@ function draw2(obj, context, ctx) {
if (this.uidata.GameTxtStyle > 0) {
return;
}
;context.textBaseline = "top";
;GAMEABC_TEXT.prepare(context, this.h);
if (this.uidata.BackColorA != 0) {
context.fillStyle = this.uidata.BackColor;
context.fillRect(this.x, this.y, this.w, this.h);
}
;context.fillStyle = this.uidata.FontColor;
context.font = 'lighter ' + this.h + 'px Arial';
if (this.caption.indexOf("\n") > 0) {
var strs = new Array();
strs = this.caption.split("\n");
@@ -5355,11 +5394,11 @@ function draw2(obj, context, ctx) {
context.fillRect(this.x, this.y, this.w, this.h * strs.length);
}
;for (var i = 0; i < strs.length; i++) {
context.fillText(strs[i], this.x - xScroll, this.y - yScroll + this.h * i);
context.fillText(strs[i], this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll + this.h * i, this.h));
}
;return;
}
;context.fillText(this.caption, this.x - xScroll, this.y - yScroll);
;context.fillText(this.caption, this.x - xScroll, GAMEABC_TEXT.baseline(this.y - yScroll, this.h));
}
}
;gameabc_txt.prototype = new VisualGameObject;
+14172
View File
File diff suppressed because one or more lines are too long
+768 -768
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -1,19 +1,20 @@
//GameData.AgentId = "i33v0llvp0euhd1n9qo1fM2RV8vtog4y";
//GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
//GameData.ChannelId = "7N0e0z2u2098pf1M2fj0kyB1D4n4ylkA";
GameData.GameId = "8x4l0rGjf026f60c48h0mbUAhK5vV16f";
GameData.GameId = "jzWe0kzmN0tAcZ5MLks3rFWcxNecogyu";
//GameData.GameId = "dQyb0dvzz0afdj65hsb08QjMhzt94E1v";
GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS";
//GameData.ChannelId = "t1q802oz10p4rn0E99313xtu2sVi9Zru";
// GameData.AgentId = "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv";
// GameData.ChannelId = "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK";
// GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
// GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS";
GameData.AgentId = "1B2h0ccl205c390Y28m1Ajdplkuu4wgy";
GameData.ChannelId = "aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG";
//GameData.GameId = "Btke0urRy0cvPd5CIvD5yfhYhKhdSdex";
GameData.Version = "1.1";//真实版本号1 代理商 天盛网络 游戏
GameData.versionCode = 10000;//真实版本号
GameData.versionCode = 1;//真实版本号
@@ -1,6 +1,6 @@
# 01 · 前端架构与运行环境
本篇建立**全局认知**:前端跑在什么环境、由哪几层构成、新旧两套架构如何并存、文件按什么顺序加载。读懂这一篇,后面的渲染、系统、网络才有坐标。
本篇建立**全局认知**:前端跑在什么环境、由哪几层构成、新旧两套架构如何并存、文件按什么顺序加载。
> 举例以麻将为主,但 `gameabc-framework` 是游戏中立的通用框架,本篇机制对任意子游戏一致。
@@ -40,7 +40,7 @@ js/01_SubGame/ 子游戏前端
> `codes/` 内部如何分目录、如何命名文件,均由子游戏自行决定,本套文档不作规定;唯一例外是 `shared/`——它是服务端共享算法的同步副本,前端只读(见 05)。
依赖方向是**单向**的:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架**不反向依赖**任何子游戏(这正是上一步把 `EventBus` 里的麻将事件剥离出去的原因,见 03/05 的「框架中立」)。
依赖方向是**单向**的:`01_SubGame/codes` → `gameabc-framework` → `gameabc.min.js`。框架**不反向依赖**任何子游戏(见 03/05 的「框架中立」)。
---
@@ -88,8 +88,6 @@ js/01_SubGame/ 子游戏前端
1. **被依赖者先加载**。例如框架 `EventBus.js` 必须在子游戏事件常量之前、事件常量又必须在任何注册这些事件的视图之前;整合所有精灵常量的入口文件必须**最后**加载。
2. **新增文件要插对位置**。新增一个常量/组件文件,必须在 `index.html` 里插到其依赖之后、使用者之前,否则运行时拿到 `undefined`。
> 示例:把玩法专属事件从框架剥到子游戏事件常量文件时,正是把它插在 `EventBus.js`(框架)之后、其使用者(视图组件)之前,引用零改动。
---
## 5. codes/ 内部组织
@@ -111,4 +109,3 @@ js/01_SubGame/ 子游戏前端
- 加载顺序即依赖,新增文件务必插对位置。
下一篇 [02-渲染与UI组件体系](./02-渲染与UI组件体系.md) 讲:精灵怎么画、资源常量怎么组织、UI 组件怎么写。
</content>
@@ -11,49 +11,34 @@
gameabc 的画面由**精灵(Sprite)**组成,按**图层(Layer)**、**群组(Group)**组织。操作精灵走一条严格分层链:
```
UI 组件
└─ SpriteManager 业务级 API:ID 范围校验 + 单位换算
└─ GameABCUtils 唯一直接调引擎原生 API 的模块(底层原子操作)
└─ gameabc.min.js 引擎
UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
→ GameABCUtils(唯一直接调引擎原生 API 的模块) → gameabc.min.js(引擎)
```
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。分层的意义:ID 校验、单位换算、引擎版本隔离都集中在边界,业务层无感。
**铁律:UI 代码只调 `SpriteManager`,禁止直接调 `GameABCUtils` 或引擎原生 API**(`set_self`/`get_self` 等)。ID 校验、单位换算、引擎版本隔离都集中在这层边界,业务层无感。
### SpriteManager 常用 API
| API | 作用 | 说明 |
|-----|------|------|
| `show(id)` / `hide(id)` | 显示/隐藏精灵 | 返回 `boolean`,**失败要查返回值** |
| `setFrame(id, frame)` | 切换多帧图片的帧 | 一个精灵多帧,靠切帧表现不同牌面,**胜过为每张牌建一个精灵** |
| `show(id)` / `hide(id)` | 显示/隐藏 | 返回 `boolean`,**失败要查返回值** |
| `setFrame(id, frame)` | 切换多帧图片的帧 | 一精灵多帧、靠切帧表现不同牌面,**胜过为每张牌建一个精灵** |
| `setPosition(id, x, y)` | 设置坐标 | |
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
| `showGroup(gid)` / `hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer(lid)` / `hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer/hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `exists(id)` | 存在性检查 | |
```js
// 显示手牌:一个精灵切帧表现不同牌面
for (var i = 0; i < handCards.length; i++) {
var sid = handSpriteIds[i];
SpriteManager.show(sid);
SpriteManager.setFrame(sid, handCards[i].code - 1); // 帧号 = code-1
}
// 刷新弃牌区:先全隐藏,再按数据显示
for (var k = 0; k < maxDiscards; k++) { SpriteManager.hide(discardIds[k]); }
for (var j = 0; j < discards.length; j++) {
SpriteManager.show(discardIds[j]);
SpriteManager.setFrame(discardIds[j], discards[j].code - 1);
}
// 一精灵切帧表现不同牌面;刷新弃牌区先全 hide 再按数据 show + setFrame(帧号 = code-1)
SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
```
### 精灵 ID 二元性
精灵 ID 有两种,`SpriteManager` 透明支持,开发者无需区分:
- **数字 ID**:编辑器里预置的精灵(如 `1121`)。
- **字符串 ID**:运行时由 `SpriteCopyUtils` 动态复制出的精灵,格式 `"容器IDadd标签"`(如 `"2836add0"`)。
精灵 ID 有两种,`SpriteManager` 透明支持、无需区分:**数字 ID**(编辑器预置,如 `1121`)与**字符串 ID**(运行时由 `SpriteCopyUtils` 动态复制,格式 `"容器IDadd标签"`,如 `"2836add0"`)。
### ID 范围(必须遵守)
@@ -66,15 +51,15 @@ for (var j = 0; j < discards.length; j++) {
| 声音 | **≥ 101** | 1–100 |
| 图层 | **101–200、301–400、501–600、701+** | 1–100、201–300、401–500、601–700 |
图层按 100 为段与框架交替分配:子游戏用 `101–200`(常规界面)、`301–400`(弹窗,示例用 302)、`501–600`、`701+`;其余段为框架保留,**不可占用**。
图层按 100 为段与框架交替分配:子游戏用 `101–200`(常规界面)、`301–400`(弹窗,示例用 302)、`501–600`、`701+`;其余段框架保留,**不可占用**。
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `SpriteManager` 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
---
## 2. 资源与布局常量三件套
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职:
界面所需的“数据”全部集中在常量文件里,业务代码只引用、不内联裸值。三类常量各司其职,外加一个**整合入口**统一为一份精灵常量并**最后加载**:
| 常量类别 | 管什么 | 一句话 |
|----------|--------|--------|
@@ -82,13 +67,9 @@ ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `S
| 图片资源常量 | **图片资源 ID** | 每个精灵用哪张图、几帧、每帧什么、尺寸 |
| 布局常量 | **坐标/尺寸/偏移** | 纯数据,精灵摆在哪、多大、怎么排 |
外加一个**整合入口**把三类统一为一份精灵常量,并**最后加载**。
### 资源手动创建,常量靠注释指路
### 资源与精灵手动创建,常量靠注释指路
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在游戏编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的**完全一致**(见 §1,绝不编造)。
正因资源是"先手动建、再按 ID 引用",**常量定义必须写准注释,让人据注释就能准确创建出对应资源**:
**精灵、图片、声音、Spine 资源全部由开发者手动创建**——精灵在编辑器里逐个建好,图片/音频/Spine 文件按平台规范放入资源目录;**代码不创建任何资源,只按常量 ID 引用**,ID 必须与编辑器/资源目录里实际存在的完全一致(见 §1,绝不编造)。因此**常量注释必须写准,让人据注释就能准确创建对应资源**:
- **精灵结构常量**:每个精灵注明【精灵类型(图片精灵 / 文字精灵)+ 功能 + 关联的图片资源 ID + 帧数说明】。
- **图片资源常量**:每个资源注明【帧数(单帧 / N 帧,且每帧含义)+ 用途 + 尺寸】。
@@ -97,22 +78,16 @@ ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → `S
### 三者如何配合(新增一块 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,21 @@ 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 回来、网页刷新都复用它,不另写一套渲染)。
4. **显隐走 `showXxx` / `hideXxx` 接口**:组件(及每个「零件 UI」)的精灵/群组显隐,由组件自身暴露的 `showXxx()`/`hideXxx()` 语义方法控制;外部**只调这些接口**,**禁止**在别处直接用该组件/零件的精灵 ID、群组 ID 去 `SpriteManager.show/hide`(或 `showGroup/hideGroup`)——那样绕过组件、状态分散,重连/刷新时不一致。
```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 +142,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 +227,3 @@ list.onClick = function (type, rowIndex, rowData) { /* ... */ };
| 动态列表用 `DynamicSpriteList` 并 `destroy` | 手搓 SpriteCopy 又忘清理 |
下一篇 [03-事件·动画·音频·Spine](./03-事件·动画·音频·Spine.md) 讲表现系统:事件总线、动画、音频与 Spine。
</content>
@@ -21,18 +21,13 @@
throw new Error('[GameEvents] EventBus 未加载;请检查 index.html 加载顺序');
}
var E = EventBus.Events;
// 通用语义事件
E.GAME_STARTED = 'game:started';
E.PLAYER_DISCARDED = 'player:discarded';
E.SCENE_CHANGED = 'ui:sceneChanged';
// 玩法专属事件
E.TILES_DEALT = 'mahjong:tilesDealt';
E.MELD_FORMED = 'mahjong:meldFormed';
E.GAME_STARTED = 'game:started'; // 通用语义事件
E.TILES_DEALT = 'mahjong:tilesDealt'; // 玩法专属事件
// ...本子游戏用到的全部事件
})();
```
> 这正是本仓库的落点:框架 `EventBus` 不预置任何事件常量、保持纯粹中立,所有事件(含通用语义)都由子游戏在其事件常量文件定义——既保证框架可被任意玩法复用,也避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
> 框架 `EventBus` 不预置任何事件常量、保持纯粹中立,避免「引用了未定义常量」导致的 `emit(undefined)`(见 05「框架中立」)。
### 用法
@@ -60,7 +55,7 @@ EventBus.once(EventBus.Events.GAME_FINISHED, fn); // 一次性
3) 动画完成回调里只刷新静态界面(refresh)
```
好处:动画是“锦上添花”,**即使不播动画,静态界面也始终正确**(据组件 `this.data` 调 `refresh` 即可还原,见 02 组件范式 / 04 重连)。**动画的开始/结束/出错等生命周期回调里只刷新界面、绝不设置核心数据**;动画期间**绝不修改游戏数据**。这样动画卡住/播错、或开发前期没有动画时,数据、逻辑与界面依然正确、互不影响。
即使不播动画,静态界面也始终正确(据组件 `this.data` 调 `refresh` 即可还原,见 02 组件范式 / 04 重连)。**动画的开始/结束/出错等生命周期回调里只刷新界面、绝不设置核心数据**;动画期间**绝不修改游戏数据**。
### AnimationManager API(框架,通用)
@@ -117,12 +112,7 @@ 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**。
```js
// 子游戏音频管理:按概念播放,内部映射到资源键并按座位性别选男/女语音
// 通用音效直接走框架:AudioManager.playSound(音效资源常量.某音效)
```
- **游戏音频管理(子游戏)**:把“出牌/碰/某张牌”等概念映射到音效资源键,再调 `AudioManager`。**不写文件名/数字 ID**。通用音效直接走框架 `AudioManager.playSound(音效资源常量.某音效)`。
**声音文件手动准备**:音频文件由开发者**手动**按平台命名规范放入资源目录(文件名/存放目录/ID→文件名转换规则以平台的资源管理接口规范为准),代码只按 ID 引用、不生成文件。因此**音效资源常量每个 ID 必须注明【类型(背景音乐 / 音效 / 语音)+ 用途 + 时长 + 播放方式(循环 / 单次)】**,让人据注释就能准确准备对应音频。
@@ -181,4 +171,3 @@ Spine 骨骼资源(`json` / `atlas` / 贴图)由开发者**手动**制作并
| Spine | 概念走 Spine 动作配置,回调走 Spine 回调分发器,改资源跑脚本 | 硬编码 spineId/animName;散接回调 |
下一篇 [04-网络对接与启动编排](./04-网络对接与启动编排.md) 讲:怎么收发包、新旧架构如何对接、一局怎么启动。
</content>
@@ -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"/子游戏路由
```
@@ -34,17 +33,30 @@ RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路
> 平台字段缺失(如 `playerid` 为 0/空)应 fail-fast 暴露,不静默发出残缺包。
### 请求包只带「意图」,不带结论
**请求包表达的是「我想做什么」,不是「结果是什么」。** 这是前端数据驱动架构([05 §6](./05-开发规范与红线.md))在发包侧的必然推论:前端不是数据源,凡由前端算出并回传的"结论",都等于把裁定权交给了不可信的客户端。
| 可以带(意图) | 禁止带(结论) |
|---|---|
| 操作类型(出牌/碰/杠/过/胡/叫分…) | 算好的得分、番数、结算金额 |
| 目标标识(牌的 `uniqueId`、`targetCard`、`choiceIndex`) | "我胡了 / 我听了 / 这步合法" 之类的判定结果 |
| 座位号(仅供服务端做一致性校验,**不作身份依据**) | 下一阶段是什么、下一个该谁、剩余时间 |
| 纯客户端偏好(音量、语言等非对局字段) | 手牌全量、他人信息等本应由服务端持有的状态 |
- **服务端按自己的权威数据重算**,对请求里出现的结论字段一律**忽略**(服务端侧见 [04 §8](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端));协议设计阶段就**不应该定义**这类入参——定义了它,就是留了一个可被伪造的洞。
- **前端 `shared/` 算出的结果不回传**:它只用于本地提示与预校验(见 05 §6.1、§8),发包时只发意图。
- **违例信号**:发包方法里出现 `score`、`isWin`、`nextSeat`、`phase`、`result`、`handCards` 之类"由前端填的结论字段"。
---
## 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 一条
};
@@ -90,7 +102,12 @@ Game_Modify.StartWar = function (_msg) {
### 重连(Reconnect)= 重画
重连入口拿到服务端 `get_deskinfo` 的完整快照,交由新架构的重连处理**据本地数据重建整个界面**。重连与“切 app 重画”应复用同一条重画路径——**任何时候执行重画函数都能还原正确界面**(如网页刷新)。这与「数据优先、表现延后」一脉相承:界面永远能从数据无歧义地重建。
前端**必须同时处理两种恢复场景**,二者本质相同、复用同一条重画路径:
- **断线重连**:`Game_Modify.Reconnect` 拿到服务端 `get_deskinfo` 的完整快照,交给新架构的重连处理。
- **硬刷新 / 页面重载**:浏览器刷新后从零重启,同样要能还原到当前对局界面。
**重连处理的本质 = 恢复数据 → 恢复界面**:先据快照把各组件的 `this.data` 恢复齐,再逐个调用各 UI 组件的 `setXxx`/`refreshXxx`(或组件总 `refresh()`)据数据重建界面与交互状态。**绝不为重连单写一套渲染**——它复用「组件数据自持 + set-refresh」范式(见 02 §3、05 §6)的同一条重画路径,因此**任何时候执行重画都能从数据无歧义地还原正确界面**。这与「数据优先、表现延后」一脉相承。
**红线**:受限文件 `Game_Modify.*` 里**只接不写**,业务逻辑全部在新架构 handler 中。
@@ -103,7 +120,6 @@ Game_Modify.StartWar = function (_msg) {
前端 RPC 没有“同步返回”,操作结果由服务端**后续主动推送**告知。判成败的唯一权威是推送 `data` 里的 **`success`**:
```js
// 处理器统一写法
function handleXxx(data) {
if (!data || !data.success) { /* 失败处理 */ return; }
// 成功逻辑
@@ -115,7 +131,81 @@ function handleXxx(data) {
---
## 5. 启动编排
## 5. 输入—渲染解耦:发包只请求,收包才表现
> 专业名:**服务端权威的悲观 UI 更新(Server-Authoritative Pessimistic Rendering)** / 单向数据流下的**输入-渲染解耦**。
**核心时序原则:用户点击只负责发出请求包,绝不直接改动任何对局状态界面;一切随对局状态变化的表现,只在收到服务端下发的结果/推送包后才更新。**
发包与表现是两条独立通道:**点击发「命令」,收包应「事件」,UI 只订阅事件**。界面永远是服务端已确认状态的投影,不做「点击即更新」的乐观预测。
### 5.1 点击回调的职责边界
点击回调**只做两件事**:①组织并发送请求包(走 §1 语义化发包封装);②(可选)纯本地物理反馈。**不得**在点击回调里改动任何对局状态界面。
| 归类 | 例子 | 点击时可否做 |
|------|------|--------------|
| 纯本地物理反馈(不碰领域状态) | 按钮按下高亮/缩放/音效 | ✅ 可即时 |
| **响应/掷骰交互按钮的隐藏**(本玩家点击的碰/杠/过/胡按钮、手动掷骰按钮) | 点击发包即隐藏该按钮(兼作防连点) | ⚠️ **受控例外**允许乐观清除——**前提是服务端合法性验证 + 收包侧兜底**(见 5.6) |
| 其余对局状态表现(领域状态) | 各类提示(等待听牌/报定等待/掷骰提示文字)、当前控制权高亮、启停倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露 | ❌ **禁止**乐观,一律等收包 |
判别标准:**这个变化服务端要不要确认?** 要 → 收包后更新(除 5.6 例外的交互按钮);纯本地物理反馈、服务端根本不关心 → 可即时。
### 5.2 为什么必须如此(与 AI 托管同源)
真人操作与 AI 托管/他人操作**共用同一后半段**:`服务端处理 → 下发结果包 → 前端更新`。
```
真人: 点击 → 发请求包 →┐
├→ 服务端处理 → 下发结果包 → 收包处理器(唯一更新点)
AI 托管:服务端 AI 决策 →┘
```
把界面更新一律挂在「**收包**」这个节点,则无论操作由真人点击还是服务端 AI 自动触发,前端表现都自动一致、**无需区分触发源**(渲染透明)。反之若挂在「点击」节点:AI 托管时**根本没有点击动作**,服务端自动发包后对应更新代码永不触发 → 界面卡死、提示不消失、按钮不刷新。
这正是根目录 CLAUDE.md「AI 托管数据一致性 / 对前端透明」在**前端时序维度**的必然要求,也与「前端不推测、当前玩家由服务端权威字段(如 `nextControlSeat`)给出」一脉相承。
### 5.3 收包处理器必须「触发源无关」
同一收包处理器既服务真人操作回包、也服务 AI 托管与他人操作广播。因此:
- **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。
- **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。
- **不得据本地推断补齐服务端未下发的状态**:包里没有的阶段/控制权/可用操作,不由前端算出来顶上——那是服务端漏发,修在服务端发包处(见 [05 §6.1](./05-开发规范与红线.md)、服务端 [03 §1.2](../../server/development-guide/03-数据收发与通信协议.md))。
### 5.4 适用范围
凡「随对局状态变化」的表现均适用,包括但不限于:交互提示(手动掷骰提示文字 / 请出牌 / 等待其他玩家听牌 / 报定等待)、当前控制权高亮(按包内 `nextControlSeat`)、倒计时(按包内剩余时间启停)、手牌/牌河/亮牌、他人副露、阶段与托管图标。
> **例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(见 5.6);但这些按钮的**显示**,以及一切**提示**(含掷骰提示文字、等待听牌、报定等待),仍严格收包驱动。
### 5.5 验收标准
每处改动须验**两条路径表现一致**:
1. **真人手动操作**:点击后界面在收到回包时才更新(点击瞬间不抢先变化)。
2. **服务端自动发包**(AI 托管 / 他人操作广播):无任何点击,界面同样在收到推送包后正确更新,且与真人路径**表现完全一致**。
两条都验过且一致,方为合规。
### 5.6 受控例外:响应交互按钮乐观清除 + 服务端合法性验证
对**本玩家点击触发的响应交互按钮**(碰/杠/过/胡 操作按钮、手动掷骰按钮),**允许**在点击发包时**乐观隐藏**该按钮——它同时充当防连点(按钮没了就点不了第二次)与即时反馈。这是对 5.1 悲观 UI 的**受控例外**,成立必须**同时满足**下列三条,缺一不可:
1. **服务端合法性验证兜底(正确性根本)**:正确性**绝不依赖**前端乐观隐藏。服务端对每个操作请求做完整合法性验证(座位鉴权 / 阶段门 / 操作可用性 / 幂等去重),默认拒绝非法/重复/越权/乱序包并回 `success:false` 且**不改状态**。即便乐观隐藏被绕过、或非正规客户端狂发包,服务端也正确拒绝。详见服务端 [04 §8 操作请求合法性验证](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。
2. **收包侧兜底同样成立(AI 托管一致)**:因 AI 托管/他人操作**无点击**、不触发乐观隐藏,该按钮的清除**必须**在收包侧同样能发生——碰/杠/吃后操作者收非空 `availableActions` 覆盖刷新、胡后收包隐藏操作按钮、掷骰结果收包 `hideManualDiceUI`、「过」由收包据服务端下发的空 `availableActions` 清(或"进入托管即隐藏交互"覆盖)。**不得因加了乐观清除就删除收包兜底**。
3. **仅限交互按钮、不外扩**:例外只覆盖"玩家自己点击的响应/掷骰交互按钮"。其余一切表现(等待听牌/报定等待/掷骰提示文字等**提示**、当前控制权高亮、倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露)**仍严格收包驱动**。
一句话:**乐观清除是即时反馈 + 防连点,收包侧与服务端验证才是权威——三者并存,不是用乐观清除替代收包/服务端。**
**例外项验收**(5.5 两条路径一致仍成立,只是真人侧多了"乐观隐藏"一步):
- 真人:点击 → 按钮立即隐藏(乐观);服务端验证通过走正常流程,若拒绝(`success:false`)则按钮由收包纠正(重显或按服务端权威保持隐藏)。
- AI 托管:无点击 → 按钮由收包侧隐藏,表现与真人一致(**重点验「过」不残留**)。
- 连点/非法包:服务端拒绝,状态不变。
---
## 6. 启动编排
一局前端的启动由一段**启动编排**一次性完成(经 `Game_Modify.appStart` 触发):
@@ -131,7 +221,7 @@ function handleXxx(data) {
---
## 6. controllers 与 managers 职责
## 7. controllers 与 managers 职责
| 类别 | 角色 | 典型成员 |
|------|------|----------|
@@ -156,16 +246,19 @@ function handleXxx(data) {
---
## 7. 本篇 DO / DON'T
## 8. 本篇 DO / DON'T
| DO ✅ | DON'T ❌ |
|------|---------|
| 发包走语义化发包封装/`RpcHelper` | 业务里直接 `Utl.sendData` 手拼包 |
| 请求包只带意图(操作类型+目标标识) | 包里回传前端算出的分数/判定/阶段等结论字段 |
| 阶段/控制权/可用操作/倒计时只读包内权威字段 | 前端自建对局状态机、本地推导或用定时器自行推进阶段 |
| 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 |
| 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 |
| 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 |
| 点击只发请求包,对局状态表现等收包后更新(响应/掷骰交互按钮乐观清除为 5.6 受控例外) | 提示/控制权/倒计时/落牌等点击即更新(乐观预测)——AI 托管无点击时界面卡死 |
| 收包处理器触发源无关,数据从包字段读 | 依赖「点击时暂存的本地变量」渲染 |
| 重连/重画复用同一路径,据数据重建界面 | 重连单写一套与正常对局不同的渲染 |
| 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 |
下一篇 [05-开发规范与红线](./05-开发规范与红线.md) 汇总前端所有工程纪律。
</content>
@@ -40,7 +40,7 @@
- 资源/布局 → 子游戏精灵结构/图片资源/布局常量。
- **违例信号**:框架文件里出现 `mahjong:`、牌型、吃碰杠胡等具体玩法字样(注释举例除外,且举例应尽量中立)。
> 范例:本仓库把框架 `EventBus.js` 内**所有**预定义事件常量清空(只留空容器 `EventBus.Events={}`),全部事件(通用语义 + 玩法专属)改由子游戏的事件常量文件定义;并把通用的精灵事件控制器从子游戏抽到框架 `system/SpriteEventController.js`、剥离其中的玩法标记耦合为「全局 draw 钩子」;把 `UIManager` 的全局 UI(Loading/Message/Confirm)从「主动读子游戏精灵常量」改为「由子游戏 `init(config)` 注入」。新玩法照此扩展,框架零改动。
> 范例:框架 `EventBus.js` 预定义事件常量全部清空(只留 `EventBus.Events={}`),事件改由子游戏事件常量文件定义;通用精灵事件控制器抽到框架 `system/SpriteEventController.js` 并把玩法标记耦合改为「全局 draw 钩子」;`UIManager` 的全局 UI(Loading/Message/Confirm)由子游戏 `init(config)` 注入而非主动读子游戏常量。新玩法照此扩展,框架零改动。
---
@@ -49,12 +49,13 @@
- **精灵只走 `SpriteManager`**:UI 代码禁止直接调 `GameABCUtils`/引擎原生 API。
- **ID 守范围**:精灵 1001–3000、群组 ≥201、普通图层 101–200、弹窗图层 301–400(另有 501–600、701+ 备用段);框架保留 1–1000 及 3001+(精灵)等交替段,不可占用;ID 必须与编辑器一致,不编造。
- **检查返回值**:`SpriteManager.*` 返回 `false` 即 ID/范围有误,及时暴露。
- **常量集中、禁硬编码**:精灵 ID / 图片资源 ID / 坐标尺寸 / 动画时长帧数 / 音效 ID / 事件名,一律定义在对应常量文件,业务代码只引用:
- 精灵结构 → 精灵结构常量(主界面/弹窗分文件)
- **常量集中、禁硬编码**:UI 代码**禁止出现任何硬编码的 id 或裸值**——精灵 ID / 群组 ID / 图层 ID / 图片资源 ID / 声音 ID / Spine 资源 / 坐标尺寸 / 动画时长帧数 / 事件名,一律定义在对应常量文件,业务代码**只引用常量**:
- 精灵结构(含群组 / 图层 ID)→ 精灵结构常量(主界面/弹窗分文件)
- 图片资源 → 图片资源常量
- 布局坐标 → 布局常量
- 动画参数 → 动画配置
- 音效 ID → 音效资源常量
- 音效 / 语音 ID → 音效资源常量
- Spine 资源 → Spine 动作配置
- 事件名 → `EventBus.Events`(专属在子游戏事件常量文件)
- 整合入口 → 精灵常量整合入口(**最后加载**)
@@ -69,12 +70,47 @@
---
## 6. 数据权威、组件数据与表现延后
## 6. 数据驱动架构:服务端状态的投影
**前端是服务端对局状态的一个投影(view),不是状态的第二个来源。** 阶段、轮次、控制权、可用操作、倒计时、分数、按钮可用性……一切对局态都由服务端唯一维护并随包下发,前端只做「**读包字段 → 写 `this.data` → 据 `this.data` 画界面**」这一条链路。这既是正确性要求(两端各推一套必然分叉),也是**反作弊**要求:**前端能自己推出来的东西,就是玩家能改的东西**。
- **服务端权威**:所有核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为「渲染副本」,不做权威计算。
### 6.1 前端无对局状态机
- **状态机唯一在服务端**:阶段(`phase`)、当前控制权(轮到谁)、该座位可用操作(`availableActions`)、倒计时剩余、比分/结算——一律**读服务端下发的权威字段**渲染。
- **禁止本地推导**:不得由「上一个包 + 本地规则」推出「现在该轮到谁 / 现在进入哪个阶段 / 现在该显示哪几个按钮」。缺字段是**服务端漏发**,修在服务端发包处(见服务端 [03 §1.2](../../server/development-guide/03-数据收发与通信协议.md)),**前端不推、不补、不猜**。
- **禁止前端推进流程**:不得用本地定时器自动推进阶段、自行结算、超时后自判胜负或自动补发包。**超时/托管一律由服务端驱动**,前端只显示服务端下发的结果。
- **倒计时的正确形态**:服务端下发锚点/剩余时长,前端可本地 tick 做插值显示;但**归零不代表状态改变**——超时的裁定与后续推进仍等服务端推送。
- **`shared/` 预判不是状态**:前端跑 `shared/` 算出的胡牌/听牌/合法性只用于**提示与预校验**(如置灰不可点的牌),**不得**据其改写对局态、也**不得**用来替代服务端下发的 `availableActions`(见 §8)。
### 6.2 视图 = f(服务端快照)
- `this.data` 是**服务端状态的镜像**,不是第二份真相;**不得存在「只活在前端、服务端不知道」的对局态**。
- **判据(可直接用于自检与代码审查)**:任意时刻丢弃全部 `this.data`,仅用**最近一次服务端快照**(重连 `get_deskinfo` 或最近一次推送)重画,界面与交互状态必须**完全一致**。做不到 → 要么前端私存了对局态、要么服务端漏发了字段,二者必居其一,**都要修**。
- 这与「重连即重画」是同一条路径(见 [04 §3.3](./04-网络对接与启动编排.md)):**重连之所以能只靠服务端快照还原,正因为前端从来没有过独占状态。**
### 6.3 允许的本地 UI 态(白名单)
只有**不影响对局裁定、服务端根本不关心**的纯表现态,才可以只存在于前端:
| 允许只在前端 | 不允许(属对局态,必须来自服务端) |
|---|---|
| 选中/待出牌的高亮、拖拽位置 | 这张牌能不能出、出了之后轮到谁 |
| 按钮按下高亮/缩放、点击音效 | 按钮**该不该出现**、**能不能点** |
| 列表滚动位置、面板展开、设置开关 | 阶段、控制权、倒计时基准、分数、结算 |
| 动画进度、特效播放中标记 | 手牌/牌河/副露内容、亮牌信息、托管状态 |
判别:**这个值若被玩家改成任意值,会不会影响对局结果、或让他看到/做到本不该的事?** 会 → 它是对局态,必须服务端权威。
> **与 [04 §5.6](./04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证) 受控例外的关系**:本玩家点击响应/掷骰交互按钮后的「乐观隐藏」仍然允许——它是抢先一步做了服务端稍后会确认的事,**不是前端私有状态**:按钮**该不该出现**依旧由服务端下发的 `availableActions` 决定,收包侧必须能独立得出同一结果(AI 托管无点击时也正确)。判据仍成立:丢弃 `this.data` 后按最近快照重画,该按钮的显隐与服务端一致。
### 6.4 组件数据与表现延后
- **组件数据自持 + set-refresh**:组件(及其下每个「零件 UI」)的自有数据集中在 `this.data`,**不散落**各处;每个 UI 都有 `setXxx`(只写数据)/`refreshXxx`(只据数据画界面)成对方法,组件另有总 `refresh()` 据 `this.data` 重建整块界面(见 02)。
- **更新时机——发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,**绝不**在点击时改动任何对局状态界面(提示显隐、按钮增删、当前控制权、倒计时启停、落牌、阶段/托管图标);这些表现一律在**收到服务端结果/推送包后**更新。点击回调只做「发包 +(可选)纯本地物理反馈(按下高亮/音效、防连点 `disable`)」。收包处理器**触发源无关**——数据从包字段读、不依赖「点击时暂存的本地变量」,故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**(无点击时也正确更新);把更新挂在「点击」而非「收包」会导致 AI 托管时界面卡死。详见 [04 §5](./04-网络对接与启动编排.md#5-输入渲染解耦发包只请求收包才表现)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 [04 §5.6](./04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证) 与服务端 [04 §8](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端);其余提示/控制权/倒计时/落牌仍严格收包驱动。
- **收包节奏**:**先 `setXxx` 写数据 →(必要时 `refresh` 刷静态界面)→ 再播动画 → 动画回调里只刷新界面**;动画的开始/结束/出错等生命周期回调里**绝不设置核心数据**,动画期间**不改数据**。
- **重画随时可用**:任何时候调 `refresh` 都能据 `this.data` 重建正确界面(网页刷新/断线重连/切 app 复用同一路径,不为重连单写一套渲染)。
- **重画随时可用(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 `setXxx`/`refreshXxx` 恢复数据与界面状态**;任何时候调 `refresh` 都能据 `this.data` 重建正确界面(断线重连、硬刷新/网页重载、切 app 复用**同一条**重画路径,不为重连单写一套渲染,详见 04 §3.3)。
- **动画是体验层**:开发阶段可**先不做动画**只保证静态界面正确;即使动画缺失/卡住/播错,数据、逻辑与界面仍正确、互不影响。
---
@@ -92,13 +128,15 @@
- **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。
- `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。
- 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §8 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §9 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#9-shared-文件同步流程)。
---
## 9. 模块职责边界
- 一个职能只在一个模块实现,其他模块**调用而非重造**:渲染找 `SpriteManager`、动画找 `AnimationManager`(及游戏动画封装)、音频找游戏音频管理、Spine 找 `SpineMgr`(及 Spine 回调分发)、发包找语义化发包封装、收包分发找收包分发器。
- **UI 组件专职自己的界面**:每个界面的数据与渲染**只由其对应 UI 组件实现**,组件对外提供 `setXxx`/`refreshXxx` 与语义化公开方法。别的模块(controllers/managers/其他组件)要改动或刷新某界面,一律**调用该组件的公开接口**,**禁止**在别处重复实现重叠或类似的界面数据/渲染逻辑(否则同一界面出现多份状态源,重连/刷新时必然不一致)。
- **显隐走 `showXxx`/`hideXxx` 接口**:UI 组件及其「零件 UI」的精灵/群组显隐,必须由组件自身暴露的 `showXxx()`/`hideXxx()` 语义接口控制;**禁止**在别处直接用该组件/零件的精灵 ID、群组 ID 去 `SpriteManager.show/hide`(或 `showGroup/hideGroup`)控制其显隐——绕过组件即状态分散,重连/刷新时必然不一致。
- 写代码前先问“这段属于谁的职责”,属于别人就调用它,不在本模块复制一份近似实现。
---
@@ -119,15 +157,19 @@
| 语言 | 严格 ES5;新文件插对加载顺序 |
| 框架中立 | 框架无玩法逻辑/专属常量;专属内容归子游戏 |
| 渲染 | 只走 `SpriteManager`;ID 守范围、不编造;查返回值 |
| 常量 | 精灵/资源/坐标/动画/音效/事件全集中,禁硬编码裸值 |
| 常量 | 精灵/群组/图层/图片/声音/Spine/坐标/动画/事件全进常量;UI 代码禁硬编码 id 与裸值 |
| 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 |
| 数据驱动 | 前端**无对局状态机**:阶段/控制权/可用操作/倒计时/分数只读包内权威字段,禁本地推导与本地推进流程;`this.data` 只是服务端快照的镜像,丢弃后仅凭最近快照重画须完全一致;只有白名单纯表现态可只存在于前端(§6.1–6.3) |
| 发包内容 | 请求包只带「意图」(操作类型+目标标识),**不带结论**(分数/判定结果/阶段指令);服务端按自己的权威数据重算(04 §1「请求包只带意图」) |
| 数据 | 服务端权威(核心运算/裁定在服务端);组件自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对、组件有总 `refresh` |
| 更新时机 | 点击只发请求包,对局状态表现等**收包后**更新(悲观 UI);收包处理器触发源无关、数据从包字段读;AI 托管/他人广播共用同一更新路径。**受控例外**:响应/掷骰交互按钮隐藏可乐观清除(防连点+即时反馈),前提是服务端合法性验证+收包兜底(04 §5.6、服务端 04 §8) |
| 节奏 | 先写数据后表现;动画回调只刷界面、**不写核心数据**;重画可随时据数据还原界面 |
| 成败 | 只认 `data.success`,禁 `status` 兜底 |
| 收发包 | 发走语义化发包封装、收走收包分发器;业务不进 `Game_Modify` |
| 职责 | 一职能一模块,调用不重造 |
| UI 职责 | 每个界面的数据/渲染/显隐只在其 UI 组件实现;别处调组件接口(含 `showXxx`/`hideXxx`),不重叠重造、不直接用其精灵/群组 id 控显隐 |
| 重连 | 断线重连 + 硬刷新都要处理;本质=恢复数据→各组件 set-refresh;复用同一重画路径 |
---
至此,从架构与环境(01)、渲染与组件(02)、表现系统(03)、网络与启动(04)到工程红线(05),构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。
</content>
至此 01–05 构成一套完整的前端子游戏开发指导。回到 [README](./README.md) 查看导航与分层模型。
@@ -44,21 +44,8 @@
`00` 是平台配置项的**结构定义**文件,配置项的**集合与结构由平台/框架固定**。子游戏接入只做一件事:**把已有配置项的值改成本游戏需要的**。
✅ **允许(改值)**:
```js
Game_Config.Max.PlayerCnt = 4; // 改人数
Game_Config.Share.title = "进贤麻将"; // 改分享标题
Game_Config.Info.TextContent = ["你好","谢谢","快点","不要","加油","收到","稍等"]; // 改常用语的【值】
Game_Config.Chat.ChatLoc = [[35,517],[1100,315]/* ... */]; // 改聊天气泡坐标【值】
```
❌ **禁止(改定义 / 结构)**:
```js
Game_Config.Info.MyNewField = 1; // ✗ 新增配置项(增定义)
delete Game_Config.Voice; // ✗ 删除配置项(删定义)
Game_Config.Max = [4]; // ✗ 改变项的类型/结构
// ✗ 重命名平台已定义的字段(如把 PlayerCnt 改成 playerCount)
```
- ✅ **允许(改值)**:给已有配置项赋值,如 `Game_Config.Max.PlayerCnt = 4;`、`Game_Config.Share.title = "进贤麻将";`、改 `Game_Config.Info.TextContent`(常用语)/`Game_Config.Chat.ChatLoc`(气泡坐标)等数组元素的【值】。
- ❌ **禁止(改定义 / 结构)**:新增配置项(`Game_Config.Info.MyNewField = 1;`);删除配置项(`delete Game_Config.Voice;`);改变项的类型/结构(`Game_Config.Max = [4];`);重命名平台已定义字段(如 `PlayerCnt` → `playerCount`)。
> **为什么**:平台代码(`00_Surface/*`)按**固定字段名**读取 `Game_Config.*`。增 / 删 / 改名定义会让平台读到 `undefined` 或破坏约定,引发线上故障。**配置项的集合与结构是平台契约,只有「值」属于子游戏**。
>
@@ -75,12 +62,10 @@ 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/,已有)
@@ -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,15 @@ 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 文件顶部设置「配置区」,由子游戏直接填值:
```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 = {}; // 游戏配置
```
模板在 01 文件顶部设置「配置区」,由子游戏直接填值(与转发壳接口段物理分开):`Game_Modify.combat/roomDes/Type_1/Type_2/CreateRoomData/game_config`。
### D 类 — 模板默认 UI 渲染(可变人数)
@@ -211,17 +168,6 @@ Game_Modify.game_config = {}; // 游戏配置
目前仅 `appStart` 存在此冲突。`gameHallImport` 侧所有同名接口均加 `hall` 前缀以区分。
```js
// SubGameHooks.js 中的写法
SubGameHooks.appStart = function () {
// 对应 Game_Modify.appStart:委托子游戏的启动编排
};
SubGameHooks.hallAppStart = function () {
// 对应 gameHallImport.appStart(大厅启动):委托子游戏的大厅初始化
};
```
---
## 6. 接口覆盖要求
+6 -39
View File
@@ -18,56 +18,24 @@
| 篇 | 文档 | 解决什么问题 |
|----|------|--------------|
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
| 00 | 本文 README | 这套文档是什么、怎么读 |
| — | [红线速查.md](./红线速查.md) | **一页纸分层模型 + 红线清单**(由 `CLAUDE.md` 常驻加载,改代码前先对照) |
| 01 | [01-前端架构与运行环境.md](./01-前端架构与运行环境.md) | 双运行时/ES5、平台/框架/子游戏三层、新旧架构并存、目录与加载顺序 |
| 02 | [02-渲染与UI组件体系.md](./02-渲染与UI组件体系.md) | 精灵 ID 体系、SpriteManager 分层、资源常量组织、BaseComponent 组件化、组件数据/set-refresh 范式、UIManager 场景、动态列表 |
| 03 | [03-事件·动画·音频·Spine.md](./03-事件·动画·音频·Spine.md) | EventBus、AnimationManager+配置、AudioManager+音效资源、SpineMgr 全链路 |
| 04 | [04-网络对接与启动编排.md](./04-网络对接与启动编排.md) | 发包链路(RpcHelper 注入平台字段)、收包统一分发、新旧架构对接边界、启动编排、处理器/管理器职责 |
| 05 | [05-开发规范与红线.md](./05-开发规范与红线.md) | 可编辑范围、ES5、框架中立、常量集中、组件生命周期、服务端权威/组件数据/表现延后、data.success、模块职责、测试 |
| 05 | [05-开发规范与红线.md](./05-开发规范与红线.md) | 可编辑范围、ES5、框架中立、常量集中、组件生命周期、**数据驱动架构(前端无状态机/视图=服务端快照投影/本地 UI 态白名单)**、组件数据与表现延后、data.success、模块职责、测试 |
| 06 | [06-子游戏接入模式与Hooks外置.md](./06-子游戏接入模式与Hooks外置.md) | 内联模式 vs Hooks 外置模式、三契约文件可改性、退化为纯转发壳 + SubGameHooks 委托、subgame-entry 模板 |
建议第一次**从 01 顺序读到 05**;之后把 02–05 当手册随用随查。06 在接入新游戏或迁移到 Hooks 外置模式时选读。
---
## 一页纸:前端分层模型
## 一页纸与红线
```
gameabc.min.js(引擎,js/vendor,第三方)
▲
GameABCUtils(core,唯一直接调引擎原生 API 的模块)
▲
SpriteManager(core,业务级精灵 API:ID 校验 + 单位换算) EventBus / AnimationManager / AudioManager / SpineMgr(system)
▲ ▲
BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(游戏中立,可复用)
══════════════════════════════════════════════════════════════════════════
01_SubGame/codes(子游戏实例,单向依赖框架;内部结构由子游戏自行组织)
shared 前后端共享算法(与服务端同源,脚本同步,只读)
══════════════════════════════════════════════════════════════════════════
旧受限对接层(平台入口,尽量不改)
00/01/02_SubGame_*.js → Game_Modify.StartWar / Reconnect / _ReceiveData / appStart
```
**分层模型与红线清单已独立成篇:[红线速查.md](./红线速查.md)。**
- **框架(gameabc-framework)**:游戏中立,提供精灵/组件/事件/动画/音频/Spine 通用能力,可被任何子游戏复用。
- **子游戏(01_SubGame/codes)**:单向依赖框架,在其内部自由组织实现(目录/文件命名由子游戏自定,仅 `shared/` 为只读同步副本)。
- **旧受限对接层**:平台框架的固定入口(`Game_Modify.*`),把平台事件转交给新架构,**尽量不改**(详见 01/04)。
---
## 红线速查(详见 05)
- **可编辑范围**:前端平台代码 `js/00_Surface/` 禁改;受限接口文件 `01_SubGame/00_/01_/02_SubGame_*.js` 不新增接口、尽量不改;其余在 `gameabc-framework/`、`01_SubGame/codes/` 内开发。
- **严格 ES5**:用 `var`/`function`/`Object.create`,禁 `let`/`const`/箭头/模板串/`class`。
- **框架游戏中立**:`gameabc-framework/` 内**不得**出现任何具体玩法逻辑或专属常量;玩法专属的事件/资源/配置一律定义在子游戏侧(如玩法事件在子游戏的事件常量文件里追加到 `EventBus.Events`)。
- **精灵只走 SpriteManager**:UI 代码**禁止**直接调 `GameABCUtils` 或引擎原生 API;ID 必须落在规定范围(精灵 1001–3000、群组 ≥201、图层 101–200/弹窗 301–400,另有 501–600、701+ 备用段,框架保留 1–1000 及 3001+ 等交替段)。
- **常量集中、禁硬编码**:精灵 ID/图片资源 ID/坐标尺寸/动画时长/音效 ID 一律定义在对应常量文件,业务代码引用,**不内联裸值**。
- **服务端权威、前端只展示**:核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为渲染副本,不做权威计算。
- **组件走 BaseComponent**:UI 组件继承 `BaseComponent`,事件用 `this.addEventListener` 注册(`destroy()` 自动清理,防泄漏);动态精灵/定时器在 `onDestroy` 清理。
- **组件数据自持 + set-refresh**:组件自有数据集中 `this.data`;每个 UI/零件有 `setXxx`(只写数据)/`refreshXxx`(只据数据画)对,组件有总 `refresh()` 可据数据重建界面。
- **数据优先、表现延后**:收到推送先 `setXxx` 写数据、再播动画;动画的开始/结束/出错回调里**只刷界面、不写核心数据**。即使动画缺失/卡住/出错,数据、逻辑、界面仍正确、互不影响。
- **成败只认 `data.success`**:前端一律 `if (!data.success)` 判成败,**不**用 `status`/`code`,**不**写 `status` 兼容兜底。
- **收发包走统一通道**:发包经统一发送封装(底层 `RpcHelper` 自动注入平台字段),收包统一分发(一 rpc 一处理器);业务逻辑放处理器,**不**写进 `Game_Modify.*`。
- **shared 只读**:`01_SubGame/codes/shared/` 是服务端 `shared/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是红线的权威源;本 README 只负责导航,不重复抄写红线(避免两处不同步)。红线的完整细节见 [05-开发规范与红线.md](./05-开发规范与红线.md)。
---
@@ -76,4 +44,3 @@ BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(
- 服务端的对应文档见 [服务端开发指导文档](../../server/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。
- 子游戏前端各层可能另有局部说明文档;本套是总纲,与之不冲突时以本套的通用原则为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲前端接入与红线,工程通则讲前后端通用的设计方法论,互补阅读。
</content>
@@ -0,0 +1,53 @@
# 前端 · 一页纸分层模型与红线速查
> 本文是前端开发**必须常驻在手边**的那一页:分层模型 + 红线清单。
> 它由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文;**红线以本文为权威**。
> 文档导航、阅读顺序、各篇主题见 [README](./README.md);红线的完整细节见 [05-开发规范与红线.md](./05-开发规范与红线.md)。
---
## 一页纸:前端分层模型
```
gameabc.min.js(引擎,js/vendor,第三方)
▲
GameABCUtils(core,唯一直接调引擎原生 API 的模块)
▲
SpriteManager(core,业务级精灵 API:ID 校验 + 单位换算) EventBus / AnimationManager / AudioManager / SpineMgr(system)
▲ ▲
BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(游戏中立,可复用)
══════════════════════════════════════════════════════════════════════════
01_SubGame/codes(子游戏实例,单向依赖框架;内部结构由子游戏自行组织)
shared 前后端共享算法(与服务端同源,脚本同步,只读)
══════════════════════════════════════════════════════════════════════════
旧受限对接层(平台入口,尽量不改)
00/01/02_SubGame_*.js → Game_Modify.StartWar / Reconnect / _ReceiveData / appStart
```
- **框架(gameabc-framework)**:游戏中立,提供精灵/组件/事件/动画/音频/Spine 通用能力,可被任何子游戏复用。
- **子游戏(01_SubGame/codes)**:单向依赖框架,在其内部自由组织实现(目录/文件命名由子游戏自定,仅 `shared/` 为只读同步副本)。
- **旧受限对接层**:平台框架的固定入口(`Game_Modify.*`),把平台事件转交给新架构,**尽量不改**(详见 01/04)。
---
## 红线速查(详见 05)
- **可编辑范围**:前端平台代码 `js/00_Surface/` 禁改;受限接口文件 `01_SubGame/00_/01_/02_SubGame_*.js` 不新增接口、尽量不改;其余在 `gameabc-framework/`、`01_SubGame/codes/` 内开发。
- **严格 ES5**:用 `var`/`function`/`Object.create`,禁 `let`/`const`/箭头/模板串/`class`。
- **框架游戏中立**:`gameabc-framework/` 内**不得**出现任何具体玩法逻辑或专属常量;玩法专属的事件/资源/配置一律定义在子游戏侧(如玩法事件在子游戏的事件常量文件里追加到 `EventBus.Events`)。
- **精灵只走 SpriteManager**:UI 代码**禁止**直接调 `GameABCUtils` 或引擎原生 API;ID 必须落在规定范围(精灵 1001–3000、群组 ≥201、图层 101–200/弹窗 301–400,另有 501–600、701+ 备用段,框架保留 1–1000 及 3001+ 等交替段)。
- **常量集中、禁硬编码**:UI 代码**禁止任何硬编码 id / 裸值**——精灵 ID / 群组 ID / 图层 ID / 图片资源 ID / 声音 ID / Spine 资源 / 坐标尺寸 / 动画时长 / 事件名 一律定义在对应常量文件、只引用常量。
- **服务端权威、前端只展示**:核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为渲染副本,不做权威计算。
- **数据驱动、前端无对局状态机**:阶段、轮次/控制权、可用操作、倒计时、分数、按钮该不该出现,一律**读服务端下发的权威字段**渲染;**禁止**前端自建状态机、由本地规则推导「现在轮到谁/进入哪个阶段/显示哪些按钮」,**禁止**用本地定时器自行推进阶段或自判超时(超时与托管由服务端驱动,倒计时前端只做插值显示)。包里没有的状态是**服务端漏发**,修在服务端发包处,前端不推、不补、不猜(详见 05 §6.1)。
- **视图 = f(服务端快照)**:`this.data` 只是服务端状态的镜像,**不得存在只活在前端、服务端不知道的对局态**。判据:任意时刻丢弃 `this.data`、仅凭最近一次服务端快照重画,界面必须完全一致——做不到即"前端私存了状态"或"服务端漏发了字段",都要修。只有不影响裁定的纯表现态(选中高亮、按下反馈、滚动位置、动画进度)可只存在于前端(详见 05 §6.2–6.3)。
- **请求包只带「意图」**:发包只带「做什么 + 目标标识」(操作类型、牌 `uniqueId`、`choiceIndex`),**禁止**回传前端算出的结论(分数/番数、"我胡了"之类判定、结算结果、阶段推进指令);前端 `shared/` 的计算只用于本地提示与预校验,结果不回传(详见 04 §1)。**前端能自己推出来的东西,就是玩家能改的东西**——这是防作弊的根本。
- **组件走 BaseComponent**:UI 组件继承 `BaseComponent`,事件用 `this.addEventListener` 注册(`destroy()` 自动清理,防泄漏);动态精灵/定时器在 `onDestroy` 清理。
- **组件数据自持 + set-refresh**:组件自有数据集中 `this.data`;每个 UI/零件有 `setXxx`(只写数据)/`refreshXxx`(只据数据画)对,组件有总 `refresh()` 可据数据重建界面。
- **UI 组件专职自己的界面**:每个界面的数据与渲染只由其对应 UI 组件实现;别的模块要改/刷该界面一律**调该组件的公开接口**(`setXxx`/`refreshXxx`/语义方法),**禁止**在别处重复实现重叠或类似的界面数据/渲染逻辑。
- **显隐走 `showXxx`/`hideXxx`**:UI 组件/零件的精灵、群组显隐必须由组件暴露的 `showXxx`/`hideXxx` 接口控制;**禁止**别处直接用其精灵 ID/群组 ID 去 `SpriteManager.show/hide` 控显隐。
- **发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,对局状态界面(提示/按钮/控制权/倒计时/落牌/阶段)一律**收到服务端结果或推送包后**才更新,点击时不做乐观预测;收包处理器**触发源无关**(数据从包字段读,不依赖点击时的本地变量),故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**——挂在「点击」而非「收包」会导致 AI 托管时界面卡死(详见 04 §5)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡、手动掷骰按钮)的隐藏允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 04 §5.6 与服务端 04 §8;其余提示/控制权/倒计时/落牌仍严格收包驱动。
- **数据优先、表现延后**:收到推送先 `setXxx` 写数据、再播动画;动画的开始/结束/出错回调里**只刷界面、不写核心数据**。即使动画缺失/卡住/出错,数据、逻辑、界面仍正确、互不影响。
- **重连即重画(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 set-refresh 恢复数据与界面状态**,复用同一条重画路径,不为重连单写一套渲染(详见 04 §3.3、05 §6)。
- **成败只认 `data.success`**:前端一律 `if (!data.success)` 判成败,**不**用 `status`/`code`,**不**写 `status` 兼容兜底。
- **收发包走统一通道**:发包经统一发送封装(底层 `RpcHelper` 自动注入平台字段),收包统一分发(一 rpc 一处理器);业务逻辑放处理器,**不**写进 `Game_Modify.*`。
- **shared 只读**:`01_SubGame/codes/shared/` 是服务端 `shared/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
@@ -9,8 +9,7 @@
### 1.1 单一权威数据源(Single Source of Truth)
同一业务数据**只能有一个计算/写入的地方**,其余模块只读取、不重算。多处并行计算同一结果,必随
规则演化而分叉、互相矛盾。
同一业务数据**只能有一个计算/写入的地方**,其余模块只读取、不重算;多处并行计算必随规则演化而分叉、矛盾。
- 上游**算 + 写 + 校验**,下游**只读 + 消费**。
- 需要某数据时,**读权威源**,而不是"顺手再算一遍"。
@@ -43,18 +42,14 @@
| 编排 vs 算法 | 流程编排(controller) | 纯计算(领域算法) |
| 输入 vs 逻辑 | 收发包/参数校验 | 业务处理 |
> 例(决策 vs 机制):自动操作模块只决定"打哪张/是否碰杠胡过",**不实现**胡牌检测/听牌分析;
> 后者是领域算法,决策层只**读取其结果**。这既是关注点分离,也是职责边界。
> 例(决策 vs 机制):自动操作模块只决定"打哪张/是否碰杠胡过",**不实现**胡牌检测/听牌分析;后者是领域算法,决策层只**读取其结果**。既是关注点分离,也是职责边界。
### 1.5 对扩展开放、对修改封闭(OCP)
新增一种玩法/规则/策略/牌型,应当是**新增一段代码并注册**,而**不是**去改动已经稳定、已被测试
覆盖的核心流程。
新增一种玩法/规则/策略/牌型,应当是**新增一段代码并注册**,而**不是**去改动已经稳定、已被测试覆盖的核心流程。
- 手段:**注册表 + 策略**、**管线/中间件**、**工厂**(见 [02 篇](./02-可扩展性与配置化.md))。
- 收益:核心不动 → 回归风险小;扩展点清晰 → 新人能照葫芦画瓢。
- 例:分级决策框架——核心是"取上下文 → 跑管线 → 选结果",新增高级策略只是**写策略对象 +
注册 + 单测**三步,核心零改动。
- 例:分级决策框架核心是"取上下文 → 跑管线 → 选结果",新增高级策略只是**写策略对象 + 注册 + 单测**三步,核心零改动。
### 1.6 配置优先于硬编码
@@ -69,7 +64,7 @@
关键路径上数据缺失,**优先报错或返回 `null`**,把问题暴露在**离根因最近**的地方;不要用
`|| 0`、`|| []`、`|| ''`、双源回退把缺失悄悄填平。
- 兜底会把 bug 藏进"看似正常"的流程里,等到很远的下游才爆发,极难定位。
- 兜底会把 bug 藏进"看似正常"的流程里,到很远的下游才爆发,极难定位。
- 只有**展示层、纯 UI 兼容、非关键日志**字段,才可在明确边界内用安全默认值。
- 详见 [03 篇](./03-数据权威·错误处理·演进.md)。
@@ -77,7 +72,7 @@
## 2. 前后端参考分层
下面是一套**成熟、可直接照搬思路**的分层。层次是稳定的,层内文件如何组织由子游戏自定。
下面是一套可直接照搬思路的分层。层次是稳定的,层内文件如何组织由子游戏自定。
### 2.1 后端分层(自上而下依赖)
@@ -1,7 +1,6 @@
# 02 · 可扩展性与配置化
本篇把总则里的 **OCP(对扩展开放)** 和 **配置优先** 落成可直接套用的模式与判据,
并给出**避免过度设计**的红线——扩展性是为了"改得动",不是为了炫技。
本篇把总则的 **OCP(对扩展开放)** 和 **配置优先** 落成可套用的模式与判据,并给出**避免过度设计**的红线——扩展性是为了"改得动",不是炫技。
---
@@ -11,30 +10,25 @@
### 1.1 注册表 + 策略(Registry + Strategy)— 最常用
把"一族可替换的行为"抽象成**策略对象**,用**注册表**收集,运行时按上下文选一个执行。
新增行为 = 写一个策略 + 注册,**核心零改动**。
把"一族可替换的行为"抽象成**策略对象**,用**注册表**收集,运行时按上下文选一个执行。新增行为 = 写一个策略 + 注册,**核心零改动**。
```
Registry(注册表) ── register(strategy) ──▶ [strategyA, strategyB, ...]
│
Context(只读上下文)──▶ 选择器 select(ctx) ─────────┘──▶ 命中的策略.execute(ctx)
Registry ──register(strategy)──▶ [strategyA, strategyB, ...] ──select(ctx)──▶ 命中策略.execute(ctx)
```
- **适用**:AI 决策分级、规则变体、牌型识别族、结算规则族——"同一类事有多种做法"。
- **要点**:
- 策略只依赖**只读上下文**,不反向修改全局;上下文封装它需要的权威数据。
- 有**默认策略兜底**(保证任何输入都有结果),高级策略**按需叠加**。
- 策略只依赖**只读上下文**(封装其所需权威数据),不反向修改全局。
- 有**默认策略兜底**(任何输入都有结果),高级策略**按需叠加**。
- 策略之间**互不知道**对方,新增不影响既有。
- **例**:分级决策框架——`Context/Registry/Pipeline + 基础策略 + 占位高级策略`,默认走最低级,
高级留扩展点;新增高级策略三步(写策略对象 + register + 单测)零侵入。
- **例**:分级决策框架默认走最低级、高级留扩展点;新增高级策略三步(写策略对象 + register + 单测)零侵入。
### 1.2 管线 / 中间件(Pipeline)
把一个复杂处理拆成**有序的小步骤**,每步只做一件事、可独立增删。
```
输入 ──▶ [校验] ──▶ [规则A] ──▶ [规则B] ──▶ [收敛] ──▶ 输出
每一节点单一职责,可插拔、可测试
输入 ──▶ [校验] ──▶ [规则A] ──▶ [规则B] ──▶ [收敛] ──▶ 输出(每节点单一职责、可插拔、可测试)
```
- **适用**:决策流水线、校验链、结算的多阶段计分(比精 → 冲关 → 霸王 → 零和)。
@@ -42,7 +36,7 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 1.3 工厂(Factory)
把"根据类型创建/选择实现"的分支收敛到一处,调用方只要"我要一个 X",不关心怎么造。
把"根据类型创建/选择实现"的分支收敛到一处,调用方只说"我要一个 X",不关心怎么造。
- **适用**:胡牌检测按牌型分派、可用操作枚举、不同房型的配置构建。
- **要点**:工厂是**唯一**的创建入口,避免 `if(type==...)` 散落各处(那是并行逻辑的温床)。
@@ -54,14 +48,13 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
- **适用**:一次数据变化要驱动多个互不相关的表现(动画 + 音效 + 计分板)。
- **克制**:
- **不要**用事件做"隐式流程控制"——关键流程的因果应显式可追踪,别埋进一堆事件里。
- **不要**用事件做"隐式流程控制"——关键流程的因果应显式可追踪。
- 事件是**通知**,不是**命令**;订阅方不该反向决定发布方的流程。
- 能直接函数调用讲清的因果,就别为"解耦"硬拆成事件。
### 1.5 统一访问层(Facade over data)
对"读权威数据"提供一个**统一入口**(如 DataAccessHelper 之类),下游都走它读,不各自摸索
数据结构。好处:数据结构演化时只改访问层一处;配合"缺失显式失败",把校验集中。
对"读权威数据"提供一个**统一入口**(如 DataAccessHelper),下游都走它读,不各自摸索数据结构。好处:数据结构演化时只改访问层一处;配合"缺失显式失败",把校验集中。
---
@@ -89,23 +82,19 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 2.3 规则驱动:把玩法开关变成数据
复杂玩法的差异(人数/局数/扣卡方式/各种开关)应编码成**一份配置**,在入口处**解析一次**,
之后全流程**只读消费**:
复杂玩法的差异(人数/局数/扣卡方式/各种开关)应编码成**一份配置**,在入口处**解析一次**,之后全流程**只读消费**:
```
房型/配置编码 ──(解析器,解析一次)──▶ 规则对象(结构化、只读)
│
各模块按需读取规则对象的字段,不再各自解析原始编码、不再散落 if
房型/配置编码 ──(解析器,解析一次)──▶ 规则对象(结构化、只读)──▶ 各模块按需读字段,不再各自解析、不再散落 if
```
- **解析只此一处**:避免"多处各自解析同一编码"产生的不一致(这是 SSOT 在配置上的体现)。
- **解析只此一处**:避免"多处各自解析同一编码"产生的不一致(SSOT 在配置上的体现)。
- **规则对象只读**:下游不回写、不推断缺省;缺字段是**配置或解析的 bug**,应显式暴露。
- **新增一个玩法开关** = 编码加一位 + 解析器认它 + 消费点读它,**不改无关逻辑**。
### 2.4 配置注入优于全局魔法值
模块需要的配置**从入口传入 / 从权威配置对象读取**,而不是在模块内部埋一堆魔法值或直接摸全局。
这让模块**可测试**(注入不同配置跑用例)、**可复用**(换配置即换行为)。
模块需要的配置**从入口传入 / 从权威配置对象读取**,而不是在模块内部埋魔法值或直接摸全局。这让模块**可测试**(注入不同配置跑用例)、**可复用**(换配置即换行为)。
---
@@ -115,8 +104,8 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 3.1 什么时候**不要**加抽象
- **只有一种实现、且看不到第二种的现实需求** → 不要为"将来也许"预留策略/工厂。先写直接实现。
- **YAGNI**:没有当前需求的扩展点就是负债;等第二个变体真的出现,再**重构**成模式(那时你更懂共性)。
- **只有一种实现、且看不到第二种的现实需求** → 不要为"将来也许"预留策略/工厂,先写直接实现。
- **YAGNI**:没有当前需求的扩展点就是负债;等第二个变体真的出现,再**重构**成模式(那时更懂共性)。
- **一次性逻辑** → 不要包装成"通用框架"。通用性从**重复中提炼**,不是凭空设计。
### 3.2 判断"值不值得抽象"
@@ -131,8 +120,7 @@ Context(只读上下文)──▶ 选择器 select(ctx) ──────
### 3.3 成熟的判据:三次法则 + 就近演进
- **三次法则**:同样的东西第 3 次出现时再抽象;第 1、2 次容忍重复。
- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),
再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。
- **就近演进**:先把直接实现写对、写清;当扩展点**自然浮现**(真来了第二个变体),再**收敛**成注册表/策略——此时抽象是"被现实拉出来的",最贴合,也最不会过度。
> 一句话:**扩展性是留给"已知会变"的地方的;对"稳定不变"的地方,简单直接才是最好的设计。**
@@ -1,7 +1,6 @@
# 03 · 数据权威 · 错误处理 · 演进
本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化。
数据权威部分在项目既有的「数据权威原则」基础上,扩展到**前后端全景**。
本篇是**正确性**的地基:数据从哪来、错了怎么暴露、代码怎么随规则长大而不腐化;数据权威部分在既有「数据权威原则」上扩展到**前后端全景**。
---
@@ -75,7 +74,7 @@
## 3. 演进与重构纪律
代码会随规则长大。让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。
代码随规则长大;让它**长而不腐**的关键,是持续把"并行/重复/临时"收敛掉。
### 3.1 收敛并行实现
+5 -18
View File
@@ -25,35 +25,22 @@
| 篇 | 文档 | 解决什么 |
|----|------|----------|
| 00 | 本文 README | 定位、适用范围、一页纸总则、与既有文档的关系 |
| 00 | 本文 README | 定位、怎么读、与既有文档的关系 |
| — | [一页纸总则.md](./一页纸总则.md) | **七大总则 + 适用边界**(由 `CLAUDE.md` 常驻加载,设计取舍时对照) |
| 01 | [01-架构总则与分层.md](./01-架构总则与分层.md) | 七大架构总则;前后端参考分层;依赖方向与稳定依赖 |
| 02 | [02-可扩展性与配置化.md](./02-可扩展性与配置化.md) | 扩展模式(注册表/策略/管线/工厂/事件)何时用;配置化与去硬编码;避免过度设计 |
| 03 | [03-数据权威·错误处理·演进.md](./03-数据权威·错误处理·演进.md) | 数据权威(前后端);错误处理与可观测;演进与重构;反模式与审查清单 |
---
## 一页纸:七大总则
## 一页纸总则
1. **单一权威数据源(SSOT)**:同一业务数据只有一个计算/写入处,其他只读;缺失即显式失败,不兜底掩盖。
2. **单向依赖**:分层自上而下依赖,稳定的被依赖、易变的作依赖方;**禁止环形依赖**。
3. **职责单一、边界清晰**:一个职能只在一个模块实现,别处**调用而非重造**。
4. **关注点分离**:决策与机制分离、数据与表现分离、编排与算法分离。
5. **对扩展开放、对修改封闭(OCP)**:用注册/策略/管线**加**能力,不改动已稳定的核心。
6. **配置优先于硬编码**:会变、复用、无语义的值一律外提为常量/配置,用数据驱动行为。
7. **显式失败优于隐式兜底**:关键路径缺数据就报错/返回 `null`,把问题暴露在最近处。
**七大总则与适用边界已独立成篇:[一页纸总则.md](./一页纸总则.md)。**
> 这七条互相支撑:**SSOT + 显式失败**保正确,**单向依赖 + 职责单一 + 关注点分离**保清晰,
> **OCP + 配置化**保可演进。任何设计取舍,回到这七条对照。
该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是总则的权威源;本 README 只负责导航,不重复抄写总则(避免两处不同步)。总则的展开见 [01-架构总则与分层.md](./01-架构总则与分层.md)。
---
## 适用范围与边界
- **适用**:子游戏自身的前后端业务代码(玩法逻辑、对局编排、收发包处理、表现层、共享算法)。
- **不覆盖**:平台框架代码(不可改)、平台接入契约(见各端 `development-guide/`)。
- **与硬约束的关系**:ES5、`require` 守卫、可编辑范围、成败标志 `data.success` 等**硬红线**仍以
`development-guide/` 为准;本套是**方法论层**,与之互补不冲突。
## 与既有文档的关系
| 文档 | 定位 |
+29
View File
@@ -0,0 +1,29 @@
# 工程与架构通则 · 一页纸
> 本文是与平台无关的**工程方法论那一页**:七大总则 + 适用边界。
> 它由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文;**总则以本文为权威**。
> 文档导航、各篇主题、与既有文档的关系见 [README](./README.md)。
---
## 一页纸:七大总则
1. **单一权威数据源(SSOT)**:同一业务数据只有一个计算/写入处,其他只读;缺失即显式失败,不兜底掩盖。
2. **单向依赖**:分层自上而下依赖,稳定的被依赖、易变的作依赖方;**禁止环形依赖**。
3. **职责单一、边界清晰**:一个职能只在一个模块实现,别处**调用而非重造**。
4. **关注点分离**:决策与机制分离、数据与表现分离、编排与算法分离。
5. **对扩展开放、对修改封闭(OCP)**:用注册/策略/管线**加**能力,不改动已稳定的核心。
6. **配置优先于硬编码**:会变、复用、无语义的值一律外提为常量/配置,用数据驱动行为。
7. **显式失败优于隐式兜底**:关键路径缺数据就报错/返回 `null`,把问题暴露在最近处。
> 这七条互相支撑:**SSOT + 显式失败**保正确,**单向依赖 + 职责单一 + 关注点分离**保清晰,
> **OCP + 配置化**保可演进。任何设计取舍,回到这七条对照。
---
## 适用范围与边界
- **适用**:子游戏自身的前后端业务代码(玩法逻辑、对局编排、收发包处理、表现层、共享算法)。
- **不覆盖**:平台框架代码(不可改)、平台接入契约(见各端 `development-guide/`)。
- **与硬约束的关系**:ES5、`require` 守卫、可编辑范围、成败标志 `data.success` 等**硬红线**仍以
`development-guide/` 为准;本套是**方法论层**,与之互补不冲突。
@@ -1,6 +1,6 @@
# 01 · 服务端环境与框架基础
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。读懂这一篇,后面的接入与协议才有坐标。
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。
> 举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。
@@ -23,14 +23,13 @@
2. **`require` 只能在文件开头的守卫块内**:
```js
// ✅ 正确:集中在顶部守卫块;运行时按全局名引用
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
// ... 之后直接用全局名 GameStateManager.xxx() ——浏览器由 mod.js 加载为同名全局
// 之后按全局名引用 GameStateManager.xxx()
```
浏览器运行时没有 `require`,任何**无守卫的、函数体内的中途 `require`** 都会抛 `ReferenceError: require is not defined`,导致功能崩溃。跨模块统一**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析,二者靠共享全局作用域下的同名 `var` 统一。
浏览器运行时没有 `require`,任何**无守卫的、函数体内的中途 `require`** 都会抛 `ReferenceError: require is not defined`。跨模块统一**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析,二者靠共享全局作用域下的同名 `var` 统一。
### 前后端是物理分离的两端
@@ -202,4 +201,3 @@ var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>",
- 房间生命周期由 export/import 接缝串起:`makewar` 开局、`get_deskinfo` 重连、`deduct_roomcard` 首局扣卡、`save_grade` 终局保存并自动回收。
下一篇 [02-子游戏接入与开发流程](./02-子游戏接入与开发流程.md) 讲:这三个文件具体怎么写、8+4 个接口各做什么、一次操作的完整数据流。
</content>
@@ -32,7 +32,7 @@ server/<游戏容器目录>/<你的游戏>/ (容器目录名由接入方
└── tests/ 单元/集成测试
```
> 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变,文件内容不同。
> 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变。
---
@@ -51,17 +51,12 @@ var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>",
### 2.2 按依赖顺序加载文件
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块):
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块)。浏览器/友乐用 `min_loadJsFile` 异步链式加载:
```js
// 浏览器/友乐:min_loadJsFile 异步链式加载
min_loadJsFile("<容器目录>/<你的游戏>/常量与工具.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/数据结构.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/import.js", function(){
min_loadJsFile("<容器目录>/<你的游戏>/业务与rpc.js", function(){
console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成");
});});});});});
min_loadJsFile("<容器目录>/<你的游戏>/export.js", function(){ /* ...嵌套加载 import.js、业务与rpc.js... */ });
});
```
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。
@@ -83,7 +78,6 @@ RPC 方法是**前后端交互的服务端入口**。以下是必须遵守的规
RPC 方法本身**不写业务逻辑**,只把 `pack` 转交给收发包层的 handler;真正的参数校验、业务编排、广播都在 handler 里:
```js
// mod_<你的游戏> 上挂的 RPC 方法(示例名,可自定)
mod_<你的游戏>.playCard = function(pack) {
// 就绪守卫:handler 由 min_loadJsFile 异步加载,未就绪时防御性返回(见规则四)
if (typeof RpcHandler === 'undefined' || !RpcHandler) {
@@ -91,9 +85,6 @@ mod_<你的游戏>.playCard = function(pack) {
}
return RpcHandler.handlePlayCard(pack); // 委托到收发包层,真正逻辑在这里
};
mod_<你的游戏>.declareHu = function(pack) {
return RpcHandler.handleDeclareHu(pack);
};
```
> `RpcHandler`、`handlePlayCard` 这些是**本项目的命名示例**;换成任何风格都行,关键是"薄入口 + 委托"的分层。
@@ -184,14 +175,13 @@ mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动
```js
exp.makewar = function(o_room, o_game_config) {
// 1) 创建子游戏牌桌对象,并与房间建立【双向引用】
// 1) 创建牌桌对象,与房间建立【双向引用】
if (!o_room.o_desk) { o_room.o_desk = {}; }
if (!o_room.o_desk.data) { o_room.o_desk.data = {}; }
o_room.o_desk.o_room = o_room; // 反向引用
// 2) 创建对局状态,挂到 o_room.o_desk.data.*(房间隔离的落点)
var gameState = createGameState(o_room, o_game_config);
o_room.o_desk.data.gameState = gameState; // 此后所有业务都从这里读对局态
o_room.o_desk.data.gameState = createGameState(o_room, o_game_config);
// 3) 返回开战数据包(通常按座位差异化下发)
return {
@@ -217,26 +207,15 @@ exp.makewar = function(o_room, o_game_config) {
## 4. `import.js` —— 子游戏调用平台的 4 个接口
这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**:
这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**。每个都形如 `imp.<接口> = function(...args){ return mod_<你的游戏>.app.youle_room.export.<接口>(...args); }`,本项目 4 个:
```js
mod_<你的游戏>.import = (function() {
var imp = {};
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) {
return mod_<你的游戏>.app.youle_room.export.check_player(
agentid, gameid, roomcode, seat, playerid, conmode, fromid);
};
imp.deduct_roomcard = function(o_room) {
return mod_<你的游戏>.app.youle_room.export.deduct_roomcard(o_room);
};
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) {
return mod_<你的游戏>.app.youle_room.export.save_grade(
o_room, o_gameinfo1, o_gameinfo2, freeroomflag);
};
imp.finish_gametask = function(agentid, o_player, taskid, finishamount) {
return mod_<你的游戏>.app.youle_room.export.finish_gametask(
agentid, o_player, taskid, finishamount);
};
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) { /* → youle_room.export.check_player(...) */ };
imp.deduct_roomcard = function(o_room) { /* → youle_room.export.deduct_roomcard(o_room) */ };
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) { /* → youle_room.export.save_grade(...) */ };
imp.finish_gametask = function(agentid, o_player, taskid, finishamount) { /* → youle_room.export.finish_gametask(...) */ };
return imp;
})();
```
@@ -306,4 +285,3 @@ mod_<你的游戏>.import = (function() {
- [ ] 改了下发结构,前端 `StartWar`/`Reconnect`/对应操作解析同步检查。
下一篇 [03-数据收发与通信协议](./03-数据收发与通信协议.md) 详解包结构、发包方式、主动推送与 `success` 成败协议。
</content>
@@ -27,20 +27,19 @@
`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` 必须与它逐字相同,平台据此把包投到你的模块**;改名要两端同步改。
@@ -56,6 +55,28 @@
> 一句话:**服务端权威不仅是"服务端算",也是"服务端把界面要用的核心数据发全"——前端只据包渲染,不推算、不兜底。**
### 1.3 发全 ≠ 发多:按可见性下发(防作弊)
§1.2 要求"该看到的发全",本节是它的另一半:**不该看到的一律不发**。前端是不可信环境——**下发即泄露**:包到了客户端就能被抓包/改内存看到,"前端拿到但不渲染"等于零防护。
- **按座位裁剪可见面**:他人手牌、牌堆剩余序列与顺序、未公开的判定结果(他人是否听牌/能否胡、暗牌内容、未到揭示时机的底牌与亮牌明细)——**对不该看到的座位不放进 `data`**,用差异化下发(`sendtype:1` + `seatlist[]` 逐座位组包,见 §3)实现。
- **只发"公开面"的统计量**:他人的信息若界面确实要显示,只发**已公开的派生量**(如剩余张数、已亮出的花色/数量),不发原始牌面。
- **不发未来**:尚未发生或尚未公开的权威结果(下一张要摸的牌、预先算好的胜负、待揭晓的底牌)不提前下发,哪怕前端"只是缓存"。
- **判别**:问一句 **"这个字段落到一个改过的客户端手里,玩家会不会因此获得优势?"** 会 → 不发(或只发到该看到的座位)。
- **违例信号**:`deepCopy(baseData)` 后**没有**逐座位裁剪敏感字段就全员广播;把全量 `handCards`/`cardPool` 塞进公共包;重连快照 `get_deskinfo` 直接回整张桌的内部状态。
> §1.2 与 §1.3 合起来才是完整的下发面:**该看到的一个不少(否则前端缺数据),不该看到的一个不多(否则等于送作弊入口)。**
### 1.4 阶段与状态机唯一在服务端,并随包下发
前端**不持有对局状态机**(前端侧规范见 [客户端 05 §6](../../client/development-guide/05-开发规范与红线.md)):阶段、轮到谁、该座位可用操作、倒计时基准,前端一概不推导。因此这些字段**必须由服务端唯一维护、并在每个相关下发包里明确给出**:
- **阶段/状态标志**(当前 `phase`、是否在响应窗口、是否已结束等)——不让前端按"收到了什么包"去反推阶段。
- **控制权**(下一个该谁操作,如 `nextControlSeat`/`currentPlayer`)——显式字段,不让前端按座位顺序自己算。
- **该座位可用操作**(`availableActions[seat]`)——按座位下发;它同时是服务端准入白名单(见 [04 §8](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)),**同一份权威数据既驱动前端按钮显示、又校验请求合法性**,天然不会两边判得不一致。
- **倒计时锚点与时长**——前端只做本地插值显示,**超时的裁定与后续推进仍在服务端**,不接受前端上报"我超时了"。
- **状态变更必须有包**:任何阶段推进都要有一个下发包承载;**没有包的状态变化 = 前端不可能正确显示**,只能靠前端猜——那正是本节要杜绝的。
---
## 2. 收包处理的固定步骤(在 handler 里)
@@ -65,9 +86,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 +104,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,16 +140,13 @@ 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);
}
```
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。裁剪清单与判别标准见 [§1.3 按可见性下发](#13-发全--发多按可见性下发防作弊)——**下发即泄露,前端"拿到但不渲染"不算防护**。
---
@@ -163,23 +174,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 条。
@@ -259,7 +260,7 @@ if (!data.success) { /* 失败处理 */ return; }
## 7. 服务端代替玩家操作时的透明性
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 [04 §7 自动操作复用真人链路](./04-开发规范与红线.md#7-服务端自动操作复用真人链路) 与 [§8 操作请求合法性验证](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。)
---
@@ -267,6 +268,8 @@ if (!data.success) { /* 失败处理 */ return; }
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
- **下发包必须发全前端界面所需的核心数据**(§1.2):前端以服务端为权威、不自算,漏发修在服务端发包处、前端不补洞。
- **不该看到的一律不发**(§1.3):下发即泄露,按座位裁剪可见面;"该看到的一个不少、不该看到的一个不多"。
- **阶段/控制权/可用操作/倒计时锚点由服务端唯一维护并显式下发**(§1.4):前端不持有状态机、不反推阶段;状态变更必须有包承载。
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- **主动推送是唯一可靠下发通道**,`return` 不算。
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
@@ -274,4 +277,3 @@ if (!data.success) { /* 失败处理 */ return; }
- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。
下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。
</content>
@@ -38,14 +38,12 @@
- 跨模块运行时**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析。
```js
// ✅ 正确
// ✅ 标准形态:require 仅在顶部守卫块,函数体内直接用全局名
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
function foo() { GameStateManager.doSomething(); } // 直接用全局名
// ❌ 错误:函数体内中途 require —— 浏览器崩溃
function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
function foo() { GameStateManager.doSomething(); }
// ❌ 函数体内中途 require → 浏览器崩溃:function bar(){ var GSM = require('...'); }
```
### 双运行时全局暴露陷阱
@@ -61,6 +59,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
- **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 set-refresh 要用的字段都发全);漏发 = 前端缺数据,**修在服务端发包处、前端不补洞**(详见 [03 §1.2](./03-数据收发与通信协议.md))。
- **前端不是数据源**:客户端只提供"意图"(想做什么、对哪个目标),**永远不是任何业务数据的权威来源**。凡进入服务端状态的值——分数、牌面、阶段、控制权、结算——只能由服务端自己算出或从 `o_desk.data.*` 读出,**绝不采信包体里前端算好的同名字段**(见 §8)。对应地,阶段/控制权/`availableActions`/倒计时锚点由服务端唯一维护并显式下发,前端不推导(见 [03 §1.4](./03-数据收发与通信协议.md)、前端 [05 §6](../../client/development-guide/05-开发规范与红线.md))。
> 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
@@ -104,7 +103,53 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
## 8. Shared 文件同步流程
## 8. 操作请求合法性验证(不可信客户端)
> 与 §7 配套:§7 保证"自动操作走真人链路",本节保证"到达的每个请求都合法才被链路执行"。
**客户端完全不可信**:它可能连点、乱序、丢包重发、篡改包体、发送非当前阶段/越权/当前不允许的操作。服务端**必须对每个到达的请求独立判定合法性**,非法即拒绝且**绝不改动任何对局状态**——就当这个包没来过。这是正确性的**根本防线**;前端的乐观清除/防连点(见前端 [04 §5.6](../../client/development-guide/04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证))只是体验层,**不承担正确性**。
### 五层验证栈(进业务前依次过闸,任一不过即 `success:false` + 不改状态)
1. **座位鉴权(防越权)**:操作者座位**由连接身份反查**(连接绑定的 playerid→座位,如平台 `check_player` 以 `fromid/conmode` 绑定),**绝不信任包体 `seat` 字段**;包内 seat 只做一致性校验,不等即拒。否则 A 玩家发 `{seat: B}` 就能替 B 操作。
2. **阶段/状态门(防非当前阶段包)**:校验当前游戏阶段/状态允许该操作——非出牌阶段发出牌、非掷骰窗口发掷骰、无响应窗口发碰/过 → 拒绝。门控读 `gameState.phase` / `pendingResponse.waiting` / 是否轮到该座位(`currentPlayer === seat`)。
3. **操作可用性校验(防"当前不允许的操作")——核心闸**:校验该操作**确实在该座位当前权威 `availableActions[seat]` 里**(没可碰的牌却发碰、没有胡机会却发胡 → 拒绝)。
> 关键:`availableActions[seat]`(听牌/胡牌检测/操作枚举产出的**权威合法操作集**)**既是自动操作模块的决策选项来源,也是校验真人请求的准入白名单**——同一份权威数据兼任"决策依据"与"准入校验",统一了数据源唯一(§4)、自动操作职责边界(§5)、自动操作复用真人链路(§7)三条线。
4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。
5. **参数 / 数据合法性**:牌 `uniqueId` 是否真在该玩家手里、`targetCard`/`fromSeat`/`choiceIndex` 是否与权威牌局一致 —— 全按**服务端权威数据**校验,不信客户端传的牌面。
### 只接受「意图」入参:客户端回传的结论一律忽略
第 5 层的前提是**入参里根本不该出现结论**。请求包只表达"我想做什么"(操作类型 + 目标标识),**不表达"结果是什么"**(前端侧规范见 [客户端 04 §1「请求包只带意图」](../../client/development-guide/04-网络对接与启动编排.md)):
- **协议层不定义结论入参**:`score`/`isWin`/`multiplier`/`nextSeat`/`phase`/`result`/`handCards` 之类字段**不出现在请求包定义里**——定义了它,就是留了一个可被伪造的洞。
- **收到也忽略**:即便非正规客户端硬塞这些字段,handler **一律不读、不落地**,全部按服务端权威数据重算。审查信号:handler 里出现 `pack.data.score`、`pack.data.isWin`、`pack.data.phase` 这类读取。
- **包内 `seat` 只作一致性校验**:身份由连接反查(第 1 层),包内座位不等即拒,**不作身份依据**。
- **前端 `shared/` 的计算结果不是输入**:前端跑 `shared/` 只为提示与预校验,其结论不回传、服务端也不采信;服务端自己跑同一份 `shared/` 得出权威结果(见 §9)。
### 设计形态:统一裁决网关,默认拒绝(fail-closed)
把五层收敛成**一道所有操作共用的准入网关**(而非每个 handler 各写一遍散点 if)。网关默认 **deny**,仅当请求显式**命中该座位 `availableActions`** 且通过座位/阶段/参数校验才 **allow**。好处:
- **单点权威**:合法性判定只有一处,各 handler 不会判得不一致(呼应 §5 职责边界);
- **自动操作与真人同闸**:自动操作(AI 托管)本就从 `availableActions` 选,必过同一网关,真正做到 §7 "自动操作 ≈ 服务端模拟一次**合法**真人操作"。
> **反模式警示**:若"是否轮到你/是否允许"的通用时序校验只存在于某个**从未被调用**的方法里(死代码),等于没有验证——尤其**出牌入口最易漏掉回合门**,导致任一玩家可在非自己回合越权出牌。验证网关必须**在统一入口真实生效**,并有测试覆盖。
### 失败返回:安静且明确
- 一律 `success:false` +(可选)`reason` 码,**绝不静默改状态**;失败推送前端只做提示/日志、**不改对局界面**(与前端 `if(!data.success)` 闭环)。
- 最危险的是**"非法包被误当合法处理"(fail-open)**——默认拒绝就是为杜绝它。
### 验收(测试构造非法包,硬断言拒绝)
按 §11 测试纪律补单测:**非自己回合出牌 / 同回合连发两张 / 非当前阶段各操作 / 响应窗口关闭后迟到碰杠 → 断言 `success:false` 且状态不变**;**合法出牌各时机(摸后/碰后/杠后/报定后)→ 断言通过**(防回合门误伤合法出牌)。
> 一句话:**服务端对每个操作请求默认拒绝,仅当命中该座位当前权威 `availableActions` 且通过座位/阶段/参数校验才执行;操作一经消费即推进状态使重复包天然失效。正确性完全由这道网关保证,前端乐观清除只是体验。**
---
## 9. Shared 文件同步流程
### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关
@@ -129,7 +174,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
## 9. 硬编码常量准则
## 10. 硬编码常量准则
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
@@ -137,7 +182,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
## 10. 测试纪律
## 11. 测试纪律
- **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。
- **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。
@@ -153,7 +198,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
## 11. Git 提交规范
## 12. Git 提交规范
- **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。
- **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。
@@ -161,7 +206,7 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
## 12. 审查速查表
## 13. 审查速查表
| 维度 | 红线 |
|------|------|
@@ -169,9 +214,13 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 |
| 前端不是数据源 | 请求包只接受「意图」(操作类型+目标标识),协议不定义 `score`/`isWin`/`phase` 等结论入参,收到也忽略并按权威数据重算;包内 `seat` 只作一致性校验 |
| 下发可见性 | 不该看到的不发(他人手牌/牌堆序列/未公开判定/未来结果),按座位差异化裁剪;"下发即泄露",前端不渲染不算防护(03 §1.3) |
| 状态机归属 | 阶段/控制权/`availableActions`/倒计时锚点由服务端唯一维护并显式下发,前端不推导;状态变更必须有包承载(03 §1.4) |
| 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 |
| 请求验证 | 客户端不可信;每个操作请求过座位鉴权/阶段门/操作可用性/幂等去重/参数五层校验,统一网关默认拒绝、失败 `success:false` 不改状态;出牌回合门尤其不可漏(防死代码) |
| Shared | 只改服务端 `shared/`,跑同步脚本 |
| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 |
| Git | 一事一提交、中文信息、及时提交 |
@@ -179,4 +228,3 @@ function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
---
至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。
</content>
+7 -61
View File
@@ -18,74 +18,22 @@
| 篇 | 文档 | 解决什么问题 |
|----|------|--------------|
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
| 00 | 本文 README | 这套文档是什么、怎么读 |
| — | [红线速查.md](./红线速查.md) | **一页纸运作模型 + 命名约定 + 红线清单**(由 `CLAUDE.md` 常驻加载,改代码前先对照) |
| 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、数据权威、模块职责、房间隔离、测试纪律 |
| 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) → 下发客户端
```
**运作模型、命名约定与红线清单已独立成篇:[红线速查.md](./红线速查.md)。**
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。
- **子游戏**负责:玩法规则、对局状态、每个操作的处理与广播。两者通过 **export / import** 两组接口对接。
> 上图是**入站半程**(前端 → 服务端 handler)。完整的往返链路(含服务端 `sendpack_toseat` 推回 → 前端 `Game_Modify._ReceiveData` 按 `rpc` 分发)见 [03 §6「端到端收发全链路」](./03-数据收发与通信协议.md#6-端到端收发全链路前后端对照),前端侧细节见 [前端 04 网络对接与启动编排](../../client/development-guide/04-网络对接与启动编排.md)。
---
## 命名约定:哪些是框架契约,哪些只是示例
本套文档的代码示例里出现的**具体名字,绝大多数是举例,并非强制标准**。请区分两类:
- **框架契约(必须照用)**——由平台代码决定,改了就对接不上:
- 数据包四字段 `{ app, route, rpc, data }`,其中 `app` 固定为 `"youle"`;
- 平台真实回调的 export 钩子名、你调用的 import 接口名(如 `makewar`、`get_deskinfo`、`check_player`、`deduct_roomcard`、`save_grade`);
- 成败字段 `data.success`;
- 平台 API 名(`cls_mod.new`、`min_loadJsFile`、`o_room.method.sendpack_toseat` / `sendpack_toother` 等)。
- **示例命名(可自定)**——本项目的举例,你的子游戏可按自己的风格命名:
- 业务 `rpc` 名(如 `playCard`、`declareHu`,只需**前后端约定一致**即可);
- handler / 类 / 文件 / 变量 / 分层目录名(如 `RpcHandler.handlePlayCard`、`OperationManager`、`ResponseBuilder`、`BroadcastManager`、`RoomAdapter`、`GameController`、`game/`、`rpc/`、`rules/` 等)。
> 一句话:**平台接缝上的名字是契约、要照用;接缝之内你自己代码里的名字都是示例、可自定。** 后续 01–04 的代码块同样遵循这条约定,不再逐处重复声明。
---
## 红线速查(详见 04)
> 以下每一条都有过真实事故或返工,**改代码前先对照**。
- **可编辑范围**:服务端只能改子游戏目录 `server/<游戏容器目录>/<你的游戏>/`(容器目录名由接入方自定,**非框架强制**,换任何不与框架冲突的目录皆可),`server/` 其余皆平台代码,禁改。接入新游戏还需在 `server/youle/app.js` 里加一行 `min_loadJsFile` 加载其 `mod.js`(唯一必须触碰的平台文件,属游戏注册接入点)。
- **双运行时**:代码同时跑在 Node 与浏览器/友乐平台。必须严格 **ES5**;`require` 只能写在文件开头 `if (typeof require !== 'undefined') {}` 守卫块内,**禁止函数体内/中途 require**。
- **成败标志唯一是 `data.success`**:前端只认 `if (!data.success)`;**主动推送的 `data` 必须自带 `success`**(mod 的 `return` 值不可靠下发前端);禁止用 `status`/`code` 判成败、禁止 `status` 兼容兜底。
- **数据权威**:同一业务数据只有一个权威来源,下游只读不重算;权威字段缺失要**显式报错/返回 null**,禁止 `|| 0`、`|| []`、`|| ''` 兜底掩盖。
- **模块职责边界**:一个职能只在一个模块实现,其他模块**调用而非重造**。
- **房间隔离**:一切随对局变化的状态都挂 `o_room.o_desk.data.*`;**禁止模块级单例/全局变量存对局态**;缓存/定时器/决策表不得只用 `seat` 作 key,必须 `房间 + seat`;结束/解散/开新局时按房间清理全部定时器与残留状态。
- **服务端自动操作复用真人链路**:服务端代替玩家做的操作(如 AI 托管)必须复用「真人操作」的同一套处理入口与广播链路,对前端透明,不为它单开一套下发分支。
- **服务器权威 + 发全下发面**:房卡、胜负、积分、状态等关键数据与操作一律以服务器为准、前端只作显示不自算;因此**服务端每个下发包必须携带前端界面所需的全部核心数据**,漏发修在服务端发包处、前端不补(详见 03 §1.2 / 04 §4)。
- **测试纪律**:测试失败先用证据裁定「业务缺陷 vs 测试脚本缺陷」,禁止 skip/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是红线的权威源;本 README 只负责导航,不重复抄写红线(避免两处不同步)。红线的完整细节见 [04-开发规范与红线.md](./04-开发规范与红线.md)。
---
@@ -94,5 +42,3 @@ o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下
- 本套文档是平台级收发包/子游戏开发规范的**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `<游戏容器目录>/<游戏>/docs/` 为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲"平台怎么接、红线是什么",工程通则讲"该怎么设计、怎么长久演进",互补阅读。
</content>
</invoke>
@@ -0,0 +1,69 @@
# 服务端 · 一页纸运作模型与红线速查
> 本文是服务端开发**必须常驻在手边**的那一页:运作模型 + 命名约定 + 红线清单。
> 它由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文;**红线以本文为权威**。
> 文档导航、阅读顺序、各篇主题见 [README](./README.md);红线的完整细节见 [04-开发规范与红线.md](./04-开发规范与红线.md)。
---
## 一页纸:核心运作模型
```
客户端数据包 { app, route, rpc, data }
│
▼
packet_face.ReceivePack 按 pack.app 找到「应用」
│
▼
app.ReceivePack 按 pack.route 找到「模块」(子游戏)
│
▼
mod.DoPack 按 pack.rpc 调到「方法」 mod[rpc](pack)
│
▼
子游戏 RPC handler 校验玩家 → 取对局状态 → 执行业务 → 主动推送
│
▼
o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下发客户端
```
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。
- **子游戏**负责:玩法规则、对局状态、每个操作的处理与广播。两者通过 **export / import** 两组接口对接。
> 上图是**入站半程**(前端 → 服务端 handler)。完整的往返链路(含服务端 `sendpack_toseat` 推回 → 前端 `Game_Modify._ReceiveData` 按 `rpc` 分发)见 [03 §6「端到端收发全链路」](./03-数据收发与通信协议.md#6-端到端收发全链路前后端对照),前端侧细节见 [前端 04 网络对接与启动编排](../../client/development-guide/04-网络对接与启动编排.md)。
---
## 命名约定:哪些是框架契约,哪些只是示例
本套文档的代码示例里出现的**具体名字,绝大多数是举例,并非强制标准**。请区分两类:
- **框架契约(必须照用)**——由平台代码决定,改了就对接不上:
- 数据包四字段 `{ app, route, rpc, data }`,其中 `app` 固定为 `"youle"`;
- 平台真实回调的 export 钩子名、你调用的 import 接口名(如 `makewar`、`get_deskinfo`、`check_player`、`deduct_roomcard`、`save_grade`);
- 成败字段 `data.success`;
- 平台 API 名(`cls_mod.new`、`min_loadJsFile`、`o_room.method.sendpack_toseat` / `sendpack_toother` 等)。
- **示例命名(可自定)**——本项目的举例,你的子游戏可按自己的风格命名:
- 业务 `rpc` 名(如 `playCard`、`declareHu`,只需**前后端约定一致**即可);
- handler / 类 / 文件 / 变量 / 分层目录名(如 `RpcHandler.handlePlayCard`、`OperationManager`、`ResponseBuilder`、`BroadcastManager`、`RoomAdapter`、`GameController`、`game/`、`rpc/`、`rules/` 等)。
> 一句话:**平台接缝上的名字是契约、要照用;接缝之内你自己代码里的名字都是示例、可自定。** 01–04 的代码块同样遵循这条约定,不再逐处重复声明。
---
## 红线速查(详见 04)
> 以下每一条都有过真实事故或返工,**改代码前先对照**。
- **可编辑范围**:服务端只能改子游戏目录 `server/<游戏容器目录>/<你的游戏>/`(容器目录名由接入方自定,**非框架强制**,换任何不与框架冲突的目录皆可),`server/` 其余皆平台代码,禁改。接入新游戏还需在 `server/youle/app.js` 里加一行 `min_loadJsFile` 加载其 `mod.js`(唯一必须触碰的平台文件,属游戏注册接入点)。
- **双运行时**:代码同时跑在 Node 与浏览器/友乐平台。必须严格 **ES5**;`require` 只能写在文件开头 `if (typeof require !== 'undefined') {}` 守卫块内,**禁止函数体内/中途 require**。
- **成败标志唯一是 `data.success`**:前端只认 `if (!data.success)`;**主动推送的 `data` 必须自带 `success`**(mod 的 `return` 值不可靠下发前端);禁止用 `status`/`code` 判成败、禁止 `status` 兼容兜底。
- **数据权威**:同一业务数据只有一个权威来源,下游只读不重算;权威字段缺失要**显式报错/返回 null**,禁止 `|| 0`、`|| []`、`|| ''` 兜底掩盖。
- **模块职责边界**:一个职能只在一个模块实现,其他模块**调用而非重造**。
- **房间隔离**:一切随对局变化的状态都挂 `o_room.o_desk.data.*`;**禁止模块级单例/全局变量存对局态**;缓存/定时器/决策表不得只用 `seat` 作 key,必须 `房间 + seat`;结束/解散/开新局时按房间清理全部定时器与残留状态。
- **服务端自动操作复用真人链路**:服务端代替玩家做的操作(如 AI 托管)必须复用「真人操作」的同一套处理入口与广播链路,对前端透明,不为它单开一套下发分支。
- **服务器权威 + 发全下发面**:房卡、胜负、积分、状态等关键数据与操作一律以服务器为准、前端只作显示不自算;因此**服务端每个下发包必须携带前端界面所需的全部核心数据**,漏发修在服务端发包处、前端不补(详见 03 §1.2 / 04 §4)。
- **状态机唯一在服务端**:前端是**数据驱动**的、不持有对局状态机(见前端 05 §6),所以阶段、控制权(轮到谁)、该座位 `availableActions`、倒计时锚点**必须由服务端唯一维护并在相关下发包里显式给出**;**任何状态变更都要有包承载**——没有包的状态变化等于逼前端去猜。超时裁定在服务端,不接受前端上报"我超时了"(详见 03 §1.4)。
- **前端不是数据源,只收「意图」**:请求包只接受「操作类型 + 目标标识」;协议**不定义** `score`/`isWin`/`phase`/`nextSeat`/`handCards` 之类**结论入参**,即便客户端硬塞也一律**不读、不落地**,全部按服务端权威数据重算;包内 `seat` 只作一致性校验、身份由连接反查(详见 04 §8)。
- **发全 ≠ 发多,按可见性下发**:他人手牌、牌堆剩余序列、未公开判定、尚未揭晓的结果**不发给不该看到的座位**(差异化下发逐座位裁剪)。**下发即泄露**——包到了客户端就能被抓包看到,"前端拿到但不渲染"不算防护(详见 03 §1.3)。
- **测试纪律**:测试失败先用证据裁定「业务缺陷 vs 测试脚本缺陷」,禁止 skip/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。