- I1:Layers.js 群组 206 改名遗漏一处注释(亮牌信息条 → 主牌统计条),全仓复查确认无第二处 - I2:出牌操作条补「提示」项后 anchor:center 导致已有两项挤偏,改用 anchor:left 并反推 anchorX=446,令「出牌」按钮回到参考图实测 x≈556(求解器验证:446/556/748) - I3:HIST_FAN/MING_FAN 的 maxWidth 反推算错(旧注释「488」有误,实际总宽恒等于 maxWidth=500),导致 RIGHT 座位整排超出画布 5px;收窄为 490 使三座位求解结果落在 0-1280 内,订正注释与清单 §6.9c 对应文字 - M3:同步修正计划文档里遗留的旧名「亮牌信息条/亮牌条」 - M6:test_constants.js 补 items[].key 的三命名空间解析守卫(此前完全无守卫) - M5/M7:订正 ACC_ROW_TEXT_GRID 与扣底按钮宽度的估值依据说明 - M1/M2:清单 §6.9 子节重新排序为 6.9a→6.9b→6.9c 并统一标题层级,修正脚注星号误渲染 client/tests/run.js 与 server/games/erqiwang/test/run.js 均全绿,精灵总数(380)/ 布局节点数(83) 两个钉死断言未变。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 KiB
二七王前端 · 子项目 A:地基与逻辑层 · 实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 搭出二七王前端的地基——常量层全量、纯逻辑核心、布局求解器、前后端共享算法同源机制、单测框架与平台接入骨架,使后续 B–F 的界面开发不再受任何前置条件阻塞。
Architecture: 三层单向依赖 config(纯数据,无依赖)← core(纯逻辑,可 Node 单测)← ui(布局求解)。前后端共享的牌值算法抽到服务端 shared/,经根目录脚本单向同步为前端只读副本,字节一致由测试守卫。资源与精灵的 ID 全部先在常量文件里定义,常量文件即后期在编辑器里建资源的规格书。
Tech Stack: 纯静态 ES5 JavaScript(浏览器 <script> 加载 + 服务端 min_loadJsFile);无构建、无包管理器;测试用 Node 原生 vm + 自制断言,无测试框架。
Spec: docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md
Global Constraints
- 严格 ES5:正式代码只用
var/function/Object.create,禁let/const/箭头/模板串/class。.githooks/pre-commit会机械拦截。测试代码例外(client/tests/、server/games/erqiwang/test/只跑 Node,可用现代语法)。 - 可编辑范围:
js/vendor/、js/00_Surface/、server/平台代码禁改;01_SubGame/00_/01_/02_SubGame_*.js三个转发壳本阶段一律不碰;服务端只动server/games/erqiwang/。 - 业务代码零裸值:精灵 ID / 群组 ID / 图层 ID / 资源 ID / 坐标尺寸 / 动画时长一律来自
config/,core/、ui/里不得出现这类字面量。 - shared 单向:
server/games/erqiwang/shared/是唯一可修改处;client/js/01_SubGame/codes/shared/只读,由仓库根目录sync_shared.cmd生成。 - 显式失败优于隐式兜底:关键路径缺数据就抛错,禁止
|| 0/|| []之类掩盖。 - 测试纪律:正面/反面/边界都要覆盖;正式代码不得为测试而加逻辑;失败先用证据裁定「业务缺陷 vs 脚本缺陷」,禁止 skip / 软化断言 / 吞异常。
- 提交:中文提交信息、聚焦一件事、结尾保留
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>;不用git add -A;不跳过 hooks。 - 权威源:玩法
server/games/erqiwang/docs/design/design.md;协议.../protocol/packet_protocol.md;界面规格docs_dev/二七王-UI资源与精灵清单.md(下称清单)。数值与 ID 一律照抄,不臆造。
文件结构
| 文件 | 职责 |
|---|---|
server/games/erqiwang/shared/cards.js |
前后端共享:牌值编码与牌型分组的 8 个纯函数(权威源) |
server/games/erqiwang/class.arith.js |
改:这 8 项改为挂 shared 引用,对外 API 不变 |
server/games/erqiwang/mod.js |
改:加载序列里 shared/cards.js 排在 class.arith.js 前 |
sync_shared.cmd / sync_shared.ps1 |
仓库根目录:server/…/shared/ → client/…/codes/shared/ 单向同步 |
client/js/01_SubGame/codes/shared/cards.js |
只读副本(脚本生成,不手改) |
client/tests/_assert.js _load.js run.js |
测试基础设施 |
client/tests/test_*.js |
8 个用例文件 |
client/js/01_SubGame/codes/core/CardCodec.js |
cardIdToFrame(清单 §0.4 唯一权威转换) |
.../core/SeatMap.js |
服务端座位 ↔ 显示位(清单 §0.1 唯一实现) |
.../core/CardOrder.js |
主牌序排序,委托 shared |
.../core/CardMark.js |
手牌三种标记推导 + 花色张数对数统计 |
.../core/SpriteIndex.js |
嵌套精灵常量 → 扁平键名索引 |
.../ui/LayoutSolver.js |
五型布局求解器(纯函数 solve + 三行 apply) |
.../config/Layers.js Groups.js |
图层 / 群组 ID |
.../config/ImageResources.js SoundResources.js |
图片 / 声音资源 ID |
.../config/Sprites_*.js ×5 |
精灵结构(清单 §5.1–§5.8) |
.../config/LayoutConstants.js Layout_*.js ×5 |
布局配置(清单 §6) |
.../config/AnimConstants.js |
动画参数 |
.../SubGameHooks.js |
平台接入骨架(本阶段留空函数) |
client/index.html |
改:加入 codes 加载段 |
CLAUDE.md |
改:「常用命令」收录 sync_shared.cmd |
Task 1: 服务端抽出 shared/cards.js
Files:
- Create:
server/games/erqiwang/shared/cards.js - Modify:
server/games/erqiwang/class.arith.js(删 62–289 行,头部加 require 守卫,对象内加 8 行引用) - Modify:
server/games/erqiwang/mod.js:16附近(加载顺序)
Interfaces:
- Produces: 全局
youle_erqiwang_shared_cards,含id_to_flower(cardid)id_to_number(cardid)id_to_code(mainflower, cardid)order_cards(mainflower, aryCardIDs)is_continuous(bigcode, smallcode)get_pairlist(mainflower, flowercards)get_tuolaji_list(mainflower, pairlist, cardtype)trump_rank(mainflower, cardid) cls_youle_erqiwang_arith的这 8 个方法名与签名保持不变(全部既有调用点零改动)
这是纯搬家:不改任何算法行为。现有 17 个测试脚本(624 checks)原样全绿即验收依据。
- Step 1: 先跑一遍现有服务端测试,记录基线
Run: node server/games/erqiwang/test/run.js
Expected: 末行 ===== 全部单测通过 =====,退出码 0。若此时就有失败,停下来先查清原因,不要开始重构。
- Step 2: 创建
server/games/erqiwang/shared/cards.js
把 class.arith.js 第 62–289 行(从 //牌id转花色 注释到 trump_rank 的 },)原样剪切过来,放进下面的骨架;再把函数体内所有 cls_youle_erqiwang_arith. 前缀全部替换为 youle_erqiwang_shared_cards.(共出现在 id_to_code、order_cards、get_pairlist、get_tuolaji_list、trump_rank 内)。算法一个字符都不要改。
///////////////////////////////////////////////////////////////
////// youle_erqiwang_shared_cards: 前后端共享牌值算法 //////////
///////////////////////////////////////////////////////////////
// 【权威源】本文件是前后端共享算法的唯一可修改处。
// 前端副本 client/js/01_SubGame/codes/shared/cards.js 由仓库根目录的
// sync_shared.cmd 单向同步生成,为只读;任何改动都必须落在本文件,
// 改完跑一次 sync_shared.cmd,否则 client/tests/test_shared_sync.js 会红。
//
// 内容:牌值编码与牌型分组的纯函数,不依赖对局状态、不依赖平台 API。
// 牌编码区间约定(id_to_code 的产物,各处判定据此):
// 9554 大王 / 9553 小王 / 8000–9000 正7 / 7000–8000 副7
// 3000–4000 正2 / 2000–3000 副2 / 1000–2000 主花色普通牌 / 100–500 副牌普通牌
// ⇒ 主牌 ⟺ code > 1000
var youle_erqiwang_shared_cards = youle_erqiwang_shared_cards || {
// ↓↓↓ 此处放入从 class.arith.js 剪切来的 8 个函数 ↓↓↓
}
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
if (typeof module !== "undefined"){
module.exports = youle_erqiwang_shared_cards;
}
- Step 3: 改
class.arith.js——头部加 require 守卫
在文件第 3 行(//////// 注释块之后)插入,格式照 class.paiju.js:4-9 的现有范式:
//跨模块依赖:Node 走 require 守卫;友乐/浏览器无 require,由 min_loadJsFile 加载为同名全局(dev-guide 01 §1)
if (typeof require !== "undefined"){
var youle_erqiwang_shared_cards = require("./shared/cards.js");
}
- Step 4: 改
class.arith.js——被剪掉的位置换成引用
在原 62–289 行的位置(即 get_qvalue 之后、decompose_trump 之前)写入:
//以下 8 项为前后端共享算法,权威实现在 shared/cards.js(经 sync_shared.cmd 同步到前端)。
//此处只挂引用:对外方法名与签名不变,全部既有调用点无需改动。
id_to_flower: youle_erqiwang_shared_cards.id_to_flower,
id_to_number: youle_erqiwang_shared_cards.id_to_number,
id_to_code: youle_erqiwang_shared_cards.id_to_code,
order_cards: youle_erqiwang_shared_cards.order_cards,
is_continuous: youle_erqiwang_shared_cards.is_continuous,
get_pairlist: youle_erqiwang_shared_cards.get_pairlist,
get_tuolaji_list: youle_erqiwang_shared_cards.get_tuolaji_list,
trump_rank: youle_erqiwang_shared_cards.trump_rank,
- Step 5: 改
mod.js的加载顺序
min_loadJsFile("games/erqiwang/class.arith.js", ...) 那一行之前加载 shared:
min_loadJsFile("games/erqiwang/shared/cards.js", function(){
嵌套层级与相邻几行保持一致(该文件是逐层嵌套回调的写法,照抄相邻行的缩进与闭合方式)。
- Step 6: 跑服务端全套测试
Run: node server/games/erqiwang/test/run.js
Expected: ===== 全部单测通过 =====,与 Step 1 的基线一致。任何一条变红都说明搬运出错(最可能是漏改 cls_youle_erqiwang_arith. 前缀,或 mod.js 加载顺序不对),修到全绿为止,不得跳过。
- Step 7: 提交
git add server/games/erqiwang/shared/cards.js server/games/erqiwang/class.arith.js server/games/erqiwang/mod.js
git commit -F - <<'EOF'
二七王:抽出前后端共享算法 shared/cards.js
把牌值编码与牌型分组的 8 个纯函数(id_to_flower/id_to_number/id_to_code/
order_cards/is_continuous/get_pairlist/get_tuolaji_list/trump_rank)移入
shared/,arith 改为挂引用——对外方法名与签名不变,既有调用点零改动。
前端排序与手牌标记将同步这一份,避免两处各写一份导致主牌序漂移。
现有 17 个测试脚本(624 checks)原样全绿,即本次纯搬运的验收依据。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 2: 根目录同步脚本与前端只读副本
Files:
- Create:
sync_shared.cmd、sync_shared.ps1(仓库根目录) - Create:
client/js/01_SubGame/codes/shared/cards.js(脚本生成,不手写) - Modify:
CLAUDE.md「常用命令」一节
Interfaces:
-
Produces: 前端全局
youle_erqiwang_shared_cards(与服务端同一份文件,浏览器里module未定义故导出段自动跳过) -
Step 1: 写
sync_shared.ps1
# server/games/erqiwang/shared/ -> client/js/01_SubGame/codes/shared/ 单向同步
# 【只有这一个方向】权威源在服务端,前端副本只读;前端的本地改动会被本脚本覆盖。
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$src = Join-Path $root 'server\games\erqiwang\shared'
$dst = Join-Path $root 'client\js\01_SubGame\codes\shared'
if (-not (Test-Path $src)) { Write-Error "源目录不存在: $src"; exit 1 }
if (-not (Test-Path $dst)) { New-Item -ItemType Directory -Path $dst -Force | Out-Null }
$files = Get-ChildItem -Path $src -Filter *.js -File
if ($files.Count -eq 0) { Write-Error "源目录没有 .js 文件: $src"; exit 1 }
foreach ($f in $files) {
Copy-Item -Path $f.FullName -Destination (Join-Path $dst $f.Name) -Force
Write-Host ("同步: " + $f.Name)
}
Write-Host ("完成,共 " + $files.Count + " 个文件 -> " + $dst)
- Step 2: 写
sync_shared.cmd
照 client/scripts/build_spine_data.cmd 的现有范式:
@echo off
REM 把服务端 shared/ 单向同步到前端只读副本。改动一律落在服务端,改完跑本脚本。
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sync_shared.ps1"
- Step 3: 运行同步
Run: ./sync_shared.cmd
Expected: 打印 同步: cards.js 与 完成,共 1 个文件 -> ...,且 client/js/01_SubGame/codes/shared/cards.js 出现。
- Step 4: 验证两份文件字节一致
Run: git diff --no-index server/games/erqiwang/shared/cards.js client/js/01_SubGame/codes/shared/cards.js
Expected: 无输出(退出码 0)。若有差异,多半是脚本改了换行符——修脚本,不要手改副本。
- Step 5: 更新
CLAUDE.md
「常用命令」一节现在写的是「仓库里唯一的脚本」,改为两条。在 build_spine_data.cmd 那段之后补:
```bash
# 把服务端共享算法同步到前端只读副本(改动一律落在服务端 shared/,改完跑一次)
sync_shared.cmd
server/games/erqiwang/shared/ 是前后端共享算法的唯一可修改处;client/js/01_SubGame/codes/shared/ 是它的只读副本,手改会被下次同步覆盖,且 client/tests/test_shared_sync.js 会因字节不一致而失败。
同时把该节开头「仓库里唯一的脚本」这句改成「仓库里的脚本」。
- [ ] **Step 6: 提交**
```bash
git add sync_shared.cmd sync_shared.ps1 client/js/01_SubGame/codes/shared/cards.js CLAUDE.md
git commit -F - <<'EOF'
二七王:新增根目录 shared 同步脚本与前端只读副本
同步是跨端动作(源在 server、目标在 client),脚本放仓库根目录。
只提供服务端→前端一个方向;前端副本只读,手改会被覆盖。
CLAUDE.md 常用命令一节同步收录。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 3: 前端单测框架与同步守卫
Files:
- Create:
client/tests/_assert.js、client/tests/_load.js、client/tests/run.js、client/tests/test_shared_sync.js
Interfaces:
-
Produces:
require('./_assert')()→{ eq(name, got, exp), done(label) }(与服务端同款)require('./_load')→{ ROOT, load(relPath), loadAll([relPath...]) };load用vm.runInThisContext把 ES5 全局脚本喂进 Node global,顶层var因此成为globalThis属性
-
后续所有
test_*.js都用这两个 -
Step 1: 写
client/tests/_assert.js
与服务端 server/games/erqiwang/test/_assert.js 逐字相同(同一套断言语义,两端一致便于对照):
// 极简断言助手(测试代码不受严格 ES5 限制,dev-guide 05 §10)
module.exports = function () {
let pass = true;
let count = 0;
function eq(name, got, exp) {
const p = JSON.stringify(got) === JSON.stringify(exp);
count++;
if (!p) pass = false;
console.log((p ? 'PASS' : 'FAIL') + ' ' + name + ' got=' + JSON.stringify(got) + ' exp=' + JSON.stringify(exp));
}
function done(label) {
console.log((pass ? 'ALL PASS' : 'HAS FAILURE') + ' [' + label + '] ' + count + ' checks');
return pass;
}
return { eq, done };
};
- Step 2: 写
client/tests/_load.js
// 把前端 ES5 全局脚本加载进 Node global。
// 正式代码是浏览器脚本(var X = {...},无 module.exports),
// 这里用 vm.runInThisContext 还原浏览器语义——顶层 var 挂到 globalThis。
// 【关键】不得为了测试而给正式代码加 module.exports(client 05 §10)。
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..', '..'); // 仓库根
function load(rel) {
const abs = path.join(ROOT, rel);
const code = fs.readFileSync(abs, 'utf8');
vm.runInThisContext(code, { filename: rel });
}
function loadAll(rels) {
rels.forEach(load);
}
// 断言抛错的小工具:期望 fn 抛异常
function throws(fn) {
try { fn(); } catch (e) { return true; }
return false;
}
module.exports = { ROOT, load, loadAll, throws };
- Step 3: 写
client/tests/run.js
照服务端 test/run.js 的写法:
// 二七王前端单测总运行器:逐个 spawn test_*.js(各自独立进程/全局),汇总结果
// 用法:node client/tests/run.js
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const dir = __dirname;
const files = fs.readdirSync(dir).filter(f => /^test_.*\.js$/.test(f)).sort();
let allOk = true;
for (const f of files) {
console.log('\n===== ' + f + ' =====');
const r = cp.spawnSync(process.execPath, [path.join(dir, f)], { stdio: 'inherit' });
if (r.status !== 0) allOk = false;
}
console.log('\n' + (allOk ? '===== 全部单测通过 =====' : '===== 存在失败单测 ====='));
process.exit(allOk ? 0 : 1);
- Step 4: 写
client/tests/test_shared_sync.js(防漂移守卫)
// shared 同步守卫:服务端权威源与前端只读副本必须逐字节一致。
// 忘了跑 sync_shared.cmd、或有人手改了前端副本,这条立刻红。
const fs = require('fs');
const path = require('path');
const { ROOT } = require('./_load');
const t = require('./_assert')();
const srcDir = path.join(ROOT, 'server', 'games', 'erqiwang', 'shared');
const dstDir = path.join(ROOT, 'client', 'js', '01_SubGame', 'codes', 'shared');
const srcFiles = fs.readdirSync(srcDir).filter(f => f.endsWith('.js')).sort();
const dstFiles = fs.readdirSync(dstDir).filter(f => f.endsWith('.js')).sort();
t.eq('文件清单一致', dstFiles, srcFiles);
t.eq('源目录非空', srcFiles.length > 0, true);
srcFiles.forEach(f => {
const a = fs.readFileSync(path.join(srcDir, f));
const b = fs.existsSync(path.join(dstDir, f)) ? fs.readFileSync(path.join(dstDir, f)) : Buffer.alloc(0);
t.eq('字节一致: ' + f, a.equals(b), true);
});
process.exit(t.done('shared_sync') ? 0 : 1);
- Step 5: 运行
Run: node client/tests/run.js
Expected: test_shared_sync.js 全 PASS,末行 ===== 全部单测通过 =====。
- Step 6: 反向验证守卫真的有效
临时在 client/js/01_SubGame/codes/shared/cards.js 末尾加一个空格,再跑 node client/tests/run.js。
Expected: 字节一致: cards.js FAIL,退出码 1。确认后 ./sync_shared.cmd 还原,再跑一次确认恢复全绿。
这一步不能省:没反向验证过的守卫等于没有守卫(参见清单 §7.5 S-8 记录的测试盲点教训)。
- Step 7: 提交
git add client/tests/_assert.js client/tests/_load.js client/tests/run.js client/tests/test_shared_sync.js
git commit -F - <<'EOF'
二七王:前端单测框架与 shared 同步守卫
测试侧用 vm.runInThisContext 加载浏览器全局脚本,正式代码不为测试加任何导出。
test_shared_sync 断言前后端 shared 逐字节一致,已做反向验证(改一个空格即红)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 4: core/CardCodec.js(cardId → 帧号)
Files:
- Create:
client/js/01_SubGame/codes/core/CardCodec.js - Test:
client/tests/test_cardcodec.js
Interfaces:
- Consumes: 无
- Produces: 全局
EQW_CardCodec,含cardIdToFrame(cardId)→number、常量CARD_BACK_FRAME = 55
清单 §0.4:花色段序与美术帧正好相反(服务端 1=方块…4=黑桃,美术帧 黑桃→红桃→梅花→方块),这是最易静默画错牌的一处,故先写测试。
- Step 1: 写失败的测试
client/tests/test_cardcodec.js
// cardId → 牌面帧号(清单 §0.4 唯一权威转换)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/CardCodec.js');
// ---- 清单 §0.4 的 10 条校验样例,逐条锁死 ----
const cases = [
[0, 40, '方块A'],
[4, 44, '方块5'],
[12, 52, '方块K'],
[13, 27, '梅花A'],
[26, 14, '红桃A'],
[39, 1, '黑桃A'],
[51, 13, '黑桃K'],
[52, 53, '小王'],
[53, 54, '大王'],
[106, 53, '小王(deck2)']
];
cases.forEach(c => t.eq('帧号 ' + c[2] + ' id=' + c[0], EQW_CardCodec.cardIdToFrame(c[0]), c[1]));
// ---- 两副牌同一张牌共用同一帧 ----
for (let id = 0; id < 54; id++) {
if (EQW_CardCodec.cardIdToFrame(id) !== EQW_CardCodec.cardIdToFrame(id + 54)) {
t.eq('deck1/deck2 同帧 id=' + id, false, true);
}
}
t.eq('deck1/deck2 全部同帧', true, true);
// ---- 帧号值域:54 张牌只落在 1–54 ----
let inRange = true;
for (let id = 0; id < 108; id++) {
const f = EQW_CardCodec.cardIdToFrame(id);
if (!(f >= 1 && f <= 54)) { inRange = false; }
}
t.eq('帧号值域 1-54', inRange, true);
// ---- 单射:deck1 的 54 张牌映射出 54 个互不相同的帧 ----
const seen = {};
let dup = false;
for (let id = 0; id < 54; id++) {
const f = EQW_CardCodec.cardIdToFrame(id);
if (seen[f]) { dup = true; }
seen[f] = true;
}
t.eq('54 张牌帧号互不重复', dup, false);
t.eq('恰好覆盖 54 个帧', Object.keys(seen).length, 54);
// ---- 二七王不使用 3、4 的帧(design §2 去掉 3 和 4)----
// 方块3 id=2 → 帧42,方块4 id=3 → 帧43;资源仍按 60 帧通用排版出图,只是不引用
t.eq('方块3 帧号', EQW_CardCodec.cardIdToFrame(2), 42);
t.eq('方块4 帧号', EQW_CardCodec.cardIdToFrame(3), 43);
// ---- 牌背常量 ----
t.eq('牌背帧', EQW_CardCodec.CARD_BACK_FRAME, 55);
process.exit(t.done('cardcodec') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_cardcodec.js
Expected: 抛错(文件不存在 / EQW_CardCodec is not defined)。
- Step 3: 写实现
client/js/01_SubGame/codes/core/CardCodec.js
///////////////////////////////////////////////////////////////
////////// EQW_CardCodec: 牌 id → 牌面资源帧号(纯逻辑)//////////
///////////////////////////////////////////////////////////////
// 清单 §0.4:牌面资源统一 10 列 × 6 行 = 60 帧,行优先编号
// 帧 1–13 黑桃 A–K / 14–26 红桃 / 27–39 梅花 / 40–52 方块
// 帧 53 小王 / 54 大王 / 55–60 牌背 1–6(二七王用帧 55)
// 服务端牌 id 的花色段序是 方块→梅花→红心→黑桃(class.pai.js:16),
// 与美术帧的花色顺序【正好相反】,转换必须把花色段反过来数。
// 【全前端只此一处实现】其余模块一律调用它,不得另写一份。
var EQW_CardCodec = EQW_CardCodec || {
//牌背帧号(二七王固定用牌背 1)
CARD_BACK_FRAME: 55,
//牌 id → 牌面资源帧号。deck1 与 deck2 的同一张牌共用同一帧(牌面完全相同)
cardIdToFrame: function (cardId) {
var n = cardId % 54; // 去掉副数
if (n === 52) { return 53; } // 小王
if (n === 53) { return 54; } // 大王
return (3 - Math.floor(n / 13)) * 13 + (n % 13) + 1;
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_cardcodec.js
Expected: 全 PASS,末行 ALL PASS [cardcodec] ...。
- Step 5: 提交
git add client/js/01_SubGame/codes/core/CardCodec.js client/tests/test_cardcodec.js
git commit -F - <<'EOF'
二七王:前端 CardCodec,cardId 转牌面帧号
清单 §0.4 的唯一权威转换:服务端花色段序与美术帧顺序相反,
转换把花色段反过来数。用清单给的 10 条样例逐条锁死,
另加两副牌同帧、值域、单射三组断言。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 5: core/SeatMap.js(座位 ↔ 显示位)
Files:
- Create:
client/js/01_SubGame/codes/core/SeatMap.js - Test:
client/tests/test_seatmap.js
Interfaces:
- Consumes: 无
- Produces: 全局
EQW_SeatMap,含常量SELF/LEFT/RIGHT(值为同名字符串)、toDisplay(seat, mySeat)→'SELF'|'LEFT'|'RIGHT'、toSeat(displayKey, mySeat)→number
清单 §0.1:
SELF = mySeat、RIGHT = (mySeat+1)%3(下家)、LEFT = (mySeat+2)%3(上家)。依据服务端class.paiju.js:264get_nextseat = (seat+1)%3。这个映射后续每个界面都要用,写错了满屏错位,故全前端只此一处实现。
- Step 1: 写失败的测试
client/tests/test_seatmap.js
// 服务端座位 ↔ 显示位(清单 §0.1)
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/SeatMap.js');
// ---- 3×3 全组合 ----
// mySeat=0: 自己0、下家1在右上、上家2在左上
t.eq('my0 seat0', EQW_SeatMap.toDisplay(0, 0), 'SELF');
t.eq('my0 seat1', EQW_SeatMap.toDisplay(1, 0), 'RIGHT');
t.eq('my0 seat2', EQW_SeatMap.toDisplay(2, 0), 'LEFT');
t.eq('my1 seat1', EQW_SeatMap.toDisplay(1, 1), 'SELF');
t.eq('my1 seat2', EQW_SeatMap.toDisplay(2, 1), 'RIGHT');
t.eq('my1 seat0', EQW_SeatMap.toDisplay(0, 1), 'LEFT');
t.eq('my2 seat2', EQW_SeatMap.toDisplay(2, 2), 'SELF');
t.eq('my2 seat0', EQW_SeatMap.toDisplay(0, 2), 'RIGHT');
t.eq('my2 seat1', EQW_SeatMap.toDisplay(1, 2), 'LEFT');
// ---- 反向 ----
t.eq('my1 -> RIGHT 是座位2', EQW_SeatMap.toSeat('RIGHT', 1), 2);
t.eq('my2 -> RIGHT 是座位0', EQW_SeatMap.toSeat('RIGHT', 2), 0);
t.eq('my2 -> LEFT 是座位1', EQW_SeatMap.toSeat('LEFT', 2), 1);
t.eq('my0 -> SELF 是座位0', EQW_SeatMap.toSeat('SELF', 0), 0);
// ---- 互逆:任意 mySeat/seat 组合 toSeat(toDisplay(s)) == s ----
let ok = true;
for (let my = 0; my < 3; my++) {
for (let s = 0; s < 3; s++) {
if (EQW_SeatMap.toSeat(EQW_SeatMap.toDisplay(s, my), my) !== s) { ok = false; }
}
}
t.eq('toDisplay/toSeat 互逆', ok, true);
// ---- 与服务端 get_nextseat 一致:下家恒在 RIGHT ----
let nextOk = true;
for (let my = 0; my < 3; my++) {
if (EQW_SeatMap.toDisplay((my + 1) % 3, my) !== 'RIGHT') { nextOk = false; }
}
t.eq('下家(seat+1)%3 恒在右上', nextOk, true);
// ---- 反面:非法显示位显式抛错,不静默返回 ----
t.eq('非法显示位抛错', throws(() => EQW_SeatMap.toSeat('TOP', 0)), true);
process.exit(t.done('seatmap') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_seatmap.js
Expected: EQW_SeatMap is not defined。
- Step 3: 写实现
client/js/01_SubGame/codes/core/SeatMap.js
///////////////////////////////////////////////////////////////
////////// EQW_SeatMap: 服务端座位 ↔ 屏幕显示位(纯逻辑)///////
///////////////////////////////////////////////////////////////
// 清单 §0.1:服务端座位是绝对序号 0/1/2;前端一律把自己放底部,
// 另两家按 design §4 的逆时针顺序分居左上、右上。
// 底部(自己) = mySeat
// 右上(下家) = (mySeat + 1) % 3 ← 服务端 class.paiju.js:264 get_nextseat
// 左上(上家) = (mySeat + 2) % 3
// 【全前端只此一处实现】布局配置的 bySeat 键、各界面的座位换算都用它。
var EQW_SeatMap = EQW_SeatMap || {
SELF: 'SELF',
LEFT: 'LEFT',
RIGHT: 'RIGHT',
//服务端座位 → 显示位键
toDisplay: function (seat, mySeat) {
var d = ((seat - mySeat) % 3 + 3) % 3;
if (d === 0) { return 'SELF'; }
if (d === 1) { return 'RIGHT'; }
return 'LEFT';
},
//显示位键 → 服务端座位
toSeat: function (displayKey, mySeat) {
if (displayKey === 'SELF') { return mySeat; }
if (displayKey === 'RIGHT') { return (mySeat + 1) % 3; }
if (displayKey === 'LEFT') { return (mySeat + 2) % 3; }
throw new Error('[EQW_SeatMap] 未知显示位: ' + displayKey);
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_seatmap.js
Expected: 全 PASS。
- Step 5: 提交
git add client/js/01_SubGame/codes/core/SeatMap.js client/tests/test_seatmap.js
git commit -F - <<'EOF'
二七王:前端 SeatMap,服务端座位与显示位互换
清单 §0.1:下家 (seat+1)%3 显示在右上,与服务端 get_nextseat 同源。
3×3 全组合 + 互逆 + 非法键抛错都已覆盖。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 6: core/CardOrder.js(主牌序排序)
Files:
- Create:
client/js/01_SubGame/codes/core/CardOrder.js - Test:
client/tests/test_cardorder.js
Interfaces:
- Consumes:
youle_erqiwang_shared_cards.order_cards(mainflower, cards)(Task 1/2) - Produces: 全局
EQW_CardOrder,含sort(cards, mainflower)→ 新数组(从大到小,与服务端同序),不修改入参
清单 T-5:前端排序对齐服务端主牌序,不另造一套。服务端
order_cards是原地排序,前端包一层concat()防止误改调用方的手牌数组。选主前传mainflower = 0(只有固定主牌成主),选主后传flower。
- Step 1: 写失败的测试
client/tests/test_cardorder.js
// 主牌序排序:必须与服务端 order_cards 同序(清单 T-5)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/shared/cards.js');
load('client/js/01_SubGame/codes/core/CardOrder.js');
// 服务端权威实现,用来做对照
const SRV = require('../../server/games/erqiwang/class.arith.js');
// 牌id构造(与服务端测试同款):deck d(1-2), flower f(1-4), number n(1-13)
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const big = d => (d - 1) * 54 + 53; // 大王
const small = d => (d - 1) * 54 + 52; // 小王
// 一手混牌:两副牌、四花色、含王与 2/7
const hand = [
id(1, 1, 5), id(1, 4, 13), big(1), id(2, 2, 7), id(1, 3, 2),
id(1, 1, 1), small(2), id(2, 4, 7), id(1, 2, 2), id(2, 3, 10)
];
// ---- 与服务端逐一对照:选主前(0) 与四种主花色 ----
[0, 1, 2, 3, 4].forEach(mf => {
const expected = SRV.order_cards(mf, hand.concat());
t.eq('与服务端同序 mainflower=' + mf, EQW_CardOrder.sort(hand, mf), expected);
});
// ---- 不修改入参数组 ----
const orig = hand.concat();
EQW_CardOrder.sort(hand, 1);
t.eq('入参未被修改', hand, orig);
// ---- 返回的是新数组,不是同一引用 ----
t.eq('返回新数组', EQW_CardOrder.sort(hand, 1) === hand, false);
// ---- 选主前后顺序确实不同(正2/正7 升格、主花色并入主牌段)----
const beforeChoose = EQW_CardOrder.sort(hand, 0);
const afterChoose = EQW_CardOrder.sort(hand, 1);
t.eq('选主后顺序变化', JSON.stringify(beforeChoose) === JSON.stringify(afterChoose), false);
// ---- 主牌在左:排序从大到小,最大的一张恒为大王 ----
t.eq('首张是大王', EQW_CardOrder.sort(hand, 1)[0], big(1));
// ---- 边界 ----
t.eq('空数组', EQW_CardOrder.sort([], 1), []);
t.eq('null 入参', EQW_CardOrder.sort(null, 1), []);
t.eq('单张', EQW_CardOrder.sort([id(1, 1, 5)], 1), [id(1, 1, 5)]);
process.exit(t.done('cardorder') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_cardorder.js
Expected: EQW_CardOrder is not defined。
- Step 3: 写实现
client/js/01_SubGame/codes/core/CardOrder.js
///////////////////////////////////////////////////////////////
////////// EQW_CardOrder: 手牌主牌序排序(纯逻辑)//////////////
///////////////////////////////////////////////////////////////
// 清单 T-5:前端排序【对齐服务端主牌序,不另造一套】——
// 权威实现是 shared/cards.js 的 order_cards(与服务端同一份文件)。
// 本模块只负责两件事:不改调用方的数组、把「未选主」表达为 mainflower = 0。
// 选主前(叫分阶段):flower 未定,只有固定主牌(双王 + 全部 2、7)是主牌
// 选主后:正2/正7 升格、主花色普通牌并入主牌段,必须整体重排(design §3)
// 排序从大到小,主牌段自然落在左侧。
var EQW_CardOrder = EQW_CardOrder || {
//返回排好序的【新数组】;cards 不被修改。mainflower:1方块 2梅花 3红心 4黑桃,0 = 尚未选主
sort: function (cards, mainflower) {
if (!cards) { return []; }
return youle_erqiwang_shared_cards.order_cards(mainflower || 0, cards.concat());
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_cardorder.js
Expected: 全 PASS。
- Step 5: 提交
git add client/js/01_SubGame/codes/core/CardOrder.js client/tests/test_cardorder.js
git commit -F - <<'EOF'
二七王:前端 CardOrder,手牌主牌序排序
直接委托 shared/cards.js 的 order_cards(与服务端同一份文件),
前端只负责 concat 防改入参、把未选主表达为 mainflower=0。
单测拿服务端 class.arith 的输出做逐一对照,五种主花色全覆盖。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 7: core/CardMark.js(手牌标记与花色统计)
Files:
- Create:
client/js/01_SubGame/codes/core/CardMark.js - Test:
client/tests/test_cardmark.js
Interfaces:
- Consumes:
youle_erqiwang_shared_cards(id_to_code/id_to_flower/order_cards/get_pairlist/is_continuous)、EQW_CardOrder - Produces: 全局
EQW_CardMark,含MARK_TRACTOR = 'tractor'、MARK_ZHENG = 'zheng'、MARK_ZHU = 'zhu'marksOfHand(cards, mainflower)→{ 牌id: 'tractor'|'zheng'|'zhu' }(无标记的牌不出现在结果里)markOf(cardId, cards, mainflower)→'tractor'|'zheng'|'zhu'|nullcountByFlower(cards)→{ 1: {count, pairs}, 2: {...}, 3: {...}, 4: {...} }
清单 §1.6 / T-8:三种标记互斥、每张牌至多一个,由前端据
flower本地标注,服务端不下发。 优先级tractor > zheng > zhu。countByFlower供选主面板的中央大数字(该花色张数)与右上角标(对子数)——协议ChooseMain注明服务端不额外下发。
- Step 1: 写失败的测试
client/tests/test_cardmark.js
// 手牌三种标记推导 + 选主面板的花色统计(清单 §1.6 / §1.5)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/shared/cards.js');
load('client/js/01_SubGame/codes/core/CardOrder.js');
load('client/js/01_SubGame/codes/core/CardMark.js');
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const big = d => (d - 1) * 54 + 53;
const small = d => (d - 1) * 54 + 52;
const MF = 1; // 主花色 = 方块
// ---- 正2 / 正7(主花色的 2 和 7)标 zheng ----
t.eq('正2 标 zheng', EQW_CardMark.markOf(id(1, MF, 2), [id(1, MF, 2)], MF), 'zheng');
t.eq('正7 标 zheng', EQW_CardMark.markOf(id(1, MF, 7), [id(1, MF, 7)], MF), 'zheng');
// ---- 其他主牌标 zhu:双王、副2、副7、主花色普通牌 ----
t.eq('大王 标 zhu', EQW_CardMark.markOf(big(1), [big(1)], MF), 'zhu');
t.eq('小王 标 zhu', EQW_CardMark.markOf(small(1), [small(1)], MF), 'zhu');
t.eq('副2 标 zhu', EQW_CardMark.markOf(id(1, 2, 2), [id(1, 2, 2)], MF), 'zhu');
t.eq('副7 标 zhu', EQW_CardMark.markOf(id(1, 3, 7), [id(1, 3, 7)], MF), 'zhu');
t.eq('主花色A 标 zhu', EQW_CardMark.markOf(id(1, MF, 1), [id(1, MF, 1)], MF), 'zhu');
// ---- 副牌无标记 ----
t.eq('副牌普通牌无标记', EQW_CardMark.markOf(id(1, 2, 5), [id(1, 2, 5)], MF), null);
// ---- 拖拉机:同花色两个连续对子 → 四张全标 tractor ----
// 副牌花色2 的 9对 + 10对(编码相差1,连续)
const tractorHand = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10)];
const m1 = EQW_CardMark.marksOfHand(tractorHand, MF);
t.eq('连续两对全标 tractor', tractorHand.map(c => m1[c]), ['tractor', 'tractor', 'tractor', 'tractor']);
// ---- 反面:不连续的两个对子不成拖拉机(副牌无标记)----
const notTractor = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 12), id(2, 2, 12)];
const m2 = EQW_CardMark.marksOfHand(notTractor, MF);
t.eq('不连续两对不成拖', notTractor.map(c => m2[c] || null), [null, null, null, null]);
// ---- 反面:单个对子不是拖拉机 ----
const onePair = [id(1, 2, 9), id(2, 2, 9)];
const m3 = EQW_CardMark.marksOfHand(onePair, MF);
t.eq('单对不成拖', onePair.map(c => m3[c] || null), [null, null]);
// ---- 反面:跨花色的对子不成拖(花色2的9对 + 花色3的10对)----
const crossFlower = [id(1, 2, 9), id(2, 2, 9), id(1, 3, 10), id(2, 3, 10)];
const m4 = EQW_CardMark.marksOfHand(crossFlower, MF);
t.eq('跨花色不成拖', crossFlower.map(c => m4[c] || null), [null, null, null, null]);
// ---- 优先级:属于拖拉机的正2,标 tractor 而非 zheng ----
// 主牌序里 正2 与 副2 相连(is_continuous: 正2、负2)
const zhengInTractor = [id(1, MF, 2), id(2, MF, 2), id(1, 2, 2), id(2, 2, 2)];
const m5 = EQW_CardMark.marksOfHand(zhengInTractor, MF);
t.eq('拖拉机优先于 zheng', m5[id(1, MF, 2)], 'tractor');
t.eq('拖拉机优先于 zhu', m5[id(1, 2, 2)], 'tractor');
// ---- 互斥:任何一张牌至多一个标记(marksOfHand 的值域)----
const mixed = [big(1), small(1), id(1, MF, 2), id(1, MF, 7), id(1, 2, 5), id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10)];
const m6 = EQW_CardMark.marksOfHand(mixed, MF);
const validMarks = Object.keys(m6).every(k => ['tractor', 'zheng', 'zhu'].indexOf(m6[k]) >= 0);
t.eq('标记值域合法', validMarks, true);
// ---- markOf 与 marksOfHand 结果一致 ----
const consistent = mixed.every(c => (m6[c] || null) === EQW_CardMark.markOf(c, mixed, MF));
t.eq('markOf 与 marksOfHand 一致', consistent, true);
// ---- 选主前(mainflower=0):只有固定主牌成主,主花色普通牌无标记 ----
t.eq('选主前 方块A 无标记', EQW_CardMark.markOf(id(1, 1, 1), [id(1, 1, 1)], 0), null);
t.eq('选主前 2 仍是主', EQW_CardMark.markOf(id(1, 1, 2), [id(1, 1, 2)], 0), 'zhu');
t.eq('选主前 王 仍是主', EQW_CardMark.markOf(big(1), [big(1)], 0), 'zhu');
// ---- countByFlower:选主面板的张数与对数 ----
const forCount = [
id(1, 1, 5), id(2, 1, 5), // 方块5 一对
id(1, 1, 9), // 方块9 单张
id(1, 2, 3), id(1, 2, 6), id(2, 2, 6), // 梅花:一张3 + 一对6
big(1), small(1) // 王不计入任何花色
];
const cnt = EQW_CardMark.countByFlower(forCount);
t.eq('方块张数', cnt[1].count, 3);
t.eq('方块对数', cnt[1].pairs, 1);
t.eq('梅花张数', cnt[2].count, 3);
t.eq('梅花对数', cnt[2].pairs, 1);
t.eq('红心张数', cnt[3].count, 0);
t.eq('黑桃对数', cnt[4].pairs, 0);
// ---- 边界 ----
t.eq('空手牌 marks', EQW_CardMark.marksOfHand([], MF), {});
t.eq('空手牌 count', EQW_CardMark.countByFlower([]), { 1: { count: 0, pairs: 0 }, 2: { count: 0, pairs: 0 }, 3: { count: 0, pairs: 0 }, 4: { count: 0, pairs: 0 } });
process.exit(t.done('cardmark') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_cardmark.js
Expected: EQW_CardMark is not defined。
- Step 3: 写实现
client/js/01_SubGame/codes/core/CardMark.js
拖拉机的识别方式与服务端 decompose_trump(class.arith.js:293)同思路:先分出对子,再扫极大连续段。
///////////////////////////////////////////////////////////////
////////// EQW_CardMark: 手牌牌面标记与花色统计(纯逻辑)///////
///////////////////////////////////////////////////////////////
// 清单 §1.6 / T-8:牌面三种标记,前端据主花色本地推导,服务端不下发。
// 'tractor' 红色圆标「拖」= 该牌属于一组拖拉机
// 'zheng' 橙色五角星 = 正2 / 正7(选定花色的 2 和 7)
// 'zhu' 蓝色五角星 = 其余主牌(双王、副2、副7、主花色普通牌)
// 三者【互斥】、每张牌至多一个,优先级 tractor > zheng > zhu。
//
// 牌编码区间(shared/cards.js 的 id_to_code 产物):
// 9553/9554 王 / 8000-9000 正7 / 7000-8000 副7 / 3000-4000 正2
// 2000-3000 副2 / 1000-2000 主花色普通牌 / 100-500 副牌普通牌
// ⇒ 主牌 ⟺ code > 1000
//
// 拖拉机只在【同一牌组】内成立:全部主牌算一组(副2/副7/王与主花色牌可相连),
// 副牌按各自花色分组。识别方式与服务端 decompose_trump 同思路:
// 先分出对子,再用 is_continuous 扫极大连续段,长度 ≥2 的段内所有牌标 tractor。
var EQW_CardMark = EQW_CardMark || {
MARK_TRACTOR: 'tractor',
MARK_ZHENG: 'zheng',
MARK_ZHU: 'zhu',
//整手牌的标记表:{ 牌id: 标记 };没有标记的牌不出现在结果里
marksOfHand: function (cards, mainflower) {
var marks = {};
if (!cards || cards.length === 0) { return marks; }
var mf = mainflower || 0;
var S = youle_erqiwang_shared_cards;
var i, code;
//1) 分组:主牌一组(键 'Z'),副牌按花色分组(键为花色号)
var groups = {};
for (i = 0; i < cards.length; i++) {
code = S.id_to_code(mf, cards[i]);
var key = (code > 1000) ? 'Z' : String(S.id_to_flower(cards[i]));
if (!groups[key]) { groups[key] = []; }
groups[key].push(cards[i]);
}
//2) 每组内找拖拉机
for (var g in groups) {
if (!groups.hasOwnProperty(g)) { continue; }
var ordered = S.order_cards(mf, groups[g].concat());
var pairs = S.get_pairlist(mf, ordered);
var j = 0;
while (j < pairs.length) {
var run = [pairs[j]];
var k = j;
while (k + 1 < pairs.length &&
S.is_continuous(S.id_to_code(mf, pairs[k][0]), S.id_to_code(mf, pairs[k + 1][0]))) {
run.push(pairs[k + 1]);
k++;
}
if (run.length >= 2) { //≥2 个连续对子才是拖拉机
for (var r = 0; r < run.length; r++) {
marks[run[r][0]] = 'tractor';
marks[run[r][1]] = 'tractor';
}
}
j = k + 1;
}
}
//3) 其余牌按 zheng / zhu 标注(拖拉机优先,已标的不覆盖)
for (i = 0; i < cards.length; i++) {
if (marks[cards[i]]) { continue; }
code = S.id_to_code(mf, cards[i]);
if ((code > 3000 && code < 4000) || (code > 8000 && code < 9000)) {
marks[cards[i]] = 'zheng'; //正2 / 正7
} else if (code > 1000) {
marks[cards[i]] = 'zhu'; //其余主牌
}
}
return marks;
},
//单张牌的标记;cards 是它所在的整手牌(拖拉机需要上下文才能判定)
markOf: function (cardId, cards, mainflower) {
var marks = this.marksOfHand(cards, mainflower);
return marks[cardId] || null;
},
//选主面板用:各花色的张数与对子数(清单 §1.5,服务端不下发、前端自算)
//按【牌面花色】统计,与主牌无关;王不属于任何花色,不计入
countByFlower: function (cards) {
var result = { 1: { count: 0, pairs: 0 }, 2: { count: 0, pairs: 0 },
3: { count: 0, pairs: 0 }, 4: { count: 0, pairs: 0 } };
if (!cards || cards.length === 0) { return result; }
var S = youle_erqiwang_shared_cards;
var byFlowerNumber = {}; //{花色: {点数: 张数}}
var i, f, n;
for (i = 0; i < cards.length; i++) {
f = S.id_to_flower(cards[i]);
if (f === 5) { continue; } //王
n = S.id_to_number(cards[i]);
if (!byFlowerNumber[f]) { byFlowerNumber[f] = {}; }
byFlowerNumber[f][n] = (byFlowerNumber[f][n] || 0) + 1;
result[f].count++;
}
for (f = 1; f <= 4; f++) {
var nums = byFlowerNumber[f];
if (!nums) { continue; }
for (n in nums) {
if (!nums.hasOwnProperty(n)) { continue; }
result[f].pairs += Math.floor(nums[n] / 2);
}
}
return result;
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_cardmark.js
Expected: 全 PASS。若「拖拉机优先于 zheng」那条失败,检查第 3 步的 if (marks[cards[i]]) { continue; } 是否漏写。
- Step 5: 提交
git add client/js/01_SubGame/codes/core/CardMark.js client/tests/test_cardmark.js
git commit -F - <<'EOF'
二七王:前端 CardMark,手牌标记推导与花色统计
三种标记互斥、优先级 tractor > zheng > zhu,据主花色本地推导。
拖拉机识别与服务端 decompose_trump 同思路:分对子后扫极大连续段,
主牌一组、副牌按花色分组。反面用例覆盖不连续、单对、跨花色三种不成拖的情形。
countByFlower 供选主面板的张数与对数,协议明确服务端不下发。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 8: ui/LayoutSolver.js(五型布局求解器)
Files:
- Create:
client/js/01_SubGame/codes/ui/LayoutSolver.js - Test:
client/tests/test_layoutsolver.js
Interfaces:
- Consumes:
AlignmentUtils(框架,client/js/gameabc-framework/ui/AlignmentUtils.js);apply()另需SpriteManager - Produces: 全局
EQW_LayoutSolver,含solve(node, ctx)→[{x, y, width, height}, ...](纯函数,恒返回数组)apply(node, spriteIds, ctx)→ 同上并逐个SpriteManager.setPosition
清单 §6.0–§6.2。
AlignmentUtils的纯矩形版 API(alignTo/alignCorner*/distributeHorizontally/distributeVertically)不依赖 SpriteManager,故solve可完全纯函数、可在 Node 里单测。两处容易写错,注意:
distributeHorizontally假设等宽,故line带items(逐项不等宽)时不能喂给它,要自己按累计宽度推进;distribute*只返回{x, y},本模块要补上width/height再返回。
- Step 1: 写失败的测试
client/tests/test_layoutsolver.js
// 五型布局求解器(清单 §6.1 / §6.2)
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// ============ point ============
const point = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
t.eq('point 返回单元素数组', EQW_LayoutSolver.solve(point, {}), [{ x: 486, y: 4, width: 276, height: 68 }]);
// ============ line 等距 ============
// 底牌区:8 张 110×190,spacing -36,anchor center,基准 (655,110)
// totalWidth = 8*110 + 7*(-36) = 880 - 252 = 628;startX = 655 - 314 = 341
const line8 = { kind: 'line', direction: 'horizontal', anchorX: 655, anchorY: 110,
itemWidth: 110, itemHeight: 190, spacing: -36, anchor: 'center' };
const r1 = EQW_LayoutSolver.solve(line8, { count: 8 });
t.eq('line 张数', r1.length, 8);
t.eq('line 首张 x', r1[0].x, 341);
t.eq('line 次张 x', r1[1].x, 341 + 110 - 36);
t.eq('line y 恒定', r1[7].y, 110);
t.eq('line 带上尺寸', { w: r1[0].width, h: r1[0].height }, { w: 110, h: 190 });
// ============ line + items 逐项不等宽 ============
// 底栏功能钮组:宽 74/87/93/78,spacing 15,anchor right,基准 x=1250
// totalWidth = 74+87+93+78 + 3*15 = 332 + 45 = 377;startX = 1250 - 377 = 873
const lineItems = { kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
itemHeight: 30, spacing: 15, anchor: 'right',
items: [{ key: 'A', width: 74 }, { key: 'B', width: 87 },
{ key: 'C', width: 93 }, { key: 'D', width: 78 }] };
const r2 = EQW_LayoutSolver.solve(lineItems, {});
t.eq('items 项数(count 由 items 决定)', r2.length, 4);
t.eq('items 首项 x', r2[0].x, 873);
t.eq('items 第二项 x', r2[1].x, 873 + 74 + 15);
t.eq('items 第三项 x', r2[2].x, 873 + 74 + 15 + 87 + 15);
t.eq('items 逐项宽度', r2.map(r => r.width), [74, 87, 93, 78]);
t.eq('items 末项右边缘贴基准', r2[3].x + r2[3].width, 1250);
// ============ line 垂直 ============
const lineV = { kind: 'line', direction: 'vertical', anchorX: 100, anchorY: 0,
itemWidth: 200, itemHeight: 32, spacing: 8, anchor: 'top' };
const r3 = EQW_LayoutSolver.solve(lineV, { count: 3 });
t.eq('垂直 y 递增', r3.map(r => r.y), [0, 40, 80]);
t.eq('垂直 x 恒定', r3.map(r => r.x), [100, 100, 100]);
// ============ fan 自适应压缩 ============
// 手牌单排:maxWidth 1215,itemWidth 110,spacingMin -78,spacingMax 0
// 36 张:raw = (1215-110)/35 - 110 = 31.571... - 110 = -78.43 → clamp 到 -78
const fan = { kind: 'fan', direction: 'horizontal', anchorX: 640, anchorY: 455,
maxWidth: 1215, itemWidth: 110, itemHeight: 190,
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left' };
const f36 = EQW_LayoutSolver.solve(fan, { count: 36 });
t.eq('fan 36 张间距取下限', f36[1].x - f36[0].x, 110 - 78);
// 少量牌时 raw 为正 → clamp 到 spacingMax
const f2 = EQW_LayoutSolver.solve(fan, { count: 2 });
t.eq('fan 2 张间距取上限', f2[1].x - f2[0].x, 110 + 0);
// count <= 1 的边界
const f1 = EQW_LayoutSolver.solve(fan, { count: 1 });
t.eq('fan 单张', f1.length, 1);
t.eq('fan 单张居中', f1[0].x, 640 - 55);
t.eq('fan 零张', EQW_LayoutSolver.solve(fan, { count: 0 }), []);
// clamp 中间值:raw 落在 [min,max] 内时原样取用
// maxWidth 500、itemWidth 100、count 5:raw = (500-100)/4 - 100 = 0 → 落在 [-60, 10] 内
const fanMid = { kind: 'fan', direction: 'horizontal', anchorX: 0, anchorY: 0,
maxWidth: 500, itemWidth: 100, itemHeight: 100,
spacingMax: 10, spacingMin: -60, anchor: 'left' };
const fm = EQW_LayoutSolver.solve(fanMid, { count: 5 });
t.eq('fan clamp 中间值', fm[1].x - fm[0].x, 100 + 0);
// ============ grid ============
// 叫分 14 档:7 列 2 行,74×56,间距 7/24,anchor center,基准 (640,160)
// 行宽 = 7*74 + 6*7 = 518 + 42 = 560;startX = 640 - 280 = 360
const grid = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
anchor: 'center', fillOrder: 'row' };
const g = EQW_LayoutSolver.solve(grid, { count: 14 });
t.eq('grid 项数', g.length, 14);
t.eq('grid 首项 x', g[0].x, 360);
t.eq('grid 首项 y', g[0].y, 160);
t.eq('grid 第2项 x', g[1].x, 360 + 74 + 7);
t.eq('grid 第8项换行 x', g[7].x, 360);
t.eq('grid 第8项 y', g[7].y, 160 + 56 + 24);
// count 少于满格时只出 count 个
t.eq('grid 不足一行', EQW_LayoutSolver.solve(grid, { count: 3 }).length, 3);
// ============ attach 内部对齐(corner 为空)============
const rects = { TARGET: { x: 100, y: 200, width: 80, height: 40 } };
const attachIn = { kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top',
offsetX: 8, offsetY: -4, w: 20, h: 10 };
const a1 = EQW_LayoutSolver.solve(attachIn, { rects: rects });
t.eq('attach 内部右上', a1, [{ x: 100 + 80 - 20 + 8, y: 200 - 4, width: 20, height: 10 }]);
// ============ attach 贴外侧角 ============
const attachCorner = { kind: 'attach', target: 'TARGET', corner: 'topRight',
offsetX: 4, offsetY: 6, w: 20, h: 10 };
const a2 = EQW_LayoutSolver.solve(attachCorner, { rects: rects });
t.eq('attach 外侧右上角', a2, [{ x: 100 + 80 + 4, y: 200 - 10 + 6, width: 20, height: 10 }]);
// 四个角都要能解
['topLeft', 'topRight', 'bottomLeft', 'bottomRight'].forEach(c => {
const r = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: c, w: 20, h: 10 }, { rects: rects });
t.eq('attach 角 ' + c + ' 可解', r.length === 1 && typeof r[0].x === 'number', true);
});
// ============ bySeat 合并:列出的覆盖、未列出的继承 ============
const bySeat = {
kind: 'fan', direction: 'horizontal', itemWidth: 90, itemHeight: 155,
maxWidth: 170, spacingMax: -35, spacingMin: -62,
bySeat: {
SELF: { anchorX: 650, anchorY: 265, anchor: 'center', overlapFrom: 'left' },
RIGHT: { anchorX: 1022, anchorY: 120, anchor: 'center', overlapFrom: 'left' },
LEFT: { anchorX: 258, anchorY: 120, anchor: 'center', overlapFrom: 'right' }
}
};
const sSelf = EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 1 });
const sLeft = EQW_LayoutSolver.solve(bySeat, { seat: 'LEFT', count: 1 });
t.eq('bySeat SELF 用自己的锚点', sSelf[0].x, 650 - 45);
t.eq('bySeat LEFT 用自己的锚点', sLeft[0].x, 258 - 45);
t.eq('bySeat 继承 base 的尺寸', sLeft[0].width, 90);
// ============ 显式失败:不兜底 ============
t.eq('未知 kind 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'blob' }, {})), true);
t.eq('attach target 缺失抛错', throws(() => EQW_LayoutSolver.solve(attachIn, { rects: {} })), true);
t.eq('attach 未给 rects 抛错', throws(() => EQW_LayoutSolver.solve(attachIn, {})), true);
t.eq('bySeat 缺 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { count: 1 })), true);
t.eq('bySeat 未知 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { seat: 'TOP', count: 1 })), true);
t.eq('node 为空抛错', throws(() => EQW_LayoutSolver.solve(null, {})), true);
t.eq('未知 corner 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'middle', w: 1, h: 1 }, { rects: rects })), true);
// ============ 纯函数:不修改入参 ============
const before = JSON.stringify(bySeat);
EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 3 });
t.eq('solve 不修改配置对象', JSON.stringify(bySeat), before);
process.exit(t.done('layoutsolver') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_layoutsolver.js
Expected: EQW_LayoutSolver is not defined。
- Step 3: 写实现
client/js/01_SubGame/codes/ui/LayoutSolver.js
///////////////////////////////////////////////////////////////
////////// EQW_LayoutSolver: 五型布局求解器 ////////////////////
///////////////////////////////////////////////////////////////
// 清单 §6:布局配置是【纯数据】,一切求解逻辑集中在本文件。
// point 定点 line 等距排列(可 items 逐项不等宽)
// fan 自适应压缩 grid 网格 attach 相对贴附
//
// solve(node, ctx) 是【纯函数】:不碰精灵、不查引擎,故可完整单测。
// node: 一份布局配置(五型之一,可含 bySeat)
// ctx : { seat, count, rects }
// seat —— 'SELF'|'LEFT'|'RIGHT',仅 bySeat 需要
// count —— 运行时项数(清单 §6.1:count 由数据给出、不进配置)
// rects —— { 键名: {x,y,width,height} },仅 attach 需要
// 返回恒为数组,point / attach 长度为 1。
//
// 参数名一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置可原样喂入。
// 精灵锚点恒在左上角,配置里的 anchorX/anchorY 是【对齐基准点】,不是精灵左上角。
// 出错一律抛异常,不返回 {x:0,y:0} 之类的兜底值(工程总则 §7 显式失败)。
var EQW_LayoutSolver = EQW_LayoutSolver || {
//——— 对外:求解 ———
solve: function (node, ctx) {
if (!node) { throw new Error('[EQW_LayoutSolver] node 为空'); }
ctx = ctx || {};
var n = this._mergeBySeat(node, ctx.seat);
switch (n.kind) {
case 'point': return this._solvePoint(n);
case 'line': return this._solveLine(n, ctx);
case 'fan': return this._solveFan(n, ctx);
case 'grid': return this._solveGrid(n, ctx);
case 'attach': return this._solveAttach(n, ctx);
default: throw new Error('[EQW_LayoutSolver] 未知 kind: ' + n.kind);
}
},
//——— 对外:求解并摆精灵(唯一触碰 SpriteManager 之处)———
apply: function (node, spriteIds, ctx) {
var rects = this.solve(node, ctx);
for (var i = 0; i < spriteIds.length && i < rects.length; i++) {
SpriteManager.setPosition(spriteIds[i], rects[i].x, rects[i].y);
}
return rects;
},
//——— bySeat 合并:bySeat[seat] 覆盖外层 base,未列出的字段继承(清单 §6.2)———
_mergeBySeat: function (node, seat) {
if (!node.bySeat) { return node; }
if (!seat) { throw new Error('[EQW_LayoutSolver] 配置含 bySeat,但 ctx.seat 未给出'); }
if (!node.bySeat[seat]) { throw new Error('[EQW_LayoutSolver] bySeat 无此显示位: ' + seat); }
var merged = {};
var k;
for (k in node) {
if (node.hasOwnProperty(k) && k !== 'bySeat') { merged[k] = node[k]; }
}
var v = node.bySeat[seat];
for (k in v) {
if (v.hasOwnProperty(k)) { merged[k] = v[k]; }
}
return merged;
},
_solvePoint: function (n) {
return [{ x: n.x, y: n.y, width: n.w, height: n.h }];
},
//line:等距排列。带 items 时逐项取宽(itemWidth 失效),此时不能走 distribute(它假设等宽)
_solveLine: function (n, ctx) {
if (n.items) { return this._solveLineItems(n); }
var count = ctx.count;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] line 需要 ctx.count'); }
if (count <= 0) { return []; }
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacing || 0,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//line + items:逐项不等宽(底栏功能钮组、埋牌操作条等,清单 §6.1)
_solveLineItems: function (n) {
var items = n.items;
var spacing = n.spacing || 0;
var i, total = 0;
for (i = 0; i < items.length; i++) { total += items[i].width; }
total += (items.length - 1) * spacing;
var start;
switch (n.anchor) {
case 'left': start = n.anchorX; break;
case 'right': start = n.anchorX - total; break;
default: start = n.anchorX - total / 2; break;
}
var out = [];
var cursor = start;
for (i = 0; i < items.length; i++) {
out.push({ x: cursor, y: n.anchorY, width: items[i].width, height: n.itemHeight });
cursor += items[i].width + spacing;
}
return out;
},
//fan:张数可变、总宽受限、动态压缩间距(清单 §6.1,间距算法【唯一实现】)
_solveFan: function (n, ctx) {
var count = ctx.count;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] fan 需要 ctx.count'); }
if (count <= 0) { return []; }
var spacing = 0;
if (count > 1) {
var raw = (n.maxWidth - n.itemWidth) / (count - 1) - n.itemWidth;
spacing = Math.max(n.spacingMin, Math.min(n.spacingMax, raw));
}
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: spacing,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//grid:固定行列的按钮阵,逐行调 distributeHorizontally(清单 §6.1)
_solveGrid: function (n, ctx) {
var count = (typeof ctx.count === 'number') ? ctx.count : (n.cols * n.rows);
if (count <= 0) { return []; }
var out = [];
for (var row = 0; row < n.rows && out.length < count; row++) {
var remain = count - out.length;
var inRow = Math.min(n.cols, remain);
var positions = AlignmentUtils.distributeHorizontally({
anchorX: n.anchorX,
anchorY: n.anchorY + row * (n.itemHeight + (n.spacingY || 0)),
count: n.cols, //按满格算位置,保证不足一行时列位不漂移
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacingX || 0,
anchor: n.anchor
});
for (var c = 0; c < inRow; c++) {
out.push({ x: positions[c].x, y: positions[c].y, width: n.itemWidth, height: n.itemHeight });
}
}
return out;
},
//attach:贴在另一个精灵上。corner 为空 = 在目标【内部】对齐;有值 = 贴到目标【外侧】角
_solveAttach: function (n, ctx) {
if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); }
var target = ctx.rects[n.target];
if (!target) { throw new Error('[EQW_LayoutSolver] attach 的 target 未在 ctx.rects 中: ' + n.target); }
var sprite = { width: n.w, height: n.h };
var offset = { x: n.offsetX || 0, y: n.offsetY || 0 };
var pos;
if (n.corner) {
switch (n.corner) {
case 'topLeft': pos = AlignmentUtils.alignCornerTopLeft(target, sprite, offset); break;
case 'topRight': pos = AlignmentUtils.alignCornerTopRight(target, sprite, offset); break;
case 'bottomLeft': pos = AlignmentUtils.alignCornerBottomLeft(target, sprite, offset); break;
case 'bottomRight': pos = AlignmentUtils.alignCornerBottomRight(target, sprite, offset); break;
default: throw new Error('[EQW_LayoutSolver] 未知 corner: ' + n.corner);
}
} else {
pos = AlignmentUtils.alignTo(target, sprite, n.hAlign, n.vAlign, offset);
}
return [{ x: pos.x, y: pos.y, width: n.w, height: n.h }];
},
//distribute* 只返回 {x,y},这里补上尺寸
_withSize: function (positions, w, h) {
var out = [];
for (var i = 0; i < positions.length; i++) {
out.push({ x: positions[i].x, y: positions[i].y, width: w, height: h });
}
return out;
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_layoutsolver.js
Expected: 全 PASS。
若 attach 外侧右上角 那条失败,先读 AlignmentUtils.alignCornerTopRight(client/js/gameabc-framework/ui/AlignmentUtils.js:292)确认它的实际语义,以框架实现为准修正测试的期望值,不要为了让测试过而在求解器里做偏移补偿。
- Step 5: 提交
git add client/js/01_SubGame/codes/ui/LayoutSolver.js client/tests/test_layoutsolver.js
git commit -F - <<'EOF'
二七王:前端 LayoutSolver,五型布局求解器
solve 是纯函数(attach 靠 ctx.rects 查目标矩形而非反查引擎),
fan 的 clamp 间距算法只此一处实现;line 带 items 时自行按累计宽度推进,
不走假设等宽的 distribute。未知 kind/缺参/target 缺失一律抛错,不兜底。
apply 是唯一触碰 SpriteManager 的三行封装。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 9: config 号段常量与资源常量
Files:
- Create:
client/js/01_SubGame/codes/config/Layers.js - Create:
client/js/01_SubGame/codes/config/Groups.js - Create:
client/js/01_SubGame/codes/config/ImageResources.js - Create:
client/js/01_SubGame/codes/config/SoundResources.js
Interfaces:
- Produces: 全局
EQW_Layers、EQW_Groups、EQW_Images、EQW_Sounds(均为扁平的键名: 数字常量对象)
内容一律照抄清单 §0.3(图层/群组表)与 §3(图片资源总表),不臆造条目、不改数值。 图片资源的每个条目必须按
ImageResources.template.js的要求注明用途 / 帧数 / 每帧含义 / 尺寸——后期就是照这些注释在编辑器里建资源。
- Step 1: 写
Layers.js
///////////////////////////////////////////////////////////////
////////// EQW_Layers: 图层 ID(清单 §0.3)////////////////////
///////////////////////////////////////////////////////////////
// 子游戏可用图层段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)
// 平台已占用 27/28/50/202/409/410/416/418/420/608/609/612/613/614/615/616/619/620,
// 均落在框架保留段,与下列不冲突。
var EQW_Layers = EQW_Layers || {
TABLE_STATIC: 101, // 牌桌常驻:顶部信息条、玩家位附加标记、主牌统计条、底栏
// ↑ 2026-08-27 改名「亮牌信息条」→「主牌统计条」:与 design §8.2 的「亮牌」同名异实,
// 见清单 §0.3 群组表下的修订说明
HAND: 102, // 自己手牌区(单排 / 埋牌双排)
TABLE_CARDS: 103, // 桌面牌:底牌区、三家已出牌区、埋牌底牌展示区
ACTION: 104, // 阶段操作区:叫分面板、选主/投降面板、埋牌/出牌操作条、倒计时
OVERLAY: 105, // 桌面浮层:提示气泡、报无主、甩错、捡分飘字、开底 3 秒
POPUP_ASET_RESULT: 302, // 小局结算
POPUP_ACCOUNT: 303, // 大局总结算 / 解散结算
POPUP_HISTORY: 304, // 出牌历史面板
POPUP_MINGPAI: 305, // 明牌面板
//建房规则选项区挂在平台创建房间界面 Layer 27 CreateRoom_Layer 上,不新开图层(清单 T-3)
PLATFORM_CREATE_ROOM: 27
};
- Step 2: 写
Groups.js
照清单 §0.3 群组表逐条录入(201–250),格式同上:每条一行注释说明用途。必须包含:
201 顶部信息条、202/203/204 左上家/右上家/自己的附加标记、205 底栏功能按钮组、206 主牌统计条(2026-08-27 由「亮牌信息条」改名,见清单 §0.3 群组表下的修订说明)、210 自己手牌、211 底牌区、212/213/214 自己/左上家/右上家已出牌区、215 埋牌底牌区、220 叫分面板、221 选主/投降面板、222 埋牌操作条、223 出牌操作条、224 倒计时、230 桌面浮层、240 小局结算、241 大局总结算、242 出牌历史、243 明牌面板、244 冲关牌型叠加层(清单 §5.6a)、250 建房规则选项。
- Step 3: 写
ImageResources.js
照清单 §3「图片资源总表」逐条录入(501 起)。每条的注释格式:
//用途:自己手牌与底牌区的牌面
//帧数:60(10列×6行,行优先)
//帧说明:帧1–13=黑桃A–K 帧14–26=红桃A–K 帧27–39=梅花A–K 帧40–52=方块A–K
// 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
// 二七王不引用帧 3,4,16,17,29,30,42,43(design §2 去掉 3 和 4),但资源照 60 帧通用排版出图
//尺寸:单帧 110×190,整图 1100×1140
CARD_FACE_L: 501,
三套牌面(CARD_FACE_L 501 / CARD_FACE_M 502 / CARD_FACE_S 503)的帧排列完全一致、只有尺寸不同(清单 §0.4)。其余按清单 §3 的号段分区录入:511–530 花色图标、531–570 按钮类、571–600 标记类、601–630 面板底图/光晕、631–650 数字与符号、651–680 提示气泡/报无主/甩错、681–700 建房选项控件。
- Step 4: 写
SoundResources.js
清单 §4 整节标注【待确认 T-20】(整套音效清单、是否需要语音包、是否分男女声、倒计时提示音从第几秒起均未定)。只建骨架与说明,不臆造条目:
///////////////////////////////////////////////////////////////
////////// EQW_Sounds: 声音资源 ID(清单 §4)//////////////////
///////////////////////////////////////////////////////////////
// 子游戏声音资源段:≥101(框架保留 1–100)。平台现占用 1–5。
//
// 【T-20 未决】整套音效尚未定案——清单 §4 整节标注待确认:
// 音效清单、是否需要语音包、是否分男女声、倒计时提示音从第几秒起。
// 定案前【不臆造条目】。届时在此按 SoundResources.template.js 的格式补充,
// 每条注明:用途 / 时长 / 是否循环。
var EQW_Sounds = EQW_Sounds || {
//待 T-20 定案后补充
};
- Step 5: 语法自检
Run: node -e "require('./client/js/01_SubGame/codes/config/Layers.js'); console.log('ok')"
(四个文件各跑一次;require 一个无导出的脚本只做语法检查,能打印 ok 即语法无误。)
Expected: 四次都打印 ok。
- Step 6: 提交
git add client/js/01_SubGame/codes/config/Layers.js client/js/01_SubGame/codes/config/Groups.js client/js/01_SubGame/codes/config/ImageResources.js client/js/01_SubGame/codes/config/SoundResources.js
git commit -F - <<'EOF'
二七王:前端图层/群组/图片/声音常量
照清单 §0.3 与 §3 逐条录入,图片资源每条注明用途/帧数/帧义/尺寸——
后期即照这些注释在编辑器里建资源。声音因 T-20 未决只建骨架、不臆造条目。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 10: config 精灵结构常量
Files:
- Create:
client/js/01_SubGame/codes/config/Sprites_Table.js(清单 §5.1) - Create:
client/js/01_SubGame/codes/config/Sprites_Cards.js(清单 §5.2 / §5.3) - Create:
client/js/01_SubGame/codes/config/Sprites_Action.js(清单 §5.4 / §5.5) - Create:
client/js/01_SubGame/codes/config/Sprites_Result.js(清单 §5.6 / §5.6a / §5.7a / §5.7b) - Create:
client/js/01_SubGame/codes/config/Sprites_CreateRoom.js(清单 §5.8)
Interfaces:
- Produces: 全局
EQW_Sprites,五个文件用保护性声明合并为一份。以 UI View 为组织单位——一个 View 将来对应一个BaseComponent组件(前端红线:每个界面的数据与渲染只由其对应 UI 组件负责):
EQW_Sprites.<ViewName> = {
Layer: <图层常量>, // 该 View 的图层;【一个 View 恰好一个图层】
<GroupName>: { // 一个群组容器:群组 ID + 它名下的全部精灵
id: <群组常量>,
<精灵键名>: <精灵ID>,
...
},
<另一个GroupName>: { id: ..., ... }
};
-
结构约定(
SpriteIndex与守卫测试据此机械区分):- View 内:键名
Layer是 View 级声明,且必须有且只有这一个图层——跨图层的界面拆成两个 View(一个 View 将来对应一个BaseComponent);其余键的值必须是对象,即一个 group 容器。View 内不允许出现别的标量键。 - group 容器内:键名
id是群组 ID;其余键一律是精灵 id(数字)。
- View 内:键名
-
精灵的所属群组由结构本身表达,注释里不必再重复写属于哪个 group;但仍要写类型(图片/文字)+ 用途 + 资源键 + 帧说明。
-
group 容器名用大驼峰、体现它是哪一块(
TopInfo、LeftMark、Footer、HandCards…),不必与群组常量同名。 -
键名与 ID 一律照抄清单 §5 各表,不改名、不改号(Task 11 的
SpriteIndex与 Task 12 的布局配置都按键名引用)。精灵键名全局唯一——索引器会把整棵树展平成一张扁平表,重名照样报错。 -
Step 1: 写
Sprites_Table.js
照清单 §5.1 表格逐条录入。文件头与结构:
///////////////////////////////////////////////////////////////
////////// EQW_Sprites: 精灵结构(清单 §5.1 牌桌常驻)/////////
///////////////////////////////////////////////////////////////
// 子游戏精灵段 1001–2999(段内仅 3000 被平台占用)。
// 以 UI View 为组织单位:Layer / Group 扁平声明在 View 下,
// 精灵只写 id,其所属 Layer/Group 在注释里说明。
// 每个精灵注明:类型(图片/文字)+ 用途 + 资源键 + 帧说明 + 所属群组——
// 后期即照这些注释在编辑器里逐个创建。
var EQW_Sprites = EQW_Sprites || {};
//顶部信息条:主 / 叫分 / 抓分 三列
EQW_Sprites.TopInfoView = {
Layer: EQW_Layers.TABLE_STATIC, //101
TopInfo: {
id: EQW_Groups.TOP_INFO, //201
TOP_INFO_BG: 1001, //图片:三列表格底,资源 PANEL_TOP_INFO
TOP_SUIT_ICON: 1002, //图片:「主」列花色,资源 SUIT_ICON_S,帧 = flower(1方块 2梅花 3红心 4黑桃)
TOP_CALL_TEXT: 1003, //文字:叫分数值
TOP_CALL_BADGE_BG: 1004, //图片:叫分角标底,资源 BADGE_MULTIPLE 帧1
TOP_CALL_BADGE_TEXT: 1005, //文字:「N子」
TOP_GRADE_TEXT: 1006, //文字:抓分数值
TOP_GRADE_BADGE_BG: 1007, //图片:抓分角标底,资源 BADGE_MULTIPLE 帧2
TOP_GRADE_BADGE_TEXT: 1008 //文字:「N倍」,取 Math.abs(curmultiple)(T-16/S-8)
}
};
//三家玩家位附加标记:跨三个群组,就是三个 group 容器
EQW_Sprites.PlayerMarkView = {
Layer: EQW_Layers.TABLE_STATIC, //101
LeftMark: {
id: EQW_Groups.P_LEFT_MARK, //202 左上家
P_LEFT_BANKER: 1100, //图片:「庄」印章,资源 MARK_BANKER
P_LEFT_ZHU_BG: 1101 //图片:「主N」角标底,资源 BADGE_ZHU_PAIR 帧1
// ... 其余按清单 §5.1 续
},
RightMark: {
id: EQW_Groups.P_RIGHT_MARK, //203 右上家
P_RIGHT_BANKER: 1110,
P_RIGHT_ZHU_BG: 1111
// ... 其余按清单 §5.1 续
}
// ... SelfMark(群组 204)同理
};
// ... 其余 View 按清单 §5.1 续:主牌统计条(群组 206,1030–1031,2026-08-27 由「亮牌条」
// 改名,见清单 §0.3 群组表下的修订说明)、底栏(群组 205,1040–1055)
清单 §5.1 里 1110–1115 P_RIGHT_*「结构同 202」是省略写法,必须展开写全:P_RIGHT_BANKER:1110、P_RIGHT_ZHU_BG:1111、P_RIGHT_ZHU_TEXT:1112、P_RIGHT_PAIR_BG:1113、P_RIGHT_PAIR_TEXT:1114、P_RIGHT_STATUS_TEXT:1115。
- Step 2: 写
Sprites_Cards.js
照清单 §5.2 / §5.3。手牌 36 张(HAND_CARD_1..36 = 1200–1235)、手牌标记 8 个(HAND_MARK_1..8 = 1236–1243)、底牌 8 张(1250–1257)、埋牌底牌 8 张(1258–1265)、三家已出牌区(自己 1300–1329、左上家 1400–1429、右上家 1500–1529,各 28 张牌 + 牌型标签底 + 标签文字)。
连号精灵逐条写全,不用循环生成——常量文件是纯数据,且要能被人逐条对照编辑器核对。
- Step 3: 写
Sprites_Action.js
照清单 §5.4(群组 220 叫分面板 1600–1649、221 选主/投降 1650–1699、222/223 埋牌与出牌操作条 1700–1749、224 倒计时)与 §5.5(群组 230 桌面浮层 1750–1799)。
- Step 4: 写
Sprites_Result.js
照清单 §5.6(群组 240 小局结算 1800–1819)、§5.6a(群组 244 冲关牌型叠加层 1820–1829,精灵复制:遮罩 1 + 容器 3 + 模板 1 + 奖数 3)、§5.7a(群组 241 大局总结算 1900–1949)、§5.7b(群组 242/243 出牌历史与明牌 1950–2049)。
- Step 5: 写
Sprites_CreateRoom.js
照清单 §5.8(群组 250,2050–2099,预置 20 个:3 类别标题 + 8 选项 × 2 + 房卡附注)。
- Step 6: 语法自检
Run: node client/tests/run.js
Expected: 已有用例仍全绿(本任务未动被测模块)。语法错误会在 Task 11 的 SpriteIndex 测试里暴露,此处先确认没破坏既有测试。
- Step 7: 提交
git add client/js/01_SubGame/codes/config/Sprites_Table.js client/js/01_SubGame/codes/config/Sprites_Cards.js client/js/01_SubGame/codes/config/Sprites_Action.js client/js/01_SubGame/codes/config/Sprites_Result.js client/js/01_SubGame/codes/config/Sprites_CreateRoom.js
git commit -F - <<'EOF'
二七王:前端精灵结构常量
照清单 §5.1–§5.8 逐条录入,键名与 ID 原样照抄;省略写法(如「结构同 202」)
全部展开写全。每个精灵注明类型/用途/资源键/帧说明,后期照此在编辑器里创建。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 11: core/SpriteIndex.js(精灵键名索引)
Files:
- Create:
client/js/01_SubGame/codes/core/SpriteIndex.js - Test:
client/tests/test_spriteindex.js
Interfaces:
- Consumes:
EQW_Sprites(Task 10) - Produces: 全局
EQW_SpriteIndex,含build(spriteTree)→{ 键名: 精灵ID }buildGroupMap(spriteTree)→{ 键名: 群组ID }init(spriteTree)(默认用EQW_Sprites建好两张内部索引)idOf(key)→number(查不到抛错)groupIdOf(key)→number(查不到抛错)
布局配置里
attach.target写的是键名而非数字 ID(清单 §6.1:「target一律写键名,ID 回填时只改一处映射表」),而精灵常量是按 View → group 组织的,故需要这张扁平索引。groupIdOf是新结构带来的收益:group 容器把精灵与它的群组 ID 绑在一起,于是「某个精灵属于哪个群组」可以机械查出——显隐走群组时(前端红线:显隐由组件的showXxx/hideXxx控制)不必再手工对照。 结构约定:View 内Layer是 View 级声明、跳过;一个 View 恰好一个图层(跨图层的界面拆成两个 View);其余键的值必须是对象(group 容器),否则报错。group 容器内id是群组 ID、跳过;其余键一律是精灵 id,值必须是数字,否则报错。 重复键必须报错而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。
- Step 1: 写失败的测试
client/tests/test_spriteindex.js
// View → group → 精灵 的常量树 → 扁平键名索引
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/core/SpriteIndex.js');
// ---- 展平:Layer 与 group 的 id 都不进索引,精灵进 ----
const tree = {
TopInfoView: {
Layer: 101,
TopInfo: { id: 201, BTN_A: 1001, BTN_B: 1002 }
},
PlayerMarkView: {
Layer: 101,
LeftMark: { id: 202, P_LEFT_BANKER: 1100 },
RightMark: { id: 203, P_RIGHT_BANKER: 1110 }
}
};
t.eq('展平结果', EQW_SpriteIndex.build(tree),
{ BTN_A: 1001, BTN_B: 1002, P_LEFT_BANKER: 1100, P_RIGHT_BANKER: 1110 });
// ---- Layer 与 group 的 id 不进索引 ----
const flat = EQW_SpriteIndex.build(tree);
t.eq('Layer 不进索引', flat.Layer, undefined);
t.eq('group 的 id 不进索引', flat.id, undefined);
// ---- 群组映射:精灵 → 它所属的群组 ID ----
t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree),
{ BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 });
// ---- 反面:一个 View 恰好一个 Layer;Layer1/Layer2 这种多图层写法要报错 ----
// (跨图层的界面应拆成两个 View,各带自己的 Layer——一个 View 将来对应一个 BaseComponent)
const multiLayer = {
OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } }
};
t.eq('多 Layer 写法报错', throws(() => EQW_SpriteIndex.build(multiLayer)), true);
// ---- 反面:View 缺 Layer 声明要报错 ----
const noLayer = { ViewA: { G1: { id: 201, BTN: 1001 } } };
t.eq('View 缺 Layer 报错', throws(() => EQW_SpriteIndex.build(noLayer)), true);
// ---- 反面:重复键报错,不静默覆盖 ----
const dupTree = {
ViewA: { Layer: 101, G1: { id: 201, SAME: 1001 } },
ViewB: { Layer: 102, G2: { id: 202, SAME: 1002 } }
};
t.eq('重复键报错', throws(() => EQW_SpriteIndex.build(dupTree)), true);
// ---- 反面:精灵值不是数字要报错 ----
const badValue = { ViewA: { Layer: 101, G1: { id: 201, NESTED: { x: 1 } } } };
t.eq('精灵值非数字报错', throws(() => EQW_SpriteIndex.build(badValue)), true);
// ---- 反面:View 下混入标量键(不是 group 容器)要报错 ----
// 例如把群组 ID 误写成 View 级的 `Group: 201`,而不是包进 group 容器
const strayScalar = { ViewA: { Layer: 101, Group: 201, G1: { id: 202, BTN: 1001 } } };
t.eq('View 下混入标量键报错', throws(() => EQW_SpriteIndex.build(strayScalar)), true);
// ---- 反面:group 容器缺 id 要报错 ----
const noGroupId = { ViewA: { Layer: 101, G1: { BTN: 1001 } } };
t.eq('group 缺 id 报错', throws(() => EQW_SpriteIndex.build(noGroupId)), true);
// ---- idOf / groupIdOf:查得到 / 查不到抛错 ----
EQW_SpriteIndex.init(tree);
t.eq('idOf 查到', EQW_SpriteIndex.idOf('BTN_A'), 1001);
t.eq('groupIdOf 查到', EQW_SpriteIndex.groupIdOf('P_RIGHT_BANKER'), 203);
t.eq('idOf 查不到抛错', throws(() => EQW_SpriteIndex.idOf('NOT_EXIST')), true);
t.eq('groupIdOf 查不到抛错', throws(() => EQW_SpriteIndex.groupIdOf('NOT_EXIST')), true);
// ---- 空树 ----
t.eq('空树', EQW_SpriteIndex.build({}), {});
// ---- 真实常量:EQW_Sprites 能完整建索引且无重复键 ----
load('client/js/01_SubGame/codes/config/Layers.js');
load('client/js/01_SubGame/codes/config/Groups.js');
load('client/js/01_SubGame/codes/config/Sprites_Table.js');
load('client/js/01_SubGame/codes/config/Sprites_Cards.js');
load('client/js/01_SubGame/codes/config/Sprites_Action.js');
load('client/js/01_SubGame/codes/config/Sprites_Result.js');
load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js');
let realOk = true;
let realIndex = {};
try { realIndex = EQW_SpriteIndex.build(EQW_Sprites); } catch (e) { realOk = false; console.log('建索引失败: ' + e.message); }
t.eq('真实精灵常量无重复键', realOk, true);
t.eq('真实精灵数量 > 200', Object.keys(realIndex).length > 200, true);
process.exit(t.done('spriteindex') ? 0 : 1);
- Step 2: 运行,确认失败
Run: node client/tests/test_spriteindex.js
Expected: EQW_SpriteIndex is not defined。
- Step 3: 写实现
client/js/01_SubGame/codes/core/SpriteIndex.js
///////////////////////////////////////////////////////////////
////////// EQW_SpriteIndex: 精灵键名 → ID 扁平索引 /////////////
///////////////////////////////////////////////////////////////
// 精灵常量以 UI View 为组织单位,View 下是一个个 group 容器:
// EQW_Sprites.XxxView = { Layer: 图层, GroupName: { id: 群组, 精灵键: id, ... } }
// 而布局配置里 attach.target 写的是【键名】(清单 §6.1:ID 回填时只改一处
// 映射表),故需把这棵树展平成 { 键名: 精灵ID } 供求解器查用;
// group 容器把精灵与群组 ID 绑在一起,顺带能给出 { 键名: 群组ID }。
// 【显式失败】重复键、值非数字、结构不合法、查不到的键一律抛错——静默覆盖
// 会让某个界面指向另一个界面的精灵,且极难排查(工程总则 §7)。
var EQW_SpriteIndex = EQW_SpriteIndex || {
_index: null,
_groupMap: null,
//遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId)
//【不变式】一个 View 恰好一个图层:View 必须有数字型的 Layer 键。
//跨图层的界面应拆成两个 View(各带自己的 Layer)——一个 View 将来对应一个
//BaseComponent 组件,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」
//只能靠注释隐含、无法机械判定。
_walk: function (spriteTree, fn) {
for (var viewName in spriteTree) {
if (!spriteTree.hasOwnProperty(viewName)) { continue; }
var view = spriteTree[viewName];
if (typeof view.Layer !== 'number') {
throw new Error('[EQW_SpriteIndex] View ' + viewName +
' 缺少数字型的 Layer(一个 View 恰好一个图层;跨图层请拆成两个 View)');
}
for (var groupName in view) {
if (!view.hasOwnProperty(groupName)) { continue; }
if (groupName === 'Layer') { continue; } //View 级图层声明
var group = view[groupName];
if (!group || typeof group !== 'object') {
throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName +
' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer');
}
if (typeof group.id !== 'number') {
throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName +
' 缺少数字型的 id(群组 ID)');
}
for (var key in group) {
if (!group.hasOwnProperty(key)) { continue; }
if (key === 'id') { continue; } //群组 ID
if (typeof group[key] !== 'number') {
throw new Error('[EQW_SpriteIndex] 精灵 ' + key + ' 的值不是数字(' +
viewName + '.' + groupName + ')');
}
fn(key, group[key], group.id, viewName, groupName);
}
}
}
},
//展平成 { 键名: 精灵ID }
build: function (spriteTree) {
var index = {};
this._walk(spriteTree, function (key, spriteId, groupId, viewName, groupName) {
if (index.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 精灵键名重复: ' + key +
'(' + viewName + '.' + groupName + ' 与更早的定义冲突)');
}
index[key] = spriteId;
});
return index;
},
//展平成 { 键名: 群组ID },供「显隐走群组」时查精灵属于哪个群组
buildGroupMap: function (spriteTree) {
var map = {};
this._walk(spriteTree, function (key, spriteId, groupId) {
map[key] = groupId;
});
return map;
},
//用给定的树(默认 EQW_Sprites)建立两张内部索引
init: function (spriteTree) {
var tree = spriteTree || EQW_Sprites;
this._index = this.build(tree);
this._groupMap = this.buildGroupMap(tree);
return this._index;
},
//按键名取精灵 ID;查不到抛错,绝不返回 undefined 让 SpriteManager 静默无效
idOf: function (key) {
if (!this._index) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); }
if (!this._index.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key);
}
return this._index[key];
},
//按键名取它所属的群组 ID
groupIdOf: function (key) {
if (!this._groupMap) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); }
if (!this._groupMap.hasOwnProperty(key)) {
throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key);
}
return this._groupMap[key];
}
};
- Step 4: 运行,确认通过
Run: node client/tests/test_spriteindex.js
Expected: 全 PASS。若「真实精灵常量无重复键」失败,按报错信息回 Task 10 的常量文件改键名(改常量、不改索引器——重复键本身就是常量层的错)。
- Step 5: 提交
git add client/js/01_SubGame/codes/core/SpriteIndex.js client/tests/test_spriteindex.js
git commit -F - <<'EOF'
二七王:前端 SpriteIndex,精灵键名扁平索引
布局配置按键名引用精灵,本模块把嵌套的精灵常量展平供查。
重复键与未知键一律抛错——静默覆盖会让界面指向别处的精灵且极难排查。
用例含真实常量的全量建索引校验。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 12: config 布局与动画常量
Files:
- Create:
client/js/01_SubGame/codes/config/LayoutConstants.js(清单 §6.3 / §6.4 + 座位键) - Create:
client/js/01_SubGame/codes/config/Layout_Table.js(清单 §6.5 + §6.6) - Create:
client/js/01_SubGame/codes/config/Layout_Cards.js(清单 §6.7) - Create:
client/js/01_SubGame/codes/config/Layout_Action.js(清单 §6.8) - Create:
client/js/01_SubGame/codes/config/Layout_Result.js(清单 §6.9) - Create:
client/js/01_SubGame/codes/config/Layout_CreateRoom.js(清单 §6.9b + §1.1 的EQW_RoomOptions) - Create:
client/js/01_SubGame/codes/config/AnimConstants.js
Interfaces:
- Produces: 全局
EQW_Layout(六个文件保护性声明合并)、EQW_RoomOptions、EQW_Anim - 每个布局节点的字段名与
EQW_LayoutSolver.solve的node一一对应(Task 8),可原样喂入
布局文件是纯数据:不得出现函数、不得引用其他模块、不得有副作用(client 02 §2、清单 §6.10)。 数值一律照抄清单 §6 各表(参考图 1280×720 目视实测估值),不自行调整。
- Step 1: 写
LayoutConstants.js
///////////////////////////////////////////////////////////////
////////// EQW_Layout: 布局配置总则(清单 §6)/////////////////
///////////////////////////////////////////////////////////////
// 设计基准 1280×720,ScreenFitMode 1,30fps(清单 §0.1)。
// 【纯数据文件】无函数、无副作用、不引用其他模块——
// fan 的间距算法、bySeat 合并、attach 换算都在 codes/ui/LayoutSolver.js。
// 数值为参考图目视实测估值(±5px),设计稿到位后只改本层。
var EQW_Layout = EQW_Layout || {};
//座位键(显示位,非服务端座位号;换算见 core/SeatMap.js)
EQW_Layout.SEAT = { SELF: 'SELF', LEFT: 'LEFT', RIGHT: 'RIGHT' };
//三套牌面尺寸档(清单 §6.4)。改牌面尺寸只动这一处,引用它的区域自动跟随
EQW_Layout.CARD_SIZE = {
L: { w: 110, h: 190, res: 'CARD_FACE_L' }, //手牌、底牌、冲关牌
M: { w: 90, h: 155, res: 'CARD_FACE_M' }, //已出牌区
S: { w: 50, h: 70, res: 'CARD_FACE_S' } //结算埋牌底牌
};
//文字样式预设(清单 §6.3)
EQW_Layout.TEXT_STYLE = {
STATUS_BIG: { fontSize: 46, color: '#FFFFFF', align: 'center' }, //玩家位「60分」「不叫」
CALL_BIG: { fontSize: 52, color: '#FFD24A', align: 'center' }, //自己叫分大字
SCORE_BIG: { fontSize: 56, color: '#FFFFFF', align: 'center' }, //结算总分大字
ASET_COUNT: { fontSize: 20, color: '#FFFFFF', align: 'center' }, //底栏「1/4 局」
ROOM_OPT: { normal: { fontSize: 18, color: '#DDDDDD' }, //建房选项文字(清单 §6.9b)
selected: { fontSize: 18, color: '#E8873A' } },
ROOM_CAT: { fontSize: 18, color: '#7FBFB0', bold: true, align: 'right', maxWidth: 60 }
};
其余文字样式按清单 §6.5–§6.9 各表中出现的 textStyle{...} 补齐。
- Step 2: 写
Layout_Table.js
照清单 §6.5(顶部信息条、主牌统计条【2026-08-27 由「亮牌条」改名,见清单 §0.3 群组表下的修订说明】、底栏、庄标、提示按钮组、平台语音/聊天钮坐标)与 §6.6(头像框 bySeat、庄印章、主N/对N 角标、状态文字、倒计时、提示气泡朝向)逐条录入。示例:
var EQW_Layout = EQW_Layout || {};
//顶部信息条底(清单 §6.5)
EQW_Layout.TOP_INFO_BG = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
//顶部 · 花色图标:贴在信息条内部左下
EQW_Layout.TOP_SUIT_ICON = { kind: 'attach', target: 'TOP_INFO_BG',
hAlign: 'left', vAlign: 'bottom', offsetX: 20, offsetY: -8, w: 32, h: 32 };
//底栏功能钮组(明牌/已出牌/上一轮/底牌):逐项不等宽(清单 §6.1 T-25)
EQW_Layout.FOOTER_FUNC_BUTTONS = {
kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
itemHeight: 30, spacing: 15, anchor: 'right',
items: [ { key: 'BTN_MINGPAI', width: 74 }, { key: 'BTN_HISTORY', width: 87 },
{ key: 'BTN_LAST_ROUND', width: 93 }, { key: 'BTN_BOTTOM_CARDS', width: 78 } ]
};
//提示气泡朝向是【配置项】,不在代码里按座位硬判(清单 §6.6)
//帧号 = (arrowRight ? 3 : 0) + tip,tip:1踩 2没分 3有分
EQW_Layout.TISHI_BUBBLE = {
kind: 'attach', w: 96, h: 44,
bySeat: {
LEFT: { target: 'P_LEFT_AVATAR', hAlign: 'right', vAlign: 'middle', offsetX: 10, arrowRight: false },
RIGHT: { target: 'P_RIGHT_AVATAR', hAlign: 'left', vAlign: 'middle', offsetX: -10, arrowRight: true },
SELF: { target: 'P_SELF_AVATAR', hAlign: 'right', vAlign: 'top', offsetX: 10, offsetY: -70, arrowRight: false }
}
};
- Step 3: 写
Layout_Cards.js
照清单 §6.7:手牌单排(fan,spacingMin:-78)、手牌双排(上下两个 fan,spacingMin:-65,分行策略 floor(n/2) / ceil(n/2),见 T-27)、选中上浮 selectedOffsetY:-40、底牌区(line,spacing -36)、埋牌底牌区(line,size S,spacing 2)、三家已出牌区(fan + bySeat,LEFT 为 overlapFrom:'right' 镜像)、牌型标签与牌角标(attach)、冲关牌叠加层(fan,spacingMin:-88,bySeat 三处锚点)。
分行策略是数据,写成 EQW_Layout.HAND_TWO_ROW.splitStrategy = 'floorTop' 之类的标记,由 B/C 阶段的组件按它分行——不要在布局文件里写函数。
- Step 4: 写
Layout_Action.js
照清单 §6.8:叫分面板底/提示条/14 档 grid/档位分数与角标 attach/不叫按钮/自己叫分大字;选主标题条/4 花色+投降 line/花色图标/张数大字/对数角标;埋牌操作条与出牌操作条(line + items);捡分飘字;两种状态提示条。
- Step 5: 写
Layout_Result.js
照清单 §6.9:底牌面板底与说明文字、三家分数光晕(bySeat)、总分大字、牌局分/冲关分、冲关牌型按钮、下一局按钮、冲关遮罩(含 opacity:0.72——透明度也是配置项)。
- Step 6: 写
Layout_CreateRoom.js
照清单 §6.9b 的两层嵌套布局(类别竖排 line + 类别内选项槽横排 line、groupGap:40、maxPerRow:4 折行走 grid),外加清单 §1.1 的选项配置数据:
//建房规则选项(清单 §1.1)。整个界面由这份配置驱动渲染,
//加一个选项 = 配置里加一条,不改渲染逻辑(工程总则 §5 OCP / §6 配置优先)。
//type: 'radio' 单选 / 'checkbox' 多选 / 'radioOptional' 单选可不选
//选择框帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0),useCheckbox = (checkbox 或 radioOptional)
//【roomtype 拼串只在一处实现】每个选项自带 bit 与 value,拼串就是遍历本配置,
//不得在别处按下标硬拼(与服务端 class.config.js parse() 对称的 SSOT 要求)
var EQW_RoomOptions = {
categories: [
{ titleFrame: 1, /* 牌局 */ groups: [
{ key: 'aset', type: 'radio', bit: 0,
items: [ { label: '6局', value: '0' }, { label: '12局', value: '1' } ] },
{ key: 'card', type: 'radio', bit: 1, note: 'cardCost',
items: [ { label: '房主扣卡', value: '0' }, { label: 'AA每人扣卡', value: '1' } ] }
] },
{ titleFrame: 2, /* 模式 */ groups: [
{ key: 'peek', type: 'radio', bit: 4,
items: [ { label: '可查牌', value: '0' }, { label: '不查牌', value: '1' } ] }
] },
{ titleFrame: 3, /* 规则 */ groups: [
{ key: 'rules', type: 'checkbox',
items: [ { label: '傍王', bit: 2 }, { label: '爬坡', bit: 3 } ] }
] }
],
//房卡消耗联动(协议 §0.5):不硬编码在 UI 代码里
cardCost: {
'0': { '0': '房主2张', '1': '房主4张' }, //房主扣卡:6局 / 12局
'1': { '0': '每人1张', '1': '每人2张' } //AA 每人扣卡:6局 / 12局
}
};
- Step 7: 写
AnimConstants.js
///////////////////////////////////////////////////////////////
////////// EQW_Anim: 动画与时长参数(纯数据)//////////////////
///////////////////////////////////////////////////////////////
// client 02 §3「数据优先、表现延后」:动画只是体验层,
// 其开始/结束/出错回调里只刷界面、绝不写核心数据。
// 动画缺失或卡住时,数据与界面仍须正确。
var EQW_Anim = EQW_Anim || {
//发牌铺开:从右往左(清单 §1.2 T-4)
DEAL: { totalDuration: 800, perCardDelay: 20, direction: 'rightToLeft' },
//手牌选中上浮(清单 §6.7)
HAND_SELECT_OFFSET_Y: -40,
//捡分飘字(清单 §6.8)
SCORE_FLOAT: { rise: -60, duration: 800 },
//即时反馈提示条自动消失(清单 §6.8)
TOAST_DURATION: 1500,
//亮牌面板自动关闭(清单 §1.6 T-34:只按时长关闭,不做点击关闭)
LIANGPAI_AUTO_CLOSE: 3000,
//开底:70 分坐庄时 8 张底牌向全场翻开 3 秒(design §4 / 清单 §1.4 D-2,不要倒计时)
ANCARD_REVEAL_DURATION: 3000
//【D-8 待补】判定结果动画(大光/小光/过庄/升N级/投降)——
//已定形态:不做结算面板上的静态文字,改为动画,在对局过程中与结算前播放;
//靠 curmultiple 的【符号】分辨该播哪个(3/2/1 庄家大光/小光/过庄,-N 闲家升N级)。
//缺动画设计稿与触发细节,定案后在此补参数。
};
- Step 8: 语法自检
Run: node client/tests/run.js
Expected: 既有用例仍全绿。
- Step 9: 提交
git add client/js/01_SubGame/codes/config/LayoutConstants.js client/js/01_SubGame/codes/config/Layout_Table.js client/js/01_SubGame/codes/config/Layout_Cards.js client/js/01_SubGame/codes/config/Layout_Action.js client/js/01_SubGame/codes/config/Layout_Result.js client/js/01_SubGame/codes/config/Layout_CreateRoom.js client/js/01_SubGame/codes/config/AnimConstants.js
git commit -F - <<'EOF'
二七王:前端布局与动画常量
照清单 §6 各表逐条录入,字段名与 LayoutSolver 的 node 一一对应、可原样喂入。
布局文件保持纯数据:无函数、无副作用;分行策略等写成数据标记而非函数。
建房选项配置自带 bit 与 value,roomtype 拼串遍历配置即可,不按下标硬拼。
动画参数含 D-8 判定动画的待补说明。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 13: test_constants.js(常量层机械守卫)
Files:
- Test:
client/tests/test_constants.js
Interfaces:
- Consumes: 全部
config/*与EQW_SpriteIndex
常量层有几百个 ID,人工核对不可靠。这条测试把清单 §0.3 的号段规划与前端红线变成机械校验。
- Step 1: 写测试
client/tests/test_constants.js
// 常量层机械守卫:号段合规、ID 不重复、布局引用可解析
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/config/Layers.js');
load('client/js/01_SubGame/codes/config/Groups.js');
load('client/js/01_SubGame/codes/config/ImageResources.js');
load('client/js/01_SubGame/codes/config/SoundResources.js');
load('client/js/01_SubGame/codes/config/Sprites_Table.js');
load('client/js/01_SubGame/codes/config/Sprites_Cards.js');
load('client/js/01_SubGame/codes/config/Sprites_Action.js');
load('client/js/01_SubGame/codes/config/Sprites_Result.js');
load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js');
load('client/js/01_SubGame/codes/config/LayoutConstants.js');
load('client/js/01_SubGame/codes/config/Layout_Table.js');
load('client/js/01_SubGame/codes/config/Layout_Cards.js');
load('client/js/01_SubGame/codes/config/Layout_Action.js');
load('client/js/01_SubGame/codes/config/Layout_Result.js');
load('client/js/01_SubGame/codes/config/Layout_CreateRoom.js');
load('client/js/01_SubGame/codes/config/AnimConstants.js');
load('client/js/01_SubGame/codes/core/SpriteIndex.js');
// ---- 图层号段:101–200 / 301–400 / 501–600 / 701+(client 02 §1)----
// 平台图层 27(建房)是引用而非子游戏自建,单独排除
const layerVals = Object.keys(EQW_Layers).filter(k => k !== 'PLATFORM_CREATE_ROOM').map(k => EQW_Layers[k]);
const layerOk = layerVals.every(v =>
(v >= 101 && v <= 200) || (v >= 301 && v <= 400) || (v >= 501 && v <= 600) || v >= 701);
t.eq('图层落在子游戏段', layerOk, true);
t.eq('图层 27 是平台的建房界面', EQW_Layers.PLATFORM_CREATE_ROOM, 27);
// ---- 群组 ≥201 ----
const groupVals = Object.keys(EQW_Groups).map(k => EQW_Groups[k]);
t.eq('群组均 >= 201', groupVals.every(v => v >= 201), true);
// ---- 图片资源 ≥501;声音 ≥101 ----
const imgVals = Object.keys(EQW_Images).map(k => EQW_Images[k]);
t.eq('图片资源均 >= 501', imgVals.every(v => v >= 501), true);
const soundVals = Object.keys(EQW_Sounds).map(k => EQW_Sounds[k]);
t.eq('声音资源均 >= 101', soundVals.every(v => v >= 101), true); // T-20 未定时为空数组,恒真
// ---- 精灵 ID 1001–2999(段内 3000 已被平台占用,清单 §0.3)----
const index = EQW_SpriteIndex.build(EQW_Sprites);
const spriteVals = Object.keys(index).map(k => index[k]);
t.eq('精灵数量 > 200', spriteVals.length > 200, true);
const spriteOk = spriteVals.every(v => v >= 1001 && v <= 2999);
if (!spriteOk) { console.log('越界精灵: ' + JSON.stringify(spriteVals.filter(v => v < 1001 || v > 2999))); }
t.eq('精灵落在 1001-2999', spriteOk, true);
// ---- 各类 ID 全局不重复 ----
function dupsOf(vals) {
const seen = {}, dups = [];
vals.forEach(v => { if (seen[v]) { dups.push(v); } seen[v] = true; });
return dups;
}
t.eq('精灵 ID 无重复', dupsOf(spriteVals), []);
t.eq('图层 ID 无重复', dupsOf(layerVals), []);
t.eq('群组 ID 无重复', dupsOf(groupVals), []);
t.eq('图片资源 ID 无重复', dupsOf(imgVals), []);
// ---- 布局配置里每个 attach.target 都能解析 ----
// target 可指向三类之一:子游戏精灵键名 / EQW_Layout 的布局节点名 / 平台精灵键名
// (平台头像框落在框架保留段 1–1000,独立登记、不参与子游戏号段校验)
const resolvable = {};
Object.keys(index).forEach(k => { resolvable[k] = true; });
Object.keys(EQW_Layout).forEach(k => { resolvable[k] = true; });
Object.keys(EQW_PlatformSprites).forEach(k => { resolvable[k] = true; });
// 收集所有布局节点,供解析检查与求解冒烟共用
const allNodes = []; // { path, node }
function collectNodes(node, path) {
if (!node || typeof node !== 'object' || Array.isArray(node)) { return; }
if (node.kind) { allNodes.push({ path, node }); }
Object.keys(node).forEach(k => {
if (k !== 'bySeat' && node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) {
collectNodes(node[k], path + '.' + k);
}
});
}
Object.keys(EQW_Layout).forEach(k => collectNodes(EQW_Layout[k], k));
const missing = [];
allNodes.forEach(entry => {
const targets = [];
if (entry.node.target) { targets.push([entry.path, entry.node.target]); }
if (entry.node.bySeat) {
Object.keys(entry.node.bySeat).forEach(s => {
const v = entry.node.bySeat[s];
if (v && v.target) { targets.push([entry.path + '.bySeat.' + s, v.target]); }
});
}
targets.forEach(pair => { if (!resolvable[pair[1]]) { missing.push(pair[0] + ' -> ' + pair[1]); } });
});
if (missing.length) { console.log('无法解析的 attach.target:
' + missing.join('
')); }
t.eq('attach.target 全部可解析', missing, []);
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 w/h、kind 不认识、
// bySeat 缺座位,全都会在这里当场抛错,而不是等到界面上静默摆歪。
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
// 合成一个万能 ctx:所有可解析的键都给一个矩形,count 给一个合理值
const fakeRects = {};
Object.keys(resolvable).forEach(k => { fakeRects[k] = { x: 100, y: 100, width: 80, height: 40 }; });
const solveFailures = [];
allNodes.forEach(entry => {
// 有 bySeat 的按三个显示位各求一次,没有的求一次
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
seats.forEach(seat => {
const ctx = { rects: fakeRects, count: 8 };
if (seat) { ctx.seat = seat; }
// 无 target 的 attach 模板节点:运行时由调用方注入目标,这里补一个假目标
const seatTarget = seat && entry.node.bySeat[seat] ? entry.node.bySeat[seat].target : null;
if (entry.node.kind === 'attach' && !entry.node.target && !seatTarget) {
ctx.target = Object.keys(fakeRects)[0];
}
const label = entry.path + (seat ? '[' + seat + ']' : '');
try {
const r = EQW_LayoutSolver.solve(entry.node, ctx);
if (!Array.isArray(r)) { solveFailures.push(label + ' -> 返回值不是数组'); return; }
r.forEach((rect, i) => {
if (typeof rect.x !== 'number' || isNaN(rect.x) ||
typeof rect.y !== 'number' || isNaN(rect.y)) {
solveFailures.push(label + ' -> 第' + i + '项坐标非法: ' + JSON.stringify(rect));
}
});
} catch (e) {
solveFailures.push(label + ' -> ' + e.message);
}
});
});
if (solveFailures.length) { console.log('求解失败的节点:
' + solveFailures.join('
')); }
t.eq('全部布局节点可求解且坐标合法', solveFailures, []);
t.eq('布局节点数量 > 40', allNodes.length > 40, true);
// ---- 布局文件是纯数据:不含函数 ----
const funcs = [];
function checkPure(node, path) {
if (!node || typeof node !== 'object') { return; }
Object.keys(node).forEach(k => {
const v = node[k];
if (typeof v === 'function') { funcs.push(path + '.' + k); }
else if (v && typeof v === 'object') { checkPure(v, path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkPure(EQW_Layout[k], k));
t.eq('布局配置不含函数', funcs, []);
// ---- 每个布局节点的 kind 合法 ----
const badKinds = [];
function checkKind(node, path) {
if (!node || typeof node !== 'object') { return; }
if (node.kind && ['point', 'line', 'fan', 'grid', 'attach'].indexOf(node.kind) < 0) {
badKinds.push(path + ' -> ' + node.kind);
}
Object.keys(node).forEach(k => {
if (node[k] && typeof node[k] === 'object' && !Array.isArray(node[k])) { checkKind(node[k], path + '.' + k); }
});
}
Object.keys(EQW_Layout).forEach(k => checkKind(EQW_Layout[k], k));
t.eq('布局 kind 全部合法', badKinds, []);
// ---- 牌尺寸档引用的资源键存在 ----
['L', 'M', 'S'].forEach(size => {
t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true);
});
process.exit(t.done('constants') ? 0 : 1);
- Step 2: 运行
Run: node client/tests/test_constants.js
Expected: 全 PASS。若「attach.target 全部可解析」失败,控制台会列出具体路径——回去改常量文件(多半是布局里写的键名与精灵常量的键名不一致),不要放宽这条断言。
- Step 3: 跑全套
Run: node client/tests/run.js
Expected: 末行 ===== 全部单测通过 =====。
- Step 4: 提交
git add client/tests/test_constants.js
git commit -F - <<'EOF'
二七王:常量层机械守卫测试
把清单 §0.3 的号段规划与前端红线变成机械校验:号段合规、ID 全局不重复、
布局里每个 attach.target 都能解析、布局配置不含函数、kind 合法。
几百个 ID 人工核对不可靠,这条守卫替代人眼。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
Task 14: 平台接入骨架与浏览器验证
Files:
- Create:
client/js/01_SubGame/codes/SubGameHooks.js - Modify:
client/index.html(加 codes 加载段)
Interfaces:
- Produces: 全局
SubGameHooks(本阶段全为空函数,B/F 阶段填充)
三个转发壳
00_/01_/02_SubGame_*.js本阶段一律不碰(client 06 §1 硬规则)。 加载顺序(client 06 §3):依赖模块 →SubGameHooks→ 三文件转发壳,顺序即依赖,不可颠倒。
- Step 1: 创建
SubGameHooks.js
从 client/js/gameabc-framework/templates/subgame-entry/SubGameHooks.template.js 复制到 client/js/01_SubGame/codes/SubGameHooks.js,改文件头注释说明这是二七王的实现骨架,各 hook 保持空函数 + 用途注释不变。本阶段不填任何实现(无 hook 时转发壳会走 A 类 no-op / B 类平台默认 / D 类模板默认渲染,平台照常运行)。
- Step 2: 改
client/index.html
在现有的 js/01_SubGame/00_SubGame_Config.js 那三行之前插入 codes 加载段(现有三行位置不变):
<!-- 阶段5: 二七王子游戏实现层 -->
<!-- 5.1 配置常量(纯数据,无依赖,最先加载) -->
<script type="text/javascript" src="js/01_SubGame/codes/config/Layers.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Groups.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/ImageResources.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/SoundResources.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Table.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Cards.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Action.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_Result.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Sprites_CreateRoom.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/LayoutConstants.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Table.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Cards.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Action.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_Result.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/Layout_CreateRoom.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/config/AnimConstants.js"></script>
<!-- 5.2 前后端共享算法(只读副本,改动落在服务端 shared/,跑根目录 sync_shared.cmd 同步) -->
<script type="text/javascript" src="js/01_SubGame/codes/shared/cards.js"></script>
<!-- 5.3 纯逻辑核心 -->
<script type="text/javascript" src="js/01_SubGame/codes/core/CardCodec.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/SeatMap.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/CardOrder.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/CardMark.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/core/SpriteIndex.js"></script>
<!-- 5.4 布局求解 -->
<script type="text/javascript" src="js/01_SubGame/codes/ui/LayoutSolver.js"></script>
<!-- 5.5 平台接入(须在依赖之后、三文件转发壳之前) -->
<script type="text/javascript" src="js/01_SubGame/codes/SubGameHooks.js"></script>
- Step 3: 浏览器验证
Run: npx http-server client -p 8080(另开一个终端),浏览器打开 http://localhost:8080/index.html,看控制台。
Expected: 无报错。在控制台逐个求值确认已就绪:
[EQW_Layers, EQW_Groups, EQW_Images, EQW_Sounds, EQW_Sprites, EQW_Layout,
EQW_RoomOptions, EQW_Anim, EQW_CardCodec, EQW_SeatMap, EQW_CardOrder,
EQW_CardMark, EQW_SpriteIndex, EQW_LayoutSolver, youle_erqiwang_shared_cards,
SubGameHooks].every(x => !!x)
// → true
EQW_CardCodec.cardIdToFrame(39) // → 1(黑桃A)
EQW_SpriteIndex.init() // 不抛错,返回索引对象
若报 404,核对 index.html 里的路径与实际文件名大小写。
- Step 4: 跑两端全套测试
Run: node client/tests/run.js && node server/games/erqiwang/test/run.js
Expected: 两个都是 ===== 全部单测通过 =====。
- Step 5: 提交
git add client/js/01_SubGame/codes/SubGameHooks.js client/index.html
git commit -F - <<'EOF'
二七王:前端接入骨架与 index.html 加载段
SubGameHooks 从模板复制、本阶段保持空骨架(无 hook 时转发壳走平台默认)。
index.html 按 client 06 §3 的顺序加入 codes 段:config → shared → core → ui
→ SubGameHooks → 三文件转发壳,三个转发壳一字未动。
浏览器打开无报错,全部 EQW_* 就绪。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
完成检查
全部任务做完后逐条核对(对应 spec §10 验收标准):
node client/tests/run.js全绿(8 个用例文件)node server/games/erqiwang/test/run.js全绿(shared 抽取的安全网)- HTTP 打开
client/index.html控制台无报错,全部EQW_*与SubGameHooks就绪 - 提交全部通过
.githooks/pre-commit的机械红线校验(严格 ES5、可编辑范围) - 常量层与清单 §3 / §5 / §6 逐条对应,无遗漏、无臆造条目
core/、ui/里不出现任何精灵 ID、坐标、时长的字面量CLAUDE.md「常用命令」已收录sync_shared.cmd- 三个转发壳
00_/01_/02_SubGame_*.js一字未动(git log --oneline -- client/js/01_SubGame/0*.js无本次提交)