原本允许 Layer1/Layer2 声明多图层,但「哪个 group 属于哪个图层」只能靠 注释隐含、无法机械判定。改为跨图层的界面拆成两个 View,各带自己的 Layer—— 一个 View 将来对应一个 BaseComponent 组件,本就该分开。 SpriteIndex 加校验守住该不变式:View 缺 Layer、或写成 Layer1/Layer2 均报错。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23 KiB
二七王前端 · 子项目 A:地基与逻辑层 · 设计
日期:2026-08-26 状态:待实施 权威源:玩法
server/games/erqiwang/docs/design/design.md;协议server/games/erqiwang/docs/protocol/packet_protocol.md; 界面规格docs_dev/二七王-UI资源与精灵清单.md(下称清单);前端规范docs/client/development-guide/。 本文只做「规格 → 代码结构」的落地设计,不重新定义规则、不修订清单结论。
1. 背景与定位
服务端二七王已完成(class.arith/paiju/desk/export/import/mod + 17 个测试脚本(624 checks)),协议与玩法文档定稿,清单已把全流程翻译成可执行的界面规格。前端尚未开工:client/js/01_SubGame/ 只有三个平台转发壳,codes/ 目录不存在。
整个前端按依赖顺序拆为 6 个子项目,各自独立走「设计 → 计划 → 实现 → 提交」:
| # | 子项目 | 内容 |
|---|---|---|
| A | 地基与逻辑层(本文) | 目录结构、常量层全量、纯逻辑核心、布局求解器、shared/ 抽取与同步、单测框架、平台接入骨架 |
| B | 网络与数据镜像 | 发包封装、rpc 分发、GameState 服务端快照镜像、重连 deskinfo 复用同一条重画路径 |
| C | 牌桌主界面 | 顶部信息条、玩家位标记、底栏、手牌区、底牌区、三家已出牌区 |
| D | 阶段操作界面 | 叫分面板、选主/投降、埋牌条、出牌条、倒计时、提示气泡、状态提示条 |
| E | 弹窗与结算 | 小局/大局结算、冲关牌型叠加层、出牌历史、明牌、亮牌、底牌/扣底查看 |
| F | 建房与接入收尾 | 建房规则选项区、SubGameHooks 全接口、PLAYER_INFO_LAYOUT 同步、清单 §7.3 验收 18 条 |
1.1 前置条件的处理方式(已定)
清单 §7.4 把「美术出图 / 编辑器建精灵」列为开发前置条件。本项目不采用等待策略:
资源、精灵、图层、群组、布局、动画参数一律先在常量文件里定义,代码只引用常量;后期按常量文件的注释在编辑器里手动创建对应资源与精灵。因此编辑器的建设进度不阻塞任何编码工作。
这与清单 §0 前言「代码里引用的 ID 必须与编辑器中实际建成的完全一致」并不冲突——次序倒过来了:常量文件即建资源的规格书,编辑器照它建。若建设过程中某个 ID 确需改动,改常量文件一处即可(业务代码零裸值,见 §4)。
清单 §0.3 已核实号段占用现状,规划的号段(精灵 1001–2999、图层 101–105/302–306、群组 201–250、图片 501+、声音 101+)段内空闲,可安全落地。
2. 本阶段范围
交付:
codes/目录结构- 常量层全量落地(清单 §3 图片资源、§4 声音、§5 精灵结构、§6 布局配置、动画参数)
core/纯逻辑模块(牌值编码、排序、标记推导、座位映射、精灵索引)codes/ui/LayoutSolver.js五型布局求解器- 服务端
shared/cards.js抽取 + 前端只读同步副本 + 同步脚本 client/tests/单测框架与全部用例codes/SubGameHooks.js空骨架 +client/index.html加载段
不在本阶段:任何 UI 组件、任何 SpriteManager 渲染调用、任何收发包逻辑(B 起)。LayoutSolver.apply() 是唯一触碰 SpriteManager 的函数,且只有三行。
3. 目录结构
client/js/01_SubGame/codes/
config/ 纯数据:无函数、无副作用、不引用其他模块
Layers.js
Groups.js
ImageResources.js
SoundResources.js
Sprites_Table.js
Sprites_Cards.js
Sprites_Action.js
Sprites_Result.js
Sprites_CreateRoom.js
LayoutConstants.js
Layout_Table.js
Layout_Cards.js
Layout_Action.js
Layout_Result.js
Layout_CreateRoom.js
AnimConstants.js
core/ 纯逻辑:不碰精灵、无副作用、可 Node 单测
CardCodec.js
CardOrder.js
CardMark.js
SeatMap.js
SpriteIndex.js
shared/ 服务端 shared/ 的只读同步副本(禁止在前端修改)
cards.js
ui/
LayoutSolver.js
SubGameHooks.js 本阶段为空骨架
<仓库根>/
sync_shared.cmd / sync_shared.ps1 跨端同步脚本,放根目录(见 §7.4)
client/tests/
_load.js _assert.js run.js test_*.js
单向依赖:config(无依赖)← core ← ui ← 后续 net/组件层。shared/ 无依赖,被 core 使用。
4. 常量层
遵循 client 02 §2「资源与布局常量三件套」与清单 §6.10 的文件组织。多文件用保护性声明(var EQW_Layout = EQW_Layout || {};),加载后合并为一份。
4.1 文件与内容来源
| 文件 | 全局名 | 内容来源 |
|---|---|---|
Layers.js |
EQW_Layers |
清单 §0.3 图层表:TABLE_STATIC:101、HAND:102、TABLE_CARDS:103、ACTION:104、OVERLAY:105、POPUP_ASET_RESULT:302、POPUP_ACCOUNT:303、POPUP_HISTORY:304、POPUP_MINGPAI:305 |
Groups.js |
EQW_Groups |
清单 §0.3 群组表 201–250 |
ImageResources.js |
EQW_Images |
清单 §3 图片资源总表,每条按 ImageResources.template 的要求注明用途 / 帧数 / 每帧含义 / 尺寸 |
SoundResources.js |
EQW_Sounds |
清单 §4——整节 T-20 未定,本阶段只建文件骨架与说明注释,不臆造条目(见 §11) |
Sprites_*.js |
EQW_Sprites |
清单 §5.1–§5.8。以 UI View 为组织单位(一个 View 将来对应一个 BaseComponent),View 下是一个个 group 容器:{ Layer: 图层, GroupName: { id: 群组, 精灵键: id, … } }。一个 View 恰好一个图层,跨图层的界面拆成两个 View。每个精灵注明类型 / 用途 / 资源键 / 帧说明 |
LayoutConstants.js |
EQW_Layout |
清单 §6.3 textStyle 预设、§6.4 CARD_SIZE 三档、座位键常量 SELF/LEFT/RIGHT |
Layout_Table.js |
EQW_Layout |
清单 §6.5 常驻区 + §6.6 玩家位(含气泡朝向 arrowRight) |
Layout_Cards.js |
EQW_Layout |
清单 §6.7 牌区(手牌单排/双排、底牌、埋牌底牌、已出牌 bySeat、冲关牌) |
Layout_Action.js |
EQW_Layout |
清单 §6.8 阶段操作区 |
Layout_Result.js |
EQW_Layout |
清单 §6.9 结算区 |
Layout_CreateRoom.js |
EQW_Layout |
清单 §6.9b 建房规则选项 + EQW_RoomOptions 选项配置(§1.1 的 categories 数据) |
AnimConstants.js |
EQW_Anim |
发牌铺开时长与每张延迟(§1.2)、selectedOffsetY(§6.7)、floatRise/floatDuration(§6.8)、toastDuration(§6.8)、liangpaiAutoClose(§1.6)、开底 3 秒(§1.4) |
4.2 硬约束
- 布局文件是纯数据:不得出现函数、不得引用其他模块、不得有副作用(client 02 §2、清单 §6.10)。
fan的间距算法、bySeat合并、attach换算全在LayoutSolver。 - 业务代码零裸值:精灵 ID / 群组 ID / 图层 ID / 资源 ID / 坐标尺寸 / 动画时长 / 事件名一律来自常量(前端红线)。
- 注释即建资源的规格:图片资源与精灵的注释必须写到「据此就能准确创建」的程度(client 02 §2)。
- 数值来自清单,均为参考图 1280×720 目视实测估值(±5px),设计稿到位后只改这一层。
5. core/ 纯逻辑层
全部为无副作用纯函数,不引用 SpriteManager、不引用引擎、不读全局状态。
5.1 CardCodec
EQW_CardCodec.cardIdToFrame(cardId) // 清单 §0.4 的唯一权威转换,全前端只此一处实现
EQW_CardCodec.CARD_BACK_FRAME // 55
实现照清单 §0.4:
var n = cardId % 54;
if (n === 52) return 53; // 小王
if (n === 53) return 54; // 大王
return (3 - Math.floor(n / 13)) * 13 + (n % 13) + 1;
花色段序与美术帧相反(服务端 1=方块…4=黑桃,美术帧 黑桃→红桃→梅花→方块),是最易静默画错牌的一处,故用清单 §0.4 的 10 条校验样例逐条锁死。
牌 id 的花色/点数解码不在此重复实现,一律调 shared/cards.js 的 id_to_flower / id_to_number(§7)。
5.2 CardOrder
EQW_CardOrder.sort(cards, mainflower) // 返回新数组,从大到小,与服务端同序
内部直接调 shared/cards.js 的 order_cards(同源,见 §7),不在前端另写排序(清单 T-5)。前端只负责:
- 传入
mainflower:选主前传0(仅固定主牌成主)、选主后传flower - 不修改入参数组(服务端
order_cards原地排序,前端包一层concat())
选主后必须整体重排(正 2 / 正 7 升格、主花色普通牌并入主牌段,design §3)——重排由调用方在收到 xuanzhu 时触发(B 阶段),本阶段只提供函数。
5.3 CardMark
清单 §1.6 T-8 的三种牌面标记,前端据 flower 本地推导,服务端不下发:
EQW_CardMark.marksOfHand(cards, mainflower) // 批量,返回 { cardId: mark };无标记的牌不出现
EQW_CardMark.markOf(cardId, cards, mainflower) // 单张 → 'tractor'|'zheng'|'zhu'|null(互斥,至多一个)
EQW_CardMark.countByFlower(cards) // 选主面板:{ flower: {count, pairs} }
'tractor'(红色拖圆标):该牌属于一组拖拉机 → 走shared的get_pairlist+get_tuolaji_list'zheng'(橙色五角星):正 2 / 正 7,即选定花色的 2 和 7'zhu'(蓝色五角星):其余主牌(双王、副 2、副 7、主花色普通牌)- 判定优先级:
tractor>zheng>zhu(互斥且每张至多一个,见清单 §1.6)
countByFlower 供选主面板的中央大数字(该花色张数)与右上角标(对子数)——协议明确服务端不下发,前端据 MyCards 自算(清单 §1.5、协议 ChooseMain 注)。
5.4 SeatMap
清单 §0.1 的座位映射,全前端唯一实现:
EQW_SeatMap.toDisplay(seat, mySeat) // → 'SELF' | 'RIGHT' | 'LEFT'
EQW_SeatMap.toSeat(displayKey, mySeat) // → 服务端座位号
规则:SELF = mySeat、RIGHT = (mySeat+1)%3(下家)、LEFT = (mySeat+2)%3(上家)。依据服务端 class.paiju.js:264 get_nextseat = (seat+1)%3。
5.5 SpriteIndex
精灵常量的组织是 View → group 容器 → 精灵:
EQW_Sprites.TopInfoView = {
Layer: EQW_Layers.TABLE_STATIC, // 一个 View 恰好一个图层
TopInfo: { // group 容器
id: EQW_Groups.TOP_INFO,
TOP_INFO_BG: 1001,
TOP_CALL_TEXT: 1003
}
};
而布局配置里 attach.target 写的是键名(清单 §6.1:「target 一律写键名而非数字 ID」),故需把这棵树展平:
EQW_SpriteIndex.build(spriteTree) // → { 键名: 精灵ID }
EQW_SpriteIndex.buildGroupMap(spriteTree) // → { 键名: 群组ID }
EQW_SpriteIndex.init(spriteTree) // 默认用 EQW_Sprites 建好两张索引
EQW_SpriteIndex.idOf(key) // 查精灵 ID;查不到显式抛错
EQW_SpriteIndex.groupIdOf(key) // 查它所属的群组 ID;查不到显式抛错
groupIdOf 是这个结构带来的收益:group 容器把精灵与群组 ID 绑在一起,「某个精灵属于哪个群组」于是可以机械查出——显隐走群组时(前端红线:显隐由组件的 showXxx/hideXxx 控制)不必再手工对照。
结构约定:View 内 Layer 是 View 级声明、跳过;一个 View 恰好一个图层——跨图层的界面拆成两个 View(一个 View 将来对应一个 BaseComponent,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」只能靠注释隐含)。其余键的值必须是对象(group 容器)。group 容器内 id 是群组 ID、跳过;其余键一律是精灵 id。
显式失败五处(工程总则 §7):
idOf/groupIdOf键名拼错时立即报错,而不是把undefined传给SpriteManager静默无效;- 重复键报错而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。同名键在不同 View 下重复由清单 §5 的键名设计避免(
PLAY_SELF_*/PLAY_LEFT_*/PLAY_RIGHT_*); - 精灵值非数字报错;
- View 下混入标量键报错(例如把群组 ID 误写成 View 级的
Group: 201而没包进 group 容器); - group 容器缺
id报错; - View 缺
Layer(或写成Layer1/Layer2的多图层形式)报错。
6. ui/LayoutSolver.js 布局求解器
6.1 接口
EQW_LayoutSolver.solve(node, ctx) // → [{x, y, width, height}, ...],纯函数
EQW_LayoutSolver.apply(node, spriteIds, ctx) // solve 后逐个 SpriteManager.setPosition
node:一份清单 §6.1 的布局配置(五型之一,可含bySeat)ctx:seat:'SELF' | 'LEFT' | 'RIGHT',用于bySeat合并(无bySeat时可省)count:运行时项数,供line/fan/grid(清单 §6.1:count由运行时数据给出,不进配置)rects:{ 键名 → {x, y, width, height} },供attach查目标矩形
- 返回恒为数组(
point/attach长度为 1),调用方按索引摆精灵
attach 通过 ctx.rects 取目标矩形,而不是反查引擎——求解器因此保持纯函数、可在 Node 里完整单测。目标矩形由调用方提供(来自已解出的布局结果或常量里的固定 x/y/w/h)。
6.2 内部流程
solve(node, ctx)
1. mergeBySeat(node, ctx.seat) bySeat[seat] 覆盖外层 base,未列出的字段继承
2. 按 kind 分派:
point → 直接取 x/y/w/h
line → AlignmentUtils.distribute;有 items 时逐项取宽(itemWidth 失效)
fan → 先按 §6.1 公式算 spacing,再走同一个 distribute
grid → 逐行调 distributeHorizontally,fillOrder 决定填充次序
attach → corner 为空 → alignTo(目标内部对齐)
corner 有值 → 对应的 alignSpriteCorner*(贴目标外侧角)
3. 返回矩形数组
fan 的间距算法(清单 §6.1,唯一实现,不散落):
count <= 1 → spacing 不参与,按 anchor 摆一张
otherwise → raw = (maxWidth - itemWidth) / (count - 1) - itemWidth
spacing = clamp(raw, spacingMin, spacingMax)
overlapFrom 决定 z 序方向('left' = 后面的牌盖住前面的;'right' 为左上家镜像),求解器只输出坐标与 z 序次序,实际 z 序由调用方按返回次序摆精灵。
6.3 参数命名
一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置对象可原样喂进框架方法、中间零转换。对齐常量沿用 AlignmentUtils.ALIGN。精灵锚点恒在左上角,配置里的 anchorX/anchorY 是对齐基准点、不是精灵左上角坐标。
6.4 错误处理
未知 kind、缺必填参数、attach 的 target 在 ctx.rects 里查不到 → 显式抛错(工程总则 §7),不返回 {x:0,y:0} 之类的兜底值。
7. shared/ 抽取与同步
7.1 动机
前端排序(CardOrder)与手牌标记(CardMark)用的算法必须与服务端主牌序完全一致(清单 T-5:「对齐服务端主牌序,不另造一套」)。两处各写一份必然漂移,故按工程总则 §1(SSOT)抽为前后端同源的共享算法。
7.2 抽取边界
这些函数构成闭合依赖链,不依赖对局状态、不依赖平台 API:
order_cards ─→ id_to_code ─→ id_to_flower
└→ id_to_number
get_pairlist ─────→ id_to_code
get_tuolaji_list ─→ id_to_code
└→ is_continuous
trump_rank ───────→ id_to_code
共 8 个:id_to_flower、id_to_number、id_to_code、order_cards、is_continuous、get_pairlist、get_tuolaji_list、trump_rank。它们在 class.arith.js 里正好是连续的一整段(第 62–289 行),中间不夹杂其他函数,可整段搬运。
7.3 服务端改动
- 新建
server/games/erqiwang/shared/cards.js,全局名youle_erqiwang_shared_cards,形态照既有文件:var X = X || { ... }+ 尾部if (typeof module !== "undefined"){ module.exports = X; }。函数内部的相互调用改走 shared 自身引用。 class.arith.js中这 7 项改为挂载 shared 的引用(id_to_code: youle_erqiwang_shared_cards.id_to_code),对外 API 与全部既有调用点零改动。mod.js的min_loadJsFile序列里shared/cards.js排在class.arith.js之前。test/_shim.js或各测试脚本按需require('../shared/cards.js'),保证 Node 侧全局可用。
安全网:现有 17 个测试脚本(624 checks)原样全绿,即为这次重构的验收依据——不新增业务行为,只搬家。
7.4 前端同步
单向流水线,方向不可逆:
server/games/erqiwang/shared/ ← 唯一可修改处(权威源)
│ sync_shared.cmd
▼
client/js/01_SubGame/codes/shared/ ← 只读副本,任何情况下都不在前端改
- 脚本位置:仓库根目录
sync_shared.cmd+sync_shared.ps1(.cmd调.ps1,仿既有client/scripts/build_spine_data.cmd的范式)。它是跨端的工程动作——源在server/、目标在client/,不属于任何一端,故不放client/scripts/。 - 脚本行为:把
server/games/erqiwang/shared/*.js覆盖拷贝到client/js/01_SubGame/codes/shared/,并打印同步了哪些文件。只有这一个方向,脚本不提供反向同步。 - 副本逐字节一致——浏览器无
module,尾部module.exports守卫自动跳过,同一份文件两个运行时都能跑。 - 纪律(写进脚本头注释与
codes/shared/目录说明):改动一律落在服务端shared/,改完跑一次根目录的sync_shared.cmd;前端副本的任何本地修改都会在下次同步时被覆盖。 - 防漂移守卫:
client/tests/test_shared_sync.js断言两份文件内容逐字节相等——忘了同步、或有人手改了前端副本,测试立刻红。这条守卫比人工纪律可靠。
8. 测试
照搬服务端 test/ 的现有范式(run.js spawn 各用例独立进程 + _assert.js + _shim.js),新建 client/tests/。
8.1 加载机制
前端正式代码是浏览器全局脚本(var X = {...},无 module.exports)。测试侧用 _load.js 以 vm.runInThisContext(fs.readFileSync(path)) 把它们喂进 Node global——不改正式代码,符合 client 05 §10「正式代码禁止为测试而加逻辑」。
测试代码可用现代语法(只跑 Node、不上线),且不受 .githooks/pre-commit 的严格 ES5 拦截。
8.2 用例
| 文件 | 覆盖 |
|---|---|
test_cardcodec.js |
清单 §0.4 的 10 条校验样例逐条;两副牌同帧;牌背常量;越界 id 的行为 |
test_cardorder.js |
与服务端 order_cards 同序(正例);选主前后(mainflower=0 vs flower)顺序变化;不改入参数组 |
test_cardmark.js |
三种标记互斥且每张至多一个;拖拉机分组正例/反例(不连续的对子不成拖);countByFlower 的张数与对数 |
test_seatmap.js |
三个 mySeat × 三个 seat 全组合;toDisplay/toSeat 互逆 |
test_spriteindex.js |
嵌套结构展平正确;键名查不到显式抛错;重复键报错 |
test_layoutsolver.js |
五型各自的坐标正确;bySeat 合并(覆盖与继承);fan 的 clamp 上下边界与 count<=1;line 的 items 不等宽;grid 的 fillOrder;attach 四角与内部对齐;未知 kind / 缺参 / target 缺失均抛错 |
test_shared_sync.js |
前后端 shared/cards.js 逐字节一致 |
test_constants.js |
号段合规:精灵 ∈ 1001–2999、群组 ≥201、图层 ∈ 101–200/301–400、图片 ≥501、声音 ≥101;全局无重复 ID;布局配置里每个 attach.target 都能在精灵索引里查到 |
test_constants.js 是清单 §0.3 与前端红线的机械化守卫——常量层有上百个 ID,人工核对不可靠。
正面 / 反面 / 边界用例都要覆盖(client 05 §10)。测试失败先用证据裁定「业务缺陷 vs 脚本缺陷」,禁止 skip / 软化断言 / 吞异常。
9. 平台接入骨架
codes/SubGameHooks.js:从gameabc-framework/templates/subgame-entry/SubGameHooks.template.js复制,本阶段保持空骨架(各 hook 留空函数 + 用途注释),B/F 阶段逐个填充。三个转发壳已就位、原样不动。client/index.html:按 client 06 §3 的顺序加入 codes 加载段——依赖模块 →SubGameHooks→ 三文件转发壳。本阶段的加载次序:
config/*(纯数据,最先)→ shared/cards.js → core/* → ui/LayoutSolver.js
→ SubGameHooks.js → 00_/01_/02_SubGame_*.js(已在文件中,位置不变)
验证标准:浏览器打开 client/index.html(HTTP 方式)控制台无报错,全局对象 EQW_* 均已就绪。
10. 验收标准
node client/tests/run.js全绿node server/games/erqiwang/test/run.js全绿(shared 抽取的安全网)- HTTP 打开
client/index.html控制台无报错,EQW_Layers/Groups/Images/Sprites/Layout/Anim/CardCodec/CardOrder/CardMark/SeatMap/SpriteIndex/LayoutSolver全部就绪 git config core.hooksPath .githooks下提交通过机械红线校验(严格 ES5、可编辑范围)- 常量层与清单 §3 / §5 / §6 逐条对应,无遗漏、无臆造条目
- 业务代码零裸值:
core/、ui/里不出现任何精灵 ID、坐标、时长的字面量 CLAUDE.md「常用命令」一节补上根目录的sync_shared.cmd(该节现在写的是「仓库里唯一的脚本」,新增后须同步修订)
11. 遗留与依赖
| 项 | 状态 | 处理 |
|---|---|---|
| T-20 音效 | 清单唯一未决规格项(整套音效清单未定) | SoundResources.js 只建骨架与说明注释,不臆造条目;定案后补 |
| D-8 判定结果动画 | 已定形态(对局中 + 结算前播放,靠 curmultiple 符号分辨),缺动画设计稿 |
动画参数位在 AnimConstants.js 预留注释;实际动画在 E 阶段 |
| 编辑器建资源 | 未开始 | 不阻塞(§1.1)。常量文件即建资源规格书 |
| 布局数值 | 参考图目视实测估值(±5px) | 设计稿到位后只改 config/Layout_*.js 一层 |
Game_Modify.PLAYER_INFO_LAYOUT 同步 |
清单 §6.11 / T-28:转发壳配置区不能引用 codes,只能手工同步 |
F 阶段做,并列入验收检查 |