Files
erqiwang_youle/docs/superpowers/plans/2026-08-26-二七王前端A-地基与逻辑层.md
T
joywayerandClaude Opus 5 2b1dd0d947 二七王:修复出牌操作条/亮牌历史布局越界,补主牌统计条改名遗漏
- 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>
2026-08-27 09:06:01 +08:00

112 KiB
Raw Blame History

二七王前端 · 子项目 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:264 get_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'|null
    • countByFlower(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 里单测。

两处容易写错,注意:

  1. distributeHorizontally 假设等宽,故 line 带 items(逐项不等宽)时不能喂给它,要自己按累计宽度推进;
  2. 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(数字)。
  • 精灵的所属群组由结构本身表达,注释里不必再重复写属于哪个 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 无本次提交)