删除hook,框架改动

This commit is contained in:
2026-08-19 08:14:48 +08:00
parent 96971f3072
commit 2ce936e7ec
51 changed files with 20002 additions and 4793 deletions
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env node
// 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。
//
// 运行模式:
// (默认,PreToolUse 钩子)读 stdin 工具调用 JSON,命中即 deny 阻止 Write/Edit。
// --staged (pre-commit·严格)扫暂存文件“整文件内容”,命中即非零退出、阻断提交。
// --staged-diff (pre-commit·增量)只查本次“新增行”,老代码存量违规不阻塞——适合已有
// 开发量的老项目回迁:新代码从此强制合规、旧债日后碰到再还。
// --scan-all (报告)扫全仓库可编辑正式代码的 ES5 存量违规,只打印、退出 0、不阻断。
//
// 覆盖:A. 可编辑范围(路径级,禁改平台/vendor/00_Surface/02_Input/server 平台,app.js 例外)
// B. 严格 ES5(运行时正式 .js 禁箭头/模板串/class/let/const;测试脚本放行现代语法)
// 本脚本自身是 Node 工具,不受被检规则约束。
var fs = require('fs');
var cp = require('child_process');
function norm(p) { return String(p || '').replace(/\\/g, '/'); }
// 归一为带前导斜杠,使绝对路径与仓库相对路径统一可匹配。
function m(p) { return '/' + norm(p).replace(/^\/+/, ''); }
function forbiddenReason(p) {
p = m(p);
if (/\/js\/vendor\//.test(p)) return '第三方/引擎 js/vendor/ 禁止修改';
if (/\/js\/00_Surface\//.test(p)) return '平台代码 js/00_Surface/ 禁止修改';
if (/\/02_SubGame_Input\.js$/.test(p)) return '受限契约文件 02_SubGame_Input.js 完全不碰';
if (/\/server\/packet\.js$/.test(p)) return '服务端收包入口 server/packet.js 禁止修改';
if (/\/server\/applist\.js$/.test(p)) return '服务端应用注册表 server/applist.js 禁止修改';
if (/\/server\/class\//.test(p)) return '平台三层路由基类 server/class/ 禁止修改';
if (/\/server\/youle\//.test(p) && !/\/server\/youle\/app\.js$/.test(p))
return 'youle 平台代码 server/youle/ 禁止修改(仅 app.js 为接入注册点例外)';
return null;
}
function isRuntimeJs(p) {
p = m(p);
if (!/\.js$/.test(p)) return false;
if (/\.min\.js$/.test(p)) return false;
if (/\/(node_modules|\.claude|docs|client\/generated)\//.test(p)) return false;
if (/\/js\/vendor\//.test(p) || /\/js\/00_Surface\//.test(p)) return false;
if (/(^|\/)tests?\//.test(p) || /\.(test|spec)\.js$/.test(p)) return false; // 测试可用现代语法
return /\/client\/js\//.test(p) || /\/server\//.test(p);
}
// 去块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。
function stripForScan(src) {
src = src.replace(/\/\*[\s\S]*?\*\//g, function (mm) { return mm.replace(/[^\n]/g, ' '); });
return src.split('\n').map(function (line) {
var s = line.replace(/\/\/.*$/, '');
s = s.replace(/'(?:\\.|[^'\\])*'/g, "''");
s = s.replace(/"(?:\\.|[^"\\])*"/g, '""');
return s;
});
}
var ES5_RULES = [
{ re: /=>/, msg: '箭头函数(=>)——请用 function' },
{ re: /`/, msg: '模板字符串(反引号)——请用字符串拼接' },
{ re: /\bclass\s+[A-Za-z_$]/, msg: 'class 声明——请用 Object.create 继承' },
{ re: /\b(?:let|const)\s+[A-Za-z_$[{]/, msg: 'let/const 声明——请用 var' }
];
// 扫描已 strip 的行数组;onlyLines 为 Set 时只查这些行号(1 基),null=全查。
function scanStripped(lines, onlyLines) {
var out = [];
for (var i = 0; i < lines.length; i++) {
if (onlyLines && !onlyLines.has(i + 1)) continue;
for (var r = 0; r < ES5_RULES.length; r++) {
if (ES5_RULES[r].re.test(lines[i])) {
out.push({ line: i + 1, msg: ES5_RULES[r].msg, text: lines[i].trim().slice(0, 80) });
}
}
}
return out;
}
// 对「路径 + 完整内容」做检查(PreToolUse 用)。
function checkOne(p, content) {
var problems = [];
var fr = forbiddenReason(p);
if (fr) problems.push('【可编辑范围】' + fr);
if (content != null && isRuntimeJs(p)) {
scanStripped(stripForScan(content), null).forEach(function (v) {
problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text);
});
}
return problems;
}
// ---------- git 辅助(--staged / --staged-diff / --scan-all 用) ----------
function gitStaged() {
try {
return cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' })
.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
} catch (e) { return []; }
}
function stagedContent(name) {
try { return cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) { return null; }
}
// 本次暂存 diff 里“新增行”的新文件行号集合。
function addedLines(name) {
var set = {}, has = function (n) { return set[n] === true; };
var out;
try { out = cp.execSync('git diff --cached -U0 -- "' + name + '"', { encoding: 'utf8' }); }
catch (e) { return { has: function () { return false; } }; }
var cur = 0;
out.split('\n').forEach(function (l) {
var mh = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (mh) { cur = parseInt(mh[1], 10); return; }
if (l.charAt(0) === '+' && l.substr(0, 3) !== '+++') { set[cur] = true; cur++; return; }
// '-' 删除行不推进新文件行号;'\' 无换行标记忽略
});
return { has: has };
}
function checkStagedFile(name, diffMode) {
var probs = [];
var fr = forbiddenReason(name);
if (fr) probs.push('【可编辑范围】' + fr);
if (isRuntimeJs(name)) {
var content = stagedContent(name);
if (content != null) {
var lines = stripForScan(content);
var only = diffMode ? addedLines(name) : null;
for (var i = 0; i < lines.length; i++) {
if (only && !only.has(i + 1)) continue;
for (var r = 0; r < ES5_RULES.length; r++) {
if (ES5_RULES[r].re.test(lines[i])) {
probs.push('【严格 ES5】第 ' + (i + 1) + ' 行 ' + ES5_RULES[r].msg + ': ' + lines[i].trim().slice(0, 80));
}
}
}
}
}
return probs;
}
function runStaged(diffMode) {
var all = [];
gitStaged().forEach(function (name) {
var probs = checkStagedFile(norm(name), diffMode);
if (probs.length) all.push(' ' + name + '\n - ' + probs.join('\n - '));
});
if (all.length) {
process.stderr.write('\n✗ 机械红线校验未通过' + (diffMode ? '(仅查本次新增行)' : '') +
',提交被阻止:\n' + all.join('\n') +
'\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n');
process.exit(1);
}
process.exit(0);
}
// 存量违规报告:只看“可编辑的正式运行时 js”的 ES5 违规;平台文件(本就禁改)不计为存量债。
function scanAll() {
var files;
try { files = cp.execSync('git ls-files', { encoding: 'utf8' }).split('\n').map(function (s) { return s.trim(); }).filter(Boolean); }
catch (e) { console.log('(非 git 仓库,跳过存量扫描)'); process.exit(0); }
var scanned = 0, bad = 0, total = 0, rows = [];
files.forEach(function (name) {
var p = norm(name);
if (!isRuntimeJs(p) || forbiddenReason(p)) return;
var content = null;
try { content = fs.readFileSync(p, 'utf8'); } catch (e) { return; }
scanned++;
var v = scanStripped(stripForScan(content), null);
if (v.length) { bad++; total += v.length; rows.push(' ' + name + ' (' + v.length + ' 处:第 ' + v.map(function (x) { return x.line; }).join('/') + ' 行)'); }
});
console.log('存量 ES5 违规扫描(可编辑正式代码,' + scanned + ' 文件):');
if (bad) {
console.log(' 发现 ' + bad + ' 个文件、' + total + ' 处违规(不阻塞,日后碰到逐步还):');
console.log(rows.join('\n'));
} else {
console.log(' ✓ 可编辑正式代码零 ES5 存量违规。');
}
process.exit(0);
}
function runPreToolUse() {
var chunks = [];
process.stdin.on('data', function (c) { chunks.push(c); });
process.stdin.on('end', function () {
var probs = [];
try {
var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
var ti = input.tool_input || {};
var p = norm(ti.file_path || '');
if (!p) process.exit(0);
var content = null;
if (typeof ti.content === 'string') content = ti.content;
else if (typeof ti.new_string === 'string') content = ti.new_string;
else if (Array.isArray(ti.edits))
content = ti.edits.map(function (e) { return e && e.new_string; }).filter(function (s) { return typeof s === 'string'; }).join('\n');
probs = checkOne(p, content);
} catch (e) { process.exit(0); }
if (probs.length) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: '机械红线校验拦截(改正后重试):\n- ' + probs.join('\n- ')
}
}));
}
process.exit(0);
});
}
var argv = process.argv;
if (argv.indexOf('--staged-diff') !== -1) runStaged(true);
else if (argv.indexOf('--staged') !== -1) runStaged(false);
else if (argv.indexOf('--scan-all') !== -1) scanAll();
else runPreToolUse();
-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);
});
-43
View File
@@ -1,43 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "rm -rf \"$CLAUDE_PROJECT_DIR/.claude/.spec-injected\""
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "rm -rf \"$CLAUDE_PROJECT_DIR/.claude/.spec-injected\""
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-redlines.js\"",
"timeout": 10,
"statusMessage": "校验机械红线…"
},
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/remind-docs.js\"",
"timeout": 15,
"statusMessage": "注入/校验开发规范…"
}
]
}
]
}
}
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# 机械红线校验:对本次暂存做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。
# 工具无关——Claude Code、别的编辑器、人手改,提交都过这道闸。
# 模式(本地 git 配置 redlines.stagedmode):
# 未设 / 非 diff → 严格:整文件扫描(模板等干净仓库用)
# diff → 增量:只查本次新增行(已有开发量的老项目回迁用)
# 启用(每个克隆一次性):git config core.hooksPath .githooks (或跑 setup-redlines.sh)
MODE="$(git config --get redlines.stagedmode 2>/dev/null)"
if [ "$MODE" = "diff" ]; then FLAG="--staged-diff"; else FLAG="--staged"; fi
exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" "$FLAG"
+7 -7
View File
@@ -1,12 +1,12 @@
# CLAUDE.md # 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` 加载。 一个友乐/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` 迁移,对模板克隆无效)。 本仓库是**模板项目**——后续会被克隆出去开新项目。因此所有需要长期保留的约定与知识**一律进仓库**(`docs/` 权威文档、本 CLAUDE.md、`.claude/` 钩子与 `settings.json`、`.githooks/`),**不依赖 Claude memory**(memory 按机器/会话存放、不随 `git clone` 迁移,对模板克隆无效)。
@@ -56,13 +56,13 @@ git config core.hooksPath .githooks
三者互补不冲突:engineering 讲「该怎么设计、怎么长久演进」,dev-guide 讲「平台怎么接、红线是什么」;硬红线冲突时以 dev-guide 为准。 三者互补不冲突:engineering 讲「该怎么设计、怎么长久演进」,dev-guide 讲「平台怎么接、红线是什么」;硬红线冲突时以 dev-guide 为准。
以下三份 README(含各自「红线速查 / 一页纸总则」)通过 `@import` **自动加载进每次会话的上下文**——红线以它们为权威,无需在本文件另抄一份;改动具体区域时再深入读对应编号文档: 以下三份「红线速查 / 一页纸总则」通过 `@import` **自动加载进每次会话的上下文**——红线以它们为权威,无需在本文件另抄一份;改动具体区域时再深入读对应编号文档:
@docs/client/development-guide/README.md @docs/client/development-guide/红线速查.md
@docs/server/development-guide/README.md @docs/server/development-guide/红线速查.md
@docs/games/engineering/README.md @docs/games/engineering/一页纸总则.md
各篇「编号 ↔ 主题」见上面 `@import` 的三份 README 的「阅读顺序 / 阅读导航」表,此处不再复述。 各套文档的「编号 ↔ 主题」导航表、面向人的阅读顺序与定位说明留在各自 README(`docs/client/development-guide/README.md`、`docs/server/development-guide/README.md`、`docs/games/engineering/README.md`)——那些是查阅时才需要的内容,不常驻上下文,需要时直接读。
## 测试与 Git ## 测试与 Git
+201 -81
View File
@@ -1,21 +1,22 @@
<!DOCTYPE HTML> <!DOCTYPE HTML>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-Control" content="no-cache"> <meta http-equiv="Cache-Control" content="no-cache">
<meta http-equiv="Expires" content="0"> <meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover" /> <meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=0,viewport-fit=cover"/>
<title>erqiwang</title> <title>jxmahjong</title>
<style> <style>
body { body {
padding-top: constant(safe-area-inset-top); padding-top: constant(safe-area-inset-top);
padding-left: constant(safe-area-inset-left); padding-left: constant(safe-area-inset-left);
padding-right: constant(safe-area-inset-right); padding-right: constant(safe-area-inset-right);
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
} }
</style> </style>
</head> </head>
@@ -41,26 +42,21 @@
<p>Your browser does not support the canvas element.</p> <p>Your browser does not support the canvas element.</p>
</canvas> </canvas>
</div> </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"> <style type="text/css">
* { * { margin: 0; padding: 0; }
margin: 0; html, body { height: 100%; width: 100%;
padding: 0; overflow-x:hidden;overflow-y:hidden;
} backgroundColor:"rab(255,0,0)";
html,
body {
height: 100%;
width: 100%;
overflow-x: hidden;
overflow-y: hidden;
backgroundColor: "rab(255,0,0)";
}
canvas {
display: block;
} }
canvas { display: block; }
</style> </style>
<script> <script>
@@ -72,74 +68,198 @@
WVJBIframe.style.display = 'none'; WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__'; WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe); document.documentElement.appendChild(WVJBIframe);
setTimeout(function () { document.documentElement.removeChild(WVJBIframe) }, 0) setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
} }
setupWebViewJavascriptBridge(function (bridge) { }); setupWebViewJavascriptBridge(function(bridge){});
</script> </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/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'>var gameabc_face = gameabc_face || {};</script> <script language='javascript'>gameabc_face.path="assets/bmp";</script>
<script language='javascript'>gameabc_face.path = "assets/bmp";</script> <!-- Spine 运行时按需加载。
<!-- Spine Canvas Runtime --> spine-canvas.js 与 spine-webgl.js 都以 var spine = (() => {...})() 暴露
<script type="text/javascript" src="js/vendor/spine-canvas.js"></script> 同一个全局名 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> <script type="text/javascript" src="js/vendor/gameabc.min.js"></script>
<!-- Spine 资源清单:列出需要预加载的 Spine 资源基础名 --> <!-- Spine 资源清单:列出需要预加载的 Spine 资源基础名 -->
<script type="text/javascript" src="generated/spine_assets.js"></script> <script type="text/javascript" src="generated/spine_assets.js"></script>
<!-- Spine 文本数据嵌入(解决 file:// 协议 CORS 问题) --> <!-- Spine 文本数据嵌入(解决 file:// 协议 CORS 问题) -->
<script type="text/javascript" src="generated/spine_data.js"></script> <script type="text/javascript" src="generated/spine_data.js"></script>
<!-- Spine 渲染后端:必须在 SpineMgr.js 之前加载(SpineMgr 末尾会立即执行预加载块)-->
<script type="text/javascript" src="js/gameabc-framework/spine/SpineOverlayTransform.js"></script>
<script type="text/javascript" src="app_data.js"></script> <script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendCanvas.js"></script>
<script type="text/javascript" src="app_battery.js"></script> <script type="text/javascript" src="js/gameabc-framework/spine/SpineBackendWebGL.js"></script>
<script type="text/javascript" src="app_network.js"></script> <!-- SpineMgr: 自动初始化+自动渲染,必须在 gamemain.js 之前加载 -->
<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 之前加载 -->
<script type="text/javascript" src="js/gameabc-framework/spine/SpineMgr.js"></script> <script type="text/javascript" src="js/gameabc-framework/spine/SpineMgr.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> <script type="text/javascript" src="output/gameabc_data.min.js"></script>
</body> </body>
</html> </html>
+4 -2
View File
@@ -142,7 +142,7 @@ GameData.hallConfigName = ["enablePay",
'wareHouse',//是否开启仓库功能0->不开启1->开启保险箱不开启赠送2->开启赠送不开启保险箱3->开启星星赠送(开启保险箱)4->开启房卡赠送(开启保险箱)5->开启星星房卡赠送(开启保险箱) 'wareHouse',//是否开启仓库功能0->不开启1->开启保险箱不开启赠送2->开启赠送不开启保险箱3->开启星星赠送(开启保险箱)4->开启房卡赠送(开启保险箱)5->开启星星房卡赠送(开启保险箱)
'betaText',//版本号后的beta显示内容 'betaText',//版本号后的beta显示内容
'launchImgUrl',//启动页图片地址 'launchImgUrl',//启动页图片地址
'shareType:',//分享朋友圈类型0->链接分享 1->图片分享 'shareType',//分享朋友圈类型0->链接分享 1->图片分享
'shareImgUrl',//分享朋友圈图片链接 'shareImgUrl',//分享朋友圈图片链接
'launchWaitTime',//启动等待时间 'launchWaitTime',//启动等待时间
'shareBgUrl',//分享背景图片 'shareBgUrl',//分享背景图片
@@ -151,7 +151,8 @@ GameData.hallConfigName = ["enablePay",
'channelKey',//支付key 'channelKey',//支付key
'payCSS',//支付css 'payCSS',//支付css
'payJS',//支付js 'payJS',//支付js
'openVideo' 'openVideo',
'showVisitor'//是否显示游客登陆
];//是否开启视频]; ];//是否开启视频];
GameData.hallConfig = { GameData.hallConfig = {
enablePay:0, enablePay:0,
@@ -185,6 +186,7 @@ GameData.hallConfig = {
payCSS:"",//支付css payCSS:"",//支付css
payJS:"",//支付js payJS:"",//支付js
openVideo:0,//是否开启视频 openVideo:0,//是否开启视频
showVisitor:0//是否显示游客登陆
}; };
GameData.starName = "星星"; GameData.starName = "星星";
GameData.roomCardName = "房卡"; 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.offline = 0;//玩家是否离线0->否 1->离线
this.canexit=1;//是否可以直接离开 this.canexit=1;//是否可以直接离开
this.onstate=0;//0:在线1:离线2:通话中 this.onstate=0;//0:在线1:离线2:通话中
/*
{"address":"江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)","city":"南昌市",
"cityCode":"0791","country":"中国","district":"青云谱区","latitude":28.623546,"longitude":115.900333,
"province":"江西省","street":"施尧路"}
*/
//定位信息 牌桌外
this.addr = null; this.addr = null;
this.invitecode = "";//邀请码 this.invitecode = "";//邀请码
this.isStart = false;//能否点击按钮开始游戏 this.isStart = false;//能否点击按钮开始游戏
@@ -31,6 +37,14 @@ function Player(seat){
this.charm = undefined; this.charm = undefined;
this.sign = "";//签名 this.sign = "";//签名
this.tel = "";//绑定手机号 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"){ if(typeof(Player.prototype.Init) == "undefined"){
@@ -67,6 +81,7 @@ if(typeof(Player.prototype.Init) == "undefined"){
this.charm = undefined; this.charm = undefined;
this.sign = "";//签名 this.sign = "";//签名
this.tel = "";//绑定手机号 this.tel = "";//绑定手机号
}; };
} }
//玩家微信信息初始化 //玩家微信信息初始化
@@ -107,6 +122,7 @@ if(typeof(Player.prototype.SetMyInfo) == "undefined"){
this.charm = object.charm; this.charm = object.charm;
this.sign = object.sign; this.sign = object.sign;
this.tel = object.tel; this.tel = object.tel;
//this.setTel(); //this.setTel();
//this.bankpwd = object.bank;//是否设置仓库密码 this.bankpwd = 0;//是否设置仓库密码 //this.bankpwd = object.bank;//是否设置仓库密码 this.bankpwd = 0;//是否设置仓库密码
//this.paycode = _paycode; //this.paycode = _paycode;
@@ -121,7 +137,7 @@ if(typeof(Player.prototype.SetLocationInfo) == "undefined"){
}else{ }else{
set_self(200, 7, "获取定位失败!", 0, 0); set_self(200, 7, "获取定位失败!", 0, 0);
} }
this.location = _locationinfo;
}; };
} }
@@ -279,6 +295,7 @@ if(typeof(Player.prototype.SetDeskInfo) == "undefined"){
}else{ }else{
this.paycode = ""; this.paycode = "";
} }
this.location = _data.location;
this.charm = _data.charm; this.charm = _data.charm;
this.sign = _data.sign; this.sign = _data.sign;
}; };
+2
View File
@@ -421,6 +421,8 @@ Desk.login=function(_msg){//登录
Func.createRoom(); Func.createRoom();
} }
Game_Modify.Reconnect(_msg.data.deskinfo); Game_Modify.Reconnect(_msg.data.deskinfo);
}else{
Game_Modify.ReconnectNoMakewar();
} }
//} //}
}else{ }else{
+6
View File
@@ -545,6 +545,11 @@ Utl.openVideo = function (_playerList, _seat, _roomcode, roomtype) {
set_self(152, 37, 0, 0, 0); set_self(152, 37, 0, 0, 0);
set_self(153, 37, 0, 0, 0); set_self(153, 37, 0, 0, 0);
set_self(107, 37, 0, 0, 0); set_self(107, 37, 0, 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++) { for (var i = 0; i < Desk.PlayerList.length; i++) {
if (Desk.PlayerList[i].nickname != "") { if (Desk.PlayerList[i].nickname != "") {
var ind = Logic.ChangeToStatus(C_Player.seat, i); var ind = Logic.ChangeToStatus(C_Player.seat, i);
@@ -582,6 +587,7 @@ Utl.openVideo = function (_playerList, _seat, _roomcode, roomtype) {
set_group(43 + ind, 37, 1, 0, 0); set_group(43 + ind, 37, 1, 0, 0);
} }
} }
// }
play_ani123(1, 156, 35, 255, 100, 1000, 0, 0, 1); play_ani123(1, 156, 35, 255, 100, 1000, 0, 0, 1);
Utl.playMusic(); Utl.playMusic();
set_self(3141, 37, 0, 0, 0); set_self(3141, 37, 0, 0, 0);
+4 -1
View File
@@ -3332,7 +3332,10 @@ GameUI.JumpWxAuth = function() { //跳转微信授权
//set_self(3,19,382,0,0); //set_self(3,19,382,0,0);
//set_self(3,20,400,0,0); //set_self(3,20,400,0,0);
//set_self(3,21,116,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) { if (GameData.hallConfig.qqLogin) {
set_self(106, 37, 1, 0, 0); set_self(106, 37, 1, 0, 0);
+28 -27
View File
@@ -337,35 +337,35 @@ Logic.AppStart=function(){
// 解决:覆盖全局字体渲染函数,统一使用更粗的中文友好字体 // 解决:覆盖全局字体渲染函数,统一使用更粗的中文友好字体
// 保存原始的 Canvas fillText 方法 // 保存原始的 Canvas fillText 方法
var originalFillText = CanvasRenderingContext2D.prototype.fillText; // var originalFillText = CanvasRenderingContext2D.prototype.fillText;
var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText; // var originalStrokeText = CanvasRenderingContext2D.prototype.strokeText;
// 覆盖 fillText 方法,在绘制前统一字体设置 // // 覆盖 fillText 方法,在绘制前统一字体设置
CanvasRenderingContext2D.prototype.fillText = function() { // CanvasRenderingContext2D.prototype.fillText = function() {
// 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体 // // 如果当前字体包含 'lighter' 或只有 'Arial',替换为更适合中文的字体
if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { // if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
// 提取字体大小 // // 提取字体大小
var sizeMatch = this.font.match(/(\d+)px/); // var sizeMatch = this.font.match(/(\d+)px/);
if (sizeMatch) { // if (sizeMatch) {
var size = sizeMatch[1]; // var size = sizeMatch[1];
// 使用 500 字重(中等粗细)+ 中文友好字体 // // 使用 500 字重(中等粗细)+ 中文友好字体
this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; // this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
} // }
} // }
return originalFillText.apply(this, arguments); // return originalFillText.apply(this, arguments);
}; // };
// 同样覆盖 strokeText 方法 // // 同样覆盖 strokeText 方法
CanvasRenderingContext2D.prototype.strokeText = function() { // CanvasRenderingContext2D.prototype.strokeText = function() {
if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) { // if (this.font && (this.font.includes('lighter') || this.font.includes('Arial'))) {
var sizeMatch = this.font.match(/(\d+)px/); // var sizeMatch = this.font.match(/(\d+)px/);
if (sizeMatch) { // if (sizeMatch) {
var size = sizeMatch[1]; // var size = sizeMatch[1];
this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'; // this.font = '500 ' + size + 'px "Microsoft YaHei", "PingFang SC", Arial, sans-serif';
} // }
} // }
return originalStrokeText.apply(this, arguments); // return originalStrokeText.apply(this, arguments);
}; // };
// console.log('[字体优化] Canvas 文字渲染已优化,中英文粗细统一'); // console.log('[字体优化] Canvas 文字渲染已优化,中英文粗细统一');
// ============================================================================ // ============================================================================
@@ -1796,6 +1796,7 @@ Logic.setConfigInfo = function(){
} }
for(var i=0;i<GameData.hallConfigName.length;i++){ for(var i=0;i<GameData.hallConfigName.length;i++){
var val = get_paravalue(GameData.serverConfig,GameData.hallConfigName[i]); var val = get_paravalue(GameData.serverConfig,GameData.hallConfigName[i]);
// console.log(GameData.hallConfigName[i] + val);
if(val != null){ if(val != null){
GameData.hallConfig[GameData.hallConfigName[i]] = val; GameData.hallConfig[GameData.hallConfigName[i]] = val;
} }
+5 -5
View File
@@ -13,8 +13,8 @@ Game_Config.Debugger={//调试配置
visitorLogin : true,//隐藏式游客登录 visitorLogin : true,//隐藏式游客登录
visiblePay:true,//审核通过后是否显示支付按钮 visiblePay:true,//审核通过后是否显示支付按钮
serverType:1,//0->正式服务器 1->本地服务器 http://ylyxservice1.0791ts.cn/config/update_json.txt 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/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/update_jsonv2.txt"+"?"+ifast_random(100000)
//gameserver:"https://gameotherwork.ld2sw.cn/update_json/update_json_haiwai.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) // gameserver:"https://tsgames.daoqi88.cn/config/update_jsonv2.txt"+"?"+ifast_random(100000)
@@ -46,13 +46,13 @@ Game_Config.Info = {
myPosition:130,//自己信息对齐点(根据游戏界面自行修改) myPosition:130,//自己信息对齐点(根据游戏界面自行修改)
position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始 position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始
//TextContent:["1","2","3","4","5","6","7"],//常用语内容 //TextContent:["1","2","3","4","5","6","7"],//常用语内容
TextContent:["你好","2","3","4","5","6","7"],//常用语内容 TextContent:["不要吵了不要吵了,专心玩游戏吧。","不要走,决战到天亮!","大家好,很高兴见到各位。","各位真是不好意思,我要离开一会儿。","快点吧,我等的花儿都谢了!","再见了,我会想念大家的。","怎么又断线了,网络怎么这么差啊!"],//常用语内容
TextContentMp3:["","","","","","",""],//常用语对应音效 TextContentMp3:["","","","","","",""],//常用语对应音效
}; };
Game_Config.Share={//分享参数 Game_Config.Share={//分享参数
appdownload:"",//下载链接(无需配置,从服务器获取) appdownload:"",//下载链接(无需配置,从服务器获取)
title:"Test",//(分享标题) title:"进贤麻将",//(分享标题)
description:"hello world",//分享描述 description:"欢乐无限",//分享描述
gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里 gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里
gameDescription:""//游戏中分享描述 gameDescription:""//游戏中分享描述
}; };
+5
View File
@@ -93,6 +93,11 @@ Game_Modify.Reconnect = function (_deskinfo) {
if (window.SubGameHooks && SubGameHooks.Reconnect) return SubGameHooks.Reconnect(_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 // A 类:关闭所有声音,无 hook 即 no-op
Game_Modify.stopAllSounds = function () { Game_Modify.stopAllSounds = function () {
if (window.SubGameHooks && SubGameHooks.stopAllSounds) return SubGameHooks.stopAllSounds(); if (window.SubGameHooks && SubGameHooks.stopAllSounds) return SubGameHooks.stopAllSounds();
+1
View File
@@ -0,0 +1 @@
//精灵事件单元...
+36 -19
View File
@@ -2,7 +2,10 @@
> 本手册针对 **gameabc 引擎** 的 Spine 项目,说明如何在 Canvas 2D 游戏中加载、 > 本手册针对 **gameabc 引擎** 的 Spine 项目,说明如何在 Canvas 2D 游戏中加载、
> 控制和管理 Spine 骨骼动画。 > 控制和管理 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/ Projects/Spine/
├── index.html ← 入口 HTML ├── index.html ← 入口 HTML
├── js/ ├── js/
│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库) │ ├── spine-webgl.js ← Spine WebGL 运行时 (第三方库,探测后二选一)
│ ├── spine-canvas.js ← Spine Canvas 2D 运行时 (第三方库,探测后二选一)
│ ├── gameabc.min.js ← gameabc 游戏引擎 │ ├── gameabc.min.js ← gameabc 游戏引擎
│ ├── SpineMgr.js ← ★ Spine 动画管理器(独立文件,自动挂钩渲染) │ ├── SpineMgr.js ← ★ Spine 动画管理器(独立文件,自动挂钩渲染)
│ ├── gamemain.js ← 游戏主逻辑(无需修改) │ ├── 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/SpineMgr.js` | Spine 动画管理器,自动初始化+自动渲染 | ✗ 不需要修改 |
| `js/gamemain.js` | 游戏主逻辑(保持原样) | ✗ 不需要修改 | | `js/gamemain.js` | 游戏主逻辑(保持原样) | ✗ 不需要修改 |
| `js/Spine_Event.js` | Spine 动画完成/自定义事件回调 | ✓ 处理动画事件 | | `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); 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) { gameabc_face.mousemove = function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
// 通过拖拽移动角色 // 通过拖拽移动角色
var mgr = gameabc_face.spineMgr; var mgr = gameabc_face.spineMgr;
var entry = mgr._entries["hero"]; mgr.ensureEntry("hero", "hero.json", "hero.atlas", { scale: 1 });
if (entry) { heroX += offmovex;
mgr.setPosition("hero", entry.x + offmovex, entry.y + offmovey); heroY += offmovey;
mgr.setPosition("hero", heroX, heroY);
// 根据移动方向翻转 // 根据移动方向翻转
mgr.setFlip("hero", offmovex < 0, false); mgr.setFlip("hero", offmovex < 0, false);
}
}; };
``` ```
@@ -742,7 +750,7 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
3. **打开浏览器控制台(F12)看报错** 3. **打开浏览器控制台(F12)看报错**
- 404 错误:文件路径有误 - 404 错误:文件路径有误
- JSON 解析错误:`.json` 文件格式异常 - JSON 解析错误:`.json` 文件格式异常
- `spine is not defined`:`spine-canvas.js` 未正确加载 - `spine is not defined`:`spine-webgl.js` / `spine-canvas.js` 均未加载(探测脚本未执行或路径错)
4. **坐标是否在可见范围内?** 4. **坐标是否在可见范围内?**
- 项目设计尺寸为 1280×720,检查 `x` 和 `y` 是否在此范围 - 项目设计尺寸为 1280×720,检查 `x` 和 `y` 是否在此范围
@@ -762,11 +770,17 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
- 检查 `gameabc_Project` 中的 `fps` 设置(默认 30) - 检查 `gameabc_Project` 中的 `fps` 设置(默认 30)
- SpineMgr 内部是用 `Date.now()` 计算真实时间差的,不依赖帧率 - SpineMgr 内部是用 `Date.now()` 计算真实时间差的,不依赖帧率
- 如果需要倍速播放,修改 `state.timeScale`: - 如果需要倍速播放,用 `setAnimation` 的返回值(Spine 的 TrackEntry)改 `timeScale`:
```javascript ```javascript
var entry = gameabc_face.spineMgr._entries["hero"]; var mgr = gameabc_face.spineMgr;
entry.state.timeScale = 2.0; // 2 倍速 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: 切换动画时有跳帧 ### Q4: 切换动画时有跳帧
@@ -778,9 +792,9 @@ gameabc_face.gamemydraw = function(gameid, spid, times, timelong) {
- 确认没有同一个 id 加载两次 - 确认没有同一个 id 加载两次
- 检查 `ctx.save()` / `ctx.restore()` 是否配对(SpineMgr 内部已处理) - 检查 `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 编辑器,请下载对应版本的运行时: - 如果使用 Spine 4.1 编辑器,请下载对应版本的运行时:
``` ```
https://unpkg.com/@esotericsoftware/spine-canvas@4.1/dist/iife/spine-canvas.js 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 标签加载顺序至关重要: `index.html` 中的 script 标签加载顺序至关重要:
``` ```
1. spine-canvas.js ← 先加载 Spine 运行时 (定义 window.spine) 1. spine-webgl.js 或 spine-canvas.js
← 先加载 Spine 运行时 (定义 window.spine),探测后只加载一套
2. gameabc.min.js ← 再加载游戏引擎 2. gameabc.min.js ← 再加载游戏引擎
3. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染 3. SpineOverlayTransform.js / SpineBackendCanvas.js / SpineBackendWebGL.js
4. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可) ← 渲染后端,必须在 SpineMgr.js 之前(SpineMgr 末尾会立即执行预加载块)
5. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face) 4. SpineMgr.js ← Spine 管理器 + defineProperty 自动挂钩渲染
6. Project1_Event.js ← 精灵事件 5. gamemain.js ← 游戏主逻辑(不需修改,直接调用 API 即可)
7. gameabc_data.min.js ← 项目配置数据 (引擎初始化) 6. Spine_Event.js ← Spine 事件回调 (依赖 gameabc_face)
7. Project1_Event.js ← 精灵事件
8. gameabc_data.min.js ← 项目配置数据 (引擎初始化)
``` ```
> **不能调换顺序**,`SpineMgr.js` 必须在 `gamemain.js` 之前加载, > **不能调换顺序**,`SpineMgr.js` 必须在 `gamemain.js` 之前加载,
@@ -283,7 +283,7 @@ var SpriteManager = (function() {
return true; return true;
} }
console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)'); // console.log('🎨 SpriteManager: 开始初始化 (重构版 v2.0)');
// 检查依赖 // 检查依赖
if (typeof GameABCUtils === 'undefined') { if (typeof GameABCUtils === 'undefined') {
@@ -292,8 +292,8 @@ var SpriteManager = (function() {
} }
_isInitialized = true; _isInitialized = true;
console.log('✅ SpriteManager: 初始化完成'); // console.log('✅ SpriteManager: 初始化完成');
console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性'); // console.log('ℹ️ SpriteManager: 设计原则 - 精灵由编辑器创建,代码只操作属性');
return true; return true;
}, },
@@ -95,21 +95,21 @@ var RpcHelper = {
} }
// 5. 调试信息输出 // 5. 调试信息输出
if (debug || Game_Config.Debugger.isDebugger) { // if (debug || Game_Config.Debugger.isDebugger) {
console.log('RpcHelper发送RPC:', { // console.log('RpcHelper发送RPC:', {
app: app, // app: app,
route: route, // route: route,
rpc: rpc, // rpc: rpc,
platform_data: { // platform_data: {
agentid: data.agentid, // agentid: data.agentid,
gameid: data.gameid, // gameid: data.gameid,
playerid: data.playerid, // playerid: data.playerid,
roomcode: data.roomcode // roomcode: data.roomcode
}, // },
game_data: gameData, // game_data: gameData,
custom_data: customData // custom_data: customData
}); // });
} // }
// 6. 通过框架发送RPC请求 // 6. 通过框架发送RPC请求
Utl.sendData(app, route, rpc, data); 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(){ (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 = { var SpineMgr = {
// --- 内部状态 --- // --- 内部状态 ---
_entries: {}, // {id: EntryObject} _entries: {}, // {id: EntryObject}
_assetManager: null, _assetManager: null,
_renderer: null, // spine.SkeletonRenderer (Canvas 2D) _backend: null, // 渲染后端(SpineBackendWebGL / SpineBackendCanvas)
_lastTime: 0, // 上一帧时间戳(ms) _lastTime: 0, // 上一帧时间戳(ms)
_inited: false, _inited: false,
_loadedPaths: {}, // 已请求加载的资源路径,避免重复请求 _loadedPaths: {}, // 已请求加载的资源路径,避免重复请求
@@ -27,15 +40,50 @@ var SpineMgr = {
// ---------------------------------------------------------- // ----------------------------------------------------------
init: function(basePath) { init: function(basePath) {
if (!window.spine) { if (!window.spine) {
logmessage("[SpineMgr] spine-canvas.js 未加载,请检查引用"); spineWarn("[SpineMgr] spine 运行时未加载,请检查 index.html 的探测脚本");
return; return;
} }
this._backend = this._selectBackend();
if (!this._backend) {
spineWarn("[SpineMgr] 无可用渲染后端");
return;
}
spineLog("[SpineMgr] 渲染后端: " + this._backend.name);
this._basePath = basePath || this._basePath; 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._lastTime = Date.now();
this._inited = true; 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() { _ensureInit: function() {
if (!this._inited) { 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 // (内部) 自动加载:根据 id 约定文件名 id.json / id.atlas
// 主流 SkeletonJson.scale 方案:从 _scaleMap 读取目标缩放,传入 load() 在解析时烘焙 // 主流 SkeletonJson.scale 方案:从 _scaleMap 读取目标缩放,传入 load() 在解析时烘焙
_autoLoad: function(id) { _autoLoad: function(id) {
@@ -101,16 +185,45 @@ var SpineMgr = {
visible: true, visible: true,
ready: false, ready: false,
_hideOnComplete: 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 共享 // 同一 jsonFile+scale 的 SkeletonData 只解析一次,多个 entry 共享
// ---------------------------------------------------------- // ----------------------------------------------------------
_buildEntry: function(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 cacheKey = entry.jsonFile + "_s" + entry.scale;
var skelData = this._skelDataCache[cacheKey]; var skelData = this._skelDataCache[cacheKey];
if (!skelData) { if (!skelData) {
@@ -122,9 +235,9 @@ var SpineMgr = {
this._assetManager.require(entry.jsonFile) this._assetManager.require(entry.jsonFile)
); );
this._skelDataCache[cacheKey] = skelData; this._skelDataCache[cacheKey] = skelData;
logmessage("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey); spineLog("[SpineMgr] SkeletonData 解析并缓存: " + cacheKey);
} else { } else {
logmessage("[SpineMgr] SkeletonData 命中缓存: " + cacheKey); spineLog("[SpineMgr] SkeletonData 命中缓存: " + cacheKey);
} }
entry.skeleton = new spine.Skeleton(skelData); entry.skeleton = new spine.Skeleton(skelData);
@@ -179,10 +292,15 @@ var SpineMgr = {
e._id = id; e._id = id;
try { try {
this._buildEntry(e); this._buildEntry(e);
logmessage("[SpineMgr] " + id + " 构建完成"); spineLog("[SpineMgr] " + id + " 构建完成");
this._flushCmds(id); this._flushCmds(id);
} catch(err) { } catch(err) {
logmessage("[SpineMgr] " + id + " 构建失败: " + err.message); // 重试本身必须保留(资源可能只是还没到齐),但日志只记一次:
// 本方法每帧调用,持续失败时会以约 60 条/秒刷屏并淹没其他日志。
if (!e._buildFailLogged) {
e._buildFailLogged = true;
spineWarn("[SpineMgr] " + id + " 构建失败(仅记一次): " + err.message);
}
e.ready = false; e.ready = false;
} }
} }
@@ -190,6 +308,79 @@ var SpineMgr = {
return true; 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) 每帧自动调用 // updateAndDraw(ctx) 每帧自动调用
// ctx: gameabc_face.dc (Canvas 2D Context) // ctx: gameabc_face.dc (Canvas 2D Context)
@@ -203,29 +394,33 @@ var SpineMgr = {
this._lastTime = now; this._lastTime = now;
if (dt <= 0 || dt > 0.5) dt = 1/30; if (dt <= 0 || dt > 0.5) dt = 1/30;
if (!this._renderer) { // 先推进动画状态:即使本帧无法绘制(后端未就绪 / context lost),
this._renderer = new spine.SkeletonRenderer(ctx); // 动画时间轴与 complete 事件也必须照常推进,否则回调链会卡死。
this._renderer.triangleRendering = true; // ★ 启用三角形渲染以支持 Mesh 网格附件 var visibleList = [];
}
// 确保 renderer 用的是当前 ctx (gameabc可能重建)
this._renderer.ctx = ctx;
for (var id in this._entries) { for (var id in this._entries) {
var e = this._entries[id]; var e = this._entries[id];
if (!e.ready || !e.visible) continue; if (!e.ready || !e.visible) continue;
e.state.update(dt); e.state.update(dt);
e.state.apply(e.skeleton); e.state.apply(e.skeleton);
// 骨骼位置归零,由 ctx.translate 控制实际位置 // 骨骼位置由后端决定:Canvas 后端归零(用 ctx.translate 定位),
e.skeleton.x = 0; // WebGL 后端写入 x/-y(位移并入骨骼,使所有 entry 共用同一 MVP)。
e.skeleton.y = 0; // 必须在 updateWorldTransform 之前设置,否则不生效于本帧。
this._backend.placeEntry(e);
e.skeleton.updateWorldTransform(spine.Physics.update); e.skeleton.updateWorldTransform(spine.Physics.update);
visibleList.push(e);
}
ctx.save(); if (!this._backend.beginFrame(visibleList.length)) return;
ctx.translate(e.x, e.y); // endFrame 必须配对执行:WebGL 后端的 PolygonBatcher 只在 endFrame 里 end(),
ctx.scale(1, -1); // ★ Spine Y-up → Canvas Y-down(scale 已烘焙进 SkeletonJson.scale) // 若 drawEntry 抛出而漏了 end(),batcher.isDrawing 会永久停在 true,
this._renderer.draw(e.skeleton); // 下一帧 begin() 即抛 "PolygonBatch is already drawing" 并冒到 gameenddraw —— Spine 永久黑屏。
ctx.restore(); 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; return;
} }
if (e.scale === sx) return; // 已是目标 scale,无需重建 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 savedX = e.x, savedY = e.y;
var savedJson = e.jsonFile, savedAtlas = e.atlasFile;
this.remove(id); 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) { setFlip: function(id, flipX, flipY) {
@@ -423,26 +621,16 @@ gameabc_face.spineMgr = SpineMgr;
// 彻底避免 file:// 协议下 XHR CORS 拦截问题 // 彻底避免 file:// 协议下 XHR CORS 拦截问题
if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) { if (gameabc_face.spineAssets && gameabc_face.spineAssets.length > 0) {
SpineMgr._ensureInit(); SpineMgr._ensureInit();
var list = gameabc_face.spineAssets; if (!SpineMgr._inited) {
var textData = gameabc_face.spineTextData || {}; // 后端未就绪:跳过预加载,但必须继续往下走完 gameenddraw 拦截的安装。
for (var i = 0; i < list.length; i++) { // 此处不能 return —— 预加载块在 IIFE 函数体内,return 会退出整个 IIFE,
var name = list[i]; // 反而导致拦截装不上,正是本守卫要避免的后果。
var jsonKey = name + ".json"; // 回归锁:codes/test/node/spineMgr.gameEndDrawGuard.test.js
var atlasKey = name + ".atlas"; spineWarn("[SpineMgr] 后端未就绪,跳过 Spine 资源预加载");
// 将嵌入文本数据注册为 rawDataURI,Spine Downloader 会优先从内存读取 } else {
if (textData[jsonKey]) { // 与 context 恢复后的重注入共用同一份实现,避免两处清单分叉
SpineMgr._assetManager.setRawDataURI(jsonKey, "data:," + textData[jsonKey]); SpineMgr._preloadAll();
} }
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;
}
logmessage("[SpineMgr] 预加载 " + list.length + " 组 Spine 资源" +
(Object.keys(textData).length > 0 ? "(使用嵌入数据)" : "(使用网络请求)"));
} }
// ★ 用 defineProperty 拦截 gameenddraw 赋值 // ★ 用 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; _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; return;
} }
_initialized = true; _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); this._listeners[eventName].push(listener);
console.log('✅ EventBus: 订阅事件', eventName); // console.log('✅ EventBus: 订阅事件', eventName);
// 返回取消订阅函数 // 返回取消订阅函数
var self = this; var self = this;
@@ -121,7 +121,7 @@ var EventBus = {
// 如果没有指定callback,取消该事件的所有监听器 // 如果没有指定callback,取消该事件的所有监听器
if (!callback) { if (!callback) {
delete this._listeners[eventName]; delete this._listeners[eventName];
console.log('✅ EventBus: 取消所有', eventName, '监听器'); // console.log('✅ EventBus: 取消所有', eventName, '监听器');
return true; return true;
} }
@@ -130,7 +130,7 @@ var EventBus = {
for (var i = listeners.length - 1; i >= 0; i--) { for (var i = listeners.length - 1; i >= 0; i--) {
if (listeners[i].callback === callback) { if (listeners[i].callback === callback) {
listeners.splice(i, 1); listeners.splice(i, 1);
console.log('✅ EventBus: 取消', eventName, '监听器'); // console.log('✅ EventBus: 取消', eventName, '监听器');
return true; return true;
} }
} }
@@ -152,7 +152,7 @@ var EventBus = {
var listeners = this._listeners[eventName].slice(); // 复制数组,避免迭代中修改 var listeners = this._listeners[eventName].slice(); // 复制数组,避免迭代中修改
var count = 0; var count = 0;
console.log('📡 EventBus: 触发事件', eventName, data); // console.log('📡 EventBus: 触发事件', eventName, data);
for (var i = 0; i < listeners.length; i++) { for (var i = 0; i < listeners.length; i++) {
try { try {
@@ -204,7 +204,7 @@ var EventBus = {
*/ */
clear: function() { clear: function() {
this._listeners = {}; this._listeners = {};
console.log('✅ EventBus: 已清空所有监听器'); // console.log('✅ EventBus: 已清空所有监听器');
}, },
/** /**
@@ -214,7 +214,7 @@ var EventBus = {
clearEvent: function(eventName) { clearEvent: function(eventName) {
if (this._listeners[eventName]) { if (this._listeners[eventName]) {
delete this._listeners[eventName]; delete this._listeners[eventName];
console.log('✅ EventBus: 已清空', eventName, '的所有监听器'); // console.log('✅ EventBus: 已清空', eventName, '的所有监听器');
} }
} }
}; };
@@ -79,7 +79,7 @@ UIBootstrap.init = function () {
return true; return true;
} }
console.log('[UIBootstrap] 开始初始化 UI 系统...'); // console.log('[UIBootstrap] 开始初始化 UI 系统...');
try { try {
if (!this._checkDependencies()) { return false; } if (!this._checkDependencies()) { return false; }
@@ -102,7 +102,7 @@ UIBootstrap.init = function () {
this._registerViews(); this._registerViews();
this.initialized = true; this.initialized = true;
console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件'); // console.log('[UIBootstrap] UI 系统初始化完成,共', Object.keys(this.views).length, '个组件');
return true; return true;
} catch (err) { } catch (err) {
console.error('[UIBootstrap] 初始化失败:', err); console.error('[UIBootstrap] 初始化失败:', err);
@@ -1,10 +1,8 @@
// ============================================================================ // ============================================================================
// 00_SubGame_Config.template.js —— 平台配置项模板(结构由平台定义,子游戏填【值】) // 游戏配置 (Game Configuration)
// 这是配置文件(C 类,非转发壳)。复制后按本游戏界面/玩法调整各项值。
// 严格 ES5。
// ============================================================================ // ============================================================================
var Game_Config = Game_Config || {}; var Game_Config = Game_Config||{};//相关配置
@@ -15,13 +13,17 @@ Game_Config.Debugger={//调试配置
visitorLogin : true,//隐藏式游客登录 visitorLogin : true,//隐藏式游客登录
visiblePay:true,//审核通过后是否显示支付按钮 visiblePay:true,//审核通过后是否显示支付按钮
serverType:1,//0->正式服务器 1->本地服务器 http://ylyxservice1.0791ts.cn/config/update_json.txt 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 = { Game_Config.Max = {
SumOfRoomtype:3,//创建时房间类型roomtype数组长度 SumOfRoomtype:3,//创建时房间类型roomtype数组长度
ShowChat:2000,//聊天停留时间 ShowChat:2000,//聊天停留时间
PlayerCnt:4,//房间最大人数(按房间最大人数设置) PlayerCnt:4,//房间最大人数
group:500,//游戏最大群组号 group:500,//游戏最大群组号
showtime:10,//加载等待最少时间 showtime:10,//加载等待最少时间
reconnecttime:30000,//唤醒游戏间隔(超过间隔断线重连) reconnecttime:30000,//唤醒游戏间隔(超过间隔断线重连)
@@ -44,13 +46,13 @@ Game_Config.Info = {
myPosition:130,//自己信息对齐点(根据游戏界面自行修改) myPosition:130,//自己信息对齐点(根据游戏界面自行修改)
position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始 position:[],//其他主界面玩家信息(昵称、积分)居中对齐的x坐标 注意是其他玩家不包括自己从下家开始
//TextContent:["1","2","3","4","5","6","7"],//常用语内容 //TextContent:["1","2","3","4","5","6","7"],//常用语内容
TextContent:["你好","谢谢","再来","快点","不要","加油",":)"],//常用语内容 TextContent:["你好","2","3","4","5","6","7"],//常用语内容
TextContentMp3:["","","","","","",""],//常用语对应音效 TextContentMp3:["","","","","","",""],//常用语对应音效
}; };
Game_Config.Share={//分享参数 Game_Config.Share={//分享参数
appdownload:"",//下载链接(无需配置,从服务器获取) appdownload:"",//下载链接(无需配置,从服务器获取)
title:"游戏名",//(分享标题) title:"Test",//(分享标题)
description:"",//分享描述 description:"hello world",//分享描述
gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里 gameTitle:"",//游戏中的分享标题模板工程会自动将游戏名字分享出去、不必写在这个变量里
gameDescription:""//游戏中分享描述 gameDescription:""//游戏中分享描述
}; };
@@ -58,24 +60,24 @@ Game_Config.Chat={//游戏内聊天配置信息
LimitLength:40,//聊天最大长度(字节长度) LimitLength:40,//聊天最大长度(字节长度)
textwidth:12.5,//聊天显示文字的宽度(未改动聊天显示文字大小无需改动无需修改) textwidth:12.5,//聊天显示文字的宽度(未改动聊天显示文字大小无需改动无需修改)
ChatDis:[30,12],//聊天气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔 ChatDis:[30,12],//聊天气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔
isLeft:[1,0,0,1,1],//聊天气泡是否为以左边为基准线(坐标/基准按各游戏界面调整) isLeft:[1,0,0,1,1],//聊天气泡是否为以左边为基准线
ChatLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//聊天气泡的基准点位置(注意是基准点位置!)(坐标/基准按各游戏界面调整) ChatLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//聊天气泡的基准点位置(注意是基准点位置!)
}; };
Game_Config.Voice={//游戏内聊天配置信息 Game_Config.Voice={//游戏内聊天配置信息
VoiceTime:600,//播放语音动画的时间一般情况无需无需修改 VoiceTime:600,//播放语音动画的时间一般情况无需无需修改
VoiceDis:[30,12],//语音气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔 VoiceDis:[30,12],//语音气泡与内容的间隔0位置左右间隔(最好不要低于30)1位置上下间隔
isLeft:[1,0,0,1,1],//气泡是否为以左边为基准线(坐标/基准按各游戏界面调整) isLeft:[1,0,0,1,1],//气泡是否为以左边为基准线
VoiceLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//气泡的基准点位置(注意是基准点位置!)(坐标/基准按各游戏界面调整) VoiceLoc:[[35,517],[1100,315],[1100,105],[175,105],[175,315]]//气泡的基准点位置(注意是基准点位置!)
}; };
Game_Config.Setting={ Game_Config.Setting={
Ads1:"本游戏仅供娱乐休闲使用,\n道具及虚拟货币不可兑换\n现金或实物,不代表任何\n真实财富价值。", Ads1:"本游戏仅为娱乐休\n闲使用,道具所有\n 游戏通用\n\n游戏问题请联系微\n 信公众号:\n",
//Ads2:"\n 或客服号:\n", //Ads2:"\n 或客服号:\n",
board:"",//从后台获取设置无效 board:"",//从后台获取设置无效
//info:[" 客服信息","QQ:","",""],//客服QQ提醒 //info:[" 客服信息","QQ:","",""],//客服QQ提醒
info:[" 客服信息","QQ:","手机:","微信:"],//客服QQ提醒 info:[" 客服信息","QQ:","手机:","微信:"],//客服QQ提醒
board_blength:24,//通知页面一行最大字节数(无需设置) board_blength:24,//通知页面一行最大字节数(无需设置)
charge:"充值提示",//充值提示 charge:"关注友乐微信公众号充值",//充值提示
}; };
Game_Config.Protocol={ Game_Config.Protocol={
x:270,//协议图片初始x坐标 x:270,//协议图片初始x坐标
@@ -128,3 +130,20 @@ Game_Config.sysConfig = {
hideNotice:false,//隐藏公告栏 hideNotice:false,//隐藏公告栏
vipInfinite:false,//是否为百人场 vipInfinite:false,//是否为百人场
} }
@@ -46,6 +46,52 @@ Game_Modify.CreateRoomData = {
Type_2: 0 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) ---------- // ---------- 接口转发壳(A 类:无 hook 即 no-op) ----------
Game_Modify.utlmousedown = function (gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6) { 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。 // 平台按全局名调用 gameHallImport.* / Game_Modify.*;本文件统一转发到 SubGameHooks 同名 hook。
// 子游戏只需在 codes/ 的 SubGameHooks 里实现需要的接口(见 SubGameHooks.template.js)。 // 子游戏只需在 codes/ 的 SubGameHooks 里实现需要的接口(见 codes/SubGameHooks.js)。
// 严格 ES5。接口清单与默认值依据 spec §9。 // 严格 ES5。接口清单与默认值依据 spec §9。
// ============================================================================ // ============================================================================
@@ -293,8 +293,210 @@ Game_Modify.getMaxPlayerCount = function (roomtype) {
return (Game_Config.Max && Game_Config.Max.PlayerCnt) || 4; 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) { Game_Modify.updatePlayerInfoUI = function (targetSeat) {
if (window.SubGameHooks && SubGameHooks.updatePlayerInfoUI) return SubGameHooks.updatePlayerInfoUI(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.isDestroyed = false;
this.isInitialized = true; this.isInitialized = true;
console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')'); // // console.log('🎨 BaseComponent: 初始化组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 调用子类的init方法 // 调用子类的init方法
try { try {
@@ -274,7 +274,7 @@ var BaseComponent = (function() {
return; 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++) { for (var i = 0; i < this.sprites.length; i++) {
var spriteId = this.sprites[i]; var spriteId = this.sprites[i];
@@ -293,7 +293,7 @@ var BaseComponent = (function() {
*/ */
init: function(config) { init: function(config) {
// 子类覆盖此方法 // 子类覆盖此方法
console.log('📝 BaseComponent.init: 请在子类中覆盖此方法'); // console.log('📝 BaseComponent.init: 请在子类中覆盖此方法');
}, },
/** /**
@@ -312,11 +312,11 @@ var BaseComponent = (function() {
} }
if (this.isVisible) { if (this.isVisible) {
console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name); // console.log('ℹ️ BaseComponent.show: 组件已可见,跳过:', this.name);
return true; return true;
} }
console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')'); // console.log('👁️ BaseComponent: 显示组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 显示图层 // 显示图层
SpriteManager.showGroup(this.layer); SpriteManager.showGroup(this.layer);
@@ -344,11 +344,11 @@ var BaseComponent = (function() {
} }
if (!this.isVisible) { if (!this.isVisible) {
console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name); // console.log('ℹ️ BaseComponent.hide: 组件已隐藏,跳过:', this.name);
return true; return true;
} }
console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')'); // console.log('🙈 BaseComponent: 隐藏组件 "' + this.name + '" (图层: ' + this.layer + ')');
// 隐藏图层 // 隐藏图层
SpriteManager.hideGroup(this.layer); SpriteManager.hideGroup(this.layer);
@@ -375,7 +375,7 @@ var BaseComponent = (function() {
return false; return false;
} }
console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"'); // console.log('💥 BaseComponent: 销毁组件 "' + this.name + '"');
// 移除所有事件监听 // 移除所有事件监听
this._removeAllEventListeners(); this._removeAllEventListeners();
@@ -447,7 +447,7 @@ var BaseComponent = (function() {
// 记录监听器 // 记录监听器
this.eventListeners[eventName] = wrappedHandler; this.eventListeners[eventName] = wrappedHandler;
console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName); // console.log('📡 BaseComponent: 组件 "' + this.name + '" 注册事件:', eventName);
return true; return true;
}, },
@@ -467,7 +467,7 @@ var BaseComponent = (function() {
EventBus.off(eventName, handler); EventBus.off(eventName, handler);
delete this.eventListeners[eventName]; delete this.eventListeners[eventName];
console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName); // console.log('📴 BaseComponent: 组件 "' + this.name + '" 移除事件:', eventName);
return true; return true;
}, },
@@ -483,7 +483,7 @@ var BaseComponent = (function() {
return; return;
} }
console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName); // console.log('📤 BaseComponent: 组件 "' + this.name + '" 触发事件:', eventName);
EventBus.emit(eventName, data); EventBus.emit(eventName, data);
}, },
@@ -511,7 +511,7 @@ var BaseComponent = (function() {
} }
this.sprites.push(spriteId); this.sprites.push(spriteId);
// console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId); // // console.log('➕ BaseComponent: 组件 "' + this.name + '" 添加精灵:', spriteId);
return true; return true;
}, },
@@ -529,7 +529,7 @@ var BaseComponent = (function() {
} }
this.sprites.splice(index, 1); this.sprites.splice(index, 1);
console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId); // console.log('➖ BaseComponent: 组件 "' + this.name + '" 移除精灵:', spriteId);
return true; return true;
}, },
@@ -596,7 +596,7 @@ var BaseComponent = (function() {
*/ */
printState: function() { printState: function() {
var state = this.getState(); var state = this.getState();
console.log('📊 组件状态:', JSON.stringify(state, null, 2)); // console.log('📊 组件状态:', JSON.stringify(state, null, 2));
} }
}; };
@@ -85,6 +85,20 @@
* bounceSpeed: 1 // 回弹速度 * 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 会清除旧行、重新创建新行 * // 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) { function DynamicSpriteList(config) {
// ======================================================================== // ========================================================================
// 参数验证 // 参数验证
@@ -189,8 +223,11 @@ function DynamicSpriteList(config) {
if (!config.clipArea) { if (!config.clipArea) {
throw new Error('DynamicSpriteList: clipArea is required'); throw new Error('DynamicSpriteList: clipArea is required');
} }
if (typeof config.rowHeight !== 'number' || config.rowHeight <= 0) { var orientation = (config.orientation === 'horizontal') ? 'horizontal' : 'vertical';
throw new Error('DynamicSpriteList: rowHeight must be a positive number'); 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 hasTemplates = config.templates && typeof config.templates === 'object';
var hasRenderRow = typeof config.renderRow === 'function'; var hasRenderRow = typeof config.renderRow === 'function';
@@ -220,10 +257,30 @@ 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} * @type {number}
*/ */
this.rowHeight = config.rowHeight; this.rowHeight = step;
/** /**
* 模板精灵配置 * 模板精灵配置
@@ -431,29 +488,29 @@ DynamicSpriteList.prototype.destroy = function() {
/** /**
* 滚动到指定位置 * 滚动到指定位置
* *
* @param {number} y - 目标Y坐标 * @param {number} offset - 目标主轴偏移量(纵向=Y方向,横向=X方向)
* @param {boolean} [animated=true] - 是否使用动画 * @param {boolean} [animated=true] - 是否使用动画
* @returns {DynamicSpriteList} 返回自身,支持链式调用 * @returns {DynamicSpriteList} 返回自身,支持链式调用
*/ */
DynamicSpriteList.prototype.scrollTo = function(y, animated) { DynamicSpriteList.prototype.scrollTo = function(offset, animated) {
var currentY = get_self(this.containerId, 19, 0, 0, 0); var axis = this._axis;
var targetY = this.clipArea.y - y; 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;
// 边界限制 // 边界钳制(双向):先钳末端,再钳起点。
var contentHeight = this._data.length * this.rowHeight; // 内容不足一屏时 min > clipStart,两条钳制叠加后恒为起点——这正是期望行为。
var minY = this.clipArea.y + this.clipArea.height - contentHeight; var contentSize = this._data.length * this._step;
if (targetY < minY) { var min = clipStart + clipSize - contentSize;
targetY = minY; if (target < min) { target = min; }
} if (target > clipStart) { target = clipStart; }
if (targetY > this.clipArea.y) {
targetY = this.clipArea.y;
}
if (animated !== false) { if (animated !== false) {
play_ani(1, this.containerId, 19, currentY, targetY, 0, play_ani(1, this.containerId, axis.posOp, current, target, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0); Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
} else { } else {
set_self(this.containerId, 19, targetY, 0, 0, 0); set_self(this.containerId, axis.posOp, target, 0, 0, 0);
} }
return this; return this;
@@ -476,8 +533,8 @@ DynamicSpriteList.prototype.scrollToTop = function(animated) {
* @returns {DynamicSpriteList} 返回自身,支持链式调用 * @returns {DynamicSpriteList} 返回自身,支持链式调用
*/ */
DynamicSpriteList.prototype.scrollToBottom = function(animated) { DynamicSpriteList.prototype.scrollToBottom = function(animated) {
var contentHeight = this._data.length * this.rowHeight; var contentSize = this._data.length * this._step;
var maxScroll = contentHeight - this.clipArea.height; var maxScroll = contentSize - this.clipArea[this._axis.clipSize];
if (maxScroll < 0) { if (maxScroll < 0) {
maxScroll = 0; maxScroll = 0;
} }
@@ -492,7 +549,7 @@ DynamicSpriteList.prototype.scrollToBottom = function(animated) {
* @returns {DynamicSpriteList} 返回自身,支持链式调用 * @returns {DynamicSpriteList} 返回自身,支持链式调用
*/ */
DynamicSpriteList.prototype.scrollToRow = function(rowIndex, animated) { DynamicSpriteList.prototype.scrollToRow = function(rowIndex, animated) {
var y = rowIndex * this.rowHeight; var y = rowIndex * this._step;
return this.scrollTo(y, animated); return this.scrollTo(y, animated);
}; };
@@ -531,36 +588,35 @@ DynamicSpriteList.prototype.handleMouseDown = function(spid, x, y) {
* 需要在 utlmousemove 回调中调用 * 需要在 utlmousemove 回调中调用
* *
* @param {number} spid - 移动的精灵ID * @param {number} spid - 移动的精灵ID
* @param {number} offsetY - Y方向移动偏移量 * @param {number} offsetMain - 主轴方向移动偏移量(纵向传 Y 方向偏移,横向传 X 方向偏移)
* @returns {boolean} 如果事件被处理返回 true * @returns {boolean} 如果事件被处理返回 true
* *
* @example * @example
* // 在 SpriteEventController 或 Game_Modify 中 * // 在 SpriteEventController 或 Game_Modify 中(纵向列表传 offmovey,横向列表传 offmovex)
* utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) { * utlmousemove: function(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey) {
* if (myList.handleMouseMove(spid, offmovey)) { * if (myList.handleMouseMove(spid, offmovey)) { // 横向列表改传 offmovex
* return; // 事件已处理 * return; // 事件已处理
* } * }
* // 其他处理... * // 其他处理...
* } * }
*/ */
DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetY) { DynamicSpriteList.prototype.handleMouseMove = function(spid, offsetMain) {
if (!this.enableScroll) { if (!this.enableScroll) {
return false; return false;
} }
if (spid === this.containerId) { if (spid === this.containerId) {
if (Math.abs(offsetY) > this.scrollSensitivity) { if (Math.abs(offsetMain) > this.scrollSensitivity) {
// 增量移动容器 var axis = this._axis;
set_self(this.containerId, 19, offsetY, 1, 0, 0); // 增量移动容器(第4参 1 = 相对增量)
set_self(this.containerId, axis.posOp, offsetMain, 1, 0, 0);
this._isSliding = true; this._isSliding = true;
// 触发滚动回调
if (typeof this.onScroll === 'function') { if (typeof this.onScroll === 'function') {
var currentY = get_self(this.containerId, 19, 0, 0, 0); var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
var scrollY = this.clipArea.y - currentY; var scrolled = this.clipArea[axis.clipPos] - current;
var contentHeight = this._data.length * this.rowHeight; var maxScroll = this._data.length * this._step - this.clipArea[axis.clipSize];
var maxScrollY = contentHeight - this.clipArea.height; this.onScroll(scrolled, maxScroll > 0 ? maxScroll : 0);
this.onScroll(scrollY, maxScrollY > 0 ? maxScrollY : 0);
} }
} }
return true; return true;
@@ -661,29 +717,30 @@ DynamicSpriteList.prototype._addSpriteInternal = function (name, templateSpriteI
* @private * @private
*/ */
DynamicSpriteList.prototype._render = function() { DynamicSpriteList.prototype._render = function() {
set_self(this.containerId, 18, this.clipArea.x, 0, 0); var axis = this._axis;
set_self(this.containerId, 19, this.clipArea.y, 0, 0); set_self(this.containerId, axis.posOp, this.clipArea[axis.clipPos], 0, 0);
var contentHeight = this._data.length * this.rowHeight; set_self(this.containerId, axis.crossPosOp, this.clipArea[axis.crossClipPos], 0, 0);
set_self(this.containerId, 21, contentHeight, 0, 0); var contentSize = this._data.length * this._step;
set_self(this.containerId, axis.sizeOp, contentSize, 0, 0);
if (this.templates) { if (this.templates) {
var tnames = Object.keys(this.templates); var tnames = Object.keys(this.templates);
if (tnames.length > 0) { if (tnames.length > 0) {
var w = get_self(this.templates[tnames[0]].spriteId, 20, 0, 0, 0); // 交叉轴尺寸取首个模板的对应尺寸(纵向=宽 op20,横向=高 op21)
set_self(this.containerId, 20, w, 0, 0); var crossFromTpl = get_self(this.templates[tnames[0]].spriteId, axis.crossSizeOp, 0, 0, 0);
set_self(this.containerId, axis.crossSizeOp, crossFromTpl, 0, 0);
} }
} else { } else {
// renderRow 模式:容器宽度取裁剪区宽,保证整行可视宽都在容器 hit 区(点击/拖动检测)内。 // renderRow 模式:交叉轴尺寸取裁剪区对应边,保证整个可视区都在容器 hit 区(点击/拖动检测)内
// 否则容器保持编辑器预置窄宽度,超出部分点击/拖动报的 spid≠containerId 而失效(对账 bak set_self(fSpid,20,bg宽))。 set_self(this.containerId, axis.crossSizeOp, this.clipArea[axis.crossClipSize], 0, 0);
set_self(this.containerId, 20, this.clipArea.width, 0, 0);
} }
for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) { for (var rowIndex = 0; rowIndex < this._data.length; rowIndex++) {
var rowData = this._data[rowIndex]; var rowData = this._data[rowIndex];
var rowY = rowIndex * this.rowHeight; var rowBase = rowIndex * this._step;
if (this.renderRow) { if (this.renderRow) {
this.renderRow(this._makeRowContext(rowIndex, rowData, rowY)); this.renderRow(this._makeRowContext(rowIndex, rowData, rowBase));
} else { } else {
var names = Object.keys(this.templates); var names = Object.keys(this.templates);
for (var i = 0; i < names.length; i++) { for (var i = 0; i < names.length; i++) {
@@ -691,7 +748,9 @@ DynamicSpriteList.prototype._render = function() {
var t = this.templates[name]; var t = this.templates[name];
var ox = (t.offset && t.offset.x) || 0; var ox = (t.offset && t.offset.x) || 0;
var oy = (t.offset && t.offset.y) || 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) { if (t.textProperty && rowData[t.textProperty] !== undefined) {
set_self(sid, 7, rowData[t.textProperty], 0, 0); set_self(sid, 7, rowData[t.textProperty], 0, 0);
} }
@@ -708,19 +767,27 @@ DynamicSpriteList.prototype._render = function() {
* 创建行上下文对象(传给 renderRow 回调) * 创建行上下文对象(传给 renderRow 回调)
* @param {number} rowIndex - 行索引 * @param {number} rowIndex - 行索引
* @param {Object} rowData - 行数据 * @param {Object} rowData - 行数据
* @param {number} rowBaseY - 行基准 Y 坐标 * @param {number} rowBase - 行基准坐标(纵向=Y,横向=X)
* @returns {Object} 行上下文 * @returns {Object} 行上下文
* @private * @private
*/ */
DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBaseY) { DynamicSpriteList.prototype._makeRowContext = function (rowIndex, rowData, rowBase) {
var self = this; var self = this;
var isH = (this.orientation === 'horizontal');
return { return {
rowIndex: rowIndex, rowIndex: rowIndex,
rowData: rowData, rowData: rowData,
rowBaseY: rowBaseY, rowBase: rowBase,
// 保留 rowBaseY:renderRow 回调的既有字段协议,不得破坏。
// 当前生产消费方为 RecordView.js(唯一使用本组件的调用方);
// 回归测试 DynamicSpriteList.test.js 对该字段有硬断言。
rowBaseY: rowBase,
orientation: this.orientation,
containerId: this.containerId, containerId: this.containerId,
addSprite: function (name, templateSpriteId, x, y) { 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 * @private
*/ */
DynamicSpriteList.prototype._handleBounce = function() { DynamicSpriteList.prototype._handleBounce = function() {
var currentY = get_self(this.containerId, 19, 0, 0, 0); var axis = this._axis;
var contentHeight = get_self(this.containerId, 21, 0, 0, 0); var current = get_self(this.containerId, axis.posOp, 0, 0, 0);
var clipY = this.clipArea.y; var contentSize = get_self(this.containerId, axis.sizeOp, 0, 0, 0);
var clipH = this.clipArea.height; var clipStart = this.clipArea[axis.clipPos];
var clipSize = this.clipArea[axis.clipSize];
var targetY = currentY; var target = current;
var needBounce = false; var needBounce = false;
if (contentHeight <= clipH) { if (contentSize <= clipSize) {
// 内容不足一屏,回弹到顶部 // 内容不足一屏,回弹到起点
if (currentY !== clipY) { if (current !== clipStart) {
targetY = clipY; target = clipStart;
needBounce = true; needBounce = true;
} }
} else { } else {
if (currentY > clipY) { if (current > clipStart) {
// 超出顶部 // 越过起点
targetY = clipY; target = clipStart;
needBounce = true; needBounce = true;
} else { } else {
var minY = clipY + clipH - contentHeight; var min = clipStart + clipSize - contentSize;
if (currentY < minY) { if (current < min) {
// 超出底部 // 越过末端
targetY = minY; target = min;
needBounce = true; needBounce = true;
} }
} }
} }
if (needBounce) { if (needBounce) {
play_ani(1, this.containerId, 19, currentY, targetY, 0, play_ani(1, this.containerId, axis.posOp, current, target, 0,
Math.abs(targetY - currentY), 0, 0, 0, this.bounceSpeed, 0, 0, 0); Math.abs(target - current), 0, 0, 0, this.bounceSpeed, 0, 0, 0);
} }
}; };
@@ -772,6 +840,16 @@ DynamicSpriteList.prototype._handleBounce = function() {
* @private * @private
*/ */
DynamicSpriteList.prototype._handleClick = function(x, y) { 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); var clickedTag = ifast_check_add(this.containerId, x, y);
if (clickedTag === -99999999) { return; } if (clickedTag === -99999999) { return; }
var rec = this._tagIndex[clickedTag]; var rec = this._tagIndex[clickedTag];
+18 -3
View File
@@ -63,6 +63,10 @@ var RecordView = {
if (typeof gradeinfo[i].gameinfo1 === 'string') { if (typeof gradeinfo[i].gameinfo1 === 'string') {
gradeinfo[i].gameinfo1 = JSON.parse(gradeinfo[i].gameinfo1); 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 === 0) { this._gradeIdx1 = gradeinfo[i].idx; }
if (i === gradeinfo.length - 1) { this._gradeIdx2 = gradeinfo[i].idx; } if (i === gradeinfo.length - 1) { this._gradeIdx2 = gradeinfo[i].idx; }
} }
@@ -70,6 +74,17 @@ var RecordView = {
this.open(); 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(type,null);翻页 _requestGrade(_,direction)。
_requestGrade: function (arg1, direction) { _requestGrade: function (arg1, direction) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId }; var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
@@ -77,9 +92,9 @@ var RecordView = {
this._type = arg1; // 类型切换 this._type = arg1; // 类型切换
data.type = arg1; data.type = arg1;
} else { } else {
data.type = this._type; // 翻页:type 用当前,direction + gradeidx data.type = this._type; // 翻页:type 用当前,direction + idx
data.direction = direction; 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); } if (typeof Net !== 'undefined' && Net.Send_get_player_grade1) { Net.Send_get_player_grade1(data); }
}, },
@@ -89,7 +104,7 @@ var RecordView = {
_requestReplayData: function (gameIndex) { _requestReplayData: function (gameIndex) {
var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId }; var data = { agentid: GameData.AgentId, playerid: C_Player.playerid, gameid: GameData.GameId };
if (this._gradeData && this._gradeData[gameIndex] && typeof this._gradeData[gameIndex].idx !== 'undefined') { 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); } 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_ICON);
SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT); SpriteManager.hide(LOADING_UI.SPRITES.LOADING_TEXT);
SpriteManager.hideGroup(LOADING_UI.GROUP_ID); 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 */ /** @private */
@@ -153,7 +153,7 @@ var UIManager = (function () {
SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND); SpriteManager.hide(MESSAGE_UI.SPRITES.BACKGROUND);
SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT); SpriteManager.hide(MESSAGE_UI.SPRITES.TEXT);
SpriteManager.hideGroup(MESSAGE_UI.GROUP_ID); 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 */ /** @private */
@@ -164,7 +164,7 @@ var UIManager = (function () {
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM); SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CONFIRM);
SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL); SpriteManager.hide(CONFIRM_UI.SPRITES.BTN_CANCEL);
SpriteManager.hideGroup(CONFIRM_UI.GROUP_ID); 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; return true;
} }
console.log('[UIManager] 初始化 (v1.0.0)'); // console.log('[UIManager] 初始化 (v1.0.0)');
try { try {
_components = {}; _components = {};
@@ -239,7 +239,7 @@ var UIManager = (function () {
_isInitialized = true; _isInitialized = true;
this.initialized = true; this.initialized = true;
console.log('[UIManager] 初始化完成'); // console.log('[UIManager] 初始化完成');
return true; return true;
} catch (err) { } catch (err) {
console.error('[UIManager] 初始化失败:', err); console.error('[UIManager] 初始化失败:', err);
@@ -279,7 +279,7 @@ var UIManager = (function () {
console.warn('[UIManager] registerComponent: 覆盖已存在组件:', name); console.warn('[UIManager] registerComponent: 覆盖已存在组件:', name);
} }
_components[name] = component; _components[name] = component;
console.log('[UIManager] 注册组件 "' + name + '"'); // console.log('[UIManager] 注册组件 "' + name + '"');
return true; return true;
}, },
@@ -305,7 +305,7 @@ var UIManager = (function () {
var c = _components[name]; var c = _components[name];
if (c.isVisible) { c.hide(); } if (c.isVisible) { c.hide(); }
delete _components[name]; delete _components[name];
console.log('[UIManager] 注销组件 "' + name + '"'); // console.log('[UIManager] 注销组件 "' + name + '"');
return true; return true;
}, },
@@ -321,7 +321,7 @@ var UIManager = (function () {
} }
_components[name].destroy(); _components[name].destroy();
delete _components[name]; delete _components[name];
console.log('[UIManager] 销毁组件 "' + name + '"'); // console.log('[UIManager] 销毁组件 "' + name + '"');
return true; return true;
}, },
@@ -371,7 +371,7 @@ var UIManager = (function () {
} }
} }
_scenes[sceneName] = componentNames; _scenes[sceneName] = componentNames;
console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)'); // console.log('[UIManager] 注册场景 "' + sceneName + '" (' + componentNames.length + ' 个组件)');
return true; return true;
}, },
@@ -390,7 +390,7 @@ var UIManager = (function () {
return true; return true;
} }
console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"'); // console.log('[UIManager] 切换场景 "' + (_currentScene || 'null') + '" → "' + sceneName + '"');
_hideCurrentScene(); _hideCurrentScene();
_showScene(sceneName); _showScene(sceneName);
@@ -447,7 +447,7 @@ var UIManager = (function () {
* @param {string} message * @param {string} message
*/ */
showToast: function (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) { showEffectToast: function (tag, title, description) {
var msg = description ? (title + ':' + description) : title; var msg = description ? (title + ':' + description) : title;
console.log('[UIManager] EffectToast [' + tag + ']:', msg); // console.log('[UIManager] EffectToast [' + tag + ']:', msg);
this.showToast(msg); this.showToast(msg);
}, },
@@ -575,14 +575,14 @@ var UIManager = (function () {
* 打印状态(调试用) * 打印状态(调试用)
*/ */
printState: function () { printState: function () {
console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2)); // console.log('[UIManager] 状态:', JSON.stringify(this.getState(), null, 2));
}, },
/** /**
* 销毁 UIManager(销毁所有组件并重置状态) * 销毁 UIManager(销毁所有组件并重置状态)
*/ */
destroy: function () { destroy: function () {
console.log('[UIManager] 开始销毁'); // console.log('[UIManager] 开始销毁');
for (var name in _components) { for (var name in _components) {
if (_components.hasOwnProperty(name)) { this.destroyComponent(name); } if (_components.hasOwnProperty(name)) { this.destroyComponent(name); }
} }
@@ -594,7 +594,7 @@ var UIManager = (function () {
_currentScene = null; _currentScene = null;
_isInitialized = false; _isInitialized = false;
this.initialized = 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_senderror = 0;
var gameabc = function() { var gameabc = function() {
function s(SIt2, L$qig3, Qf4) { function s(SIt2, L$qig3, Qf4) {
@@ -455,7 +496,7 @@ function dfwtime200() {
return 0; return 0;
} }
;var dc = ifast_getdc(); ;var dc = ifast_getdc();
dc.font = 'lighter ' + obj.h + 'px Arial'; dc.font = GAMEABC_TEXT.font(obj.h);
if (!caption) { if (!caption) {
caption = obj.caption; caption = obj.caption;
} }
@@ -2120,10 +2161,9 @@ function GameObjectManager() {
var yyyy; var yyyy;
var uu = gameabc_face.loglist.length - 1; var uu = gameabc_face.loglist.length - 1;
for (yyyy = uu; yyyy >= 0; yyyy--) { for (yyyy = uu; yyyy >= 0; yyyy--) {
this.backBufferContext2D.textBaseline = "top"; GAMEABC_TEXT.prepare(this.backBufferContext2D, 32);
this.backBufferContext2D.fillStyle = "red"; this.backBufferContext2D.fillStyle = "red";
this.backBufferContext2D.font = 32 + 'px' + ' bold san-serif'; this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, GAMEABC_TEXT.baseline(80 + (uu - yyyy) * 32, 32));
this.backBufferContext2D.fillText(gameabc_face.loglist[yyyy], 10, 80 + (uu - yyyy) * 32);
} }
; ;
} }
@@ -2135,7 +2175,10 @@ function GameObjectManager() {
} }
;if (gameabc_face.obj.caption != "") { ;if (gameabc_face.obj.caption != "") {
this.backBufferContext2D.fillStyle = "white"; 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); this.backBufferContext2D.fillText(gameabc_face.obj.caption, parseInt(this.backBuffer.width / 2) - 50, parseInt(this.backBuffer.height / 2) + 100);
} }
;if (gameabc_face.checkmustdraw() == 0) { ;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) { ;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.fillStyle = gameabc_GameTxt.GameTxtList[recid].Color;
gameabc_face.dc.font = sp_h + 'px' + ' bold san-serif';
var caption = gameabc_GameTxt.GameTxtList[recid].Text; var caption = gameabc_GameTxt.GameTxtList[recid].Text;
if (spid <= 0) { 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; return;
} }
;var obj = ifast_getobj(spid); ;var obj = ifast_getobj(spid);
if (typeof (obj) == "object") { 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_save(this, context);
gameabc_charge(this, context); gameabc_charge(this, context);
context.textBaseline = "top"; GAMEABC_TEXT.prepare(context, this.h);
context.fillStyle = gameabc_GameTxt.GameTxtList[this.frame].Color; context.fillStyle = gameabc_GameTxt.GameTxtList[this.frame].Color;
context.font = 'lighter ' + this.h + 'px Arial';
this.caption = gameabc_GameTxt.GameTxtList[this.frame].Text; this.caption = gameabc_GameTxt.GameTxtList[this.frame].Text;
if (this.uidata.GameTxtStyle == 0) { if (this.uidata.GameTxtStyle == 0) {
gameabc_drawtext = this.caption; gameabc_drawtext = this.caption;
@@ -5300,18 +5341,17 @@ function gameabc_imgtxt() {
gameabc_drawtext = gameabc_password.substring(0, this.caption.length + 1); gameabc_drawtext = gameabc_password.substring(0, this.caption.length + 1);
} }
;if (this.w > 0) { ;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 { } 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_restore(this, context);
} }
} }
;gameabc_imgtxt.prototype = new VisualGameObject; ;gameabc_imgtxt.prototype = new VisualGameObject;
function draw2(obj, context, ctx) { function draw2(obj, context, ctx) {
context.textBaseline = "top";
var h = obj.h * iui.ratio; var h = obj.h * iui.ratio;
context.font = 'lighter ' + h + 'px Arial'; GAMEABC_TEXT.prepare(context, h);
var w = context.measureText(obj.caption).width; var w = context.measureText(obj.caption).width;
context.clearRect(0, 0, w, h); context.clearRect(0, 0, w, h);
if (obj.uidata.BackColorA != 0) { if (obj.uidata.BackColorA != 0) {
@@ -5319,7 +5359,7 @@ function draw2(obj, context, ctx) {
context.fillRect(0, 0, w, h); context.fillRect(0, 0, w, h);
} }
;context.fillStyle = obj.uidata.FontColor; ;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); ctx.drawImage(iui.test, 0, 0, w, h, obj.x, obj.y, w / iui.ratio, obj.h);
} }
;function gameabc_txt() { ;function gameabc_txt() {
@@ -5340,13 +5380,12 @@ function draw2(obj, context, ctx) {
if (this.uidata.GameTxtStyle > 0) { if (this.uidata.GameTxtStyle > 0) {
return; return;
} }
;context.textBaseline = "top"; ;GAMEABC_TEXT.prepare(context, this.h);
if (this.uidata.BackColorA != 0) { if (this.uidata.BackColorA != 0) {
context.fillStyle = this.uidata.BackColor; context.fillStyle = this.uidata.BackColor;
context.fillRect(this.x, this.y, this.w, this.h); context.fillRect(this.x, this.y, this.w, this.h);
} }
;context.fillStyle = this.uidata.FontColor; ;context.fillStyle = this.uidata.FontColor;
context.font = 'lighter ' + this.h + 'px Arial';
if (this.caption.indexOf("\n") > 0) { if (this.caption.indexOf("\n") > 0) {
var strs = new Array(); var strs = new Array();
strs = this.caption.split("\n"); 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); context.fillRect(this.x, this.y, this.w, this.h * strs.length);
} }
;for (var i = 0; i < strs.length; i++) { ;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; ;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; ;gameabc_txt.prototype = new VisualGameObject;
+14172
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Project SoftVersion="2.7" SaveTime="2020-02-16 22:52:46"> <Project SoftVersion="2.7" SaveTime="2020-02-16 22:52:46">
<PropertyList> <PropertyList>
<Property Name="Name" Value="Game_Surface_3"/> <Property Name="Name" Value="client"/>
<Property Name="SceneWidth" Value="1699"/> <Property Name="SceneWidth" Value="1699"/>
<Property Name="SceneHeight" Value="1800"/> <Property Name="SceneHeight" Value="1800"/>
<Property Name="ScreenWidth" Value="1280"/> <Property Name="ScreenWidth" Value="1280"/>
+9 -8
View File
@@ -1,19 +1,20 @@
//GameData.AgentId = "i33v0llvp0euhd1n9qo1fM2RV8vtog4y"; //GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
//GameData.ChannelId = "7N0e0z2u2098pf1M2fj0kyB1D4n4ylkA"; //GameData.ChannelId = "7N0e0z2u2098pf1M2fj0kyB1D4n4ylkA";
GameData.GameId = "8x4l0rGjf026f60c48h0mbUAhK5vV16f"; GameData.GameId = "jzWe0kzmN0tAcZ5MLks3rFWcxNecogyu";
//GameData.GameId = "dQyb0dvzz0afdj65hsb08QjMhzt94E1v"; //GameData.GameId = "dQyb0dvzz0afdj65hsb08QjMhzt94E1v";
GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ"; // GameData.AgentId = "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv";
GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS"; // GameData.ChannelId = "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK";
// GameData.AgentId = "00bA05haB0d9ZC0fwGD09Q2OA30insbQ";
//GameData.ChannelId = "t1q802oz10p4rn0E99313xtu2sVi9Zru"; // GameData.ChannelId = "frdt0C1GG0t91P0McFo0rbA1he5yurbS";
GameData.AgentId = "1B2h0ccl205c390Y28m1Ajdplkuu4wgy";
GameData.ChannelId = "aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG";
//GameData.GameId = "Btke0urRy0cvPd5CIvD5yfhYhKhdSdex"; //GameData.GameId = "Btke0urRy0cvPd5CIvD5yfhYhKhdSdex";
GameData.Version = "1.1";//真实版本号1 代理商 天盛网络 游戏 GameData.Version = "1.1";//真实版本号1 代理商 天盛网络 游戏
GameData.versionCode = 10000;//真实版本号 GameData.versionCode = 1;//真实版本号
@@ -33,6 +33,21 @@ RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路
> 平台字段缺失(如 `playerid` 为 0/空)应 fail-fast 暴露,不静默发出残缺包。 > 平台字段缺失(如 `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. 收包:统一分发 ## 2. 收包:统一分发
@@ -156,6 +171,7 @@ AI 托管:服务端 AI 决策 →┘
- **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。 - **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。
- **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。 - **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。
- **不得据本地推断补齐服务端未下发的状态**:包里没有的阶段/控制权/可用操作,不由前端算出来顶上——那是服务端漏发,修在服务端发包处(见 [05 §6.1](./05-开发规范与红线.md)、服务端 [03 §1.2](../../server/development-guide/03-数据收发与通信协议.md))。
### 5.4 适用范围 ### 5.4 适用范围
@@ -235,6 +251,8 @@ AI 托管:服务端 AI 决策 →┘
| DO ✅ | DON'T ❌ | | DO ✅ | DON'T ❌ |
|------|---------| |------|---------|
| 发包走语义化发包封装/`RpcHelper` | 业务里直接 `Utl.sendData` 手拼包 | | 发包走语义化发包封装/`RpcHelper` | 业务里直接 `Utl.sendData` 手拼包 |
| 请求包只带意图(操作类型+目标标识) | 包里回传前端算出的分数/判定/阶段等结论字段 |
| 阶段/控制权/可用操作/倒计时只读包内权威字段 | 前端自建对局状态机、本地推导或用定时器自行推进阶段 |
| 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 | | 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 |
| 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 | | 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 |
| 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 | | 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 |
@@ -70,9 +70,43 @@
--- ---
## 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)。 - **组件数据自持 + 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-操作请求合法性验证不可信客户端);其余提示/控制权/倒计时/落牌仍严格收包驱动。 - **更新时机——发包只请求、收包才表现(悲观 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` 刷静态界面)→ 再播动画 → 动画回调里只刷新界面**;动画的开始/结束/出错等生命周期回调里**绝不设置核心数据**,动画期间**不改数据**。 - **收包节奏**:**先 `setXxx` 写数据 →(必要时 `refresh` 刷静态界面)→ 再播动画 → 动画回调里只刷新界面**;动画的开始/结束/出错等生命周期回调里**绝不设置核心数据**,动画期间**不改数据**。
@@ -91,7 +125,7 @@
## 8. shared 同步 ## 8. shared 同步
- **`shared/` 只放"前后端都真正用到"的那部分逻辑,务必慎重**:进 `shared/` 的门槛不是"它是玩法逻辑",而是"**前端确实会用到、且两端必须算出完全一致的结果**"。因为**前端只做展示、不做核心运算**(服务端权威,见 §5)叠加**悲观 UI**(可操作项/提示/结果由服务端推送、前端不预测,见 §5),**前端实际会重算的逻辑很少**——发牌/胡牌裁定/计分/AI 等计算类逻辑前端根本不算(服务端算好推来展示即可),它们**不进 `shared/`**。只有本地即时预判/高亮所需的校验、或纯展示所需的规则常量/牌型映射等,才把**那一部分**提升进 `shared/`。**默认留服务端**,拿不准就别放。它不是平台代码,平台既不提供也不感知。 - **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。
- `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。 - `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。
- 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。 - 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。
- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §9 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#9-shared-文件同步流程)。 - 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §9 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#9-shared-文件同步流程)。
@@ -125,6 +159,8 @@
| 渲染 | 只走 `SpriteManager`;ID 守范围、不编造;查返回值 | | 渲染 | 只走 `SpriteManager`;ID 守范围、不编造;查返回值 |
| 常量 | 精灵/群组/图层/图片/声音/Spine/坐标/动画/事件全进常量;UI 代码禁硬编码 id 与裸值 | | 常量 | 精灵/群组/图层/图片/声音/Spine/坐标/动画/事件全进常量;UI 代码禁硬编码 id 与裸值 |
| 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 | | 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 |
| 数据驱动 | 前端**无对局状态机**:阶段/控制权/可用操作/倒计时/分数只读包内权威字段,禁本地推导与本地推进流程;`this.data` 只是服务端快照的镜像,丢弃后仅凭最近快照重画须完全一致;只有白名单纯表现态可只存在于前端(§6.1–6.3) |
| 发包内容 | 请求包只带「意图」(操作类型+目标标识),**不带结论**(分数/判定结果/阶段指令);服务端按自己的权威数据重算(04 §1「请求包只带意图」) |
| 数据 | 服务端权威(核心运算/裁定在服务端);组件自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对、组件有总 `refresh` | | 数据 | 服务端权威(核心运算/裁定在服务端);组件自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对、组件有总 `refresh` |
| 更新时机 | 点击只发请求包,对局状态表现等**收包后**更新(悲观 UI);收包处理器触发源无关、数据从包字段读;AI 托管/他人广播共用同一更新路径。**受控例外**:响应/掷骰交互按钮隐藏可乐观清除(防连点+即时反馈),前提是服务端合法性验证+收包兜底(04 §5.6、服务端 04 §8) | | 更新时机 | 点击只发请求包,对局状态表现等**收包后**更新(悲观 UI);收包处理器触发源无关、数据从包字段读;AI 托管/他人广播共用同一更新路径。**受控例外**:响应/掷骰交互按钮隐藏可乐观清除(防连点+即时反馈),前提是服务端合法性验证+收包兜底(04 §5.6、服务端 04 §8) |
| 节奏 | 先写数据后表现;动画回调只刷界面、**不写核心数据**;重画可随时据数据还原界面 | | 节奏 | 先写数据后表现;动画回调只刷界面、**不写核心数据**;重画可随时据数据还原界面 |
+6 -43
View File
@@ -18,60 +18,24 @@
| 篇 | 文档 | 解决什么问题 | | 篇 | 文档 | 解决什么问题 |
|----|------|--------------| |----|------|--------------|
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 | | 00 | 本文 README | 这套文档是什么、怎么读 |
| — | [红线速查.md](./红线速查.md) | **一页纸分层模型 + 红线清单**(由 `CLAUDE.md` 常驻加载,改代码前先对照) |
| 01 | [01-前端架构与运行环境.md](./01-前端架构与运行环境.md) | 双运行时/ES5、平台/框架/子游戏三层、新旧架构并存、目录与加载顺序 | | 01 | [01-前端架构与运行环境.md](./01-前端架构与运行环境.md) | 双运行时/ES5、平台/框架/子游戏三层、新旧架构并存、目录与加载顺序 |
| 02 | [02-渲染与UI组件体系.md](./02-渲染与UI组件体系.md) | 精灵 ID 体系、SpriteManager 分层、资源常量组织、BaseComponent 组件化、组件数据/set-refresh 范式、UIManager 场景、动态列表 | | 02 | [02-渲染与UI组件体系.md](./02-渲染与UI组件体系.md) | 精灵 ID 体系、SpriteManager 分层、资源常量组织、BaseComponent 组件化、组件数据/set-refresh 范式、UIManager 场景、动态列表 |
| 03 | [03-事件·动画·音频·Spine.md](./03-事件·动画·音频·Spine.md) | EventBus、AnimationManager+配置、AudioManager+音效资源、SpineMgr 全链路 | | 03 | [03-事件·动画·音频·Spine.md](./03-事件·动画·音频·Spine.md) | EventBus、AnimationManager+配置、AudioManager+音效资源、SpineMgr 全链路 |
| 04 | [04-网络对接与启动编排.md](./04-网络对接与启动编排.md) | 发包链路(RpcHelper 注入平台字段)、收包统一分发、新旧架构对接边界、启动编排、处理器/管理器职责 | | 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 模板 | | 06 | [06-子游戏接入模式与Hooks外置.md](./06-子游戏接入模式与Hooks外置.md) | 内联模式 vs Hooks 外置模式、三契约文件可改性、退化为纯转发壳 + SubGameHooks 委托、subgame-entry 模板 |
建议第一次**从 01 顺序读到 05**;之后把 02–05 当手册随用随查。06 在接入新游戏或迁移到 Hooks 外置模式时选读。 建议第一次**从 01 顺序读到 05**;之后把 02–05 当手册随用随查。06 在接入新游戏或迁移到 Hooks 外置模式时选读。
--- ---
## 一页纸:前端分层模型 ## 一页纸与红线
``` **分层模型与红线清单已独立成篇:[红线速查.md](./红线速查.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 通用能力,可被任何子游戏复用。 该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是红线的权威源;本 README 只负责导航,不重复抄写红线(避免两处不同步)。红线的完整细节见 [05-开发规范与红线.md](./05-开发规范与红线.md)。
- **子游戏(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 资源 / 坐标尺寸 / 动画时长 / 事件名 一律定义在对应常量文件、只引用常量。
- **服务端权威、前端只展示**:核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为渲染副本,不做权威计算。
- **组件走 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/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
--- ---
@@ -80,4 +44,3 @@ BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework(
- 服务端的对应文档见 [服务端开发指导文档](../../server/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。 - 服务端的对应文档见 [服务端开发指导文档](../../server/development-guide/);前端「成败标志 `data.success`」「收发包链路」与之同源,互为对照。
- 子游戏前端各层可能另有局部说明文档;本套是总纲,与之不冲突时以本套的通用原则为准。 - 子游戏前端各层可能另有局部说明文档;本套是总纲,与之不冲突时以本套的通用原则为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲前端接入与红线,工程通则讲前后端通用的设计方法论,互补阅读。 - **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../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/` 的同步副本,**不在前端改**,改服务端权威源后跑同步脚本。
+5 -18
View File
@@ -25,35 +25,22 @@
| 篇 | 文档 | 解决什么 | | 篇 | 文档 | 解决什么 |
|----|------|----------| |----|------|----------|
| 00 | 本文 README | 定位、适用范围、一页纸总则、与既有文档的关系 | | 00 | 本文 README | 定位、怎么读、与既有文档的关系 |
| — | [一页纸总则.md](./一页纸总则.md) | **七大总则 + 适用边界**(由 `CLAUDE.md` 常驻加载,设计取舍时对照) |
| 01 | [01-架构总则与分层.md](./01-架构总则与分层.md) | 七大架构总则;前后端参考分层;依赖方向与稳定依赖 | | 01 | [01-架构总则与分层.md](./01-架构总则与分层.md) | 七大架构总则;前后端参考分层;依赖方向与稳定依赖 |
| 02 | [02-可扩展性与配置化.md](./02-可扩展性与配置化.md) | 扩展模式(注册表/策略/管线/工厂/事件)何时用;配置化与去硬编码;避免过度设计 | | 02 | [02-可扩展性与配置化.md](./02-可扩展性与配置化.md) | 扩展模式(注册表/策略/管线/工厂/事件)何时用;配置化与去硬编码;避免过度设计 |
| 03 | [03-数据权威·错误处理·演进.md](./03-数据权威·错误处理·演进.md) | 数据权威(前后端);错误处理与可观测;演进与重构;反模式与审查清单 | | 03 | [03-数据权威·错误处理·演进.md](./03-数据权威·错误处理·演进.md) | 数据权威(前后端);错误处理与可观测;演进与重构;反模式与审查清单 |
--- ---
## 一页纸:七大总则 ## 一页纸总则
1. **单一权威数据源(SSOT)**:同一业务数据只有一个计算/写入处,其他只读;缺失即显式失败,不兜底掩盖。 **七大总则与适用边界已独立成篇:[一页纸总则.md](./一页纸总则.md)。**
2. **单向依赖**:分层自上而下依赖,稳定的被依赖、易变的作依赖方;**禁止环形依赖**。
3. **职责单一、边界清晰**:一个职能只在一个模块实现,别处**调用而非重造**。
4. **关注点分离**:决策与机制分离、数据与表现分离、编排与算法分离。
5. **对扩展开放、对修改封闭(OCP)**:用注册/策略/管线**加**能力,不改动已稳定的核心。
6. **配置优先于硬编码**:会变、复用、无语义的值一律外提为常量/配置,用数据驱动行为。
7. **显式失败优于隐式兜底**:关键路径缺数据就报错/返回 `null`,把问题暴露在最近处。
> 这七条互相支撑:**SSOT + 显式失败**保正确,**单向依赖 + 职责单一 + 关注点分离**保清晰, 该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是总则的权威源;本 README 只负责导航,不重复抄写总则(避免两处不同步)。总则的展开见 [01-架构总则与分层.md](./01-架构总则与分层.md)。
> **OCP + 配置化**保可演进。任何设计取舍,回到这七条对照。
--- ---
## 适用范围与边界
- **适用**:子游戏自身的前后端业务代码(玩法逻辑、对局编排、收发包处理、表现层、共享算法)。
- **不覆盖**:平台框架代码(不可改)、平台接入契约(见各端 `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/` 为准;本套是**方法论层**,与之互补不冲突。
@@ -55,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 里) ## 2. 收包处理的固定步骤(在 handler 里)
@@ -124,7 +146,7 @@ for (var seat = 0; seat < o_room.seatlist.length; seat++) {
} }
``` ```
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。 > 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。裁剪清单与判别标准见 [§1.3 按可见性下发](#13-发全--发多按可见性下发防作弊)——**下发即泄露,前端"拿到但不渲染"不算防护**。
--- ---
@@ -246,6 +268,8 @@ if (!data.success) { /* 失败处理 */ return; }
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。 - 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
- **下发包必须发全前端界面所需的核心数据**(§1.2):前端以服务端为权威、不自算,漏发修在服务端发包处、前端不补洞。 - **下发包必须发全前端界面所需的核心数据**(§1.2):前端以服务端为权威、不自算,漏发修在服务端发包处、前端不补洞。
- **不该看到的一律不发**(§1.3):下发即泄露,按座位裁剪可见面;"该看到的一个不少、不该看到的一个不多"。
- **阶段/控制权/可用操作/倒计时锚点由服务端唯一维护并显式下发**(§1.4):前端不持有状态机、不反推阶段;状态变更必须有包承载。
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。 - 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- **主动推送是唯一可靠下发通道**,`return` 不算。 - **主动推送是唯一可靠下发通道**,`return` 不算。
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。 - **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
@@ -59,6 +59,7 @@ function foo() { GameStateManager.doSomething(); }
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。 - **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。 - **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
- **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 set-refresh 要用的字段都发全);漏发 = 前端缺数据,**修在服务端发包处、前端不补洞**(详见 [03 §1.2](./03-数据收发与通信协议.md))。 - **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 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`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。 > 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
@@ -117,6 +118,15 @@ function foo() { GameStateManager.doSomething(); }
4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。 4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。
5. **参数 / 数据合法性**:牌 `uniqueId` 是否真在该玩家手里、`targetCard`/`fromSeat`/`choiceIndex` 是否与权威牌局一致 —— 全按**服务端权威数据**校验,不信客户端传的牌面。 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) ### 设计形态:统一裁决网关,默认拒绝(fail-closed)
把五层收敛成**一道所有操作共用的准入网关**(而非每个 handler 各写一遍散点 if)。网关默认 **deny**,仅当请求显式**命中该座位 `availableActions`** 且通过座位/阶段/参数校验才 **allow**。好处: 把五层收敛成**一道所有操作共用的准入网关**(而非每个 handler 各写一遍散点 if)。网关默认 **deny**,仅当请求显式**命中该座位 `availableActions`** 且通过座位/阶段/参数校验才 **allow**。好处:
@@ -143,19 +153,10 @@ function foo() { GameStateManager.doSomething(); }
### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关 ### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关
- **`shared/` 属于子游戏,不是平台代码**:它存放**本玩法自身**、且**前后端都真正会用到**的那部分纯逻辑/常量/数据结构。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。 - **`shared/` 属于子游戏,不是平台代码**:它存放**本玩法自身**的规则、算法、常量、数据结构(本项目如胡牌检测 `WinDetectionFactory`、精牌 `JingAlgorithm`、计分 `ScoreCalculation`、牌型/比精,以及 `constants/` 下各类常量)。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。
- **为什么要"共享"**:**同一段逻辑需要在两处运行且必须算出完全一致的结果**——服务端(Node)做**权威裁定**,前端(浏览器)确有场景**在本地即时预判/预校验**同一段逻辑。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"两端都用、且必须逐字一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。 - **为什么要"共享"**:**同一份玩法逻辑需要在两处运行**——服务端(Node)做**权威裁定**,前端(浏览器)做**即时表现/预校验**。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"前后端必须完全一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。
- **它与"数据权威"的关系**:`shared/` 是**逻辑同源**(两端同一套算法),不改变**数据权威**(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 `shared/` 算的为准。 - **它与"数据权威"的关系**:`shared/` 是**逻辑同源**(两端同一套算法),不改变**数据权威**(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 `shared/` 算的为准。
### 准入门槛:慎重——默认留服务端,别把 shared 当玩法逻辑的垃圾桶
**进 `shared/` 的唯一理由是"前后端都真正会用到这段逻辑",不是"它是玩法逻辑"。** 判定务必慎重:
- **前端只做展示、不做核心运算**(服务器权威,裁定与结果均由服务端下发,见 §4),叠加**悲观 UI**(可操作项/提示/落子结果由服务端推送、前端不预测,见前端 04 §5)——因此**前端实际会重算的玩法逻辑很少**。
- 绝大多数**计算类逻辑**(发牌、洗牌、胡牌/听牌裁定、计分、AI 决策、随机数等)前端**根本不重算**:结果由服务端算好推给前端展示即可。它们应**只留在服务端、不进 `shared/`**。
- 只有当某段逻辑**确有明确的前端使用场景**——例如需要本地即时预判/高亮可操作项,或纯展示所需的**规则常量、牌型/花色映射、文案枚举**——才把**那一部分**提升进 `shared/`;提升的是"前端确需的最小子集",不是把整套算法一并搬过去。
- **默认放服务端**:拿不准某文件前端到底用不用,就先留在服务端子游戏目录下,等出现真实的前端使用点再提升进 `shared/`。宁可后补,也不要预防性地把一堆前端用不到的文件塞进 `shared/`(徒增只读副本体积、误导读者以为前端在重算)。
### 同步流程:服务端权威源 → 前端只读副本 ### 同步流程:服务端权威源 → 前端只读副本
前后端各持一份 `shared/`,**必须**经同步流程单向更新,禁止直接编辑前端副本: 前后端各持一份 `shared/`,**必须**经同步流程单向更新,禁止直接编辑前端副本:
@@ -169,20 +170,13 @@ function foo() { GameStateManager.doSomething(); }
2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。 2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。
3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。 3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。
> 判别一段逻辑该不该进 `shared/`,连问两关:**①前端到底用不用它?**(前端只做展示、悲观 UI 下多数计算不重算——不用 → 只留服务端)**②两端是否必须算出完全一致的结果?**(是 → 才有共享价值)。两关都过(前端确有本地预判/展示用途,且必须与服务端逐字一致,如某些规则常量、牌型映射、本地可高亮的预校验)→ 放 `shared/`;**只要有一关不过**——服务端流程编排、只有服务端裁定的计算(发牌/胡牌/计分/AI)、只是前端表现——都**各自放自己那侧,不进 `shared/`**。 > 判别一段逻辑该不该进 `shared/`:**它是不是"前后端必须算出完全相同结果"的玩法逻辑**?是(胡牌/听牌/比精/牌型/计分/规则常量)→ 放 `shared/`;只是服务端流程编排或只是前端表现 → 各自放自己那侧,不进 `shared/`。
--- ---
## 10. 硬编码常量准则 ## 10. 硬编码常量准则
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。 **需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
**魔法字符串尤其要慎重、默认常量化**:出现在**判等/分支/作为 key/跨模块传递**的字符串字面量(状态名、类型/枚举值、rpc 名、字段名、事件名等)几乎都属"魔法字符串"——**同一个值被硬编码在多处**时,拼写漂移、改名遗漏、无法被搜索/校验,是隐性 bug 的高发点。这类一律**提取为单一来源的常量**、各处只引用;同一语义禁止在多处重复写裸串。
**常量定义放哪里,同样要慎重(沿用 §9 的 shared 准入门槛)**:常量并非一律进 `shared/constants/`。判据仍是"**前后端是否都真正用到**"——
- **仅服务端用到**的常量(服务端内部状态机名、只在服务端分支的类型标识、服务端流程 key 等)→ 放在**服务端子游戏目录内**的常量文件,**不进 `shared/`**。
- **前后端都会用到、且必须取值完全一致**的常量(前端展示/预判也要引用的规则常量、牌型/花色映射、双方约定的枚举值等)→ 才归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
- 默认放服务端一侧,只有出现真实的前端引用点,才把**那一部分**常量提升进 `shared/`。
**不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。 **不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。
@@ -220,6 +214,9 @@ function foo() { GameStateManager.doSomething(); }
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 | | 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 | | 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 | | 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 |
| 前端不是数据源 | 请求包只接受「意图」(操作类型+目标标识),协议不定义 `score`/`isWin`/`phase` 等结论入参,收到也忽略并按权威数据重算;包内 `seat` 只作一致性校验 |
| 下发可见性 | 不该看到的不发(他人手牌/牌堆序列/未公开判定/未来结果),按座位差异化裁剪;"下发即泄露",前端不渲染不算防护(03 §1.3) |
| 状态机归属 | 阶段/控制权/`availableActions`/倒计时锚点由服务端唯一维护并显式下发,前端不推导;状态变更必须有包承载(03 §1.4) |
| 职责 | 一职能一模块,调用不重造 | | 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 | | 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 | | 自动操作 | 复用真人链路,对前端透明 |
+7 -61
View File
@@ -18,74 +18,22 @@
| 篇 | 文档 | 解决什么问题 | | 篇 | 文档 | 解决什么问题 |
|----|------|--------------| |----|------|--------------|
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 | | 00 | 本文 README | 这套文档是什么、怎么读 |
| — | [红线速查.md](./红线速查.md) | **一页纸运作模型 + 命名约定 + 红线清单**(由 `CLAUDE.md` 常驻加载,改代码前先对照) |
| 01 | [01-服务端环境与框架基础.md](./01-服务端环境与框架基础.md) | 平台与子游戏的关系、核心对象模型、三层路由、房间生命周期 | | 01 | [01-服务端环境与框架基础.md](./01-服务端环境与框架基础.md) | 平台与子游戏的关系、核心对象模型、三层路由、房间生命周期 |
| 02 | [02-子游戏接入与开发流程.md](./02-子游戏接入与开发流程.md) | 三文件架构、export/import 接口、makewar/重连、一次操作的完整数据流 | | 02 | [02-子游戏接入与开发流程.md](./02-子游戏接入与开发流程.md) | 三文件架构、export/import 接口、makewar/重连、一次操作的完整数据流 |
| 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、主动推送、成败标志协议、前后端对接点 | | 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、**下发面发全 / 按可见性裁剪 / 状态机唯一在服务端**、主动推送、成败标志协议、前后端对接点 |
| 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威、模块职责、房间隔离、测试纪律 | | 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威(含**前端不是数据源**)、模块职责、房间隔离、请求合法性验证、测试纪律 |
建议第一次**从 01 顺序读到 04**;之后把 03/04 当手册随用随查。 建议第一次**从 01 顺序读到 04**;之后把 03/04 当手册随用随查。
--- ---
## 一页纸:核心运作模型 ## 一页纸与红线
``` **运作模型、命名约定与红线清单已独立成篇:[红线速查.md](./红线速查.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) → 下发客户端
```
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。 该文件由 `CLAUDE.md` 通过 `@import` 常驻每次会话上下文,是红线的权威源;本 README 只负责导航,不重复抄写红线(避免两处不同步)。红线的完整细节见 [04-开发规范与红线.md](./04-开发规范与红线.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/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
--- ---
@@ -94,5 +42,3 @@ o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下
- 本套文档是平台级收发包/子游戏开发规范的**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。 - 本套文档是平台级收发包/子游戏开发规范的**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `<游戏容器目录>/<游戏>/docs/` 为准。 - 各子游戏内部的架构细节(模块划分、算法)仍以各自 `<游戏容器目录>/<游戏>/docs/` 为准。
- **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../games/engineering/):本套讲"平台怎么接、红线是什么",工程通则讲"该怎么设计、怎么长久演进",互补阅读。 - **平台无关的通用工程与架构规范**(分层、可扩展模式、配置化、数据权威、反模式与审查清单)见 [工程与架构通则](../../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/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
-53
View File
@@ -1,53 +0,0 @@
#!/bin/sh
# 一键激活 + 自检机械红线校验体系。模板全新克隆、或老项目回迁都适用。
#
# 用法(在仓库内任意目录):
# sh setup-redlines.sh 严格模式(整文件扫描;干净仓库/模板克隆用)
# sh setup-redlines.sh --diff 增量模式(只查新增行;已有开发量的老项目回迁用,
# 老代码存量违规不阻塞,新代码从此强制合规)
#
# 它做四件事:迁移清单自检 → 依赖/权限检查 → 激活 hooksPath → 存量违规扫描(只报不挡)。
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$ROOT" ]; then echo "✗ 当前不在 git 仓库内。"; exit 1; fi
cd "$ROOT" || exit 1
echo "== 1) 迁移清单自检(缺一不可,尤其 docs/ 是被遵循的内容与注入源)=="
missing=0
for f in CLAUDE.md .claude/settings.json .claude/hooks/check-redlines.js .claude/hooks/remind-docs.js .githooks/pre-commit \
docs/client/development-guide docs/server/development-guide docs/games/engineering; do
if [ -e "$f" ]; then echo " ✓ $f"; else echo " ✗ 缺: $f"; missing=1; fi
done
if [ "$missing" = 1 ]; then
echo " ⚠ 有缺失——请从模板补齐后再跑本脚本(缺 docs/ 会让 @import 悬空、注入为空)。"
fi
echo "== 2) 依赖检查 =="
if command -v node >/dev/null 2>&1; then echo " ✓ node: $(node -v)"; else
echo " ✗ 未找到 node —— PreToolUse 与 pre-commit 都会失败,请先安装 Node。"; fi
echo "== 3) 权限 =="
if chmod +x .githooks/pre-commit 2>/dev/null; then echo " ✓ .githooks/pre-commit 可执行"; else
echo " ! 无法 chmod(多为 Windows 文件系统,一般不影响 git 调用)"; fi
echo "== 4) 激活提交闸 =="
git config core.hooksPath .githooks
if [ "$1" = "--diff" ]; then
git config redlines.stagedmode diff
echo " ✓ core.hooksPath=.githooks;提交闸=增量模式(只查新增行)"
else
git config --unset redlines.stagedmode 2>/dev/null
echo " ✓ core.hooksPath=.githooks;提交闸=严格模式(整文件)"
fi
echo " git 解析到的 hooks 目录:$(git rev-parse --git-path hooks)"
echo "== 5) 存量违规扫描(只报告,不阻塞)=="
if command -v node >/dev/null 2>&1 && [ -f .claude/hooks/check-redlines.js ]; then
node .claude/hooks/check-redlines.js --scan-all
else
echo " (缺 node 或 check-redlines.js,跳过)"
fi
echo ""
echo "完成。日常提交起即受机械红线闸保护(严格 ES5 + 可编辑范围)。"
echo "注意:本层只管可机器判定的硬红线;判断类规范(职责单一/数据权威/房间隔离等)仍需人工与复审。"