Files
erqiwang_youle/docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md
T
joywayerandClaude Opus 5 69cb04ecfa 二七王:确立「一个 View 恰好一个图层」不变式
原本允许 Layer1/Layer2 声明多图层,但「哪个 group 属于哪个图层」只能靠
注释隐含、无法机械判定。改为跨图层的界面拆成两个 View,各带自己的 Layer——
一个 View 将来对应一个 BaseComponent 组件,本就该分开。

SpriteIndex 加校验守住该不变式:View 缺 Layer、或写成 Layer1/Layer2 均报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:01:04 +08:00

23 KiB
Raw Blame History

二七王前端 · 子项目 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. 本阶段范围

交付:

  1. codes/ 目录结构
  2. 常量层全量落地(清单 §3 图片资源、§4 声音、§5 精灵结构、§6 布局配置、动画参数)
  3. core/ 纯逻辑模块(牌值编码、排序、标记推导、座位映射、精灵索引)
  4. codes/ui/LayoutSolver.js 五型布局求解器
  5. 服务端 shared/cards.js 抽取 + 前端只读同步副本 + 同步脚本
  6. client/tests/ 单测框架与全部用例
  7. 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 服务端改动

  1. 新建 server/games/erqiwang/shared/cards.js,全局名 youle_erqiwang_shared_cards,形态照既有文件:var X = X || { ... } + 尾部 if (typeof module !== "undefined"){ module.exports = X; }。函数内部的相互调用改走 shared 自身引用。
  2. class.arith.js 中这 7 项改为挂载 shared 的引用(id_to_code: youle_erqiwang_shared_cards.id_to_code),对外 API 与全部既有调用点零改动。
  3. mod.js 的 min_loadJsFile 序列里 shared/cards.js 排在 class.arith.js 之前。
  4. 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. 验收标准

  1. node client/tests/run.js 全绿
  2. node server/games/erqiwang/test/run.js 全绿(shared 抽取的安全网)
  3. HTTP 打开 client/index.html 控制台无报错,EQW_Layers/Groups/Images/Sprites/Layout/Anim/CardCodec/CardOrder/CardMark/SeatMap/SpriteIndex/LayoutSolver 全部就绪
  4. git config core.hooksPath .githooks 下提交通过机械红线校验(严格 ES5、可编辑范围)
  5. 常量层与清单 §3 / §5 / §6 逐条对应,无遗漏、无臆造条目
  6. 业务代码零裸值:core/、ui/ 里不出现任何精灵 ID、坐标、时长的字面量
  7. 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 阶段做,并列入验收检查