Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637fa8fca7 | ||
|
|
52ad71f8b2 | ||
|
|
08c387e609 | ||
|
|
8e0c8fdc90 | ||
|
|
f8a154ff7a | ||
|
|
b9a423071b | ||
|
|
8b2cfdaf27 | ||
|
|
5d981a722e | ||
|
|
cfa0409d94 | ||
|
|
af779f473a | ||
|
|
22e43cf2e6 | ||
|
|
dac4a94b38 | ||
|
|
4508fe005f | ||
|
|
5689ceab6f | ||
|
|
cdf2bc730e | ||
|
|
729128aa9d | ||
|
|
f7b87f32e8 | ||
|
|
84525359fc | ||
|
|
c4c061b0e1 | ||
|
|
33c18a2c17 | ||
|
|
e2d470d37f | ||
|
|
e61725da40 | ||
|
|
a5ca3bc5d9 | ||
|
|
e8fc3a9bfa | ||
|
|
070ff37db1 | ||
|
|
47c8a27d0d | ||
|
|
5a77e4b132 | ||
|
|
5637d63c3b | ||
|
|
daf2708af9 | ||
|
|
081130b020 | ||
|
|
01eb6c60b5 | ||
|
|
65674542b3 | ||
|
|
ff46d874c0 | ||
|
|
a82969a23b | ||
|
|
4e79f135ce | ||
|
|
2df7179266 | ||
|
|
d4de34c951 | ||
|
|
f63de40229 | ||
|
|
8dba099717 | ||
|
|
2b1dd0d947 | ||
|
|
8f36b61620 | ||
|
|
f982460c9c | ||
|
|
dc08513886 | ||
|
|
92a8caa53f | ||
|
|
b11a3f17a9 | ||
|
|
6c339060bf | ||
|
|
1644b3ebb2 | ||
|
|
ccc41ce092 | ||
|
|
a4b12a1ebb | ||
|
|
19f93bb96f | ||
|
|
0c58ae67db | ||
|
|
857bc955bb | ||
|
|
717e405292 | ||
|
|
35ee85e1d6 | ||
|
|
dbdd6607d7 | ||
|
|
2fe0f661b4 | ||
|
|
3789e0814e | ||
|
|
bc75e40acc | ||
|
|
b81e800f22 | ||
|
|
be3e8d0837 | ||
|
|
503ad4b374 | ||
|
|
d1b60275a4 | ||
|
|
ee65871694 | ||
|
|
92edb6a3f5 | ||
|
|
b81be36f6e | ||
|
|
b22558d0b3 | ||
|
|
a05cc7b7fa | ||
|
|
6390ce74c2 | ||
|
|
ec954ea4fe | ||
|
|
949702dc90 | ||
|
|
91f0eb116c | ||
|
|
6c92920df8 | ||
|
|
1976edf9bd | ||
|
|
4d05457590 | ||
|
|
69cb04ecfa | ||
|
|
488f36b545 | ||
|
|
8b579896b2 | ||
|
|
5ac445d08d | ||
|
|
6546f75965 | ||
|
|
8d6d272f76 | ||
|
|
2d0469e7f3 | ||
|
|
3a5da5b975 | ||
|
|
39a920bed5 | ||
|
|
e9333303d5 | ||
|
|
65a73c5c2a | ||
|
|
3b9828c8ca | ||
|
|
656066b1a5 | ||
|
|
147a28805e | ||
|
|
d287ce5c8c | ||
|
|
9f32af7d72 | ||
|
|
e326481f2f | ||
|
|
0f29c2e08c | ||
|
|
849de06194 | ||
|
|
f04bff4e9f | ||
|
|
fd31272bd1 | ||
|
|
e51cde02b8 | ||
|
|
32cbe2fe91 | ||
|
|
6c78b37e89 | ||
|
|
796503153d | ||
|
|
9ea6bfa14d | ||
|
|
76e82405e1 | ||
|
|
50e6fd3ca4 | ||
|
|
b98f58effc | ||
|
|
dc9123da92 | ||
|
|
6ec6bdbbc2 | ||
|
|
be06d3bf58 | ||
|
|
4aab71e6a3 | ||
|
|
ecec44e93a | ||
|
|
82cfc98502 | ||
|
|
cfef896fd6 | ||
|
|
5dda571250 | ||
|
|
2c3464b6f0 | ||
|
|
b85c286dde | ||
|
|
766ef7eae3 | ||
|
|
ddb4f7d6a2 | ||
|
|
6fc9f15976 | ||
|
|
df4eefc9e2 | ||
|
|
b924b6e864 | ||
|
|
31011e2eeb | ||
|
|
b5d9eece5d | ||
|
|
49ee068c5c | ||
|
|
9d0675fce8 | ||
|
|
a4361a802d | ||
|
|
1b76683704 | ||
|
|
e8a7b245cc | ||
|
|
2fd5829551 | ||
|
|
ce4c972b7b |
@@ -2,4 +2,4 @@
|
||||
# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。
|
||||
# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。
|
||||
# 启用(每个克隆一次性):git config core.hooksPath .githooks
|
||||
# exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged
|
||||
exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged
|
||||
|
||||
@@ -24,3 +24,6 @@ yarn-error.log*
|
||||
.claude/settings.local.json
|
||||
# 会话级哨兵:记录本会话已注入过哪些规范目录,SessionStart 会清空,勿提交
|
||||
.claude/.spec-injected/
|
||||
|
||||
# 多智能体工作区(工具产物,不入库)
|
||||
.superpowers/
|
||||
|
||||
@@ -12,13 +12,20 @@
|
||||
|
||||
## 常用命令
|
||||
|
||||
没有配置任何构建/lint/测试工具。仓库里唯一的脚本:
|
||||
没有配置任何构建/lint/测试工具。仓库里的脚本:
|
||||
|
||||
```bash
|
||||
# 根据 client/assets/spine/*.json 重新生成 client/generated/spine_assets.js 与 spine_data.js
|
||||
client/scripts/build_spine_data.cmd # 内部调用 build_spine_data.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# 把服务端共享算法同步到前端只读副本(改动一律落在服务端 shared/,改完跑一次)
|
||||
sync_shared.cmd
|
||||
```
|
||||
|
||||
`server/games/erqiwang/shared/` 是前后端共享算法的**唯一可修改处**;`client/js/01_SubGame/codes/shared/` 是它的只读副本,手改会被下次同步覆盖,且 `client/tests/test_shared_sync.js` 会因字节不一致而失败。
|
||||
|
||||
在 `client/assets/spine/` 增删 Spine 导出文件后运行;不要手改这两个生成文件。
|
||||
|
||||
查看客户端效果需用 HTTP 方式(而非 `file://`)打开 `client/index.html`,例如 `npx http-server client -p 8080`。
|
||||
@@ -67,4 +74,6 @@ git config core.hooksPath .githooks
|
||||
## 测试与 Git
|
||||
|
||||
- **测试纪律**:细则见 client 05 §10 / server 04 §10。要点——测试代码可用现代语法(只跑 Node,不上线);**正式代码禁止为测试而加逻辑/放宽校验**,禁止为过测而改正式代码或降低测试标准(skip、软化断言、吞异常等);失败先用证据裁定「业务缺陷 vs 脚本缺陷」;正面/反面/边界用例都要覆盖。
|
||||
- **新增断言要反向验证**:写完一条守卫断言,**故意破坏它守的那处逻辑,确认它真会变红**——否则你加的可能是一条空转断言(「全绿但什么都没验」比红更有害)。
|
||||
- ⚠️ **本仓库文件是 CRLF 行尾**:任何基于行尾的脚本化改写(`sed`/正则批量删改)必须按 `\r?\n` 处理。写 `\n` 会**一行都匹配不上却静默成功**——曾据此得出「测试抓不住缺陷」的错误结论。脚本改完先 `git diff --stat` 确认文件真的变了,再据其结果下判断。
|
||||
- **Git 提交**(细则见 server 04 §11):完成一个可独立成立的逻辑改动即可**自动提交、无需逐次确认**(`push` 按需);提交信息用中文、聚焦一件事、结尾保留 `Co-Authored-By` 署名;不用 `git add -A`/`git add .`、不跳过 hooks、不做 `reset --hard`/`push --force`。
|
||||
|
||||
@@ -208,6 +208,54 @@
|
||||
|
||||
|
||||
|
||||
<!-- 阶段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/state/Events.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/state/GameState.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/state/RoomOptions.js"></script>
|
||||
<!-- 5.6 网络:handler 在前,Dispatcher 在后(它注册时要引用全部 handler) -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/FailHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/DealHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/CallHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/MainHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/BuryHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/PlayHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/QueryHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ReadyHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResultHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResyncHandler.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/Dispatcher.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/net/Rpc.js"></script>
|
||||
<!-- 5.7 平台接入(须在依赖之后、三文件转发壳之前) -->
|
||||
<script type="text/javascript" src="js/01_SubGame/codes/SubGameHooks.js"></script>
|
||||
|
||||
<!-- 子游戏 Hooks 实现(转发壳三文件转发到此;须在依赖的 controllers/handlers 之后、三文件之前)-->
|
||||
<script type="text/javascript" src="js/01_SubGame/00_SubGame_Config.js"></script>
|
||||
<script type="text/javascript" src="js/01_SubGame/01_SubGame_modify.js"></script>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// ============================================================================
|
||||
// SubGameHooks.js —— 二七王子游戏接入入口(实现骨架,本阶段空函数,B/F 阶段逐个填充)
|
||||
// 平台契约文件(00/01/02 转发壳)会把同名平台接口转发到这里。只需实现【本游戏需要的】hook,
|
||||
// 未实现的接口由转发壳走空操作(A)/平台默认返回值(B)/模板默认 UI 渲染(D)。严格 ES5。
|
||||
// 接口清单与默认值见 spec §9 / 文档 06。
|
||||
// ============================================================================
|
||||
|
||||
var SubGameHooks = window.SubGameHooks || {};
|
||||
|
||||
// ===== gameHallImport.*(大厅,9) =====
|
||||
|
||||
// [A] hallAppStart():大厅应用启动(注:与 Game_Modify.appStart 同名冲突,故加 hall 前缀区分)。
|
||||
// SubGameHooks.hallAppStart = function () { /* ... */ };
|
||||
|
||||
// [A] jumpMenuScene():大厅跳转菜单场景。
|
||||
// SubGameHooks.jumpMenuScene = function () { /* ... */ };
|
||||
|
||||
// [A] gameStart():大厅游戏开始入口。
|
||||
// SubGameHooks.gameStart = function () { /* ... */ };
|
||||
|
||||
// [A] setGameList():设置游戏列表。
|
||||
// SubGameHooks.setGameList = function () { /* ... */ };
|
||||
|
||||
// [A] clearGameinfo():清除游戏信息。
|
||||
// SubGameHooks.clearGameinfo = function () { /* ... */ };
|
||||
|
||||
// [A] getWebdata(data):获取网络数据回调。
|
||||
// SubGameHooks.getWebdata = function (data) { /* ... */ };
|
||||
|
||||
// [B] isInstalled(data):检查是否已安装;无 hook 时默认:return 1。
|
||||
// SubGameHooks.isInstalled = function (data) { /* ... */ };
|
||||
|
||||
// [A] up_imgurl(recid, photo):更新图片 URL。
|
||||
// SubGameHooks.up_imgurl = function (recid, photo) { /* ... */ };
|
||||
|
||||
// [A] getphoto(recid, photo):获取玩家头像。
|
||||
// SubGameHooks.getphoto = function (recid, photo) { /* ... */ };
|
||||
|
||||
// ===== Game_Modify.*(事件/交互,来自 01,9) =====
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 精灵交互 6 件套:【只做转发,不写任何业务逻辑】
|
||||
//
|
||||
// 平台事件到达框架的完整链路:
|
||||
// 引擎 → Game_Modify.xxx(01 转发壳) → SubGameHooks.xxx(本文件) → SpriteEventController.handleXxx
|
||||
// → 按精灵 ID 分发到各 UI 组件注册的 handler
|
||||
//
|
||||
// 这一段接不上,整条链就是断的:转发壳里已注册的 13 号战绩按钮、150 号帮助按钮、
|
||||
// 248 号协议勾选(绘制回调里叠绘对钩)全都收不到事件,且不报错、只是静默失效。
|
||||
// 参数顺序必须与 SpriteEventController.handleXxx 的签名逐个对齐——错位会让事件【静默失效】。
|
||||
// 防御风格与转发壳一致:先判断依赖存在再转发。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// [A] utlmousedown(gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6):精灵按下事件;子游戏转发到 SpriteEventController。
|
||||
SubGameHooks.utlmousedown = function (gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleMouseDown(gameid, spid, downx, downy, no1, no2, no3, no4, no5, no6);
|
||||
};
|
||||
|
||||
// [A] utlmousedown_nomove(gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5):精灵按下(无移动)事件。
|
||||
SubGameHooks.utlmousedown_nomove = function (gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleMouseDownNoMove(gameid, spid, downx, downy, timelong, no1, no2, no3, no4, no5);
|
||||
};
|
||||
|
||||
// [A] mouseup(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2):精灵抬起事件。
|
||||
SubGameHooks.mouseup = function (gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleMouseUp(gameid, spid_down, downx, downy, spid_up, upx, upy, timelong, no1, no2);
|
||||
};
|
||||
|
||||
// [A] utlmousemove(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1):精灵移动事件。
|
||||
SubGameHooks.utlmousemove = function (gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleMouseMove(gameid, spid, downx, downy, movex, movey, timelong, offmovex, offmovey, no1);
|
||||
};
|
||||
|
||||
// [A] utlgamemydrawbegin(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7):精灵绘制开始事件。
|
||||
SubGameHooks.utlgamemydrawbegin = function (gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleDrawBegin(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7);
|
||||
};
|
||||
|
||||
// [A] gamemydraw(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7):精灵绘制帧事件。
|
||||
SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7) {
|
||||
if (typeof SpriteEventController === 'undefined') { return; }
|
||||
return SpriteEventController.handleDraw(gameid, spid, times, timelong, no2, no3, no4, no5, no6, no7);
|
||||
};
|
||||
|
||||
// [A] OpenCreateRoom():打开创建房间界面。
|
||||
// SubGameHooks.OpenCreateRoom = function () { /* ... */ };
|
||||
|
||||
// [A] CloseCreateRoom():关闭创建房间界面。
|
||||
// SubGameHooks.CloseCreateRoom = function () { /* ... */ };
|
||||
|
||||
// [A] setRoomDes(roomcode, asetcount, roomtype):设置房间描述文案。
|
||||
// SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) { /* ... */ };
|
||||
|
||||
// ===== Game_Modify.*(生命周期/回调,来自 02,45) =====
|
||||
|
||||
// [A] appStart():游戏应用启动(与 gameHallImport.appStart 语义不同,对应 Game_Modify 侧启动)。
|
||||
// SubGameHooks.appStart = function () { /* ... */ };
|
||||
|
||||
// [A] StartWar(_msg):开战(开局)回调,解包开局数据并初始化游戏状态。
|
||||
// SubGameHooks.StartWar = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] _ReceiveData(_msg):接收服务端主动推送的数据包,派发给对应业务处理器。
|
||||
// SubGameHooks._ReceiveData = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] createRoom(_roomtype, _infinite):前端发起创建房间请求。
|
||||
// SubGameHooks.createRoom = function (_roomtype, _infinite) { /* ... */ };
|
||||
|
||||
// [A] myJoinRoom(_msg):自己成功进入房间回调。
|
||||
// SubGameHooks.myJoinRoom = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] Reconnect(_deskinfo):重连回调,用服务端返回的桌面信息恢复游戏界面。
|
||||
// SubGameHooks.Reconnect = function (_deskinfo) { /* ... */ };
|
||||
|
||||
// [A] stopAllSounds():停止所有声音(切换到后台时触发)。
|
||||
// SubGameHooks.stopAllSounds = function () { /* ... */ };
|
||||
|
||||
// [A] Free(_msg):投票解散确认回调。
|
||||
// SubGameHooks.Free = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] DeskInfo(_msg):牌局已开始时加入牌桌的数据同步(实现见文末「网络与对局」一段)。
|
||||
|
||||
// [A] breakRoom():开局后自己退出房间触发。
|
||||
// SubGameHooks.breakRoom = function () { /* ... */ };
|
||||
|
||||
// [A] playerJoinRoom(seat):其他玩家加入房间通知。
|
||||
// SubGameHooks.playerJoinRoom = function (seat) { /* ... */ };
|
||||
|
||||
// [A] playerLeaveRoom(seat):其他玩家离开房间通知。
|
||||
// SubGameHooks.playerLeaveRoom = function (seat) { /* ... */ };
|
||||
|
||||
// [A] updateScene():更新游戏界面(分辨率变化等)。
|
||||
// SubGameHooks.updateScene = function () { /* ... */ };
|
||||
|
||||
// [A] closeGameScene():关闭游戏界面。
|
||||
// SubGameHooks.closeGameScene = function () { /* ... */ };
|
||||
|
||||
// [A] playerOffline(seat):玩家掉线通知。
|
||||
// SubGameHooks.playerOffline = function (seat) { /* ... */ };
|
||||
|
||||
// [A] playerOnline(seat):玩家上线通知。
|
||||
// SubGameHooks.playerOnline = function (seat) { /* ... */ };
|
||||
|
||||
// [A] playerphonestate(seat, type):玩家电话状态变化通知。
|
||||
// SubGameHooks.playerphonestate = function (seat, type) { /* ... */ };
|
||||
|
||||
// [A] shakeEvent():设备摇一摇事件。
|
||||
// SubGameHooks.shakeEvent = function () { /* ... */ };
|
||||
|
||||
// [A] calResult(inputArr):计算结算结果。
|
||||
// SubGameHooks.calResult = function (inputArr) { /* ... */ };
|
||||
|
||||
// [A] onEnterMainScene(roomtype):进入游戏主场景。
|
||||
// SubGameHooks.onEnterMainScene = function (roomtype) { /* ... */ };
|
||||
|
||||
// [A] onExitMainScene():退出游戏主场景。
|
||||
// SubGameHooks.onExitMainScene = function () { /* ... */ };
|
||||
|
||||
// [A] onGameConfig(_gameConfig):获取游戏配置时触发。
|
||||
// SubGameHooks.onGameConfig = function (_gameConfig) { /* ... */ };
|
||||
|
||||
// [A] onEnterVideo():进入牌局回放模式。
|
||||
// SubGameHooks.onEnterVideo = function () { /* ... */ };
|
||||
|
||||
// [A] onSurrender(_msg):收到投降回包。
|
||||
// SubGameHooks.onSurrender = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] onReady(seat):玩家准备触发。
|
||||
// SubGameHooks.onReady = function (seat) { /* ... */ };
|
||||
|
||||
// [B] getRoomInfo(roomtype, type, tea):获取房间描述文案;无 hook 时默认:"房间描述" + roomtype。
|
||||
// SubGameHooks.getRoomInfo = function (roomtype, type, tea) { /* ... */ };
|
||||
|
||||
// [B] getStarLimit(roomtype):获取星星场下限分数;无 hook 时默认:return 2001000。
|
||||
// SubGameHooks.getStarLimit = function (roomtype) { /* ... */ };
|
||||
|
||||
// [B] getMult(roomtype, type):获取星星场倍数;无 hook 时默认:return 10000。
|
||||
// SubGameHooks.getMult = function (roomtype, type) { /* ... */ };
|
||||
|
||||
// [B] getFullRoomInfo(roomtype):获取房间完整信息描述;无 hook 时默认:"房间描述" + roomtype。
|
||||
// SubGameHooks.getFullRoomInfo = function (roomtype) { /* ... */ };
|
||||
|
||||
// [A] onOpenHelp(spid):打开帮助页面。
|
||||
// SubGameHooks.onOpenHelp = function (spid) { /* ... */ };
|
||||
|
||||
// [A] onMainMenuScene():显示大厅主菜单界面。
|
||||
// SubGameHooks.onMainMenuScene = function () { /* ... */ };
|
||||
|
||||
// [A] onCheckInput(_result):数字输入框确认回调。
|
||||
// SubGameHooks.onCheckInput = function (_result) { /* ... */ };
|
||||
|
||||
// [A] onCreateRoom(_data):创建房间成功后触发。
|
||||
// SubGameHooks.onCreateRoom = function (_data) { /* ... */ };
|
||||
|
||||
// [B] getLeaveLimit(roomtype):获取离场限制局数;无 hook 时默认:return 10。
|
||||
// SubGameHooks.getLeaveLimit = function (roomtype) { /* ... */ };
|
||||
|
||||
// [A] onLocationInfo(_locationInfo):成功获取定位信息回调。
|
||||
// SubGameHooks.onLocationInfo = function (_locationInfo) { /* ... */ };
|
||||
|
||||
// [A] onCreateDesk(roomtype):进入游戏界面创建牌桌之前触发。
|
||||
// SubGameHooks.onCreateDesk = function (roomtype) { /* ... */ };
|
||||
|
||||
// [B] getVideoByRoomType(roomtype):获取该房型是否开启视频功能;无 hook 时默认:return 1。
|
||||
// SubGameHooks.getVideoByRoomType = function (roomtype) { /* ... */ };
|
||||
|
||||
// [B] getRoomTopDescAry(roomtype):获取房间顶部描述文案数组;无 hook 时默认:["" + roomtype]。
|
||||
// SubGameHooks.getRoomTopDescAry = function (roomtype) { /* ... */ };
|
||||
|
||||
// [A] getShareRoom(_msg):星星场房间列表数据。
|
||||
// SubGameHooks.getShareRoom = function (_msg) { /* ... */ };
|
||||
|
||||
// [A] myExitRoom(seat):玩家自己退出房间。
|
||||
// SubGameHooks.myExitRoom = function (seat) { /* ... */ };
|
||||
|
||||
// [A] changeSeat(seat1, seat2):接收到换座包触发。
|
||||
// SubGameHooks.changeSeat = function (seat1, seat2) { /* ... */ };
|
||||
|
||||
// [A] onCloseVip():关闭 VIP 选项时调用。
|
||||
// SubGameHooks.onCloseVip = function () { /* ... */ };
|
||||
|
||||
// [B] getRoomMode(roomtype):判断房间模式(0=非金币场);无 hook 时默认:return 0。
|
||||
// SubGameHooks.getRoomMode = function (roomtype) { /* ... */ };
|
||||
|
||||
// [B] getMaxPlayerCount(roomtype):获取房间最大玩家数量;无 hook 时默认:return Game_Config.Max.PlayerCnt || 4。
|
||||
// SubGameHooks.getMaxPlayerCount = function (roomtype) { /* ... */ };
|
||||
|
||||
// [D] updatePlayerInfoUI(targetSeat):更新指定座位玩家信息 UI;无 hook 时模板提供【可变人数】默认渲染
|
||||
// (头像/昵称/分数随人数 2/3/4 贴合各玩家,布局见 01 的 Game_Modify.PLAYER_INFO_LAYOUT)。子游戏可用 hook 完全接管。
|
||||
// SubGameHooks.updatePlayerInfoUI = function (targetSeat) { /* ... */ };
|
||||
|
||||
// ===== Game_Modify.*(桌面聊天/语音气泡,3) =====
|
||||
// 以下接口无 hook 时,模板提供【可变人数】默认渲染(气泡随人数贴合各玩家,布局见 01 的 Game_Modify.BUBBLE_LAYOUT)。
|
||||
|
||||
// [D] ShowChat(seat, text):显示桌面文字聊天气泡(seat 为绝对座位)。
|
||||
// SubGameHooks.ShowChat = function (seat, text) { /* ... */ };
|
||||
|
||||
// [D] gameui_play_voice(uiIndex):播放桌面语音气泡(uiIndex 为相对显示位序)。
|
||||
// SubGameHooks.gameui_play_voice = function (uiIndex) { /* ... */ };
|
||||
|
||||
// [D] gameui_stop_voice(uiIndex):停止桌面语音气泡。
|
||||
// SubGameHooks.gameui_stop_voice = function (uiIndex) { /* ... */ };
|
||||
|
||||
//—— 网络与对局(B 阶段接线)——
|
||||
//平台入口只解包转交,业务全在 net/ 的 handler 里。
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 【room.mySeat 的唯一写入处】(SSOT)
|
||||
//
|
||||
// 为什么不能只在 appStart 里取一次:
|
||||
// 平台 12_Logic.js:389 调 Game_Modify.appStart() 时,C_Player 还只是一个
|
||||
// `var C_Player;`——`C_Player = new Player(-1)` 要到【同一个函数】的第 480 行才执行。
|
||||
// 此刻 typeof C_Player === 'undefined',守卫为假、整个 appStart 空转;
|
||||
// 之后玩家登录、07_Desk.js 里 C_Player.SetSeat() 拿到真座位,但 appStart 再也不会被调用,
|
||||
// 于是 mySeat 永远停在 -1:发包带 seat:-1(服务端 check_player 必然拒绝)、
|
||||
// StartWar 差异化下发找不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
|
||||
// ——而且【全程无报错】,这正是它能潜伏下来的原因。
|
||||
//
|
||||
// 因此改为:单一写入处 + 多个调用点。凡是平台已经确定座位的入口都同步一次,
|
||||
// 不赌「所有路径都会经过某一个入口」。
|
||||
//
|
||||
// 幂等:可重复调用;C_Player 不可用或座位非法时【保持原值不写】——
|
||||
// 绝不用 -1 覆盖已同步好的正确座位。
|
||||
// ---------------------------------------------------------------------------
|
||||
SubGameHooks._syncMySeat = function () {
|
||||
if (typeof C_Player === 'undefined' || !C_Player) { return; }
|
||||
var seat = C_Player.seat;
|
||||
if (seat !== 0 && seat !== 1 && seat !== 2) { return; }
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
};
|
||||
|
||||
// 对局推送。_msg 是完整包 {app, route, rpc, data}
|
||||
// (平台 12_Logic.js 按 route 分流,只有子游戏路由 erqiwang 才到这里)
|
||||
SubGameHooks._ReceiveData = function (_msg) {
|
||||
if (!_msg) { return; }
|
||||
EQW_Dispatcher.dispatch({ rpc: _msg.rpc, data: _msg.data });
|
||||
};
|
||||
|
||||
// 开局。可能差异化下发(sendtype:1 + seatlist[])
|
||||
// 【必须先同步座位】handleStartWar 要按 mySeat 在 seatlist[] 里认领自己那份
|
||||
SubGameHooks.StartWar = function (_msg) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleStartWar(_msg);
|
||||
};
|
||||
|
||||
// 断线重连:拿到 get_deskinfo 的全量快照
|
||||
// (handleDeskinfo 内的 GameState.reset() 不清 room.mySeat,此处同步的值不会被冲掉)
|
||||
SubGameHooks.Reconnect = function (_deskinfo) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
|
||||
};
|
||||
|
||||
// 中途进桌:牌局【已经开始】,但自己走的是「加入房间」而非「重连」这条路径。
|
||||
// 平台 07_Desk.js 在 self_join_room 且 battlestate==1 时走 Game_Modify.DeskInfo(_msg.data.deskinfo),
|
||||
// 与 self_login 的 Game_Modify.Reconnect(_msg.data.deskinfo) 是两条并列分支——
|
||||
// 不接这个 hook,整份 deskinfo 会被 02 转发壳静默丢弃(无 hook 即 no-op),界面全空且不报错。
|
||||
//
|
||||
// 【入参与 Reconnect 完全同源、且已按【自己的】座位裁剪】:
|
||||
// 两条路径的 deskinfo 都出自服务端 server/youle/server_room/class.return.js —— 进房走
|
||||
// self_join_room 的 get_deskinfo(o_room, o_player.gameinfo.seat),登录走 self_login 的
|
||||
// get_deskinfo(o_player.gameinfo.o_room, o_player.gameinfo.seat),同一个函数、同一个
|
||||
// 「本人座位」入参。get_deskinfo 内部的可见性裁剪(底牌/埋牌底牌只给庄家、亮牌只给闲家、
|
||||
// mustcard 只给轮到的那家)因此对中途进桌一样生效,不会把别人的视角发过来。
|
||||
// 故直接复用同一条重画路径(前端 04 §3「重连 = 重画」),不为它单写一套渲染。
|
||||
SubGameHooks.DeskInfo = function (_deskinfo) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
|
||||
};
|
||||
|
||||
// 重连但未开战:没有牌局数据,无需重画对局
|
||||
SubGameHooks.ReconnectNoMakewar = function () {
|
||||
SubGameHooks._syncMySeat();
|
||||
console.log('[EQW] 重连(未开战)');
|
||||
};
|
||||
|
||||
// 解散。_msg 即 Desk.deskfree,可能为 null(首局发牌前解散)
|
||||
SubGameHooks.Free = function (_msg) {
|
||||
EQW_ResultHandler.handleFree(_msg);
|
||||
};
|
||||
|
||||
// 房间信息(房号 / 总局数 / roomtype 位串)
|
||||
// 平台三条进桌路径(07_Desk.js:358 登录 / 510 建房 / 631 进房)都在 C_Player.SetSeat() 之后、
|
||||
// Reconnect / StartWar / DeskInfo 之前调它——是覆盖面最广的一个同步点
|
||||
SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) {
|
||||
SubGameHooks._syncMySeat();
|
||||
EQW_ResyncHandler.handleSetRoomDes(roomcode, asetcount, roomtype);
|
||||
};
|
||||
|
||||
// 换座(平台 07_Desk.js:186-191 已先把 C_Player.seat 改成新座位再回调这里)。
|
||||
// 这是唯一「座位会中途变化、且不重走 setRoomDes」的路径,漏掉这里 mySeat 会静默过期
|
||||
SubGameHooks.changeSeat = function (seat1, seat2) {
|
||||
SubGameHooks._syncMySeat();
|
||||
};
|
||||
|
||||
// 启动编排。B 阶段只记下自己的座位;UI 初始化留给 C 阶段
|
||||
// 注意:此刻 C_Player 通常还没创建(见 _syncMySeat 上方注释),座位靠后续入口补齐
|
||||
SubGameHooks.appStart = function () {
|
||||
SubGameHooks._syncMySeat();
|
||||
if (typeof Desk !== 'undefined' && Desk.roomtype) {
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype);
|
||||
}
|
||||
};
|
||||
|
||||
window.SubGameHooks = SubGameHooks;
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = SubGameHooks; }
|
||||
@@ -0,0 +1,34 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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:只按时长关闭,不做点击关闭)——
|
||||
//这里的「亮牌」是 design §8.2 那件事(群组 242,复用 HIST_* 精灵),
|
||||
//与群组 206 的主牌统计条(ZHU_STAT_*,2026-08-27 前也叫「亮牌条」)无关。
|
||||
//清单原文时长「默认值待定」,此处沿用与开底展示同数量级的 3 秒作为起始估值,设计定稿后回填
|
||||
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级)。
|
||||
//缺动画设计稿与触发细节,定案后在此补参数。
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Groups: 群组 ID(清单 §0.3)////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏可用群组段:≥201(框架保留 0–200)。平台已占用 0–79,与下列不冲突。
|
||||
// 键名为已裁决的硬约定——下一任务的精灵常量按这些键名引用,不得改名。
|
||||
var EQW_Groups = EQW_Groups || {
|
||||
|
||||
TOP_INFO: 201, // 顶部信息条(主 / 叫分 / 抓分)
|
||||
P_LEFT_MARK: 202, // 左上家附加标记(庄印章、主N对N、状态文字)
|
||||
P_RIGHT_MARK: 203, // 右上家附加标记(庄印章、主N对N、状态文字)
|
||||
P_SELF_MARK: 204, // 自己附加标记(庄印章、主N对N、状态文字)
|
||||
FOOTER: 205, // 底栏功能按钮组(明牌 / 已出牌 / 上一轮 / 扣底 / 底牌)
|
||||
ZHU_STAT_BAR: 206, // 自己的主牌统计条(手牌上方「x对 x主」,本地统计,清单 §2.5)
|
||||
// ⚠ 2026-08-27 由 LIANGPAI_BAR 改名:它【不是】design §8.2 的「亮牌」——
|
||||
// 那是庄家向闲家亮出固定主牌的具体牌面,走遮罩面板(群组 242),两者同名异实
|
||||
|
||||
HAND: 210, // 自己手牌
|
||||
BOTTOM_CARDS: 211, // 底牌区(发牌留桌 8 张)
|
||||
PLAY_SELF: 212, // 自己的已出牌区
|
||||
PLAY_LEFT: 213, // 左上家的已出牌区
|
||||
PLAY_RIGHT: 214, // 右上家的已出牌区
|
||||
BURY_CARDS: 215, // 埋牌底牌展示区(结算时 8 张)
|
||||
|
||||
CALL_PANEL: 220, // 叫分面板
|
||||
CHOOSE_MAIN: 221, // 选主 / 投降面板
|
||||
BURY_BAR: 222, // 埋牌操作条
|
||||
PLAY_BAR: 223, // 出牌操作条
|
||||
COUNTDOWN: 224, // 倒计时(三位置)
|
||||
|
||||
OVERLAY: 230, // 桌面浮层(气泡 / 飘字 / 报无主 / 甩错)
|
||||
|
||||
ASET_RESULT: 240, // 小局结算
|
||||
ACCOUNT: 241, // 大局总结算
|
||||
HISTORY: 242, // 出牌历史 / 上一轮 / 亮牌——【一组三用】,共用同一套 HIST_* 精灵
|
||||
// 「亮牌」即 design §8.2(庄家亮固定主牌),只显示庄家那一排、其余容器隐藏,
|
||||
// 且只按时长自动关闭(EQW_Anim.LIANGPAI_AUTO_CLOSE),详见 Sprites_Result.js
|
||||
MINGPAI: 243, // 明牌面板
|
||||
CHONGGUAN: 244, // 冲关牌型叠加层(清单 §5.6a,挂在图层 302,精灵复制)
|
||||
|
||||
CREATE_ROOM: 250 // 建房规则选项
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Images: 图片资源 ID(清单 §3,帧排布见 §0.4)////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏图片资源段:≥501(框架保留 1–500)。平台占用 1–439,与下列不冲突。
|
||||
// 每条注释为后期在编辑器里建资源的规格书:用途 / 帧数 / 帧说明 / 尺寸。
|
||||
// 尺寸均为参考图实测估值,出图前须与最终设计稿核对(清单 §3 表头说明)。
|
||||
// 按钮类资源一律只出 1 帧普通态,按下缩放/透明反馈由框架统一处理。
|
||||
//
|
||||
// 以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
|
||||
// 512 SUIT_ICON_L —— 选主面板花色图标已并入 BTN_SUIT_CHOOSE 帧1–4(美术改一张图出,2026-08-26)
|
||||
// 534 BTN_SURRENDER —— 投降按钮已并入 BTN_SUIT_CHOOSE 帧5(美术改一张图出,2026-08-26)
|
||||
// 539 BTN_READY —— 准备标识用平台自带的(清单 §0.2)
|
||||
// 652 TXT_BAOZHU —— 报无主不做全场提示(D-4),只让他家 主N/对N 角标出现
|
||||
// 655 PANEL_BOTTOM_3S —— 底牌 3 秒展示复用庄家翻底牌 UI(D-2),仅可见范围不同
|
||||
var EQW_Images = EQW_Images || {
|
||||
|
||||
//===================== §3.1 牌面(3 套,见清单 §0.4)=====================
|
||||
|
||||
//用途:自己手牌与底牌区的牌面
|
||||
//帧数: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,
|
||||
|
||||
//用途:三家已出牌区的牌面
|
||||
//帧数:60(10列×6行,行优先)
|
||||
//帧说明:帧排布与 CARD_FACE_L 完全一致——帧1–13=黑桃A–K 帧14–26=红桃A–K
|
||||
// 帧27–39=梅花A–K 帧40–52=方块A–K 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
|
||||
//尺寸:单帧 90×155,整图 900×930
|
||||
CARD_FACE_M: 502,
|
||||
|
||||
//用途:结算埋牌底牌区的牌面
|
||||
//帧数:60(10列×6行,行优先)
|
||||
//帧说明:帧排布与 CARD_FACE_L 完全一致——帧1–13=黑桃A–K 帧14–26=红桃A–K
|
||||
// 帧27–39=梅花A–K 帧40–52=方块A–K 帧53=小王 帧54=大王 帧55–60=牌背1–6(二七王用帧55)
|
||||
//尺寸:单帧 50×70,整图 500×420
|
||||
CARD_FACE_S: 503,
|
||||
|
||||
//===================== §3.2 花色图标 =====================
|
||||
|
||||
//用途:顶部信息条「主」列的花色小图标
|
||||
//帧数:4
|
||||
//帧说明:帧1=方块♦ 帧2=梅花♣ 帧3=红心♥ 帧4=黑桃♠(按服务端 flower 编号 1–4 排序,
|
||||
// 与牌面资源的花色顺序黑桃→红桃→梅花→方块不同,两套互不换算)
|
||||
//尺寸:30×30
|
||||
SUIT_ICON_S: 511,
|
||||
|
||||
//===================== §3.3 按钮 =====================
|
||||
|
||||
//用途:叫分档位按钮底 + 右上角角标底(同一张图,不含文字)
|
||||
//帧数:4
|
||||
//帧说明:帧1=浅黄底+蓝角标(对应档位 70/65/60/55) 帧2=金黄底+绿角标(50/45/40)
|
||||
// 帧3=橙底+深橙角标(35…5) 帧4=灰(已叫过/不可选)。颜色按档位位置固定,只因不可叫变灰
|
||||
//尺寸:74×56
|
||||
BTN_CALL_SCORE: 531,
|
||||
|
||||
//用途:「不叫」按钮
|
||||
//帧数:1
|
||||
//帧说明:蓝色渐变
|
||||
//尺寸:184×57
|
||||
BTN_NO_CALL: 532,
|
||||
|
||||
//用途:选主 / 投降面板按钮(花色按钮 + 投降按钮,一张图 5 帧)
|
||||
//帧数:5
|
||||
//帧说明:帧1–4=四个花色按钮,每帧已包含「按钮底 + 花色图标 + 右上角蓝色角标底」,
|
||||
// 花色序按服务端 flower 编号——帧1=方块♦ 帧2=梅花♣ 帧3=红心♥ 帧4=黑桃♠;
|
||||
// 帧5=投降按钮,已包含「按钮底 + 投降二字」
|
||||
//尺寸:128×55
|
||||
BTN_SUIT_CHOOSE: 533,
|
||||
|
||||
//用途:「提示」按钮
|
||||
//帧数:1
|
||||
//帧说明:蓝色渐变
|
||||
//尺寸:180×63
|
||||
BTN_TIP: 535,
|
||||
|
||||
//用途:「埋牌 (N/8)」按钮
|
||||
//帧数:2
|
||||
//帧说明:帧1=可提交(金黄) 帧2=置灰(未满 8 张)
|
||||
//尺寸:180×63
|
||||
BTN_BURY: 536,
|
||||
|
||||
//用途:「出牌」按钮
|
||||
//帧数:2
|
||||
//帧说明:帧1=可出 帧2=置灰
|
||||
//尺寸:157×55
|
||||
BTN_PLAY: 537,
|
||||
|
||||
//用途:底栏功能钮底(明牌/已出牌/上一轮/底牌共用)
|
||||
//帧数:1
|
||||
//帧说明:深色圆角
|
||||
//尺寸:88×30
|
||||
BTN_BOTTOM_FUNC: 538,
|
||||
|
||||
//用途:底栏「踩」/「有分」/「没分」按钮底(三者共用)
|
||||
//帧数:1
|
||||
//帧说明:深色圆角,同 BTN_BOTTOM_FUNC 风格但更窄
|
||||
//尺寸:52×30
|
||||
BTN_TISHI: 540,
|
||||
|
||||
//用途:结算「下一局」按钮
|
||||
//帧数:1
|
||||
//帧说明:金黄渐变
|
||||
//尺寸:180×65
|
||||
BTN_NEXT_ASET: 541,
|
||||
|
||||
//用途:结算「冲关牌型」按钮
|
||||
//帧数:1
|
||||
//帧说明:蓝色渐变
|
||||
//尺寸:183×63
|
||||
BTN_CG_CARDS: 542,
|
||||
|
||||
//用途:大局结算「再来一局」按钮
|
||||
//帧数:1
|
||||
//帧说明:黄色渐变
|
||||
//尺寸:190×60
|
||||
BTN_ACCOUNT_AGAIN: 543,
|
||||
|
||||
//用途:大局结算「分享好友」按钮
|
||||
//帧数:1
|
||||
//帧说明:蓝色渐变
|
||||
//尺寸:190×60
|
||||
BTN_ACCOUNT_SHARE: 544,
|
||||
|
||||
//用途:大局结算「复制战绩」按钮
|
||||
//帧数:1
|
||||
//帧说明:蓝色渐变
|
||||
//尺寸:190×60
|
||||
BTN_ACCOUNT_COPY: 545,
|
||||
|
||||
//用途:大局结算「退出房间」按钮
|
||||
//帧数:1
|
||||
//帧说明:红色渐变
|
||||
//尺寸:190×60
|
||||
BTN_ACCOUNT_EXIT: 546,
|
||||
|
||||
//用途:大局结算标题栏右上角关闭按钮
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:36×36
|
||||
BTN_ACC_CLOSE: 547,
|
||||
|
||||
//===================== §3.4 标记类 =====================
|
||||
|
||||
//用途:「庄」红色印章
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:40×40
|
||||
MARK_BANKER: 571,
|
||||
|
||||
//用途:牌角「庄」橙色斜标
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:28×28
|
||||
MARK_BANKER_CORNER: 572,
|
||||
|
||||
//用途:大王「大」圆标
|
||||
//帧数:2
|
||||
//帧说明:帧1=大王 帧2=小王
|
||||
//尺寸:34×34
|
||||
MARK_JOKER_BIG: 573,
|
||||
|
||||
//用途:「主N」/「对N」角标底
|
||||
//帧数:2
|
||||
//帧说明:帧1=黄底(主) 帧2=白底(对)
|
||||
//尺寸:44×20
|
||||
BADGE_ZHU_PAIR: 574,
|
||||
|
||||
//用途:子数/倍数角标底
|
||||
//帧数:2
|
||||
//帧说明:帧1=蓝底 帧2=橙底
|
||||
//尺寸:34×18
|
||||
BADGE_MULTIPLE: 575,
|
||||
|
||||
//用途:牌型标签
|
||||
//帧数:1
|
||||
//帧说明:只做「毙」一种(前端只展示这一个标签)。数据层仍保留 毙/垫/混合出牌 三种标识以备扩展,
|
||||
// 但当前不出图、不显示
|
||||
//尺寸:76×24
|
||||
LABEL_CARDTYPE: 576,
|
||||
|
||||
//用途:手牌牌型标记
|
||||
//帧数:3
|
||||
//帧说明:帧1=「拖」(拖拉机) 帧2=橙色五角星(正2/正7) 帧3=蓝色五角星(除正2正7外的其他主牌)
|
||||
//尺寸:26×26
|
||||
MARK_CARD_GROUP: 577,
|
||||
|
||||
//用途:手牌选中标记
|
||||
//帧数:1
|
||||
//帧说明:待定(也可只用上浮表现,不出图)
|
||||
//尺寸:待定
|
||||
MARK_SELECTED: 578,
|
||||
|
||||
//用途:大局结算名次徽章(花瓣形)
|
||||
//帧数:3
|
||||
//帧说明:帧1=第1名(金) 帧2=第2名(橙) 帧3=第3名(蓝)
|
||||
//尺寸:44×44
|
||||
BADGE_RANK: 579,
|
||||
|
||||
//===================== §3.5 面板底图 =====================
|
||||
|
||||
//用途:顶部三列信息条底
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:276×68
|
||||
PANEL_TOP_INFO: 601,
|
||||
|
||||
//用途:叫分面板底
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:575×170
|
||||
PANEL_CALL_SCORE: 602,
|
||||
|
||||
//用途:「70分坐庄才可投降」提示条底
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:345×33
|
||||
BAR_HINT_TOP: 603,
|
||||
|
||||
//用途:「亮主」斜角标题条
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:425×50
|
||||
BAR_CHOOSE_MAIN: 604,
|
||||
|
||||
//用途:自己的主牌统计条底(半透圆角,手牌上方「x对 x主」)
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:210×35
|
||||
//2026-08-27 由 BAR_LIANGPAI 改名:与 design §8.2 的「亮牌」同名异实(后者是遮罩面板),见清单 §0.0
|
||||
BAR_ZHU_STAT: 605,
|
||||
|
||||
//用途:结算底牌区面板底
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:420×148
|
||||
PANEL_BOTTOM_CARDS: 606,
|
||||
|
||||
//用途:结算分数光晕背景
|
||||
//帧数:2
|
||||
//帧说明:帧1=赢(橙) 帧2=输(蓝)
|
||||
//尺寸:225×100
|
||||
GLOW_RESULT: 607,
|
||||
|
||||
//用途:底栏深色条
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:1280×72
|
||||
BAR_FOOTER: 608,
|
||||
|
||||
//用途:半透黑遮罩(冲关牌型/出牌历史/明牌 共用)
|
||||
//帧数:1
|
||||
//帧说明:纯色可拉伸,出 1 张小图即可
|
||||
//尺寸:1280×720
|
||||
MASK_DIM: 609,
|
||||
|
||||
//用途:大局总结算面板底(圆角浅灰绿)
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:1100×540
|
||||
PANEL_ACCOUNT: 610,
|
||||
|
||||
//用途:大局结算标题栏(青绿底,含圆角上沿)
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:1100×56
|
||||
BAR_ACCOUNT_TITLE: 611,
|
||||
|
||||
//用途:大局结算底部规则栏(浅绿底,含圆角下沿)
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:1100×62
|
||||
BAR_ACCOUNT_RULE: 612,
|
||||
|
||||
//用途:玩家栏之间的分隔线
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:1060×2
|
||||
LINE_DIVIDER: 613,
|
||||
|
||||
//===================== §3.6 数字与符号 =====================
|
||||
//
|
||||
//【数字类资源的统一规范:16 帧固定帧序】(2026-08-26 订正)
|
||||
//以下 NUM_* 一律是【多帧图片资源,16 帧】,帧序固定为:0 1 2 3 4 5 6 7 8 9 . + - x / p
|
||||
// 帧1–10 = 数字 0–9 帧11 = 「.」 帧12 = 「+」 帧13 = 「-」
|
||||
// 帧14 = 「x」 帧15 = 「/」 帧16 = 可变后缀(该套资源自己的后缀字,如「分」「倍」「张」)
|
||||
//
|
||||
//用法前提:显示多位数字【不拆精灵、不用 setFrame 逐位切】,而是用平台既有接口
|
||||
// SpriteManager.setNumberImage(spriteId, text, charWidth)
|
||||
//把整串文本设给【一个】多帧图片精灵,引擎按字符逐帧渲染,精灵宽度由该接口按
|
||||
//【字符数 × charWidth】自动调整(charWidth 见 EQW_Layout.NUM_STYLE.*,本处只记出图规格)。
|
||||
//
|
||||
//出图要求:帧号由字符直接换算('0'–'9' → 帧1–10,后缀 → 帧16),所以帧序【不可重排、不可省略占位】——
|
||||
//即使某套资源用不到 . + - x / 这几帧,也要按顺序留出帧位,否则帧号对不上、画面串位。
|
||||
//整图尺寸一律 = 16 × 单字符宽(横向 16 帧)。
|
||||
|
||||
//用途:倒计时数字(带描边/渐变美术字)
|
||||
//帧数:16(固定帧序 0123456789.+-x/p)
|
||||
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
|
||||
// 帧16 = 后缀【本套无后缀,出空白占位】
|
||||
//单字符尺寸:44×60
|
||||
//整图尺寸:704×60(16 帧 × 44)
|
||||
NUM_COUNTDOWN: 631,
|
||||
|
||||
//用途:结算得分数字(赢,橙,带描边/渐变美术字)
|
||||
//帧数:16(固定帧序 0123456789.+-x/p)
|
||||
//帧说明:帧1–10 = 0–9;帧12 = 「+」、帧13 = 「-」(得分带正负号,必出);帧11/14/15 = . x /(占位);
|
||||
// 帧16 = 后缀【待美术与规则确认:结算大字目前只显示数字,若日后要显示「+120分」则此帧出「分」】
|
||||
//单字符尺寸:46×62
|
||||
//整图尺寸:736×62(16 帧 × 46)
|
||||
NUM_RESULT_WIN: 632,
|
||||
|
||||
//用途:结算得分数字(输,蓝,带描边/渐变美术字)
|
||||
//帧数:16(固定帧序 0123456789.+-x/p)
|
||||
//帧说明:与 NUM_RESULT_WIN 完全一致(含帧16 待确认项),仅配色不同
|
||||
//单字符尺寸:46×62
|
||||
//整图尺寸:736×62(16 帧 × 46)
|
||||
NUM_RESULT_LOSE: 633,
|
||||
|
||||
//用途:判定结果动画(大光/小光/过庄/升N级/投降)——帧动画资源,播放参数进 AnimationConfigs【待设计 D-8】
|
||||
//帧数:待定
|
||||
//帧说明:待定
|
||||
//尺寸:待定
|
||||
ANI_JUDGE: 634,
|
||||
|
||||
//用途:叫分档位的分数数字(深棕描边美术字,带描边/渐变,文字精灵做不出此效果)
|
||||
//帧数:16(固定帧序 0123456789.+-x/p)
|
||||
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
|
||||
// 帧16 = 无后缀(第16帧留空/不使用)【已确认,2026-08-27】:档位面板只显示纯数字(70…5),
|
||||
// 旁边的「N子」角标是独立的文字精灵,不走这里
|
||||
//单字符尺寸:30×40
|
||||
//整图尺寸:480×40(16 帧 × 30)
|
||||
//说明:14 个档位中 13 个是两位数(70…10),整串交给 setNumberImage,一档【一个】精灵,
|
||||
// 见 Sprites_Action.js 的 CALL_BTN_NUM_1..14;面板上的「N子」角标是文字精灵,不走本资源
|
||||
NUM_CALL_SCORE: 635,
|
||||
|
||||
//用途:选主面板花色张数数字(美术单独出图,字号配色与以上几套不同;一个花色一个精灵,见 Sprites_Action.js)
|
||||
//帧数:16(固定帧序 0123456789.+-x/p)
|
||||
//帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
|
||||
// 帧16 = 后缀「张」(显示形如「26张」,后缀与数字等宽,同为单字符宽)
|
||||
//单字符尺寸:24×32(估值,与选主按钮 128×55 协调估算,出图前须与最终设计稿核对)
|
||||
//整图尺寸:384×32(16 帧 × 24)
|
||||
NUM_MAIN_SUIT_COUNT: 636,
|
||||
|
||||
//===================== §3.7 浮层与气泡 =====================
|
||||
|
||||
//用途:「踩」/「没分」/「有分」提示气泡
|
||||
//帧数:6(3×2)
|
||||
//帧说明:帧1=踩·箭头向左 帧2=没分·箭头向左 帧3=有分·箭头向左
|
||||
// 帧4=踩·箭头向右 帧5=没分·箭头向右 帧6=有分·箭头向右
|
||||
// 帧序内层是 tip(1踩/2没分/3有分)、外层是箭头朝向 → frame = (arrowRight ? 3 : 0) + tip
|
||||
// 箭头向左 = 头像在气泡左侧、箭头向右 = 头像在气泡右侧
|
||||
//尺寸:待定
|
||||
BUBBLE_TISHI: 651,
|
||||
|
||||
//用途:通用状态提示条底(深色半透圆角,白字居中)——「等待庄家选主」/「强甩失败」等共用(清单 §2.8)
|
||||
//帧数:1
|
||||
//帧说明:可九宫拉伸,按文案长度变宽
|
||||
//尺寸:363×50
|
||||
BAR_TOAST: 653,
|
||||
|
||||
//用途:捡分飘字底(「20分」金色)
|
||||
//帧数:1
|
||||
//帧说明:(无)
|
||||
//尺寸:待定
|
||||
TXT_GRADE_FLOAT: 654,
|
||||
|
||||
//===================== §3.8 建房选项 =====================
|
||||
|
||||
//用途:建房选择框(单选钮 + 复选框合一)
|
||||
//帧数:4(2×2)
|
||||
//帧说明:帧1=单选·未选中(灰空心环) 帧2=单选·已选中(橙实心环)
|
||||
// 帧3=复选·未选中(浅绿空方块) 帧4=复选·已选中
|
||||
// 帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0);「单选(可不选)」用复选帧 3/4
|
||||
//尺寸:22×22
|
||||
ROOM_OPT_BOX: 681,
|
||||
|
||||
//用途:建房类别标题(一张图多帧)
|
||||
//帧数:3
|
||||
//帧说明:帧1=牌局 帧2=模式 帧3=规则
|
||||
//尺寸:60×24
|
||||
ROOM_CAT_TITLE: 682,
|
||||
|
||||
//用途:选项行底
|
||||
//帧数:1
|
||||
//帧说明:参考图为无底透明,可选
|
||||
//尺寸:待定
|
||||
ROW_ROOM_OPT: 683
|
||||
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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, // 牌桌常驻:顶部信息条、玩家位附加标记、主牌统计条、底栏(局数 + 功能钮)
|
||||
HAND: 102, // 自己手牌区(单排 / 埋牌双排)
|
||||
TABLE_CARDS: 103, // 桌面牌:底牌区、三家已出牌区、埋牌底牌展示区
|
||||
ACTION: 104, // 阶段操作区:叫分面板、选主/投降面板、埋牌/出牌操作条、倒计时
|
||||
OVERLAY: 105, // 桌面浮层:提示气泡、报无主、甩错、捡分飘字、70 分底牌亮 3 秒
|
||||
|
||||
POPUP_ASET_RESULT: 302, // 小局结算(清单 §5.6a:冲关牌型叠加层群组 244 也挂在本图层)
|
||||
POPUP_ACCOUNT: 303, // 大局总结算 / 解散结算
|
||||
POPUP_HISTORY: 304, // 出牌历史面板
|
||||
POPUP_MINGPAI: 305, // 明牌面板
|
||||
//306 POPUP_DISBAND 解散投票面板——清单 T-2「是否由平台提供」未决,暂不占号,定案后再补
|
||||
|
||||
//建房规则选项区挂在平台创建房间界面 Layer 27 CreateRoom_Layer 上,不新开图层(清单 T-3)
|
||||
PLATFORM_CREATE_ROOM: 27
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout: 布局配置总则(清单 §6)/////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 设计基准 1280×720,ScreenFitMode 1,30fps(清单 §0.1)。
|
||||
// 【纯数据文件】无函数、无副作用、不引用其他模块——
|
||||
// fan 的间距算法、bySeat 合并、attach 换算都在 codes/ui/LayoutSolver.js。
|
||||
// 数值为参考图目视实测估值(±5px),设计稿到位后只改本层。
|
||||
//
|
||||
// 本文件须最先加载(其余 5 个 Layout_*.js 会引用本文件的 CARD_SIZE / TEXT_STYLE,
|
||||
// 这是 EQW_Layout 内部的自引用,不是跨模块引用)。
|
||||
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,逐条对应 §6.5–§6.9b 各表出现的 textStyle{...})
|
||||
EQW_Layout.TEXT_STYLE = {
|
||||
STATUS_BIG: { fontSize: 46, color: '#FFFFFF', align: 'center' }, //玩家位「60分」「不叫」(§6.6)
|
||||
CALL_BIG: { fontSize: 52, color: '#FFD24A', align: 'center' }, //自己叫分大字(§6.8)
|
||||
//结算总分大字原用 SCORE_BIG(§6.9),2026-08-27 改为多帧图数字精灵(NUM_STYLE.RESULT_WIN/RESULT_LOSE),
|
||||
//不再是文字精灵,SCORE_BIG 无消费者,移除(避免留一个没人用的样式常量)
|
||||
ASET_COUNT: { fontSize: 20, color: '#FFFFFF', align: 'center' }, //底栏「1/4 局」(§6.5)
|
||||
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 },
|
||||
ROOM_CARD_COST_NOTE: { fontSize: 16, color: '#999999' } //建房房卡消耗附注(§6.9b)
|
||||
};
|
||||
|
||||
//多帧图数字样式(清单 §3.6 / §6.8,2026-08-26 新增)——配合平台既有的
|
||||
//SpriteManager.setNumberImage(spriteId, text, charWidth):把整串数字设给【一个】多帧图片精灵,
|
||||
//引擎按字符逐帧渲染,精灵宽度由该接口按【字符数 × charWidth】自动调整。
|
||||
//故多位数不需要拆成十位/个位多个精灵,也不用 setFrame 逐位切。
|
||||
//本表是【零裸值红线】的落点:charWidth/charHeight 只在此定义一处,业务代码只引用不内联。
|
||||
//
|
||||
//字段:res —— 图片资源【键名】(与 CARD_SIZE.res 同一约定:纯数据文件不直接引用 EQW_Images,
|
||||
// 调用方用 EQW_Images[style.res] 取真实 ID)
|
||||
// charWidth —— 单字符宽(传给 setNumberImage;精灵宽 = 字符数 × charWidth,运行时自动变)
|
||||
// charHeight —— 单字符高(setNumberImage 不改高度,此值供布局节点定 h)
|
||||
// suffix —— 该套资源第 16 帧承载的后缀字(无后缀为 '';见 ImageResources.js §3.6 的帧序说明)
|
||||
EQW_Layout.NUM_STYLE = {
|
||||
//倒计时(§2.4):纯数字无后缀,秒数最多两位
|
||||
COUNTDOWN: { res: 'NUM_COUNTDOWN', charWidth: 44, charHeight: 60, suffix: '' },
|
||||
|
||||
//结算得分 · 赢(§1.8/§6.9):用到 '+'(帧12)与 '-'(帧13);后缀是否为「分」待定,见资源注释
|
||||
RESULT_WIN: { res: 'NUM_RESULT_WIN', charWidth: 46, charHeight: 62, suffix: '' },
|
||||
|
||||
//结算得分 · 输(§1.8/§6.9):帧序与赢的一致,仅配色不同
|
||||
RESULT_LOSE: { res: 'NUM_RESULT_LOSE', charWidth: 46, charHeight: 62, suffix: '' },
|
||||
|
||||
//叫分档位分数(§1.3/§6.8):14 档中 13 档是两位数,整串交给 setNumberImage,无后缀
|
||||
//(面板上的「N子」角标是文字精灵,不走这套)
|
||||
CALL_SCORE: { res: 'NUM_CALL_SCORE', charWidth: 30, charHeight: 40, suffix: '' },
|
||||
|
||||
//选主面板花色张数(§1.5/§6.8):显示「N张」,「张」= 第 16 帧;单花色最多 26 张
|
||||
MAIN_SUIT_COUNT: { res: 'NUM_MAIN_SUIT_COUNT', charWidth: 24, charHeight: 32, suffix: '张' }
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout: 阶段操作区配置(清单 §6.8)//////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
|
||||
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
|
||||
var EQW_Layout = EQW_Layout || {};
|
||||
|
||||
//===== 叫分面板 =====
|
||||
|
||||
EQW_Layout.CALL_PANEL_BG = { kind: 'point', x: 355, y: 140, w: 575, h: 170 };
|
||||
|
||||
EQW_Layout.CALL_HINT_BG = { kind: 'point', x: 470, y: 100, w: 345, h: 33 };
|
||||
|
||||
//14 档位按钮:grid 布局(清单 §6.1 示例,7×2)
|
||||
EQW_Layout.CALL_BTN_GRID = {
|
||||
kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
|
||||
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
|
||||
anchor: 'center', fillOrder: 'row'
|
||||
};
|
||||
|
||||
//档位分数数字(多帧图数字精灵):贴对应档位按钮居中。CALL_BTN_1..14 是 14 个固定精灵,但本节点是共用模板,
|
||||
//target 由调用方按第几档注入(不预置单一 target)。
|
||||
//宽度随位数变化(SpriteManager.setNumberImage 会按【字符数 × charWidth】自动改精灵宽),
|
||||
//故 w 留到运行时注入;高度恒为单字高,取 NUM_STYLE.CALL_SCORE.charHeight(SSOT,2026-08-26)
|
||||
//节点名去掉 _TEXT 后缀改为 _NUM:驱动的是数字图精灵而非文字精灵(2026-08-27)
|
||||
EQW_Layout.CALL_BTN_SCORE_NUM = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'middle', offsetY: 4,
|
||||
h: EQW_Layout.NUM_STYLE.CALL_SCORE.charHeight,
|
||||
runtime: ['target', 'w']
|
||||
};
|
||||
|
||||
//档位子数角标:贴对应档位按钮右上角,纯文字无底图,清单未给宽高(同上,调用方注入 target)
|
||||
EQW_Layout.CALL_BTN_MULTIPLE_BADGE = {
|
||||
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -30, offsetY: 2,
|
||||
runtime: ['target', 'w', 'h']
|
||||
};
|
||||
|
||||
EQW_Layout.CALL_BTN_NONE = { kind: 'point', x: 553, y: 345, w: 184, h: 57 };
|
||||
|
||||
//自己叫分大字
|
||||
EQW_Layout.CALL_SELF_SCORE_TEXT = {
|
||||
kind: 'point', x: 570, y: 320, w: 140, h: 70, textStyle: EQW_Layout.TEXT_STYLE.CALL_BIG
|
||||
};
|
||||
|
||||
//===== 选主 / 投降面板 =====
|
||||
|
||||
EQW_Layout.MAIN_TITLE_BG = { kind: 'point', x: 330, y: 315, w: 425, h: 50 };
|
||||
|
||||
//4 花色 + 投降:投降不显示时只排前 4 项,anchor:left 保证前 4 个位置不变
|
||||
EQW_Layout.MAIN_SUIT_LINE = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 330, anchorY: 373,
|
||||
itemWidth: 128, itemHeight: 55, spacing: 9, anchor: 'left'
|
||||
};
|
||||
|
||||
//张数数字:贴对应花色按钮下方居中,一个花色【一个】精灵,整串「N张」由 setNumberImage 显示。
|
||||
//2026-08-26:原十位/个位两节点合并为本节点;宽度随位数变化(2 或 3 个字符)留到运行时注入,
|
||||
//高度恒为单字高,取 NUM_STYLE.MAIN_SUIT_COUNT.charHeight(SSOT)
|
||||
EQW_Layout.MAIN_SUIT_COUNT = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'bottom', offsetY: -6,
|
||||
h: EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charHeight,
|
||||
runtime: ['target', 'w']
|
||||
};
|
||||
|
||||
//对数角标文字:贴对应花色按钮右上角(按钮帧内已含角标底,本节点只定位叠加的文字)。
|
||||
//清单未给宽高(调用方注入 target)
|
||||
EQW_Layout.MAIN_SUIT_PAIR_BADGE = {
|
||||
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -36, offsetY: 2, w: 34, h: 18,
|
||||
runtime: ['target']
|
||||
};
|
||||
|
||||
//===== 埋牌 / 出牌操作条 =====
|
||||
|
||||
//埋牌操作条:提示 / 倒计时(CD_SELF) / 埋牌,逐项不等宽(清单 §6.1)
|
||||
EQW_Layout.BURY_OPERATION_BAR = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 640, anchorY: 165,
|
||||
itemHeight: 63, spacing: 45, anchor: 'center',
|
||||
items: [
|
||||
{ key: 'BURY_BTN_TIP', width: 180 },
|
||||
{ key: 'CD_SELF', width: 60 },
|
||||
{ key: 'BURY_BTN_SUBMIT', width: 180 }
|
||||
]
|
||||
};
|
||||
|
||||
//出牌操作条:倒计时(CD_SELF) / 出牌 / 提示,逐项不等宽(清单 §6.1)。
|
||||
//2026-08-27 补上「提示」项:清单 §1.7 明确「出牌操作条 = 倒计时 + 出牌 按钮 + 提示 按钮
|
||||
//(出牌阶段必须有)」,但 §6.8 给的 items 只有 [75,157](倒计时/出牌)——清单自身漏了提示;
|
||||
//排序照 §1.7 的行文(倒计时 → 出牌 → 提示)。
|
||||
//【估值】提示 180:清单未给该项宽度;提示按钮与埋牌条的提示按钮同资源 BTN_TIP(§3.3 实测 180×63),
|
||||
//埋牌条 items 首项(同一个提示按钮)也取 180,故沿用 180,待设计稿复核。
|
||||
//⚠ 本条 itemHeight 为 55(对齐出牌按钮 BTN_PLAY 157×55),而 BTN_TIP 原生高 63——
|
||||
// 求解结果里提示项的 height 会是 55,实际显示高度以精灵自身资源为准;此高度差同样待设计稿裁定
|
||||
//
|
||||
//2026-08-27 修订:补「提示」项后原先仍用 anchor:'center',导致整排重新居中、把已有的
|
||||
//CD_SELF / PLAY_BTN_SUBMIT 两项相对原先(仅 2 项时)左移了 107.5px——「出牌」按钮的位置在
|
||||
//参考图 `出牌.png` 是有实测依据的(556–711,宽约 156×57),不该因为插入第 3 项而漂移。
|
||||
//改为 anchor:'left',把 anchorX 定成【首项 CD_SELF 的起点】,让先前已有的两项固定不动、只是
|
||||
//在其后追加「提示」——与 FOOTER_FUNC_BUTTONS 用 anchor:'right' 固定住最右项「底牌」是同一思路
|
||||
//(那边插入更早的项,这边追加更晚的项,对称地选左/右锚点)。
|
||||
//反推 anchorX:_solveLineItems 在 anchor:'left' 下 start = anchorX,故
|
||||
// PLAY_BTN_SUBMIT.x = anchorX + CD_SELF.width(75) + spacing(35) = anchorX + 110
|
||||
//要 PLAY_BTN_SUBMIT.x ≈ 556(参考图实测),故 anchorX = 556 - 110 = 446。
|
||||
//实际调用求解器验证(count=3,spacing=35):
|
||||
// CD_SELF x=446 width=75 (参考图目测约 444,估值层面 2px 差可接受)
|
||||
// PLAY_BTN_SUBMIT x=556 width=157 (与参考图 556–711 实测吻合)
|
||||
// PLAY_BTN_TIP x=748 width=180
|
||||
EQW_Layout.PLAY_OPERATION_BAR = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 446, anchorY: 345,
|
||||
itemHeight: 55, spacing: 35, anchor: 'left',
|
||||
items: [
|
||||
{ key: 'CD_SELF', width: 75 },
|
||||
{ key: 'PLAY_BTN_SUBMIT', width: 157 },
|
||||
{ key: 'PLAY_BTN_TIP', width: 180 }
|
||||
]
|
||||
};
|
||||
|
||||
//===== 浮层 =====
|
||||
|
||||
//捡分飘字:起始位置在此。动画参数(floatRise / floatDuration)见 EQW_Anim.SCORE_FLOAT,不在此重复(SSOT)
|
||||
EQW_Layout.GRADE_FLOAT = { kind: 'point', x: 570, y: 180, w: 170, h: 55 };
|
||||
|
||||
//状态提示条 · 等待类(如『等待庄家选主』)。与即时反馈类共用同一套精灵 BAR_TOAST/TXT_TOAST,
|
||||
//仅位置预设不同,调用方按提示类型二选一应用(清单 §2.8:收敛为 3 条)
|
||||
EQW_Layout.TOAST_WAIT = { kind: 'point', x: 500, y: 373, w: 320, h: 47 };
|
||||
|
||||
//状态提示条 · 即时反馈类(如『强甩失败』)。自动消失时长见 EQW_Anim.TOAST_DURATION,不在此重复(SSOT)
|
||||
EQW_Layout.TOAST_INSTANT = { kind: 'point', x: 462, y: 172, w: 363, h: 50 };
|
||||
@@ -0,0 +1,100 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout: 牌区配置(清单 §6.7)////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
|
||||
// (引用其 EQW_Layout.CARD_SIZE,属 EQW_Layout 内部自引用)。
|
||||
var EQW_Layout = EQW_Layout || {};
|
||||
|
||||
//===== 自己手牌区(清单 §6.7)=====
|
||||
|
||||
//自己手牌 · 单排:36 张时 spacing ≈ -78(露 32px),与参考图实测 33px 吻合
|
||||
EQW_Layout.HAND_FAN_SINGLE = {
|
||||
kind: 'fan', direction: 'horizontal',
|
||||
anchorX: 640, anchorY: 455, maxWidth: 1215,
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left'
|
||||
};
|
||||
|
||||
//自己手牌 · 双排(埋牌)。分行策略是数据标记 splitStrategy,由调用方据此分行,本文件不写函数:
|
||||
//floorTop = 上排 floor(n/2)、下排 ceil(n/2)(36 张 = 18/18;参考图 17/19 属填充画法,不作依据,见清单 §0.5)。
|
||||
//spacingMax / anchor / overlapFrom 清单双排行未重复给出,沿用单排同参数(同一批牌、同一压缩规则)
|
||||
EQW_Layout.HAND_TWO_ROW = {
|
||||
splitStrategy: 'floorTop',
|
||||
ROW_TOP: {
|
||||
kind: 'fan', direction: 'horizontal',
|
||||
anchorX: 644, anchorY: 285, maxWidth: 928,
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
spacingMax: 0, spacingMin: -65, anchor: 'center', overlapFrom: 'left'
|
||||
},
|
||||
ROW_BOTTOM: {
|
||||
kind: 'fan', direction: 'horizontal',
|
||||
anchorX: 644, anchorY: 455, maxWidth: 928,
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
spacingMax: 0, spacingMin: -65, anchor: 'center', overlapFrom: 'left'
|
||||
}
|
||||
};
|
||||
|
||||
//手牌 · 选中上浮偏移量定义在 EQW_Anim.HAND_SELECT_OFFSET_Y(AnimConstants.js),此处不重复(SSOT)
|
||||
|
||||
//底牌区(8 张,未揭示为牌背、揭示后翻正面,同一批精灵)
|
||||
EQW_Layout.BOTTOM_CARDS = {
|
||||
kind: 'line', direction: 'horizontal',
|
||||
anchorX: 655, anchorY: 110,
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
spacing: -36, anchor: 'center'
|
||||
};
|
||||
|
||||
//埋牌底牌区(结算,8 张,尺寸档 S)
|
||||
EQW_Layout.BURY_CARDS = {
|
||||
kind: 'line', direction: 'horizontal',
|
||||
anchorX: 640, anchorY: 163,
|
||||
itemWidth: EQW_Layout.CARD_SIZE.S.w, itemHeight: EQW_Layout.CARD_SIZE.S.h,
|
||||
spacing: 2, anchor: 'center'
|
||||
};
|
||||
|
||||
//===== 已出牌区(清单 §6.2 示例 / §6.7)=====
|
||||
|
||||
//三家已出牌区:三家共用 base,LEFT 与 RIGHT 左右镜像(overlapFrom 相反)
|
||||
EQW_Layout.PLAY_AREA = {
|
||||
kind: 'fan', direction: 'horizontal',
|
||||
itemWidth: EQW_Layout.CARD_SIZE.M.w, itemHeight: EQW_Layout.CARD_SIZE.M.h,
|
||||
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' }
|
||||
}
|
||||
};
|
||||
|
||||
//已出牌 · 牌型标签:贴该区第一张牌。首槽是固定精灵(PLAY_SELF_1 / PLAY_LEFT_1 / PLAY_RIGHT_1),非动态目标。
|
||||
//资源 LABEL_CARDTYPE 76×24(清单 §3)
|
||||
EQW_Layout.PLAY_TYPE_LABEL = {
|
||||
kind: 'attach', targetKind: 'sprite', corner: 'bottomLeft', offsetX: 0, offsetY: -24, w: 76, h: 24,
|
||||
bySeat: {
|
||||
SELF: { target: 'PLAY_SELF_1' },
|
||||
LEFT: { target: 'PLAY_LEFT_1' },
|
||||
RIGHT: { target: 'PLAY_RIGHT_1' }
|
||||
}
|
||||
};
|
||||
|
||||
//已出牌 · 牌角『庄』/『大』标:贴的是本轮打出的哪张具体牌,运行时才能确定,不是固定精灵键。
|
||||
//本节点只是模板(corner/offset),调用方在确定具体是哪张牌之后,以此为基础补上 target 再喂给
|
||||
//EQW_LayoutSolver.solve——这是有意的动态目标模式,不在配置里预置 target(清单未给尺寸;
|
||||
//两种资源分别是 MARK_BANKER_CORNER 28×28 / MARK_JOKER_BIG 34×34,按实际显示的是哪个徽标取值)
|
||||
EQW_Layout.PLAY_CARD_CORNER_MARK = {
|
||||
kind: 'attach', targetKind: 'sprite', corner: 'topRight', offsetX: -28, offsetY: 0,
|
||||
runtime: ['target', 'w', 'h']
|
||||
};
|
||||
|
||||
//冲关牌(叠加层):精灵复制场景(§5.6a),spacingMin 放宽到 -88 以容纳 20+ 张的极端情况。
|
||||
//anchor / direction 清单未重复给出,沿用其余 fan 配置的通用约定(center / horizontal)
|
||||
EQW_Layout.CHONGGUAN_FAN = {
|
||||
kind: 'fan', direction: 'horizontal', anchor: 'center',
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
maxWidth: 420, spacingMax: -40, spacingMin: -88,
|
||||
bySeat: {
|
||||
LEFT: { anchorX: 260, anchorY: 120 },
|
||||
RIGHT: { anchorX: 1035, anchorY: 120 },
|
||||
SELF: { anchorX: 640, anchorY: 400 }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout / EQW_RoomOptions: 建房规则选项(清单 §6.9b / §1.1)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
|
||||
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
|
||||
var EQW_Layout = EQW_Layout || {};
|
||||
|
||||
//===== 建房规则选项区布局(清单 §6.9b)=====
|
||||
//两层嵌套:类别竖排(ROOM_CATEGORY_COLUMN)+ 类别内选项槽横排(ROOM_OPTION_ROW)。
|
||||
//本节数值按参考图比例估,实际以平台建房界面尺寸为准(清单 §6.9b 原文,不是本文件误差)。
|
||||
|
||||
//类别竖排。itemWidth 清单未给——类别行宽度等于平台内容区实际宽度,由调用方按实际面板尺寸注入。
|
||||
//anchor 必须是竖排语义的 top/bottom/center:写成水平语义的 'left' 会被框架 switch 的 default
|
||||
//当成居中(3 个类别时算出 y=[-56,-16,24],前两行跑到画布外),求解器现已按方向校验并抛错
|
||||
EQW_Layout.ROOM_CATEGORY_COLUMN = {
|
||||
kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0,
|
||||
itemHeight: 32, spacing: 8, anchor: 'top',
|
||||
runtime: ['itemWidth']
|
||||
};
|
||||
|
||||
//类别标题:贴所在类别行。该行是 ROOM_CATEGORY_COLUMN 求解结果的第 N 项,不是固定精灵,
|
||||
//target 由调用方按类别序号注入。资源 ROOM_CAT_TITLE 60×24(清单 §3)
|
||||
EQW_Layout.ROOM_CATEGORY_TITLE = {
|
||||
kind: 'attach', targetKind: 'layout', hAlign: 'left', vAlign: 'middle', offsetX: 0, w: 60, h: 24,
|
||||
runtime: ['target']
|
||||
};
|
||||
|
||||
//类别内选项槽横排。anchorY 清单未给(随所在类别行的 y 浮动,由调用方按该行位置注入);
|
||||
//itemHeight 清单未给,沿用 ROOM_CATEGORY_COLUMN.itemHeight(同一行内选择框与文字纵向居中)
|
||||
EQW_Layout.ROOM_OPTION_ROW = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 70,
|
||||
itemWidth: 150, itemHeight: EQW_Layout.ROOM_CATEGORY_COLUMN.itemHeight, spacing: 0, anchor: 'left',
|
||||
runtime: ['anchorY']
|
||||
};
|
||||
|
||||
//选项组之间的额外间隔(同类别下两个选项组之间比组内选项多留的间距)
|
||||
EQW_Layout.ROOM_OPTION_GROUP_GAP = 40;
|
||||
|
||||
//选择框:贴所在选项槽左侧(资源 ROOM_OPT_BOX 22×22)。target 由调用方按选项槽注入
|
||||
//(槽是 ROOM_OPTION_ROW 求解结果的第 N 项,故 targetKind 是 layout 而非精灵键)
|
||||
EQW_Layout.ROOM_OPTION_BOX = {
|
||||
kind: 'attach', targetKind: 'layout', hAlign: 'left', vAlign: 'middle', w: 22, h: 22,
|
||||
runtime: ['target']
|
||||
};
|
||||
|
||||
//选项文字:贴同槽选择框右侧(target 是该槽的 ROOM_BOX_* 精灵,由调用方按选项配置的 box 键注入),
|
||||
//纯文字,清单未给宽高,由调用方按渲染出的文案尺寸注入。
|
||||
//选中态变橙见 EQW_Layout.TEXT_STYLE.ROOM_OPT(normal / selected 两态,由调用方按选中状态二选一)
|
||||
EQW_Layout.ROOM_OPTION_TEXT = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'right', vAlign: 'middle', offsetX: 8,
|
||||
runtime: ['target', 'w', 'h']
|
||||
};
|
||||
|
||||
//房卡附注:贴扣卡组最后一个选项(固定精灵 ROOM_BOX_CARD_2)右侧。纯文字,宽高随文案运行时注入
|
||||
EQW_Layout.ROOM_CARD_COST_NOTE = {
|
||||
kind: 'attach', targetKind: 'sprite', target: 'ROOM_BOX_CARD_2',
|
||||
hAlign: 'right', vAlign: 'middle', offsetX: 20,
|
||||
textStyle: EQW_Layout.TEXT_STYLE.ROOM_CARD_COST_NOTE,
|
||||
runtime: ['w', 'h']
|
||||
};
|
||||
|
||||
//折行:选项数超过 maxPerRow 时改用 grid。二七王每组最多 2 项,不会触发(清单 §6.9b 原文);
|
||||
//清单原文是 grid{cols:maxPerRow, spacingX:0, spacingY:8, fillOrder:'row'},cols 直接取自 maxPerRow,
|
||||
//故与 spacingX/spacingY/fillOrder 一并记为已定的四个字段。
|
||||
//rows 由实际选项数决定(ceil(选项数 / cols)),anchorX/anchorY 随所在类别行浮动 —— 三者都是运行时值,
|
||||
//在 runtime 里显式声明;缺 rows 时求解器会抛错,不再静默返回空数组(一个矩形都不产出)。
|
||||
//itemWidth / itemHeight 清单同样未给:折行摆的还是同一批选项槽,沿用 ROOM_OPTION_ROW 的槽尺寸,
|
||||
//不另编一套数值;anchor 同理沿用 ROOM_OPTION_ROW 的 'left'(折行前后左边缘对齐一致)
|
||||
EQW_Layout.ROOM_OPTION_MAX_PER_ROW = 4;
|
||||
EQW_Layout.ROOM_OPTION_OVERFLOW_GRID = {
|
||||
kind: 'grid', cols: EQW_Layout.ROOM_OPTION_MAX_PER_ROW, spacingX: 0, spacingY: 8, fillOrder: 'row',
|
||||
itemWidth: EQW_Layout.ROOM_OPTION_ROW.itemWidth, itemHeight: EQW_Layout.ROOM_OPTION_ROW.itemHeight,
|
||||
anchor: 'left',
|
||||
runtime: ['rows', 'anchorX', 'anchorY']
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_RoomOptions: 建房规则选项配置(清单 §1.1)///////
|
||||
///////////////////////////////////////////////////////////////
|
||||
//整个界面由本配置驱动渲染,加一个选项 = 配置里加一条,不改渲染逻辑(工程总则 §5 OCP / §6 配置优先)。
|
||||
//type: 'radio' 单选 / 'checkbox' 多选 / 'radioOptional' 单选可不选
|
||||
//选择框帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0),useCheckbox = (checkbox 或 radioOptional)
|
||||
//
|
||||
//【精灵键由配置给出】每个类别声明自己的标题精灵 titleSprite,每个选项声明自己的选择框精灵 box
|
||||
//与文字精灵 text,附注声明 noteSprite——渲染器一律按键名取精灵,【不得按下标拼字符串】
|
||||
//(组的 key 是 'rules'、精灵却叫 ROOM_BOX_RULE_1,本来就拼不出来)。
|
||||
//
|
||||
//【roomtype 拼串只在一处实现】拼串就是遍历本配置,不得在别处按下标硬拼
|
||||
//(与服务端 class.config.js parse() 对称的 SSOT 要求)。bit/value 统一挂在【每个 item 上】:
|
||||
// - radio:组内各 item 写同一个 bit,选中哪个就把该 item 的 value 写进该位;
|
||||
// - checkbox:勾选写 item.value、不勾写 '0'。
|
||||
//这样拼串循环对两种 type 是同一个形状(item → 写 value 到 bit),不必按组/项两种层级分支
|
||||
var EQW_RoomOptions = EQW_RoomOptions || {
|
||||
categories: [
|
||||
{ titleFrame: 1, titleSprite: 'ROOM_CAT_TITLE_1', /* 牌局 */ groups: [
|
||||
{ key: 'aset', type: 'radio',
|
||||
items: [ { label: '6局', value: '0', bit: 0, box: 'ROOM_BOX_ASET_1', text: 'ROOM_TEXT_ASET_1' },
|
||||
{ label: '12局', value: '1', bit: 0, box: 'ROOM_BOX_ASET_2', text: 'ROOM_TEXT_ASET_2' } ] },
|
||||
{ key: 'card', type: 'radio', note: 'cardCost', noteSprite: 'ROOM_TEXT_CARD_COST',
|
||||
items: [ { label: '房主扣卡', value: '0', bit: 1, box: 'ROOM_BOX_CARD_1', text: 'ROOM_TEXT_CARD_1' },
|
||||
{ label: 'AA每人扣卡', value: '1', bit: 1, box: 'ROOM_BOX_CARD_2', text: 'ROOM_TEXT_CARD_2' } ] }
|
||||
] },
|
||||
{ titleFrame: 2, titleSprite: 'ROOM_CAT_TITLE_2', /* 模式 */ groups: [
|
||||
{ key: 'peek', type: 'radio',
|
||||
items: [ { label: '可查牌', value: '0', bit: 4, box: 'ROOM_BOX_PEEK_1', text: 'ROOM_TEXT_PEEK_1' },
|
||||
{ label: '不查牌', value: '1', bit: 4, box: 'ROOM_BOX_PEEK_2', text: 'ROOM_TEXT_PEEK_2' } ] }
|
||||
] },
|
||||
{ titleFrame: 3, titleSprite: 'ROOM_CAT_TITLE_3', /* 规则 */ groups: [
|
||||
{ key: 'rules', type: 'checkbox',
|
||||
items: [ { label: '傍王', value: '1', bit: 2, box: 'ROOM_BOX_RULE_1', text: 'ROOM_TEXT_RULE_1' },
|
||||
{ label: '爬坡', value: '1', bit: 3, box: 'ROOM_BOX_RULE_2', text: 'ROOM_TEXT_RULE_2' } ] }
|
||||
] }
|
||||
],
|
||||
//房卡消耗联动(协议 §0.5):不硬编码在 UI 代码里
|
||||
cardCost: {
|
||||
'0': { '0': '房主2张', '1': '房主4张' }, //房主扣卡:6局 / 12局
|
||||
'1': { '0': '每人1张', '1': '每人2张' } //AA 每人扣卡:6局 / 12局
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout: 结算区配置(清单 §6.9 / §5.7a / §5.7b)///
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 小局结算 + 冲关遮罩 + 大局总结算 + 出牌历史/亮牌 + 明牌,共五块。
|
||||
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
|
||||
// (引用其 EQW_Layout.NUM_STYLE.* / CARD_SIZE.*,属 EQW_Layout 内部自引用)。
|
||||
var EQW_Layout = EQW_Layout || {};
|
||||
|
||||
EQW_Layout.RESULT_BOTTOM_PANEL = { kind: 'point', x: 430, y: 140, w: 420, h: 148 };
|
||||
|
||||
//底牌说明文字:贴面板底部,纯文字,清单未给宽高
|
||||
EQW_Layout.RESULT_BOTTOM_TEXT = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'RESULT_BOTTOM_PANEL',
|
||||
hAlign: 'center', vAlign: 'bottom', offsetY: -14,
|
||||
runtime: ['w', 'h']
|
||||
};
|
||||
|
||||
//三家分数光晕
|
||||
EQW_Layout.RESULT_GLOW = {
|
||||
kind: 'point', w: 225, h: 100,
|
||||
bySeat: {
|
||||
LEFT: { x: 175, y: 150 },
|
||||
RIGHT: { x: 865, y: 150 },
|
||||
SELF: { x: 490, y: 340 }
|
||||
}
|
||||
};
|
||||
|
||||
//三家总分大字(多帧图数字精灵,非文字精灵,2026-08-27):贴对应光晕居中。
|
||||
//target 直接指向光晕精灵键(固定,非动态);高度恒为单字高,取 NUM_STYLE.RESULT_WIN.charHeight
|
||||
//(与 NUM_STYLE.RESULT_LOSE 相同,SSOT);宽度随位数变化,留到运行时注入
|
||||
//节点名去掉 _TEXT 后缀改为 _NUM:与 CALL_BTN_SCORE_NUM 同一理由,_TEXT 会让人误以为是文字精灵
|
||||
EQW_Layout.RESULT_SCORE_NUM = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'middle',
|
||||
h: EQW_Layout.NUM_STYLE.RESULT_WIN.charHeight, runtime: ['w'],
|
||||
bySeat: {
|
||||
LEFT: { target: 'RESULT_GLOW_LEFT' },
|
||||
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
|
||||
SELF: { target: 'RESULT_GLOW_SELF' }
|
||||
}
|
||||
};
|
||||
|
||||
//三家『牌局分』:贴对应光晕左下
|
||||
EQW_Layout.RESULT_JF_TEXT = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'left', vAlign: 'bottom', offsetX: -15, offsetY: 24,
|
||||
runtime: ['w', 'h'],
|
||||
bySeat: {
|
||||
LEFT: { target: 'RESULT_GLOW_LEFT' },
|
||||
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
|
||||
SELF: { target: 'RESULT_GLOW_SELF' }
|
||||
}
|
||||
};
|
||||
|
||||
//三家『冲关分』:贴对应光晕右下
|
||||
EQW_Layout.RESULT_AW_TEXT = {
|
||||
kind: 'attach', targetKind: 'sprite', hAlign: 'right', vAlign: 'bottom', offsetX: 15, offsetY: 24,
|
||||
runtime: ['w', 'h'],
|
||||
bySeat: {
|
||||
LEFT: { target: 'RESULT_GLOW_LEFT' },
|
||||
RIGHT: { target: 'RESULT_GLOW_RIGHT' },
|
||||
SELF: { target: 'RESULT_GLOW_SELF' }
|
||||
}
|
||||
};
|
||||
|
||||
EQW_Layout.RESULT_BTN_CG = { kind: 'point', x: 535, y: 515, w: 183, h: 63 };
|
||||
|
||||
EQW_Layout.RESULT_BTN_NEXT = { kind: 'point', x: 835, y: 518, w: 180, h: 65 };
|
||||
|
||||
//冲关遮罩:透明度也是配置项
|
||||
EQW_Layout.CG_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
|
||||
|
||||
//===== 大局总结算 / 解散结算(AccountView,群组 241)=====
|
||||
//
|
||||
// 【坐标来源 · 2026-08-27 补齐】清单 §6.9 没有本面板的配置表,以下数值全部是
|
||||
// docs_dev/uiref/大局结算.png 的【目视实测估值】(±5px),面板/标题栏/规则栏/按钮的 w/h
|
||||
// 取清单 §3.5 / §3.3 的资源尺寸(那是权威值)。设计稿到位后只改本节。
|
||||
// 之所以现在就落成配置:不补的话 C/D/E 阶段写渲染代码时会就地硬编码坐标(零裸值红线)。
|
||||
//
|
||||
// 【玩家栏是精灵复制】三条栏结构相同、条数随人数变,故不逐行写死,而是给
|
||||
//「容器 ACC_ROW_CONTAINER + 行节点 ACC_ROW(行高即 itemHeight)+ 行内各模板贴附到行矩形」。
|
||||
// 约定:ACC_ROW 及贴附到它的 ACC_TPL_* / ACC_ROW_TEXT_GRID 求解出的坐标都是
|
||||
//【相对 ACC_ROW_CONTAINER 的】——SpriteCopyUtils 复制出的精灵是容器的子精灵,
|
||||
// x/y 本就相对容器,求解结果可直接喂给 SpriteCopyUtils.create(与 §5.6a 冲关牌同一用法)。
|
||||
// 行数由数据给出(ctx.count),不进配置(清单 §6.1)。
|
||||
|
||||
//面板底(1100×540,资源 PANEL_ACCOUNT)
|
||||
EQW_Layout.ACC_PANEL_BG = { kind: 'point', x: 80, y: 88, w: 1100, h: 540 };
|
||||
|
||||
//标题栏:封住面板上沿(1100×56,资源 BAR_ACCOUNT_TITLE)
|
||||
EQW_Layout.ACC_TITLE_BAR = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_PANEL_BG',
|
||||
corner: 'topLeft', offsetX: 0, offsetY: 0, w: 1100, h: 56
|
||||
};
|
||||
|
||||
//标题文字「二七王-3人 房号… 共…局 …」:贴标题栏内部左侧居中。
|
||||
//文案随房间信息变长变短,真实宽高要等文案渲染出来才知道 → 留到运行时注入
|
||||
EQW_Layout.ACC_TITLE_TEXT = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_TITLE_BAR',
|
||||
hAlign: 'left', vAlign: 'middle', offsetX: 30,
|
||||
runtime: ['w', 'h']
|
||||
};
|
||||
|
||||
//关闭按钮:贴标题栏右上角(36×36,资源 BTN_ACC_CLOSE)
|
||||
EQW_Layout.ACC_BTN_CLOSE = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_TITLE_BAR',
|
||||
corner: 'topRight', offsetX: -20, offsetY: 10, w: 36, h: 36
|
||||
};
|
||||
|
||||
//规则栏:封住面板下沿(1100×62,资源 BAR_ACCOUNT_RULE)
|
||||
EQW_Layout.ACC_RULE_BAR = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_PANEL_BG',
|
||||
corner: 'bottomLeft', offsetX: 0, offsetY: 0, w: 1100, h: 62
|
||||
};
|
||||
|
||||
//规则文字「规则:可查牌、傍王、爬坡算子」:贴规则栏内部左侧居中,文案据 roomtype 拼出、长度不定
|
||||
EQW_Layout.ACC_RULE_TEXT = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_RULE_BAR',
|
||||
hAlign: 'left', vAlign: 'middle', offsetX: 30,
|
||||
runtime: ['w', 'h']
|
||||
};
|
||||
|
||||
//玩家栏容器(ACC_ROW_CONTAINER 精灵的位置):标题栏下沿 144 到规则栏上沿 566 之间
|
||||
EQW_Layout.ACC_ROW_CONTAINER = { kind: 'point', x: 80, y: 144, w: 1100, h: 422 };
|
||||
|
||||
//玩家栏(行):竖排等距,itemHeight 即【行高/行距】。坐标相对容器,行数由 ctx.count 给出
|
||||
EQW_Layout.ACC_ROW = {
|
||||
kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 14,
|
||||
itemWidth: 1100, itemHeight: 132, spacing: 0, anchor: 'top'
|
||||
};
|
||||
|
||||
//行内 · 名次徽章:贴该行内部左侧居中(44×44,资源 BADGE_RANK,帧=名次1–3)
|
||||
EQW_Layout.ACC_TPL_RANK = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
|
||||
hAlign: 'left', vAlign: 'middle', offsetX: 28, w: 44, h: 44
|
||||
};
|
||||
|
||||
//行内 · 头像:贴该行内部左侧居中,名次徽章右侧
|
||||
EQW_Layout.ACC_TPL_AVATAR = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
|
||||
hAlign: 'left', vAlign: 'middle', offsetX: 92, w: 80, h: 80
|
||||
};
|
||||
|
||||
//行内 · 文字栅格(ACC_TPL_TEXT 复制出的 6 条文字的槽位):4 列 × 2 行,行优先——
|
||||
// 列1: 昵称 / ID:{playerid} 列2: 基础分{n} / 总得分{n}
|
||||
// 列3: 冲关分{n}(参考图小字写「算奖分」,清单 §1.9 定案字段为 grade_cg_total)
|
||||
// 列4: 傍王分{n}
|
||||
// 第 2 行只有前两列有文字,第 3/4 列留空(求解仍返回 8 个槽位,调用方按数据取前 6 个)。
|
||||
// anchorX 相对容器(= 参考图列1 绝对 x272 − 容器 x80);anchorY 随所在行浮动 → 运行时注入
|
||||
// 【估值精度说明】本节点用等距列模型(itemWidth:176 spacingX:0 → 相对容器 192/368/544/720),
|
||||
// 而参考图「大局结算.png」目视实测的列 x 约为 272/455/634/800(列距 183/179/166,并不等距)——
|
||||
// 与等距模型偏差约 10px,比本文件头部通用声明的「±5px」更大,待设计稿复核(本次未改数值,仅订正估值说明)
|
||||
EQW_Layout.ACC_ROW_TEXT_GRID = {
|
||||
kind: 'grid', anchorX: 192, cols: 4, rows: 2,
|
||||
itemWidth: 176, itemHeight: 34, spacingX: 0, spacingY: 10,
|
||||
anchor: 'left', fillOrder: 'row',
|
||||
runtime: ['anchorY']
|
||||
};
|
||||
|
||||
//行内 · 右侧总分大字(多帧图数字精灵):贴该行内部右侧居中。
|
||||
//高度恒为单字高,取 NUM_STYLE.RESULT_WIN.charHeight(与 RESULT_LOSE 相同,SSOT);
|
||||
//宽度随位数变化(setNumberImage 按【字符数 × charWidth】自动改精灵宽),留到运行时注入
|
||||
EQW_Layout.ACC_TPL_SCORE_NUM = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
|
||||
hAlign: 'right', vAlign: 'middle', offsetX: -25,
|
||||
h: EQW_Layout.NUM_STYLE.RESULT_WIN.charHeight, runtime: ['w']
|
||||
};
|
||||
|
||||
//行内 · 行间分隔线:贴该行【下沿】(vAlign bottom 的语义是"目标底边之下",见 AlignmentUtils)。
|
||||
//1060×2,资源 LINE_DIVIDER;水平居中于 1100 宽的行,左右各留 20
|
||||
EQW_Layout.ACC_TPL_DIVIDER = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'ACC_ROW',
|
||||
hAlign: 'center', vAlign: 'bottom', offsetX: 0, offsetY: 0, w: 1060, h: 2
|
||||
};
|
||||
|
||||
//面板【外】底部 4 个功能按钮(再来一局/分享好友/复制战绩/退出房间,各 190×60):
|
||||
//绝对坐标(不在玩家栏容器内)。参考图里这排不是居中于屏幕、而是整体偏右,故用 anchor:left 定起点
|
||||
EQW_Layout.ACC_FUNC_BUTTONS = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 345, anchorY: 640,
|
||||
itemWidth: 190, itemHeight: 60, spacing: 25, anchor: 'left'
|
||||
};
|
||||
|
||||
//===== 出牌历史 / 上一轮 / 亮牌(HistoryView,群组 242)=====
|
||||
//
|
||||
// 【坐标来源 · 2026-08-27 补齐】清单 §5.7b 说本 View 与 MingPaiView、ChongGuanOverlayView
|
||||
// 三者【同一种版式】(半透黑遮罩 + 每家一排重叠大牌),参考图同为
|
||||
// docs_dev/uiref/冲关牌型显示.png;故以下节点照 CHONGGUAN_FAN / CG_MASK 同构给出,
|
||||
// 三家落点沿用清单 §6.7 的冲关牌锚点(LEFT 260,120 / RIGHT 1035,120 / SELF 640,400)。
|
||||
// 与冲关牌的唯一差别是【张数上限】:出牌历史摊平后可达 28 张以上,420 的 maxWidth 装不下。
|
||||
// 2026-08-27 订正:原估值 maxWidth:500 是算错的——_solveFan 的间距公式是
|
||||
// spacing = max(spacingMin, min(spacingMax, (maxWidth-itemWidth)/(count-1) - itemWidth))
|
||||
// 代入 maxWidth 500 / itemWidth 110(CARD_SIZE.L.w)/ count 28:
|
||||
// (500-110)/27 - 110 = -95.56,没有触到 spacingMin:-96 的下限,故实际 spacing 是 -95.56,
|
||||
// 而 fan 的总宽恒等于 maxWidth(AlignmentUtils.distributeHorizontally 的 totalWidth 公式代入后
|
||||
// 可化简为 maxWidth,只要 spacing 没被下限截断)——不是旧注释算的「488」。
|
||||
// RIGHT 锚点 anchorX:1035、anchor:center 时,总宽 500 使整排右边缘落在 1035+250=1285,
|
||||
// 超出 1280 设计画布 5px(LEFT/SELF 因锚点更靠左,仍在画布内,故此前没暴露)。
|
||||
// 2026-08-27 再订正(裁定 maxWidth:490 → 480):490 虽能让 RIGHT 恰好贴住 1280,但零余量太脆弱——
|
||||
// 这些锚点本身是参考图目视实测(±5px 误差),且 490 时 spacingMin:-96 永远不触底、是个死参数,
|
||||
// 与「每张最窄只露 14px」这条约束名存实亡;旧注释原本写的「每张露 14px、总宽 488」正是
|
||||
// spacingMin 触底后的结果,说明 488 才是当初想要的值,maxWidth:500 是与注释不符的笔误。
|
||||
// 故改为 maxWidth:480:代入公式 (480-110)/27-110 = -96.30,**触及** spacingMin:-96 下限,
|
||||
// 实际 spacing 被截断为 -96(不再等于 raw),总宽 = 28×110 + 27×(-96) = 3080-2592 = 488(不是 480,
|
||||
// 因为 spacing 被下限截断后总宽不再恒等于 maxWidth)。RIGHT 边缘 = 1035+488/2 = 1279,留 1px 余量;
|
||||
// LEFT 边缘 = 260+244 = 504,SELF 边缘 = 640+244 = 884,均有余量。spacingMin:-96 就此真正生效
|
||||
// (不再是死参数)。每张露 14px(itemWidth+spacing = 110-96 = 14,与旧注释「14px」精确一致)。
|
||||
// maxWidth:480 与 spacingMin:-96 仍是【估值,待设计稿复核】——清单未给本 View 的排布参数。
|
||||
// 【已知限制,留给后来者】每张仅露 14px、视觉偏窄,待设计稿复核是否改为分两排显示,
|
||||
// 或改用 CARD_SIZE.M(更小的牌面)——这是本次估值阶段接受的限制,不是本次要解决的问题。
|
||||
//
|
||||
// 【牌用精灵复制】求解结果直接作为 SpriteCopyUtils.create 的 x/y(相对容器),
|
||||
// 与 §5.6a 冲关牌同一用法:三个容器精灵摆在原点、尺寸铺满屏,故容器内坐标 = 屏幕坐标。
|
||||
|
||||
//出牌历史 · 遮罩(兼点击关闭热区;【亮牌】用途下不注册点击,见 Sprites_Result.js 说明)
|
||||
EQW_Layout.HIST_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
|
||||
|
||||
//出牌历史 · 三个牌容器(HIST_BOX_LEFT/RIGHT/SELF):同摆在原点、铺满屏,
|
||||
//使 HIST_FAN 求出的屏幕坐标可直接当作容器内坐标使用
|
||||
EQW_Layout.HIST_BOX = { kind: 'point', x: 0, y: 0, w: 1280, h: 720 };
|
||||
|
||||
//出牌历史 · 每家一排牌(张数不定,重叠压缩)
|
||||
EQW_Layout.HIST_FAN = {
|
||||
kind: 'fan', direction: 'horizontal', anchor: 'center',
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
maxWidth: 480, spacingMax: -40, spacingMin: -96,
|
||||
bySeat: {
|
||||
LEFT: { anchorX: 260, anchorY: 120 },
|
||||
RIGHT: { anchorX: 1035, anchorY: 120 },
|
||||
SELF: { anchorX: 640, anchorY: 400 }
|
||||
}
|
||||
};
|
||||
|
||||
//出牌历史 · 三家座位标签(昵称/张数):各排牌下方;文案长度不定,w/h 运行时注入
|
||||
EQW_Layout.HIST_LABEL = {
|
||||
kind: 'point',
|
||||
runtime: ['w', 'h'],
|
||||
bySeat: {
|
||||
LEFT: { x: 200, y: 318 },
|
||||
RIGHT: { x: 975, y: 318 },
|
||||
SELF: { x: 580, y: 598 }
|
||||
}
|
||||
};
|
||||
|
||||
//===== 明牌面板(MingPaiView,群组 243)=====
|
||||
//
|
||||
// 版式与 HistoryView 完全相同,差别只在【家数】:明牌只看另两家,没有自己那排,
|
||||
// 故 bySeat 只有 LEFT / RIGHT 两个变体(精灵键为 MING_BOX_A / _B,A 落 LEFT 位、B 落 RIGHT 位)。
|
||||
// 坐标来源同上:冲关牌型显示.png 目视实测 + §6.7 冲关牌锚点,【估值,待设计稿复核】。
|
||||
|
||||
EQW_Layout.MING_MASK = { kind: 'point', x: 0, y: 0, w: 1280, h: 720, opacity: 0.72 };
|
||||
|
||||
EQW_Layout.MING_BOX = { kind: 'point', x: 0, y: 0, w: 1280, h: 720 };
|
||||
|
||||
EQW_Layout.MING_FAN = {
|
||||
kind: 'fan', direction: 'horizontal', anchor: 'center',
|
||||
itemWidth: EQW_Layout.CARD_SIZE.L.w, itemHeight: EQW_Layout.CARD_SIZE.L.h,
|
||||
maxWidth: 480, spacingMax: -40, spacingMin: -96,
|
||||
bySeat: {
|
||||
LEFT: { anchorX: 260, anchorY: 120 },
|
||||
RIGHT: { anchorX: 1035, anchorY: 120 }
|
||||
}
|
||||
};
|
||||
|
||||
EQW_Layout.MING_LABEL = {
|
||||
kind: 'point',
|
||||
runtime: ['w', 'h'],
|
||||
bySeat: {
|
||||
LEFT: { x: 200, y: 318 },
|
||||
RIGHT: { x: 975, y: 318 }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Layout: 常驻区 + 玩家位(清单 §6.5 / §6.6)//////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 【纯数据文件】无函数、无副作用;依赖 LayoutConstants.js 先加载
|
||||
// (引用其 EQW_Layout.TEXT_STYLE.*,属 EQW_Layout 内部自引用)。
|
||||
var EQW_Layout = EQW_Layout || {};
|
||||
|
||||
//===== 常驻区(清单 §6.5)=====
|
||||
|
||||
//顶部信息条底
|
||||
EQW_Layout.TOP_INFO_BG = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
|
||||
|
||||
//顶部 · 花色图标:贴信息条内部左下角(资源 SUIT_ICON_S 30×30,清单 §3)
|
||||
EQW_Layout.TOP_SUIT_ICON = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'TOP_INFO_BG',
|
||||
hAlign: 'left', vAlign: 'bottom', offsetX: 20, offsetY: -8, w: 30, h: 30
|
||||
};
|
||||
|
||||
//顶部 · 叫分数值文字 / 抓分数值文字:【清单 §6.5 未给出位置】——那张表只列了两个角标底与角标
|
||||
//文字,而角标底又必须贴在这两段数值文字身上。此处显式占位:坐标与宽高全部标为运行时注入,
|
||||
//由调用方按信息条内的三列表格实际排布给出;设计稿补齐后把 x/y/w/h 回填成固定值即可。
|
||||
//显式占位是为了避免 C 阶段在渲染代码里就地硬编码一组坐标——那样这两处位置就再也无从统一修改
|
||||
EQW_Layout.TOP_CALL_TEXT = { kind: 'point', runtime: ['x', 'y', 'w', 'h'] };
|
||||
EQW_Layout.TOP_GRADE_TEXT = { kind: 'point', runtime: ['x', 'y', 'w', 'h'] };
|
||||
|
||||
//顶部 · 叫分角标底:贴叫分数值文字右上角(资源 BADGE_MULTIPLE 帧1 34×18)。
|
||||
//角标数值文字复用同一矩形,不单独配置(清单未给独立位置,下同)
|
||||
EQW_Layout.TOP_CALL_BADGE_BG = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'TOP_CALL_TEXT',
|
||||
corner: 'topRight', offsetX: 4, offsetY: 6, w: 34, h: 18
|
||||
};
|
||||
|
||||
//顶部 · 抓分角标底:贴抓分数值文字右上角(资源 BADGE_MULTIPLE 帧2 34×18)
|
||||
EQW_Layout.TOP_GRADE_BADGE_BG = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'TOP_GRADE_TEXT',
|
||||
corner: 'topRight', offsetX: 4, offsetY: 6, w: 34, h: 18
|
||||
};
|
||||
|
||||
//自己的主牌统计条(手牌上方「x对 x主」,群组 206)。
|
||||
//2026-08-27 由 LIANGPAI_BG 改名:与 design §8.2 的「亮牌」同名异实,后者是遮罩面板(群组 242)
|
||||
EQW_Layout.ZHU_STAT_BG = { kind: 'point', x: 35, y: 400, w: 210, h: 35 };
|
||||
|
||||
//底栏条
|
||||
EQW_Layout.FOOTER_BG = { kind: 'point', x: 0, y: 648, w: 1280, h: 72 };
|
||||
|
||||
//底栏 · 局数文字「1/4 局」
|
||||
EQW_Layout.FOOTER_ASET_TEXT = {
|
||||
kind: 'point', x: 635, y: 672, w: 65, h: 26, textStyle: EQW_Layout.TEXT_STYLE.ASET_COUNT
|
||||
};
|
||||
|
||||
//底栏 · 功能钮组(明牌/已出牌/上一轮/扣底/底牌):逐项不等宽(清单 §6.1 T-25)。
|
||||
//2026-08-27 补上「扣底」项(清单 §2.6 列的是 5 个按钮,此处原只排了 4 个)——
|
||||
//排序按 §2.6 表:扣底紧邻底牌(两者分别看埋牌底牌 / 底牌,成对但不同批,勿写反)。
|
||||
//【估值】扣底 78:清单未给该项宽度,参考图(等待叫分.png)底栏也只画出「底牌」一项
|
||||
//(当时还没有「扣底」按钮,§2.6 后补)。依据取自【底牌】按钮实测的 78——不能笼统地说
|
||||
//「两字按钮 = 78」:同为两字的「明牌」实测只有 74,两者并不相同,不可一概而论。
|
||||
//故本项沿用「底牌」实测值 78,待设计稿复核(其余四项 74/87/93/78 为清单 §6.5 实测值)
|
||||
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_BURY_CARDS', width: 78 },
|
||||
{ key: 'BTN_BOTTOM_CARDS', width: 78 }
|
||||
]
|
||||
};
|
||||
|
||||
//底栏 · 提示按钮组(踩/有分/没分):count 由运行时同时可见的按钮数给出,不进配置
|
||||
EQW_Layout.FOOTER_TISHI_BUTTONS = {
|
||||
kind: 'line', direction: 'horizontal', anchorX: 335, anchorY: 672,
|
||||
itemWidth: 52, itemHeight: 30, spacing: 26, anchor: 'left'
|
||||
};
|
||||
|
||||
//右侧语音钮(平台):坐标仅登记供 01_SubGame_modify.js 配置区人工同步核对(清单 §6.11 同类处理)
|
||||
EQW_Layout.VOICE_BTN_REF = { kind: 'point', x: 1210, y: 310, w: 56, h: 56 };
|
||||
|
||||
//右侧聊天钮(平台):同上
|
||||
EQW_Layout.CHAT_BTN_REF = { kind: 'point', x: 1210, y: 385, w: 56, h: 56 };
|
||||
|
||||
//===== 玩家位(清单 §6.6)=====
|
||||
|
||||
//头像框(平台提供):仅登记坐标供 attach 参考与 Game_Modify.PLAYER_INFO_LAYOUT 人工同步(清单 §6.11)。
|
||||
//SELF 尺寸与 LEFT/RIGHT 不同(84×53 vs 91×118),bySeat 内单独覆盖 w/h
|
||||
EQW_Layout.PLAYER_AVATAR_FRAME_REF = {
|
||||
kind: 'point', w: 91, h: 118,
|
||||
bySeat: {
|
||||
LEFT: { x: 22, y: 122 },
|
||||
RIGHT: { x: 1160, y: 122 },
|
||||
SELF: { x: 22, y: 645, w: 84, h: 53 }
|
||||
}
|
||||
};
|
||||
|
||||
//『庄』印章:三家各一个精灵(P_LEFT_BANKER / P_RIGHT_BANKER / P_SELF_BANKER),按 bySeat 求位置。
|
||||
//LEFT/RIGHT 贴平台头像框外角;SELF 为底栏固定点(与清单 §6.5「底栏·庄标」同一元素,此处合并,避免重复定义)。
|
||||
//注意:base 的 kind 是 attach,SELF 变体里【有意】把 kind 覆盖为 point——EQW_LayoutSolver._mergeBySeat
|
||||
//会合并包括 kind 在内的所有字段,这里利用该机制表达「底栏庄标是固定坐标、不贴附任何目标」,
|
||||
//与 LEFT/RIGHT 两个上家贴头像框(attach)是不同的定位方式,不是笔误
|
||||
EQW_Layout.P_BANKER_MARK = {
|
||||
kind: 'attach', targetKind: 'platform', w: 40, h: 40,
|
||||
bySeat: {
|
||||
LEFT: { target: 'P_LEFT_AVATAR', corner: 'topRight', offsetX: 7, offsetY: 8 },
|
||||
RIGHT: { target: 'P_RIGHT_AVATAR', corner: 'topLeft', offsetX: -40, offsetY: 8 },
|
||||
SELF: { kind: 'point', x: 338, y: 668, w: 32, h: 34 }
|
||||
}
|
||||
};
|
||||
|
||||
//『主N』角标底:仅左右两家(自己主/对数已在手牌区可见,无此角标)。w/h 为清单 §6.6 实测值
|
||||
EQW_Layout.P_ZHU_BADGE = {
|
||||
kind: 'attach', targetKind: 'platform', corner: 'bottomLeft', offsetX: 0, offsetY: 6, w: 40, h: 19,
|
||||
bySeat: {
|
||||
LEFT: { target: 'P_LEFT_AVATAR' },
|
||||
RIGHT: { target: 'P_RIGHT_AVATAR' }
|
||||
}
|
||||
};
|
||||
|
||||
//『对N』角标底:贴主N角标右侧。w/h 清单未重复给出,沿用主N角标同尺寸
|
||||
//(同资源 BADGE_ZHU_PAIR 的另一帧,帧1黄底=主N、帧2白底=对N,尺寸一致)
|
||||
EQW_Layout.P_PAIR_BADGE = {
|
||||
kind: 'attach', targetKind: 'layout', target: 'P_ZHU_BADGE', hAlign: 'right', vAlign: 'middle',
|
||||
offsetX: 44, offsetY: 0, w: 40, h: 19
|
||||
};
|
||||
|
||||
//状态文字「60分」/「不叫」:仅左右两家(自己此阶段无独立文字区,见清单 §6.6 表)
|
||||
EQW_Layout.P_STATUS_TEXT = {
|
||||
kind: 'point', w: 130, h: 70, textStyle: EQW_Layout.TEXT_STYLE.STATUS_BIG,
|
||||
bySeat: {
|
||||
LEFT: { x: 170, y: 165 },
|
||||
RIGHT: { x: 980, y: 165 }
|
||||
}
|
||||
};
|
||||
|
||||
//倒计时:三家都有(多帧图数字精灵,纯秒数无后缀,见 NUM_STYLE.COUNTDOWN)。
|
||||
//高度恒为单字高,取 NUM_STYLE.COUNTDOWN.charHeight(SSOT);宽度随位数变化
|
||||
//(SpriteManager.setNumberImage 按【字符数 × charWidth】自动改精灵宽),故 w 留到运行时注入
|
||||
EQW_Layout.P_COUNTDOWN = {
|
||||
kind: 'point', h: EQW_Layout.NUM_STYLE.COUNTDOWN.charHeight,
|
||||
runtime: ['w'],
|
||||
bySeat: {
|
||||
LEFT: { x: 195, y: 165 },
|
||||
RIGHT: { x: 1010, y: 165 },
|
||||
SELF: { x: 445, y: 345 }
|
||||
}
|
||||
};
|
||||
|
||||
//提示气泡(踩/没分/有分):朝向 arrowRight 是配置项,不按座位硬判(清单 §6.6)。
|
||||
//w/h 清单 §3 标『待定』,此处用示意尺寸占位,资源定稿后回填(不影响 kind/字段结构)
|
||||
EQW_Layout.TISHI_BUBBLE = {
|
||||
kind: 'attach', targetKind: 'platform', 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 }
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sounds: 声音资源 ID(清单 §4)//////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏声音资源段:≥101(框架保留 1–100)。平台现占用 1–5。
|
||||
//
|
||||
// 【T-20 未决】整套音效尚未定案——清单 §4 整节标注「整节待确认 T-20」:
|
||||
// design 与协议均未定义任何音效;清单里的候选表(发牌/叫分/不叫/上庄/选主/投降/
|
||||
// 埋牌/出牌/甩牌/甩错/毙牌/赢下一轮/报无主/踩没分有分/扣底/结算赢/结算输/
|
||||
// 倒计时/按钮点击)明确标注「未经确认,不得直接作为出音依据」。
|
||||
// 待确认项:是否需要男/女声区分、是否需要方言语音包、SND_TISHI 是否用真人语音、
|
||||
// 倒计时提示音从第几秒开始。
|
||||
// 定案前【不臆造条目】。届时在此按 SoundResources.template.js 的格式补充,
|
||||
// 每条注明:用途 / 时长 / 是否循环。
|
||||
var EQW_Sounds = EQW_Sounds || {
|
||||
//待 T-20 定案后补充
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sprites: 精灵结构(清单 §5.4/§5.5 阶段操作区与浮层)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
|
||||
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
|
||||
// 容器内 id 为群组 ID、其余键才是精灵 id。
|
||||
// 连号精灵逐条写全,不用循环生成。
|
||||
var EQW_Sprites = EQW_Sprites || {};
|
||||
|
||||
// ============================================================================
|
||||
// 叫分面板 - Layer 104 (ACTION), Group 220 (CALL_PANEL)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 叫分面板 - CallPanelView
|
||||
*
|
||||
* 用途: 轮到【自己】叫分时在桌面中央弹出,14 个档位按钮(两行 × 7)+「不叫」按钮 +
|
||||
* 上方一条静态提示条。收到 jiaofen 推送后收起。轮到他家叫分时本面板不出现。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/自己叫分.png 全景、docs_dev/uiref/叫分按钮面板.png 近景):
|
||||
*
|
||||
* ┌──────────────────────────────┐ y=100 x=470,345×33
|
||||
* │ 70分坐庄才可投降 │ ← CALL_HINT_BG(1601)
|
||||
* └──────────────────────────────┘ y=133 +CALL_HINT_TEXT(1602)
|
||||
* ┌────────────────────────────────────────────────────────────┐ y=140
|
||||
* │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ x=355
|
||||
* │ │70│ │65│ │60│ │55│ │50│ │45│ │40│ ← 行1 = 档位 1–7 │ 575×170
|
||||
* │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
|
||||
* │ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
|
||||
* │ │35│ │30│ │25│ │20│ │15│ │10│ │5 │ ← 行2 = 档位 8–14 │
|
||||
* │ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
|
||||
* └────────────────────────────────────────────────────────────┘ y=310
|
||||
* ↑ CALL_PANEL_BG(1600) 面板底
|
||||
* 档位排布 EQW_Layout.CALL_BTN_GRID: grid, anchor center, anchorX=640 anchorY=160,
|
||||
* cols=7 rows=2, itemWidth=74 itemHeight=56, spacingX=7 spacingY=24, fillOrder='row'
|
||||
*
|
||||
* 12 ┌──────────────┐ y=345
|
||||
* ↑ │ 不 叫 │ x=553, 184×57
|
||||
* CD_SELF └──────────────┘ y=402
|
||||
* (CountdownView, ↑ CALL_BTN_NONE(1645)+CALL_BTN_NONE_TEXT(1646)
|
||||
* EQW_Layout.P_COUNTDOWN.SELF = 445,345)
|
||||
*
|
||||
* 单个档位按钮由【3 个精灵】叠成(第 n 档 n=1..14):
|
||||
*
|
||||
* ┌───────────────┐
|
||||
* │ ┌────┐│ ← CALL_BTN_BADGE_n (1631+n-1) 文字精灵,随爬坡开关变
|
||||
* │ │2子 ││ EQW_Layout.CALL_BTN_MULTIPLE_BADGE: attach 按钮 topRight(-30,+2)
|
||||
* │ 70 └────┘│ ← CALL_BTN_NUM_n (1617+n-1) 多帧图数字精灵
|
||||
* │ │ EQW_Layout.CALL_BTN_SCORE_NUM: attach 按钮 center/middle(+4)
|
||||
* └───────────────┘
|
||||
* ↑ CALL_BTN_n (1603+n-1) 按钮底(已含右上角角标底),74×56
|
||||
*
|
||||
* 元素说明:
|
||||
* - 14 档固定为 70 65 60 55 50 45 40 / 35 30 25 20 15 10 5(行1 = 70…40,行2 = 35…5)。
|
||||
* - 【按钮配色按档位位置写死,不随数据变】:
|
||||
* 位置 1–4 (70/65/60/55) → 帧1 浅黄底 + 蓝角标底
|
||||
* 位置 5–7 (50/45/40) → 帧2 金黄底 + 绿角标底
|
||||
* 位置 8–14 (35…5) → 帧3 橙底 + 深橙角标底
|
||||
* 不可叫(已被叫过 / 不高于 currcall)→ 帧4 全灰
|
||||
* ⚠ 参考图里角标写的 1子/2子/4子 是填充假数据(§0.5),恰好与三组配色一一对应,
|
||||
* 【不要据此以为颜色随子数变】——颜色按位置固定,角标数值才随爬坡开关变。
|
||||
* 反证:常规算子下 50/45/40 与 35…5 的 multiple 同为 6 子,图里却是两种颜色。
|
||||
* - 【两种数字,两种精灵】档位分数用多帧图数字精灵(带描边美术字,setNumberImage 整串一个
|
||||
* 精灵,两位数不拆);角标子数用文字精灵(值域 2–15,随爬坡动态变,用文字最省)。
|
||||
* - 可选性由服务端权威 currcall 决定,前端只据其置灰、不自行推导规则:
|
||||
* 暂定庄家首叫 → 5–70 全可选,【无「不叫」】;其后 → 只有严格低于 currcall 的档位可选。
|
||||
* - 【悲观 UI】点击后只发包(jiaofen {seat, call},不叫为 call:0),面板收起与状态文字
|
||||
* 出现一律等推送包到达再做;唯一允许的乐观处理是按钮本身防连点禁用。
|
||||
* - 上方提示条「70分坐庄才可投降」是静态文字,仅在面板展开时显示。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 面板底: EQW_Images.PANEL_CALL_SCORE (602), 1 帧, 575×170
|
||||
* - 提示条底: EQW_Images.BAR_HINT_TOP (603), 1 帧, 345×33
|
||||
* - 档位按钮底: EQW_Images.BTN_CALL_SCORE (531), 4 帧, 74×56(含角标底,见上)
|
||||
* - 档位数字: EQW_Images.NUM_CALL_SCORE (635), 16 帧固定帧序 0123456789.+-x/p,
|
||||
* 单字符 30×40, 整图 480×40;charWidth 取 EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth
|
||||
* - 不叫按钮: EQW_Images.BTN_NO_CALL (532), 1 帧, 184×57, 蓝色渐变
|
||||
*
|
||||
* 精灵ID分配: 1600–1646(群组 220 号段 1600–1649)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.3 叫分坐庄 / §5.4 Layer 104 / §6.8
|
||||
* @see docs_dev/uiref/自己叫分.png
|
||||
* @see docs_dev/uiref/叫分按钮面板.png
|
||||
* @see docs_dev/uiref/自己已经叫分轮到下家叫分.png
|
||||
*/
|
||||
//叫分面板:14 档位按钮 + 「不叫」按钮(清单 §5.4,Layer 104 群组 220)
|
||||
EQW_Sprites.CallPanelView = {
|
||||
Layer: EQW_Layers.ACTION, //104
|
||||
|
||||
CallPanel: {
|
||||
id: EQW_Groups.CALL_PANEL, //220
|
||||
|
||||
CALL_PANEL_BG: 1600, //图片:面板底,资源 EQW_Images.PANEL_CALL_SCORE
|
||||
CALL_HINT_BG: 1601, //图片:提示条底,资源 EQW_Images.BAR_HINT_TOP
|
||||
CALL_HINT_TEXT: 1602, //文字:「70分坐庄才可投降」
|
||||
|
||||
//—— 14 档位按钮底(含角标底),资源 EQW_Images.BTN_CALL_SCORE,档位 70..5,
|
||||
// 帧1=浅黄+蓝角标(70/65/60/55) 帧2=金黄+绿角标(50/45/40) 帧3=橙+深橙角标(35..5) 帧4=灰(不可选),
|
||||
// 颜色按档位位置固定,仅因不可叫切换到灰帧4 ——
|
||||
CALL_BTN_1: 1603,
|
||||
CALL_BTN_2: 1604,
|
||||
CALL_BTN_3: 1605,
|
||||
CALL_BTN_4: 1606,
|
||||
CALL_BTN_5: 1607,
|
||||
CALL_BTN_6: 1608,
|
||||
CALL_BTN_7: 1609,
|
||||
CALL_BTN_8: 1610,
|
||||
CALL_BTN_9: 1611,
|
||||
CALL_BTN_10: 1612,
|
||||
CALL_BTN_11: 1613,
|
||||
CALL_BTN_12: 1614,
|
||||
CALL_BTN_13: 1615,
|
||||
CALL_BTN_14: 1616,
|
||||
|
||||
//—— 14 档位分数数字(多帧图数字精灵),资源 EQW_Images.NUM_CALL_SCORE(16 帧固定帧序),
|
||||
// 字符宽见 EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth。
|
||||
// 显示用 SpriteManager.setNumberImage(spriteId, '70', charWidth):整串交给引擎按字符逐帧渲染、
|
||||
// 精灵宽度自动按字符数调整,故 14 档(其中 13 档是两位数)各只需【一个】精灵,不拆十位/个位 ——
|
||||
CALL_BTN_NUM_1: 1617,
|
||||
CALL_BTN_NUM_2: 1618,
|
||||
CALL_BTN_NUM_3: 1619,
|
||||
CALL_BTN_NUM_4: 1620,
|
||||
CALL_BTN_NUM_5: 1621,
|
||||
CALL_BTN_NUM_6: 1622,
|
||||
CALL_BTN_NUM_7: 1623,
|
||||
CALL_BTN_NUM_8: 1624,
|
||||
CALL_BTN_NUM_9: 1625,
|
||||
CALL_BTN_NUM_10: 1626,
|
||||
CALL_BTN_NUM_11: 1627,
|
||||
CALL_BTN_NUM_12: 1628,
|
||||
CALL_BTN_NUM_13: 1629,
|
||||
CALL_BTN_NUM_14: 1630,
|
||||
|
||||
//—— 14 档位 multiple 子数角标文字(「2子」…「15子」),随爬坡规则开关变 ——
|
||||
CALL_BTN_BADGE_1: 1631,
|
||||
CALL_BTN_BADGE_2: 1632,
|
||||
CALL_BTN_BADGE_3: 1633,
|
||||
CALL_BTN_BADGE_4: 1634,
|
||||
CALL_BTN_BADGE_5: 1635,
|
||||
CALL_BTN_BADGE_6: 1636,
|
||||
CALL_BTN_BADGE_7: 1637,
|
||||
CALL_BTN_BADGE_8: 1638,
|
||||
CALL_BTN_BADGE_9: 1639,
|
||||
CALL_BTN_BADGE_10: 1640,
|
||||
CALL_BTN_BADGE_11: 1641,
|
||||
CALL_BTN_BADGE_12: 1642,
|
||||
CALL_BTN_BADGE_13: 1643,
|
||||
CALL_BTN_BADGE_14: 1644,
|
||||
|
||||
CALL_BTN_NONE: 1645, //图片:「不叫」按钮,资源 EQW_Images.BTN_NO_CALL
|
||||
CALL_BTN_NONE_TEXT: 1646 //文字:按钮文案「不叫」
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 选主 / 投降面板 - Layer 104 (ACTION), Group 221 (CHOOSE_MAIN)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 选主 / 投降面板 - ChooseMainView
|
||||
*
|
||||
* 用途: 庄家上庄后弹出,让庄家在【4 个花色】与【投降】之间二选一(同一决策点、互斥)。
|
||||
* 只有庄家看得到;两个闲家此时看到的是「等待庄家选主」提示条(OverlayView)。
|
||||
* 收到 xuanzhu 推送后收起,进入埋牌。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/亮主.png):
|
||||
*
|
||||
* ┌─────────────────────────────────────────┐ y=315 x=330, 425×50
|
||||
* │ 亮主 20 │ ← MAIN_TITLE_BG(1650) 斜角条
|
||||
* └─────────────────────────────────────────┘ y=365 +MAIN_TITLE_TEXT(1651)「亮主」
|
||||
* ↑ ↑
|
||||
* 标题文字 倒计时 CD_SELF(属 CountdownView,不在本群组)
|
||||
*
|
||||
* ┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌─────────┐ y=373 x=330 起
|
||||
* │ ♠ 2对 ││ ♥ 2对 ││ ♣ 2对 ││ ♦ 2对 ││ 投 降 │ 128×55,间距 9
|
||||
* │ 6张 ││ 8张 ││ 6张 ││ 7张 ││ │
|
||||
* └─────────┘└─────────┘└─────────┘└─────────┘└─────────┘ y=428
|
||||
* ↑ ↑ ↑ ↑
|
||||
* │ │ └ MAIN_SUIT_BTN_2(1653) … └ MAIN_BTN_SURRENDER(1672)
|
||||
* │ └ MAIN_SUIT_PAIR_TEXT_n(1668+)「N对」文字精灵,叠在按钮帧内的角标底上
|
||||
* │ EQW_Layout.MAIN_SUIT_PAIR_BADGE: attach 按钮 topRight(-36,+2), 34×18
|
||||
* └ MAIN_SUIT_BTN_1(1652):一个精灵一帧就含「按钮底 + 花色图标 + 右上角蓝色角标底」
|
||||
* 下方 MAIN_SUIT_COUNT_n(1674+)「N张」多帧图数字精灵
|
||||
* EQW_Layout.MAIN_SUIT_COUNT: attach 按钮 center/bottom(-6)
|
||||
*
|
||||
* 排布 EQW_Layout.MAIN_SUIT_LINE: line/horizontal, anchor='left',
|
||||
* anchorX=330 anchorY=373, itemWidth=128 itemHeight=55, spacing=9
|
||||
* —— anchor 取 left 是有意的:投降按钮不显示时只排前 4 项,前 4 个位置保持不变。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 花色按钮帧 = 花色序号 1–4,按【服务端 flower 编号】:帧1=方块♦ 帧2=梅花♣
|
||||
* 帧3=红心♥ 帧4=黑桃♠。⚠ 参考图上 4 个按钮自左向右显示为 ♠♥♣♦,与 flower 编号相反;
|
||||
* 槽位与帧的对应由渲染代码决定,本文件只登记 4 个精灵、不预设槽位顺序。
|
||||
* - 「N对」= 该花色的对子数;「N张」= 该花色在庄家手中的总张数(两副牌单花色最多 26 张)。
|
||||
* 两者都【由前端据自己的 cards 本地统计】,协议明确服务端不额外下发。
|
||||
* - 「N张」用 SpriteManager.setNumberImage(spriteId, '26张', charWidth) 在【一个】精灵上
|
||||
* 显示整串,后缀「张」= 资源第 16 帧;不拆十位/个位(2026-08-26 定案,见下方空号说明)。
|
||||
* - 投降按钮仅 touxiang === 1(即叫分 70)时显示;点它直接跳到结算,不选主、不埋牌、不出牌。
|
||||
* - 交互:点花色 → xuanzhu { seat, flower };点投降 → touxiang { seat }。收包后才收面板。
|
||||
* - 【时序】收到 xuanzhu 推送后:顶部信息条「主」列显示花色图标 → 手牌按新主牌集合重排
|
||||
* (正2/正7 升格为主牌)→ 本面板收起 → 进入埋牌。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 标题斜角条: EQW_Images.BAR_CHOOSE_MAIN (604), 1 帧, 425×50
|
||||
* - 花色/投降按钮: EQW_Images.BTN_SUIT_CHOOSE (533), 5 帧, 128×55
|
||||
* 帧1–4 = 四花色按钮(各含按钮底 + 花色图标 + 右上角蓝色角标底);帧5 = 投降按钮(含文字)
|
||||
* - 张数数字: EQW_Images.NUM_MAIN_SUIT_COUNT (636), 16 帧固定帧序, 单字符 24×32,
|
||||
* 整图 384×32, 第 16 帧 = 后缀「张」;charWidth 取 NUM_STYLE.MAIN_SUIT_COUNT.charWidth
|
||||
*
|
||||
* 精灵ID分配: 1650–1655 / 1668–1672 / 1674–1677(群组 221 号段 1650–1699)
|
||||
* —— 中间的空号是美术改一张图 5 帧出图后作废的键,逐条追溯见下方原注释。
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.5 选主·投降 / §5.4 / §6.8
|
||||
* @see docs_dev/uiref/亮主.png
|
||||
*/
|
||||
//选主/投降面板:4 花色按钮 + 投降按钮(清单 §5.4,Layer 104 群组 221)
|
||||
//
|
||||
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯(美术改一张图 5 帧出图,
|
||||
//花色图标与右上角角标底已并入 BTN_SUIT_CHOOSE 各帧,2026-08-26):
|
||||
// 1656–1659 MAIN_SUIT_ICON_1..4 —— 花色图标并入 BTN_SUIT_CHOOSE 帧1–4
|
||||
// 1660–1663 MAIN_SUIT_COUNT_1..4 —— 原文字精灵,改为下方数字图片精灵(键名沿用,ID 改到 1674–1677)
|
||||
// 1664–1667 MAIN_SUIT_PAIR_BG_1..4 —— 角标底并入 BTN_SUIT_CHOOSE 帧1–4
|
||||
// 1673 MAIN_BTN_SURRENDER_TEXT —— 「投降」二字并入 BTN_SUIT_CHOOSE 帧5
|
||||
// 1678–1681 原 MAIN_SUIT_COUNT_3/4_TENS/ONES —— 张数改回「一个花色一个精灵」(多帧图数字,见下:
|
||||
// SpriteManager.setNumberImage 一个精灵就能显示整串),上一轮的十位/个位拆分作废,
|
||||
// 空出的 4 个 ID 不复用(2026-08-26)
|
||||
EQW_Sprites.ChooseMainView = {
|
||||
Layer: EQW_Layers.ACTION, //104
|
||||
|
||||
ChooseMain: {
|
||||
id: EQW_Groups.CHOOSE_MAIN, //221
|
||||
|
||||
MAIN_TITLE_BG: 1650, //图片:「亮主」斜角条,资源 EQW_Images.BAR_CHOOSE_MAIN
|
||||
MAIN_TITLE_TEXT: 1651, //文字:「亮主」
|
||||
|
||||
//—— 4 个花色按钮,资源 EQW_Images.BTN_SUIT_CHOOSE,帧 = 花色序号 1–4(服务端 flower 编号),
|
||||
// 该帧已含「按钮底 + 花色图标 + 右上角蓝色角标底」——
|
||||
MAIN_SUIT_BTN_1: 1652,
|
||||
MAIN_SUIT_BTN_2: 1653,
|
||||
MAIN_SUIT_BTN_3: 1654,
|
||||
MAIN_SUIT_BTN_4: 1655,
|
||||
|
||||
//—— 4 个对数角标文字:「N对」,叠在对应花色按钮的角标底之上 ——
|
||||
MAIN_SUIT_PAIR_TEXT_1: 1668,
|
||||
MAIN_SUIT_PAIR_TEXT_2: 1669,
|
||||
MAIN_SUIT_PAIR_TEXT_3: 1670,
|
||||
MAIN_SUIT_PAIR_TEXT_4: 1671,
|
||||
|
||||
//—— 4 个花色张数数字精灵(多帧图数字精灵,一花色一个):该花色在庄家手中的张数
|
||||
// (两副牌单花色最多 26 张,两位数),资源 EQW_Images.NUM_MAIN_SUIT_COUNT(16 帧固定帧序,
|
||||
// 第16帧=后缀「张」),字符宽见 EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charWidth。
|
||||
// 显示用 SpriteManager.setNumberImage(spriteId, '26张', charWidth),整串一个精灵搞定,
|
||||
// 不拆十位/个位(2026-08-26)——
|
||||
MAIN_SUIT_COUNT_1: 1674,
|
||||
MAIN_SUIT_COUNT_2: 1675,
|
||||
MAIN_SUIT_COUNT_3: 1676,
|
||||
MAIN_SUIT_COUNT_4: 1677,
|
||||
|
||||
MAIN_BTN_SURRENDER: 1672 //图片:「投降」按钮,资源 EQW_Images.BTN_SUIT_CHOOSE 帧5(已含底与「投降」字)
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 阶段操作条 - Layer 104 (ACTION), Group 222 (BURY_BAR) / 223 (PLAY_BAR)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 埋牌 / 出牌操作条 - OperationBarView
|
||||
*
|
||||
* 用途: 自己该行动时,在手牌区【上方】横排一条操作按钮。两条操作条按阶段【互斥】出现:
|
||||
* 埋牌条只有庄家在埋牌阶段看得到,出牌条在出牌阶段轮到自己时出现。
|
||||
* 两条都把倒计时 CD_SELF 排在自己的 items 里,故倒计时随操作条一起移动。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/庄家埋牌.png = 埋牌条;docs_dev/uiref/出牌.png = 出牌条):
|
||||
*
|
||||
* 埋牌操作条(群组 222,EQW_Layout.BURY_OPERATION_BAR)
|
||||
* line/horizontal, anchor center, anchorX=640 anchorY=165, itemHeight=63, spacing=45
|
||||
* items 逐项不等宽:BURY_BTN_TIP 180 / CD_SELF 60 / BURY_BTN_SUBMIT 180
|
||||
*
|
||||
* ┌────────────────┐ ┌────────────────┐ y=165
|
||||
* │ 提示 │ 12 │ 埋牌 (1/8) │
|
||||
* └────────────────┘ ↑ └────────────────┘ y=228
|
||||
* ↑ CD_SELF ↑
|
||||
* BURY_BTN_TIP(1700) BURY_BTN_SUBMIT(1702)
|
||||
* +_TEXT(1701) +_TEXT(1703)「埋牌 (N/8)」
|
||||
* 帧1=可提交(金黄) / 帧2=灰(未满 8 张)
|
||||
*
|
||||
* ┌── 手牌【双排】(Layer 102,见 Sprites_Cards.js HandView)──┐ y=285 起
|
||||
*
|
||||
* 出牌操作条(群组 223,EQW_Layout.PLAY_OPERATION_BAR)
|
||||
* line/horizontal, anchor center, anchorX=640 anchorY=345, itemHeight=55, spacing=35
|
||||
* items:CD_SELF 75 / PLAY_BTN_SUBMIT 157 / PLAY_BTN_TIP 180【估值】
|
||||
*
|
||||
* 20 ┌──────────────┐ ┌────────────────┐ y=345
|
||||
* ↑ │ 出 牌 │ │ 提示 │
|
||||
* CD_SELF └──────────────┘ └────────────────┘ y=400
|
||||
* ↑ PLAY_BTN_SUBMIT(1710)+_TEXT(1711) ↑ PLAY_BTN_TIP(1712)
|
||||
* 帧1=可出 / 帧2=灰(选中牌数为 0 时) +_TEXT(1713)
|
||||
*
|
||||
* ┌── 手牌【单排】(Layer 102)──┐ y=455 起
|
||||
*
|
||||
* 元素说明:
|
||||
* - 埋牌条:N 为已选张数,N < 8 时提交按钮置灰;选中的牌在手牌区上浮表示待埋。
|
||||
* 提交后庄家手牌回到单排 28 张。
|
||||
* - 出牌条:点手牌选中(上浮)→ 点「出牌」→ 发 chupai { seat, cards }。
|
||||
* 【只发意图不发结论】——不回传牌型、不回传「我最大」之类判定;前端可用 shared/ 做本地
|
||||
* 预校验与提示,结果不回传。
|
||||
* - 【自动选中】服务端会在下发包里带「应当选中的牌」(mustcard, S-2),前端收到后自动把这些
|
||||
* 牌选中上浮,玩家确认后点「出牌」即可——方向是服务端→前端的建议,不违反红线。
|
||||
* - 【2026-08-27 补齐】出牌阶段的「提示」按钮 PLAY_BTN_TIP(1712)/PLAY_BTN_TIP_TEXT(1713)
|
||||
* (清单 §1.7 明确「出牌阶段必须有」)此前只登记了精灵、没有布局槽位——
|
||||
* EQW_Layout.PLAY_OPERATION_BAR 的 items 只排了倒计时与出牌两项(清单 §6.8 自身也漏了它)。
|
||||
* 现已按 §1.7 的行文顺序补为第 3 项,宽度取 BTN_TIP 资源原生宽 180【估值,待设计稿复核】:
|
||||
* 参考图 出牌.png 里未画出该按钮,位置仍属目视推定。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 提示按钮: EQW_Images.BTN_TIP (535), 1 帧, 180×63, 蓝色渐变(埋牌/出牌两条共用同一资源)
|
||||
* - 埋牌按钮: EQW_Images.BTN_BURY (536), 2 帧, 180×63, 帧1=可提交 帧2=置灰
|
||||
* - 出牌按钮: EQW_Images.BTN_PLAY (537), 2 帧, 157×55, 帧1=可出 帧2=置灰
|
||||
*
|
||||
* 精灵ID分配: 1700–1703(埋牌条)/ 1710–1713(出牌条)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.6 埋牌 / §1.7 出牌对局 / §5.4 / §6.8
|
||||
* @see docs_dev/uiref/庄家埋牌.png
|
||||
* @see docs_dev/uiref/出牌.png
|
||||
*/
|
||||
//操作条:埋牌操作条(群组 222)+ 出牌操作条(群组 223),同一屏幕位置随阶段互斥展示(清单 §5.4)
|
||||
EQW_Sprites.OperationBarView = {
|
||||
Layer: EQW_Layers.ACTION, //104
|
||||
|
||||
BuryBar: {
|
||||
id: EQW_Groups.BURY_BAR, //222
|
||||
|
||||
BURY_BTN_TIP: 1700, //图片:「提示」按钮,资源 EQW_Images.BTN_TIP
|
||||
BURY_BTN_TIP_TEXT: 1701, //文字:按钮文案「提示」
|
||||
BURY_BTN_SUBMIT: 1702, //图片:「埋牌」按钮,资源 EQW_Images.BTN_BURY,帧1=可提交 帧2=灰(未满8张)
|
||||
BURY_BTN_SUBMIT_TEXT: 1703 //文字:「埋牌 (N/8)」
|
||||
},
|
||||
|
||||
PlayBar: {
|
||||
id: EQW_Groups.PLAY_BAR, //223
|
||||
|
||||
PLAY_BTN_SUBMIT: 1710, //图片:「出牌」按钮,资源 EQW_Images.BTN_PLAY,帧1=可出 帧2=灰
|
||||
PLAY_BTN_SUBMIT_TEXT: 1711, //文字:按钮文案「出牌」
|
||||
PLAY_BTN_TIP: 1712, //图片:「提示」按钮(出牌阶段必备),资源 EQW_Images.BTN_TIP
|
||||
PLAY_BTN_TIP_TEXT: 1713 //文字:按钮文案「提示」
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 倒计时 - Layer 104 (ACTION), Group 224 (COUNTDOWN)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 倒计时 - CountdownView
|
||||
*
|
||||
* 用途: 把当前操作者剩余的秒数显示在【他自己的位置旁】。三家各一个精灵,
|
||||
* 按当前操作者显隐其一——所以看到数字在哪儿,就知道在等谁。
|
||||
*
|
||||
* 布局说明(参考图:等待叫分.png 的「12」在左上家、出牌2.png 的「15」在左上家、
|
||||
* 自己叫分.png 的「12」在不叫按钮左侧、庄家埋牌.png 的「12」在操作条中间):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌ 顶部信息条 ┐ │
|
||||
* │ ┌────┐ ┌────┐ │
|
||||
* │ │头像│ 12 15 │头像│ │ y≈165
|
||||
* │ └────┘ ↑ ↑ └────┘ │
|
||||
* │ CD_LEFT(1150) CD_RIGHT(1151) │
|
||||
* │ point(195,165) point(1010,165) │
|
||||
* │ │
|
||||
* │ 20 ┌──────────┐ │ y≈345
|
||||
* │ ↑ │ 出 牌 │ │
|
||||
* │ CD_SELF └──────────┘ │
|
||||
* │ (1152) │
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* ⚠ 左右两家的倒计时与该家的状态文字(P_LEFT/RIGHT_STATUS_TEXT,见 Sprites_Table.js)
|
||||
* 落在【同一块区域】:轮到他时显示秒数,他表态后显示「60分」/「不叫」,两者不共存。
|
||||
*
|
||||
* CD_SELF 一个精灵、三处落点,由当前阶段的布局节点决定:
|
||||
* 叫分阶段 → EQW_Layout.P_COUNTDOWN.SELF = point(445, 345)(不叫按钮左侧)
|
||||
* 埋牌阶段 → EQW_Layout.BURY_OPERATION_BAR 的 items 第 2 项(提示与埋牌之间,y=165)
|
||||
* 出牌阶段 → EQW_Layout.PLAY_OPERATION_BAR 的 items 第 1 项(出牌按钮左侧,y=345)
|
||||
* 选主阶段 → 参考图 亮主.png 显示在「亮主」标题条内、标题文字右侧(布局节点待补)
|
||||
*
|
||||
* 元素说明:
|
||||
* - 三个精灵均为【多帧图数字精灵】而非文字精灵(2026-08-27 订正):玩家视线焦点、视觉权重
|
||||
* 最高的数字,参考图是带描边/渐变的美术字,文字精灵做不出这个效果。
|
||||
* 显示走 SpriteManager.setNumberImage(spriteId, '15', charWidth),纯秒数、无后缀。
|
||||
* - 布局节点 EQW_Layout.P_COUNTDOWN 的 h 恒取 NUM_STYLE.COUNTDOWN.charHeight(SSOT),
|
||||
* w 留到运行时注入——setNumberImage 会按【字符数 × charWidth】自动改精灵宽。
|
||||
* - 数据来源:各包的 countdown 字段。
|
||||
* - 【只做展示,不触发任何操作】倒计时归零后不自动叫分/选主/埋牌/出牌,也不判负、不跳过;
|
||||
* 前端不得在归零时做任何乐观界面推进(不跳阶段、不清控制权),一切仍等服务端推送。
|
||||
* 超时裁定在服务端,前端只做插值显示。
|
||||
* - 【归零后停在 0 继续显示,不隐藏】——隐藏会被误读为「已超时 / 已跳过」。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 数字: EQW_Images.NUM_COUNTDOWN (631), 16 帧固定帧序 0123456789.+-x/p,
|
||||
* 单字符 44×60, 整图 704×60;帧11–15 本套用不到但必须留占位,帧16 出空白(无后缀)
|
||||
* - charWidth / charHeight 只在 EQW_Layout.NUM_STYLE.COUNTDOWN 定义一处,业务代码只引用
|
||||
*
|
||||
* 精灵ID分配: 1150–1152(群组 224 号段 1150–1159)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.4 倒计时 / §5.4 / §6.8
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
* @see docs_dev/uiref/自己叫分.png
|
||||
* @see docs_dev/uiref/庄家埋牌.png
|
||||
*/
|
||||
//倒计时:左上家/右上家/自己 三处操作区倒计时(清单 §5.4,Layer 104 群组 224)
|
||||
//
|
||||
//三处均为【多帧图数字精灵】(非文字精灵):玩家视线焦点、视觉权重最高的数字,参考图是带描边/渐变
|
||||
//的美术字,文字精灵做不出这个效果。资源 EQW_Images.NUM_COUNTDOWN(16 帧固定帧序),纯秒数无后缀。
|
||||
//显示用 SpriteManager.setNumberImage(spriteId, '15', charWidth),charWidth 见
|
||||
//EQW_Layout.NUM_STYLE.COUNTDOWN.charWidth(2026-08-27,与 NUM_STYLE.COUNTDOWN 一起落地)
|
||||
EQW_Sprites.CountdownView = {
|
||||
Layer: EQW_Layers.ACTION, //104
|
||||
|
||||
Countdown: {
|
||||
id: EQW_Groups.COUNTDOWN, //224
|
||||
|
||||
CD_LEFT: 1150, //多帧图数字精灵:左上家位倒计时
|
||||
CD_RIGHT: 1151, //多帧图数字精灵:右上家位倒计时
|
||||
CD_SELF: 1152 //多帧图数字精灵:自己操作区倒计时
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 桌面浮层 - Layer 105 (OVERLAY), Group 230 (OVERLAY)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 桌面浮层 - OverlayView
|
||||
*
|
||||
* 用途: 盖在牌桌之上的一次性/短时表现层,三类互不相干的东西共用一个群组:
|
||||
* (1) 闲家提示气泡 (2) 捡分飘字 (3) 通用状态提示条。
|
||||
* 都是「浮」在桌面上、不属于任何固定面板的元素,故集中在 Layer 105。
|
||||
*
|
||||
* 布局说明(参考图:自己是闲家时,底下的踩有分没分按钮.png = 气泡入口;
|
||||
* 出牌2.png 中央的「20分」= 捡分飘字;强甩失败提示.png / 等待庄家选主.png = 提示条):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌ 顶部信息条 ┐ │
|
||||
* │ ┌────┐ ┌────┐ │
|
||||
* │ │头像│(踩) (踩)│头像│ │ y≈122
|
||||
* │ └────┘ ↑ ↑ └────┘ │
|
||||
* │ BUBBLE_TISHI_LEFT(1750) BUBBLE_TISHI_RIGHT(1751) │
|
||||
* │ 箭头向左(帧1–3) 箭头向右(帧4–6) │
|
||||
* │ │
|
||||
* │ ┌────────────────────────────────┐ │ y=172
|
||||
* │ │ 强 甩 失 败 │ ← TOAST_INSTANT│ 462,363×50
|
||||
* │ └────────────────────────────────┘ │
|
||||
* │ ┌──────────────┐ │ y=180
|
||||
* │ │ 20分 │ ← GRADE_FLOAT │ 570,170×55
|
||||
* │ └──────────────┘ │
|
||||
* │ │
|
||||
* │ ┌────────────────────────────┐ │ y=373
|
||||
* │ │ 等待庄家选主 │ ← TOAST_WAIT │ 500,320×47
|
||||
* │ └────────────────────────────┘ │
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* (1) 提示气泡 BUBBLE_TISHI_LEFT/RIGHT/SELF (1750–1752)
|
||||
* EQW_Layout.TISHI_BUBBLE, attach 平台头像框, 96×44(尺寸为占位估值,资源待定稿):
|
||||
* LEFT → attach P_LEFT_AVATAR(379) right/middle, offsetX=+10, arrowRight=false
|
||||
* RIGHT → attach P_RIGHT_AVATAR(377) left/middle, offsetX=-10, arrowRight=true
|
||||
* SELF → attach P_SELF_AVATAR(376) right/top, offset(+10,-70), arrowRight=false
|
||||
* 帧 = (arrowRight ? 3 : 0) + tip,tip:1踩 2没分 3有分。
|
||||
* 【朝向是配置项 arrowRight,不按座位硬判】——头像在气泡左侧就用箭头向左的帧。
|
||||
* (2) 捡分飘字 GRADE_FLOAT_BG(1755) + GRADE_FLOAT_TEXT(1756)
|
||||
* EQW_Layout.GRADE_FLOAT = point(570, 180, 170×55) 为【起始】位置;
|
||||
* 上飘与时长见 EQW_Anim.SCORE_FLOAT = { rise: -60, duration: 800 }(SSOT,布局里不重复)。
|
||||
* (3) 通用状态提示条 BAR_TOAST(1760) + TXT_TOAST(1761)
|
||||
* 整局【复用同一套精灵】,只换文案与位置预设,两个预设二选一:
|
||||
* 等待类(常驻到状态结束)→ EQW_Layout.TOAST_WAIT = point(500, 373, 320×47)
|
||||
* 即时反馈类(自动淡出) → EQW_Layout.TOAST_INSTANT = point(462, 172, 363×50)
|
||||
* 自动消失时长见 EQW_Anim.TOAST_DURATION = 1500(SSOT)。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 【提示条文案共 3 条,T-33 已定案】:
|
||||
* 「等待庄家选主」 状态类,两个闲家可见,收 shangzhuang 后 → 收 xuanzhu 前
|
||||
* 「等待庄家埋牌」 状态类,两个闲家可见,收 xuanzhu 后 → 收 maipai 前
|
||||
* 「强甩失败」 即时反馈,全场可见,收到 chupai1.shuaicuo == 1 时,自动淡出
|
||||
* 明确【不做】:叫分等待 / 出牌等待(倒计时已指明在等谁)、准备等待(平台自带标识)、
|
||||
* 掉线重连提示(平台已覆盖)、开底提示(画面本身已说清楚)。文案表进配置,不写死字符串。
|
||||
* - 【甩错 D-3 已定】不做收回动画——甩出的牌不演「飞回手里」,直接按服务端下发的 cards
|
||||
* (被强制打出的最小主牌单张)落牌即可,另配一条「强甩失败」提示。
|
||||
* - 【气泡的受控例外】服务端只把提示转发给对家、不回执给发送者,所以发送者【自己的气泡由
|
||||
* 前端在点击时本地回显】。这是「悲观 UI」红线的一处受控例外,成立理由是提示不含任何对局
|
||||
* 状态、不改阶段/控制权/分数,服务端也明确不校验其真实性;失败回包到达时须撤掉本地回显。
|
||||
* 本人看不到自己的气泡(BUBBLE_TISHI_SELF 是给对家看的,见清单 §6.6)。
|
||||
* - 【报无主 D-4 已定】不做全场提示,唯一界面变化是他家的主N/对N 角标出现(PlayerMarkView)。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 气泡: EQW_Images.BUBBLE_TISHI (651), 6 帧 (3×2), 尺寸待定
|
||||
* 帧1=踩·左 帧2=没分·左 帧3=有分·左 / 帧4=踩·右 帧5=没分·右 帧6=有分·右
|
||||
* - 提示条底: EQW_Images.BAR_TOAST (653), 1 帧, 363×50, 可九宫拉伸(按文案长度变宽)
|
||||
* - 捡分飘字底: EQW_Images.TXT_GRADE_FLOAT (654), 1 帧, 尺寸待定
|
||||
*
|
||||
* 精灵ID分配: 1750–1752 / 1755–1756 / 1760–1761(群组 230 号段 1750–1799)
|
||||
* —— 中间空号(1753/1754/1757/1758/1759)是清单显式判定「不需要」的键,见下方原注释。
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.8 状态提示条 / §1.7 闲家提示·甩错 / §5.5 / §6.8
|
||||
* @see docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png
|
||||
* @see docs_dev/uiref/强甩失败提示.png
|
||||
* @see docs_dev/uiref/等待庄家选主.png
|
||||
* @see docs_dev/uiref/出牌2.png
|
||||
*/
|
||||
//桌面浮层:提示气泡 + 通用状态提示条 + 捡分飘字(清单 §5.5,Layer 105 群组 230)
|
||||
//
|
||||
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
|
||||
// 1753 TXT_BAOZHU —— 报无主不做全场提示(D-4),只让他家 主N/对N 角标出现
|
||||
// 1754/1759 BAR_SHUAICUO —— 甩错提示并入通用状态提示条 BAR_TOAST/TXT_TOAST(§2.8)
|
||||
// 1757/1758 BOTTOM_3S_* —— 70分底牌亮3秒复用底牌区翻牌(D-2),仅可见范围不同,不单独建精灵
|
||||
EQW_Sprites.OverlayView = {
|
||||
Layer: EQW_Layers.OVERLAY, //105
|
||||
|
||||
Overlay: {
|
||||
id: EQW_Groups.OVERLAY, //230
|
||||
|
||||
//—— 提示气泡:踩/没分/有分,按发送者位置三选一显示,资源 EQW_Images.BUBBLE_TISHI,
|
||||
// 帧 = (arrowRight ? 3 : 0) + tip(tip:1踩 2没分 3有分);朝向为配置项 arrowRight,非按座位硬判(清单 §6.6)——
|
||||
BUBBLE_TISHI_LEFT: 1750, //左上家气泡:头像在气泡左侧,箭头向左,帧1–3,attach 头像框 hAlign:right
|
||||
BUBBLE_TISHI_RIGHT: 1751, //右上家气泡:头像在气泡右侧,箭头向右,帧4–6,attach 头像框 hAlign:left
|
||||
BUBBLE_TISHI_SELF: 1752, //自己气泡:头像在气泡左侧,箭头向左,帧1–3(本人看不到自己气泡,仅对家可见,见清单 §6.6)
|
||||
|
||||
GRADE_FLOAT_BG: 1755, //图片:捡分飘字底,资源 EQW_Images.TXT_GRADE_FLOAT
|
||||
GRADE_FLOAT_TEXT: 1756, //文字:「20分」
|
||||
|
||||
BAR_TOAST: 1760, //图片:通用状态提示条底(等待类/即时反馈类共用,收敛为 3 条,见清单 §2.8),资源 EQW_Images.BAR_TOAST,可九宫拉伸
|
||||
TXT_TOAST: 1761 //文字:提示文案
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sprites: 精灵结构(清单 §5.2/§5.3 手牌与桌面牌)//
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
|
||||
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
|
||||
// 容器内 id 为群组 ID、其余键才是精灵 id。
|
||||
// 连号精灵逐条写全,不用循环生成——便于人逐张对照编辑器核对。
|
||||
var EQW_Sprites = EQW_Sprites || {};
|
||||
|
||||
// ============================================================================
|
||||
// 自己手牌区 - Layer 102 (HAND), Group 210 (HAND)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 自己手牌区 - HandView
|
||||
*
|
||||
* 用途: 屏幕下部铺开自己的手牌(重叠排列),并在每张牌上叠加牌型标记。
|
||||
* 两种形态:常态【单排】、庄家埋牌时【双排】。张数上限 36(庄家摸底后 28+8)。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/等待叫分.png = 单排;
|
||||
* docs_dev/uiref/庄家埋牌.png = 双排):
|
||||
*
|
||||
* 形态一 · 单排(叫分 / 选主 / 出牌阶段,EQW_Layout.HAND_FAN_SINGLE)
|
||||
* fan/horizontal, anchor center, anchorX=640 anchorY=455, maxWidth=1215,
|
||||
* 牌尺寸 CARD_SIZE.L = 110×190, spacingMax=0 spacingMin=-78, overlapFrom='left'
|
||||
*
|
||||
* x≈35 x≈1250
|
||||
* ┌──┬──┬──┬──┬──┬──┬──┬──┬── … ──┬──┬──┬──────────┐ y=455
|
||||
* │K │K │K │K │K │K │K │K │ │K │K │ K │
|
||||
* │♦ │♦ │♦ │♦ │♦ │♦ │♦ │♦ │ …… │♦ │♦ │ ♦ │
|
||||
* └──┴──┴──┴──┴──┴──┴──┴──┴── … ──┴──┴──┴──────────┘ y=645
|
||||
* ↑ HAND_CARD_1(1200) 起自左,逐张右叠;末张整张露出(110px)
|
||||
* 36 张时每张只露约 32px(spacing -78),与参考图实测 33px 吻合
|
||||
*
|
||||
* 形态二 · 双排(仅庄家埋牌阶段,EQW_Layout.HAND_TWO_ROW)
|
||||
* splitStrategy='floorTop':上排 floor(n/2)、下排 ceil(n/2)(36 张 = 18/18)
|
||||
* ROW_TOP anchorX=644 anchorY=285 maxWidth=928 spacingMin=-65
|
||||
* ROW_BOTTOM anchorX=644 anchorY=455 maxWidth=928 spacingMin=-65
|
||||
*
|
||||
* ┌──┬──┬──┬── … ──┬──┬──────┐ y=285 ← ROW_TOP
|
||||
* │K │K │K │ …… │K │ K │
|
||||
* └──┴──┴──┴── … ──┴──┴──────┘ y=455
|
||||
* ┌──┬──┬──┬── … ──┬──┬──────┐ y=455 ← ROW_BOTTOM
|
||||
* │K │K │K │ …… │K │ K │
|
||||
* └──┴──┴──┴── … ──┴──┴──────┘ y=645
|
||||
* ⚠ 参考图画的是 17/19 分行,属填充画法(清单 §0.5),不作依据;分行以 floorTop 为准。
|
||||
*
|
||||
* 选中态: 整张牌上浮,偏移量 EQW_Anim.HAND_SELECT_OFFSET_Y = -40(SSOT,布局里不重复定义)。
|
||||
*
|
||||
* 元素说明:
|
||||
* - HAND_CARD_1..36 与 HAND_MARK_1..36 【一一对应】(HAND_MARK_i 属于 HAND_CARD_i),
|
||||
* 同属群组 210;张数少于 36 时隐藏多余精灵并重算间距。
|
||||
* - 牌面帧 = cardIdToFrame(id)(清单 §0.4 唯一权威转换,前端只此一处实现)。
|
||||
* - 手牌标记三种互斥、每张牌至多一个,由前端据 flower(主牌花色)本地标注,服务端不下发:
|
||||
* 帧1 =「拖」红色圆标 —— 该牌属于一组拖拉机
|
||||
* 帧2 = 橙色五角星 —— 正 2 / 正 7(主花色的 2 和 7)
|
||||
* 帧3 = 蓝色五角星 —— 除正2正7外的其他主牌(双王、副2、副7、主花色普通牌)
|
||||
* 在参考图 庄家埋牌.png 下排牌的左下角可看到这三种标记。
|
||||
* - 【时序】发牌动画从右往左铺开(EQW_Anim.DEAL: 800ms / 每张 20ms 延迟);须先
|
||||
* setHandCards() 把牌写进 this.data、再播动画,动画回调里只刷界面不写核心数据。
|
||||
* - 【时序】选主后主牌集合会变(正2/正7 升格),手牌必须按新主牌序重排(清单 §7.2 T-5)。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 牌面: EQW_Images.CARD_FACE_L (501), 60 帧 (10列×6行行优先), 单帧 110×190, 整图 1100×1140
|
||||
* 帧1–13 黑桃A–K / 帧14–26 红桃 / 帧27–39 梅花 / 帧40–52 方块 /
|
||||
* 帧53 小王 / 帧54 大王 / 帧55–60 牌背(二七王用帧55)
|
||||
* - 牌上标记: EQW_Images.MARK_CARD_GROUP (577), 3 帧, 26×26
|
||||
* - 选中标记: EQW_Images.MARK_SELECTED (578) 尺寸待定,也可只用上浮表现、不出图
|
||||
*
|
||||
* 精灵ID分配: 1200–1235(手牌)+ 2100–2135(手牌标记)
|
||||
* —— 标记另起 2100 段的原因见下方原注释(1236 起接不下 36 个连号)。
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.7 手牌区 / §5.2 Layer 102 / §6.7 牌区配置
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
* @see docs_dev/uiref/庄家埋牌.png
|
||||
*/
|
||||
//自己手牌区:36 张手牌 + 36 个手牌标记(每张牌一个,见下方裁决说明),同属群组 210(清单 §5.2,Layer 102)
|
||||
EQW_Sprites.HandView = {
|
||||
Layer: EQW_Layers.HAND, //102
|
||||
|
||||
HandCards: {
|
||||
id: EQW_Groups.HAND, //210
|
||||
|
||||
//—— 36 张手牌,资源 EQW_Images.CARD_FACE_L,帧 = cardIdToFrame(id)(§0.4 唯一权威转换)——
|
||||
HAND_CARD_1: 1200,
|
||||
HAND_CARD_2: 1201,
|
||||
HAND_CARD_3: 1202,
|
||||
HAND_CARD_4: 1203,
|
||||
HAND_CARD_5: 1204,
|
||||
HAND_CARD_6: 1205,
|
||||
HAND_CARD_7: 1206,
|
||||
HAND_CARD_8: 1207,
|
||||
HAND_CARD_9: 1208,
|
||||
HAND_CARD_10: 1209,
|
||||
HAND_CARD_11: 1210,
|
||||
HAND_CARD_12: 1211,
|
||||
HAND_CARD_13: 1212,
|
||||
HAND_CARD_14: 1213,
|
||||
HAND_CARD_15: 1214,
|
||||
HAND_CARD_16: 1215,
|
||||
HAND_CARD_17: 1216,
|
||||
HAND_CARD_18: 1217,
|
||||
HAND_CARD_19: 1218,
|
||||
HAND_CARD_20: 1219,
|
||||
HAND_CARD_21: 1220,
|
||||
HAND_CARD_22: 1221,
|
||||
HAND_CARD_23: 1222,
|
||||
HAND_CARD_24: 1223,
|
||||
HAND_CARD_25: 1224,
|
||||
HAND_CARD_26: 1225,
|
||||
HAND_CARD_27: 1226,
|
||||
HAND_CARD_28: 1227,
|
||||
HAND_CARD_29: 1228,
|
||||
HAND_CARD_30: 1229,
|
||||
HAND_CARD_31: 1230,
|
||||
HAND_CARD_32: 1231,
|
||||
HAND_CARD_33: 1232,
|
||||
HAND_CARD_34: 1233,
|
||||
HAND_CARD_35: 1234,
|
||||
HAND_CARD_36: 1235,
|
||||
|
||||
//—— 36 个手牌标记【T-8】,与 HAND_CARD_1..36 一一对应(HAND_MARK_i 属于 HAND_CARD_i)——
|
||||
//资源 EQW_Images.MARK_CARD_GROUP,帧1=拖(拖拉机) 帧2=橙五角星(正2/正7) 帧3=蓝五角星(其他主牌)。
|
||||
//【裁决:按「每张牌」而非「每组」】清单 §5.2 的表格写的是「牌型分组标记 ×8」,但 §1.6 明确
|
||||
//规定三种标记互斥、【每张牌】至多一个,core/CardMark.js 也是按每张牌返回标记的。
|
||||
//一手 28 张牌通常带 13 个以上标记、36 张埋牌手更多,8 个远远不够——§1.6 是行为规格,
|
||||
//比 §5.2 表格的措辞更权威,故按 §1.6 扩到与手牌一一对应。
|
||||
//ID 另起 2100 段:1236 往后接不下 36 个连号(1250 起已是底牌/埋牌区),
|
||||
//换整段空号 2100–2135 比给别的 View 重新编号更安全。
|
||||
HAND_MARK_1: 2100,
|
||||
HAND_MARK_2: 2101,
|
||||
HAND_MARK_3: 2102,
|
||||
HAND_MARK_4: 2103,
|
||||
HAND_MARK_5: 2104,
|
||||
HAND_MARK_6: 2105,
|
||||
HAND_MARK_7: 2106,
|
||||
HAND_MARK_8: 2107,
|
||||
HAND_MARK_9: 2108,
|
||||
HAND_MARK_10: 2109,
|
||||
HAND_MARK_11: 2110,
|
||||
HAND_MARK_12: 2111,
|
||||
HAND_MARK_13: 2112,
|
||||
HAND_MARK_14: 2113,
|
||||
HAND_MARK_15: 2114,
|
||||
HAND_MARK_16: 2115,
|
||||
HAND_MARK_17: 2116,
|
||||
HAND_MARK_18: 2117,
|
||||
HAND_MARK_19: 2118,
|
||||
HAND_MARK_20: 2119,
|
||||
HAND_MARK_21: 2120,
|
||||
HAND_MARK_22: 2121,
|
||||
HAND_MARK_23: 2122,
|
||||
HAND_MARK_24: 2123,
|
||||
HAND_MARK_25: 2124,
|
||||
HAND_MARK_26: 2125,
|
||||
HAND_MARK_27: 2126,
|
||||
HAND_MARK_28: 2127,
|
||||
HAND_MARK_29: 2128,
|
||||
HAND_MARK_30: 2129,
|
||||
HAND_MARK_31: 2130,
|
||||
HAND_MARK_32: 2131,
|
||||
HAND_MARK_33: 2132,
|
||||
HAND_MARK_34: 2133,
|
||||
HAND_MARK_35: 2134,
|
||||
HAND_MARK_36: 2135
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 底牌区(发牌留桌 8 张)- Layer 103 (TABLE_CARDS), Group 211 (BOTTOM_CARDS)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 底牌区 - BottomCardsView
|
||||
*
|
||||
* 用途: 桌面中央上部的 8 张【底牌】——发牌时没有发给玩家、留在桌面的那 8 张。
|
||||
* 发牌起显示牌背;庄家摸底 / 70 分开底时【同一批精灵】翻成正面。
|
||||
*
|
||||
* 布局说明(背面:docs_dev/uiref/等待叫分.png 中央;正面:docs_dev/uiref/叫分确定庄.png、
|
||||
* docs_dev/uiref/等待庄家选主.png 中央;
|
||||
* 位置见 EQW_Layout.BOTTOM_CARDS = line/horizontal, anchor center,
|
||||
* anchorX=655 anchorY=110, 牌尺寸 CARD_SIZE.L 110×190, spacing=-36):
|
||||
*
|
||||
* x 居中于 655
|
||||
* ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬────────────┐ y=110
|
||||
* │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │ 背 │
|
||||
* │ │ │ │ │ │ │ │ │
|
||||
* └──────┴──────┴──────┴──────┴──────┴──────┴──────┴────────────┘ y=300
|
||||
* ↑ ↑ ↑
|
||||
* BOTTOM_ BOTTOM_ BOTTOM_
|
||||
* CARD_1 CARD_2 … (等距 spacing -36,每张露 74px)… CARD_8
|
||||
* (1250) (1251) (1257)
|
||||
*
|
||||
* 翻开后同一批精灵改帧为正面(帧 = cardIdToFrame(id)):
|
||||
* ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬────────────┐
|
||||
* │K │K │K │K │K │K │K │K │
|
||||
* │♦ │♦ │♦ │♦ │♦ │♦ │♦ │♦ │
|
||||
* └──────┴──────┴──────┴──────┴──────┴──────┴──────┴────────────┘
|
||||
*
|
||||
* 元素说明:
|
||||
* - 未揭示时帧 = 55(牌背);揭示时帧 = cardIdToFrame(id)(清单 §0.4)。
|
||||
* - 【时序·开底 D-2 已定】翻牌这套 UI 只有一套,两种情形只是【谁能看到】不同:
|
||||
* 非 70 分坐庄(只摸底)→ 只有庄家看得到这 3 秒,闲家侧保持背面 / 直接收起
|
||||
* 70 分坐庄(开底) → 全场三人都看得到 3 秒(EQW_Anim.ANCARD_REVEAL_DURATION = 3000)
|
||||
* 开底【不做倒计时、不做单独的公示面板、不加提示文案】——画面本身已说清楚(§2.8)。
|
||||
* 3 秒后庄家摸入手牌、闲家侧收起,底牌区隐藏。
|
||||
* - 【重连不重放】70 分的 3 秒亮牌是上庄时的一次性事件,deskinfo 重连不得补播(§1.4)。
|
||||
* - 底栏「底牌」按钮再看这 8 张时也复用本区(庄家全程可看,闲家仅开底过的局,S-3)。
|
||||
* - ⚠ 参考图 等待庄家选主.png 把「开底摊牌」与「等待庄家选主」提示条画在了一起,属叠画
|
||||
* 示意(§0.5);实际顺序是 开底 3 秒 → 庄家摸底入手 → 选主,摸底后本区即收起。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 牌面: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190(与手牌同一套资源)
|
||||
*
|
||||
* 精灵ID分配: 1250–1257
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.2 发牌 / §1.4 看底牌·上庄 / §5.3 / §6.7
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
* @see docs_dev/uiref/叫分确定庄.png
|
||||
* @see docs_dev/uiref/等待庄家选主.png
|
||||
*/
|
||||
//底牌区:发牌留桌 8 张(清单 §5.3,Layer 103 群组 211)
|
||||
EQW_Sprites.BottomCardsView = {
|
||||
Layer: EQW_Layers.TABLE_CARDS, //103
|
||||
|
||||
BottomCards: {
|
||||
id: EQW_Groups.BOTTOM_CARDS, //211
|
||||
|
||||
//—— 资源 EQW_Images.CARD_FACE_L;未揭示时帧=55(牌背),70分公示/庄家查看时翻正面(帧=cardIdToFrame(id))——
|
||||
BOTTOM_CARD_1: 1250,
|
||||
BOTTOM_CARD_2: 1251,
|
||||
BOTTOM_CARD_3: 1252,
|
||||
BOTTOM_CARD_4: 1253,
|
||||
BOTTOM_CARD_5: 1254,
|
||||
BOTTOM_CARD_6: 1255,
|
||||
BOTTOM_CARD_7: 1256,
|
||||
BOTTOM_CARD_8: 1257
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 埋牌底牌展示区 - Layer 103 (TABLE_CARDS), Group 215 (BURY_CARDS)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 埋牌底牌展示区 - BuryCardsView
|
||||
*
|
||||
* 用途: 小局结算时亮出【埋牌底牌】——庄家埋牌阶段从手里扣下的那 8 张。
|
||||
* 用最小的牌面尺寸档 S,摆在小局结算面板的上半部。
|
||||
*
|
||||
* 术语区分(清单 §2.6 已统一,别混):
|
||||
* 「底牌」 = 发牌留桌的 8 张 → BottomCardsView(群组 211),协议字段 bottomcards
|
||||
* 「埋牌底牌」= 庄家埋下的 8 张 → 本 View(群组 215),协议字段 burycards / bottom.cards
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/小局结算.png 中央面板内那排小牌;
|
||||
* 位置见 EQW_Layout.BURY_CARDS = line/horizontal, anchor center,
|
||||
* anchorX=640 anchorY=163, 牌尺寸 CARD_SIZE.S 50×70, spacing=2):
|
||||
*
|
||||
* ┌──────────────────────────────────────────┐ y=140 ← RESULT_BOTTOM_PANEL(1800)
|
||||
* │ ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │ y=163 (见 Sprites_Result.js)
|
||||
* │ │10││10││10││10││10││10││10││10│ │
|
||||
* │ │♠ ││♥ ││♣ ││♦ ││♠ ││♥ ││♣ ││♦ │ │ y=233
|
||||
* │ └──┘└──┘└──┘└──┘└──┘└──┘└──┘└──┘ │
|
||||
* │ 叫分2子 底牌 20x0 │ ← RESULT_BOTTOM_TEXT(1801)
|
||||
* └──────────────────────────────────────────┘ y=288
|
||||
* ↑ ↑ ↑
|
||||
* BURY_ BURY_ BURY_
|
||||
* CARD_1 CARD_2 …(等距 spacing +2,不重叠)… CARD_8
|
||||
* (1258) (1259) (1265)
|
||||
*
|
||||
* 本区居中于 x=640,与外层面板(EQW_Layout.RESULT_BOTTOM_PANEL = 430,140,420×148)
|
||||
* 的中心 x=640 对齐;8×50 + 7×2 = 414,正好落在面板 420 宽度之内。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 数据取结算包 bottom.cards,【恒有】(design §11:牌局结束需要亮出底牌)——
|
||||
* 即便闲家未扣底也照常亮 8 张,只是面板文案里的倍数位显示 0。
|
||||
* - 帧 = cardIdToFrame(id),恒为正面(本区只在结算时出现,不存在牌背态)。
|
||||
* - 【容错】投降结算只有 aset、无 bottom 分组 → 结算面板必须容忍底牌区缺失,
|
||||
* 不能因读不到 bottom.cards 就崩或显示空框(§1.8)。
|
||||
* - 底栏「扣底」按钮(清单 §2.6,尚无精灵)看的也是这 8 张。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 牌面: EQW_Images.CARD_FACE_S (503), 60 帧, 单帧 50×70, 整图 500×420
|
||||
* 帧排布与 CARD_FACE_L 完全一致(§0.4)
|
||||
*
|
||||
* 精灵ID分配: 1258–1265
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8 末轮扣底 + 小局结算 / §5.3 / §6.7
|
||||
* @see docs_dev/uiref/小局结算.png
|
||||
*/
|
||||
//埋牌底牌展示区:结算时亮出的埋牌底牌 8 张(清单 §5.3,Layer 103 群组 215)
|
||||
EQW_Sprites.BuryCardsView = {
|
||||
Layer: EQW_Layers.TABLE_CARDS, //103
|
||||
|
||||
BuryCards: {
|
||||
id: EQW_Groups.BURY_CARDS, //215
|
||||
|
||||
//—— 资源 EQW_Images.CARD_FACE_S,帧 = cardIdToFrame(id)——
|
||||
BURY_CARD_1: 1258,
|
||||
BURY_CARD_2: 1259,
|
||||
BURY_CARD_3: 1260,
|
||||
BURY_CARD_4: 1261,
|
||||
BURY_CARD_5: 1262,
|
||||
BURY_CARD_6: 1263,
|
||||
BURY_CARD_7: 1264,
|
||||
BURY_CARD_8: 1265
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 三家已出牌区 - Layer 103 (TABLE_CARDS), Group 212 / 213 / 214
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 三家已出牌区 - PlayAreaView
|
||||
*
|
||||
* 用途: 出牌阶段,三家各有一块区域展示【本轮】打出的牌(中号牌面 CARD_FACE_M)。
|
||||
* 每家一个群组、各预置 28 张(= 单次出牌张数上限,甩牌最多可甩满手牌)。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/出牌2.png;
|
||||
* 位置见 EQW_Layout.PLAY_AREA,三家共用 base + bySeat 三个变体:
|
||||
* fan/horizontal, 牌尺寸 CARD_SIZE.M 90×155, maxWidth=170,
|
||||
* spacingMax=-35 spacingMin=-62):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌ 顶部信息条 ┐ │
|
||||
* │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
|
||||
* │ │头像│ │K K │ │K K │ │头像│ │ y≈120
|
||||
* │ └────┘ │ 大│ │ 大│ └────┘ │
|
||||
* │ └────┘ └────┘ │
|
||||
* │ LEFT 258,120 RIGHT 1022,120 │
|
||||
* │ overlapFrom='right' overlapFrom='left' │
|
||||
* │ (左右镜像:同参数,堆叠朝向相反) │
|
||||
* │ │
|
||||
* │ ┌────┐ │
|
||||
* │ │K K │ ← SELF 650,265 │ y≈265
|
||||
* │ │ 庄│ overlapFrom='left' │
|
||||
* │ ┌──┴────┘ │
|
||||
* │ │拖拉机│ ← 牌型标签,贴首张牌 bottomLeft │
|
||||
* │ └──────┘ │
|
||||
* │ │
|
||||
* │ ┌─────────── 自己手牌区(Layer 102)────────────┐ │ y≈455
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* 单个出牌区的内部结构(以自己为例):
|
||||
*
|
||||
* PLAY_SELF_1(1300) 起自左,逐张右叠(fan 压缩,最多 28 张)
|
||||
* ↓
|
||||
* ┌────┬────┬────────┐
|
||||
* │ K │ K │ K │ 每张牌【右上角】可再叠一个角标:
|
||||
* │ ♦ │ ♦ │ ♦ [庄]│ MARK_BANKER_CORNER(572) 28×28「庄」橙色斜标
|
||||
* └────┴────┴────────┘ MARK_JOKER_BIG(573) 34×34「大/小」王圆标
|
||||
* ┌──────┐ —— 贴的是运行时才确定的某一张牌,故
|
||||
* │拖拉机│ EQW_Layout.PLAY_CARD_CORNER_MARK 只给模板
|
||||
* └──────┘ (corner/offset),target/w/h 由调用方注入
|
||||
* ↑ PLAY_SELF_TYPE_BG(1328) + PLAY_SELF_TYPE_TEXT(1329)
|
||||
* EQW_Layout.PLAY_TYPE_LABEL: attach 首张牌 bottomLeft, offsetY=-24, 76×24
|
||||
* 首槽是【固定】精灵(PLAY_SELF_1 / PLAY_LEFT_1 / PLAY_RIGHT_1),非动态目标
|
||||
*
|
||||
* 元素说明:
|
||||
* - 牌面帧 = cardIdToFrame(id)(§0.4);数据取 chupai1/2/3 的 seat + cards。
|
||||
* - 牌型标签当前【只出「毙」一种】——EQW_Images.LABEL_CARDTYPE 只做这一帧;
|
||||
* 数据层仍保留 毙/垫/混合出牌 三种标识以备扩展,但不出图、不显示。
|
||||
* ⚠ 参考图 出牌2.png 左下角的橙色「拖拉机」标签是设计示意,与当前资源定案(只做「毙」)
|
||||
* 不一致,实现时以资源注释为准(清单 §7.2 T-11 标签种类以美术稿为准,尚未定案)。
|
||||
* - 甩牌的真实结构只能读 chupai1.shuai({tractors, pairs, singles}),
|
||||
* 【不得据 cardtype 反推】(协议 §11 明确警告)。
|
||||
* - 【时序·一轮结束】maxseat 高亮 → 三家出牌区收牌 → 若有 grade 播捡分飘字
|
||||
* (OverlayView 的 GRADE_FLOAT)并累加顶部「抓分」列 → 下一轮由 maxseat 先出。
|
||||
* - 【为什么预置而不复制】号段每家 100 个、容量充裕,且需要叠压 z 序与出牌动画、
|
||||
* 逐张独立控制,故不使用 SpriteCopyUtils,避免显式清理负担(清单 §5.6a 判据表)。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 牌面: EQW_Images.CARD_FACE_M (502), 60 帧, 单帧 90×155, 整图 900×930
|
||||
* - 牌型标签底: EQW_Images.LABEL_CARDTYPE (576), 1 帧, 76×24
|
||||
* - 牌角标: EQW_Images.MARK_BANKER_CORNER (572) 1 帧 28×28 /
|
||||
* EQW_Images.MARK_JOKER_BIG (573) 2 帧 34×34(帧1=大王 帧2=小王)
|
||||
*
|
||||
* 精灵ID分配: 1300–1329(自己)/ 1400–1429(左上家)/ 1500–1529(右上家)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.3 已出牌区 / §1.7 出牌对局 / §5.3 / §6.7
|
||||
* @see docs_dev/uiref/出牌.png
|
||||
* @see docs_dev/uiref/出牌2.png
|
||||
*/
|
||||
//三家已出牌区:自己/左上家/右上家 各 28 张牌 + 牌型标签底 + 标签文字(清单 §5.3,Layer 103 群组 212/213/214)
|
||||
EQW_Sprites.PlayAreaView = {
|
||||
Layer: EQW_Layers.TABLE_CARDS, //103
|
||||
|
||||
PlaySelf: {
|
||||
id: EQW_Groups.PLAY_SELF, //212 自己
|
||||
|
||||
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
|
||||
PLAY_SELF_1: 1300,
|
||||
PLAY_SELF_2: 1301,
|
||||
PLAY_SELF_3: 1302,
|
||||
PLAY_SELF_4: 1303,
|
||||
PLAY_SELF_5: 1304,
|
||||
PLAY_SELF_6: 1305,
|
||||
PLAY_SELF_7: 1306,
|
||||
PLAY_SELF_8: 1307,
|
||||
PLAY_SELF_9: 1308,
|
||||
PLAY_SELF_10: 1309,
|
||||
PLAY_SELF_11: 1310,
|
||||
PLAY_SELF_12: 1311,
|
||||
PLAY_SELF_13: 1312,
|
||||
PLAY_SELF_14: 1313,
|
||||
PLAY_SELF_15: 1314,
|
||||
PLAY_SELF_16: 1315,
|
||||
PLAY_SELF_17: 1316,
|
||||
PLAY_SELF_18: 1317,
|
||||
PLAY_SELF_19: 1318,
|
||||
PLAY_SELF_20: 1319,
|
||||
PLAY_SELF_21: 1320,
|
||||
PLAY_SELF_22: 1321,
|
||||
PLAY_SELF_23: 1322,
|
||||
PLAY_SELF_24: 1323,
|
||||
PLAY_SELF_25: 1324,
|
||||
PLAY_SELF_26: 1325,
|
||||
PLAY_SELF_27: 1326,
|
||||
PLAY_SELF_28: 1327,
|
||||
PLAY_SELF_TYPE_BG: 1328, //图片:牌型标签底,资源 EQW_Images.LABEL_CARDTYPE,attach 该区首张牌 bottomLeft
|
||||
PLAY_SELF_TYPE_TEXT: 1329 //文字:标签文案(当前只出「毙」一种,见 EQW_Images.LABEL_CARDTYPE 用途说明)
|
||||
},
|
||||
|
||||
PlayLeft: {
|
||||
id: EQW_Groups.PLAY_LEFT, //213 左上家,结构同 212
|
||||
|
||||
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
|
||||
PLAY_LEFT_1: 1400,
|
||||
PLAY_LEFT_2: 1401,
|
||||
PLAY_LEFT_3: 1402,
|
||||
PLAY_LEFT_4: 1403,
|
||||
PLAY_LEFT_5: 1404,
|
||||
PLAY_LEFT_6: 1405,
|
||||
PLAY_LEFT_7: 1406,
|
||||
PLAY_LEFT_8: 1407,
|
||||
PLAY_LEFT_9: 1408,
|
||||
PLAY_LEFT_10: 1409,
|
||||
PLAY_LEFT_11: 1410,
|
||||
PLAY_LEFT_12: 1411,
|
||||
PLAY_LEFT_13: 1412,
|
||||
PLAY_LEFT_14: 1413,
|
||||
PLAY_LEFT_15: 1414,
|
||||
PLAY_LEFT_16: 1415,
|
||||
PLAY_LEFT_17: 1416,
|
||||
PLAY_LEFT_18: 1417,
|
||||
PLAY_LEFT_19: 1418,
|
||||
PLAY_LEFT_20: 1419,
|
||||
PLAY_LEFT_21: 1420,
|
||||
PLAY_LEFT_22: 1421,
|
||||
PLAY_LEFT_23: 1422,
|
||||
PLAY_LEFT_24: 1423,
|
||||
PLAY_LEFT_25: 1424,
|
||||
PLAY_LEFT_26: 1425,
|
||||
PLAY_LEFT_27: 1426,
|
||||
PLAY_LEFT_28: 1427,
|
||||
PLAY_LEFT_TYPE_BG: 1428,
|
||||
PLAY_LEFT_TYPE_TEXT: 1429
|
||||
},
|
||||
|
||||
PlayRight: {
|
||||
id: EQW_Groups.PLAY_RIGHT, //214 右上家,结构同 212
|
||||
|
||||
//—— 本轮打出的牌,资源 EQW_Images.CARD_FACE_M,帧 = cardIdToFrame(id)——
|
||||
PLAY_RIGHT_1: 1500,
|
||||
PLAY_RIGHT_2: 1501,
|
||||
PLAY_RIGHT_3: 1502,
|
||||
PLAY_RIGHT_4: 1503,
|
||||
PLAY_RIGHT_5: 1504,
|
||||
PLAY_RIGHT_6: 1505,
|
||||
PLAY_RIGHT_7: 1506,
|
||||
PLAY_RIGHT_8: 1507,
|
||||
PLAY_RIGHT_9: 1508,
|
||||
PLAY_RIGHT_10: 1509,
|
||||
PLAY_RIGHT_11: 1510,
|
||||
PLAY_RIGHT_12: 1511,
|
||||
PLAY_RIGHT_13: 1512,
|
||||
PLAY_RIGHT_14: 1513,
|
||||
PLAY_RIGHT_15: 1514,
|
||||
PLAY_RIGHT_16: 1515,
|
||||
PLAY_RIGHT_17: 1516,
|
||||
PLAY_RIGHT_18: 1517,
|
||||
PLAY_RIGHT_19: 1518,
|
||||
PLAY_RIGHT_20: 1519,
|
||||
PLAY_RIGHT_21: 1520,
|
||||
PLAY_RIGHT_22: 1521,
|
||||
PLAY_RIGHT_23: 1522,
|
||||
PLAY_RIGHT_24: 1523,
|
||||
PLAY_RIGHT_25: 1524,
|
||||
PLAY_RIGHT_26: 1525,
|
||||
PLAY_RIGHT_27: 1526,
|
||||
PLAY_RIGHT_28: 1527,
|
||||
PLAY_RIGHT_TYPE_BG: 1528,
|
||||
PLAY_RIGHT_TYPE_TEXT: 1529
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sprites: 精灵结构(清单 §5.8 建房规则选项)//////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
|
||||
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
|
||||
// 容器内 id 为群组 ID、其余键才是精灵 id。
|
||||
// 3 个类别、4 个选项组、8 个选项,选项集固定,全部预置,共 20 个精灵。
|
||||
// 渲染仍由 §1.1 的配置数据驱动:配置里每个选项声明自己用哪个精灵键、
|
||||
// type(决定选择框帧号)、bit/value(决定 roomtype 拼串)——本文件不认识
|
||||
// 「傍王」「爬坡」这些具体玩法名。
|
||||
var EQW_Sprites = EQW_Sprites || {};
|
||||
|
||||
// ============================================================================
|
||||
// 建房规则选项区 - Layer 27 (平台 CreateRoom_Layer), Group 250 (CREATE_ROOM)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 建房规则选项区 - CreateRoomView
|
||||
*
|
||||
* 用途: 创建房间弹窗里【只属于子游戏的那块内容区】——3 个类别标题 + 4 个选项组 + 8 个选项,
|
||||
* 玩家勾选后拼成 5 位 roomtype 串发给服务端。
|
||||
* 弹窗背景、标题、关闭按钮、确认按钮(平台约定的 25 号精灵)都【由平台提供】,
|
||||
* 本 View 不重复建弹窗外壳(D-1 已定)。也因此不新开图层,直接挂平台的 Layer 27。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/创建房间选项的UI参考内容根据实际二七王游戏改动.png
|
||||
* ——⚠ 那是【另一个游戏】的建房选项(局数 8/16/24、人数 2/3/4、霸王 ×2/×4,
|
||||
* 二七王都没有)。【只照搬视觉范式与控件样式,内容一律按二七王的 roomtype 重写】。
|
||||
* 下图画的是二七王的实际内容):
|
||||
*
|
||||
* 两层嵌套:类别竖排(ROOM_CATEGORY_COLUMN)+ 类别内选项槽横排(ROOM_OPTION_ROW)
|
||||
*
|
||||
* x=0 x=70 内容区右边缘
|
||||
* ┌──────┬──────────────────┬──────────────────┬─────────────────┐
|
||||
* │ 牌局 │ ◉ 6局 │ ◯ 12局 │ │ ← 类别 1 行 1
|
||||
* │ │ ◉ 房主扣卡 │ ◯ AA每人扣卡 │ 房主2张 │ ← 类别 1 行 2
|
||||
* ├──────┼──────────────────┼──────────────────┼─────────────────┤
|
||||
* │ 模式 │ ◉ 可查牌 │ ◯ 不查牌 │ │ ← 类别 2
|
||||
* ├──────┼──────────────────┼──────────────────┼─────────────────┤
|
||||
* │ 规则 │ ▣ 傍王 │ ▣ 爬坡 │ │ ← 类别 3
|
||||
* └──────┴──────────────────┴──────────────────┴─────────────────┘
|
||||
* ↑ ↑ ↑ ↑
|
||||
* │ │ └ ROOM_TEXT_*:attach 同槽选择框右侧 offsetX=+8(ROOM_OPTION_TEXT)
|
||||
* │ └── ROOM_BOX_*:attach 所在选项槽 left/middle,22×22(ROOM_OPTION_BOX)
|
||||
* └───────── ROOM_CAT_TITLE_1..3 (2050–2052):attach 所在类别行 left/middle,60×24
|
||||
* 帧1=牌局 帧2=模式 帧3=规则(ROOM_CATEGORY_TITLE)
|
||||
* ↑
|
||||
* ROOM_TEXT_CARD_COST(2061) 房卡消耗附注(灰色小字)
|
||||
* attach ROOM_BOX_CARD_2 右侧 offsetX=+20(ROOM_CARD_COST_NOTE)
|
||||
*
|
||||
* 布局参数(EQW_Layout,清单 §6.9b 原文说明数值按参考图比例估、实际以平台建房界面尺寸为准):
|
||||
* ROOM_CATEGORY_COLUMN line/vertical, anchor='top', itemHeight=32, spacing=8,
|
||||
* itemWidth 由调用方按平台内容区实际宽度注入
|
||||
* ⚠ anchor 必须用竖排语义 top/bottom/center,写成 'left' 会被
|
||||
* 求解器按方向校验拒掉(历史坑:会算出画布外的 y)
|
||||
* ROOM_OPTION_ROW line/horizontal, anchor='left', anchorX=70, itemWidth=150,
|
||||
* itemHeight 沿用类别行高 32,spacing=0,anchorY 随所在类别行注入
|
||||
* ROOM_OPTION_GROUP_GAP 40 同类别下两个选项组之间比组内选项多留的间距
|
||||
* ROOM_OPTION_MAX_PER_ROW 4 + ROOM_OPTION_OVERFLOW_GRID —— 选项超 4 个才折行;
|
||||
* 二七王每组最多 2 项,不会触发
|
||||
*
|
||||
* 元素说明:
|
||||
* - 每个选项 = 选择框(图片精灵)+ 文字精灵,成对出现,共 8 组。
|
||||
* - 选择框帧号 = (useCheckbox ? 3 : 1) + (selected ? 1 : 0),
|
||||
* useCheckbox = (类型为 checkbox 或 radioOptional)。三个单选组用帧1–2,附加规则组用帧3–4。
|
||||
* - 选中态文字变橙: EQW_Layout.TEXT_STYLE.ROOM_OPT 的 normal / selected 两态。
|
||||
* - 【本文件不认识玩法名】渲染由 EQW_RoomOptions(Layout_CreateRoom.js)这份配置数据驱动:
|
||||
* 配置里每个选项自己声明用哪个精灵键(box / text)、type(决定帧号)、bit/value
|
||||
* (决定 roomtype 拼串)。渲染器一律【按键名取精灵,不得按下标拼字符串】——
|
||||
* 组的 key 是 'rules'、精灵却叫 ROOM_BOX_RULE_1,本来就拼不出来。
|
||||
* 加一个选项 = 配置里加一条,不改渲染逻辑(OCP + 配置优先)。
|
||||
* - 【roomtype 拼串只在一处实现】遍历配置写 item.value 到 item.bit,不得在别处按下标硬拼
|
||||
* (与服务端 class.config.js parse() 对称的 SSOT 要求)。二七王 5 位:
|
||||
* 位0 局数(6局/12局) 位1 扣卡(房主/AA) 位2 傍王 位3 爬坡 位4 查牌(可查/不查)
|
||||
* 缺省值 roomtype = "00000"。
|
||||
* - 【房卡消耗联动】随「局数 × 扣卡」两组实时变化,取自 EQW_RoomOptions.cardCost 配置表,
|
||||
* 不硬编码在 UI 代码里:房主扣卡 6局=房主2张 / 12局=房主4张;AA 6局=每人1张 / 12局=每人2张。
|
||||
* - 【参考图里不要照抄的部分】「人数」行(二七王固定 3 人,不给玩家选)与「霸王」行。
|
||||
* - 交互:点平台约定的 25 号精灵 → 拼 roomtype → Net.Send_create_room(...)。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 选择框: EQW_Images.ROOM_OPT_BOX (681), 4 帧 (2×2), 22×22
|
||||
* 帧1=单选·未选中(灰空心环) 帧2=单选·已选中(橙实心环)
|
||||
* 帧3=复选·未选中(浅绿空方块) 帧4=复选·已选中
|
||||
* - 类别标题: EQW_Images.ROOM_CAT_TITLE (682), 3 帧, 60×24, 帧1=牌局 帧2=模式 帧3=规则
|
||||
* - 选项行底: EQW_Images.ROW_ROOM_OPT (683), 1 帧, 尺寸待定——参考图为无底透明,可选,
|
||||
* 当前本 View 未使用该资源(没有对应精灵)
|
||||
*
|
||||
* 精灵ID分配: 2050–2069(群组 250 号段 2050–2099),共 20 个,全部预置
|
||||
* —— 选项集固定(3 类别 / 4 选项组 / 8 选项),数量小,不用精灵复制(§5.6a 判据表)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.1 建房·房间设置 / §5.8 / §6.9b
|
||||
* @see docs_dev/uiref/创建房间选项的UI参考内容根据实际二七王游戏改动.png(样式范式,非内容)
|
||||
*/
|
||||
//建房规则选项区:挂在平台创建房间界面所在图层(清单 §5.8,群组 250)
|
||||
EQW_Sprites.CreateRoomView = {
|
||||
Layer: EQW_Layers.PLATFORM_CREATE_ROOM, //27
|
||||
|
||||
CreateRoom: {
|
||||
id: EQW_Groups.CREATE_ROOM, //250
|
||||
|
||||
//—— 3 个类别标题,资源 EQW_Images.ROOM_CAT_TITLE,帧1=牌局 帧2=模式 帧3=规则 ——
|
||||
ROOM_CAT_TITLE_1: 2050,
|
||||
ROOM_CAT_TITLE_2: 2051,
|
||||
ROOM_CAT_TITLE_3: 2052,
|
||||
|
||||
//—— 局数选择框(6局/12局),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
|
||||
ROOM_BOX_ASET_1: 2053,
|
||||
ROOM_BOX_ASET_2: 2054,
|
||||
ROOM_TEXT_ASET_1: 2055, //文字:局数选项文字
|
||||
ROOM_TEXT_ASET_2: 2056, //文字:局数选项文字
|
||||
|
||||
//—— 扣卡选择框(房主/AA),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
|
||||
ROOM_BOX_CARD_1: 2057,
|
||||
ROOM_BOX_CARD_2: 2058,
|
||||
ROOM_TEXT_CARD_1: 2059, //文字:扣卡选项文字
|
||||
ROOM_TEXT_CARD_2: 2060, //文字:扣卡选项文字
|
||||
|
||||
ROOM_TEXT_CARD_COST: 2061, //文字:房卡消耗附注(灰色小字,随局数×扣卡联动)
|
||||
|
||||
//—— 查牌选择框(可查/不查),资源 EQW_Images.ROOM_OPT_BOX,单选帧1–2 ——
|
||||
ROOM_BOX_PEEK_1: 2062,
|
||||
ROOM_BOX_PEEK_2: 2063,
|
||||
ROOM_TEXT_PEEK_1: 2064, //文字:查牌选项文字
|
||||
ROOM_TEXT_PEEK_2: 2065, //文字:查牌选项文字
|
||||
|
||||
//—— 附加规则选择框(傍王/爬坡),资源 EQW_Images.ROOM_OPT_BOX,复选帧3–4 ——
|
||||
ROOM_BOX_RULE_1: 2066,
|
||||
ROOM_BOX_RULE_2: 2067,
|
||||
ROOM_TEXT_RULE_1: 2068, //文字:附加规则选项文字
|
||||
ROOM_TEXT_RULE_2: 2069 //文字:附加规则选项文字
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sprites: 精灵结构(清单 §5.6/§5.6a/§5.7a/§5.7b 结算)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏精灵段 1001–2999。结构与 Sprites_Table.js 一致:
|
||||
// View 下 Layer 为图层声明(一个 View 恰好一个图层),其余键是 group 容器;
|
||||
// 容器内 id 为群组 ID、其余键才是精灵 id。
|
||||
// 连号精灵逐条写全,不用循环生成。
|
||||
var EQW_Sprites = EQW_Sprites || {};
|
||||
|
||||
// ============================================================================
|
||||
// 小局结算面板 - Layer 302 (POPUP_ASET_RESULT), Group 240 (ASET_RESULT)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 小局结算面板 - AsetResultView
|
||||
*
|
||||
* 用途: 一小局打完后盖在牌桌之上,展示【埋牌底牌 + 扣底算式】与【三家得分】,
|
||||
* 并给出「冲关牌型」「下一局」两个出口。本面板没有整块面板底图——
|
||||
* 各元素直接摆在半透牌桌上(见参考图,牌桌仍隐约可见)。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/小局结算.png):
|
||||
*
|
||||
* ┌ 175,150 ─────────┐ ┌ 430,140 ────────────────┐ ┌ 865,150 ─────────┐
|
||||
* │ │ │ ┌┐┌┐┌┐┌┐┌┐┌┐┌┐┌┐ │ │ │
|
||||
* │ +20 │ │ └┘└┘└┘└┘└┘└┘└┘└┘ ×8 │ │ -10 │
|
||||
* │ │ │ 叫分2子 底牌 20x0 │ │ │
|
||||
* └──────────────────┘ └─────────────────────────┘ └──────────────────┘
|
||||
* 牌局分 10 冲关分 10 牌局分 -5 冲关分 -5
|
||||
* ↑ ↑ ↑ ↑
|
||||
* RESULT_JF_LEFT RESULT_AW_LEFT RESULT_JF_RIGHT RESULT_AW_RIGHT
|
||||
* (1808) (1811) (1809) (1812)
|
||||
*
|
||||
* ┌ 490,340 ─────────┐
|
||||
* │ -10 │ ← RESULT_GLOW_SELF(1804) 光晕 225×100
|
||||
* └──────────────────┘ RESULT_SCORE_SELF(1807) 总分大字
|
||||
* 牌局分 -5 冲关分 -5 RESULT_JF_SELF(1810) / RESULT_AW_SELF(1813)
|
||||
*
|
||||
* ┌ 535,515 ────┐ ┌ 835,518 ───┐
|
||||
* │ 冲关牌型 │ │ 下一局 │
|
||||
* └─────────────┘ └────────────┘
|
||||
* ↑ RESULT_BTN_CG(1815) ↑ RESULT_BTN_NEXT(1817)
|
||||
* +_TEXT(1816) 183×63 +_TEXT(1818) 180×65
|
||||
*
|
||||
* 三家的位置与牌桌上各自的座位方位一致:左上家在左、右上家在右、自己在中央偏下。
|
||||
* 底牌区面板 RESULT_BOTTOM_PANEL(1800) = point(430,140,420×148),
|
||||
* 内含群组 215 的 8 张埋牌底牌(BuryCardsView,见 Sprites_Cards.js)与
|
||||
* RESULT_BOTTOM_TEXT(1801)(attach 面板 center/bottom, offsetY=-14)。
|
||||
*
|
||||
* 每家一组三个数字(EQW_Layout.RESULT_GLOW / RESULT_SCORE_NUM / RESULT_JF_TEXT /
|
||||
* RESULT_AW_TEXT,都用 bySeat 三变体):
|
||||
* ┌───── 光晕 225×100 ─────┐
|
||||
* │ +20 │ ← 总分大字:attach 光晕 center/middle
|
||||
* └────────────────────────┘
|
||||
* 牌局分 10 冲关分 10
|
||||
* ↑ attach 光晕 left/bottom ↑ attach 光晕 right/bottom
|
||||
* offset(-15,+24) offset(+15,+24)
|
||||
*
|
||||
* 元素说明:
|
||||
* - 光晕帧: GLOW_RESULT 帧1=赢(橙) / 帧2=输(蓝),按该家 grade 的正负取帧。
|
||||
* - 总分大字: 取 aset.seatlist[i].grade,正分橙带「+」、负分蓝带「-」;
|
||||
* 按符号在 NUM_RESULT_WIN / NUM_RESULT_LOSE 两套资源里二选一绑定,
|
||||
* 显示用 SpriteManager.setNumberImage(spriteId, '+20', charWidth)。
|
||||
* 与大局结算的 ACC_TPL_SCORE_NUM(1914) 同一套资源与用法,两处得分显示保持一致。
|
||||
* - 扣底文案: 格式「叫分{aset.multiple}子 底牌 {bottom.grade1}x{bottom.multiple}」——
|
||||
* x 后面是【扣底倍数】(单张 ×1、主对 ×2、N 连对 ×2N)。闲家未扣底时 bottom 只有 cards,
|
||||
* multiple/grade1/grade2 都不下发 → 倍数位显示 0(参考图的「底牌 20x0」即此情形)。
|
||||
* - 【标签文案与字段】参考图两个小字标签已定案为「牌局分」/「冲关分」(清单 §1.8):
|
||||
* 牌局分取 grade_jf(捡分子数得分)、冲关分取 grade_cg(算奖里的冲关分量,S-6 新增,
|
||||
* 开傍王时不含傍王那部分)。精灵键名沿用早期的 _JF / _AW,与文案不完全同名,勿混。
|
||||
* - 【判定结果不在本面板显示,D-8】大光/小光/过庄/升N级/投降由【动画】呈现,
|
||||
* 故 1814 RESULT_JUDGE_TEXT 被判定不需要(见下方空号说明)。动画在两个时机播:
|
||||
* 对局过程中 —— 依 curmultiple(随 chupai1/2/3 下发,带符号,S-8)档位跳变时提示
|
||||
* 结算前 —— 依 aset.upgrade(带符号:3大光 2小光 1过庄 -N升N级 -99投降 0解散)
|
||||
* 末轮打完、本面板出现【之前】播一次最终判定
|
||||
* 【待设计稿】动画资源 EQW_Images.ANI_JUDGE(634) 帧数/尺寸待定,
|
||||
* 播放参数在 EQW_Anim 里留白(AnimConstants.js 末尾「D-8 待补」)——本 View 无对应精灵。
|
||||
* - 【容错】投降结算只有 aset、无 chupai 无 bottom → 面板必须容忍底牌区缺失,
|
||||
* 不能因读不到 bottom.cards 就崩或显示空框。
|
||||
* - 【时序】先 setXxx 把结算数据写进 this.data、再播动画;动画的开始/结束/出错回调里
|
||||
* 只刷界面、不写核心数据。动画缺失或卡住时,面板与数据仍须正确。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 底牌区面板底: EQW_Images.PANEL_BOTTOM_CARDS (606), 1 帧, 420×148
|
||||
* - 分数光晕: EQW_Images.GLOW_RESULT (607), 2 帧, 225×100, 帧1=赢(橙) 帧2=输(蓝)
|
||||
* - 得分数字: EQW_Images.NUM_RESULT_WIN (632) / NUM_RESULT_LOSE (633),
|
||||
* 各 16 帧固定帧序, 单字符 46×62, 整图 736×62;帧12=「+」帧13=「-」必出;
|
||||
* charWidth 取 EQW_Layout.NUM_STYLE.RESULT_WIN / RESULT_LOSE.charWidth
|
||||
* - 冲关牌型按钮: EQW_Images.BTN_CG_CARDS (542), 1 帧, 183×63, 蓝色渐变
|
||||
* - 下一局按钮: EQW_Images.BTN_NEXT_ASET (541), 1 帧, 180×65, 金黄渐变
|
||||
*
|
||||
* 精灵ID分配: 1800–1813 / 1815–1818(群组 240);埋牌底牌 8 张复用群组 215(1258–1265)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8 末轮扣底 + 小局结算 / §5.6 / §6.9
|
||||
* @see docs_dev/uiref/小局结算.png
|
||||
*/
|
||||
//小局结算面板(清单 §5.6,Layer 302 群组 240)
|
||||
//
|
||||
//以下键号在源清单中被显式判定「不需要」,本文件不占用,留空号以便追溯:
|
||||
// 1814 RESULT_JUDGE_TEXT —— 结算面板不显示判定文字,改由动画呈现(§1.8,D-8)
|
||||
EQW_Sprites.AsetResultView = {
|
||||
Layer: EQW_Layers.POPUP_ASET_RESULT, //302
|
||||
|
||||
AsetResult: {
|
||||
id: EQW_Groups.ASET_RESULT, //240
|
||||
|
||||
RESULT_BOTTOM_PANEL: 1800, //图片:底牌区面板底,资源 EQW_Images.PANEL_BOTTOM_CARDS
|
||||
RESULT_BOTTOM_TEXT: 1801, //文字:「叫分{multiple}子 底牌 {grade1}x{bottom.multiple}」
|
||||
|
||||
//—— 三家分数光晕,资源 EQW_Images.GLOW_RESULT,帧1=赢(橙) 帧2=输(蓝) ——
|
||||
RESULT_GLOW_LEFT: 1802,
|
||||
RESULT_GLOW_RIGHT: 1803,
|
||||
RESULT_GLOW_SELF: 1804,
|
||||
|
||||
//—— 三家总分(aset.seatlist[i].grade),多帧图数字精灵,正分橙带 `+`、负分蓝带 `-`,
|
||||
// 资源 EQW_Images.NUM_RESULT_WIN(赢)/ NUM_RESULT_LOSE(输),按符号二选一绑资源,
|
||||
// 显示用 SpriteManager.setNumberImage(spriteId, '+20', charWidth),charWidth 见
|
||||
// EQW_Layout.NUM_STYLE.RESULT_WIN/RESULT_LOSE——与 ACC_TPL_SCORE_NUM(1914,大局结算总分)
|
||||
// 同一套资源与用法,两处结算得分显示保持一致(2026-08-27 由文字精灵改为数字图精灵)——
|
||||
RESULT_SCORE_LEFT: 1805,
|
||||
RESULT_SCORE_RIGHT: 1806,
|
||||
RESULT_SCORE_SELF: 1807,
|
||||
|
||||
//—— 三家捡分子数得分文字 grade_jf【T-13】——
|
||||
RESULT_JF_LEFT: 1808,
|
||||
RESULT_JF_RIGHT: 1809,
|
||||
RESULT_JF_SELF: 1810,
|
||||
|
||||
//—— 三家算奖得分文字 grade_aw【T-13】——
|
||||
RESULT_AW_LEFT: 1811,
|
||||
RESULT_AW_RIGHT: 1812,
|
||||
RESULT_AW_SELF: 1813,
|
||||
|
||||
RESULT_BTN_CG: 1815, //图片:「冲关牌型」按钮,资源 EQW_Images.BTN_CG_CARDS
|
||||
RESULT_BTN_CG_TEXT: 1816, //文字:按钮文案「冲关牌型」
|
||||
RESULT_BTN_NEXT: 1817, //图片:「下一局」按钮,资源 EQW_Images.BTN_NEXT_ASET
|
||||
RESULT_BTN_NEXT_TEXT: 1818 //文字:按钮文案「下一局」
|
||||
}
|
||||
};
|
||||
//> 底牌 8 张精灵复用 Sprites_Cards.js 群组 215(BuryCardsView.BuryCards,1258–1265),不在此重复。
|
||||
|
||||
// ============================================================================
|
||||
// 冲关牌型叠加层 - Layer 302 (POPUP_ASET_RESULT), Group 244 (CHONGGUAN)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 冲关牌型叠加层 - ChongGuanOverlayView
|
||||
*
|
||||
* 用途: 在小局结算面板【之上】再盖一层半透黑遮罩,三家各摊开自己参与冲关的牌
|
||||
* (aset.seatlist[i].cards),并在旁边标出奖数。点「冲关牌型」按钮打开。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/冲关牌型显示.png;牌的排布见
|
||||
* EQW_Layout.CHONGGUAN_FAN: fan/horizontal, anchor center,
|
||||
* 牌尺寸 CARD_SIZE.L 110×190, maxWidth=420, spacingMax=-40 spacingMin=-88):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌──┬──┬──┬────┐ ┌──┬──┬──┬────┐ │
|
||||
* │ │K │K │K │ K │ │K │K │K │ K │ │ 260,120 / 1035,120
|
||||
* │ └──┴──┴──┴────┘ └──┴──┴──┴────┘ │
|
||||
* │ CG_BOX_LEFT(1821) CG_BOX_RIGHT(1822) │
|
||||
* │ CG_COUNT_LEFT(1825) CG_COUNT_RIGHT(1826)│
|
||||
* │ │
|
||||
* │ (半透黑遮罩 CG_MASK(1820) 铺满全屏) │
|
||||
* │ │
|
||||
* │ ┌──┬──┬──┬────┐ │
|
||||
* │ │K │K │K │ K │ ← CG_BOX_SELF(1823) 640,400│
|
||||
* │ └──┴──┴──┴────┘ CG_COUNT_SELF(1827) │
|
||||
* │ │
|
||||
* │ ┌────────────┐ │
|
||||
* │ │ 下一局 │ ← 遮罩之上│ 835,518
|
||||
* │ └────────────┘ 仍可见可点│
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* 三家牌位与结算面板同方位:左上家、右上家在各自头像内侧,自己在中央偏下。
|
||||
* 遮罩 EQW_Layout.CG_MASK = point(0,0,1280×720, opacity 0.72)。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 【牌用精灵复制,不预置固定张数】cards 长度不定——四王 + 一条长连对链 + 八个 7 +
|
||||
* 八个 2 叠加时可达 20 余张,预置既可能不够、又在多数局里白占 ID。
|
||||
* 编辑器里只需建 5 个精灵:3 个容器 + 1 个牌模板 + 1 个遮罩;奖数文字 3 个照常预置。
|
||||
* 生成方式见下方原注释与清单 §5.6a 的代码片段(SpriteCopyUtils.create + setFrame)。
|
||||
* - 【清理是硬要求】关闭叠加层 / 组件 onDestroy 时必须
|
||||
* SpriteCopyUtils.removeRange(boxId, 0, cards.length),否则复制精灵残留。
|
||||
* - 【关闭方式】遮罩本身就是关闭热区,点遮罩即关闭,不另设关闭按钮 →
|
||||
* CG_MASK 必须注册点击事件;同时 RESULT_BTN_NEXT(1817/1818) 的层内层级须【高于】遮罩,
|
||||
* 点它不被遮罩吃掉(「冲关牌型」按钮本身被遮住则无妨)。
|
||||
* ⚠ 三个遮罩面板的关闭方式各不相同,别写混:
|
||||
* 冲关牌型(本 View)→ 点遮罩关闭 遮罩可点
|
||||
* 明牌 / 出牌历史 → 点遮罩关闭,明牌另可再点按钮切换 遮罩可点
|
||||
* 亮牌 → 仅自动时长,到点自动收起 遮罩【不】注册点击
|
||||
* - 奖数取 aset.seatlist[i].naward(该家总奖数,支付基数);chongguan / wang 可作明细选展。
|
||||
* 前端【不重算冲关】,一律直接取服务端下发值。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(出 1 张小图即可,拉伸满 1280×720)
|
||||
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
|
||||
*
|
||||
* 精灵ID分配: 1820–1827(群组 244)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.8「冲关」是什么 / §5.6a / §6.7
|
||||
* @see docs_dev/uiref/冲关牌型显示.png
|
||||
*/
|
||||
//冲关牌型叠加层(清单 §5.6a,Layer 302 群组 244,精灵复制)——
|
||||
//本层的牌用 SpriteCopyUtils 运行时复制,不预置固定张数(cards 长度不定,最多可达 20 余张)。
|
||||
//编辑器里只需建 5 个精灵(3 容器 + 1 牌模板 + 1 遮罩);奖数文字 3 个照常预置。
|
||||
//RESULT_BTN_NEXT(1817/1818)在遮罩之上仍需可见可点,其图层内层级须高于 CG_MASK。
|
||||
//清理是硬要求:关闭叠加层/组件 onDestroy 时必须 SpriteCopyUtils.removeRange(boxId, 0, cards.length),否则复制精灵残留。
|
||||
EQW_Sprites.ChongGuanOverlayView = {
|
||||
Layer: EQW_Layers.POPUP_ASET_RESULT, //302
|
||||
|
||||
ChongGuan: {
|
||||
id: EQW_Groups.CHONGGUAN, //244
|
||||
|
||||
CG_MASK: 1820, //图片:半透黑遮罩(拉伸满屏,兼作点击关闭热区),资源 EQW_Images.MASK_DIM
|
||||
CG_BOX_LEFT: 1821, //容器:左上家冲关牌容器(SpriteCopyUtils 复制的父精灵)
|
||||
CG_BOX_RIGHT: 1822, //容器:右上家冲关牌容器
|
||||
CG_BOX_SELF: 1823, //容器:自己冲关牌容器
|
||||
CG_CARD_TPL: 1824, //模板:冲关牌模板(编辑器里放好、默认隐藏,只作样式来源),资源 EQW_Images.CARD_FACE_L
|
||||
|
||||
//—— 三家奖数文字,与该家的冲关牌并列显示,取 aset.seatlist[i].naward ——
|
||||
CG_COUNT_LEFT: 1825,
|
||||
CG_COUNT_RIGHT: 1826,
|
||||
CG_COUNT_SELF: 1827
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 大局总结算 / 解散结算 - Layer 303 (POPUP_ACCOUNT), Group 241 (ACCOUNT)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 大局总结算 / 解散结算 - AccountView
|
||||
*
|
||||
* 用途: 打完最后一局(或房间解散)时弹出的总账面板:标题栏 + 三条玩家栏 + 规则栏,
|
||||
* 面板【外】底部另有 4 个功能按钮。解散结算复用同一面板,只是 aset 各项为 0。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/大局结算.png;面板居中于 1280×720,
|
||||
* 实测约 x≈80–1180, y≈88–620,按钮条 y≈640–700):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────┐ ← ACC_TITLE_BAR(1901)
|
||||
* │ 二七王-3人 房号654216 共24局 2025-12-14 11:45 [×] │ 1100×56,青绿底
|
||||
* ├──────────────────────────────────────────────────────────┤ ↑ACC_TITLE_TEXT(1902)
|
||||
* │ │ ↑ACC_BTN_CLOSE(1903)
|
||||
* │ ⑴ [头像] 玩家1 基础分30 算奖分12 傍王分15 +135 │ ← 玩家栏(复制生成)
|
||||
* │ ID:100001 总得分30 │
|
||||
* │ ──────────────────────────────────────────────────────── │ ← ACC_TPL_DIVIDER(1915)
|
||||
* │ ⑵ [头像] 玩家2 基础分-30 算奖分-12 傍王分-15 -45 │
|
||||
* │ ID:100002 总得分-30 │
|
||||
* │ ──────────────────────────────────────────────────────── │
|
||||
* │ ⑶ [头像] 玩家3 …… -45 │
|
||||
* │ │
|
||||
* ├──────────────────────────────────────────────────────────┤ ← ACC_RULE_BAR(1904)
|
||||
* │ 规则:可查牌、傍王、爬坡算子 │ 1100×62,浅绿底
|
||||
* └──────────────────────────────────────────────────────────┘ ↑ACC_RULE_TEXT(1905)
|
||||
* ↑ ACC_PANEL_BG(1900) 圆角浅灰绿 1100×540(标题栏与规则栏是它的上下封边)
|
||||
*
|
||||
* ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ← 面板【外】底部
|
||||
* │ 再来一局 │ │ 分享好友 │ │ 复制战绩 │ │ 退出房间 │ 各 190×60
|
||||
* └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
* (1920/21) (1922/23) (1924/25) (1926/27)
|
||||
* 黄 蓝 蓝 红
|
||||
*
|
||||
* 单条玩家栏的列序(左→右):
|
||||
* 名次徽章 → 头像 → 昵称 /「ID:{playerid}」(两行) → 「基础分{n}」/「总得分{n}」(两行)
|
||||
* → 「冲关分{n}」→「傍王分{n}」→ 右侧总分大字
|
||||
* ⚠ 参考图第三列小字写的是「算奖分」,清单 §1.9 定案的字段是 grade_cg_total(冲关分);
|
||||
* 四项 grade_jf_total / grade_cg_total / grade_bw_total 之和恒等于 score(S-6)。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 【玩家栏用精灵复制】三条结构完全相同、仅数据不同,用 SpriteCopyUtils 从 5 个模板
|
||||
* (ACC_TPL_RANK / AVATAR / TEXT / SCORE_NUM / DIVIDER)复制生成;面板外壳与按钮预置。
|
||||
* tag 分段见下方原注释;关闭面板时按各段 removeRange 清理。
|
||||
* - 名次徽章按总分排名取帧(1金 / 2橙 / 3蓝,花瓣形)。
|
||||
* - 总分大字与小局结算的 RESULT_SCORE_* 同一套资源与用法:正分橙带「+」、负分蓝带「-」,
|
||||
* setNumberImage 走 TEXT/WIDTH 属性,_validateSpriteId 也接受复制精灵的字符串 ID,
|
||||
* 故复制出来的玩家栏总分同样【一个精灵显示整串】。
|
||||
* - 规则栏文案据 roomtype 位串拼出已启用项,不硬编码。
|
||||
* - 【解散结算】投递方式与小局结算完全不同(协议 §14.1):rpc 是平台的 free_room、
|
||||
* route 是 room,真实取值路径是 data.deskfree.data.aset / .account(多一层 data 包装),
|
||||
* 且 data.deskfree 可能缺失(开战后、首局发牌前解散)→ 前端必须容忍。
|
||||
* 解散的申请/投票/同意拒绝界面【由平台全包】(Layer 420),子游戏不做投票界面。
|
||||
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「大局总结算」一节:
|
||||
* 外壳 —— ACC_PANEL_BG / ACC_TITLE_BAR / ACC_TITLE_TEXT / ACC_BTN_CLOSE /
|
||||
* ACC_RULE_BAR / ACC_RULE_TEXT
|
||||
* 玩家栏(精灵复制)—— ACC_ROW_CONTAINER(容器)+ ACC_ROW(行模板,itemHeight 即行高,
|
||||
* 行数由 ctx.count 给出)+ ACC_TPL_RANK / ACC_TPL_AVATAR / ACC_ROW_TEXT_GRID /
|
||||
* ACC_TPL_SCORE_NUM / ACC_TPL_DIVIDER(都 attach 到 ACC_ROW 的行矩形上)
|
||||
* 按钮 —— ACC_FUNC_BUTTONS(面板外底部 4 个)
|
||||
* ⚠ 行内各节点求出的是【相对 ACC_ROW_CONTAINER 的】坐标,可直接喂 SpriteCopyUtils.create。
|
||||
* ⚠ 这些坐标是参考图【目视实测估值】(w/h 取资源尺寸),待设计稿复核;
|
||||
* 但配置已成型,渲染代码一律引用节点,不得就地硬编码坐标(零裸值红线)。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 面板底: EQW_Images.PANEL_ACCOUNT (610), 1 帧, 1100×540, 圆角浅灰绿
|
||||
* - 标题栏: EQW_Images.BAR_ACCOUNT_TITLE (611), 1 帧, 1100×56, 青绿底含圆角上沿
|
||||
* - 规则栏: EQW_Images.BAR_ACCOUNT_RULE (612), 1 帧, 1100×62, 浅绿底含圆角下沿
|
||||
* - 关闭钮: EQW_Images.BTN_ACC_CLOSE (547), 1 帧, 36×36
|
||||
* - 名次徽章: EQW_Images.BADGE_RANK (579), 3 帧, 44×44, 帧=名次 1–3
|
||||
* - 分隔线: EQW_Images.LINE_DIVIDER (613), 1 帧, 1060×2
|
||||
* - 总分数字: EQW_Images.NUM_RESULT_WIN (632) / NUM_RESULT_LOSE (633), 16 帧, 单字符 46×62
|
||||
* - 四个按钮: EQW_Images.BTN_ACCOUNT_AGAIN (543) / SHARE (544) / COPY (545) / EXIT (546),
|
||||
* 各 1 帧, 190×60
|
||||
*
|
||||
* 精灵ID分配: 1900–1905 / 1910–1915 / 1920–1927(群组 241)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.9 大局总结算 / §1.10 解散结算 / §5.7a
|
||||
* @see docs_dev/uiref/大局结算.png
|
||||
*/
|
||||
//大局总结算 / 解散结算(清单 §5.7a,Layer 303 群组 241,玩家栏用精灵复制)——
|
||||
//三条玩家栏结构完全相同、仅数据不同,用 SpriteCopyUtils 复制生成;面板外壳与按钮预置。
|
||||
//tag 分段(SpriteCopyUtils.createTagManager):rank 1–20 / avatar 21–40 / text 41–140(每行 6 条文字)/ score 141–160 / divider 161–180。
|
||||
//清理:关闭面板时按各段 removeRange。
|
||||
//解散结算复用本面板(协议 §14.1,解散包也带 account),只是 aset 各项为 0。
|
||||
EQW_Sprites.AccountView = {
|
||||
Layer: EQW_Layers.POPUP_ACCOUNT, //303
|
||||
|
||||
Account: {
|
||||
id: EQW_Groups.ACCOUNT, //241
|
||||
|
||||
ACC_PANEL_BG: 1900, //图片:面板底(圆角浅灰绿),资源 EQW_Images.PANEL_ACCOUNT
|
||||
ACC_TITLE_BAR: 1901, //图片:标题栏(青绿底),资源 EQW_Images.BAR_ACCOUNT_TITLE
|
||||
ACC_TITLE_TEXT: 1902, //文字:「二七王-3人 房号… 共…局 …」
|
||||
ACC_BTN_CLOSE: 1903, //图片:右上角关闭按钮,资源 EQW_Images.BTN_ACC_CLOSE
|
||||
ACC_RULE_BAR: 1904, //图片:底部规则栏(浅绿底),资源 EQW_Images.BAR_ACCOUNT_RULE
|
||||
ACC_RULE_TEXT: 1905, //文字:「规则:可查牌、傍王、爬坡算子」
|
||||
|
||||
ACC_ROW_CONTAINER: 1910, //容器:玩家栏的复制父精灵
|
||||
|
||||
//—— 玩家栏复制模板(tag 分段见上)——
|
||||
ACC_TPL_RANK: 1911, //模板:名次徽章,资源 EQW_Images.BADGE_RANK,帧=名次1–3
|
||||
ACC_TPL_AVATAR: 1912, //模板:头像,运行时贴玩家头像
|
||||
ACC_TPL_TEXT: 1913, //模板:通用文字(昵称/ID/各项分数,一行一个)
|
||||
ACC_TPL_SCORE_NUM: 1914, //模板:右侧总分大字(多帧图数字精灵),资源 EQW_Images.NUM_RESULT_WIN / NUM_RESULT_LOSE,
|
||||
//显示用 SpriteManager.setNumberImage(复制精灵ID, '+120', charWidth)——
|
||||
//setNumberImage 走的是 TEXT/WIDTH 属性,_validateSpriteId 也接受复制精灵的字符串 ID,
|
||||
//故复制出来的玩家栏总分同样一个精灵显示整串(2026-08-26 核对)
|
||||
ACC_TPL_DIVIDER: 1915, //模板:行间分隔线,资源 EQW_Images.LINE_DIVIDER
|
||||
|
||||
//—— 底部 4 个功能按钮 ——
|
||||
ACC_BTN_AGAIN: 1920, //图片:「再来一局」按钮,资源 EQW_Images.BTN_ACCOUNT_AGAIN
|
||||
ACC_BTN_AGAIN_TEXT: 1921, //文字:按钮文案「再来一局」
|
||||
ACC_BTN_SHARE: 1922, //图片:「分享好友」按钮,资源 EQW_Images.BTN_ACCOUNT_SHARE
|
||||
ACC_BTN_SHARE_TEXT: 1923, //文字:按钮文案「分享好友」
|
||||
ACC_BTN_COPY: 1924, //图片:「复制战绩」按钮,资源 EQW_Images.BTN_ACCOUNT_COPY
|
||||
ACC_BTN_COPY_TEXT: 1925, //文字:按钮文案「复制战绩」
|
||||
ACC_BTN_EXIT: 1926, //图片:「退出房间」按钮,资源 EQW_Images.BTN_ACCOUNT_EXIT
|
||||
ACC_BTN_EXIT_TEXT: 1927 //文字:按钮文案「退出房间」
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 出牌历史 / 上一轮 / 亮牌 - Layer 304 (POPUP_HISTORY), Group 242 (HISTORY)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 出牌历史 / 上一轮 / 亮牌 - HistoryView
|
||||
*
|
||||
* 用途: 半透黑遮罩 + 每家一排牌的查看面板,【一套精灵三种用途】,只换数据源:
|
||||
* 出牌历史 = pushlist 全部轮次摊平聚合到各家,三排
|
||||
* 上一轮 = pushlist 末尾一项,三排
|
||||
* 亮牌 = liangpai.cards(庄家的固定主牌),只用一排
|
||||
*
|
||||
* 布局说明(版式照 docs_dev/uiref/冲关牌型显示.png——本 View 与 MingPaiView、
|
||||
* ChongGuanOverlayView 三者是同一种版式:遮罩 + 每家一排重叠排列的大牌):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌──┬──┬──┬──┬────┐ ┌──┬──┬──┬──┬────┐ │
|
||||
* │ │K │K │K │K │ K │ │K │K │K │K │ K │ │
|
||||
* │ └──┴──┴──┴──┴────┘ └──┴──┴──┴──┴────┘ │
|
||||
* │ ↑CG_BOX 位同左上家 ↑位同右上家 │
|
||||
* │ HIST_BOX_LEFT(1951) HIST_BOX_RIGHT(1952) │
|
||||
* │ HIST_LABEL_LEFT(1955) HIST_LABEL_RIGHT(1956)│
|
||||
* │ │
|
||||
* │ (半透黑遮罩 HIST_MASK(1950) 铺满全屏,兼关闭热区)│
|
||||
* │ │
|
||||
* │ ┌──┬──┬──┬──┬────┐ │
|
||||
* │ │K │K │K │K │ K │ ← HIST_BOX_SELF(1953) │
|
||||
* │ └──┴──┴──┴──┴────┘ HIST_LABEL_SELF(1957) │
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* 三排的落点与各自座位方位一致(左上家在左、右上家在右、自己中央偏下),
|
||||
* 与 ChongGuanOverlayView 同构;【亮牌】只显示庄家那一排,其余两个容器隐藏。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 【牌用精灵复制】张数不定(出牌历史摊平后可达整手 28 张以上),
|
||||
* 编辑器里只建 1 个遮罩 + 3 个容器 + 1 个牌模板 + 3 个座位标签文字。
|
||||
* 关闭 / onDestroy 时必须 removeRange 清理复制精灵。
|
||||
* - 出牌历史与上一轮【仅可查牌模式】;不可查牌模式下底栏那两个按钮整体不出现。
|
||||
* 数据在出牌过程中由前端逐包(chupai1/2/3)自行累积,重连时从 pushlist 恢复。
|
||||
* - 出牌历史【不按轮次分组】(D-7 已定):每家出过的牌全部摊平聚合到该玩家名下,
|
||||
* 再按手牌那套主牌序排序,三家各一排。前端自行摊平 + 排序,不需要改服务端。
|
||||
* - 【亮牌的关闭方式与其余两个面板不同】:只按自动时长关闭
|
||||
* (EQW_Anim.LIANGPAI_AUTO_CLOSE = 3000,清单原文时长「默认值待定」,现值为起始估值),
|
||||
* 遮罩【不注册点击事件】;而出牌历史 / 上一轮是点遮罩关闭。同一批精灵、两种关闭策略,
|
||||
* 实现时按用途分别挂事件,别写混。
|
||||
* - 【亮牌时机 T-34 已定】埋牌后、出牌前——庄家埋牌完成、服务端下发 maipai 包(带 liangpai)
|
||||
* 时弹出,出牌开始前收起;只有【闲家】会收到并弹出,庄家自己不弹。
|
||||
* 一次性弹出、不是常驻入口;不阻塞出牌,若面板还开着就收到出牌推送,直接收起即可。
|
||||
* - 【与 MingPaiView 的关系】版式相同、渲染逻辑可共用,但数据独立、图层不同
|
||||
* (304 vs 305),故拆成两个 View——一个 View 恰好一个图层。
|
||||
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「出牌历史 / 上一轮 / 亮牌」一节:
|
||||
* HIST_MASK(遮罩)/ HIST_BOX(三个容器同摆原点、铺满屏)/ HIST_FAN(每家一排牌,
|
||||
* bySeat 三变体,锚点沿用 §6.7 冲关牌的 LEFT 260,120 · RIGHT 1035,120 · SELF 640,400)/
|
||||
* HIST_LABEL(三家座位标签,w/h 运行时注入)。
|
||||
* 与 CHONGGUAN_FAN 的差别只有张数上限:出牌历史摊平后可达 28 张以上,
|
||||
* 故 maxWidth 放宽到 500、spacingMin 收到 -96【估值,待设计稿复核】。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(与冲关/明牌共用)
|
||||
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
|
||||
*
|
||||
* 精灵ID分配: 1950–1957(群组 242)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.6 亮牌 / §1.7 出牌历史 / §5.7b
|
||||
* @see docs_dev/uiref/冲关牌型显示.png
|
||||
*/
|
||||
//出牌历史 / 上一轮 / 亮牌(清单 §5.7b,Layer 304,群组 242,牌用精灵复制)——
|
||||
//三家都显示,数据 = pushlist 摊平聚合到各家(§1.7 D-7);上一轮同一套精灵,只取 pushlist 末尾一项;
|
||||
//亮牌只显示一排(庄家固定主牌),数据 = liangpai.cards(§1.6),复用同一套遮罩+容器+牌模板,仅数据源与排数不同。
|
||||
//与下方 MingPaiView(Layer 305)版式相同(遮罩 + 每家一排牌),差别只在数据源与家数,两个 View 共用同一套渲染逻辑,
|
||||
//但各自数据独立、图层不同,故拆成两个 View(一个 View 恰好一个图层)。
|
||||
EQW_Sprites.HistoryView = {
|
||||
Layer: EQW_Layers.POPUP_HISTORY, //304
|
||||
|
||||
History: {
|
||||
id: EQW_Groups.HISTORY, //242
|
||||
|
||||
HIST_MASK: 1950, //图片:半透黑遮罩(兼关闭热区),资源 EQW_Images.MASK_DIM
|
||||
HIST_BOX_LEFT: 1951, //容器:左上家牌容器
|
||||
HIST_BOX_RIGHT: 1952, //容器:右上家牌容器
|
||||
HIST_BOX_SELF: 1953, //容器:自己牌容器
|
||||
HIST_CARD_TPL: 1954, //模板:牌模板,资源 EQW_Images.CARD_FACE_L
|
||||
HIST_LABEL_LEFT: 1955, //文字:左上家座位标签(昵称/张数)
|
||||
HIST_LABEL_RIGHT: 1956, //文字:右上家座位标签
|
||||
HIST_LABEL_SELF: 1957 //文字:自己座位标签
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 明牌面板 - Layer 305 (POPUP_MINGPAI), Group 243 (MINGPAI)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 明牌面板 - MingPaiView
|
||||
*
|
||||
* 用途: 半透黑遮罩 + 【另两家】各一排牌,展示他们手中全部未出主牌的具体牌面。
|
||||
* 与 HistoryView 版式相同,差别只在数据源与家数(明牌只看他家,没有自己那排)。
|
||||
*
|
||||
* 布局说明(版式照 docs_dev/uiref/冲关牌型显示.png,去掉中央偏下的自己那一排):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌──┬──┬──┬──┬────┐ ┌──┬──┬──┬──┬────┐ │
|
||||
* │ │K │K │K │K │ K │ │K │K │K │K │ K │ │
|
||||
* │ └──┴──┴──┴──┴────┘ └──┴──┴──┴──┴────┘ │
|
||||
* │ ↑ MING_BOX_A(2001) ↑ MING_BOX_B(2002) │
|
||||
* │ MING_LABEL_A(2004) MING_LABEL_B(2005) │
|
||||
* │ │
|
||||
* │ (半透黑遮罩 MING_MASK(2000) 铺满全屏,兼关闭热区) │
|
||||
* │ │
|
||||
* │ (自己那排不存在——自己的牌自己看得见) │
|
||||
* │ │
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* A / B 两个容器分别落在两个他家的方位(左上 / 右上),与 HistoryView 的
|
||||
* HIST_BOX_LEFT / HIST_BOX_RIGHT 同位;牌用 CARD_FACE_L 重叠排列(fan)。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 【按钮显示条件 D-6 已定,按 design 手册】可查牌模式 + 出牌阶段 +
|
||||
* 【场上已有任一玩家报无主】(baozhu == 1) → 三家都显示「明牌」按钮,可随时查看、不限次数。
|
||||
* 判定的是「场上有人报无主」,不是「自己报无主」——报无主的那位自己也能看。
|
||||
* - 交互: 点按钮 → 发 mingpai { seat } → 收 mingpai { seat, others:[{seat, zhucards}] }
|
||||
* → 弹出本面板。【再点一次取消是纯前端开关】,不再请求服务端。
|
||||
* - 关闭方式: 点遮罩关闭;另可再点底栏按钮切换(与亮牌的「仅自动时长关闭」不同)。
|
||||
* - 【牌用精灵复制】张数不定;编辑器里只建 1 遮罩 + 2 容器 + 1 牌模板 + 2 标签文字。
|
||||
* 关闭 / onDestroy 时必须 removeRange 清理。
|
||||
* - 【为什么与 HistoryView 拆成两个 View】版式相同、渲染逻辑可共用,但两者数据独立、
|
||||
* 图层不同(304 vs 305);本项目约定「一个 View 恰好一个图层」,故各自成 View。
|
||||
* - 【布局节点已补齐 · 2026-08-27】见 Layout_Result.js「明牌面板」一节:
|
||||
* MING_MASK / MING_BOX / MING_FAN / MING_LABEL,与 HIST_* 同构,差别只在【家数】——
|
||||
* bySeat 只有 LEFT / RIGHT 两个变体(MING_BOX_A 落 LEFT 位、MING_BOX_B 落 RIGHT 位),
|
||||
* 没有 SELF 那排。坐标同为目视实测估值,待设计稿复核。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 遮罩: EQW_Images.MASK_DIM (609), 1 帧, 纯色可拉伸(与冲关/出牌历史共用同一资源)
|
||||
* - 牌模板: EQW_Images.CARD_FACE_L (501), 60 帧, 单帧 110×190
|
||||
*
|
||||
* 精灵ID分配: 2000–2005(群组 243)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §1.7 明牌(D-6)/ §2.6 底栏 / §5.7b
|
||||
* @see docs_dev/uiref/冲关牌型显示.png
|
||||
*/
|
||||
//明牌(清单 §5.7b,Layer 305,群组 243,牌用精灵复制)——
|
||||
//只显示另两家,数据 = mingpai.others[].zhucards(§1.7 D-6);
|
||||
//版式与上方 HistoryView(Layer 304)相同(遮罩 + 每家一排牌),差别只在数据源与家数,两个 View 共用同一套渲染逻辑,
|
||||
//但各自数据独立、图层不同,故拆成两个 View(一个 View 恰好一个图层)。
|
||||
EQW_Sprites.MingPaiView = {
|
||||
Layer: EQW_Layers.POPUP_MINGPAI, //305
|
||||
|
||||
MingPai: {
|
||||
id: EQW_Groups.MINGPAI, //243
|
||||
|
||||
MING_MASK: 2000, //图片:明牌遮罩,资源 EQW_Images.MASK_DIM
|
||||
MING_BOX_A: 2001, //容器:另一家A牌容器
|
||||
MING_BOX_B: 2002, //容器:另一家B牌容器
|
||||
MING_CARD_TPL: 2003, //模板:牌模板,资源 EQW_Images.CARD_FACE_L
|
||||
MING_LABEL_A: 2004, //文字:A座位标签
|
||||
MING_LABEL_B: 2005 //文字:B座位标签
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Sprites: 精灵结构(清单 §5.1 牌桌常驻)/////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 子游戏精灵段 1001–2999(段内仅 3000 被平台占用)。
|
||||
// 以 UI View 为组织单位:View 下 Layer 为图层声明(一个 View 恰好一个图层),
|
||||
// 其余键一律是「group 容器」——容器内 id 为群组 ID、其余键才是精灵 id。
|
||||
// 每个精灵注明:类型(图片/文字)+ 用途 + 资源键 + 帧说明——
|
||||
// 后期即照这些注释在编辑器里逐个创建。
|
||||
var EQW_Sprites = EQW_Sprites || {};
|
||||
|
||||
// ============================================================================
|
||||
// 顶部信息条 - Layer 101 (TABLE_STATIC), Group 201 (TOP_INFO)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 顶部信息条 - TopInfoView
|
||||
*
|
||||
* 用途: 屏幕顶部正中常驻的三列小表格,展示本局的「主牌花色 / 庄家叫分 / 闲家抓分」。
|
||||
* 发牌起出现、整局不收起;收到 fapai 时三列清零(主=无、叫分=0、抓分=0)。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 顶部中央;
|
||||
* 位置见 EQW_Layout.TOP_INFO_BG = point(486, 4, 276×68)):
|
||||
*
|
||||
* ┌────────┬───────────────┬───────────────┐ y=4
|
||||
* │ 主 │ 叫分 │ 抓分 │ ← 表头三列:已画在底图上,无独立精灵
|
||||
* ├────────┼───────────────┼───────────────┤
|
||||
* │ [花] │ 60 [1子] │ 45 [1倍] │ ← 数据行(图中数值均为填充假数据)
|
||||
* └────────┴───────────────┴───────────────┘ y=72
|
||||
* ^ ^ ^ ^ ^
|
||||
* │ │ │ │ └ TOP_GRADE_BADGE_BG(1007) + TOP_GRADE_BADGE_TEXT(1008)
|
||||
* │ │ │ └─── TOP_GRADE_TEXT(1006)
|
||||
* │ │ └──────────────── TOP_CALL_BADGE_BG(1004) + TOP_CALL_BADGE_TEXT(1005)
|
||||
* │ └─────────────────── TOP_CALL_TEXT(1003)
|
||||
* └──────────────────────────── TOP_SUIT_ICON(1002)
|
||||
*
|
||||
* TOP_INFO_BG(1001) 铺满整块(含表头文字与竖分隔线),其余 7 个精灵叠在其上。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 「主」列花色图标: 帧 = flower(1方块 2梅花 3红心 4黑桃,服务端编号),选主前隐藏;
|
||||
* 贴信息条内部左下角(EQW_Layout.TOP_SUIT_ICON: attach TOP_INFO_BG left/bottom, 30×30)。
|
||||
* - 「叫分」列: 数值取 shangzhuang.grade,角标「N子」取 multiple(随房间「爬坡」开关变)。
|
||||
* - 「抓分」列: 数值为闲家累计捡分(逐轮 chupai3.grade 累加,结算时以 aset.grade 为准);
|
||||
* 角标取 Math.abs(curmultiple)(T-16/S-8:curmultiple 带符号,角标只显示大小)。
|
||||
* - 两个角标底都贴各自数值文字的 topRight(EQW_Layout.TOP_CALL_BADGE_BG / TOP_GRADE_BADGE_BG),
|
||||
* 角标内的文字精灵复用同一矩形、不单独配布局。
|
||||
* - 【待设计稿】TOP_CALL_TEXT / TOP_GRADE_TEXT 两处数值文字的 x/y/w/h 清单 §6.5 未给,
|
||||
* 布局里标为 runtime 注入,设计稿到位后回填成定点值。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 面板底: EQW_Images.PANEL_TOP_INFO (601), 1 帧, 276×68
|
||||
* - 花色图标: EQW_Images.SUIT_ICON_S (511), 4 帧, 30×30, 帧序按 flower 编号(与牌面资源的
|
||||
* 花色顺序不同,两套互不换算)
|
||||
* - 角标底: EQW_Images.BADGE_MULTIPLE (575), 2 帧, 34×18, 帧1=蓝底(叫分) 帧2=橙底(抓分)
|
||||
*
|
||||
* 精灵ID分配: 1001–1008(群组 201 号段 1001–1029)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.1 顶部信息条 / §5.1 Layer 101 牌桌常驻
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
*/
|
||||
//顶部信息条:主 / 叫分 / 抓分 三列(清单 §5.1 群组 201)
|
||||
EQW_Sprites.TopInfoView = {
|
||||
Layer: EQW_Layers.TABLE_STATIC, //101
|
||||
|
||||
TopInfo: {
|
||||
id: EQW_Groups.TOP_INFO, //201
|
||||
|
||||
TOP_INFO_BG: 1001, //图片:三列表格底,资源 EQW_Images.PANEL_TOP_INFO
|
||||
TOP_SUIT_ICON: 1002, //图片:「主」列花色图标,资源 EQW_Images.SUIT_ICON_S,帧 = flower(1方块 2梅花 3红心 4黑桃,服务端 flower 编号)
|
||||
TOP_CALL_TEXT: 1003, //文字:叫分数值
|
||||
TOP_CALL_BADGE_BG: 1004, //图片:叫分角标底,资源 EQW_Images.BADGE_MULTIPLE 帧1(蓝底)
|
||||
TOP_CALL_BADGE_TEXT: 1005, //文字:「N子」
|
||||
TOP_GRADE_TEXT: 1006, //文字:抓分数值
|
||||
TOP_GRADE_BADGE_BG: 1007, //图片:抓分角标底,资源 EQW_Images.BADGE_MULTIPLE 帧2(橙底)
|
||||
TOP_GRADE_BADGE_TEXT: 1008 //文字:「N倍」,取 Math.abs(curmultiple)(T-16/S-8:curmultiple 带符号,展示取绝对值)
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 玩家位附加标记 - Layer 101 (TABLE_STATIC), Group 202 / 203 / 204
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 三家玩家位附加标记 - PlayerMarkView
|
||||
*
|
||||
* 用途: 在【平台画的头像框】旁叠加子游戏自己的标记——庄印章、余主公示的「主N/对N」角标、
|
||||
* 叫分阶段的状态文字。三个座位各一个群组,结构对称。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/等待叫分.png;三人局座位映射见清单 §0.2:
|
||||
* ind0=南=自己 / ind1=东=右上家 / ind3=西=左上家):
|
||||
*
|
||||
* 全屏 1280×720 的三处落点 ——
|
||||
* ┌──────────────────────────────────────────────────────────────┐ y=0
|
||||
* │ ┌ 顶部信息条 ┐ │
|
||||
* │ [左上家] [右上家] │ y≈122
|
||||
* │ │
|
||||
* │ (桌面牌 / 操作区) │
|
||||
* │ │
|
||||
* │ [自己·底栏] │ y≈648
|
||||
* └──────────────────────────────────────────────────────────────┘ y=720
|
||||
*
|
||||
* 左上家近景(EQW_Layout.PLAYER_AVATAR_FRAME_REF.LEFT = 22,122, 91×118,平台精灵 379):
|
||||
*
|
||||
* ┌─────────┐┌──┐
|
||||
* │ ││庄│ ← P_LEFT_BANKER(1100) attach 头像框 topRight, offset(+7,+8), 40×40
|
||||
* │ 头像 │└──┘
|
||||
* │ (平台) │ 60分 ← P_LEFT_STATUS_TEXT(1105) point(170,165) 130×70
|
||||
* │ │ 12 ← 同一块区域也是倒计时 CD_LEFT 的位置(见 CountdownView)
|
||||
* ├─────────┤
|
||||
* │ 10 │ ← 昵称/分数,平台渲染,子游戏不碰
|
||||
* └─────────┘
|
||||
* ┌────┐┌────┐
|
||||
* │主 1││对 0│ ← P_LEFT_ZHU_BG(1101)+P_LEFT_ZHU_TEXT(1102)
|
||||
* └────┘└────┘ P_LEFT_PAIR_BG(1103)+P_LEFT_PAIR_TEXT(1104)
|
||||
* 主N 贴头像框 bottomLeft(0,+6) 40×19;对N 再贴主N 右侧 +44
|
||||
*
|
||||
* 右上家(PLAYER_AVATAR_FRAME_REF.RIGHT = 1160,122,平台精灵 377)与左上家【左右镜像】:
|
||||
* 庄印章改贴 topLeft(-40,+8),状态文字改到 point(980,165);主N/对N 仍贴 bottomLeft。
|
||||
*
|
||||
* 自己(P_SELF_MARK, 群组 204)只有两个精灵,且落在【底栏】而非头像框旁:
|
||||
* P_SELF_BANKER(1120) = point(338,668) 32×34(EQW_Layout.P_BANKER_MARK.SELF 把 kind
|
||||
* 覆盖成 point,不贴附任何目标,与两个上家的 attach 定位方式不同,非笔误);
|
||||
* 该位置与底栏「踩/有分/没分」按钮【互斥共用】,见 FooterView。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 庄印章: 数据取 shangzhuang.banker / aset.banker,三家三个独立精灵、按 banker 显隐其一。
|
||||
* - 主N / 对N 角标: 数据取 seatlist[seat][4] = [剩余主牌数, 剩余主对数];
|
||||
* 【仅可查牌模式 + 场上已有人报无主】才出现,初始 [-1,-1] 时隐藏;不可查牌模式下永不出现。
|
||||
* 自己没有这组角标——自己的主/对数在手牌区旁的统计条上已经可见(见 ZhuStatView)。
|
||||
* - 状态文字: 叫分阶段显示「60分」(金) / 「不叫」(白),进入选主后清空;
|
||||
* 样式 EQW_Layout.TEXT_STYLE.STATUS_BIG (46px 白字居中)。自己(204)也有一个状态文字精灵
|
||||
* P_SELF_STATUS_TEXT(1121),但清单 §6.6 未给它独立坐标(自己叫的分显示在中央大字区)。
|
||||
* - ⚠ 参考图上三家同时挂着「庄」印章,是叠画示意(清单 §0.5),实际同时只有一家是庄。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 庄印章: EQW_Images.MARK_BANKER (571), 1 帧, 40×40
|
||||
* - 主N/对N 角标底: EQW_Images.BADGE_ZHU_PAIR (574), 2 帧, 44×20, 帧1=黄底(主) 帧2=白底(对)
|
||||
* - 头像框本身由平台提供,子游戏【不建】,仅登记 ID 供 attach 引用——见本文件末尾
|
||||
* EQW_PlatformSprites(376+ind,落在框架保留段 1–1000)。
|
||||
*
|
||||
* 精灵ID分配: 1100–1105(左)/ 1110–1115(右)/ 1120–1121(自己),群组号段 1100–1149
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.2 玩家位附加标记 / §2.5 余主公示 / §6.6 玩家位配置
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
*/
|
||||
//三家玩家位附加标记:庄印章 + 主N/对N 角标 + 状态文字,跨三个群组
|
||||
//(头像框本身是平台提供,登记见本文件末尾 EQW_PlatformSprites;本 View 的精灵全部 attach 到平台头像框上,见清单 §6.6)
|
||||
EQW_Sprites.PlayerMarkView = {
|
||||
Layer: EQW_Layers.TABLE_STATIC, //101
|
||||
|
||||
LeftMark: {
|
||||
id: EQW_Groups.P_LEFT_MARK, //202 左上家
|
||||
|
||||
P_LEFT_BANKER: 1100, //图片:「庄」印章,资源 EQW_Images.MARK_BANKER,attach 头像框 topRight
|
||||
P_LEFT_ZHU_BG: 1101, //图片:「主N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧1(黄底),attach 头像框 bottomLeft
|
||||
P_LEFT_ZHU_TEXT: 1102, //文字:剩余主牌数
|
||||
P_LEFT_PAIR_BG: 1103, //图片:「对N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧2(白底),attach 主N角标右侧
|
||||
P_LEFT_PAIR_TEXT: 1104, //文字:剩余主对数
|
||||
P_LEFT_STATUS_TEXT: 1105 //文字:「60分」/「不叫」
|
||||
},
|
||||
|
||||
RightMark: {
|
||||
id: EQW_Groups.P_RIGHT_MARK, //203 右上家,结构同 202
|
||||
|
||||
P_RIGHT_BANKER: 1110, //图片:「庄」印章,资源 EQW_Images.MARK_BANKER,attach 头像框 topLeft
|
||||
P_RIGHT_ZHU_BG: 1111, //图片:「主N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧1(黄底),attach 头像框 bottomLeft
|
||||
P_RIGHT_ZHU_TEXT: 1112, //文字:剩余主牌数
|
||||
P_RIGHT_PAIR_BG: 1113, //图片:「对N」角标底,资源 EQW_Images.BADGE_ZHU_PAIR 帧2(白底),attach 主N角标右侧
|
||||
P_RIGHT_PAIR_TEXT: 1114, //文字:剩余主对数
|
||||
P_RIGHT_STATUS_TEXT: 1115 //文字:「60分」/「不叫」
|
||||
},
|
||||
|
||||
SelfMark: {
|
||||
id: EQW_Groups.P_SELF_MARK, //204 自己,无主N/对N角标——自己主/对数已在手牌区可见
|
||||
|
||||
P_SELF_BANKER: 1120, //图片:底栏「庄」印章,资源 EQW_Images.MARK_BANKER,point (338,668)
|
||||
P_SELF_STATUS_TEXT: 1121 //文字:「50分」/「不叫」
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 手牌上方的主牌统计条 - Layer 101 (TABLE_STATIC), Group 206 (ZHU_STAT_BAR)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 自己的主牌统计条 - ZhuStatView
|
||||
*
|
||||
* 用途: 手牌区【上方偏左】的一条半透明圆角小条,显示一行统计文案。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 左侧「主牌对子: 1对」那条;
|
||||
* 位置见 EQW_Layout.ZHU_STAT_BG = point(35, 400, 210×35)):
|
||||
*
|
||||
* x=35 x=245
|
||||
* ┌──────────────────────────────┐ y=400
|
||||
* │ 主牌对子: 1对 │ ← ZHU_STAT_TEXT(1031),居中白字
|
||||
* └──────────────────────────────┘ y=435
|
||||
* ↑ ZHU_STAT_BG(1030) 半透圆角条底,210×35
|
||||
* ┌──────────────────────────────────────────────────── … ─┐ y=455
|
||||
* │ 自己手牌区(单排) │
|
||||
* └──────────────────────────────────────────────────── … ─┘ y=645
|
||||
*
|
||||
* 即:紧贴手牌区上沿、左对齐到手牌左边缘附近,整局常驻不遮牌。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 文案为【前端据 flower 本地统计自己手牌】得出,不需要服务端下发,也不受查牌模式与
|
||||
* 报无主影响;有手牌时始终显示。文案格式暂定「x对 x主」(清单 §7.2 T-9 未定案,
|
||||
* 参考图写的是「主牌对子: 1对」)——文案进配置,不在代码里写死字符串。
|
||||
* - 【命名已统一 · 2026-08-27】本 View、EQW_Groups.ZHU_STAT_BAR 与清单 §5.1 原先都叫
|
||||
* 「亮牌条 / LIANGPAI_*」,与 design §8.2 的「亮牌」【同名异实】,极易写错地方,故改名。
|
||||
* 两者的区别(清单 §0.0 专节区分四类「公开信息」):
|
||||
* 本条(群组 206)—— 自己的主牌【数量】统计,本地算、有手牌就常显,不属"公开信息"
|
||||
* 亮牌(群组 242)—— 庄家埋牌后向两个闲家亮出自己全部固定主牌的【具体牌面】,
|
||||
* 走遮罩面板(复用 HistoryView 精灵,见 Sprites_Result.js),
|
||||
* 数据来自 maipai 包的 liangpai.cards
|
||||
*
|
||||
* 资源说明:
|
||||
* - 条底: EQW_Images.BAR_ZHU_STAT (605), 1 帧, 210×35, 半透明圆角
|
||||
*
|
||||
* 精灵ID分配: 1030–1031(群组 206)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.5 自己的主牌统计 + 余主公示 / §5.1 / §0.0
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
*/
|
||||
//自己的主牌统计条(清单 §5.1 群组 206;非 design §8.2 的「亮牌」)
|
||||
EQW_Sprites.ZhuStatView = {
|
||||
Layer: EQW_Layers.TABLE_STATIC, //101
|
||||
|
||||
ZhuStatBar: {
|
||||
id: EQW_Groups.ZHU_STAT_BAR, //206
|
||||
|
||||
ZHU_STAT_BG: 1030, //图片:半透圆角条,资源 EQW_Images.BAR_ZHU_STAT
|
||||
ZHU_STAT_TEXT: 1031 //文字:自己主牌统计文案「x对 x主」
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 底栏 - Layer 101 (TABLE_STATIC), Group 205 (FOOTER)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 底栏 - FooterView
|
||||
*
|
||||
* 用途: 屏幕最下方一条深色横条(整局常驻),承载局数、右侧功能按钮组、
|
||||
* 以及闲家专用的「踩/有分/没分」提示按钮。左侧头像/昵称/分数由平台渲染。
|
||||
*
|
||||
* 布局说明(参考图 docs_dev/uiref/等待叫分.png 底栏 +
|
||||
* docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png;
|
||||
* 条底见 EQW_Layout.FOOTER_BG = point(0, 648, 1280×72)):
|
||||
*
|
||||
* y=648 ┌──────────────────────────────────────────────────────────────────────┐
|
||||
* │ ┌────┐ │
|
||||
* │ │头像│ 玩家昵称 10 [庄] 1/4 局 [明牌][已出牌][上一轮][扣底][底牌] │
|
||||
* │ └────┘ │
|
||||
* y=720 └──────────────────────────────────────────────────────────────────────┘
|
||||
* └── 平台渲染,子游戏不碰 ──┘
|
||||
* (A) (B) (C)
|
||||
*
|
||||
* (A) x≈335–573:庄印章与三个提示按钮【互斥】共用同一块区域(清单 §1.7)——
|
||||
* 自己是庄 → P_SELF_BANKER(1120)(属 PlayerMarkView),point(338,668) 32×34
|
||||
* 自己是闲 → 三个提示按钮,EQW_Layout.FOOTER_TISHI_BUTTONS:
|
||||
* line/horizontal, anchor left, anchorX=335, anchorY=672,
|
||||
* itemWidth=52, itemHeight=30, spacing=26
|
||||
* ┌────┐ ┌────┐ ┌────┐
|
||||
* │ 踩 │ │有分│ │没分│
|
||||
* └────┘ └────┘ └────┘
|
||||
* BTN_TISHI_CAI BTN_TISHI_HAS BTN_TISHI_NONE
|
||||
* (1050) (1052) (1054)
|
||||
* ⚠ 按钮顺序是 踩→有分→没分,与协议 tip 编号(1踩 2没分 3有分)
|
||||
* 【不同序】,映射不要按位置下标推。
|
||||
* (B) FOOTER_ASET_TEXT(1041)「1/4 局」,point(635,672) 65×26,
|
||||
* 样式 EQW_Layout.TEXT_STYLE.ASET_COUNT (20px 白字居中),常驻,取 asetidx/asetcount。
|
||||
* (C) 右侧功能钮组 EQW_Layout.FOOTER_FUNC_BUTTONS:line/horizontal,右对齐到 x=1250,
|
||||
* anchorY=672,itemHeight=30,spacing=15,【逐项不等宽】(T-25):
|
||||
* BTN_MINGPAI(1042) 74 / BTN_HISTORY(1044) 87 / BTN_LAST_ROUND(1046) 93 /
|
||||
* BTN_BURY_CARDS(1056) 78【估值】/ BTN_BOTTOM_CARDS(1048) 78
|
||||
* 五个按钮各按条件显隐、不全常驻(清单 §2.6);参考图里同时画出属叠画示意。
|
||||
*
|
||||
* 元素说明:
|
||||
* - 每个按钮 = 一个图片精灵(按钮底)+ 一个文字精灵(按钮文案),成对出现。
|
||||
* - 显隐条件(服务端权威字段驱动,前端不自行推导):
|
||||
* 明牌 —— 可查牌 + 出牌阶段 + 场上已有人报无主(baozhu==1),三家都显示
|
||||
* 已出牌 —— 可查牌 + 出牌阶段
|
||||
* 上一轮 —— 可查牌 + 出牌阶段 + 已打过至少一轮
|
||||
* 扣底 —— 仅庄家 + 已埋牌之后
|
||||
* 底牌 —— 庄家全程;闲家仅开底过的局(70 分坐庄),见 S-3
|
||||
* 踩/有分/没分 —— 自己是闲家 + 出牌阶段
|
||||
* - 【扣底 ≠ 底牌,别写反】(清单 §0.0 / §2.6,验收清单第 13 条):
|
||||
* 扣底 → 看【埋牌底牌】:庄家埋牌时从手里扣下的 8 张,字段 burycards(结算包为 bottom.cards)
|
||||
* 底牌 → 看【底牌】 :发牌时没发给玩家、留在桌面的 8 张,字段 bottomcards
|
||||
* 两批牌完全不同;内部变量建议按 buryCards / bottomCards 与字段名一一对应。
|
||||
*
|
||||
* 资源说明:
|
||||
* - 底栏条: EQW_Images.BAR_FOOTER (608), 1 帧, 1280×72
|
||||
* - 功能钮底: EQW_Images.BTN_BOTTOM_FUNC (538), 1 帧, 88×30, 深色圆角(5 个按钮共用)
|
||||
* - 提示钮底: EQW_Images.BTN_TISHI (540), 1 帧, 52×30, 同风格但更窄(3 个按钮共用)
|
||||
*
|
||||
* 精灵ID分配: 1040–1057(群组 205 号段 1040–1069)
|
||||
*
|
||||
* @see docs_dev/二七王-UI资源与精灵清单.md §2.6 底栏 / §1.7 闲家提示 / §5.1 / §6.5
|
||||
* @see docs_dev/uiref/等待叫分.png
|
||||
* @see docs_dev/uiref/自己是闲家时,底下的踩有分没分按钮.png
|
||||
*/
|
||||
//底栏:局数 + 功能按钮(明牌/已出牌/上一轮/扣底/底牌)+ 提示按钮(清单 §5.1 群组 205)
|
||||
EQW_Sprites.FooterView = {
|
||||
Layer: EQW_Layers.TABLE_STATIC, //101
|
||||
|
||||
Footer: {
|
||||
id: EQW_Groups.FOOTER, //205
|
||||
|
||||
FOOTER_BG: 1040, //图片:底栏深色条,资源 EQW_Images.BAR_FOOTER
|
||||
FOOTER_ASET_TEXT: 1041, //文字:「1/4 局」
|
||||
BTN_MINGPAI: 1042, //图片:「明牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
|
||||
BTN_MINGPAI_TEXT: 1043, //文字:按钮文案「明牌」
|
||||
BTN_HISTORY: 1044, //图片:「已出牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
|
||||
BTN_HISTORY_TEXT: 1045, //文字:按钮文案「已出牌」
|
||||
BTN_LAST_ROUND: 1046, //图片:「上一轮」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
|
||||
BTN_LAST_ROUND_TEXT: 1047, //文字:按钮文案「上一轮」
|
||||
BTN_BOTTOM_CARDS: 1048, //图片:「底牌」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
|
||||
BTN_BOTTOM_CARDS_TEXT: 1049, //文字:按钮文案「底牌」
|
||||
BTN_TISHI_CAI: 1050, //图片:「踩」按钮(闲家,占庄标区),资源 EQW_Images.BTN_TISHI
|
||||
BTN_TISHI_CAI_TEXT: 1051, //文字:「踩」
|
||||
BTN_TISHI_HAS: 1052, //图片:「有分」按钮,资源 EQW_Images.BTN_TISHI
|
||||
BTN_TISHI_HAS_TEXT: 1053, //文字:「有分」
|
||||
BTN_TISHI_NONE: 1054, //图片:「没分」按钮,资源 EQW_Images.BTN_TISHI
|
||||
BTN_TISHI_NONE_TEXT: 1055, //文字:「没分」
|
||||
|
||||
//——「扣底」按钮(2026-08-27 补齐):看【埋牌底牌】= 庄家埋下的 8 张(字段 burycards),
|
||||
// 与上面的「底牌」按钮(看发牌留桌的 8 张,字段 bottomcards)是两批不同的牌,勿写反 ——
|
||||
BTN_BURY_CARDS: 1056, //图片:「扣底」按钮,资源 EQW_Images.BTN_BOTTOM_FUNC
|
||||
BTN_BURY_CARDS_TEXT: 1057 //文字:按钮文案「扣底」
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_PlatformSprites: 平台提供精灵登记表 //////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
//平台提供的精灵,子游戏【不建】,仅登记 ID 供布局 attach 引用。
|
||||
//落在框架保留段 1–1000,故独立于 EQW_Sprites、不参与子游戏号段校验。
|
||||
//三人局座位映射(清单 §0.2):ind0=南=自己 ind1=东=右上家 ind3=西=左上家;
|
||||
//头像框基址 376+ind(清单 §6.6)。
|
||||
var EQW_PlatformSprites = EQW_PlatformSprites || {
|
||||
P_SELF_AVATAR: 376, //平台头像框 376+ind0,自己(南)
|
||||
P_RIGHT_AVATAR: 377, //平台头像框 376+ind1,右上家(东)
|
||||
P_LEFT_AVATAR: 379 //平台头像框 376+ind3,左上家(西)
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_CardMark: 手牌牌面标记与花色统计(纯逻辑)///////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 清单 §1.6 / T-8:牌面三种标记,前端据主花色本地推导,服务端不下发。
|
||||
// 'tractor' 红色圆标「拖」= 该牌属于一组拖拉机
|
||||
// 'zheng' 橙色五角星 = 正2 / 正7(选定花色的 2 和 7)
|
||||
// 'zhu' 蓝色五角星 = 其余主牌(双王、副2、副7、主花色普通牌)
|
||||
// 三者【互斥】、每张牌至多一个,优先级 tractor > zheng > zhu。
|
||||
//
|
||||
// 牌编码区间边界一律取自 shared/cards.js 导出的具名常量(CODE_ZHU_MIN / CODE_ZHENG2_* /
|
||||
// CODE_ZHENG7_* …)——那里是 id_to_code 的权威说明处,本文件不重抄 1000/3000/8000 之类裸值。
|
||||
// ⇒ 主牌 ⟺ code > CODE_ZHU_MIN
|
||||
//
|
||||
// 拖拉机只在【同一牌组】内成立:全部主牌算一组(副2/副7/王与主花色牌可相连),
|
||||
// 副牌按各自花色分组。极大连续段的扫描与「几对起算拖拉机」是前后端共享算法
|
||||
// (shared/cards.js 的 group_tractor_runs / TRACTOR_MIN_PAIRS,服务端 decompose_trump 同源),
|
||||
// 本文件只负责按段落标记,不再自己实现一遍扫描。
|
||||
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 > S.CODE_ZHU_MIN) ? '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 runs = S.group_tractor_runs(mf, pairs);
|
||||
for (var j = 0; j < runs.length; j++) {
|
||||
var run = runs[j];
|
||||
if (run.length < S.TRACTOR_MIN_PAIRS) { continue; } //孤立对子(长度1的段)不是拖拉机
|
||||
for (var r = 0; r < run.length; r++) {
|
||||
marks[run[r][0]] = this.MARK_TRACTOR;
|
||||
marks[run[r][1]] = this.MARK_TRACTOR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//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 > S.CODE_ZHENG2_MIN && code < S.CODE_ZHENG2_MAX) ||
|
||||
(code > S.CODE_ZHENG7_MIN && code < S.CODE_ZHENG7_MAX)) {
|
||||
marks[cards[i]] = this.MARK_ZHENG; //正2 / 正7
|
||||
} else if (code > S.CODE_ZHU_MIN) {
|
||||
marks[cards[i]] = this.MARK_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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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',
|
||||
|
||||
//服务端座位 → 显示位键。
|
||||
//入参必须是 0/1/2:非法值(undefined / 3 / 小数)会算出 NaN,NaN 既不等于 0 也不等于 1,
|
||||
//最后落到 return 'LEFT'——一个看起来很合理的错误答案。故与 toSeat 一样显式抛错
|
||||
toDisplay: function (seat, mySeat) {
|
||||
this._requireSeat(seat, 'seat');
|
||||
this._requireSeat(mySeat, 'mySeat');
|
||||
var d = ((seat - mySeat) % 3 + 3) % 3;
|
||||
if (d === 0) { return 'SELF'; }
|
||||
if (d === 1) { return 'RIGHT'; }
|
||||
return 'LEFT';
|
||||
},
|
||||
|
||||
_requireSeat: function (v, name) {
|
||||
if (v !== 0 && v !== 1 && v !== 2) {
|
||||
throw new Error('[EQW_SeatMap] ' + name + ' 必须是 0/1/2,收到: ' + v);
|
||||
}
|
||||
},
|
||||
|
||||
//显示位键 → 服务端座位。mySeat 同样必须是 0/1/2(否则算出 NaN 座位号)
|
||||
toSeat: function (displayKey, mySeat) {
|
||||
this._requireSeat(mySeat, '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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Dispatcher: 唯一收包入口 + 纯路由表 ////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §2:收包统一分发,一 rpc 一处理器。
|
||||
// 【本文件只分发,不写业务】——新增一种服务端推送 = 注册一条 rpc → handler。
|
||||
//
|
||||
// 判定顺序(关键):先看 data.success,为 false 一律交 FailHandler、不进业务 handler。
|
||||
// 因为协议 §0.2 规定失败回包的 rpc 与【请求】同名(chupai 失败回 chupai,
|
||||
// 而成功走 chupai1/2/3),不先分流的话每个业务 handler 都得自己判一遍。
|
||||
var EQW_Dispatcher = EQW_Dispatcher || {
|
||||
|
||||
_handlers: {},
|
||||
|
||||
//注册一条 rpc → handler
|
||||
register: function (rpc, handler) {
|
||||
if (typeof rpc !== 'string' || typeof handler !== 'function') {
|
||||
throw new Error('[EQW_Dispatcher] register 参数非法: ' + rpc);
|
||||
}
|
||||
if (this._handlers.hasOwnProperty(rpc)) {
|
||||
throw new Error('[EQW_Dispatcher] rpc 重复注册: ' + rpc);
|
||||
}
|
||||
this._handlers[rpc] = handler;
|
||||
},
|
||||
|
||||
hasHandler: function (rpc) {
|
||||
return this._handlers.hasOwnProperty(rpc);
|
||||
},
|
||||
|
||||
//唯一收包入口。msg = { rpc, data }
|
||||
dispatch: function (msg) {
|
||||
if (!msg || typeof msg.rpc !== 'string') {
|
||||
console.warn('[EQW_Dispatcher] 畸形数据包,已忽略');
|
||||
return;
|
||||
}
|
||||
var data = msg.data || {};
|
||||
|
||||
//成败只认 data.success(前端 04 §4):失败包一律先分流
|
||||
if (data.success === false) {
|
||||
EQW_FailHandler.handle(msg.rpc, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var h = this._handlers[msg.rpc];
|
||||
if (h) {
|
||||
h(data);
|
||||
} else {
|
||||
//平台日后新增推送不该让前端崩
|
||||
console.warn('[EQW_Dispatcher] 未处理的 rpc: ' + msg.rpc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//—— 路由表:一 rpc 一处理器(协议里每个服务端推送都要在这里注册)——
|
||||
//新增一种推送 = 加一条注册 + 写对应 handler;本文件不写业务。
|
||||
EQW_Dispatcher.register('fapai', function (d) { EQW_DealHandler.handle(d); });
|
||||
EQW_Dispatcher.register('jiaofen', function (d) { EQW_CallHandler.handleJiaofen(d); });
|
||||
EQW_Dispatcher.register('shangzhuang', function (d) { EQW_CallHandler.handleShangzhuang(d); });
|
||||
EQW_Dispatcher.register('xuanzhu', function (d) { EQW_MainHandler.handleXuanzhu(d); });
|
||||
EQW_Dispatcher.register('maipai', function (d) { EQW_BuryHandler.handle(d); });
|
||||
EQW_Dispatcher.register('chupai1', function (d) { EQW_PlayHandler.handle(d, 1); });
|
||||
EQW_Dispatcher.register('chupai2', function (d) { EQW_PlayHandler.handle(d, 2); });
|
||||
EQW_Dispatcher.register('chupai3', function (d) { EQW_PlayHandler.handle(d, 3); });
|
||||
EQW_Dispatcher.register('mingpai', function (d) { EQW_QueryHandler.handleMingpai(d); });
|
||||
EQW_Dispatcher.register('tishi', function (d) { EQW_QueryHandler.handleTishi(d); });
|
||||
EQW_Dispatcher.register('jiesuan', function (d) { EQW_ResultHandler.handleJiesuan(d); });
|
||||
EQW_Dispatcher.register('zhunbei', function (d) { EQW_ReadyHandler.handle(d); });
|
||||
@@ -0,0 +1,123 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Rpc: 语义化发包(一操作一方法)/////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §1:业务不直接拼包,走两层封装——
|
||||
// 业务 → EQW_Rpc.xxx(意图) → RpcHelper(注入平台字段)→ 引擎发送
|
||||
//
|
||||
// 【route 必须是 erqiwang】RpcHelper.sendGameRpc 预设 route="room"(平台房间模块),
|
||||
// 用它包会被路由到平台、永远到不了子游戏 mod.js,而且不会有任何报错。
|
||||
//
|
||||
// 【请求包只带意图,不带结论】(前端 04 §1 / 服务端 04 §8)
|
||||
// 可以带:操作类型、目标标识(牌 id / 花色 / 提示类型)、座位号(仅供一致性校验)。
|
||||
// 禁止带:分数、番数、判定结果、阶段推进指令、nextSeat、handCards。
|
||||
// 前端 shared/ 的计算只用于本地提示与预校验,【结果不回传】。
|
||||
// 一句话:前端能自己推出来的东西,就是玩家能改的东西——这是防作弊的根本。
|
||||
//
|
||||
// 本文件只做【形状】校验(牌 id 范围/去重/张数),不做【规则】校验
|
||||
// (这手牌能不能出、叫分够不够低由服务端裁定)。形状不合法就 fail-fast 抛错,
|
||||
// 不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
|
||||
var EQW_Rpc = EQW_Rpc || {
|
||||
|
||||
APP: 'youle',
|
||||
ROUTE: 'erqiwang',
|
||||
|
||||
//—— 内部:取自己的座位(fail-fast)——
|
||||
//room.mySeat 由 SubGameHooks._syncMySeat 从平台 C_Player.seat 同步,初始值是 -1。
|
||||
//【为什么不能透传】seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||||
//叫分/选主/埋牌/出牌全部发不出去,而前端一声不吭。无声透传正是这类缺陷能长期潜伏的原因,
|
||||
//故与本文件其余形状校验一样在最近处显式失败。
|
||||
_seat: function () {
|
||||
var seat = EQW_GameState.room.mySeat;
|
||||
if (seat !== 0 && seat !== 1 && seat !== 2) {
|
||||
throw new Error('[EQW_Rpc] 座位未同步: ' + seat +
|
||||
'(须为 0/1/2;检查 SubGameHooks._syncMySeat 是否已由平台入口调用)');
|
||||
}
|
||||
return seat;
|
||||
},
|
||||
|
||||
//—— 内部:发送 ——
|
||||
_send: function (rpc, data) {
|
||||
var pack = { seat: this._seat() };
|
||||
var key;
|
||||
for (key in data) {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
pack[key] = data[key];
|
||||
}
|
||||
}
|
||||
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, pack);
|
||||
},
|
||||
|
||||
//—— 内部:牌 id 列表形状校验(协议 §0.2)——
|
||||
//必须是非空数组,元素是 0–107 的整数,互不重复
|
||||
_checkCards: function (cards, expectCount) {
|
||||
if (Object.prototype.toString.call(cards) !== '[object Array]' || cards.length === 0) {
|
||||
throw new Error('[EQW_Rpc] cards 必须是非空数组');
|
||||
}
|
||||
if (typeof expectCount === 'number' && cards.length !== expectCount) {
|
||||
throw new Error('[EQW_Rpc] cards 张数应为 ' + expectCount + ',实际 ' + cards.length);
|
||||
}
|
||||
var seen = {};
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var c = cards[i];
|
||||
if (typeof c !== 'number' || c !== Math.floor(c) || c < 0 || c > 107) {
|
||||
throw new Error('[EQW_Rpc] 非法牌 id: ' + c + '(须为 0–107 的整数)');
|
||||
}
|
||||
if (seen[c]) { throw new Error('[EQW_Rpc] 牌 id 重复: ' + c); }
|
||||
seen[c] = true;
|
||||
}
|
||||
},
|
||||
|
||||
//—— 叫分(call: 5–70 的 5 的倍数;0 = 不叫)——
|
||||
jiaofen: function (call) {
|
||||
if (typeof call !== 'number' || call !== Math.floor(call)) {
|
||||
throw new Error('[EQW_Rpc] call 必须是整数');
|
||||
}
|
||||
if (call !== 0 && (call < 5 || call > 70 || call % 5 !== 0)) {
|
||||
throw new Error('[EQW_Rpc] 非法叫分: ' + call + '(须为 0 或 5–70 的 5 的倍数)');
|
||||
}
|
||||
this._send('jiaofen', { call: call });
|
||||
},
|
||||
|
||||
//—— 投降(仅 70 分坐庄、选主阶段;合法性由服务端裁定)——
|
||||
touxiang: function () {
|
||||
this._send('touxiang', {});
|
||||
},
|
||||
|
||||
//—— 选主(flower: 1方块 2梅花 3红心 4黑桃)——
|
||||
xuanzhu: function (flower) {
|
||||
if (typeof flower !== 'number' || flower < 1 || flower > 4) {
|
||||
throw new Error('[EQW_Rpc] 非法花色: ' + flower + '(须为 1–4)');
|
||||
}
|
||||
this._send('xuanzhu', { flower: flower });
|
||||
},
|
||||
|
||||
//—— 埋牌(恒 8 张)——
|
||||
maipai: function (cards) {
|
||||
this._checkCards(cards, 8);
|
||||
this._send('maipai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 出牌 ——
|
||||
chupai: function (cards) {
|
||||
this._checkCards(cards);
|
||||
this._send('chupai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 明牌(查看他家未出主牌)——
|
||||
mingpai: function () {
|
||||
this._send('mingpai', {});
|
||||
},
|
||||
|
||||
//—— 出牌提示(tip: 1踩 2没分 3有分)——
|
||||
tishi: function (tip) {
|
||||
if (tip !== 1 && tip !== 2 && tip !== 3) {
|
||||
throw new Error('[EQW_Rpc] 非法提示类型: ' + tip + '(须为 1踩 2没分 3有分)');
|
||||
}
|
||||
this._send('tishi', { tip: tip });
|
||||
},
|
||||
|
||||
//—— 准备 ——
|
||||
zhunbei: function () {
|
||||
this._send('zhunbei', {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_BuryHandler: 埋牌(maipai)////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
|
||||
// step 阶段(=5 出牌) / seat 控制权=首出者(必为庄家) / countdown 出牌倒计时 /
|
||||
// seatlist 三家牌况(仅可查牌模式) /
|
||||
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
|
||||
//
|
||||
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
|
||||
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
|
||||
//
|
||||
// 【seatlist 此刻是全初值,但必须接】:埋牌完成时这张表刚初始化
|
||||
// (每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),界面上看不出差别——但「埋牌完成 → 庄家首出」
|
||||
// 这段窗口内重连拿到的 PushCards.seatlist 就是它,漏接会让增量路径此刻为空、重连路径却有表。
|
||||
//
|
||||
// 【bottomcards 到此失效,要清空】:底牌在上庄时已翻给玩家看并并入手牌,埋牌完成后
|
||||
// 只剩「埋牌底牌 burycards」有意义;deskinfo 的 PushCards 不带 bottomcards,重连重建恒为 []。
|
||||
//
|
||||
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:埋牌完成时服务端已就地开好 round-1
|
||||
// 的进行态(庄家首出、桌面全空),恒有、三家同值,与 chupai1/2/3.playproc / 重连包
|
||||
// PushCards.playproc 同源同结构(服务端同一个 get_playproc())。没有它,「埋牌完成 →
|
||||
// 庄家首出」这段窗口增量路径 table.playproc 会停在上一阶段的 null,与重连路径不一致。
|
||||
var EQW_BuryHandler = EQW_BuryHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_BuryHandler] maipai 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//庄家才有的两项,闲家缺字段时保持原值、不抹手牌
|
||||
EQW_GameState._apply(EQW_GameState.my, data, {
|
||||
cards: 'cards',
|
||||
buryCards: 'burycards'
|
||||
});
|
||||
//底牌已并入手牌、埋牌完成即失效(重连的 PushCards 不带它),显式清空保持两条路径一致
|
||||
EQW_GameState.my.bottomCards = [];
|
||||
//三家牌况(仅可查牌模式)、闲家才可能有的亮牌、本轮进行态(恒有,见上方注释)
|
||||
EQW_GameState._apply(EQW_GameState.table, data, {
|
||||
seatlist: 'seatlist',
|
||||
liangpai: 'liangpai',
|
||||
playproc: 'playproc'
|
||||
});
|
||||
//阶段只读包字段(服务端权威),不按 rpc 名硬编码 step=5
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
|
||||
//控制权:本包的 seat 就是「轮到谁」(即将首出的庄家,服务端取自 playproc.currseat)
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_BURY_DONE);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_CallHandler: 叫分(jiaofen)与上庄(shangzhuang)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §3 jiaofen:seat 叫分者 / call 分数(0=不叫) / currcall 当前叫到的分 /
|
||||
// multiple 该叫分对应基础子数 / step 阶段(仍=1) /
|
||||
// nextseat 下一个叫分者(控制权) / countdown
|
||||
// 协议 §4 shangzhuang:seat / call / banker / grade 庄家叫分 / multiple /
|
||||
// step 阶段(=2 选主/投降) / nextseat 控制权(=庄家) /
|
||||
// bottomcards 底牌(庄家恒有,闲家仅 70 分时有) / ancard3s 开底标志 /
|
||||
// cards 摸底后的 36 张(仅庄家) / countdown 选主倒计时 /
|
||||
// touxiang 是否允许投降 / curmultiple 当前抓分倍数(带符号)
|
||||
//
|
||||
// 【shangzhuang 的 seat 与 nextseat 别混用】seat 是【最后一个叫分者】,nextseat 才是
|
||||
// 「轮到谁」(选主/投降只由庄家做,服务端 mod.xuanzhu/mod.touxiang 以 banker 为准)。
|
||||
// 曾经这里写 turn.seat = aset.banker 自己推——那是把「选主 = 庄家」这条规则搬到了前端;
|
||||
// 同一条规则在 ResyncHandler 里还有一份,同一份数据两个写入处(SSOT)。现一律读包字段。
|
||||
var EQW_CallHandler = EQW_CallHandler || {
|
||||
|
||||
handleJiaofen: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_CallHandler] jiaofen 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//记录这一家叫了多少:0 = 不叫,与「还没叫」(null) 区分
|
||||
if (data.hasOwnProperty('call') && data.seat >= 0 && data.seat < 3) {
|
||||
EQW_GameState.call.calls[data.seat] = data.call;
|
||||
}
|
||||
EQW_GameState._apply(EQW_GameState.call, data, { currcall: 'currcall' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
multiple: 'multiple',
|
||||
step: 'step'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_CALL_CHANGED);
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
},
|
||||
|
||||
handleShangzhuang: function (data) {
|
||||
if (!data.hasOwnProperty('banker')) {
|
||||
console.error('[EQW_CallHandler] shangzhuang 缺 banker,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
banker: 'banker',
|
||||
call: 'grade', //协议里庄家叫分叫 grade
|
||||
multiple: 'multiple',
|
||||
touxiang: 'touxiang',
|
||||
curmultiple:'curmultiple',
|
||||
step: 'step' //阶段服务端权威给出,不按 rpc 名硬编码
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { ancard3s: 'ancard3s' });
|
||||
//底牌与摸底后的手牌只有庄家(及 70 分时的闲家)才有,缺就不写、不抹已有值
|
||||
EQW_GameState._apply(EQW_GameState.my, data, {
|
||||
bottomCards: 'bottomcards',
|
||||
cards: 'cards'
|
||||
});
|
||||
//控制权:读包里的 nextseat(服务端权威),不用本地的 banker 反推
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
//叫分过程量(currcall/calls)step2 起无语义:deskinfo 从 ChooseMain 起就不再带 CallRun,
|
||||
//这里清成 reset() 的默认值,避免前端留着一份 deskinfo 不会再给的数据(视图=f(快照))
|
||||
EQW_GameState.call.currcall = 0;
|
||||
EQW_GameState.call.calls = [null, null, null];
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_BANKER_SET);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_DealHandler: 发牌(fapai)//////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §1:asetidx 当前局数 / asetcount 总局数 / cards 自己的 28 张 /
|
||||
// step 阶段(=1 叫分) / seat 当前等待叫分者 / countdown 叫分倒计时
|
||||
//
|
||||
// 【step 只读包字段,不按 rpc 硬编码】阶段与控制权由服务端唯一维护、逐包显式下发
|
||||
//(前端红线「数据驱动、前端无对局状态机」/ 服务端红线「任何状态变更都要有包承载」)。
|
||||
// 曾经这里写死 aset.step = 1:服务端一旦插入中间阶段,前端不会红、只会静默画错。
|
||||
//
|
||||
// fapai 是一局的起点,先 reset 清掉上一局的残留(reset 保留 mySeat 与 options)。
|
||||
var EQW_DealHandler = EQW_DealHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('cards')) {
|
||||
console.error('[EQW_DealHandler] fapai 缺 cards,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState.reset();
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.room, data, {
|
||||
asetIdx: 'asetidx',
|
||||
asetCount: 'asetcount'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, { step: 'step' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_RESET);
|
||||
EventBus.emit(EQW_Events.EQW_HAND_CHANGED);
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_FailHandler: 失败回包 //////////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §0.2:服务端受理请求时任一校验不通过,回一个【rpc 与请求同名】的失败包,
|
||||
// data 只含 success:false 与 errcode,且只回给请求者。
|
||||
//
|
||||
// errcode(服务端 youle_erqiwang.ERR):
|
||||
// 1 PLAYER 玩家/房间/座位校验不通过 2 NODESK 牌桌或牌局不存在
|
||||
// 3 STEP 当前阶段不允许该操作 4 SEAT 位置不符或还没轮到
|
||||
// 5 PARAM 参数非法 6 RULE 规则不允许
|
||||
//
|
||||
// 失败信息是【一次性】的,不属于对局状态,故不进 GameState,只随事件带出去。
|
||||
var EQW_FailHandler = EQW_FailHandler || {
|
||||
|
||||
ERR: {
|
||||
PLAYER: 1, NODESK: 2, STEP: 3, SEAT: 4, PARAM: 5, RULE: 6
|
||||
},
|
||||
|
||||
handle: function (rpc, data) {
|
||||
var errcode = (data && typeof data.errcode === 'number') ? data.errcode : 0;
|
||||
console.warn('[EQW] 请求失败: rpc=' + rpc + ' errcode=' + errcode);
|
||||
EventBus.emit(EQW_Events.EQW_RPC_FAILED, { rpc: rpc, errcode: errcode });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_MainHandler: 选主(xuanzhu)///////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §7:banker 庄家 / flower 主牌花色(1方块 2梅花 3红心 4黑桃) /
|
||||
// step 阶段(=3 埋牌) / nextseat 控制权(=庄家,埋牌由庄家做) /
|
||||
// countdown 埋牌倒计时 / cards 选主后自己手上的牌
|
||||
//
|
||||
// 【step 与 nextseat 只读包字段】曾经这里写死 step=3 且 turn.seat = aset.banker:
|
||||
// 阶段与「埋牌 = 庄家」两条规则都被搬到了前端(前端红线「数据驱动、前端无对局状态机」)。
|
||||
//
|
||||
// 选主后正 2 / 正 7 升格、主花色普通牌并入主牌段(design §3),手牌需整体重排——
|
||||
// 但【重排是渲染时的事】,由 core/CardOrder 现算,不在这里落地成状态。
|
||||
//
|
||||
// 投降(touxiang)没有成功推送:点投降后服务端直接结算,前端收到的是 jiesuan。
|
||||
// 失败时才回一个同名的 touxiang 失败包,由 FailHandler 统一处理。
|
||||
var EQW_MainHandler = EQW_MainHandler || {
|
||||
|
||||
handleXuanzhu: function (data) {
|
||||
if (!data.hasOwnProperty('flower')) {
|
||||
console.error('[EQW_MainHandler] xuanzhu 缺 flower,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
banker: 'banker',
|
||||
flower: 'flower',
|
||||
step: 'step'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_MAIN_SET);
|
||||
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_PlayHandler: 出牌(chupai1 / 2 / 3)///////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §11–§13。三个包字段不完全相同:
|
||||
// chupai1 首家独有:shuaicuo 甩错标志 / count 出牌数量 / flower 出牌花色 /
|
||||
// cardtype 牌型 / shuai 甩牌分量构成
|
||||
// chupai3 末家独有:maxseat 本轮谁最大 / grade 本轮闲家得分
|
||||
// 共有:seat / cards / seatlist(仅可查牌) / countdown / baozhu / nextseat /
|
||||
// cardsinhand(仅出牌者自己有) / curmultiple(1 和 3 有)
|
||||
// mustcard:三个包都可能带,但只发给 nextseat 那一家(其余两家无此属性)
|
||||
//
|
||||
// 【cardsinhand 只有出牌者自己有】:收到就更新自己的手牌;别人出牌时没这个字段,
|
||||
// 绝不能抹掉自己的手牌——这正是「缺字段不兜底」要防的。
|
||||
//
|
||||
// 【mustcard 没有就要清空】:它是「本轮跟牌的必出牌」,只对当前这一轮有效。
|
||||
// 上一轮的建议残留下来会让界面自动选中错误的牌。
|
||||
//
|
||||
// 【playproc 是服务端的「本轮进行态」,原样拷贝】:它的结构是
|
||||
// {round, start, currseat, startcount, startflower, starttype, maxseat, maxcard, cards, shuai_demand}
|
||||
// (协议 §11),与重连包 PushCards.playproc 同源同结构(服务端同一个 get_playproc())。
|
||||
// 注意它的 cards 是【本轮三家各自出的牌、下标 = 座位】,与本包的 cards(这一手出的牌)
|
||||
// 语义相反,绝不能混写。轮次/最大者由服务端给,前端不累积、不推导。
|
||||
//
|
||||
// 【这一手的信息走事件载荷,不进 GameState】:谁出了什么、几张、什么牌型是【一次性表现】
|
||||
// (落牌动画),不是对局状态;deskinfo 也不下发它。放进 GameState 会立刻制造一处
|
||||
// 增量/全量不一致。故随 EQW_CARD_PLAYED 事件下发给 UI。
|
||||
// 【本墩结果同理】:chupai3 的 maxseat / grade(谁最大、这墩闲家得几分)也是一次性表现,
|
||||
// 随 EQW_TRICK_END 事件载荷下发,同样不进 GameState。
|
||||
var EQW_PlayHandler = EQW_PlayHandler || {
|
||||
|
||||
//order: 1/2/3,对应 chupai1/2/3
|
||||
handle: function (data, order) {
|
||||
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('cards')) {
|
||||
console.error('[EQW_PlayHandler] chupai' + order + ' 缺 seat 或 cards,已跳过该包');
|
||||
return;
|
||||
}
|
||||
|
||||
//—— 本轮进行态:服务端权威快照,原样拷贝,前端零推导 ——
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { playproc: 'playproc' });
|
||||
|
||||
//—— 出牌历史:把这一手记进本轮(chupai1 = 一轮的第一手,开新的一轮)——
|
||||
EQW_GameState.pushPlay(data.seat, data.cards, order === 1);
|
||||
|
||||
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
|
||||
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
|
||||
|
||||
//—— 必出牌:本轮有效,没给就清空 ——
|
||||
if (data.hasOwnProperty('mustcard')) {
|
||||
EQW_GameState.my.mustCard = data.mustcard;
|
||||
} else {
|
||||
EQW_GameState.my.mustCard = [];
|
||||
}
|
||||
|
||||
//—— 牌况与报无主(仅可查牌模式下发)——
|
||||
EQW_GameState._apply(EQW_GameState.table, data, { seatlist: 'seatlist' });
|
||||
EQW_GameState._apply(EQW_GameState.aset, data, {
|
||||
baozhu: 'baozhu',
|
||||
curmultiple: 'curmultiple'
|
||||
});
|
||||
|
||||
//—— 控制权与倒计时 ——
|
||||
EQW_GameState._apply(EQW_GameState.turn, data, {
|
||||
seat: 'nextseat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, this._playPayload(data, order));
|
||||
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
|
||||
|
||||
//—— 末家出完:本轮结束 ——
|
||||
//【本墩结果走事件载荷,不进 GameState】谁最大、这墩闲家得了几分,是【一次性表现】
|
||||
//(收牌动画、得分飘字),与 EQW_CARD_PLAYED 同类;任何阶段的 deskinfo 都不下发它,
|
||||
//写进 GameState 就多出一处「只活在前端、服务端不知道」的对局态——出牌中途断线重连
|
||||
//即归零,违反前端红线「丢弃 this.data、仅凭最近一次服务端快照重画,界面必须一致」。
|
||||
//小局累计捡分 aset.grade 不同:它有服务端来源(PushCards.grade / chupai3.grade),
|
||||
//属于镜像量,仍留在 GameState。
|
||||
if (order === 3) {
|
||||
//本轮闲家得分累加进小局捡分;没得分时不带 grade,累计值保持不变
|
||||
if (data.hasOwnProperty('grade')) {
|
||||
EQW_GameState.aset.grade = EQW_GameState.aset.grade + data.grade;
|
||||
}
|
||||
EventBus.emit(EQW_Events.EQW_TRICK_END, this._trickPayload(data));
|
||||
}
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
|
||||
},
|
||||
|
||||
//EQW_CARD_PLAYED 的事件载荷:这一手的一次性信息(落牌动画用),不落 GameState。
|
||||
//【缺字段不兜底】:包里没有的键就不带(count/flower/cardtype 只有 chupai1 有,
|
||||
//shuai 仅合法甩牌时有,shuaicuo 仅甩错时有),订阅方按 hasOwnProperty 判断。
|
||||
_playPayload: function (data, order) {
|
||||
var payload = { order: order, seat: data.seat, cards: data.cards };
|
||||
var keys = ['count', 'flower', 'cardtype', 'shuai', 'shuaicuo'];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
if (data.hasOwnProperty(keys[i])) { payload[keys[i]] = data[keys[i]]; }
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
//EQW_TRICK_END 的事件载荷:这一墩的一次性结果(收牌动画、得分飘字用),不落 GameState。
|
||||
//【缺字段不兜底】grade 只在这墩被闲家收走且确实有分时才有,包里没有就不带这个键。
|
||||
//与 ResultHandler 为收尾墩发的那份载荷字段一致(收尾墩不发 chupai3,整包换成 jiesuan)。
|
||||
_trickPayload: function (data) {
|
||||
var payload = { seat: data.seat, cards: data.cards };
|
||||
if (data.hasOwnProperty('maxseat')) { payload.maxseat = data.maxseat; }
|
||||
if (data.hasOwnProperty('grade')) { payload.grade = data.grade; }
|
||||
return payload;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_QueryHandler: 明牌(mingpai)与提示(tishi)///
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §13.6 mingpai(只回给请求者):
|
||||
// seat 请求者 / others 另外两家各自未出的全部主牌 [{seat, zhucards}]
|
||||
// 受理条件:可查牌模式 + 出牌阶段 + 已有【任一】玩家报无主(design §9.3)
|
||||
//
|
||||
// 协议 §13.8 tishi(只转发给对家,即另一闲家):
|
||||
// seat 发出提示的闲家 / tip 1=踩 2=没分 3=有分
|
||||
// 注意:成功时【不给发送者回执】,只转发给对家;只有失败才回给发送者。
|
||||
//
|
||||
// 明牌数据进 GameState(面板可反复开关查看),提示不进——
|
||||
// 提示是一次性的通知、不是对局状态,重连也不重放,存下来只会变成幽灵数据。
|
||||
var EQW_QueryHandler = EQW_QueryHandler || {
|
||||
|
||||
handleMingpai: function (data) {
|
||||
if (!data.hasOwnProperty('others')) {
|
||||
console.error('[EQW_QueryHandler] mingpai 缺 others,已跳过该包');
|
||||
return;
|
||||
}
|
||||
EQW_GameState.table.mingpai = data.others;
|
||||
EventBus.emit(EQW_Events.EQW_MINGPAI);
|
||||
},
|
||||
|
||||
handleTishi: function (data) {
|
||||
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('tip')) {
|
||||
console.error('[EQW_QueryHandler] tishi 缺 seat 或 tip,已跳过该包');
|
||||
return;
|
||||
}
|
||||
//一次性通知:不进 GameState,随事件把内容带给订阅者
|
||||
EventBus.emit(EQW_Events.EQW_TIP, { seat: data.seat, tip: data.tip });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ReadyHandler: 准备(zhunbei)//////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §16:seat 位置序号。
|
||||
// 准备状态由平台的玩家信息 UI 呈现(清单 T-15:准备标识用平台的),
|
||||
// 子游戏只需把事件发出去,让关心的组件(如结算面板的「下一局」按钮)响应。
|
||||
var EQW_ReadyHandler = EQW_ReadyHandler || {
|
||||
|
||||
handle: function (data) {
|
||||
if (!data.hasOwnProperty('seat')) {
|
||||
console.error('[EQW_ReadyHandler] zhunbei 缺 seat,已跳过该包');
|
||||
return;
|
||||
}
|
||||
EventBus.emit(EQW_Events.EQW_READY_CHANGED, { seat: data.seat });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ResultHandler: 结算(jiesuan)与解散(Free)///
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §14:结算包由 chupai + bottom + aset 拼装,末局再加 account。
|
||||
// 三种来源的组成不同:
|
||||
// 正常出牌结算:chupai + bottom + aset(末局加 account)
|
||||
// 投降结算: 只有 aset(末局加 account),无 chupai、无 bottom
|
||||
// 解散结算: 只有 aset + account,且【走另一个入口】,见下
|
||||
//
|
||||
// 【解散的特殊性】(协议 §14.1,已对平台代码核实):
|
||||
// 解散包的 route 是平台的 "room",平台 12_Logic.js 按 route 分流时把它交给
|
||||
// Net[rpc] 而【不是】Game_Modify._ReceiveData——所以它根本进不了子游戏的分发表。
|
||||
// 平台另有专门入口:07_Desk.js 的 Game_Modify.Free(Desk.deskfree)。
|
||||
// 两个后果:
|
||||
// 1. 取值路径比协议文档少一层——平台已把 deskfree 取出来传进来,
|
||||
// 所以是 deskfree.data.aset,不是 data.deskfree.data.aset;
|
||||
// 2. 参数可能是 null——解散若发生在开战后、首局发牌前,服务端 get_disbandRoom
|
||||
// 返回 null,平台走「不带 deskfree」分支。这时只做房间收尾、不弹结算。
|
||||
var EQW_ResultHandler = EQW_ResultHandler || {
|
||||
|
||||
handleJiesuan: function (data) {
|
||||
if (!data.hasOwnProperty('aset')) {
|
||||
console.error('[EQW_ResultHandler] jiesuan 缺 aset,已跳过该包');
|
||||
return;
|
||||
}
|
||||
this._applyResult(data);
|
||||
},
|
||||
|
||||
//平台解散入口。deskfree = { rpc:"jiesuan", data:{ success, aset, account } },可能为 null
|
||||
handleFree: function (deskfree) {
|
||||
if (!deskfree || !deskfree.data) {
|
||||
//首局发牌前解散:没有结算数据,只做房间收尾,不弹结算面板
|
||||
console.warn('[EQW_ResultHandler] 解散包无 deskfree 数据(首局发牌前解散)');
|
||||
return;
|
||||
}
|
||||
var d = deskfree.data;
|
||||
if (!d.hasOwnProperty('aset')) {
|
||||
console.error('[EQW_ResultHandler] 解散包缺 aset,已跳过');
|
||||
return;
|
||||
}
|
||||
this._applyResult(d);
|
||||
},
|
||||
|
||||
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
|
||||
_applyResult: function (d) {
|
||||
//—— 收尾轮的第三手:牌局在最后一手打完时【不发 chupai3】,整包换成 jiesuan(协议 §13/§14)——
|
||||
//夹具实跑一局的包数就是证据:chupai1×28 / chupai2×28 / chupai3×27。
|
||||
//只在 chupai3 归档会永远丢掉最后一轮,故在这里补齐——它必是本轮第三手,落进当前这一轮。
|
||||
//jiesuan.chupai 的字段与 chupai3 不同:只有 seat / cards / maxseat(+ 有分时的 grade),
|
||||
//没有 order / playproc / cardsinhand。
|
||||
//归档随后会被下面的结算收敛清掉(deskinfo step6 不带 PushCards),但 emit 是同步的:
|
||||
//落牌与收牌表现在清空之前就已取到完整的收尾轮。
|
||||
//【本墩结果只走事件载荷、不进 GameState】:谁最大、这墩得几分是一次性表现,
|
||||
//deskinfo 的 Balance 不带它(它属于「出牌过程」而非结算数据),写进 GameState
|
||||
//就会多出一处服务端不知道的对局态。收尾墩与 chupai3 走同一对事件、同一份载荷字段。
|
||||
if (d.chupai && d.chupai.hasOwnProperty('seat') && d.chupai.hasOwnProperty('cards')) {
|
||||
EQW_GameState.pushPlay(d.chupai.seat, d.chupai.cards, false);
|
||||
var lastPlay = { seat: d.chupai.seat, cards: d.chupai.cards };
|
||||
if (d.chupai.hasOwnProperty('maxseat')) { lastPlay.maxseat = d.chupai.maxseat; }
|
||||
if (d.chupai.hasOwnProperty('grade')) { lastPlay.grade = d.chupai.grade; }
|
||||
EventBus.emit(EQW_Events.EQW_CARD_PLAYED, lastPlay);
|
||||
EventBus.emit(EQW_Events.EQW_TRICK_END, lastPlay);
|
||||
}
|
||||
|
||||
//结算是"清空过程量、只留结算数据"的快照点:deskinfo step6 只带 Balance(=aset),
|
||||
//其余分组一律回到 reset() 默认值——jiesuan 也要收敛到同一状态,否则重连前后画面不一致
|
||||
//(清单以 ResyncHandler.handleDeskinfo 在 step6 时实际重建出的字段集为准,不多清不少清)。
|
||||
EQW_GameState.aset.banker = -1;
|
||||
EQW_GameState.aset.call = -1;
|
||||
EQW_GameState.aset.multiple = 0;
|
||||
EQW_GameState.aset.flower = 0;
|
||||
EQW_GameState.aset.curmultiple = 0;
|
||||
EQW_GameState.aset.grade = 0;
|
||||
EQW_GameState.aset.baozhu = 0;
|
||||
EQW_GameState.aset.touxiang = 0;
|
||||
EQW_GameState.turn.seat = -1;
|
||||
EQW_GameState.turn.countdown = 0;
|
||||
EQW_GameState.call.currcall = 0;
|
||||
EQW_GameState.call.calls = [null, null, null];
|
||||
//末轮若由 jiesuan.chupai 带出(无 cardsinhand),出牌者自己的手牌不会被清空,这里显式清掉,
|
||||
//不留幽灵牌——deskinfo 在结算阶段的 MyCards 恒为 []
|
||||
EQW_GameState.my.cards = [];
|
||||
EQW_GameState.my.mustCard = [];
|
||||
EQW_GameState.my.bottomCards = [];
|
||||
EQW_GameState.my.buryCards = [];
|
||||
EQW_GameState.table.ancard3s = 0;
|
||||
EQW_GameState.table.playproc = null;
|
||||
EQW_GameState.table.pushlist = [];
|
||||
EQW_GameState.table.seatlist = [];
|
||||
EQW_GameState.table.liangpai = null;
|
||||
EQW_GameState.table.mingpai = null;
|
||||
|
||||
//【不写 chupai】收尾墩已在上面随事件载荷发走;deskinfo step6 的 Balance 只有
|
||||
//aset / bottom / account 三份,多存一个 chupai 就是增量路径独有的私有对局态
|
||||
EQW_GameState._apply(EQW_GameState.result, d, {
|
||||
bottom: 'bottom',
|
||||
aset: 'aset',
|
||||
account: 'account'
|
||||
});
|
||||
//阶段只读包字段:jiesuan 的 step 由服务端 get_paiju_account 统一给出(三种结算来源恒为 6),
|
||||
//前端不按 rpc 名硬编码——解散结算走的是另一条投递路径,同样带着这个字段
|
||||
EQW_GameState._apply(EQW_GameState.aset, d, { step: 'step' });
|
||||
|
||||
//三家总积分:jiesuan 的 aset.seatlist[i].score 就是"累积得分"(协议 §14),
|
||||
//与 deskinfo.PlayerInfo 同源同值——deskinfo 恒有的 PlayerInfo 在增量路径上没有对应来源,
|
||||
//只能从这里回填,否则一局中段(fapai 后至 jiesuan 前)room.playerScores 永远追不上服务端
|
||||
if (d.aset && Object.prototype.toString.call(d.aset.seatlist) === '[object Array]') {
|
||||
var scores = [];
|
||||
for (var i = 0; i < d.aset.seatlist.length; i++) {
|
||||
scores.push(d.aset.seatlist[i] && d.aset.seatlist[i].score);
|
||||
}
|
||||
EQW_GameState.room.playerScores = scores;
|
||||
}
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_ASET_RESULT);
|
||||
//account 只在末局或解散时才有
|
||||
if (d.hasOwnProperty('account')) {
|
||||
EventBus.emit(EQW_Events.EQW_ACCOUNT_RESULT);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_ResyncHandler: 重连(deskinfo)与开局(StartWar)
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §3:重连 = 重画。断线重连与硬刷新本质相同,复用同一条路径:
|
||||
// 填 GameState → emit EQW_RESYNC_ALL → 各组件据数据重建界面
|
||||
// 【绝不为重连单写一套渲染】。
|
||||
//
|
||||
// deskinfo 按 step 只带对应阶段的分组(协议文末):
|
||||
// 恒有 count / idx / PlayerInfo / step / MyCards
|
||||
// CallRun(step1) ChooseMain(step2) BuryCards(step3) PushCards(step5) Balance(step6)
|
||||
//
|
||||
// 【重连不重放一次性事件】:70 分坐庄的 3 秒开底是上庄时的一次性事件,
|
||||
// deskinfo 里没有 ancard3s,前端也不得补播。
|
||||
var EQW_ResyncHandler = EQW_ResyncHandler || {
|
||||
|
||||
//平台房间信息(roomcode / 总局数 / roomtype 位串)
|
||||
handleSetRoomDes: function (roomcode, asetcount, roomtype) {
|
||||
if (typeof asetcount === 'number') { EQW_GameState.room.asetCount = asetcount; }
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(roomtype);
|
||||
},
|
||||
|
||||
//开局:makewar 可能差异化下发(sendtype:1 + seatlist[]),先取自己那份
|
||||
handleStartWar: function (msg) {
|
||||
if (!msg || !msg.data) {
|
||||
console.warn('[EQW_ResyncHandler] StartWar 无数据,已忽略');
|
||||
return;
|
||||
}
|
||||
var raw = msg.data.deskwar || msg.data;
|
||||
var mine = raw;
|
||||
if (raw && raw.sendtype === 1 && Object.prototype.toString.call(raw.seatlist) === '[object Array]') {
|
||||
mine = null;
|
||||
for (var i = 0; i < raw.seatlist.length; i++) {
|
||||
if (raw.seatlist[i] && raw.seatlist[i].seat === EQW_GameState.room.mySeat) {
|
||||
mine = raw.seatlist[i].data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mine) {
|
||||
console.error('[EQW_ResyncHandler] StartWar 差异化下发里没有本座位的数据');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.handleDeskinfo(mine);
|
||||
},
|
||||
|
||||
//重连:deskinfo 全量快照
|
||||
handleDeskinfo: function (info) {
|
||||
if (!info) {
|
||||
console.warn('[EQW_ResyncHandler] deskinfo 为空,已忽略');
|
||||
return;
|
||||
}
|
||||
|
||||
EQW_GameState.reset();
|
||||
|
||||
//—— 恒有的部分 ——
|
||||
EQW_GameState._apply(EQW_GameState.room, info, {
|
||||
asetCount: 'count',
|
||||
asetIdx: 'idx',
|
||||
playerScores: 'PlayerInfo'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.aset, info, { step: 'step' });
|
||||
EQW_GameState._apply(EQW_GameState.my, info, { cards: 'MyCards' });
|
||||
|
||||
//—— 按阶段分组 ——
|
||||
if (info.CallRun) { this._applyCallRun(info.CallRun); }
|
||||
if (info.ChooseMain) { this._applyChooseMain(info.ChooseMain); }
|
||||
if (info.BuryCards) { this._applyBuryCards(info.BuryCards); }
|
||||
if (info.PushCards) { this._applyPushCards(info.PushCards); }
|
||||
if (info.Balance) { this._applyBalance(info.Balance); }
|
||||
|
||||
EventBus.emit(EQW_Events.EQW_RESYNC_ALL);
|
||||
},
|
||||
|
||||
_applyCallRun: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.call, g, {
|
||||
currcall: 'nowcall',
|
||||
calls: 'call'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, { multiple: 'multiple' });
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyChooseMain: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
touxiang: 'touxiang',
|
||||
curmultiple: 'curmultiple' //与 shangzhuang 推送同源同值,漏读会让「抓分」角标掉档
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||
//控制权由服务端在分组里显式给出(与 shangzhuang.nextseat 同源同值);
|
||||
//曾经这里写 turn.seat = aset.banker 自推,与 CallHandler 里那份是同一条规则的第二个写入处
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyBuryCards: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
flower: 'flower',
|
||||
curmultiple: 'curmultiple' //同上:一张牌未出时恒为 +3(大光),也必须重建
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
|
||||
//控制权同上,读服务端给的 seat(与 xuanzhu.nextseat 同源同值)
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, {
|
||||
seat: 'seat',
|
||||
countdown: 'countdown'
|
||||
});
|
||||
},
|
||||
|
||||
_applyPushCards: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.aset, g, {
|
||||
banker: 'banker',
|
||||
call: 'call',
|
||||
multiple: 'multiple',
|
||||
flower: 'flower',
|
||||
grade: 'grade',
|
||||
curmultiple: 'curmultiple',
|
||||
//报无主:明牌按钮与余主公示的开关,增量路径由 chupai1/2/3 的 baozhu 维护,
|
||||
//重连路径必须从 PushCards.baozhu 一起恢复,否则重连后按钮凭空消失
|
||||
baozhu: 'baozhu'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.my, g, {
|
||||
buryCards: 'burycards',
|
||||
mustCard: 'mustcard'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.table, g, {
|
||||
playproc: 'playproc',
|
||||
pushlist: 'pushlist',
|
||||
seatlist: 'seatlist',
|
||||
liangpai: 'liangpai'
|
||||
});
|
||||
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
|
||||
//当前该谁出牌由 playproc.currseat 给出(服务端权威,前端不推导)
|
||||
if (g.playproc && typeof g.playproc.currseat === 'number') {
|
||||
EQW_GameState.turn.seat = g.playproc.currseat;
|
||||
}
|
||||
},
|
||||
|
||||
//结算阶段(step6):Balance 与 jiesuan 推送给的是同一份结算数据(服务端同源快照)。
|
||||
//bottom(抠底明细)只有正常出牌结算才有、account(大局结算)只有末局/解散才有——
|
||||
//缺就不写、保持 reset() 的 null,与 jiesuan 那条路径的取舍完全一致。
|
||||
//【为什么必须接】结算面板还开着时断线重连/硬刷新,只恢复 aset 会让抠底明细与末局大结算空白。
|
||||
_applyBalance: function (g) {
|
||||
EQW_GameState._apply(EQW_GameState.result, g, {
|
||||
aset: 'aset',
|
||||
bottom: 'bottom',
|
||||
account: 'account'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////// 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 || {
|
||||
|
||||
//——— 牌编码区间边界:上面那张区间表的机器可读形式,是【全部区间判定的唯一权威来源】———
|
||||
//一律是【开区间】:CODE_ZHENG7_MIN < code < CODE_ZHENG7_MAX ⟺ 正7,其余同理。
|
||||
//本文件内部(is_continuous / trump_rank / id_to_code)与前端 core/CardMark.js 都引用这些常量,
|
||||
//不再各处重抄 3000/4000/8000/9000 之类裸字面量(工程总则 §1 SSOT / §6 配置优先于硬编码)。
|
||||
CODE_XIAOWANG: 9553, //小王(单值,非区间)
|
||||
CODE_DAWANG: 9554, //大王(单值,非区间)
|
||||
CODE_ZHENG7_MIN: 8000, //正7:8000 < code < 9000
|
||||
CODE_ZHENG7_MAX: 9000,
|
||||
CODE_FU7_MIN: 7000, //副7:7000 < code < 8000
|
||||
CODE_FU7_MAX: 8000,
|
||||
CODE_ZHENG2_MIN: 3000, //正2:3000 < code < 4000
|
||||
CODE_ZHENG2_MAX: 4000,
|
||||
CODE_FU2_MIN: 2000, //副2:2000 < code < 3000
|
||||
CODE_FU2_MAX: 3000,
|
||||
CODE_ZHU_MIN: 1000, //主牌 ⟺ code > CODE_ZHU_MIN;主花色普通牌:1000 < code < 2000
|
||||
CODE_ZHU_MAX: 2000,
|
||||
CODE_A_IN_ZHU: 14, //主A 的 code % 100(A 在 id_to_code 里 +13 提到 14)
|
||||
|
||||
//几对连续对子起算拖拉机(design §5.3)。group_tractor_runs 返回极大连续段,
|
||||
//段长 >= 本值即拖拉机——服务端分解甩牌与前端手牌标记共用这一个阈值
|
||||
TRACTOR_MIN_PAIRS: 2,
|
||||
|
||||
//牌id转花色
|
||||
id_to_flower: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 5;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 5;
|
||||
}
|
||||
return parseInt((cardid % 54) / 13) + 1;
|
||||
},
|
||||
|
||||
//牌id转牌面数值
|
||||
id_to_number: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 53;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 54;
|
||||
}
|
||||
return (cardid % 54) % 13 + 1;
|
||||
},
|
||||
|
||||
//牌id转牌编码
|
||||
id_to_code: function(mainflower, cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return youle_erqiwang_shared_cards.CODE_XIAOWANG;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return youle_erqiwang_shared_cards.CODE_DAWANG;
|
||||
}
|
||||
var flower = youle_erqiwang_shared_cards.id_to_flower(cardid);
|
||||
var number = youle_erqiwang_shared_cards.id_to_number(cardid);
|
||||
var code = flower * 100 + number;
|
||||
//特殊处理
|
||||
switch (number)
|
||||
{
|
||||
case 1:
|
||||
//A 提到 K 之上(14)
|
||||
code = code + 13;
|
||||
break;
|
||||
case 2:
|
||||
//2 提到副2 段
|
||||
code = code + youle_erqiwang_shared_cards.CODE_FU2_MIN;
|
||||
break;
|
||||
case 7:
|
||||
//7 提到副7 段
|
||||
code = code + youle_erqiwang_shared_cards.CODE_FU7_MIN;
|
||||
break;
|
||||
}
|
||||
//主牌花色处理:整体再上移一个主牌段(副2→正2、副7→正7、普通牌→主花色普通牌)
|
||||
if (flower == mainflower){
|
||||
code = code + youle_erqiwang_shared_cards.CODE_ZHU_MIN;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
//从大到小排序(冒泡排序)
|
||||
order_cards: function(mainflower, aryCardIDs){
|
||||
if (aryCardIDs){
|
||||
for (var i = 0; i < aryCardIDs.length; i++) {
|
||||
for (var j = i + 1; j < aryCardIDs.length; j++) {
|
||||
var i_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[i]);
|
||||
var j_code = youle_erqiwang_shared_cards.id_to_code(mainflower, aryCardIDs[j]);
|
||||
if (i_code < j_code) {
|
||||
var tmp = aryCardIDs[j];
|
||||
aryCardIDs[j] = aryCardIDs[i];
|
||||
aryCardIDs[i] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return aryCardIDs;
|
||||
},
|
||||
|
||||
//判断两个牌是否是连续的
|
||||
is_continuous: function(bigcode, smallcode){
|
||||
var S = youle_erqiwang_shared_cards;
|
||||
//是小王
|
||||
var is_xiaowang = function(_code){
|
||||
if (_code == S.CODE_XIAOWANG){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正7
|
||||
var is_zheng7 = function(_code){
|
||||
if (_code > S.CODE_ZHENG7_MIN && _code < S.CODE_ZHENG7_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负7
|
||||
var is_fu7 = function(_code){
|
||||
if (_code > S.CODE_FU7_MIN && _code < S.CODE_FU7_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正2
|
||||
var is_zheng2 = function(_code){
|
||||
if (_code > S.CODE_ZHENG2_MIN && _code < S.CODE_ZHENG2_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负2
|
||||
var is_fu2 = function(_code){
|
||||
if (_code > S.CODE_FU2_MIN && _code < S.CODE_FU2_MAX){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是主A
|
||||
var is_zhuA = function(_code){
|
||||
if (_code > S.CODE_ZHU_MIN && _code < S.CODE_ZHU_MAX && _code % 100 == S.CODE_A_IN_ZHU){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是8
|
||||
var is_8 = function(_code){
|
||||
if (_code % 100 == 8){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是6
|
||||
var is_6 = function(_code){
|
||||
if (_code % 100 == 6){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//普通的连牌
|
||||
if (bigcode - smallcode == 1){
|
||||
return true;
|
||||
}
|
||||
//小王、正7
|
||||
if (is_xiaowang(bigcode) && is_zheng7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正7、负7
|
||||
if (is_zheng7(bigcode) && is_fu7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负7、正2
|
||||
if (is_fu7(bigcode) && is_zheng2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正2、负2
|
||||
if (is_zheng2(bigcode) && is_fu2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负2、主A
|
||||
if (is_fu2(bigcode) && is_zhuA(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//8、6(design §5.3:7 不与 6/8 相连,6 与 8 相连)
|
||||
//必须是同一花色的 8 与 6:牌编码同花色相差 2(主花色 1408-1406=2、副牌 308-306=2),
|
||||
//跨花色则相差 100 的倍数。只判 %100 会把「♥8 + ♣6」误判为连对,
|
||||
//进而在 get_chongguan 扫描整手牌时让副牌的 6 对错误接续主 8、多算奖(design §8.1 连对链)
|
||||
if (is_8(bigcode) && is_6(smallcode) && (bigcode - smallcode == 2)){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
//获取对子列表,cards为同一花色的排过序的牌列表
|
||||
get_pairlist: function(mainflower, flowercards){
|
||||
var pairlist = [];
|
||||
var i = 0;
|
||||
while (i < flowercards.length - 1){
|
||||
//牌编码,两张牌两张牌一取
|
||||
var code_i = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i]);
|
||||
var code_j = youle_erqiwang_shared_cards.id_to_code(mainflower, flowercards[i + 1]);
|
||||
if (code_i == code_j){
|
||||
pairlist.push([flowercards[i], flowercards[i + 1]]);
|
||||
i = i + 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return pairlist;
|
||||
},
|
||||
|
||||
//把对子列表扫成【极大连续段】。pairlist 为同一牌组内、已按牌编码从大到小排好的对子列表
|
||||
//(形如 get_pairlist 的返回值:[[cardid, cardid], ...])。
|
||||
//返回 [[pair, ...], ...]:每一段是若干【相邻且 is_continuous】的对子,段与段之间必不连续;
|
||||
//孤立对子也作为长度 1 的段返回,故 concat 所有段 == 原 pairlist(顺序不变)。
|
||||
//段长 >= TRACTOR_MIN_PAIRS 即拖拉机。
|
||||
//【唯一实现】服务端 decompose_trump(甩牌分解)与前端 core/CardMark.js(手牌「拖」标记)
|
||||
//此前各写过一遍同样的扫描,规则内容(什么算连续、几对起算拖拉机)散在两处——现收敛到这里。
|
||||
group_tractor_runs: function(mainflower, pairlist){
|
||||
var runs = [];
|
||||
var i = 0;
|
||||
while (i < pairlist.length){
|
||||
var run = [pairlist[i]];
|
||||
var k = i;
|
||||
while (k + 1 < pairlist.length){
|
||||
var code_k = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k][0]);
|
||||
var code_k1 = youle_erqiwang_shared_cards.id_to_code(mainflower, pairlist[k + 1][0]);
|
||||
if (!youle_erqiwang_shared_cards.is_continuous(code_k, code_k1)){
|
||||
break;
|
||||
}
|
||||
run.push(pairlist[k + 1]);
|
||||
k++;
|
||||
}
|
||||
runs.push(run);
|
||||
i = k + 1;
|
||||
}
|
||||
return runs;
|
||||
},
|
||||
|
||||
//获取拖拉机列表,pairlist为同一花色的排过序的对子列表,cardtype为拖拉机牌型
|
||||
get_tuolaji_list: function(mainflower, pairlist, cardtype){
|
||||
var tuolaji = [];
|
||||
var count = cardtype - 300; //拖拉机的对子数量
|
||||
var i = 0;
|
||||
for (var i = 0; i < pairlist.length - count + 1; i++){
|
||||
//每次按拖拉机的对子数量取N对
|
||||
var tmplist = pairlist.slice(i, i + count);
|
||||
//判断是否是cardtype类型的拖拉机
|
||||
var is_tlj = true;
|
||||
for (var j = 0; j < tmplist.length - 1; j++) {
|
||||
var code_j1 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j][0]);
|
||||
var code_j2 = youle_erqiwang_shared_cards.id_to_code(mainflower, tmplist[j+1][0]);
|
||||
if (!youle_erqiwang_shared_cards.is_continuous(code_j1, code_j2)){
|
||||
is_tlj = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_tlj){
|
||||
var tlj = [];
|
||||
for (var k = 0; k < tmplist.length; k++){
|
||||
tlj = tlj.concat(tmplist[k]);
|
||||
}
|
||||
tuolaji.push(tlj);
|
||||
}
|
||||
}
|
||||
return tuolaji;
|
||||
},
|
||||
|
||||
//主牌归一化大小(副7 同级取 7000、副2 同级取 2000,其余用牌编码),仅用于甩牌分量比大小
|
||||
trump_rank: function(mainflower, cardid){
|
||||
var code = youle_erqiwang_shared_cards.id_to_code(mainflower, cardid);
|
||||
if (code > youle_erqiwang_shared_cards.CODE_FU7_MIN && code < youle_erqiwang_shared_cards.CODE_FU7_MAX){ //副7
|
||||
return youle_erqiwang_shared_cards.CODE_FU7_MIN;
|
||||
}
|
||||
if (code > youle_erqiwang_shared_cards.CODE_FU2_MIN && code < youle_erqiwang_shared_cards.CODE_FU2_MAX){ //副2
|
||||
return youle_erqiwang_shared_cards.CODE_FU2_MIN;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
//Node 导出;友乐/浏览器无 module 时跳过(dev-guide 01 §1)
|
||||
if (typeof module !== "undefined"){
|
||||
module.exports = youle_erqiwang_shared_cards;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Events: 玩法事件常量 ///////////////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
|
||||
//
|
||||
// 【对局状态不进事件载荷】——凡是 GameState 里有的,订阅者一律从 EQW_GameState 读,
|
||||
// 同一份数据不在事件载荷与状态里各存一份(SSOT)。
|
||||
// 例外只有「一次性、不属于对局状态、deskinfo 也不下发」的信息,它们不该进 GameState
|
||||
// (进了就会立刻制造一处增量/全量不一致),只能随事件载荷走:
|
||||
// EQW_RPC_FAILED {rpc, errcode} 一次性错误信息
|
||||
// EQW_CARD_PLAYED {order, seat, cards, count?, flower?, cardtype?, shuai?, shuaicuo?}
|
||||
// 这一手的落牌信息(一次性表现)。带 ? 的键【缺字段不兜底】,
|
||||
// 包里没有就不带;收尾轮由 jiesuan.chupai 带出时只有 {seat, cards, maxseat?}
|
||||
var EQW_Events = EQW_Events || {
|
||||
|
||||
EQW_RESET: 'eqw.reset', //新一局发牌,全部清场
|
||||
EQW_HAND_CHANGED: 'eqw.hand_changed', //自己手牌变化
|
||||
EQW_CALL_CHANGED: 'eqw.call_changed', //叫分推进
|
||||
EQW_BANKER_SET: 'eqw.banker_set', //上庄
|
||||
EQW_MAIN_SET: 'eqw.main_set', //选主
|
||||
EQW_BURY_DONE: 'eqw.bury_done', //埋牌完成
|
||||
EQW_CARD_PLAYED: 'eqw.card_played', //有人出牌
|
||||
EQW_TRICK_END: 'eqw.trick_end', //一墩结束;载荷 {seat,cards,maxseat[,grade]},不落 GameState
|
||||
EQW_TURN_CHANGED: 'eqw.turn_changed', //控制权或倒计时变化
|
||||
EQW_MINGPAI: 'eqw.mingpai', //收到明牌数据
|
||||
EQW_TIP: 'eqw.tip', //收到对家提示
|
||||
EQW_ASET_RESULT: 'eqw.aset_result', //小局结算
|
||||
EQW_ACCOUNT_RESULT: 'eqw.account_result', //大局/解散结算
|
||||
EQW_READY_CHANGED: 'eqw.ready_changed', //有人准备
|
||||
EQW_RESYNC_ALL: 'eqw.resync_all', //重连/开局全量重画
|
||||
EQW_RPC_FAILED: 'eqw.rpc_failed' //失败回包 {rpc, errcode}
|
||||
};
|
||||
|
||||
//追加到框架事件总线的事件表(框架不认识玩法事件,由子游戏注册)
|
||||
if (typeof EventBus !== 'undefined' && EventBus.Events) {
|
||||
for (var _eqwEvtKey in EQW_Events) {
|
||||
if (EQW_Events.hasOwnProperty(_eqwEvtKey)) {
|
||||
EventBus.Events[_eqwEvtKey] = EQW_Events[_eqwEvtKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_GameState: 服务端快照的镜像(前端 SSOT)////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端红线「视图 = f(服务端快照)」的落点:本对象只存【服务端下发过的字段】,
|
||||
// 不存任何前端算出来的结论。手牌排序、牌面标记、花色统计等由 core/ 的纯函数
|
||||
// 在渲染时现算,不落地成状态。
|
||||
//
|
||||
// 三条硬约束:
|
||||
// 1. 只镜像不派生——存进来的必须是服务端给过的
|
||||
// 2. 字段名对齐协议——协议叫 currcall 就叫 currcall,便于逐条核对
|
||||
// 3. 缺字段不兜底——包里没有的保持原值,不填默认值掩盖;必需字段缺失
|
||||
// 则 console.error 并跳过该包(服务端漏发,修在服务端)
|
||||
var EQW_GameState = EQW_GameState || {
|
||||
|
||||
//数据分组名(snapshot / reset 按此遍历;新增分组要同步加进来)
|
||||
_GROUPS: ['room', 'aset', 'turn', 'call', 'my', 'table', 'result'],
|
||||
|
||||
//—— 房间级(跨小局)——
|
||||
room: {
|
||||
asetCount: 0, //总局数 ← fapai.asetcount / deskinfo.count / setRoomDes
|
||||
asetIdx: 0, //当前第几局 ← fapai.asetidx / deskinfo.idx
|
||||
mySeat: -1, //自己的座位 ← 平台 C_Player.seat
|
||||
playerScores: [], //三家总积分 ← deskinfo.PlayerInfo / jiesuan.aset.seatlist[].score,跨局不清
|
||||
options: null //roomtype 解析结果
|
||||
},
|
||||
|
||||
//—— 小局级 ——
|
||||
aset: {
|
||||
step: 0, //1叫分 2选主/投降 3埋牌 5出牌 6结算
|
||||
banker: -1,
|
||||
call: -1, //庄家叫分
|
||||
multiple: 0, //基础子数
|
||||
flower: 0, //主牌花色(0 = 未选主)
|
||||
curmultiple: 0, //当前抓分倍数(带符号,服务端权威,前端不自算)
|
||||
grade: 0, //闲家已捡分
|
||||
baozhu: 0, //是否已有人报无主
|
||||
touxiang: 0 //是否允许投降
|
||||
},
|
||||
|
||||
//—— 当前控制权与倒计时(任何阶段都读这里)——
|
||||
turn: {
|
||||
seat: -1, //该谁操作
|
||||
countdown: 0 //展示用秒数;服务端无对应定时器,归零【不做任何界面推进】
|
||||
},
|
||||
|
||||
//—— 叫分过程 ——
|
||||
call: {
|
||||
currcall: 0,
|
||||
calls: [null, null, null] //null 未叫 / 0 不叫 / >0 叫了多少
|
||||
},
|
||||
|
||||
//—— 自己 ——
|
||||
my: {
|
||||
cards: [],
|
||||
mustCard: [], //本轮必出牌(服务端建议,只发给轮到的那家)
|
||||
bottomCards: [], //底牌(庄家恒有;闲家仅 70 分坐庄时有)
|
||||
buryCards: [] //埋牌底牌(仅庄家)
|
||||
},
|
||||
|
||||
//—— 桌面 ——
|
||||
table: {
|
||||
ancard3s: 0, //开底标志(70 分坐庄)
|
||||
playproc: null,
|
||||
pushlist: [],
|
||||
seatlist: [],
|
||||
liangpai: null,
|
||||
mingpai: null
|
||||
},
|
||||
|
||||
//—— 结算 ——
|
||||
//字段集与 deskinfo step6 的 Balance 严格对齐(aset / bottom / account)。
|
||||
//【不存 chupai】收尾墩的「谁最大、得几分」是一次性表现,随 EQW_TRICK_END 事件载荷走,
|
||||
//任何 deskinfo 都不下发它——存进来就是一处服务端不知道的私有对局态(见 ResultHandler / PlayHandler)
|
||||
result: {
|
||||
bottom: null,
|
||||
aset: null,
|
||||
account: null
|
||||
},
|
||||
|
||||
//新一局开局时清空对局态;room.mySeat / room.options / room.playerScores 跨局不变,不清
|
||||
//(playerScores 由 deskinfo.PlayerInfo 或 jiesuan.aset.seatlist[].score 增量更新,见 ResyncHandler / ResultHandler)
|
||||
reset: function () {
|
||||
this.room.asetIdx = 0;
|
||||
|
||||
this.aset = { step: 0, banker: -1, call: -1, multiple: 0, flower: 0,
|
||||
curmultiple: 0, grade: 0, baozhu: 0, touxiang: 0 };
|
||||
this.turn = { seat: -1, countdown: 0 };
|
||||
this.call = { currcall: 0, calls: [null, null, null] };
|
||||
this.my = { cards: [], mustCard: [], bottomCards: [], buryCards: [] };
|
||||
this.table = { ancard3s: 0, playproc: null, pushlist: [], seatlist: [],
|
||||
liangpai: null, mingpai: null };
|
||||
this.result = { bottom: null, aset: null, account: null };
|
||||
},
|
||||
|
||||
//深拷贝快照(只含数据分组,不含方法),供测试做「增量 vs 全量」比对
|
||||
snapshot: function () {
|
||||
var out = {};
|
||||
for (var i = 0; i < this._GROUPS.length; i++) {
|
||||
var g = this._GROUPS[i];
|
||||
out[g] = JSON.parse(JSON.stringify(this[g]));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
//把「一手牌」记进出牌历史 table.pushlist。
|
||||
//结构与 deskinfo.PushCards.pushlist 完全一致:外层下标 = 轮次-1,内层恒 3 个数组、
|
||||
//下标 = 座位序号(协议 §断线重连 pushlist)。
|
||||
//
|
||||
//【只记录服务端发来的事实】:谁、出了哪几张。轮次的推进不在这里推导——
|
||||
//startRound 由调用方按【包类型】给出:chupai1 就是「一轮的第一手」(服务端定义的包,
|
||||
//不是前端算出来的),其余各手落进当前这一轮。
|
||||
//
|
||||
//【为什么不能读 chupai3.playproc.cards 直接归档】:服务端 do_playcard 在第三家出完后
|
||||
//就地调用 new_playround,chupai3 带的是【下一轮】的进行态(round+1、cards 全空,
|
||||
//协议 §13 明标),本轮那三手只在 chupai1/2/3 各自的 seat + cards 里。
|
||||
//
|
||||
//【仅可查牌模式累积】:pushlist 是「查牌」历史,不查牌模式下服务端的 deskinfo 压根
|
||||
//不下发它(design §9),前端也不能自己攒一份——否则增量路径会凭空多出重连路径没有的历史。
|
||||
pushPlay: function (seat, cards, startRound) {
|
||||
var opts = this.room.options;
|
||||
if (!opts || opts.nocheck) { return; }
|
||||
var list = this.table.pushlist;
|
||||
if (startRound || list.length === 0) { list.push([[], [], []]); }
|
||||
list[list.length - 1][seat] = cards;
|
||||
},
|
||||
|
||||
//把包里的字段写进目标分组。map = { 目标键: 源键 }。
|
||||
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
|
||||
_apply: function (target, src, map) {
|
||||
for (var dst in map) {
|
||||
if (!map.hasOwnProperty(dst)) { continue; }
|
||||
var srcKey = map[dst];
|
||||
if (src.hasOwnProperty(srcKey)) { target[dst] = src[srcKey]; }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_RoomOptions_Parse: roomtype 位串解析 ///////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 协议 §0.5:roomtype 是建房时拼好的定长数字位串(字符串),每一位 '0'/'1'。
|
||||
// 服务端解析入口是 class.config.js 的 parse(),本文件必须与之【同规则】。
|
||||
//
|
||||
// 位0 局数 '0'=6局(缺省) '1'=12局
|
||||
// 位1 扣卡 '0'=房主扣卡 '1'=AA 每人扣卡
|
||||
// 位2 傍王 '0'=关 '1'=开
|
||||
// 位3 爬坡 '0'=常规算子 '1'=爬坡
|
||||
// 位4 查牌 '0'=可查牌 '1'=不查牌
|
||||
//
|
||||
// 对【缺失、非字符串、过短】的 roomtype 一律按 '0' 处理(与服务端一致),
|
||||
// 这是协议明文规定的行为,不是隐式兜底。
|
||||
var EQW_RoomOptions_Parse = (function() {
|
||||
//取第 idx 位,越界或非字符串时按 '0'
|
||||
function _bit(roomtype, idx) {
|
||||
if (typeof roomtype !== 'string') { return 0; }
|
||||
if (idx >= roomtype.length) { return 0; }
|
||||
return roomtype.charAt(idx) === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: function (roomtype) {
|
||||
return {
|
||||
asetCount: _bit(roomtype, 0) ? 12 : 6,
|
||||
deductAA: _bit(roomtype, 1),
|
||||
bangwang: _bit(roomtype, 2),
|
||||
climb: _bit(roomtype, 3),
|
||||
nocheck: _bit(roomtype, 4)
|
||||
};
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,336 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// 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。
|
||||
//
|
||||
// 【运行时注入】配置里写不出固定值的字段(动态文字宽高、贴哪张牌、随数据变化的行数/锚点…)
|
||||
// 一律留空,由调用方在 ctx 里补上;可注入字段见 INJECT_KEYS,优先级恒为 ctx > bySeat > base。
|
||||
// 布局节点用 runtime: ['target','w',…] 显式声明自己有哪些字段延后到运行时——那是给人和
|
||||
// 机械守卫看的契约(区分「忘了写」与「故意留空」),求解器本身对所有 INJECT_KEYS 一视同仁。
|
||||
//
|
||||
// 参数名一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置可原样喂入。
|
||||
// 精灵锚点恒在左上角,配置里的 anchorX/anchorY 是【对齐基准点】,不是精灵左上角。
|
||||
// 出错一律抛异常,不返回 {x:0,y:0} 之类的兜底值(工程总则 §7 显式失败)。
|
||||
var EQW_LayoutSolver = EQW_LayoutSolver || {
|
||||
|
||||
//可由 ctx 运行时注入的配置字段(与布局节点 runtime 声明里允许出现的键名同一份清单)
|
||||
INJECT_KEYS: ['target', 'x', 'y', 'w', 'h', 'anchorX', 'anchorY', 'rows', 'itemWidth', 'itemHeight'],
|
||||
|
||||
//各方向合法的对齐基准:框架 AlignmentUtils 的 switch 对不认识的 anchor 一律落到
|
||||
//default(居中),写错方向的 anchor(如竖排写 'left')会被静默吞掉、界面偏到画外也不报错。
|
||||
//故在此按方向白名单校验,把笔误挡在求解入口(工程总则 §7 显式失败)
|
||||
ANCHORS_HORIZONTAL: ['left', 'right', 'center'],
|
||||
ANCHORS_VERTICAL: ['top', 'bottom', 'center'],
|
||||
|
||||
//——— 对外:求解 ———
|
||||
solve: function (node, ctx) {
|
||||
if (!node) { throw new Error('[EQW_LayoutSolver] node 为空'); }
|
||||
ctx = ctx || {};
|
||||
var n = this._mergeCtx(this._mergeBySeat(node, ctx.seat), ctx);
|
||||
|
||||
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 之处)———
|
||||
//精灵个数与求解出的矩形数必须一一对应:数量不等时显式抛错,【不静默截断】——
|
||||
//少摆的那几个精灵会停在上一手牌的旧坐标上,是最难反查的一类错位(工程总则 §7 显式失败)
|
||||
apply: function (node, spriteIds, ctx) {
|
||||
var rects = this.solve(node, ctx);
|
||||
if (!spriteIds || spriteIds.length !== rects.length) {
|
||||
throw new Error('[EQW_LayoutSolver] apply 精灵数与矩形数不一致: spriteIds=' +
|
||||
(spriteIds ? spriteIds.length : 0) + ' rects=' + rects.length);
|
||||
}
|
||||
for (var i = 0; i < spriteIds.length; i++) {
|
||||
SpriteManager.setPosition(spriteIds[i], rects[i].x, rects[i].y);
|
||||
}
|
||||
return rects;
|
||||
},
|
||||
|
||||
//——— ctx 运行时注入:ctx 里给出的 INJECT_KEYS 字段覆盖配置同名字段 ———
|
||||
_mergeCtx: function (node, ctx) {
|
||||
var hit = false;
|
||||
var k, i;
|
||||
for (i = 0; i < this.INJECT_KEYS.length; i++) {
|
||||
if (typeof ctx[this.INJECT_KEYS[i]] !== 'undefined') { hit = true; break; }
|
||||
}
|
||||
if (!hit) { return node; }
|
||||
|
||||
var merged = {};
|
||||
for (k in node) {
|
||||
if (node.hasOwnProperty(k)) { merged[k] = node[k]; }
|
||||
}
|
||||
for (i = 0; i < this.INJECT_KEYS.length; i++) {
|
||||
k = this.INJECT_KEYS[i];
|
||||
if (typeof ctx[k] !== 'undefined') { merged[k] = ctx[k]; }
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
|
||||
//——— 对齐基准点校验:line/fan/grid 都要 anchorX/anchorY ———
|
||||
//框架 AlignmentUtils 内部是 options.anchorY || 0,缺失会被静默当成 0(整排贴到画布顶边),
|
||||
//故在此显式要求;写不出固定值的(如随类别行浮动的 anchorY)用 runtime 声明 + ctx 注入
|
||||
_requireAnchorPoint: function (n) {
|
||||
if (typeof n.anchorX !== 'number' || typeof n.anchorY !== 'number') {
|
||||
throw new Error('[EQW_LayoutSolver] ' + n.kind + ' 需要数字 anchorX/anchorY:配置未写,' +
|
||||
'且 ctx.anchorX/ctx.anchorY 未给出(anchorX=' + n.anchorX + ' anchorY=' + n.anchorY + ')');
|
||||
}
|
||||
},
|
||||
|
||||
//——— 方向 × 对齐基准校验 ———
|
||||
_checkAnchor: function (direction, anchor) {
|
||||
var allowed;
|
||||
if (direction === 'vertical') {
|
||||
allowed = this.ANCHORS_VERTICAL;
|
||||
} else if (direction === 'horizontal' || typeof direction === 'undefined') {
|
||||
allowed = this.ANCHORS_HORIZONTAL; //不写 direction 等同水平(框架默认)
|
||||
} else {
|
||||
throw new Error('[EQW_LayoutSolver] 未知 direction: ' + direction);
|
||||
}
|
||||
//不写 anchor 等同框架默认 center,两个方向都合法;写了就必须与方向匹配
|
||||
if (typeof anchor === 'undefined') { return; }
|
||||
for (var i = 0; i < allowed.length; i++) {
|
||||
if (allowed[i] === anchor) { return; }
|
||||
}
|
||||
throw new Error('[EQW_LayoutSolver] direction=' + (direction || 'horizontal') +
|
||||
' 不接受 anchor=' + anchor + ',只能是 ' + allowed.join('/'));
|
||||
},
|
||||
|
||||
//——— 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;
|
||||
},
|
||||
|
||||
//point:定点。x/y 缺失即无法定位,显式抛错——不返回 {x:undefined} 让 NaN 流到界面上
|
||||
//(x/y 写不出固定值的节点,用 runtime 声明并由 ctx.x/ctx.y 注入)
|
||||
_solvePoint: function (n) {
|
||||
if (typeof n.x !== 'number' || typeof n.y !== 'number') {
|
||||
throw new Error('[EQW_LayoutSolver] point 需要数字 x/y:配置未写,且 ctx.x/ctx.y 未给出(x=' +
|
||||
n.x + ' y=' + n.y + ')');
|
||||
}
|
||||
return [{ x: n.x, y: n.y, width: n.w, height: n.h }];
|
||||
},
|
||||
|
||||
//line:等距排列。带 items 时逐项取宽(itemWidth 失效),此时不能走 distribute(它假设等宽)
|
||||
_solveLine: function (n, ctx) {
|
||||
this._checkAnchor(n.direction, n.anchor);
|
||||
this._requireAnchorPoint(n);
|
||||
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)
|
||||
//只实现了水平方向(逐项取宽、沿 x 推进);竖排逐项不等高暂无用例,显式拒绝而不是按水平算
|
||||
_solveLineItems: function (n) {
|
||||
if (n.direction === 'vertical') {
|
||||
throw new Error('[EQW_LayoutSolver] line + items 仅支持 direction=horizontal');
|
||||
}
|
||||
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) {
|
||||
this._checkAnchor(n.direction, n.anchor);
|
||||
this._requireAnchorPoint(n);
|
||||
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) {
|
||||
//fillOrder 目前只实现行优先;不写等同 row,写了别的值一律显式拒绝(YAGNI,不做无人用的列优先)
|
||||
if (n.fillOrder && n.fillOrder !== 'row') {
|
||||
throw new Error('[EQW_LayoutSolver] grid 仅支持 fillOrder=row,暂不支持: ' + n.fillOrder);
|
||||
}
|
||||
//cols/rows 缺一即容量为 NaN:超载守卫失效(count > NaN 恒为 false)、循环一次都不跑、
|
||||
//静默返回空数组——摆不下与「一个都没摆」同样必须显式失败(工程总则 §7)。
|
||||
//rows 允许由 ctx.rows 运行时注入(行数随实际项数而定的场景),此处校验的是合并后的有效值
|
||||
if (typeof n.cols !== 'number' || typeof n.rows !== 'number') {
|
||||
throw new Error('[EQW_LayoutSolver] grid 需要数字 cols/rows:配置未写,且 ctx.rows 未给出(cols=' +
|
||||
n.cols + ' rows=' + n.rows + ')');
|
||||
}
|
||||
this._checkAnchor('horizontal', n.anchor); //grid 逐行走水平分布,anchor 同水平语义
|
||||
this._requireAnchorPoint(n);
|
||||
|
||||
var capacity = n.cols * n.rows;
|
||||
var count = (typeof ctx.count === 'number') ? ctx.count : capacity;
|
||||
//配置与运行时项数不一致时必须显式报错,不能静默截断多出的项(工程总则 §7 显式失败)
|
||||
if (count > capacity) {
|
||||
throw new Error('[EQW_LayoutSolver] grid 项数超出容量: count=' + count + ' > cols(' + n.cols + ')*rows(' + n.rows + ')=' + capacity);
|
||||
}
|
||||
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 为空 = 在目标【内部】对齐;有值 = 与目标对应角重合
|
||||
//target/w/h 都支持运行时注入:叫分档位贴自己的按钮、花色图标贴自己的按钮、牌角标贴"打出的那张牌",
|
||||
//这类目标配置里写不出固定值;动态文字标签的真实宽高要等文案渲染出来才知道,配置里也留空。
|
||||
//这些节点写成模板并用 runtime 声明留空字段,由 ctx 在运行时补上(已在 solve 里合并进 n)
|
||||
_solveAttach: function (n, ctx) {
|
||||
if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); }
|
||||
var targetKey = n.target;
|
||||
if (!targetKey) { throw new Error('[EQW_LayoutSolver] attach 配置未写 target,且 ctx.target 未给出'); }
|
||||
var target = ctx.rects[targetKey];
|
||||
if (!target) { throw new Error('[EQW_LayoutSolver] attach 的 target 未在 ctx.rects 中: ' + targetKey); }
|
||||
|
||||
var w = n.w;
|
||||
var h = n.h;
|
||||
|
||||
var sprite = { width: w, height: h };
|
||||
var offset = { x: n.offsetX || 0, y: n.offsetY || 0 };
|
||||
var pos;
|
||||
|
||||
//w/h 是否必填取决于本次实际走的对齐分支是否用到精灵尺寸(AlignmentUtils 源码逐分支核对):
|
||||
//纯文字标签在 left/top/bottom 对齐下不需要尺寸,一刀切要求 w/h 会逼配置去编造假数值
|
||||
if (n.corner) {
|
||||
switch (n.corner) {
|
||||
case 'topLeft':
|
||||
pos = AlignmentUtils.alignCornerTopLeft(target, sprite, offset); break;
|
||||
case 'topRight':
|
||||
this._requireW(w, targetKey, 'corner=topRight');
|
||||
pos = AlignmentUtils.alignCornerTopRight(target, sprite, offset); break;
|
||||
case 'bottomLeft':
|
||||
this._requireH(h, targetKey, 'corner=bottomLeft');
|
||||
pos = AlignmentUtils.alignCornerBottomLeft(target, sprite, offset); break;
|
||||
case 'bottomRight':
|
||||
this._requireW(w, targetKey, 'corner=bottomRight');
|
||||
this._requireH(h, targetKey, 'corner=bottomRight');
|
||||
pos = AlignmentUtils.alignCornerBottomRight(target, sprite, offset); break;
|
||||
default: throw new Error('[EQW_LayoutSolver] 未知 corner: ' + n.corner);
|
||||
}
|
||||
} else {
|
||||
//hAlign: center/right 要用 sprite.width;left 不用(AlignmentUtils.js 第106/111/101行)
|
||||
if (n.hAlign === 'center' || n.hAlign === 'right') {
|
||||
this._requireW(w, targetKey, 'hAlign=' + n.hAlign);
|
||||
}
|
||||
//vAlign: 只有 middle 用 sprite.height;top/bottom 都不用(bottom 用的是 target.height,第132行)
|
||||
if (n.vAlign === 'middle') {
|
||||
this._requireH(h, targetKey, 'vAlign=' + n.vAlign);
|
||||
}
|
||||
pos = AlignmentUtils.alignTo(target, sprite, n.hAlign, n.vAlign, offset);
|
||||
}
|
||||
|
||||
return [{ x: pos.x, y: pos.y, width: w, height: h }];
|
||||
},
|
||||
|
||||
_requireW: function (w, targetKey, mode) {
|
||||
if (typeof w !== 'number') {
|
||||
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 w:配置未写 w,且 ctx.w 未给出');
|
||||
}
|
||||
},
|
||||
|
||||
_requireH: function (h, targetKey, mode) {
|
||||
if (typeof h !== 'number') {
|
||||
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 h:配置未写 h,且 ctx.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;
|
||||
}
|
||||
};
|
||||
@@ -1062,7 +1062,7 @@ var SpriteManager = (function() {
|
||||
*
|
||||
* 说明:
|
||||
* - 多帧图片资源需要包含16帧:0123456789.+-x/p
|
||||
* - 帧序号:0-9对应数字,10='.', 11='+', 12='-', 13='x', 14='/', 15='p'(可变)
|
||||
* - 帧序号为 1-based:帧1-10对应数字0-9,帧11='.', 帧12='+', 帧13='-', 帧14='x', 帧15='/', 帧16='p'(可变后缀)
|
||||
* - 精灵宽度会根据字符数量自动调整
|
||||
* - 编码转换:. → b, + → c, - → d, x → e, / → f, 分/倍/张 → g
|
||||
* - 第16帧(p位)为可变内容,需要调用者确保资源图与使用场景匹配
|
||||
@@ -1088,17 +1088,19 @@ var SpriteManager = (function() {
|
||||
|
||||
// gameabc引擎帧索引计算(1-based):
|
||||
// - 数字 '0'-'9' → charCode - 47 = 帧1-10
|
||||
// - 字母 'b'-'g' → charCode - 87 = 帧10-16
|
||||
//
|
||||
// - 字母 'b'-'g' → charCode - 87 = 帧11-16
|
||||
// 编码故意从 'b' 起跳、跳过 'a':若用 'a'(charCode 97) 则 97-87=10,
|
||||
// 会与 '9' → 帧10 撞帧,所以把 '.' 的编码让给 'b'、从帧11 开始,避免撞车
|
||||
//
|
||||
// 编码映射:
|
||||
// - '.' → 'b' → 帧10 (实际帧11,因为0是帧1)
|
||||
// - '+' → 'c' → 帧11 (实际帧12)
|
||||
// - '-' → 'd' → 帧12 (实际帧13)
|
||||
// - 'x' → 'e' → 帧13 (实际帧14)
|
||||
// - '/' → 'f' → 帧14 (实际帧15)
|
||||
// - '分/倍/张' → 'g' → 帧16 (第16帧)
|
||||
//
|
||||
// 帧顺序(资源图片):0123456789.+-x/分 (共16帧)
|
||||
// - '.' → 'b' → 帧11
|
||||
// - '+' → 'c' → 帧12
|
||||
// - '-' → 'd' → 帧13
|
||||
// - 'x' → 'e' → 帧14
|
||||
// - '/' → 'f' → 帧15
|
||||
// - '分/倍/张' → 'g' → 帧16 (可变后缀)
|
||||
//
|
||||
// 帧顺序(资源图片):0123456789.+-x/后缀 (共16帧)
|
||||
var encodedText = textStr
|
||||
.replace(/\./g, 'b')
|
||||
.replace(/\+/g, 'c')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// 极简断言助手(测试代码不受严格 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 };
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// 把前端 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 };
|
||||
@@ -0,0 +1,319 @@
|
||||
// 导出脚本(测试代码,跑在 Node,非正式代码):
|
||||
// 用服务端 test/_rpc.js + class.desk.js 的既有测试脚手架跑一局完整流程
|
||||
//(发牌 fapai → 叫分 jiaofen → 上庄 shangzhuang → 选主 xuanzhu → 埋牌 maipai
|
||||
// → 出牌 chupai1/2/3 若干轮 → 结算 jiesuan),把真实下发包按座位快照导出成
|
||||
// client/tests/fixtures/packets.json,供前端测试回放(不手写假包,测的是真实契约)。
|
||||
//
|
||||
// 用法:node client/tests/fixtures/export_packets.js
|
||||
//
|
||||
// 装配方式参照服务端既有测试的两种建立法:
|
||||
// - _rpc.js 的 setup() 只接受"已构造好的 o_paiju",不走 class.desk.js,
|
||||
// 因此不会产生 fapai(发牌)包——它是给"单个 RPC 处理器"用的轻量桩。
|
||||
// - test_flow.js / test_endgame.js 的 mkRoom() 模式改用真实 class.desk.js
|
||||
// 的 D.new()/D.do_new_paiju(),才会真的下发 fapai;本脚本需要完整流程
|
||||
// (含 fapai),故照抄这一种既有用法,而不是 setup()。
|
||||
// 两条路径殊途同归:都是把 mod.app / mod.import 指向同一个 sent 捕获数组,
|
||||
// 区别只在"谁负责建立 o_room/o_desk/o_paiju"。
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 装配 mod.js 及其依赖类,把关键类挂到 global(与 _rpc.js 内部做法一致)
|
||||
const R = require('../../../server/games/erqiwang/test/_rpc.js');
|
||||
const mod = R.mod;
|
||||
const P = global.cls_youle_erqiwang_paiju;
|
||||
const A = global.cls_youle_erqiwang_arith;
|
||||
const D = require('../../../server/games/erqiwang/class.desk.js');
|
||||
const E = require('../../../server/games/erqiwang/class.export.js').new();
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const OUT = path.join(ROOT, 'client/tests/fixtures/packets.json');
|
||||
|
||||
// 常规房:可查牌、不爬坡、不傍王(roomtype 位串全 0,见 class.config.js)
|
||||
const ROOMTYPE = '00000';
|
||||
|
||||
// 固定种子:只在本导出脚本进程内替换 global.min_random(不改 server/_shim.js),
|
||||
// 让「同一份脚本重复运行」产出逐字节相同的 packets.json,便于 review diff 与回归。
|
||||
// 写法与服务端既有测试(test_flow.js/test_fuzz.js/test_leak.js)的 xorshift 种子一致。
|
||||
// 改这个值会重新生成整局牌(发牌结果、叫分走向、出牌过程全变),产物 diff 会很大。
|
||||
const DEAL_SEED = 0x5A5A2026;
|
||||
(function seedDeal(seed) {
|
||||
let state = seed >>> 0;
|
||||
const rnd = function (n) {
|
||||
state ^= state << 13; state >>>= 0;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5; state >>>= 0;
|
||||
return state % n;
|
||||
};
|
||||
global.min_random = function (min, max) { return min + rnd(max - min + 1); };
|
||||
})(DEAL_SEED);
|
||||
|
||||
const clone = function (m) { return JSON.parse(JSON.stringify(m)); };
|
||||
|
||||
// 装配一个"真实牌桌":D.new() 建牌桌,global.youle_erqiwang.app/import 与
|
||||
// mod.app/mod.import 同时指向同一个 sent 捕获数组——class.desk.js 的 fapai/zhunbei
|
||||
// 走前者(bare 全局引用),mod.js 的 RPC handler 走后者(mod 自身的模块变量),
|
||||
// 两条路径必须都接上,否则 fapai 会走到 _shim.js 的默认空实现,捕获不到。
|
||||
function makeRoom(roomtype) {
|
||||
const sent = [];
|
||||
const o_room = {
|
||||
roomtype: roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
|
||||
seatlist: [0, 1, 2].map(function (i) {
|
||||
return { conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} };
|
||||
}),
|
||||
method: { sendpack_toother: function (m) { sent.push(clone(m)); } }
|
||||
};
|
||||
const desk = D.new(o_room);
|
||||
o_room.o_desk = desk;
|
||||
global.youle_erqiwang.app = { SendPack: function (m) { sent.push(clone(m)); } };
|
||||
global.youle_erqiwang.import = { check_player: function () { return o_room; }, deduct_roomcard: function () { }, save_grade: function () { } };
|
||||
mod.import = global.youle_erqiwang.import;
|
||||
mod.app = global.youle_erqiwang.app;
|
||||
return { o_room: o_room, desk: desk, sent: sent };
|
||||
}
|
||||
|
||||
const pk = function (seat, extra) {
|
||||
return {
|
||||
conmode: 0, fromid: seat,
|
||||
data: Object.assign({ agentid: 'a', playerid: seat, gameid: 'g', roomcode: 1, seat: seat }, extra || {})
|
||||
};
|
||||
};
|
||||
|
||||
const room = makeRoom(ROOMTYPE);
|
||||
const o_room = room.o_room;
|
||||
const desk = room.desk;
|
||||
const sent = room.sent;
|
||||
|
||||
// ---- 按座位收集下发包(保留差异化下发:谁收到什么、谁没收到) ----
|
||||
// sent 里每一项要么带明确的 fromid(0/1/2,逐座位分别下发,内容可能互不相同),
|
||||
// 要么是 sendpack_toother(msg, -1) 的整体广播(未逐座位改写 conmode/fromid,
|
||||
// fromid 为 undefined)——广播内容三家相同,按"三家都收到"处理。
|
||||
const seatPackets = [[], [], []];
|
||||
const deskinfo = [];
|
||||
let drained = 0;
|
||||
function drain() {
|
||||
for (; drained < sent.length; drained++) {
|
||||
const raw = sent[drained];
|
||||
const packet = { rpc: raw.rpc, data: raw.data };
|
||||
if (raw.fromid === 0 || raw.fromid === 1 || raw.fromid === 2) {
|
||||
seatPackets[raw.fromid].push(packet);
|
||||
} else {
|
||||
seatPackets[0].push(packet);
|
||||
seatPackets[1].push(packet);
|
||||
seatPackets[2].push(packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
// packetIndex 是关键对齐锚点:取快照时先 drain,记下该座位此刻已收到的包数,
|
||||
// 与 Task 12 一致性测试"喂完 seats[seat] 的前 packetIndex 个包"对齐。
|
||||
//
|
||||
// 【必须 clone】get_deskinfo 的返回值里仍有若干【无拷贝的活引用赋值】——
|
||||
// 例如 Balance.readystate = o_desk.prepare、Balance.aset/bottom/account 三份结算快照。
|
||||
// (PushCards.playproc / PushCards.seatlist / pushlist / burycards 已各自走深拷贝快照函数,
|
||||
// 这段注释早先把它们也算在内,已过时,现更正。)
|
||||
// 真实链路里这份返回值被平台立刻序列化下发,活引用不会被观测到;但本脚本要跑完整局之后才
|
||||
// 统一 JSON.stringify 写盘,若不在这里当场拷贝一份,写盘时这些字段会被后续操作篡改成终局
|
||||
// 状态,导致同一座位不同 packetIndex 下的快照"逐字节相同"——这是本脚本自身的缺陷,与服务端
|
||||
// 无关(服务端没有"延后序列化"这个用法)。
|
||||
function snap(step, seat) {
|
||||
drain();
|
||||
deskinfo.push({
|
||||
step: step,
|
||||
seat: seat,
|
||||
packetIndex: seatPackets[seat].length,
|
||||
info: clone(E.get_deskinfo(o_room, seat))
|
||||
});
|
||||
}
|
||||
|
||||
// ================= 跑一局 =================
|
||||
|
||||
// 发牌(fapai):class.desk.js 内部随机发牌 + 广播
|
||||
D.do_new_paiju(desk, 0);
|
||||
const pj = desk.method.curr_paiju();
|
||||
|
||||
// StartWar(开局握手,前端 04/05 讲的"增量路径真实起点"):真实链路里客户端开局收到的
|
||||
// 第一包,结构就是 deskwar —— 即 get_deskinfo() 的返回值(见 server/youle/server_room/
|
||||
// rpc.js 里 pack.data.deskwar = youle_room.import.makewar_deskwar(o_room) 一段)。
|
||||
// 当前 erqiwang 的 exp.makewar(class.export.js)尚未把 get_deskinfo 接成返回值,这是
|
||||
// 另一项任务;本脚本独立调用 get_deskinfo 反映"接线后客户端此刻会收到什么",让前端测试
|
||||
// 能提前对齐这条起点(尤其是 room.playerScores 这类 fapai 包不带、只有 StartWar 能给的初值)。
|
||||
// 与紧随其后的 step1 快照取同一时刻(do_new_paiju 之后、任何 jiaofen 之前),按座位各存
|
||||
// 一份(三家 MyCards 视角不同),套一层 { deskwar: ... } 外壳与生产环境 pack.data.deskwar
|
||||
// 的包裹方式对齐,供 EQW_ResyncHandler.handleStartWar 直接消费。
|
||||
const startwar = [0, 1, 2].map(function (seat) {
|
||||
return { deskwar: clone(E.get_deskinfo(o_room, seat)) };
|
||||
});
|
||||
|
||||
snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同
|
||||
|
||||
// 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发)
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 65 }));
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
|
||||
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
|
||||
snap(2, 0); snap(2, 1); snap(2, 2); // 选主阶段(step2):庄家有 cards/bottomcards,闲家没有
|
||||
|
||||
// 选主:庄家选方块为主
|
||||
mod.xuanzhu(pk(pj.banker, { flower: 1 }));
|
||||
snap(3, pj.banker); // 埋牌阶段(step3):庄家视角
|
||||
|
||||
// 埋牌:庄家埋掉当前手上最后 8 张(get_seat_cards 已按主牌排序,任取8张即可)
|
||||
mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
|
||||
snap(5, 0); snap(5, 1); snap(5, 2); // 出牌阶段开局(step5):出牌者 cardsinhand、下一位 mustcard 差异
|
||||
|
||||
// ================= 出牌策略 =================
|
||||
// 【为什么不再是"全出单张"】:单张一手无所谓顺序,服务端「这一手打出的牌」的两个顺序口径
|
||||
// (chupai/playproc.cards 与 deskinfo.PushCards.pushlist)在单张局面下永远相等,夹具测不出
|
||||
// 分岔。甩牌(design §5.4)与领对子会产生"一手多张",才真正压到这条契约。
|
||||
//
|
||||
// 策略(确定性,不含随机):
|
||||
// 首家:优先甩牌(多分量主牌,且服务端 can_playcard 判定合法)→ 其次领最小的一对
|
||||
// → 否则最小单张
|
||||
// 跟家:先取 get_followcard 的必出牌,再从可出牌里补足张数,逐个组合试到
|
||||
// can_followcard(+ 甩牌时的 flush_follow_ok)通过为止
|
||||
//
|
||||
// 【提交顺序刻意用升序】:真实客户端提交的是玩家的点击顺序,服务端不得依赖它
|
||||
//(server 04 §8「前端不是数据源」;class.arith.js can_followcard 里也有同样的告诫)。
|
||||
// 服务端的权威展示顺序是 order_cards 的【降序】,所以这里一律把选好的一手【反转成升序】
|
||||
// 再提交,模拟"玩家从小到大点选"。这不是伪造数据——包仍然是服务端真实产出的,
|
||||
// 只是让请求侧的顺序与权威顺序刻意不同,把"下发顺序是否被请求顺序污染"暴露出来。
|
||||
const asc = function (cards) {
|
||||
return A.order_cards(pj.flower, cards.concat()).reverse();
|
||||
};
|
||||
|
||||
// 两名对手未出的主牌(供甩牌最大性判定),与 class.paiju.js do_playcard 的取法一致
|
||||
function oppZhuList(seat) {
|
||||
const list = [];
|
||||
for (let os = 0; os < 3; os++) {
|
||||
if (os !== seat) { list.push(P.get_seat_zhucards(pj, os)); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 首家:尝试甩牌——把手上主牌分解成分量,按分量牌力从大到小取前 k 个(k 从多到少),
|
||||
// 交给服务端 can_playcard 判定;只接受 result===true 的(合法甩牌),
|
||||
// 不提交会被判"甩错"的组合(那会被服务端收回成单张,反而回到单张局面)。
|
||||
// 注意 can_playcard 会【就地排序】入参数组,故一律传副本。
|
||||
function tryShuai(seat, hand) {
|
||||
const trumps = hand.filter(function (c) { return A.id_to_code(pj.flower, c) > 1000; });
|
||||
if (trumps.length < 2) { return null; }
|
||||
const comps = A.decompose_trump(pj.flower, trumps).slice()
|
||||
.sort(function (a, b) { return b.value - a.value; });
|
||||
const opp = oppZhuList(seat);
|
||||
for (let k = Math.min(comps.length, 3); k >= 2; k--) {
|
||||
let pick = [];
|
||||
for (let i = 0; i < k; i++) { pick = pick.concat(comps[i].cards); }
|
||||
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, opp).result) {
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 首家:尝试领一对。取【最大的一对】——甩牌合法的前提是两名对手都压不过任一分量
|
||||
//(§5.4.2 opp_can_beat_flush),大牌先走能更快拔掉对手的主牌,后续局面才会出现合法甩牌。
|
||||
// 这不是"为了凑测试"的取巧:本夹具的目的就是覆盖甩牌,而甩牌本来就只在这种局面下成立。
|
||||
// 同一颗种子下:领最小的一对 → 全局只有 1 手合法甩牌;领最大的一对 → 4 手。
|
||||
function tryLeadPair(seat, hand) {
|
||||
const pairs = A.get_pairlist(pj.flower, hand.concat());
|
||||
if (!pairs.length) { return null; }
|
||||
const pick = pairs[0].concat();
|
||||
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, oppZhuList(seat)).result) {
|
||||
return pick;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 跟家:这一手是否被服务端接受(与 class.paiju.js do_playcard 的跟牌校验同一组判定)
|
||||
function followOk(hand, cand) {
|
||||
const proc = pj.playproc;
|
||||
if (!A.can_followcard(pj.flower, hand, cand, proc.startcount, proc.startflower, proc.starttype).result) {
|
||||
return false;
|
||||
}
|
||||
if (proc.shuai_demand && !A.flush_follow_ok(pj.flower, hand, cand, proc.shuai_demand)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 跟家:凑出 startcount 张的合法跟牌
|
||||
function pickFollow(seat, hand) {
|
||||
const proc = pj.playproc;
|
||||
const n = proc.startcount;
|
||||
const get = A.get_followcard(pj.flower, hand.concat(), n, proc.startflower, proc.starttype);
|
||||
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
|
||||
if (base.length === n) {
|
||||
return followOk(hand, base) ? base : null;
|
||||
}
|
||||
const need = n - base.length;
|
||||
if (need < 0) { return null; }
|
||||
// 候选池:优先服务端给出的"可出的牌",为空则退回整手牌
|
||||
let pool = (get && get.cancard && get.cancard.length) ? get.cancard.concat() : hand.concat();
|
||||
pool = pool.filter(function (c) { return base.indexOf(c) < 0; });
|
||||
let found = null;
|
||||
const combo = [];
|
||||
(function walk(start) {
|
||||
if (found) { return; }
|
||||
if (combo.length === need) {
|
||||
const cand = base.concat(combo);
|
||||
if (followOk(hand, cand)) { found = cand; }
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < pool.length && !found; i++) {
|
||||
combo.push(pool[i]);
|
||||
walk(i + 1);
|
||||
combo.pop();
|
||||
}
|
||||
})(0);
|
||||
return found;
|
||||
}
|
||||
|
||||
// 打到一半时的 step5 快照锚点(按已出手数计)。
|
||||
// 【为什么要不止一个】:出牌阶段开局那三张 step5 快照的 pushlist 都还是空的,
|
||||
// 只有"打到一半"的快照才真的比对到出牌历史(一致性测试里唯一压到 pushlist 的点)。
|
||||
// 一个锚点=一个比对点,太薄;多取几个覆盖不同轮次/不同座位/轮中不同位次。
|
||||
const MID_SNAP_AT = [4, 9, 16, 25, 34];
|
||||
let guard = 0;
|
||||
let shuaiHands = 0; // 统计:合法甩牌手数
|
||||
let multiHands = 0; // 统计:一手多张的手数(含跟牌)
|
||||
while (pj.step === 5 && guard < 400) {
|
||||
guard++;
|
||||
const seat = pj.playproc.currseat;
|
||||
const hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) {
|
||||
pick = tryShuai(seat, hand);
|
||||
if (pick) { shuaiHands++; }
|
||||
if (!pick) { pick = tryLeadPair(seat, hand); }
|
||||
if (!pick) { pick = [hand[hand.length - 1]]; }
|
||||
} else {
|
||||
pick = pickFollow(seat, hand);
|
||||
}
|
||||
// 找不到合法出牌属脚本缺陷(服务端保证当前座位总有牌可出),显式失败、不静默跳过
|
||||
if (!pick) {
|
||||
throw new Error('export_packets: 座位 ' + seat + ' 第 ' + guard + ' 手凑不出合法出牌');
|
||||
}
|
||||
if (pick.length > 1) { multiHands++; }
|
||||
mod.chupai(pk(seat, { cards: asc(pick) }));
|
||||
// 出牌进行到中段时额外拍 step5 快照,覆盖"打到一半"的重连场景
|
||||
if (MID_SNAP_AT.indexOf(guard) >= 0 && pj.step === 5) {
|
||||
snap(5, pj.playproc.currseat);
|
||||
}
|
||||
}
|
||||
snap(6, 0); snap(6, 1); snap(6, 2); // 结算阶段(step6):jiesuan 广播后
|
||||
|
||||
drain(); // 保险:吸收循环末尾可能残留的未 drain 包
|
||||
|
||||
const fixture = {
|
||||
roomtype: ROOMTYPE,
|
||||
startwar: startwar,
|
||||
seats: seatPackets,
|
||||
deskinfo: deskinfo
|
||||
};
|
||||
|
||||
fs.writeFileSync(OUT, JSON.stringify(fixture, null, 2));
|
||||
console.log('已写入 ' + OUT);
|
||||
console.log('座位包数: ' + seatPackets.map(function (a) { return a.length; }).join(' / '));
|
||||
console.log('deskinfo 快照数: ' + deskinfo.length);
|
||||
console.log('出牌手数: ' + guard + '(其中一手多张 ' + multiHands + ' 手,合法甩牌 ' + shuaiHands + ' 手)');
|
||||
console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);
|
||||
@@ -0,0 +1,16 @@
|
||||
// 二七王前端单测总运行器:逐个 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);
|
||||
@@ -0,0 +1,150 @@
|
||||
// 【C-1 回归】room.mySeat 的同步必须跑在【真实的平台初始化时序】上。
|
||||
//
|
||||
// 平台 12_Logic.js 的 Logic.AppStart 跨 332–531 行,里面:
|
||||
// line 389: Game_Modify.appStart() ← 此时 C_Player 只是 `var C_Player;`,未赋值
|
||||
// line 480: C_Player = new Player(-1) ← 对象到这里才存在,seat 仍是 -1
|
||||
// 之后玩家登录,07_Desk.js 的 C_Player.SetSeat(真座位),
|
||||
// 但 Game_Modify.appStart() 【再也不会被调用】。
|
||||
//
|
||||
// 所以「只在 appStart 里读一次 C_Player.seat」必然拿不到座位,mySeat 永远停在 -1:
|
||||
// 发包带 seat:-1(服务端 check_player 必拒,叫分/选主/埋牌/出牌全发不出去)、
|
||||
// StartWar 差异化下发认领不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。
|
||||
// ——全程无一处报错。本测试就是拿这条时序当尺子量。
|
||||
//
|
||||
// 本文件【不允许】用 `S.room.mySeat = N` 直接赋值来制造前提(其余 8 个测试文件那样做,
|
||||
// 正是它们全部漏掉 C-1 的原因)。座位只能经由平台入口同步进来。
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
global.window = global;
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/gameabc-framework/system/SpriteEventController.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
|
||||
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
|
||||
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
load('client/js/01_SubGame/codes/core/SeatMap.js');
|
||||
|
||||
const sent = [];
|
||||
global.RpcHelper = { sendRpc: (app, route, rpc, data) => sent.push({ rpc, data }) };
|
||||
load('client/js/01_SubGame/codes/net/Rpc.js');
|
||||
|
||||
load('client/js/01_SubGame/codes/SubGameHooks.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
|
||||
// 平台 06_Player.js 的最小复刻:只有 seat 与 SetSeat 与本用例相关
|
||||
function Player(seat) { this.seat = seat; }
|
||||
Player.prototype.SetSeat = function (seat) { this.seat = seat; };
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 1:Logic.AppStart 第 389 行——C_Player 尚未 new
|
||||
// ============================================================================
|
||||
t.eq('前提:C_Player 此刻确实不存在', typeof global.C_Player, 'undefined');
|
||||
t.eq('前提:mySeat 初始值是 -1', S.room.mySeat, -1);
|
||||
|
||||
let boom = false;
|
||||
try { SubGameHooks.appStart(); } catch (e) { boom = true; }
|
||||
t.eq('appStart 在 C_Player 缺席时不崩', boom, false);
|
||||
t.eq('appStart 拿不到座位,mySeat 仍是 -1(不是缺陷,是时序事实)', S.room.mySeat, -1);
|
||||
|
||||
// 这个 -1 一旦被发包无声带出去就是 C-1 的第一重后果
|
||||
t.eq('座位未同步时发包 fail-fast', throws(() => EQW_Rpc.zhunbei()), true);
|
||||
t.eq('座位未同步时一个包都没发出去', sent.length, 0);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 2:Logic.AppStart 第 480 行——C_Player = new Player(-1)
|
||||
// ============================================================================
|
||||
global.C_Player = new Player(-1);
|
||||
SubGameHooks.setRoomDes(1234, 6, '00010');
|
||||
t.eq('C_Player 存在但座位还是 -1 时,不写坏值', S.room.mySeat, -1);
|
||||
t.eq('setRoomDes 该干的正事照干(解析 roomtype)', S.room.options.climb, 1);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 3:玩家登录,07_Desk.js:320 C_Player.SetSeat(真座位)
|
||||
// 紧接着 07_Desk.js:358 Game_Modify.setRoomDes(...)
|
||||
// ============================================================================
|
||||
C_Player.SetSeat(2);
|
||||
t.eq('SetSeat 本身不写 GameState(平台不知道子游戏)', S.room.mySeat, -1);
|
||||
|
||||
SubGameHooks.setRoomDes(1234, 6, '00010');
|
||||
t.eq('【核心】走完平台时序后 mySeat = 真座位', S.room.mySeat, 2);
|
||||
|
||||
// ---- 第一重后果解除:发包带真座位 ----
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('发包带上真座位', sent[0].data.seat, 2);
|
||||
|
||||
// ---- 第二重后果解除:StartWar 差异化下发能认领到自己那份 ----
|
||||
const share = (seat, cards) => ({ seat, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: cards } });
|
||||
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
share(0, [10, 11]), share(1, [20, 21]), share(2, [30, 31])
|
||||
] } } });
|
||||
t.eq('StartWar 认领到本座位那份手牌', S.my.cards, [30, 31]);
|
||||
|
||||
// ---- 第三重后果解除:SeatMap 不再对 -1 抛错 ----
|
||||
t.eq('SeatMap.toDisplay 用 mySeat 不抛错', throws(() => EQW_SeatMap.toDisplay(0, S.room.mySeat)), false);
|
||||
t.eq('自己映射到 SELF', EQW_SeatMap.toDisplay(S.room.mySeat, S.room.mySeat), 'SELF');
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 4:换座(07_Desk.js:186-191 先改 C_Player.seat,再回调 changeSeat)
|
||||
// 这是唯一「座位中途变化且不重走 setRoomDes」的路径
|
||||
// ============================================================================
|
||||
C_Player.SetSeat(0);
|
||||
SubGameHooks.changeSeat(2, 0);
|
||||
t.eq('换座后 mySeat 跟着变', S.room.mySeat, 0);
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('换座后发包带新座位', sent[0].data.seat, 0);
|
||||
|
||||
// ============================================================================
|
||||
// 阶段 5:断线重连(07_Desk.js:320 SetSeat → 358 setRoomDes → 423 Reconnect)
|
||||
// 这里单独校验 Reconnect 自身也同步,不依赖前面的入口是否跑过
|
||||
// ============================================================================
|
||||
S.room.mySeat = -1; // 人为退回未同步态,验证 Reconnect 能独立自愈
|
||||
C_Player.SetSeat(1);
|
||||
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
|
||||
t.eq('Reconnect 自身也同步座位', S.room.mySeat, 1);
|
||||
t.eq('Reconnect 的 reset() 不会把刚同步的座位清掉', S.room.mySeat, 1);
|
||||
t.eq('Reconnect 正常填状态', S.my.cards, [5, 6]);
|
||||
|
||||
S.room.mySeat = -1;
|
||||
C_Player.SetSeat(2);
|
||||
SubGameHooks.ReconnectNoMakewar();
|
||||
t.eq('ReconnectNoMakewar 也同步座位', S.room.mySeat, 2);
|
||||
|
||||
S.room.mySeat = -1;
|
||||
C_Player.SetSeat(0);
|
||||
SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
share(0, [7, 8]), share(1, [9]), share(2, [1]) ] } } });
|
||||
t.eq('StartWar 自身也同步座位', S.room.mySeat, 0);
|
||||
t.eq('StartWar 同步后认领正确的那份', S.my.cards, [7, 8]);
|
||||
|
||||
// ============================================================================
|
||||
// 幂等 / 防倒退:_syncMySeat 可重复调用,且绝不用坏值覆盖好值
|
||||
// ============================================================================
|
||||
SubGameHooks._syncMySeat();
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('重复同步结果不变(幂等)', S.room.mySeat, 0);
|
||||
|
||||
C_Player.SetSeat(-1); // 平台 new Player(-1) 的初值又冒出来
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位为 -1 时保持原值,不倒退', S.room.mySeat, 0);
|
||||
C_Player.SetSeat(3);
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位越界时保持原值,不倒退', S.room.mySeat, 0);
|
||||
C_Player.SetSeat('1');
|
||||
SubGameHooks._syncMySeat();
|
||||
t.eq('座位是字符串时保持原值,不倒退', S.room.mySeat, 0);
|
||||
|
||||
delete global.C_Player;
|
||||
boom = false;
|
||||
try { SubGameHooks._syncMySeat(); } catch (e) { boom = true; }
|
||||
t.eq('C_Player 消失时同步不崩', boom, false);
|
||||
t.eq('C_Player 消失时保持原值', S.room.mySeat, 0);
|
||||
|
||||
process.exit(t.done('appstart_timing') ? 0 : 1);
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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]));
|
||||
|
||||
// ---- 两副牌同一张牌共用同一帧 ----
|
||||
// 逐张比对;不再补一条 t.eq(true, true) 的恒真断言——那种检查在循环被删掉后仍然 PASS
|
||||
const deckMismatch = [];
|
||||
for (let id = 0; id < 54; id++) {
|
||||
if (EQW_CardCodec.cardIdToFrame(id) !== EQW_CardCodec.cardIdToFrame(id + 54)) {
|
||||
deckMismatch.push(id);
|
||||
}
|
||||
}
|
||||
t.eq('deck1/deck2 逐张同帧', deckMismatch, []);
|
||||
|
||||
// ---- 帧号值域: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);
|
||||
@@ -0,0 +1,119 @@
|
||||
// 手牌三种标记推导 + 选主面板的花色统计(清单 §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]);
|
||||
|
||||
// ---- 三连对:整段 6 张全标 tractor ----
|
||||
const tractor3 = [id(1, 2, 9), id(2, 2, 9), id(1, 2, 10), id(2, 2, 10), id(1, 2, 11), id(2, 2, 11)];
|
||||
const m3a = EQW_CardMark.marksOfHand(tractor3, MF);
|
||||
t.eq('三连对全标 tractor', tractor3.map(c => m3a[c]), ['tractor', 'tractor', 'tractor', 'tractor', 'tractor', 'tractor']);
|
||||
|
||||
// ---- 极大连续段:连续的 10/J 成拖,同花色不相连的 8 对不成拖(段与段互不粘连)----
|
||||
const runAndLone = [id(1, 2, 10), id(2, 2, 10), id(1, 2, 11), id(2, 2, 11), id(1, 2, 8), id(2, 2, 8)];
|
||||
const m3b = EQW_CardMark.marksOfHand(runAndLone, MF);
|
||||
t.eq('连续段内标 tractor', [m3b[id(1, 2, 10)], m3b[id(1, 2, 11)]], ['tractor', 'tractor']);
|
||||
t.eq('段外孤立对子不标', m3b[id(1, 2, 8)] || null, null);
|
||||
|
||||
// ---- 与服务端同源:标记扫描走 shared 的 group_tractor_runs(前后端唯一实现)----
|
||||
t.eq('shared 导出极大连续段扫描', typeof youle_erqiwang_shared_cards.group_tractor_runs, 'function');
|
||||
t.eq('shared 导出拖拉机起算对数', youle_erqiwang_shared_cards.TRACTOR_MIN_PAIRS, 2);
|
||||
|
||||
// ---- 区间边界取自 shared 具名常量(CardMark 内不再有裸字面量)----
|
||||
t.eq('shared 导出主牌下界', youle_erqiwang_shared_cards.CODE_ZHU_MIN, 1000);
|
||||
t.eq('正2 落在 CODE_ZHENG2 区间',
|
||||
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 2)) > youle_erqiwang_shared_cards.CODE_ZHENG2_MIN &&
|
||||
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 2)) < youle_erqiwang_shared_cards.CODE_ZHENG2_MAX, true);
|
||||
t.eq('正7 落在 CODE_ZHENG7 区间',
|
||||
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 7)) > youle_erqiwang_shared_cards.CODE_ZHENG7_MIN &&
|
||||
youle_erqiwang_shared_cards.id_to_code(MF, id(1, MF, 7)) < youle_erqiwang_shared_cards.CODE_ZHENG7_MAX, true);
|
||||
|
||||
// ---- 标记值取自 MARK_* 常量,不在别处重写字面量 ----
|
||||
t.eq('MARK_TRACTOR 常量', EQW_CardMark.MARK_TRACTOR, 'tractor');
|
||||
t.eq('MARK_ZHENG 常量', EQW_CardMark.MARK_ZHENG, 'zheng');
|
||||
t.eq('MARK_ZHU 常量', EQW_CardMark.MARK_ZHU, 'zhu');
|
||||
|
||||
// ---- 优先级:属于拖拉机的正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);
|
||||
@@ -0,0 +1,49 @@
|
||||
// 主牌序排序:必须与服务端 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);
|
||||
@@ -0,0 +1,114 @@
|
||||
// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ROOT, load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
|
||||
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
|
||||
|
||||
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
|
||||
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
|
||||
//
|
||||
// 【清单历史】result.bottom / result.account 已随服务端 Balance 补发而删除;
|
||||
// result.chupai 已随「本墩结果改走 EQW_TRICK_END 事件载荷、不进 GameState」而删除。
|
||||
// 现在只剩下面这一条。
|
||||
const EXCLUDE = [
|
||||
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放。
|
||||
// ⚠️ 当前夹具无法触发(65 分坐庄、单局),此条未经验证——保留是因为删掉之后
|
||||
// 将来真出现不一致会静默通过;但它此刻并没有在守住任何东西,不要当成已验证的豁免。
|
||||
'table.ancard3s'
|
||||
];
|
||||
|
||||
function pick(snap, exclude) {
|
||||
const out = JSON.parse(JSON.stringify(snap));
|
||||
exclude.forEach(p => {
|
||||
const parts = p.split('.');
|
||||
let o = out;
|
||||
for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; }
|
||||
if (o) { delete o[parts[parts.length - 1]]; }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// 每个对齐点独立起步,不共用进程内任何残留(下面两个 build 函数各自从
|
||||
// EQW_GameState.reset() + 显式赋值房间级字段开始,互不依赖调用顺序/次数):
|
||||
//
|
||||
// 【曾经的假绿】EQW_GameState.reset() 按设计不清 room.playerScores(它是"跨局不清"的
|
||||
// 房间级累积量,见 GameState.js 注释)。旧版路径 A 从不写 playerScores(fapai 包不带),
|
||||
// 于是它的值全靠"上一次由谁最后写过"——如果上一次恰好是路径 B(deskinfo 带 PlayerInfo)
|
||||
// 跑过,路径 A 就会【继承】那份残留,凑巧与路径 B 的期望值相等,测试假绿;换一个座位
|
||||
// 顺序、或把它放到第一个跑,残留没了就立刻转红。
|
||||
// 真实客户端不存在这个残留——它在 fapai 之前先收到 StartWar(deskwar,见夹具补捕),
|
||||
// 由它给出 room.playerScores 的开局初值。所以路径 A 的真实起点也改成先喂 StartWar,
|
||||
// 每次都显式重新赋值,不再依赖任何"恰好还没被冲掉的旧值"。
|
||||
function buildIncremental(seat, packetIndex) {
|
||||
EQW_GameState.reset();
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
|
||||
EQW_ResyncHandler.handleStartWar({ data: fx.startwar[seat] });
|
||||
fx.seats[seat].slice(0, packetIndex).forEach(p => {
|
||||
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
|
||||
});
|
||||
return EQW_GameState.snapshot();
|
||||
}
|
||||
|
||||
function buildFull(seat, info) {
|
||||
EQW_GameState.reset();
|
||||
EQW_GameState.room.mySeat = seat;
|
||||
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
|
||||
EQW_ResyncHandler.handleDeskinfo(info);
|
||||
return EQW_GameState.snapshot();
|
||||
}
|
||||
|
||||
// 对每个座位、每个 deskinfo 快照点做比对
|
||||
let compared = 0;
|
||||
fx.deskinfo.forEach(snapPoint => {
|
||||
const seat = snapPoint.seat;
|
||||
|
||||
const incremental = buildIncremental(seat, snapPoint.packetIndex);
|
||||
const full = buildFull(seat, snapPoint.info);
|
||||
|
||||
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
|
||||
pick(incremental, EXCLUDE), pick(full, EXCLUDE));
|
||||
compared++;
|
||||
});
|
||||
|
||||
t.eq('比对点数量 > 0', compared > 0, true);
|
||||
|
||||
// ================= Ruling 3:step3(埋牌)deskinfo 直读断言 =================
|
||||
// 一致性比对无法单独覆盖 ResyncHandler._applyBuryCards 的字段映射正确性——
|
||||
// 若增量路径压根不写某字段,两边可能「同为初始值」而比对照样绿。
|
||||
// 这里独立取 step3 快照,逐字段直读 GameState 并核对具体值。
|
||||
const step3 = fx.deskinfo.filter(d => d.step === 3)[0];
|
||||
if (!step3) {
|
||||
console.warn('[test_consistency] 夹具里没有 step3 快照,Ruling 3 的直读断言无法执行');
|
||||
} else {
|
||||
EQW_GameState.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo(step3.info);
|
||||
const bc = step3.info.BuryCards;
|
||||
t.eq('step3 直读 aset.banker', EQW_GameState.aset.banker, bc.banker);
|
||||
t.eq('step3 直读 aset.call', EQW_GameState.aset.call, bc.call);
|
||||
t.eq('step3 直读 aset.multiple', EQW_GameState.aset.multiple, bc.multiple);
|
||||
t.eq('step3 直读 aset.flower', EQW_GameState.aset.flower, bc.flower);
|
||||
t.eq('step3 直读 my.bottomCards', EQW_GameState.my.bottomCards, bc.bottomcards);
|
||||
t.eq('step3 直读 turn.countdown', EQW_GameState.turn.countdown, bc.countdown);
|
||||
t.eq('step3 直读 turn.seat(埋牌由庄家做)', EQW_GameState.turn.seat, bc.banker);
|
||||
t.eq('step3 直读 aset.step', EQW_GameState.aset.step, step3.info.step);
|
||||
}
|
||||
|
||||
process.exit(t.done('consistency') ? 0 : 1);
|
||||
@@ -0,0 +1,375 @@
|
||||
// 常量层机械守卫:号段合规、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]);
|
||||
// 钉住精确数量:松下界(如 >200)删掉整个 View 都还能通过。
|
||||
// 增删精灵时连同本数字一起改——有意的顺手更新,不是有意的就红给你看
|
||||
t.eq('精灵总数', spriteVals.length, 380);
|
||||
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);
|
||||
|
||||
// ---- 手牌标记与手牌一一对应(清单 §1.6:三种标记互斥、【每张牌】至多一个)----
|
||||
// core/CardMark.js 也是按每张牌返回标记的:一手 28 张常带 13 个以上标记、36 张埋牌手更多,
|
||||
// 标记精灵必须与手牌等量,否则渲染时必然有牌的标记画不出来
|
||||
const groupMap = EQW_SpriteIndex.buildGroupMap(EQW_Sprites);
|
||||
const handPairing = [];
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
if (!index.hasOwnProperty('HAND_CARD_' + i)) { handPairing.push('缺 HAND_CARD_' + i); }
|
||||
if (!index.hasOwnProperty('HAND_MARK_' + i)) { handPairing.push('缺 HAND_MARK_' + i); }
|
||||
else if (groupMap['HAND_MARK_' + i] !== groupMap['HAND_CARD_' + i]) {
|
||||
handPairing.push('HAND_MARK_' + i + ' 与手牌不同群组');
|
||||
}
|
||||
}
|
||||
if (handPairing.length) { console.log('手牌标记配对问题:\n ' + handPairing.join('\n ')); }
|
||||
t.eq('手牌标记与手牌 1..36 一一对应', handPairing, []);
|
||||
t.eq('手牌标记恰好 36 个(无第 37 个)', index.hasOwnProperty('HAND_MARK_37'), false);
|
||||
|
||||
// ---- 各类 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 的来源判别:targetKind 指明去哪个命名空间取矩形 ----
|
||||
// 'sprite' 子游戏精灵键名(EQW_Sprites 索引)
|
||||
// 'layout' EQW_Layout 的布局节点名(矩形来自该节点的求解结果,可能是其中第 N 项)
|
||||
// 'platform' 平台精灵键名(如平台头像框,落在框架保留段 1–1000,不参与子游戏号段校验)
|
||||
// 只在【对应的那一个】命名空间里校验存在性——查三者并集范围太宽,几乎任何标识符都能"解析"。
|
||||
const NAMESPACES = { sprite: index, layout: EQW_Layout, platform: EQW_PlatformSprites };
|
||||
|
||||
const resolvable = {};
|
||||
Object.keys(NAMESPACES).forEach(kind => {
|
||||
Object.keys(NAMESPACES[kind]).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));
|
||||
|
||||
// 每个 attach 节点必须声明合法 targetKind;非 attach 节点不得声明(避免复制粘贴留下的无意义字段)
|
||||
const kindDecl = [];
|
||||
allNodes.forEach(entry => {
|
||||
const tk = entry.node.targetKind;
|
||||
if (entry.node.kind === 'attach') {
|
||||
if (!NAMESPACES.hasOwnProperty(tk)) { kindDecl.push(entry.path + ' -> targetKind=' + tk); }
|
||||
} else if (typeof tk !== 'undefined') {
|
||||
kindDecl.push(entry.path + ' -> 非 attach 却声明了 targetKind=' + tk);
|
||||
}
|
||||
});
|
||||
if (kindDecl.length) { console.log('targetKind 声明有问题的节点:\n ' + kindDecl.join('\n ')); }
|
||||
t.eq('attach 节点均声明合法 targetKind', kindDecl, []);
|
||||
|
||||
// 配置里写死的 target 必须存在于 targetKind 指定的那个命名空间
|
||||
const missing = [];
|
||||
allNodes.forEach(entry => {
|
||||
const ns = NAMESPACES[entry.node.targetKind];
|
||||
if (!ns) { return; } // targetKind 非法已由上一条断言报出
|
||||
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 (!Object.prototype.hasOwnProperty.call(ns, pair[1])) {
|
||||
missing.push(pair[0] + ' -> ' + pair[1] + '(不在 ' + entry.node.targetKind + ' 命名空间里)');
|
||||
}
|
||||
});
|
||||
});
|
||||
if (missing.length) { console.log('无法解析的 attach.target:\n ' + missing.join('\n ')); }
|
||||
t.eq('attach.target 在其 targetKind 命名空间内可解析', missing, []);
|
||||
|
||||
// line + items 节点:每项 key 同样是"指向另一个命名空间对象"的标识(多为精灵键),
|
||||
// 与 attach.target 同一职责,此前完全没有守卫——写错 key 不会报错,只会在渲染时摆错精灵。
|
||||
// items 没有 targetKind 字段,按 attach.target 同样的三命名空间并集解析
|
||||
const missingItemKeys = [];
|
||||
allNodes.forEach(entry => {
|
||||
if (!entry.node.items) { return; }
|
||||
entry.node.items.forEach((it, i) => {
|
||||
if (!resolvable[it.key]) {
|
||||
missingItemKeys.push(entry.path + '.items[' + i + '] -> key 不存在: ' + it.key);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (missingItemKeys.length) { console.log('无法解析的 items[].key:\n ' + missingItemKeys.join('\n ')); }
|
||||
t.eq('items[].key 在三命名空间并集内可解析', missingItemKeys, []);
|
||||
|
||||
// ---- 求解冒烟:每个布局节点都真的能被求解器算出来 ----
|
||||
// 这是配置与求解器之间契约的最强守卫——字段名写错、缺 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 }; });
|
||||
|
||||
// 运行时注入字段的假值表:【只对节点 runtime 里显式声明过的键】注入。
|
||||
// 没声明却缺字段的节点,求解器照常抛错、本守卫照常变红——这正是 runtime 声明的意义:
|
||||
// 区分「故意延后到运行时」与「忘了写」,守卫不再无差别地自动补齐缺失字段。
|
||||
const RUNTIME_FAKE = {
|
||||
target: 'TOP_INFO_BG', x: 10, y: 20, w: 60, h: 24,
|
||||
anchorX: 30, anchorY: 40, rows: 2, itemWidth: 150, itemHeight: 32
|
||||
};
|
||||
// 求解器新开了注入通道却忘了给假值 → 这里立刻红
|
||||
t.eq('运行时假值表覆盖全部可注入字段',
|
||||
EQW_LayoutSolver.INJECT_KEYS.filter(k => !RUNTIME_FAKE.hasOwnProperty(k)), []);
|
||||
t.eq('假值表里的 target 是真实可解析键', resolvable[RUNTIME_FAKE.target], true);
|
||||
|
||||
// bySeat 覆盖后的有效配置(bySeat 可覆盖包括 kind 在内的任意字段)
|
||||
function effectiveNode(node, seat) {
|
||||
if (!node.bySeat || !seat) { return node; }
|
||||
const out = {};
|
||||
Object.keys(node).forEach(k => { if (k !== 'bySeat') { out[k] = node[k]; } });
|
||||
Object.keys(node.bySeat[seat]).forEach(k => { out[k] = node.bySeat[seat][k]; });
|
||||
return out;
|
||||
}
|
||||
|
||||
// runtime 声明本身要合法:必须是字符串数组、键名在求解器的可注入清单里、
|
||||
// 且该字段在配置里确实留空(配置已经写了值还声明 runtime,说明声明是过期的)
|
||||
const runtimeDecl = [];
|
||||
allNodes.forEach(entry => {
|
||||
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
|
||||
seats.forEach(seat => {
|
||||
const eff = effectiveNode(entry.node, seat);
|
||||
const label = entry.path + (seat ? '[' + seat + ']' : '');
|
||||
if (typeof eff.runtime === 'undefined') { return; }
|
||||
if (!Array.isArray(eff.runtime)) { runtimeDecl.push(label + ' -> runtime 不是数组'); return; }
|
||||
eff.runtime.forEach(k => {
|
||||
if (EQW_LayoutSolver.INJECT_KEYS.indexOf(k) < 0) {
|
||||
runtimeDecl.push(label + ' -> runtime 含不可注入的字段: ' + k);
|
||||
} else if (typeof eff[k] !== 'undefined') {
|
||||
runtimeDecl.push(label + ' -> runtime 声明了 ' + k + ',但配置里已写了值');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if (runtimeDecl.length) { console.log('runtime 声明有问题的节点:\n ' + runtimeDecl.join('\n ')); }
|
||||
t.eq('runtime 声明合法且确实留空', runtimeDecl, []);
|
||||
|
||||
const SMOKE_COUNT = 8;
|
||||
const solveFailures = [];
|
||||
allNodes.forEach(entry => {
|
||||
// 有 bySeat 的按三个显示位各求一次,没有的求一次
|
||||
const seats = entry.node.bySeat ? Object.keys(entry.node.bySeat) : [null];
|
||||
seats.forEach(seat => {
|
||||
const eff = effectiveNode(entry.node, seat);
|
||||
const ctx = { rects: fakeRects };
|
||||
if (seat) { ctx.seat = seat; }
|
||||
(eff.runtime || []).forEach(k => { ctx[k] = RUNTIME_FAKE[k]; });
|
||||
|
||||
// 期望产出多少个矩形——只断言"是数组、坐标是数字"的话,返回空数组也照样通过,
|
||||
// 「少画了几张牌」这类缺陷对守卫完全不可见(曾经 grid 缺 rows 就是这么漏过去的)
|
||||
let expected;
|
||||
if (eff.kind === 'grid') {
|
||||
const rows = (typeof ctx.rows === 'number') ? ctx.rows : eff.rows;
|
||||
const capacity = eff.cols * rows;
|
||||
ctx.count = Math.min(SMOKE_COUNT, capacity); // 不制造超载(超载抛错另有专门用例)
|
||||
expected = ctx.count;
|
||||
} else if (eff.kind === 'line' || eff.kind === 'fan') {
|
||||
ctx.count = SMOKE_COUNT;
|
||||
expected = eff.items ? eff.items.length : SMOKE_COUNT;
|
||||
} else {
|
||||
expected = 1; // point / attach 恒为 1
|
||||
}
|
||||
|
||||
const label = entry.path + (seat ? '[' + seat + ']' : '');
|
||||
try {
|
||||
const r = EQW_LayoutSolver.solve(entry.node, ctx);
|
||||
if (!Array.isArray(r)) { solveFailures.push(label + ' -> 返回值不是数组'); return; }
|
||||
if (r.length !== expected) {
|
||||
solveFailures.push(label + ' -> 矩形数 ' + r.length + ',期望 ' + expected);
|
||||
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('求解失败的节点:\n ' + solveFailures.join('\n ')); }
|
||||
t.eq('全部布局节点可求解、矩形数与坐标都对', solveFailures, []);
|
||||
|
||||
// 钉住节点总数:松下界(如 >40)删掉整个 View 都还能通过,任何意外的增删都要在这里可见。
|
||||
// 增删布局节点时,连同本数字一起改——改动是有意的就顺手更新,不是有意的就红给你看
|
||||
t.eq('布局节点总数', allNodes.length, 83);
|
||||
|
||||
// ---- 布局文件是纯数据:不含函数 ----
|
||||
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, []);
|
||||
|
||||
// ---- 建房选项配置:精灵键由配置给出,渲染器不按下标拼字符串 ----
|
||||
const optProblems = [];
|
||||
const usedOptSprites = {};
|
||||
function useSprite(where, key) {
|
||||
if (!key) { optProblems.push(where + ' 未声明精灵键'); return; }
|
||||
if (!index.hasOwnProperty(key)) { optProblems.push(where + ' -> 精灵键不存在: ' + key); return; }
|
||||
if (usedOptSprites[key]) { optProblems.push(where + ' -> 精灵键被重复引用: ' + key); }
|
||||
usedOptSprites[key] = true;
|
||||
}
|
||||
const bitsSeen = {};
|
||||
EQW_RoomOptions.categories.forEach((cat, ci) => {
|
||||
useSprite('类别' + ci + ' titleSprite', cat.titleSprite);
|
||||
cat.groups.forEach(g => {
|
||||
if (g.hasOwnProperty('bit')) { optProblems.push('组 ' + g.key + ' 仍把 bit 挂在组上(应统一到 item)'); }
|
||||
if (g.note) { useSprite('组 ' + g.key + ' noteSprite', g.noteSprite); }
|
||||
if (['radio', 'checkbox', 'radioOptional'].indexOf(g.type) < 0) {
|
||||
optProblems.push('组 ' + g.key + ' -> 未知 type: ' + g.type);
|
||||
}
|
||||
g.items.forEach((it, ii) => {
|
||||
const where = '组 ' + g.key + ' 第' + ii + '项';
|
||||
useSprite(where + ' box', it.box);
|
||||
useSprite(where + ' text', it.text);
|
||||
if (typeof it.bit !== 'number') { optProblems.push(where + ' 缺 bit'); }
|
||||
if (it.value !== '0' && it.value !== '1') { optProblems.push(where + ' value 非 0/1: ' + it.value); }
|
||||
bitsSeen[it.bit] = true;
|
||||
// 单选组内各项必须共用同一个 bit(同一位互斥取值)
|
||||
if (g.type !== 'checkbox' && it.bit !== g.items[0].bit) {
|
||||
optProblems.push(where + ' 与同组首项 bit 不一致');
|
||||
}
|
||||
});
|
||||
// 多选组各项必须占用互不相同的位
|
||||
if (g.type === 'checkbox') {
|
||||
const bs = g.items.map(it => it.bit);
|
||||
if (new Set(bs).size !== bs.length) { optProblems.push('组 ' + g.key + ' 多选各项 bit 重复'); }
|
||||
}
|
||||
});
|
||||
});
|
||||
// 配置声明的精灵必须恰好覆盖建房 View 里预置的全部选项精灵(多一个少一个都说明两边不同步)
|
||||
Object.keys(EQW_Sprites.CreateRoomView.CreateRoom).forEach(k => {
|
||||
if (k === 'id') { return; }
|
||||
if (!usedOptSprites[k]) { optProblems.push('精灵 ' + k + ' 没有任何配置项引用它'); }
|
||||
});
|
||||
if (optProblems.length) { console.log('建房选项配置问题:\n ' + optProblems.join('\n ')); }
|
||||
t.eq('建房选项精灵键与 bit 声明完整', optProblems, []);
|
||||
// roomtype 是 5 位串(服务端 class.config.js IDX_ASET..IDX_NOCHECK = 0..4),位号必须刚好铺满
|
||||
t.eq('roomtype 位号铺满 0-4', Object.keys(bitsSeen).map(Number).sort((a, b) => a - b), [0, 1, 2, 3, 4]);
|
||||
|
||||
// ---- 牌尺寸档引用的资源键存在 ----
|
||||
['L', 'M', 'S'].forEach(size => {
|
||||
t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true);
|
||||
});
|
||||
|
||||
// ---- 多帧图数字样式(配合 SpriteManager.setNumberImage):资源键存在、字段齐全合法 ----
|
||||
// 与 CARD_SIZE 同一约定:纯数据配置只存资源【键名】,调用方用 EQW_Images[res] 取真实 ID
|
||||
const numStyleProblems = [];
|
||||
Object.keys(EQW_Layout.NUM_STYLE).forEach(key => {
|
||||
const s = EQW_Layout.NUM_STYLE[key];
|
||||
if (!EQW_Images.hasOwnProperty(s.res)) { numStyleProblems.push(key + ' 的资源键不存在: ' + s.res); }
|
||||
// charWidth 是 setNumberImage 的必需入参(该接口对非正数直接返回 false)
|
||||
['charWidth', 'charHeight'].forEach(f => {
|
||||
if (typeof s[f] !== 'number' || s[f] <= 0) { numStyleProblems.push(key + '.' + f + ' 非正数: ' + s[f]); }
|
||||
});
|
||||
// 后缀落在资源第 16 帧,只能是 0 或 1 个字符;且必须是 setNumberImage 认识的那三个
|
||||
// (编码表 /[分倍张]/ → 'g' → 帧16;写别的字会被原样丢给引擎,按未知字符渲染)
|
||||
if (typeof s.suffix !== 'string') { numStyleProblems.push(key + '.suffix 必须是字符串: ' + s.suffix); }
|
||||
else if (s.suffix !== '' && ['分', '倍', '张'].indexOf(s.suffix) < 0) {
|
||||
numStyleProblems.push(key + '.suffix 不是 setNumberImage 支持的后缀(分/倍/张): ' + s.suffix);
|
||||
}
|
||||
});
|
||||
if (numStyleProblems.length) { console.log('数字样式问题:\n ' + numStyleProblems.join('\n ')); }
|
||||
t.eq('NUM_STYLE 资源键存在且字段合法', numStyleProblems, []);
|
||||
|
||||
// 引用 NUM_STYLE 的布局节点必须与样式同源(SSOT:数值只在 LayoutConstants 定义一处,
|
||||
// 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红)。
|
||||
// 注意 w 不在此列:setNumberImage 会按【字符数 × charWidth】自动改精灵宽,故 w 由运行时注入
|
||||
[
|
||||
['CALL_BTN_SCORE_NUM', 'CALL_SCORE'],
|
||||
['MAIN_SUIT_COUNT', 'MAIN_SUIT_COUNT'],
|
||||
['P_COUNTDOWN', 'COUNTDOWN'],
|
||||
['RESULT_SCORE_NUM', 'RESULT_WIN'],
|
||||
['RESULT_SCORE_NUM', 'RESULT_LOSE'],
|
||||
['ACC_TPL_SCORE_NUM', 'RESULT_WIN'],
|
||||
['ACC_TPL_SCORE_NUM', 'RESULT_LOSE']
|
||||
].forEach(pair => {
|
||||
const node = EQW_Layout[pair[0]];
|
||||
const st = EQW_Layout.NUM_STYLE[pair[1]];
|
||||
t.eq(pair[0] + '.h 与 NUM_STYLE.' + pair[1] + '.charHeight 同源', node.h, st.charHeight);
|
||||
t.eq(pair[0] + '.w 留到运行时注入', node.runtime.indexOf('w') >= 0 && typeof node.w === 'undefined', true);
|
||||
});
|
||||
|
||||
process.exit(t.done('constants') ? 0 : 1);
|
||||
@@ -0,0 +1,59 @@
|
||||
// 分发器:success 优先分流、未知 rpc 不崩、路由表完整性
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/FailHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
|
||||
// ---- 注册与分发 ----
|
||||
const got = [];
|
||||
EQW_Dispatcher.register('testRpc', function (d) { got.push(d); });
|
||||
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: true, v: 1 } });
|
||||
t.eq('成功包进业务 handler', got, [{ success: true, v: 1 }]);
|
||||
|
||||
// ---- success:false 优先走 FailHandler,不进业务 handler ----
|
||||
const failed = [];
|
||||
EventBus.on(EQW_Events.EQW_RPC_FAILED, function (e) { failed.push(e); });
|
||||
got.length = 0;
|
||||
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: false, errcode: 5 } });
|
||||
t.eq('失败包不进业务 handler', got, []);
|
||||
t.eq('失败包 emit RPC_FAILED', failed, [{ rpc: 'testRpc', errcode: 5 }]);
|
||||
|
||||
// ---- 失败包即使 rpc 没注册过也要被处理(如 chupai 只会以失败包出现)----
|
||||
failed.length = 0;
|
||||
EQW_Dispatcher.dispatch({ rpc: 'chupai', data: { success: false, errcode: 6 } });
|
||||
t.eq('未注册 rpc 的失败包也处理', failed, [{ rpc: 'chupai', errcode: 6 }]);
|
||||
|
||||
// ---- 未知 rpc 的成功包:只警告、不抛错 ----
|
||||
let threw = false;
|
||||
try { EQW_Dispatcher.dispatch({ rpc: 'brandNewRpc', data: { success: true } }); }
|
||||
catch (e) { threw = true; }
|
||||
t.eq('未知 rpc 不抛错', threw, false);
|
||||
|
||||
// ---- 畸形入参不崩 ----
|
||||
[null, undefined, {}, { rpc: 'testRpc' }, { data: {} }].forEach((bad, i) => {
|
||||
let boom = false;
|
||||
try { EQW_Dispatcher.dispatch(bad); } catch (e) { boom = true; }
|
||||
t.eq('畸形入参 #' + i + ' 不崩', boom, false);
|
||||
});
|
||||
|
||||
// ---- 路由表完整性:协议里每个服务端推送 rpc 都要有 handler ----
|
||||
['fapai','jiaofen','shangzhuang','xuanzhu','maipai',
|
||||
'chupai1','chupai2','chupai3','mingpai','tishi','jiesuan','zhunbei'].forEach(rpc => {
|
||||
t.eq('已注册 handler: ' + rpc, EQW_Dispatcher.hasHandler(rpc), true);
|
||||
});
|
||||
|
||||
process.exit(t.done('dispatcher') ? 0 : 1);
|
||||
@@ -0,0 +1,111 @@
|
||||
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ROOT } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
|
||||
|
||||
t.eq('三个座位', fx.seats.length, 3);
|
||||
|
||||
const rpcsOf = seat => fx.seats[seat].map(p => p.rpc);
|
||||
const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2));
|
||||
|
||||
['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan']
|
||||
.forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true));
|
||||
|
||||
// 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言)
|
||||
const withBottom = [0,1,2].filter(s =>
|
||||
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
|
||||
t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1);
|
||||
|
||||
// deskinfo 快照必须覆盖全部 5 个阶段(下游任务要用 step3 埋牌快照做回归断言)
|
||||
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort((a,b) => a - b);
|
||||
t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]);
|
||||
|
||||
// roomtype 必须存在且非空,Task 12 要用它还原房间选项
|
||||
t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true);
|
||||
|
||||
// startwar 是增量路径的真实起点(开局握手,结构=deskwar=deskinfo),必须每座位各一份,
|
||||
// 且不得混进 seats[] 数组头部——那会让全部 packetIndex 集体错位一位(见夹具补捕说明)
|
||||
t.eq('startwar 存在且为 3 份', Array.isArray(fx.startwar) && fx.startwar.length, 3);
|
||||
[0, 1, 2].forEach(s => {
|
||||
const dw = fx.startwar[s] && fx.startwar[s].deskwar;
|
||||
t.eq('startwar[' + s + '].deskwar 存在', !!dw, true);
|
||||
t.eq('startwar[' + s + '] 结构=deskinfo(带 step/PlayerInfo/MyCards)',
|
||||
dw && dw.hasOwnProperty('step') && dw.hasOwnProperty('PlayerInfo') && dw.hasOwnProperty('MyCards'), true);
|
||||
t.eq('startwar[' + s + '].PlayerInfo 为开局初值(三家均 0)', dw && dw.PlayerInfo, [0, 0, 0]);
|
||||
});
|
||||
// seats[] 的第一包必须仍是 fapai——确认 startwar 没有被误插进 seats[] 头部
|
||||
[0, 1, 2].forEach(s => {
|
||||
t.eq('seats[' + s + '][0] 仍是 fapai(startwar 未混入 seats)', fx.seats[s][0].rpc, 'fapai');
|
||||
});
|
||||
|
||||
// packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽
|
||||
fx.deskinfo.forEach(d => {
|
||||
const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length;
|
||||
t.eq('packetIndex 落在有效区间 (step' + d.step + ' seat' + d.seat + ')', inRange, true);
|
||||
t.eq('deskinfo.step 与 info.step 一致 (seat' + d.seat + ' pi' + d.packetIndex + ')', d.step, d.info.step);
|
||||
});
|
||||
|
||||
// C-1 回归锁:同一座位的两条 step5 快照,playproc(当前轮桌面牌/currseat/round)不得相同——
|
||||
// 否则说明 deskinfo 快照存的是活引用,被后续出牌篡改成了终局状态(曾经的真实缺陷)
|
||||
const step5BySeat = {};
|
||||
fx.deskinfo.filter(d => d.step === 5).forEach(d => {
|
||||
(step5BySeat[d.seat] = step5BySeat[d.seat] || []).push(d);
|
||||
});
|
||||
Object.keys(step5BySeat).forEach(seat => {
|
||||
const list = step5BySeat[seat];
|
||||
if (list.length < 2) { return; }
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
const same = JSON.stringify(list[0].info.PushCards.playproc) === JSON.stringify(list[i].info.PushCards.playproc);
|
||||
t.eq('seat' + seat + ' 两条 step5 快照 playproc 不相同(未被活引用污染)', same, false);
|
||||
}
|
||||
});
|
||||
|
||||
// I-3:推进阶段的下发包必须自带 step,且与该时刻的 deskinfo.step 属于同一套取值。
|
||||
// 前端不得按 rpc 名硬编码阶段(前端红线「数据驱动、前端无对局状态机」)。
|
||||
const STEP_OF = { fapai: 1, jiaofen: 1, shangzhuang: 2, xuanzhu: 3, maipai: 5, jiesuan: 6 };
|
||||
const noStep = [], badStep = [];
|
||||
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if (!STEP_OF.hasOwnProperty(p.rpc)) { return; }
|
||||
if (!p.data.hasOwnProperty('step')) { noStep.push(s + ':' + p.rpc); return; }
|
||||
if (p.data.step !== STEP_OF[p.rpc]) { badStep.push(s + ':' + p.rpc + '=' + p.data.step); }
|
||||
}));
|
||||
t.eq('推进阶段的下发包都带 step', noStep, []);
|
||||
t.eq('各包的 step 就是该时点的真实阶段', badStep, []);
|
||||
|
||||
// I-3:选主/埋牌阶段的「轮到谁」必须由服务端显式给出(增量 nextseat / 重连分组的 seat)
|
||||
const noNext = [];
|
||||
[0, 1, 2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if ((p.rpc === 'shangzhuang' || p.rpc === 'xuanzhu') && !p.data.hasOwnProperty('nextseat')) {
|
||||
noNext.push(s + ':' + p.rpc);
|
||||
}
|
||||
}));
|
||||
t.eq('shangzhuang/xuanzhu 都带 nextseat(控制权)', noNext, []);
|
||||
fx.deskinfo.filter(d => d.step === 2).forEach(d => {
|
||||
t.eq('重连 ChooseMain 带控制权 seat (seat' + d.seat + ')', typeof d.info.ChooseMain.seat, 'number');
|
||||
});
|
||||
fx.deskinfo.filter(d => d.step === 3).forEach(d => {
|
||||
t.eq('重连 BuryCards 带控制权 seat (seat' + d.seat + ')', typeof d.info.BuryCards.seat, 'number');
|
||||
});
|
||||
|
||||
// I-2:结算阶段的重连包必须与 jiesuan 推送给同一份结算数据(抠底明细 bottom 恒有;
|
||||
// account 只在末局,本夹具是第 1/6 局,故两条路径都没有)
|
||||
const jiesuanPk = fx.seats[0].filter(p => p.rpc === 'jiesuan')[0];
|
||||
t.eq('夹具的 jiesuan 带抠底明细 bottom', !!(jiesuanPk && jiesuanPk.data.bottom), true);
|
||||
fx.deskinfo.filter(d => d.step === 6).forEach(d => {
|
||||
t.eq('重连 Balance.bottom == jiesuan.bottom (seat' + d.seat + ')',
|
||||
JSON.stringify(d.info.Balance.bottom), JSON.stringify(jiesuanPk.data.bottom));
|
||||
t.eq('非末局:两条路径都不带 account (seat' + d.seat + ')',
|
||||
[d.info.Balance.hasOwnProperty('account'), jiesuanPk.data.hasOwnProperty('account')], [false, false]);
|
||||
});
|
||||
|
||||
// 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success)
|
||||
const noSuccess = [];
|
||||
[0,1,2].forEach(s => fx.seats[s].forEach(p => {
|
||||
if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); }
|
||||
}));
|
||||
t.eq('每个下发包都带 success', noSuccess, []);
|
||||
|
||||
process.exit(t.done('fixture') ? 0 : 1);
|
||||
@@ -0,0 +1,56 @@
|
||||
// GameState 骨架:初始值、reset 的清空范围、snapshot 深拷贝
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
|
||||
// ---- 事件常量已追加进 EventBus.Events ----
|
||||
t.eq('事件常量非空', Object.keys(EQW_Events).length > 0, true);
|
||||
t.eq('RESET 已入 EventBus.Events', EventBus.Events.EQW_RESET, EQW_Events.EQW_RESET);
|
||||
t.eq('事件名互不重复',
|
||||
Object.keys(EQW_Events).length,
|
||||
Object.keys(EQW_Events).map(k => EQW_Events[k]).filter((v, i, a) => a.indexOf(v) === i).length);
|
||||
|
||||
// ---- 初始值 ----
|
||||
t.eq('初始 mySeat', EQW_GameState.room.mySeat, -1);
|
||||
t.eq('初始 banker', EQW_GameState.aset.banker, -1);
|
||||
t.eq('初始 flower', EQW_GameState.aset.flower, 0);
|
||||
t.eq('初始手牌', EQW_GameState.my.cards, []);
|
||||
t.eq('初始三家叫分', EQW_GameState.call.calls, [null, null, null]);
|
||||
|
||||
// ---- snapshot 是深拷贝、且不含方法 ----
|
||||
EQW_GameState.my.cards = [1, 2, 3];
|
||||
const snap = EQW_GameState.snapshot();
|
||||
t.eq('snapshot 取到值', snap.my.cards, [1, 2, 3]);
|
||||
EQW_GameState.my.cards.push(4);
|
||||
t.eq('snapshot 是深拷贝(不随后续修改)', snap.my.cards, [1, 2, 3]);
|
||||
t.eq('snapshot 不含方法', typeof snap.reset, 'undefined');
|
||||
t.eq('snapshot 含全部分组',
|
||||
Object.keys(snap).sort(),
|
||||
['aset', 'call', 'my', 'result', 'room', 'table', 'turn']);
|
||||
|
||||
// ---- reset:清对局态,但 mySeat / options 不清(跨局不变)----
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
EQW_GameState.room.options = { climb: 1 };
|
||||
EQW_GameState.aset.banker = 1;
|
||||
EQW_GameState.aset.flower = 3;
|
||||
EQW_GameState.table.pushlist = [[1], [2]];
|
||||
EQW_GameState.result.aset = { grade: 5 };
|
||||
EQW_GameState.reset();
|
||||
|
||||
t.eq('reset 后 mySeat 保留', EQW_GameState.room.mySeat, 2);
|
||||
t.eq('reset 后 options 保留', EQW_GameState.room.options, { climb: 1 });
|
||||
t.eq('reset 后 banker 清空', EQW_GameState.aset.banker, -1);
|
||||
t.eq('reset 后 flower 清空', EQW_GameState.aset.flower, 0);
|
||||
t.eq('reset 后 pushlist 清空', EQW_GameState.table.pushlist, []);
|
||||
t.eq('reset 后 result 清空', EQW_GameState.result.aset, null);
|
||||
t.eq('reset 后手牌清空', EQW_GameState.my.cards, []);
|
||||
|
||||
// ---- reset 两次结果一致(幂等)----
|
||||
const afterFirst = EQW_GameState.snapshot();
|
||||
EQW_GameState.reset();
|
||||
t.eq('reset 幂等', EQW_GameState.snapshot(), afterFirst);
|
||||
|
||||
process.exit(t.done('gamestate') ? 0 : 1);
|
||||
@@ -0,0 +1,85 @@
|
||||
// 选主 / 埋牌 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= xuanzhu =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 1, flower: 3, step: 3, nextseat: 1,
|
||||
countdown: 30, cards: [5, 6, 7] });
|
||||
t.eq('主牌花色', S.aset.flower, 3);
|
||||
t.eq('庄家', S.aset.banker, 1);
|
||||
t.eq('选主后手牌', S.my.cards, [5, 6, 7]);
|
||||
t.eq('step 读包字段(埋牌=3)', S.aset.step, 3);
|
||||
t.eq('控制权读包里的 nextseat', S.turn.seat, 1);
|
||||
t.eq('倒计时', S.turn.countdown, 30);
|
||||
t.eq('选主发事件',
|
||||
['EQW_MAIN_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 闲家视角:没有 cards,不抹已有手牌
|
||||
S.reset(); S.my.cards = [1, 2];
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 3, nextseat: 0, countdown: 30 });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [1, 2]);
|
||||
t.eq('闲家也拿到花色', S.aset.flower, 2);
|
||||
|
||||
// 【守卫】阶段与控制权都必须来自包,不得按 rpc 名硬编码、也不得用本地 banker 反推。
|
||||
// 真实链路里 nextseat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `aset.step = 3` / `turn.seat = aset.banker` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, step: 4, nextseat: 2, countdown: 30 });
|
||||
t.eq('xuanzhu step 照读包字段', S.aset.step, 4);
|
||||
t.eq('xuanzhu 控制权取 nextseat,不取 banker', S.turn.seat, 2);
|
||||
|
||||
// ================= maipai =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_BuryHandler.handle({ success: true, cards: [1,2,3], burycards: [9,10,11,12,13,14,15,16],
|
||||
step: 5, seat: 1, countdown: 20 });
|
||||
t.eq('埋牌后手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('埋牌底牌', S.my.buryCards, [9,10,11,12,13,14,15,16]);
|
||||
t.eq('step 读包字段(出牌=5)', S.aset.step, 5);
|
||||
t.eq('首出者(控制权读包里的 seat)', S.turn.seat, 1);
|
||||
t.eq('埋牌发事件',
|
||||
['EQW_BURY_DONE','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 闲家视角:无 cards/burycards,但可能有 liangpai
|
||||
S.reset(); S.my.cards = [4, 5]; evts.length = 0;
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20,
|
||||
liangpai: { cards: [52, 53, 2, 15] } });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [4, 5]);
|
||||
t.eq('闲家无埋牌底牌', S.my.buryCards, []);
|
||||
t.eq('亮牌数据', S.table.liangpai, { cards: [52, 53, 2, 15] });
|
||||
|
||||
// 不达标/不查牌:无 liangpai 字段,保持 null
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20 });
|
||||
t.eq('无亮牌时保持 null', S.table.liangpai, null);
|
||||
|
||||
// 【守卫】maipai 的 step 同样只读包字段
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 4, seat: 0, countdown: 20 });
|
||||
t.eq('maipai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
|
||||
|
||||
// playproc:埋牌完成时服务端已就地开好 round-1 的进行态,恒有、原样拷贝进 table.playproc,
|
||||
// 与 chupai1/2/3.playproc 同源同结构(协议 §9),漏接会让「埋牌完成→庄家首出」这段窗口
|
||||
// 增量路径 table.playproc 停在上一阶段的 null、与重连路径不一致
|
||||
S.reset();
|
||||
EQW_BuryHandler.handle({ success: true, step: 5, seat: 0, countdown: 20, cards: [1, 2, 3],
|
||||
burycards: [9, 10, 11, 12, 13, 14, 15, 16],
|
||||
playproc: { round: 1, start: 0, currseat: 0, startcount: -1,
|
||||
startflower: -1, starttype: -1, maxseat: -1, maxcard: -1,
|
||||
cards: [null, null, null], shuai_demand: null } });
|
||||
t.eq('埋牌 playproc 原样拷贝服务端本轮进行态', S.table.playproc, {
|
||||
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
|
||||
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
|
||||
});
|
||||
|
||||
process.exit(t.done('handlers_bury') ? 0 : 1);
|
||||
@@ -0,0 +1,96 @@
|
||||
// 发牌 / 叫分 / 上庄 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= fapai =================
|
||||
S.reset(); S.room.mySeat = 1; evts.length = 0;
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 2, asetcount: 6,
|
||||
cards: [1, 2, 3], step: 1, seat: 0, countdown: 15 });
|
||||
|
||||
t.eq('局数', S.room.asetIdx, 2);
|
||||
t.eq('总局数', S.room.asetCount, 6);
|
||||
t.eq('手牌', S.my.cards, [1, 2, 3]);
|
||||
t.eq('当前叫分者', S.turn.seat, 0);
|
||||
t.eq('倒计时', S.turn.countdown, 15);
|
||||
t.eq('step 读包字段(叫分=1)', S.aset.step, 1);
|
||||
t.eq('mySeat 未被 reset 冲掉', S.room.mySeat, 1);
|
||||
t.eq('fapai 发了 RESET/HAND/TURN 事件',
|
||||
['EQW_RESET','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// fapai 必须先 reset:上一局的残留要清掉
|
||||
S.aset.banker = 2; S.table.pushlist = [[9]];
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 1, seat: 1, countdown: 15 });
|
||||
t.eq('fapai 清掉上局 banker', S.aset.banker, -1);
|
||||
t.eq('fapai 清掉上局 pushlist', S.table.pushlist, []);
|
||||
|
||||
// 【守卫】step 必须来自包,不得按 rpc 名硬编码。这里刻意给一个"服务端将来新插的阶段"值:
|
||||
// 前端应照单收下(服务端权威),而不是把它改回 1。退回 `aset.step = 1` 的写法必转红。
|
||||
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], step: 4, seat: 1, countdown: 15 });
|
||||
t.eq('fapai step 照读包字段(不按 rpc 名硬编码)', S.aset.step, 4);
|
||||
|
||||
// ================= jiaofen =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_CallHandler.handleJiaofen({ success: true, seat: 0, call: 60, currcall: 60,
|
||||
multiple: 3, step: 1, nextseat: 1, countdown: 15 });
|
||||
t.eq('记录该家叫分', S.call.calls, [60, null, null]);
|
||||
t.eq('当前叫到的分', S.call.currcall, 60);
|
||||
t.eq('基础子数', S.aset.multiple, 3);
|
||||
t.eq('jiaofen step 读包字段(仍在叫分=1)', S.aset.step, 1);
|
||||
t.eq('下一个叫分者', S.turn.seat, 1);
|
||||
t.eq('jiaofen 发事件', evts.indexOf('EQW_CALL_CHANGED') >= 0, true);
|
||||
|
||||
// 不叫(call = 0)也要记下来,与「还没叫」(null) 区分
|
||||
EQW_CallHandler.handleJiaofen({ success: true, seat: 1, call: 0, currcall: 60,
|
||||
multiple: 3, step: 1, nextseat: 2, countdown: 15 });
|
||||
t.eq('不叫记为 0,与未叫 null 区分', S.call.calls, [60, 0, null]);
|
||||
|
||||
// ================= shangzhuang =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 70, banker: 0, grade: 70,
|
||||
multiple: 2, step: 2, nextseat: 0,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], ancard3s: 1,
|
||||
cards: [1,2,3], countdown: 20, touxiang: 1, curmultiple: 3 });
|
||||
t.eq('庄家', S.aset.banker, 0);
|
||||
t.eq('庄家叫分', S.aset.call, 70);
|
||||
t.eq('基础子数', S.aset.multiple, 2);
|
||||
t.eq('允许投降', S.aset.touxiang, 1);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 3);
|
||||
t.eq('开底标志', S.table.ancard3s, 1);
|
||||
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
|
||||
t.eq('摸底后手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('step 读包字段(选主=2)', S.aset.step, 2);
|
||||
t.eq('控制权读包里的 nextseat', S.turn.seat, 0);
|
||||
t.eq('shangzhuang 发事件',
|
||||
['EQW_BANKER_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
|
||||
|
||||
// 【守卫】控制权必须来自包里的 nextseat,不得用本地 banker 反推。
|
||||
// 真实链路里 nextseat 恒 == banker,两者相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `turn.seat = EQW_GameState.aset.banker` 的写法必转红。
|
||||
// 同理 data.seat(=最后一个叫分者)也不是控制权,这里也给成第三个值。
|
||||
S.reset();
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 1, call: 55, banker: 0, grade: 55,
|
||||
multiple: 4, step: 2, nextseat: 2,
|
||||
countdown: 20, touxiang: 0, curmultiple: 3 });
|
||||
t.eq('控制权取 nextseat,不取 banker', S.turn.seat, 2);
|
||||
t.eq('控制权不取 data.seat(最后一个叫分者)', S.turn.seat !== 1, true);
|
||||
|
||||
// 闲家视角:没有 cards / bottomcards(非 70 分),缺字段不能把已有值抹掉
|
||||
S.reset(); S.my.cards = [7, 8, 9];
|
||||
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 55, banker: 0, grade: 55,
|
||||
multiple: 4, step: 2, nextseat: 0,
|
||||
countdown: 20, touxiang: 0, curmultiple: 3 });
|
||||
t.eq('闲家手牌不被抹', S.my.cards, [7, 8, 9]);
|
||||
t.eq('闲家无底牌', S.my.bottomCards, []);
|
||||
t.eq('非 70 分无开底标志', S.table.ancard3s, 0);
|
||||
|
||||
process.exit(t.done('handlers_call') ? 0 : 1);
|
||||
@@ -0,0 +1,107 @@
|
||||
// 出牌 handler(chupai1/2/3)
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= chupai1(首家出牌,自己出的)=================
|
||||
S.reset(); S.room.mySeat = 0; S.my.cards = [1,2,3,4]; evts.length = 0;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [1,2], count: 2, flower: 3,
|
||||
cardtype: 201, nextseat: 1, countdown: 15, baozhu: 0,
|
||||
cardsinhand: [3,4], curmultiple: 2,
|
||||
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]] }, 1);
|
||||
|
||||
t.eq('出牌者手牌被更新', S.my.cards, [3,4]);
|
||||
t.eq('下一个出牌者', S.turn.seat, 1);
|
||||
t.eq('倒计时', S.turn.countdown, 15);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 2);
|
||||
t.eq('牌况表', S.table.seatlist.length, 3);
|
||||
t.eq('出牌发事件', evts.indexOf('EQW_CARD_PLAYED') >= 0, true);
|
||||
|
||||
// ================= chupai2(别人出牌)=================
|
||||
// 别人出牌时没有 cardsinhand,不能抹掉自己的手牌
|
||||
S.reset(); S.room.mySeat = 0; S.my.cards = [3,4]; evts.length = 0;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
|
||||
countdown: 15, baozhu: 0 }, 2);
|
||||
t.eq('别人出牌不抹自己手牌', S.my.cards, [3,4]);
|
||||
t.eq('控制权推进', S.turn.seat, 2);
|
||||
|
||||
// mustcard 只发给下一个出牌者
|
||||
S.reset(); S.room.mySeat = 2;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
|
||||
countdown: 15, mustcard: [7,8] }, 2);
|
||||
t.eq('必出牌', S.my.mustCard, [7,8]);
|
||||
|
||||
// 没有 mustcard 时要清空——否则上一轮的建议会残留
|
||||
S.reset(); S.my.mustCard = [7,8];
|
||||
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 0, countdown: 15 }, 2);
|
||||
t.eq('无 mustcard 时清空', S.my.mustCard, []);
|
||||
|
||||
// ================= chupai3(末家出牌,本轮结束)=================
|
||||
// 真实的 chupai3 也带 nextseat(下一轮首家):服务端在 switch 前统一设置,
|
||||
// 只有整轮结束进入结算时才会整体换成 jiesuan rpc(不会以 chupai3 到达 handler)
|
||||
// 本墩结果(谁最大 / 这墩得几分)是一次性表现,走 EQW_TRICK_END 事件载荷,【不进 GameState】——
|
||||
// 任何 deskinfo 都不下发它,存进来就是一处服务端不知道的私有对局态(出牌中途重连即归零)
|
||||
S.reset(); evts.length = 0;
|
||||
let lastTrick = null;
|
||||
EventBus.on(EQW_Events.EQW_TRICK_END, function (p) { lastTrick = p; });
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 0, grade: 25,
|
||||
nextseat: 0, countdown: 15, baozhu: 1, curmultiple: -2 }, 3);
|
||||
t.eq('本墩结果走事件载荷', lastTrick, { seat: 2, cards: [9,10], maxseat: 0, grade: 25 });
|
||||
t.eq('本墩结果不进 GameState', S.result.hasOwnProperty('chupai'), false);
|
||||
t.eq('累计捡分', S.aset.grade, 25);
|
||||
t.eq('报无主标志', S.aset.baozhu, 1);
|
||||
t.eq('抓分倍数(带符号)', S.aset.curmultiple, -2);
|
||||
t.eq('下一轮首家', S.turn.seat, 0);
|
||||
t.eq('本轮结束发事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
|
||||
|
||||
// 本轮闲家没得分时不带 grade,累计分不该被抹;事件载荷也不得凭空补 grade(缺字段不兜底)
|
||||
S.reset(); S.aset.grade = 40; lastTrick = null;
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 1,
|
||||
nextseat: 1, countdown: 15 }, 3);
|
||||
t.eq('无 grade 时累计分保持', S.aset.grade, 40);
|
||||
t.eq('无 grade 时事件载荷不带该键', lastTrick.hasOwnProperty('grade'), false);
|
||||
t.eq('无 grade 时仍推进控制权', S.turn.seat, 1);
|
||||
|
||||
// grade 是「本轮」得分,要跨包累加进小局捡分,不是赋值——
|
||||
// 若实现改成 aset.grade = data.grade,这条会把 25 覆盖成 15 而非累加到 25
|
||||
S.reset();
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [1,2], maxseat: 0, grade: 10,
|
||||
nextseat: 0, countdown: 15 }, 3);
|
||||
t.eq('第一轮捡分', S.aset.grade, 10);
|
||||
t.eq('第一轮下一轮首家', S.turn.seat, 0);
|
||||
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [3,4], maxseat: 1, grade: 15,
|
||||
nextseat: 1, countdown: 15 }, 3);
|
||||
t.eq('第二轮跨包累加捡分', S.aset.grade, 25);
|
||||
t.eq('第二轮下一轮首家(证明第二个包确被处理)', S.turn.seat, 1);
|
||||
|
||||
// ================= 甩错 =================
|
||||
// shuaicuo 是「这一手」的一次性信息,走 EQW_CARD_PLAYED 事件载荷,不进 GameState;
|
||||
// playproc 是服务端下发的本轮进行态,原样拷贝,两者结构不同、不得混写(见 PlayHandler 顶部注释)。
|
||||
S.reset(); evts.length = 0;
|
||||
var lastPlayed = null;
|
||||
EventBus.on(EQW_Events.EQW_CARD_PLAYED, function (p) { lastPlayed = p; });
|
||||
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [11], shuaicuo: 1, count: 1,
|
||||
flower: 3, cardtype: 101, nextseat: 1, countdown: 15,
|
||||
playproc: { round: 2, start: 0, currseat: 1, cards: [[11], null, null] } }, 1);
|
||||
t.eq('甩错标志走事件载荷', lastPlayed.shuaicuo, 1);
|
||||
t.eq('本手信息走事件载荷', [lastPlayed.order, lastPlayed.seat, lastPlayed.cards,
|
||||
lastPlayed.count, lastPlayed.flower, lastPlayed.cardtype],
|
||||
[1, 0, [11], 1, 3, 101]);
|
||||
t.eq('本手信息不进 GameState', S.table.playproc.hasOwnProperty('shuaicuo'), false);
|
||||
t.eq('playproc 原样拷贝服务端本轮进行态', S.table.playproc,
|
||||
{ round: 2, start: 0, currseat: 1, cards: [[11], null, null] });
|
||||
|
||||
// ================= 缺必需字段 =================
|
||||
S.reset();
|
||||
EQW_PlayHandler.handle({ success: true, cards: [1] }, 1); //缺 seat
|
||||
t.eq('缺 seat 时跳过该包', S.turn.seat, -1);
|
||||
|
||||
process.exit(t.done('handlers_play') ? 0 : 1);
|
||||
@@ -0,0 +1,47 @@
|
||||
// 明牌 / 提示 / 准备 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const payloads = [];
|
||||
EventBus.on(EQW_Events.EQW_TIP, d => payloads.push(d));
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= mingpai =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_QueryHandler.handleMingpai({ success: true, seat: 1,
|
||||
others: [{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }] });
|
||||
t.eq('明牌数据入状态', S.table.mingpai,
|
||||
[{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }]);
|
||||
t.eq('明牌发事件', evts.indexOf('EQW_MINGPAI') >= 0, true);
|
||||
|
||||
// ================= tishi =================
|
||||
// 提示是一次性的,不进 GameState,只随事件带出去
|
||||
S.reset(); payloads.length = 0; evts.length = 0;
|
||||
const before = S.snapshot(); // 处理前的状态快照
|
||||
EQW_QueryHandler.handleTishi({ success: true, seat: 2, tip: 1 });
|
||||
t.eq('提示发事件', evts.indexOf('EQW_TIP') >= 0, true);
|
||||
t.eq('提示内容随事件带出', payloads, [{ seat: 2, tip: 1 }]);
|
||||
t.eq('提示未改动 GameState 任何字段', S.snapshot(), before);
|
||||
|
||||
// ================= zhunbei =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ReadyHandler.handle({ success: true, seat: 1 });
|
||||
t.eq('准备发事件', evts.indexOf('EQW_READY_CHANGED') >= 0, true);
|
||||
|
||||
// ================= 缺必需字段 =================
|
||||
S.reset();
|
||||
EQW_QueryHandler.handleMingpai({ success: true }); //缺 others
|
||||
t.eq('明牌缺 others 时不写', S.table.mingpai, null);
|
||||
payloads.length = 0;
|
||||
EQW_QueryHandler.handleTishi({ success: true, seat: 0 }); //缺 tip
|
||||
t.eq('提示缺 tip 时不发事件', payloads, []);
|
||||
|
||||
process.exit(t.done('handlers_query') ? 0 : 1);
|
||||
@@ -0,0 +1,85 @@
|
||||
// 结算 / 解散 handler
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
let lastTrick = null;
|
||||
EventBus.on(EQW_Events.EQW_TRICK_END, p => { lastTrick = p; });
|
||||
|
||||
const ASET = { banker: 0, call: 60, multiple: 3, flower: 3, grade: 45, upgrade: 1,
|
||||
bangwang: 0, climb: 0,
|
||||
seatlist: [{ grade: 6, score: 6 }, { grade: -3, score: -3 }, { grade: -3, score: -3 }] };
|
||||
|
||||
// ================= 正常出牌结算 =================
|
||||
S.reset(); evts.length = 0; lastTrick = null;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6,
|
||||
chupai: { seat: 2, cards: [9], maxseat: 0, grade: 10 },
|
||||
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
|
||||
aset: ASET });
|
||||
|
||||
t.eq('小局结算入状态', S.result.aset, ASET);
|
||||
t.eq('抠底入状态', S.result.bottom.grade2, 40);
|
||||
// 收尾墩(服务端不发 chupai3,整包换成 jiesuan)走事件载荷,与 chupai3 同一对事件、同一份字段;
|
||||
// 【不进 GameState】——deskinfo step6 的 Balance 只有 aset/bottom/account,多存就是私有对局态
|
||||
t.eq('收尾墩走事件载荷', lastTrick, { seat: 2, cards: [9], maxseat: 0, grade: 10 });
|
||||
t.eq('收尾墩也发本墩结束事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
|
||||
t.eq('收尾墩不进 GameState', S.result.hasOwnProperty('chupai'), false);
|
||||
t.eq('step 读包字段(结算=6)', S.aset.step, 6);
|
||||
t.eq('结算发事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
|
||||
t.eq('非末局不发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
|
||||
|
||||
// ================= 投降结算(无 chupai / 无 bottom)=================
|
||||
S.reset(); evts.length = 0; lastTrick = null;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: { banker: 0, call: 70, multiple: 1,
|
||||
upgrade: -99, seatlist: [] } });
|
||||
t.eq('投降结算入状态', S.result.aset.upgrade, -99);
|
||||
t.eq('投降无 chupai → 不发本墩结束事件', lastTrick, null);
|
||||
t.eq('投降无 bottom', S.result.bottom, null);
|
||||
t.eq('投降也发结算事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
|
||||
|
||||
// ================= 末局:带 account =================
|
||||
const ACCOUNT = [{ score: 12, grades: [6,6], grade_jf_total: 8, grade_cg_total: 4, grade_bw_total: 0 },
|
||||
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 },
|
||||
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 }];
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 6, aset: ASET, account: ACCOUNT });
|
||||
t.eq('大局结算入状态', S.result.account, ACCOUNT);
|
||||
t.eq('末局发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
|
||||
|
||||
// ================= 解散:走 Free 入口,取值少一层 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResultHandler.handleFree({ rpc: 'jiesuan',
|
||||
data: { success: true, step: 6, aset: { banker: -1, call: -1, multiple: 0, upgrade: 0, seatlist: [] },
|
||||
account: ACCOUNT } });
|
||||
t.eq('解散取到 aset', S.result.aset.multiple, 0);
|
||||
t.eq('解散取到 account', S.result.account, ACCOUNT);
|
||||
t.eq('解散发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
|
||||
t.eq('解散包的 step 也读包字段', S.aset.step, 6);
|
||||
|
||||
// 【守卫】结算 step 只读包字段,不按 rpc 名硬编码为 6。
|
||||
// 退回 `aset.step = 6` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_ResultHandler.handleJiesuan({ success: true, step: 4, aset: ASET });
|
||||
t.eq('jiesuan step 照读包字段', S.aset.step, 4);
|
||||
|
||||
// ================= 解散:deskfree 为 null(首局发牌前解散)=================
|
||||
S.reset(); evts.length = 0;
|
||||
let boom = false;
|
||||
try { EQW_ResultHandler.handleFree(null); } catch (e) { boom = true; }
|
||||
t.eq('deskfree 为 null 不崩', boom, false);
|
||||
t.eq('deskfree 为 null 不写状态', S.result.aset, null);
|
||||
t.eq('deskfree 为 null 不发结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
|
||||
|
||||
// deskfree 存在但 data 缺失也要容忍
|
||||
boom = false;
|
||||
try { EQW_ResultHandler.handleFree({ rpc: 'jiesuan' }); } catch (e) { boom = true; }
|
||||
t.eq('deskfree.data 缺失不崩', boom, false);
|
||||
|
||||
process.exit(t.done('handlers_result') ? 0 : 1);
|
||||
@@ -0,0 +1,72 @@
|
||||
// SubGameHooks 接线:平台入口 → 新架构
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
global.window = global; // 转发壳用 window.SubGameHooks 判定
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/gameabc-framework/system/SpriteEventController.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
|
||||
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
|
||||
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
|
||||
load('client/js/01_SubGame/codes/net/Dispatcher.js');
|
||||
load('client/js/01_SubGame/codes/SubGameHooks.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
|
||||
// ---- _ReceiveData:完整包 → 分发 ----
|
||||
S.reset(); S.room.mySeat = 0;
|
||||
SubGameHooks._ReceiveData({ app: 'youle', route: 'erqiwang', rpc: 'fapai',
|
||||
data: { success: true, asetidx: 1, asetcount: 6,
|
||||
cards: [1,2], seat: 0, countdown: 15 } });
|
||||
t.eq('_ReceiveData 分发到 handler', S.my.cards, [1,2]);
|
||||
|
||||
// ---- setRoomDes ----
|
||||
SubGameHooks.setRoomDes(123, 12, '00010');
|
||||
t.eq('setRoomDes 解析选项', S.room.options.climb, 1);
|
||||
|
||||
// ---- Free:null 不崩 ----
|
||||
let boom = false;
|
||||
try { SubGameHooks.Free(null); } catch (e) { boom = true; }
|
||||
t.eq('Free(null) 不崩', boom, false);
|
||||
|
||||
// ---- Reconnect ----
|
||||
S.reset(); S.room.mySeat = 1;
|
||||
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
|
||||
t.eq('Reconnect 填状态', S.my.cards, [5,6]);
|
||||
t.eq('Reconnect 填局数', S.room.asetIdx, 3);
|
||||
|
||||
// ---- DeskInfo(中途进桌)----
|
||||
// 平台 07_Desk.js 在「牌局已开始 + 走加入房间而非重连」时调 Game_Modify.DeskInfo(_msg.data.deskinfo)。
|
||||
// 入参结构与 Reconnect 完全一致(服务端两条路径都是 get_deskinfo(o_room, 本人座位)),
|
||||
// 故必须走同一条重画路径;不接线的话整份 deskinfo 被转发壳静默丢弃、界面空白且不报错。
|
||||
const MIDJOIN = { count: 6, idx: 2, PlayerInfo: [3,-1,-2], step: 5, MyCards: [10,11],
|
||||
PushCards: { banker: 1, call: 60, multiple: 3, flower: 2, grade: 25,
|
||||
curmultiple: -1, baozhu: 0, countdown: 30,
|
||||
playproc: { round: 4, start: 1, currseat: 2, cards: [null,[12],null] } } };
|
||||
S.reset(); S.room.mySeat = 2;
|
||||
SubGameHooks.DeskInfo(MIDJOIN);
|
||||
t.eq('DeskInfo 已接线(不再静默丢包)', S.my.cards, [10,11]);
|
||||
t.eq('DeskInfo 复用重连的重画路径(阶段)', S.aset.step, 5);
|
||||
t.eq('DeskInfo 复用重连的重画路径(控制权读 playproc.currseat)', S.turn.seat, 2);
|
||||
t.eq('DeskInfo 与 Reconnect 落地结果逐字段相同',
|
||||
(function () { const a = JSON.stringify(EQW_GameState.snapshot());
|
||||
S.reset(); S.room.mySeat = 2; SubGameHooks.Reconnect(MIDJOIN);
|
||||
return a === JSON.stringify(EQW_GameState.snapshot()); })(), true);
|
||||
|
||||
// ---- 畸形入参一律不崩 ----
|
||||
boom = false;
|
||||
try {
|
||||
SubGameHooks._ReceiveData(null);
|
||||
SubGameHooks._ReceiveData({});
|
||||
SubGameHooks.StartWar(null);
|
||||
SubGameHooks.Reconnect(null);
|
||||
SubGameHooks.DeskInfo(null);
|
||||
SubGameHooks.setRoomDes();
|
||||
} catch (e) { boom = true; }
|
||||
t.eq('畸形入参不崩', boom, false);
|
||||
|
||||
process.exit(t.done('hooks') ? 0 : 1);
|
||||
@@ -0,0 +1,109 @@
|
||||
// index.html 加载顺序自检(task-13 步骤6守卫):
|
||||
// 按 index.html 里 <script src> 的实际先后顺序,抽取「codes/ 全部文件 + 它们依赖的
|
||||
// 3 个框架文件(EventBus/SpriteEventController/AlignmentUtils)」,依次用 vm 加载,
|
||||
// 确认:1) 无异常抛出(顺序错了会因引用未定义全局而报错)
|
||||
// 2) 关键全局全部就绪
|
||||
// 3) EQW_Dispatcher 的路由表确已注册(hasHandler('chupai1') === true)
|
||||
// 这份顺序即是 index.html 的真实加载顺序,不是我们另行假设的一份。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const t = require('./_assert')();
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const indexHtml = fs.readFileSync(path.join(ROOT, 'client', 'index.html'), 'utf8');
|
||||
|
||||
// 抽取全部本地 <script src="..."> 路径,按文档出现顺序
|
||||
const scriptRe = /<script[^>]*\ssrc="([^"]+)"[^>]*><\/script>/g;
|
||||
const allSrcs = [];
|
||||
let m;
|
||||
while ((m = scriptRe.exec(indexHtml)) !== null) {
|
||||
allSrcs.push(m[1]);
|
||||
}
|
||||
t.eq('index.html 至少解析出脚本标签', allSrcs.length > 0, true);
|
||||
|
||||
// 只保留:codes/ 下全部文件 + 3 个框架依赖文件,其余(vendor/引擎/大厅平台代码等)
|
||||
// 无法在 Node 里单独跑,本自检不覆盖,留给「浏览器人工验证」。
|
||||
const NEEDED_FRAMEWORK = [
|
||||
'js/gameabc-framework/system/EventBus.js',
|
||||
'js/gameabc-framework/system/SpriteEventController.js',
|
||||
'js/gameabc-framework/ui/AlignmentUtils.js'
|
||||
];
|
||||
const relevant = allSrcs.filter(function (src) {
|
||||
return NEEDED_FRAMEWORK.indexOf(src) !== -1 || src.indexOf('js/01_SubGame/codes/') === 0;
|
||||
});
|
||||
|
||||
// 期望齐全的清单:本任务新增/相关的 state/ net/ 文件,一个都不能漏
|
||||
const EXPECT_PRESENT = [
|
||||
'js/01_SubGame/codes/state/Events.js',
|
||||
'js/01_SubGame/codes/state/GameState.js',
|
||||
'js/01_SubGame/codes/state/RoomOptions.js',
|
||||
'js/01_SubGame/codes/net/handlers/FailHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/DealHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/CallHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/MainHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResyncHandler.js',
|
||||
'js/01_SubGame/codes/net/Dispatcher.js',
|
||||
'js/01_SubGame/codes/net/Rpc.js',
|
||||
'js/01_SubGame/codes/SubGameHooks.js'
|
||||
];
|
||||
EXPECT_PRESENT.forEach(function (src) {
|
||||
t.eq('index.html 含脚本 ' + src, allSrcs.indexOf(src) !== -1, true);
|
||||
});
|
||||
|
||||
// 相对顺序断言:handler 全部排在 Dispatcher 之前;state/ 排在 net/handlers/ 之前
|
||||
const idx = {};
|
||||
relevant.forEach(function (src, i) { idx[src] = i; });
|
||||
var dispatcherIdx = idx['js/01_SubGame/codes/net/Dispatcher.js'];
|
||||
[
|
||||
'js/01_SubGame/codes/net/handlers/FailHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/DealHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/CallHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/MainHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/BuryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/PlayHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/QueryHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ReadyHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResultHandler.js',
|
||||
'js/01_SubGame/codes/net/handlers/ResyncHandler.js'
|
||||
].forEach(function (h) {
|
||||
t.eq('handler 排在 Dispatcher 之前: ' + h, idx[h] < dispatcherIdx, true);
|
||||
});
|
||||
t.eq('state/ 排在 net/handlers/ 之前', idx['js/01_SubGame/codes/state/GameState.js'] < idx['js/01_SubGame/codes/net/handlers/FailHandler.js'], true);
|
||||
t.eq('SubGameHooks 排在 Dispatcher 之后', idx['js/01_SubGame/codes/SubGameHooks.js'] > dispatcherIdx, true);
|
||||
|
||||
// 依次实际加载,确认不抛异常,且顺序自洽
|
||||
global.window = global;
|
||||
let loadErr = null;
|
||||
try {
|
||||
relevant.forEach(function (src) {
|
||||
const abs = path.join(ROOT, 'client', src);
|
||||
const code = fs.readFileSync(abs, 'utf8');
|
||||
vm.runInThisContext(code, { filename: src });
|
||||
});
|
||||
} catch (e) {
|
||||
loadErr = e;
|
||||
}
|
||||
t.eq('按 index.html 顺序加载无异常', loadErr ? (loadErr.stack || String(loadErr)) : null, null);
|
||||
|
||||
// 关键全局就绪
|
||||
[
|
||||
'EQW_Events', 'EQW_GameState', 'EQW_RoomOptions_Parse', 'EQW_Dispatcher', 'EQW_Rpc',
|
||||
'EQW_DealHandler', 'EQW_CallHandler', 'EQW_MainHandler', 'EQW_BuryHandler', 'EQW_PlayHandler',
|
||||
'EQW_QueryHandler', 'EQW_ReadyHandler', 'EQW_ResultHandler', 'EQW_ResyncHandler', 'EQW_FailHandler',
|
||||
'SubGameHooks'
|
||||
].forEach(function (name) {
|
||||
t.eq('全局就绪: ' + name, typeof global[name] !== 'undefined', true);
|
||||
});
|
||||
|
||||
// 路由表确已注册
|
||||
if (typeof global.EQW_Dispatcher !== 'undefined') {
|
||||
t.eq("EQW_Dispatcher.hasHandler('chupai1')", global.EQW_Dispatcher.hasHandler('chupai1'), true);
|
||||
}
|
||||
|
||||
process.exit(t.done('index_load_order') ? 0 : 1);
|
||||
@@ -0,0 +1,341 @@
|
||||
// 五型布局求解器(清单 §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);
|
||||
|
||||
// 边界:count 恰好等于容量(cols*rows=14)时正常返回,不抛错
|
||||
t.eq('grid 恰好满格不抛错', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
|
||||
|
||||
// count 超出容量:显式抛错,不静默截断多出的项
|
||||
t.eq('grid 超载抛错', throws(() => EQW_LayoutSolver.solve(grid, { count: 15 })), true);
|
||||
|
||||
// fillOrder 校验:目前只支持行优先(YAGNI,未实现列优先),不写等同 row
|
||||
const gridNoFillOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
|
||||
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center' };
|
||||
t.eq('grid 不写 fillOrder 正常', EQW_LayoutSolver.solve(gridNoFillOrder, { count: 14 }).length, 14);
|
||||
t.eq('grid fillOrder=row 正常', EQW_LayoutSolver.solve(grid, { count: 14 }).length, 14);
|
||||
const gridColumnOrder = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
|
||||
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24, anchor: 'center', fillOrder: 'column' };
|
||||
t.eq('grid fillOrder=column 抛错', throws(() => EQW_LayoutSolver.solve(gridColumnOrder, { count: 14 })), true);
|
||||
|
||||
// ============ 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 贴外侧角 ============
|
||||
// 注意:AlignmentUtils.alignCornerTopRight(client/js/gameabc-framework/ui/AlignmentUtils.js:292-298)
|
||||
// 的真实语义是【精灵的右上角与目标的右上角重合】,即
|
||||
// x = target.x + target.width - sprite.width + offsetX
|
||||
// y = target.y + offsetY
|
||||
// 这与「贴在目标外侧右上角」(x = target.x + target.width + offsetX, y = target.y - sprite.height + offsetY)
|
||||
// 不是一回事——它是角对角重合(精灵会与目标重叠在右上角内侧),不是贴到外侧。
|
||||
// 期望值以框架实际实现为准:x = 100 + 80 - 20 + 4 = 164,y = 200 + 6 = 206。
|
||||
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 - 20 + 4, y: 200 + 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);
|
||||
});
|
||||
|
||||
// 四角精确坐标核对:target={x:100,y:200,width:80,height:40},sprite={w:20,h:10},offset 缺省=0
|
||||
// topLeft(AlignmentUtils.js:266-272):x=target.x=100,y=target.y=200
|
||||
const cTopLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft', w: 20, h: 10 }, { rects: rects });
|
||||
t.eq('attach 角 topLeft 精确坐标', cTopLeft, [{ x: 100, y: 200, width: 20, height: 10 }]);
|
||||
|
||||
// topRight(AlignmentUtils.js:292-298):x=target.x+target.width-sprite.width=100+80-20=160,y=target.y=200
|
||||
const cTopRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topRight', w: 20, h: 10 }, { rects: rects });
|
||||
t.eq('attach 角 topRight 精确坐标', cTopRight, [{ x: 160, y: 200, width: 20, height: 10 }]);
|
||||
|
||||
// bottomLeft(AlignmentUtils.js:318-324):x=target.x=100,y=target.y+target.height-sprite.height=200+40-10=230
|
||||
const cBottomLeft = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomLeft', w: 20, h: 10 }, { rects: rects });
|
||||
t.eq('attach 角 bottomLeft 精确坐标', cBottomLeft, [{ x: 100, y: 230, width: 20, height: 10 }]);
|
||||
|
||||
// bottomRight(AlignmentUtils.js:344-350):x=target.x+target.width-sprite.width=100+80-20=160,y=同 bottomLeft=230
|
||||
const cBottomRight = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20, h: 10 }, { rects: rects });
|
||||
t.eq('attach 角 bottomRight 精确坐标', cBottomRight, [{ x: 160, y: 230, width: 20, height: 10 }]);
|
||||
|
||||
// ============ attach:按实际对齐模式校验 w/h(缺失且用得到时抛错,用不到时不强制) ============
|
||||
// hAlign=right 用到 sprite.width(AlignmentUtils.js:111),缺 w 抛错;hAlign=left(101行)不用,缺 w 不抛错
|
||||
t.eq('attach hAlign=right 缺 w 抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects })), true);
|
||||
t.eq('attach hAlign=left 缺 w 不抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', h: 10 }, { rects: rects })), false);
|
||||
|
||||
// vAlign=middle 用到 sprite.height(127行),缺 h 抛错;vAlign=top(122行)不用,缺 h 不抛错
|
||||
t.eq('attach vAlign=middle 缺 h 抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 }, { rects: rects })), true);
|
||||
t.eq('attach vAlign=top 缺 h 不抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top', w: 20 }, { rects: rects })), false);
|
||||
|
||||
// vAlign=bottom(132行)用的是 target.height,不是 sprite.height,缺 h 不应抛错——这条容易想当然写错
|
||||
t.eq('attach vAlign=bottom 缺 h 不抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'bottom', w: 20 }, { rects: rects })), false);
|
||||
|
||||
// corner=topLeft(266-272行)不用 w/h,都缺不抛错;corner=bottomRight(344-350行)两者都用,缺一即抛错
|
||||
t.eq('attach corner=topLeft 缺 w/h 不抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'topLeft' }, { rects: rects })), false);
|
||||
t.eq('attach corner=bottomRight 缺 w 抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', h: 10 }, { rects: rects })), true);
|
||||
t.eq('attach corner=bottomRight 缺 h 抛错',
|
||||
throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'bottomRight', w: 20 }, { rects: rects })), true);
|
||||
|
||||
// ============ attach w/h 运行时注入(ctx.w/ctx.h,供动态文字标签/牌角标等尺寸未知的模板节点用)============
|
||||
// 配置无 w、hAlign=right(用得到 sprite.width)、靠 ctx.w 补上 → 正常求解,返回 width 就是 ctx.w
|
||||
const wFromCtx = EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 },
|
||||
{ rects: rects, w: 42 }
|
||||
);
|
||||
t.eq('ctx.w 补全缺失的 w,正常求解', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects, w: 42 }
|
||||
)), false);
|
||||
t.eq('返回的 width 等于 ctx.w', wFromCtx[0].width, 42);
|
||||
|
||||
// 配置有 w,同时给 ctx.w → ctx.w 优先覆盖配置值
|
||||
const wOverride = EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', w: 20, h: 10 },
|
||||
{ rects: rects, w: 99 }
|
||||
);
|
||||
t.eq('ctx.w 覆盖配置里的 w', wOverride[0].width, 99);
|
||||
|
||||
// 配置无 h、vAlign=middle(用得到 sprite.height)、靠 ctx.h 补上 → 正常求解,返回 height 就是 ctx.h
|
||||
const hFromCtx = EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'middle', w: 20 },
|
||||
{ rects: rects, h: 33 }
|
||||
);
|
||||
t.eq('返回的 height 等于 ctx.h', hFromCtx[0].height, 33);
|
||||
|
||||
// 配置无 w、hAlign=right、ctx.w 也没给 → 仍然抛错(不能因为加了通道就失效)
|
||||
t.eq('两处都没给 w 仍抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top', h: 10 }, { rects: rects }
|
||||
)), true);
|
||||
|
||||
// ctx.w/ctx.h 对不需要该维度的对齐模式(hAlign=left)不产生影响,仍正常求解
|
||||
t.eq('hAlign=left 时给不给 ctx.w 都不影响', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'attach', target: 'TARGET', hAlign: 'left', vAlign: 'top' }, { rects: rects, w: 7 }
|
||||
)), false);
|
||||
|
||||
// ============ attach target 运行时注入(ctx.target,供叫分档位/花色图标/牌角标等模板节点用)============
|
||||
const rects2 = { TARGET: { x: 100, y: 200, width: 80, height: 40 }, OTHER: { x: 5, y: 6, width: 10, height: 10 } };
|
||||
|
||||
// ctx.target 覆盖配置里写死的 target
|
||||
const attachOverride = { kind: 'attach', target: 'TARGET', corner: 'topLeft' };
|
||||
const aOverride = EQW_LayoutSolver.solve(attachOverride, { rects: rects2, target: 'OTHER' });
|
||||
t.eq('ctx.target 覆盖配置 target', { x: aOverride[0].x, y: aOverride[0].y }, { x: 5, y: 6 });
|
||||
|
||||
// 配置无 target(模板节点)时,ctx.target 生效
|
||||
const attachTemplate = { kind: 'attach', corner: 'topLeft' };
|
||||
const aFromCtxOnly = EQW_LayoutSolver.solve(attachTemplate, { rects: rects, target: 'TARGET' });
|
||||
t.eq('配置无 target 时 ctx.target 生效', { x: aFromCtxOnly[0].x, y: aFromCtxOnly[0].y }, { x: 100, y: 200 });
|
||||
|
||||
// 配置与 ctx 都没给 target:抛错
|
||||
t.eq('target 与 ctx.target 都缺失抛错',
|
||||
throws(() => EQW_LayoutSolver.solve(attachTemplate, { rects: rects })), 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);
|
||||
|
||||
// ============ 方向 × 对齐基准:写错方向的 anchor 必须抛错,不被框架 default 静默吞掉 ============
|
||||
// AlignmentUtils.distributeVertically 只认 top/bottom/center,'left' 会落到 default(居中)——
|
||||
// 3 项 32 高 8 间距时算出 y=[-56,-16,24],前两行跑到画布外,界面上却看不出是配置写错了
|
||||
const vLine = (anchor) => ({ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
|
||||
const hLine = (anchor) => ({ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 200, itemHeight: 32, spacing: 8, anchor: anchor });
|
||||
t.eq('竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(vLine('left'), { count: 3 })), true);
|
||||
t.eq('竖排 anchor=right 抛错', throws(() => EQW_LayoutSolver.solve(vLine('right'), { count: 3 })), true);
|
||||
t.eq('竖排 anchor=top 正常', throws(() => EQW_LayoutSolver.solve(vLine('top'), { count: 3 })), false);
|
||||
t.eq('竖排 anchor=bottom 正常', throws(() => EQW_LayoutSolver.solve(vLine('bottom'), { count: 3 })), false);
|
||||
t.eq('竖排 anchor=center 正常', throws(() => EQW_LayoutSolver.solve(vLine('center'), { count: 3 })), false);
|
||||
t.eq('竖排 不写 anchor 正常(框架默认 center)', throws(() => EQW_LayoutSolver.solve(vLine(undefined), { count: 3 })), false);
|
||||
t.eq('横排 anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(hLine('top'), { count: 3 })), true);
|
||||
t.eq('横排 anchor=bottom 抛错', throws(() => EQW_LayoutSolver.solve(hLine('bottom'), { count: 3 })), true);
|
||||
t.eq('横排 anchor=left 正常', throws(() => EQW_LayoutSolver.solve(hLine('left'), { count: 3 })), false);
|
||||
t.eq('横排 anchor=right 正常', throws(() => EQW_LayoutSolver.solve(hLine('right'), { count: 3 })), false);
|
||||
t.eq('未知 direction 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'line', direction: 'diagonal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
|
||||
// fan 走同一条 distribute,校验一致
|
||||
t.eq('fan 竖排 anchor=left 抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'fan', direction: 'vertical', anchorX: 0, anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
|
||||
// grid 逐行走水平分布,竖排语义的 anchor 同样拒绝
|
||||
t.eq('grid anchor=top 抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 2, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'top' }, { count: 4 })), true);
|
||||
// line + items 只实现了水平:竖排显式拒绝,不按水平硬算
|
||||
t.eq('line+items 竖排抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 10, anchor: 'top', items: [{ key: 'A', width: 10 }] }, {})), true);
|
||||
|
||||
// ============ 对齐基准点必填:框架的 anchorY||0 会把缺失静默变成 0 ============
|
||||
t.eq('line 缺 anchorY 抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 2 })), true);
|
||||
t.eq('line 由 ctx.anchorY 补上则正常', EQW_LayoutSolver.solve(
|
||||
{ kind: 'line', direction: 'horizontal', anchorX: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||||
{ count: 2, anchorY: 77 })[0].y, 77);
|
||||
t.eq('ctx.anchorX 覆盖配置 anchorX', EQW_LayoutSolver.solve(
|
||||
{ kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||||
{ count: 2, anchorX: 500 })[0].x, 500);
|
||||
t.eq('fan 缺 anchorX 抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'fan', direction: 'horizontal', anchorY: 0, maxWidth: 100, itemWidth: 10, itemHeight: 10, spacingMax: 0, spacingMin: -5, anchor: 'left' }, { count: 3 })), true);
|
||||
|
||||
// ============ grid:cols/rows 缺失必须抛错(曾静默返回空数组)============
|
||||
// cols*undefined = NaN → count > NaN 恒 false(超载守卫失效)、for(row<undefined) 一次不跑 → 返回 []
|
||||
const gridNoRows = { kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' };
|
||||
t.eq('grid 缺 rows 抛错', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 3 })), true);
|
||||
t.eq('grid 缺 cols 抛错', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'grid', anchorX: 0, anchorY: 0, rows: 2, itemWidth: 10, itemHeight: 10, anchor: 'left' }, { count: 3 })), true);
|
||||
// ctx.rows 运行时注入:行数由实际项数决定的场景
|
||||
t.eq('ctx.rows 注入后正常求解', EQW_LayoutSolver.solve(gridNoRows, { count: 6, rows: 2 }).length, 6);
|
||||
t.eq('ctx.rows 参与容量计算(满格)', EQW_LayoutSolver.solve(gridNoRows, { count: 8, rows: 2 }).length, 8);
|
||||
t.eq('ctx.rows 参与容量计算(超载仍抛错)', throws(() => EQW_LayoutSolver.solve(gridNoRows, { count: 9, rows: 2 })), true);
|
||||
t.eq('ctx.rows 覆盖配置 rows', throws(() => EQW_LayoutSolver.solve(
|
||||
{ kind: 'grid', anchorX: 0, anchorY: 0, cols: 4, rows: 4, itemWidth: 10, itemHeight: 10, anchor: 'left' },
|
||||
{ count: 9, rows: 2 })), true);
|
||||
|
||||
// ============ point:x/y 缺失抛错 + 运行时注入 ============
|
||||
t.eq('point 缺 x/y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, {})), true);
|
||||
t.eq('point 缺 y 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'point', x: 1, w: 10, h: 10 }, {})), true);
|
||||
t.eq('point 由 ctx.x/ctx.y 注入', EQW_LayoutSolver.solve({ kind: 'point', w: 10, h: 10 }, { x: 5, y: 6 }),
|
||||
[{ x: 5, y: 6, width: 10, height: 10 }]);
|
||||
t.eq('point 的 ctx.w/ctx.h 也生效', EQW_LayoutSolver.solve({ kind: 'point', x: 1, y: 2 }, { w: 33, h: 44 }),
|
||||
[{ x: 1, y: 2, width: 33, height: 44 }]);
|
||||
|
||||
// ============ 运行时注入通道清单 ============
|
||||
t.eq('INJECT_KEYS 覆盖 target/x/y/w/h/anchorX/anchorY/rows/itemWidth/itemHeight',
|
||||
EQW_LayoutSolver.INJECT_KEYS.slice().sort(),
|
||||
['anchorX', 'anchorY', 'h', 'itemHeight', 'itemWidth', 'rows', 'target', 'w', 'x', 'y']);
|
||||
t.eq('ctx.itemWidth 注入生效', EQW_LayoutSolver.solve(
|
||||
{ kind: 'line', direction: 'vertical', anchorX: 0, anchorY: 0, itemHeight: 32, spacing: 8, anchor: 'top' },
|
||||
{ count: 2, itemWidth: 240 })[0].width, 240);
|
||||
// runtime 声明只是给人和守卫看的契约,不影响求解(求解器对所有 INJECT_KEYS 一视同仁)
|
||||
t.eq('runtime 声明不影响求解', EQW_LayoutSolver.solve(
|
||||
{ kind: 'point', runtime: ['x', 'y'] }, { x: 7, y: 8 }), [{ x: 7, y: 8, width: undefined, height: undefined }]);
|
||||
|
||||
// ============ apply:精灵数与矩形数不一致必须抛错,不静默截断 ============
|
||||
// 少摆的精灵会停在上一手牌的旧坐标上(陈旧精灵 + 陈旧坐标),是最难反查的一类错位
|
||||
const moved = [];
|
||||
global.SpriteManager = { setPosition: function (id, x, y) { moved.push([id, x, y]); } };
|
||||
const applyLine = { kind: 'line', direction: 'horizontal', anchorX: 0, anchorY: 0, itemWidth: 10, itemHeight: 10, spacing: 0, anchor: 'left' };
|
||||
moved.length = 0;
|
||||
EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 3 });
|
||||
t.eq('apply 数量相等时逐个摆位', moved, [[11, 0, 0], [12, 10, 0], [13, 20, 0]]);
|
||||
moved.length = 0;
|
||||
t.eq('apply 精灵多于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12, 13], { count: 2 })), true);
|
||||
t.eq('apply 精灵少于矩形抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [11, 12], { count: 3 })), true);
|
||||
t.eq('apply 抛错时一个都没摆', moved, []);
|
||||
t.eq('apply 未传 spriteIds 抛错', throws(() => EQW_LayoutSolver.apply(applyLine, null, { count: 3 })), true);
|
||||
t.eq('apply 空数组 + 零矩形不抛错', throws(() => EQW_LayoutSolver.apply(applyLine, [], { count: 0 })), false);
|
||||
|
||||
// ============ 纯函数:不修改入参 ============
|
||||
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);
|
||||
@@ -0,0 +1,158 @@
|
||||
// 重连(deskinfo)与开局(StartWar)
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/system/EventBus.js');
|
||||
load('client/js/01_SubGame/codes/state/Events.js');
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
|
||||
|
||||
const S = EQW_GameState;
|
||||
const evts = [];
|
||||
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
|
||||
|
||||
// ================= setRoomDes:房间选项 =================
|
||||
S.reset(); S.room.options = null;
|
||||
EQW_ResyncHandler.handleSetRoomDes(123456, 12, '10100');
|
||||
t.eq('总局数', S.room.asetCount, 12);
|
||||
t.eq('傍王开', S.room.options.bangwang, 1);
|
||||
t.eq('可查牌', S.room.options.nocheck, 0);
|
||||
|
||||
// ================= deskinfo:step 5 出牌阶段 =================
|
||||
S.reset(); S.room.mySeat = 1; evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [10, -5, -5], step: 5, MyCards: [1,2,3],
|
||||
PushCards: {
|
||||
banker: 0, call: 60, multiple: 3, flower: 3, countdown: 15,
|
||||
grade: 25, playproc: { round: 4, currseat: 1, cards: [[9],[],[]] },
|
||||
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]],
|
||||
liangpai: { cards: [52,53] }, pushlist: [[[1],[2],[3]]],
|
||||
curmultiple: 2, mustcard: [4,5], baozhu: 1
|
||||
}
|
||||
});
|
||||
t.eq('总局数', S.room.asetCount, 6);
|
||||
t.eq('当前局', S.room.asetIdx, 2);
|
||||
t.eq('三家总分', S.room.playerScores, [10, -5, -5]);
|
||||
t.eq('阶段', S.aset.step, 5);
|
||||
t.eq('手牌', S.my.cards, [1,2,3]);
|
||||
t.eq('庄家', S.aset.banker, 0);
|
||||
t.eq('主牌花色', S.aset.flower, 3);
|
||||
t.eq('捡分', S.aset.grade, 25);
|
||||
t.eq('抓分倍数', S.aset.curmultiple, 2);
|
||||
t.eq('当前轮出牌情况', S.table.playproc.round, 4);
|
||||
t.eq('出牌历史', S.table.pushlist.length, 1);
|
||||
t.eq('亮牌', S.table.liangpai, { cards: [52,53] });
|
||||
t.eq('必出牌', S.my.mustCard, [4,5]);
|
||||
t.eq('控制权(由 playproc.currseat 给出)', S.turn.seat, 1);
|
||||
t.eq('重连发全量重画事件', evts.indexOf('EQW_RESYNC_ALL') >= 0, true);
|
||||
// 报无主:明牌按钮与余主公示的开关(协议 PushCards.baozhu)。
|
||||
// 增量路径由 chupai1/2/3 的 baozhu 维护,重连路径必须一并恢复,
|
||||
// 否则重连后按钮凭空消失(两条路径对同一状态给出不同结果)。
|
||||
t.eq('报无主(PushCards.baozhu)', S.aset.baozhu, 1);
|
||||
// 反面:不查牌房服务端恒发 0,前端照写不反推
|
||||
S.reset(); S.room.mySeat = 1;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 5, MyCards: [1],
|
||||
PushCards: { banker: 0, flower: 3, playproc: { round: 1, currseat: 1, cards: [[],[],[]] }, baozhu: 0 }
|
||||
});
|
||||
t.eq('不查牌 baozhu 恒 0', S.aset.baozhu, 0);
|
||||
|
||||
// ================= deskinfo:step 1 叫分阶段 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [7,8],
|
||||
CallRun: { seat: 2, countdown: 15, nowcall: 65, multiple: 2, call: [null, 0, 65] }
|
||||
});
|
||||
t.eq('叫分阶段', S.aset.step, 1);
|
||||
t.eq('当前叫分者', S.turn.seat, 2);
|
||||
t.eq('当前叫到的分', S.call.currcall, 65);
|
||||
t.eq('三家叫分', S.call.calls, [null, 0, 65]);
|
||||
|
||||
// ================= deskinfo:step 2 选主阶段(庄家有 bottomcards)=================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
|
||||
ChooseMain: { seat: 0, banker: 0, call: 70, multiple: 2, countdown: 20,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], touxiang: 1 }
|
||||
});
|
||||
t.eq('选主阶段', S.aset.step, 2);
|
||||
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
|
||||
t.eq('允许投降', S.aset.touxiang, 1);
|
||||
t.eq('控制权读 ChooseMain.seat', S.turn.seat, 0);
|
||||
|
||||
// 重连【不重放】70 分开底:deskinfo 里没有 ancard3s
|
||||
t.eq('重连不重放开底', S.table.ancard3s, 0);
|
||||
|
||||
// 【守卫】控制权取分组里的 seat(服务端权威),不用本地 banker 反推。
|
||||
// 真实链路里 ChooseMain.seat 恒 == banker,相等时"读哪个"分辨不出来;这里刻意让它们不同,
|
||||
// 退回 `turn.seat = aset.banker` 的写法必转红。
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
|
||||
ChooseMain: { seat: 2, banker: 0, call: 70, multiple: 2, countdown: 20, touxiang: 1 }
|
||||
});
|
||||
t.eq('ChooseMain 控制权取 seat,不取 banker', S.turn.seat, 2);
|
||||
|
||||
// ================= deskinfo:step 3 埋牌阶段 =================
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
|
||||
BuryCards: { seat: 0, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25,
|
||||
bottomcards: [1,2,3,4,5,6,7,8], curmultiple: 3 }
|
||||
});
|
||||
t.eq('埋牌阶段', S.aset.step, 3);
|
||||
t.eq('控制权读 BuryCards.seat', S.turn.seat, 0);
|
||||
// 同样的守卫:seat 与 banker 刻意不同
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 3, MyCards: [1,2],
|
||||
BuryCards: { seat: 1, banker: 0, call: 65, multiple: 2, flower: 1, countdown: 25 }
|
||||
});
|
||||
t.eq('BuryCards 控制权取 seat,不取 banker', S.turn.seat, 1);
|
||||
|
||||
// ================= deskinfo:step 6 结算阶段 =================
|
||||
S.reset(); evts.length = 0;
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 6, PlayerInfo: [12,-6,-6], step: 6, MyCards: [],
|
||||
Balance: { readystate: [1,0,0], aset: { banker: 0, upgrade: 1, seatlist: [] },
|
||||
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
|
||||
account: [{ score: 12 }, { score: -6 }, { score: -6 }] }
|
||||
});
|
||||
t.eq('结算阶段', S.aset.step, 6);
|
||||
t.eq('小局结算数据', S.result.aset.upgrade, 1);
|
||||
// I-2:结算面板还开着时重连,抠底明细与大局结算必须一并恢复(与 jiesuan 推送同一份数据)。
|
||||
// 漏接会让重连后抠底明细、末局大结算全部空白
|
||||
t.eq('重连恢复抠底明细', S.result.bottom.grade2, 40);
|
||||
t.eq('重连恢复大局结算', S.result.account.length, 3);
|
||||
|
||||
// 反面:非末局 / 投降局的 Balance 没有 bottom / account,缺字段保持 null,不兜底造分组
|
||||
S.reset();
|
||||
EQW_ResyncHandler.handleDeskinfo({
|
||||
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 6, MyCards: [],
|
||||
Balance: { readystate: [0,0,0], aset: { banker: 0, upgrade: -99, seatlist: [] } }
|
||||
});
|
||||
t.eq('无抠底分组时保持 null', S.result.bottom, null);
|
||||
t.eq('无大局结算时保持 null', S.result.account, null);
|
||||
|
||||
// ================= StartWar:差异化下发 =================
|
||||
S.reset(); S.room.mySeat = 2; evts.length = 0;
|
||||
EQW_ResyncHandler.handleStartWar({ data: { deskwar: { sendtype: 1, seatlist: [
|
||||
{ seat: 0, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [90] } },
|
||||
{ seat: 2, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [11,12] } }
|
||||
] } } });
|
||||
t.eq('按本座位取到自己那份', S.my.cards, [11,12]);
|
||||
|
||||
// 非差异化下发:直接用 data
|
||||
S.reset(); S.room.mySeat = 0;
|
||||
EQW_ResyncHandler.handleStartWar({ data: { count: 6, idx: 1, PlayerInfo: [0,0,0],
|
||||
step: 1, MyCards: [3,4] } });
|
||||
t.eq('非差异化下发', S.my.cards, [3,4]);
|
||||
|
||||
// 畸形入参不崩
|
||||
let boom = false;
|
||||
try { EQW_ResyncHandler.handleStartWar(null); EQW_ResyncHandler.handleDeskinfo(null); }
|
||||
catch (e) { boom = true; }
|
||||
t.eq('畸形入参不崩', boom, false);
|
||||
|
||||
process.exit(t.done('resync') ? 0 : 1);
|
||||
@@ -0,0 +1,34 @@
|
||||
// roomtype 位串解析(协议 §0.5),必须与服务端 class.config.js 的 parse() 同规则
|
||||
const { load } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/01_SubGame/codes/state/RoomOptions.js');
|
||||
|
||||
const P = EQW_RoomOptions_Parse.parse;
|
||||
|
||||
// ---- 缺省 "00000" = 6局 / 房主扣卡 / 无傍王 / 常规算子 / 可查牌 ----
|
||||
t.eq('缺省', P('00000'), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 协议 §0.5 的示例:10100 = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌 ----
|
||||
t.eq('示例 10100', P('10100'), { asetCount: 12, deductAA: 0, bangwang: 1, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 逐位 ----
|
||||
t.eq('位0 局数', P('10000').asetCount, 12);
|
||||
t.eq('位1 AA扣卡', P('01000').deductAA, 1);
|
||||
t.eq('位2 傍王', P('00100').bangwang, 1);
|
||||
t.eq('位3 爬坡', P('00010').climb, 1);
|
||||
t.eq('位4 不查牌', P('00001').nocheck, 1);
|
||||
t.eq('全开', P('11111'), { asetCount: 12, deductAA: 1, bangwang: 1, climb: 1, nocheck: 1 });
|
||||
|
||||
// ---- 反面:缺失/非字符串/过短一律按 '0' ----
|
||||
t.eq('undefined', P(undefined), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('null', P(null), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('空串', P(''), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('过短只有两位', P('11'), { asetCount: 12, deductAA: 1, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('非字符串(数字)', P(11111), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
t.eq('非字符串(数组)', P([]), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
|
||||
|
||||
// ---- 超长不报错,只取前 5 位 ----
|
||||
t.eq('超长', P('1111199999').bangwang, 1);
|
||||
|
||||
process.exit(t.done('roomoptions') ? 0 : 1);
|
||||
@@ -0,0 +1,110 @@
|
||||
// 语义化发包:route 正确、只带意图、参数形状校验
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
|
||||
// 桩掉 RpcHelper,捕获发出的包
|
||||
const sent = [];
|
||||
global.RpcHelper = {
|
||||
sendRpc: function (app, route, rpc, data) { sent.push({ app, route, rpc, data }); }
|
||||
};
|
||||
|
||||
load('client/js/01_SubGame/codes/net/Rpc.js');
|
||||
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- route 必须是 erqiwang,不是 room ----
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60);
|
||||
t.eq('发了一个包', sent.length, 1);
|
||||
t.eq('app', sent[0].app, 'youle');
|
||||
t.eq('route 是 erqiwang', sent[0].route, 'erqiwang');
|
||||
t.eq('rpc', sent[0].rpc, 'jiaofen');
|
||||
t.eq('业务字段', sent[0].data, { seat: 2, call: 60 });
|
||||
|
||||
// ---- seat 自动从 GameState 带上 ----
|
||||
EQW_GameState.room.mySeat = 0;
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('seat 自动带上', sent[0].data.seat, 0);
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- 各方法的字段集合 ----
|
||||
const call = (fn) => { sent.length = 0; fn(); return sent[0]; };
|
||||
|
||||
t.eq('不叫', call(() => EQW_Rpc.jiaofen(0)).data, { seat: 2, call: 0 });
|
||||
t.eq('投降', call(() => EQW_Rpc.touxiang()).data, { seat: 2 });
|
||||
t.eq('选主', call(() => EQW_Rpc.xuanzhu(3)).data, { seat: 2, flower: 3 });
|
||||
t.eq('埋牌', call(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])).data, { seat: 2, cards: [1,2,3,4,5,6,7,8] });
|
||||
t.eq('出牌', call(() => EQW_Rpc.chupai([10,11])).data, { seat: 2, cards: [10,11] });
|
||||
t.eq('明牌', call(() => EQW_Rpc.mingpai()).data, { seat: 2 });
|
||||
t.eq('提示', call(() => EQW_Rpc.tishi(1)).data, { seat: 2, tip: 1 });
|
||||
t.eq('准备', call(() => EQW_Rpc.zhunbei()).data, { seat: 2 });
|
||||
|
||||
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
|
||||
// FORBIDDEN 列表:协议中服务端→客户端方向的字段名。原则上都不该出现在客户端→服务端的包里。
|
||||
// 作为回归网:新增发包方法时若引入了服务端字段名会被它拦下。
|
||||
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
|
||||
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade',
|
||||
'cardsinhand', 'mustcard', 'seatlist', 'baozhu', 'liangpai', 'bottomcards', 'burycards',
|
||||
'ancard3s', 'countdown', 'currcall', 'chongguan', 'wang', 'naward',
|
||||
'grade_aw', 'grade_cg', 'grade_bw', 'grade_jf', 'account', 'readystate',
|
||||
'shuai', 'shuaicuo', 'cardtype', 'maxseat', 'gradecards', 'others', 'zhucards',
|
||||
'playproc', 'pushlist', 'touxiang'];
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60); EQW_Rpc.touxiang(); EQW_Rpc.xuanzhu(1);
|
||||
EQW_Rpc.maipai([1,2,3,4,5,6,7,8]); EQW_Rpc.chupai([9]);
|
||||
EQW_Rpc.mingpai(); EQW_Rpc.tishi(2); EQW_Rpc.zhunbei();
|
||||
|
||||
const leaked = [];
|
||||
sent.forEach(p => Object.keys(p.data).forEach(k => {
|
||||
if (FORBIDDEN.indexOf(k) >= 0) { leaked.push(p.rpc + '.' + k); }
|
||||
}));
|
||||
t.eq('发包不含任何结论字段', leaked, []);
|
||||
|
||||
// ---- 参数形状校验:非法即抛错,不发残缺包 ----
|
||||
sent.length = 0;
|
||||
t.eq('牌 id 非数组抛错', throws(() => EQW_Rpc.chupai('5')), true);
|
||||
t.eq('空数组抛错', throws(() => EQW_Rpc.chupai([])), true);
|
||||
t.eq('字符串牌 id 抛错', throws(() => EQW_Rpc.chupai(['5'])), true);
|
||||
t.eq('小数牌 id 抛错', throws(() => EQW_Rpc.chupai([1.5])), true);
|
||||
t.eq('越界牌 id 抛错', throws(() => EQW_Rpc.chupai([108])), true);
|
||||
t.eq('负数牌 id 抛错', throws(() => EQW_Rpc.chupai([-1])), true);
|
||||
t.eq('重复牌 id 抛错', throws(() => EQW_Rpc.chupai([5, 5])), true);
|
||||
t.eq('埋牌非 8 张抛错', throws(() => EQW_Rpc.maipai([1,2,3])), true);
|
||||
t.eq('叫分越界抛错', throws(() => EQW_Rpc.jiaofen(71)), true);
|
||||
t.eq('叫分非 5 的倍数抛错', throws(() => EQW_Rpc.jiaofen(7)), true);
|
||||
t.eq('花色越界抛错', throws(() => EQW_Rpc.xuanzhu(5)), true);
|
||||
t.eq('提示类型越界抛错', throws(() => EQW_Rpc.tishi(4)), true);
|
||||
t.eq('校验失败时一个包都没发', sent.length, 0);
|
||||
|
||||
// ---- 合法边界值不抛 ----
|
||||
t.eq('牌 id 0 合法', throws(() => EQW_Rpc.chupai([0])), false);
|
||||
t.eq('牌 id 107 合法', throws(() => EQW_Rpc.chupai([107])), false);
|
||||
t.eq('叫分 5 合法', throws(() => EQW_Rpc.jiaofen(5)), false);
|
||||
t.eq('叫分 70 合法', throws(() => EQW_Rpc.jiaofen(70)), false);
|
||||
|
||||
// ---- 【C-1】座位校验:非 0/1/2 一律 fail-fast,绝不无声透传 ----
|
||||
// mySeat 初始值是 -1(平台 appStart 早于 C_Player 创建,见 test_appstart_timing.js)。
|
||||
// seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||||
// 而前端一声不吭——整局玩不了却查不出原因。所以在最近处抛错。
|
||||
const badSeats = [-1, 3, 1.5, undefined, null, '1', NaN];
|
||||
badSeats.forEach(s => {
|
||||
sent.length = 0;
|
||||
EQW_GameState.room.mySeat = s;
|
||||
t.eq('座位 ' + JSON.stringify(s) + ' 发包抛错', throws(() => EQW_Rpc.zhunbei()), true);
|
||||
t.eq('座位 ' + JSON.stringify(s) + ' 时一个包都没发', sent.length, 0);
|
||||
});
|
||||
// 非法座位对每个发包方法都拦(校验在 _send 的公共入口,不是逐方法漏配)
|
||||
EQW_GameState.room.mySeat = -1;
|
||||
t.eq('非法座位时出牌也抛错', throws(() => EQW_Rpc.chupai([1, 2])), true);
|
||||
t.eq('非法座位时叫分也抛错', throws(() => EQW_Rpc.jiaofen(5)), true);
|
||||
t.eq('非法座位时埋牌也抛错', throws(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])), true);
|
||||
|
||||
[0, 1, 2].forEach(s => {
|
||||
EQW_GameState.room.mySeat = s;
|
||||
t.eq('座位 ' + s + ' 合法不抛', throws(() => EQW_Rpc.zhunbei()), false);
|
||||
});
|
||||
|
||||
process.exit(t.done('rpc_send') ? 0 : 1);
|
||||
@@ -0,0 +1,65 @@
|
||||
// 服务端座位 ↔ 显示位(清单 §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);
|
||||
|
||||
// ---- 反面:非法座位号显式抛错 ----
|
||||
// toDisplay 曾不做校验:((NaN)%3+3)%3 = NaN,既不等于 0 也不等于 1,最后落到 return 'LEFT'
|
||||
// ——一个看起来很合理的错误答案,比抛错难查得多
|
||||
t.eq('toDisplay mySeat=undefined 抛错', throws(() => EQW_SeatMap.toDisplay(0, undefined)), true);
|
||||
t.eq('toDisplay seat=undefined 抛错', throws(() => EQW_SeatMap.toDisplay(undefined, 0)), true);
|
||||
t.eq('toDisplay seat=3 抛错', throws(() => EQW_SeatMap.toDisplay(3, 0)), true);
|
||||
t.eq('toDisplay seat=-1 抛错', throws(() => EQW_SeatMap.toDisplay(-1, 0)), true);
|
||||
t.eq('toDisplay seat=1.5 抛错', throws(() => EQW_SeatMap.toDisplay(1.5, 0)), true);
|
||||
t.eq('toDisplay seat 是字符串 抛错', throws(() => EQW_SeatMap.toDisplay('1', 0)), true);
|
||||
t.eq('toDisplay seat=null 抛错', throws(() => EQW_SeatMap.toDisplay(null, 0)), true);
|
||||
t.eq('toSeat mySeat=undefined 抛错', throws(() => EQW_SeatMap.toSeat('SELF', undefined)), true);
|
||||
t.eq('toSeat mySeat=3 抛错', throws(() => EQW_SeatMap.toSeat('LEFT', 3)), true);
|
||||
// 正面:合法的 9 种组合都不抛错(上面已逐条断言过返回值,这里确认校验没误伤)
|
||||
let legalOk = true;
|
||||
for (let my = 0; my < 3; my++) {
|
||||
for (let s = 0; s < 3; s++) {
|
||||
if (throws(() => EQW_SeatMap.toDisplay(s, my))) { legalOk = false; }
|
||||
}
|
||||
}
|
||||
t.eq('合法座位号不被误伤', legalOk, true);
|
||||
|
||||
process.exit(t.done('seatmap') ? 0 : 1);
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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);
|
||||
// 钉住精确数量(与 test_constants.js 同一个数):松下界删掉整个 View 都还能通过
|
||||
t.eq('真实精灵数量', Object.keys(realIndex).length, 380);
|
||||
|
||||
process.exit(t.done('spriteindex') ? 0 : 1);
|
||||
@@ -26,7 +26,10 @@ UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
|
||||
| `setPosition(id, x, y)` | 设置坐标 | |
|
||||
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
|
||||
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
|
||||
| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
|
||||
| `setText(id, text)` | 文字 | 文字精灵,系统字体 |
|
||||
| `setTextWithWidth(id, text, charWidth)` | 文字 + 自动定宽 | **文字精灵**:设文本并把宽度设为「字符数 × `charWidth`」,中文按 2 个字符计 |
|
||||
| `setNumberImage(id, text, charWidth)` | **多帧图美术字数字** | **多帧图片精灵**:一个精灵显示**整串**(见下) |
|
||||
| `drawImage(id, imgId, dx,dy,dw,dh)` | 在精灵上叠绘整张图 | **只能在绘制回调里调**(见 §5),用于标记叠加 |
|
||||
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
|
||||
| `showLayer/hideLayer(lid)` | 图层批量 | 整个界面显隐 |
|
||||
| `exists(id)` | 存在性检查 | |
|
||||
@@ -55,6 +58,49 @@ SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
|
||||
|
||||
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
|
||||
|
||||
### 三种显示机制:文字精灵 / 多帧图片精灵切帧 / 多帧图数字
|
||||
|
||||
同样是"在精灵上显示内容",`SpriteManager` 给了三条路,**能力边界完全不同,选错了会在后期变成返工**:
|
||||
|
||||
| 机制 | API | 一次能显示 | 字形 | 适用 |
|
||||
|------|-----|-----------|------|------|
|
||||
| **文字精灵** | `setText(id, text)`、`setTextWithWidth(id, text, charWidth)` | **任意长度**的文本 | **系统字体**(只能调字号/颜色,做不出描边、渐变、投影) | 纯数据型文本:昵称、局数、`N对`/`主N` 角标、`2子`、提示文案 |
|
||||
| **多帧图片精灵切帧** | `setFrame(id, frame)` | **一帧**(整张精灵就是那一帧) | 美术出图 | **"选其一"的状态**:按钮可用/置灰、单选框选中/未选、花色图标、牌面(一张牌 = 一帧) |
|
||||
| **多帧图数字** | `setNumberImage(id, text, charWidth)` | **整串**(多位数字 + 符号 + 后缀),都在**同一个**精灵里 | 美术出图 | **多位美术字数字**:倒计时、结算得分、叫分档位分数、张数、比分 |
|
||||
|
||||
**选用判据(按顺序问自己三个问题)**:
|
||||
|
||||
1. **要不要美术字形**(描边/渐变/投影)?不要 → **文字精灵**,到此为止,最省。需要按字符数定宽就用 `setTextWithWidth`(中文按 2 个字符计)。
|
||||
2. 要美术字形,显示的是**"若干形态里选一个"**吗(一张牌、一个图标、一个按钮态)?是 → **`setFrame`**。
|
||||
3. 要美术字形,显示的是**一串数字/符号**(可能两位、带正负号、带 `分`/`张` 后缀)?→ **`setNumberImage`**。
|
||||
|
||||
> ⚠️ **最容易踩的坑:显示多位数字时拆成多个精灵,或用 `setFrame` 逐位切。**
|
||||
> `setFrame` 是"整个精灵显示第 N 帧",**一个精灵一次只显示一位**。有人据此把两位数拆成"十位精灵 + 个位精灵",三位数拆三个——精灵数随位数线性膨胀,布局、显隐、清理全要按位处理(十位为 0 还得单独隐藏),位数一变就得重排。
|
||||
> **平台早就给了 `setNumberImage`,一个精灵显示整串,别再造这个轮子。**
|
||||
> 真实事故:某次评审误判"一个精灵只能显示一位",先把选主张数拆成十位/个位 8 个精灵,随后又差点为此新造一套"图集叠绘"渲染器——直到发现 `core/SpriteManager.js` 的 `setNumberImage` 本来就有这个接口。
|
||||
> **判据一句话:值可能超过一个字符,用 `setNumberImage`,既不拆精灵也不用 `setFrame`。**
|
||||
|
||||
**`setNumberImage` 怎么工作**(读实现,不要凭印象):它把文本里的符号编码后设给精灵的 TEXT 属性,由**引擎按字符逐帧渲染**(帧号由字符直接换算),并把精灵宽度设为 **`字符数 × charWidth`**。所以:
|
||||
|
||||
```js
|
||||
// 资源须是 16 帧、帧序固定为 0123456789.+-x/p 的多帧图片
|
||||
SpriteManager.setNumberImage(spriteId, '70', charWidth); // 叫分档位分数(两位数,一个精灵)
|
||||
SpriteManager.setNumberImage(spriteId, '+120', charWidth); // 结算得分(帧12 = '+'、帧13 = '-')
|
||||
SpriteManager.setNumberImage(spriteId, '26张', charWidth); // 带后缀('张' 落在第 16 帧)
|
||||
// charWidth 来自常量文件,禁裸值;精灵宽度由该接口自动设,无需再 setSize
|
||||
```
|
||||
|
||||
**资源规格(写进图片资源常量的注释里,供美术照做)**:
|
||||
|
||||
| 帧 | 1–10 | 11 | 12 | 13 | 14 | 15 | 16 |
|
||||
|----|------|----|----|----|----|----|----|
|
||||
| 内容 | `0`–`9` | `.` | `+` | `-` | `x` | `/` | **可变后缀**(每套资源自己的后缀字,只支持 `分` / `倍` / `张`) |
|
||||
|
||||
- **帧序不可重排、不可省略占位**:帧号由字符直接换算,用不到的帧位也要按顺序留出来,否则帧号对不上、画面串位。
|
||||
- **16 帧等宽**(后缀字与数字同宽),整图宽 = `16 × 单字符宽`;单字符宽即代码里传的 `charWidth`。
|
||||
- 第 16 帧的后缀只认 `分` / `倍` / `张` 这三个字(接口内部把它们统一编码到该帧),别的字写进去不会被识别。
|
||||
- **复制精灵也能用**:`setNumberImage` 走的是属性设置、不依赖绘制回调,`SpriteCopyUtils` 复制出的字符串 ID 精灵同样适用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 资源与布局常量三件套
|
||||
@@ -173,6 +219,8 @@ SpriteEventController.registerMouseMove(sprites.HAND_AREA, function (event) { se
|
||||
|
||||
支持的事件类型:`mouseDown` / `mouseDownNoMove`(长按) / `mouseUp` / `mouseMove`(拖拽) / `drawBegin` / `draw`。
|
||||
|
||||
> ⚠️ **转发这一段必须真的接上,且参数顺序要逐个对齐 `handleXxx` 的签名**。转发缺失或参数错位不会报错,只会让**所有**精灵交互与绘制回调**静默失效**——按钮点了没反应、叠绘的标记一片空白,却查不到任何异常日志。接线时对着 `SpriteEventController` 里各 `handleXxx` 的形参表逐个核对,转发层**只转发、不写业务**。
|
||||
|
||||
对**每个**绘制精灵统一处理(不针对某个固定 ID)用 `registerGlobalDraw(fn)`——框架对每个 draw 事件都回调它、但**不认识**其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
|
||||
|
||||
### 更高层:手势识别 `SpriteGestureRecognizer`
|
||||
@@ -217,7 +265,8 @@ list.onClick = function (type, rowIndex, rowData) { /* ... */ };
|
||||
| DO ✅ | DON'T ❌ |
|
||||
|------|---------|
|
||||
| UI 只调 `SpriteManager` | 直接调 `GameABCUtils`/引擎原生 API |
|
||||
| 一精灵多帧、`setFrame` 切换 | 为每种牌面建一个精灵 |
|
||||
| 一精灵多帧、`setFrame` 切换**状态** | 为每种牌面建一个精灵 |
|
||||
| 多位美术字数字用 `setNumberImage`,**一个精灵显示整串** | 用 `setFrame` 逐位切 / 拆十位个位各建一个精灵 / 另造一套数字渲染器 |
|
||||
| 精灵 ID 1001–3000、群组 ≥201、从编辑器查证 | 随意编造 ID / 超范围 ID |
|
||||
| 检查 `SpriteManager` 返回值 | 忽略 `false` 返回 |
|
||||
| 精灵/资源/坐标全进常量文件 | 在业务代码内联裸数字 |
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# 二七王前端 · 子项目 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`
|
||||
|
||||
```js
|
||||
EQW_CardCodec.cardIdToFrame(cardId) // 清单 §0.4 的唯一权威转换,全前端只此一处实现
|
||||
EQW_CardCodec.CARD_BACK_FRAME // 55
|
||||
```
|
||||
|
||||
实现照清单 §0.4:
|
||||
|
||||
```js
|
||||
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`
|
||||
|
||||
```js
|
||||
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` 本地推导,服务端不下发:
|
||||
|
||||
```js
|
||||
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 的座位映射,**全前端唯一实现**:
|
||||
|
||||
```js
|
||||
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 容器 → 精灵**:
|
||||
|
||||
```js
|
||||
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」),故需把这棵树展平:
|
||||
|
||||
```js
|
||||
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 接口
|
||||
|
||||
```js
|
||||
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 阶段做,并列入验收检查 |
|
||||
@@ -0,0 +1,370 @@
|
||||
# 二七王前端 · 子项目 B:网络与数据镜像 · 设计
|
||||
|
||||
> 日期:2026-08-27 状态:待实施
|
||||
> 权威源:协议 `server/games/erqiwang/docs/protocol/packet_protocol.md`;玩法 `server/games/erqiwang/docs/design/design.md`;
|
||||
> 前端规范 `docs/client/development-guide/`(尤其 04 网络对接、05 §6 数据驱动);界面规格 `docs_dev/二七王-UI资源与精灵清单.md`。
|
||||
> 前置:子项目 A(地基与逻辑层)已合入 master。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与定位
|
||||
|
||||
子项目 A 交付了常量层、纯逻辑核心(`CardCodec`/`CardOrder`/`CardMark`/`SeatMap`/`SpriteIndex`)、布局求解器、前后端共享算法与单测框架。**B 负责把前端接上服务端**:发包、收包分发、把服务端快照镜像成前端状态、重连重画。
|
||||
|
||||
**B 不含任何 UI 组件与渲染**——那是 C–E。B 的全部产物都能脱离界面用单测覆盖,这既是范围约束,也是可测性的保证。
|
||||
|
||||
### 1.1 B 在六阶段中的位置
|
||||
|
||||
| # | 子项目 | 状态 |
|
||||
|---|---|---|
|
||||
| A | 地基与逻辑层 | ✅ 已完成(52 commit,合入 master) |
|
||||
| **B** | **网络与数据镜像(本文)** | 本次 |
|
||||
| C | 牌桌主界面 | 待做 |
|
||||
| D | 阶段操作界面 | 待做 |
|
||||
| E | 弹窗与结算 | 待做 |
|
||||
| F | 建房与接入收尾 | 待做 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```
|
||||
点击(C–E 阶段的组件)
|
||||
└─ Rpc.sendXxx(意图) 语义化发包,只带「做什么 + 目标标识」
|
||||
└─ RpcHelper.sendRpc('youle','erqiwang') 框架基座,自动注入平台字段
|
||||
└─ 引擎发送
|
||||
|
||||
服务端推送
|
||||
└─ Game_Modify._ReceiveData 平台入口(转发壳,不写业务)
|
||||
└─ SubGameHooks._ReceiveData
|
||||
└─ Dispatcher.dispatch(msg) 唯一收包入口,纯路由表
|
||||
└─ handlers.handleXxx(data) 一 rpc 一处理器
|
||||
├─ ① 判 data.success
|
||||
├─ ② 把包内权威字段写进 GameState
|
||||
└─ ③ emit 语义事件
|
||||
└─ (C–E)组件订阅事件 → 从 GameState 读 → set/refresh
|
||||
```
|
||||
|
||||
**关键约束**:`handlers` **不引用任何 UI 组件**。这是 B 能脱离 C 独立完成与测试的前提,也避免了「网络层反向依赖表现层」。
|
||||
|
||||
### 2.1 目录
|
||||
|
||||
```
|
||||
client/js/01_SubGame/codes/
|
||||
net/
|
||||
Rpc.js 语义化发包(一操作一方法)
|
||||
Dispatcher.js 唯一收包入口 + 纯路由表
|
||||
handlers/
|
||||
DealHandler.js fapai
|
||||
CallHandler.js jiaofen / shangzhuang
|
||||
MainHandler.js xuanzhu
|
||||
BuryHandler.js maipai
|
||||
PlayHandler.js chupai1 / chupai2 / chupai3
|
||||
QueryHandler.js mingpai / tishi
|
||||
ResultHandler.js jiesuan / Free(解散结算,走平台 Game_Modify.Free 入口)
|
||||
ReadyHandler.js zhunbei
|
||||
ResyncHandler.js deskinfo(重连)+ StartWar(开局)
|
||||
FailHandler.js 失败回包(与请求同名的 rpc)
|
||||
state/
|
||||
GameState.js 服务端快照的镜像,前端 SSOT
|
||||
Events.js 事件常量(追加到 EventBus.Events)
|
||||
```
|
||||
|
||||
单向依赖:`handlers` → `GameState` → `Events`;`Rpc` 独立于三者,只依赖 `GameState.room.mySeat`。
|
||||
|
||||
---
|
||||
|
||||
## 3. `GameState`:服务端快照的镜像
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
- **只镜像、不派生**:`GameState` 只存服务端下发过的字段,**不存任何前端算出来的结论**。需要派生的(手牌排序、标记、花色统计)由 A 阶段的 `core/` 纯函数在渲染时现算,不落地成状态。
|
||||
- **字段名对齐协议**:便于逐条核对「前端状态 == 服务端快照」。协议里叫 `currcall` 就叫 `currcall`,不改名成 `currentCall`。
|
||||
- **缺字段不兜底**:包里没有的字段保持原值,**不填默认值掩盖**。必须存在却缺失的(如 `chupai1` 缺 `seat`)显式 `console.error` 并跳过该包——这是服务端漏发,修在服务端(前端 05 §6.1)。
|
||||
|
||||
### 3.2 结构
|
||||
|
||||
```js
|
||||
EQW_GameState = {
|
||||
// —— 房间级(跨小局)——
|
||||
room: {
|
||||
asetCount: 0, // 总局数 ← fapai.asetcount / deskinfo.count
|
||||
asetIdx: 0, // 当前第几局 ← fapai.asetidx / deskinfo.idx
|
||||
mySeat: -1, // 自己的座位 ← 平台 C_Player.seat
|
||||
playerScores: [], // 三家总积分 ← deskinfo.PlayerInfo
|
||||
options: null // roomtype 解析结果(局数/扣卡/傍王/爬坡/查牌)
|
||||
},
|
||||
|
||||
// —— 小局级(一副牌)——
|
||||
aset: {
|
||||
step: 0, // 1叫分 2选主/投降 3埋牌 5出牌 6结算
|
||||
banker: -1,
|
||||
call: -1, // 庄家叫分
|
||||
multiple: 0, // 基础子数
|
||||
flower: 0, // 主牌花色(0 = 未选主)
|
||||
curmultiple: 0, // 当前抓分倍数(带符号,服务端权威,前端不自算)
|
||||
grade: 0, // 闲家已捡分
|
||||
baozhu: 0, // 是否已有人报无主
|
||||
touxiang: 0 // 是否允许投降(仅 70 分坐庄为 1)
|
||||
},
|
||||
|
||||
// —— 当前控制权与倒计时(任何阶段都读这里)——
|
||||
turn: {
|
||||
seat: -1, // 该谁操作 ← 各包的 seat / nextseat
|
||||
countdown: 0 // 展示用秒数 ← 各包的 countdown
|
||||
},
|
||||
|
||||
// —— 叫分过程 ——
|
||||
call: {
|
||||
currcall: 0, // 当前叫到的分
|
||||
calls: [null, null, null] // 三家叫分:null 未叫 / 0 不叫 / >0 叫了多少
|
||||
},
|
||||
|
||||
// —— 自己 ——
|
||||
my: {
|
||||
cards: [], // 手牌
|
||||
mustCard: [], // 本轮必出牌(服务端建议,只发给轮到的那家)
|
||||
bottomCards: [], // 底牌(庄家恒有;闲家仅 70 分坐庄时有)
|
||||
buryCards: [] // 埋牌底牌(仅庄家)
|
||||
},
|
||||
|
||||
// —— 桌面 ——
|
||||
table: {
|
||||
ancard3s: 0, // 开底标志(70 分坐庄)
|
||||
playproc: null, // 当前轮出牌情况
|
||||
pushlist: [], // 出牌历史(仅可查牌模式)
|
||||
seatlist: [], // 三家牌况(仅可查牌模式)
|
||||
liangpai: null, // 亮牌(仅闲家 + 可查牌 + 庄家达标)
|
||||
mingpai: null // 明牌查询结果(仅请求者)
|
||||
},
|
||||
|
||||
// —— 结算 ——
|
||||
result: {
|
||||
chupai: null, // 最后一手
|
||||
bottom: null, // 抠底
|
||||
aset: null, // 小局结算
|
||||
account: null // 大局结算(末局或解散才有)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 读写接口
|
||||
|
||||
- `GameState.reset()`——清空到初始值(新一局发牌时调)。`room.mySeat` 与 `room.options` **不清**(跨局不变)。
|
||||
- `GameState.applyXxx(data)`——每类包一个写入方法,只写包里**存在**的字段(`hasOwnProperty` 判定)。
|
||||
- `GameState.snapshot()`——返回深拷贝,供测试做「增量 vs 全量」比对。
|
||||
|
||||
**没有 getter 语义方法**:组件直接读 `GameState.aset.banker` 这样的路径。多加一层读接口只是转发,不产生价值(YAGNI)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 收包分发
|
||||
|
||||
### 4.1 分发表
|
||||
|
||||
`Dispatcher` 是**纯路由表 + 分发器**,不写业务:
|
||||
|
||||
```js
|
||||
var _handlers = {
|
||||
'fapai': function (d) { DealHandler.handle(d); },
|
||||
'jiaofen': function (d) { CallHandler.handleJiaofen(d); },
|
||||
'shangzhuang': function (d) { CallHandler.handleShangzhuang(d); },
|
||||
'xuanzhu': function (d) { MainHandler.handleXuanzhu(d); },
|
||||
'maipai': function (d) { BuryHandler.handle(d); },
|
||||
'chupai1': function (d) { PlayHandler.handle(d, 1); },
|
||||
'chupai2': function (d) { PlayHandler.handle(d, 2); },
|
||||
'chupai3': function (d) { PlayHandler.handle(d, 3); },
|
||||
'mingpai': function (d) { QueryHandler.handleMingpai(d); },
|
||||
'tishi': function (d) { QueryHandler.handleTishi(d); },
|
||||
'jiesuan': function (d) { ResultHandler.handleJiesuan(d); },
|
||||
'zhunbei': function (d) { ReadyHandler.handle(d); }
|
||||
};
|
||||
```
|
||||
|
||||
未知 rpc 只 `console.warn`、不抛错——平台日后新增推送不该让前端崩。
|
||||
|
||||
### 4.2 三个容易写错的地方
|
||||
|
||||
**① 失败回包与成功推送同名。** 协议 §0.2:任一校验不通过,服务端回一个 **rpc 与请求同名**的失败包(`chupai` 失败回 `chupai`,而不是 `chupai1/2/3`),只含 `success:false` + `errcode`,且只回给请求者。
|
||||
|
||||
于是 `mingpai` / `tishi` / `jiaofen` 这些 rpc 既可能是成功推送、也可能是失败回包;而 `chupai` / `maipai` / `xuanzhu` / `touxiang` **只**会以失败回包的形式出现(成功走 `chupai1/2/3`、`maipai`、`xuanzhu` 推送)。
|
||||
|
||||
**判定顺序**:`dispatch` 先看 `data.success`——为 `false` 一律交给 `FailHandler`(emit `RPC_FAILED`,带 rpc 与 errcode),不进业务 handler。这样每个业务 handler 都可以假定自己拿到的是成功包。
|
||||
|
||||
**② 解散结算走另一个平台入口。** 它**根本进不了 `_ReceiveData`**——平台 `12_Logic.js:258` 按 `_msg.route` 分流,`platform`/`agent`/`room` 三个路由交给 `Net[rpc]`,**只有子游戏路由**(`erqiwang`)才转给 `Game_Modify._ReceiveData`。而解散包的 `route` 是 `room`。
|
||||
|
||||
平台为此提供了专门入口:`07_Desk.js:875` 的 `Game_Modify.Free(Desk.deskfree)` → `SubGameHooks.Free(deskfree)`。
|
||||
|
||||
两个要点:
|
||||
|
||||
- **取值路径比协议文档少一层**:协议 §14.1 描述的是原始下发包(`data.deskfree.data.aset`),但平台已经把 `deskfree` 取出来作为参数传入,所以子游戏侧是 **`deskfree.data.aset`** / `deskfree.data.account`。
|
||||
- **参数可能是 `null`**:`Desk.deskfree` 初值为 `null`,解散若发生在开战后、首局发牌前,服务端 `get_disbandRoom` 返回 `null`、平台走「不带 `deskfree`」分支——`Free(null)` 必须容忍,只做房间收尾、不弹结算。
|
||||
|
||||
**③ `countdown` 只是展示用的秒数。** 协议 §0.3:服务端**没有**对应定时器,归零不会自动叫分/选主/埋牌/出牌,也不判负、不跳过。轮到谁而谁不操作,牌局就停在那一直等。
|
||||
|
||||
因此 `GameState.turn.countdown` 只是个数字,**前端绝不能在归零时做任何界面推进**(不跳阶段、不清控制权)。倒计时的插值显示是 D 阶段的事,B 只负责把服务端给的秒数存下来。
|
||||
|
||||
---
|
||||
|
||||
## 5. 发包
|
||||
|
||||
### 5.1 语义化方法
|
||||
|
||||
**必须用 `RpcHelper.sendRpc('youle', 'erqiwang', rpc, data)`,不能用 `sendGameRpc`**——后者预设 `route = "room"`(平台房间模块),而二七王的 route 是 `erqiwang`。用错了包会被平台路由到房间模块、永远到不了子游戏的 `mod.js`,且不会有任何报错。
|
||||
|
||||
`RpcHelper` 已自动注入 `agentid` / `gameid` / `playerid` / `roomcode` 四个平台字段,`Rpc.js` 只需补 `seat` 与业务字段。
|
||||
|
||||
`Rpc.js` 一个操作一个方法,`seat` 从 `GameState.room.mySeat` 自动带上(协议要求包内 `seat` 供服务端做一致性校验,但**身份由连接反查**,前端填的 seat 不作身份依据):
|
||||
|
||||
```js
|
||||
Rpc.jiaofen(call) // call: 5–70,0 = 不叫
|
||||
Rpc.touxiang() // 仅 70 分坐庄且在选主阶段可用
|
||||
Rpc.xuanzhu(flower) // flower: 1方块 2梅花 3红心 4黑桃
|
||||
Rpc.maipai(cards) // 8 张牌 id
|
||||
Rpc.chupai(cards) // 牌 id 列表
|
||||
Rpc.mingpai() // 查看他家主牌
|
||||
Rpc.tishi(tip) // tip: 1踩 2没分 3有分
|
||||
Rpc.zhunbei() // 准备
|
||||
```
|
||||
|
||||
### 5.2 只带意图,不带结论
|
||||
|
||||
这是防作弊的根本(前端 04 §1):**前端能自己推出来的东西,就是玩家能改的东西**。
|
||||
|
||||
| 可以带 | 禁止带 |
|
||||
|---|---|
|
||||
| 操作类型、牌 id 列表、花色、提示类型、座位号 | 分数、番数、"我胡了"之类判定、结算结果、阶段推进指令、`nextSeat`、`handCards` |
|
||||
|
||||
A 阶段的 `shared/` 算法只用于本地提示与预校验,**结果不回传**。
|
||||
|
||||
### 5.3 参数校验
|
||||
|
||||
发包前做**本地形状校验**(不是规则校验):牌 id 必须是 `0–107` 的整数、互不重复、数组非空。不合形状就 fail-fast 抛错,不发出残缺包——服务端会按 `PARAM` 拒绝,但让错误暴露在最近处更省事。
|
||||
|
||||
**规则合法性不在前端判**(能不能出这手牌、叫分够不够低),那是服务端的事。
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件契约
|
||||
|
||||
事件常量追加到 `EventBus.Events`(框架游戏中立,玩法专属事件定义在子游戏侧):
|
||||
|
||||
| 事件 | 何时 emit | C–E 谁关心 |
|
||||
|---|---|---|
|
||||
| `EQW_RESET` | 新一局发牌 | 全部(清场) |
|
||||
| `EQW_HAND_CHANGED` | 手牌变化(发牌/摸底/选主重排/埋牌/出牌后) | 手牌区 |
|
||||
| `EQW_CALL_CHANGED` | 叫分推进 | 叫分面板、玩家位状态 |
|
||||
| `EQW_BANKER_SET` | 上庄 | 顶部信息条、玩家位庄标、底牌区 |
|
||||
| `EQW_MAIN_SET` | 选主 | 顶部信息条、手牌区(重排) |
|
||||
| `EQW_BURY_DONE` | 埋牌完成 | 手牌区、亮牌面板 |
|
||||
| `EQW_CARD_PLAYED` | 有人出牌 | 出牌区、手牌区、牌况角标 |
|
||||
| `EQW_TRICK_END` | 一轮结束(`chupai3` 带 `maxseat`) | 出牌区(收牌)、捡分飘字 |
|
||||
| `EQW_TURN_CHANGED` | 控制权或倒计时变化 | 倒计时、操作条显隐 |
|
||||
| `EQW_MINGPAI` | 收到明牌数据 | 明牌面板 |
|
||||
| `EQW_TIP` | 收到对家提示 | 提示气泡 |
|
||||
| `EQW_ASET_RESULT` | 小局结算 | 小局结算面板 |
|
||||
| `EQW_ACCOUNT_RESULT` | 大局/解散结算 | 大局结算面板 |
|
||||
| `EQW_RESYNC_ALL` | 重连全量重画 | 全部 |
|
||||
| `EQW_RPC_FAILED` | 失败回包 | 提示条、按钮恢复 |
|
||||
|
||||
**事件只带「发生了什么」,不带数据**——订阅者从 `GameState` 读。这样避免同一份数据在事件载荷与状态里各存一份(SSOT)。唯一例外是 `EQW_RPC_FAILED`,它带 `{rpc, errcode}`——这是一次性的错误信息,不属于对局状态、不该进 `GameState`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 重连与开局
|
||||
|
||||
### 7.1 两条入口,一条路径
|
||||
|
||||
| 平台入口 | 场景 | 数据 |
|
||||
|---|---|---|
|
||||
| `SubGameHooks.StartWar(_msg)` | 开局 | `makewar` 差异化下发(`sendtype:1` + `seatlist[]`),先按本座位取自己那份 |
|
||||
| `SubGameHooks.Reconnect(_deskinfo)` | 断线重连 / 硬刷新 | `get_deskinfo` 全量快照 |
|
||||
|
||||
两者都交给 `ResyncHandler`:**填 `GameState` → emit `EQW_RESYNC_ALL`**。差别只在「填多少」,后半段完全相同。
|
||||
|
||||
### 7.2 `deskinfo` 的分组
|
||||
|
||||
按 `step` 只带对应阶段的分组,`ResyncHandler` 逐组判在则填:
|
||||
|
||||
- 恒有:`count` / `idx` / `PlayerInfo` / `step` / `MyCards`
|
||||
- `CallRun`(step 1):`seat` / `countdown` / `nowcall` / `multiple` / `call[]`
|
||||
- `ChooseMain`(step 2):`banker` / `call` / `multiple` / `countdown` / `bottomcards`(仅庄家)/ `touxiang`
|
||||
- `BuryCards`(step 3):同上 + `flower`
|
||||
- `PushCards`(step 5):`banker` / `call` / `multiple` / `flower` / `countdown` / `burycards`(仅庄家)/ `grade` / `gradecards` / `playproc` / `seatlist` / `liangpai` / `pushlist` / `curmultiple` / `mustcard`
|
||||
- `Balance`(step 6):`readystate` / `aset`
|
||||
|
||||
**重连不重放一次性事件**:协议明确 70 分坐庄的 3 秒开底是上庄时的一次性事件,`deskinfo` 不重放——前端不得在重连时补播。
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
| 情形 | 处理 |
|
||||
|---|---|
|
||||
| `data.success === false` | 交 `FailHandler`,emit `EQW_RPC_FAILED{rpc, errcode}`,不进业务 handler |
|
||||
| 未知 rpc | `console.warn`,不抛错 |
|
||||
| 必需字段缺失 | `console.error` 并跳过该包(服务端漏发,修在服务端) |
|
||||
| 可选字段缺失 | 保持 `GameState` 原值,**不填默认值** |
|
||||
| 解散包缺 `deskfree` | 容忍(首局发牌前解散的正常情形),只做房间收尾 |
|
||||
| 发包参数形状非法 | fail-fast 抛错,不发出残缺包 |
|
||||
|
||||
**一律不做的**:用 `|| 0` / `|| []` 兜底掩盖缺失、据本地推断补齐服务端未下发的状态、在 `countdown` 归零时推进界面。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试
|
||||
|
||||
B 没有界面,**全部产物都用 Node 单测覆盖**,沿用 A 阶段的 `client/tests/` 框架。
|
||||
|
||||
| 测试 | 覆盖 |
|
||||
|---|---|
|
||||
| `test_gamestate.js` | 各 `applyXxx` 的字段写入;`reset` 的清空范围(`mySeat`/`options` 不清);缺字段不兜底 |
|
||||
| `test_dispatcher.js` | 路由表完整性(协议里每个推送 rpc 都有 handler);`success:false` 优先走 `FailHandler`;未知 rpc 不抛错 |
|
||||
| `test_handlers.js` | 逐包喂入 → 断言 `GameState` 与 emit 的事件 |
|
||||
| `test_rpc.js` | **发包只带意图**:桩掉 `RpcHelper`,断言字段集合不含结论字段;参数形状校验的正反例 |
|
||||
| `test_resync.js` | `deskinfo` 各 `step` 分组的填充;`StartWar` 差异化下发按座位取值 |
|
||||
| `test_consistency.js` | **增量 vs 全量一致性**(见下) |
|
||||
|
||||
### 9.1 增量 vs 全量一致性(核心验收)
|
||||
|
||||
把一局的推送包按序喂进去累积出的 `GameState`,与用**同一时刻**的 `deskinfo` 全量重建出的 `GameState`,**必须完全相等**。
|
||||
|
||||
这是前端红线「视图 = f(服务端快照)」的可执行形式:
|
||||
|
||||
> 判据:任意时刻丢弃 `this.data`、仅凭最近一次服务端快照重画,界面必须完全一致——做不到即"前端私存了状态"或"服务端漏发了字段",都要修。
|
||||
|
||||
不一致意味着两件事之一,都要修:
|
||||
- **前端私存了服务端不知道的状态**(增量路径写了 `deskinfo` 里没有的东西);
|
||||
- **服务端漏发了字段**(`deskinfo` 缺了增量推送给过的信息)——这时修在服务端。
|
||||
|
||||
### 9.2 测试数据用服务端真实下发的包
|
||||
|
||||
`server/games/erqiwang/test/_rpc.js` 是现成的 **L3 RPC 脚手架**:它装配 `mod.js`、把 `SendPack`/`sendpack_toother` 全指向同一个 `sent` 数组,**逐个快照真实的下发包**。
|
||||
|
||||
所以 B 的测试**不手写假包**——跑一局服务端流程,把 `sent` 里的真实包喂进前端 `GameState`。这样测的是真实契约,而不是我对协议文档的理解。协议文档写错或前端理解偏差,都会在这里暴露。
|
||||
|
||||
具体做法:写一个夹具把服务端 `sent` 数组导出成 JSON(按座位区分,因为下发是差异化的),前端测试读它回放。**导出脚本放 `client/tests/`,不污染服务端测试目录。**
|
||||
|
||||
---
|
||||
|
||||
## 10. 范围边界
|
||||
|
||||
**做**:发包封装、收包分发、`GameState`、事件契约、重连/开局路径、上述单测。
|
||||
|
||||
**不做**:
|
||||
- 任何 UI 组件与渲染调用(C–E)
|
||||
- 倒计时的插值显示(D,B 只存服务端给的秒数)
|
||||
- 建房界面与 `roomtype` 拼串(F)
|
||||
- 战绩(F,框架 `RecordView` + 平台入口)
|
||||
- 平台聊天 / 语音 / 解散投票界面(平台全包,子游戏只处理解散**结算包**)
|
||||
|
||||
---
|
||||
|
||||
## 11. 遗留与依赖
|
||||
|
||||
| 项 | 状态 | 处理 |
|
||||
|---|---|---|
|
||||
| 结算得分数字资源第 16 帧后缀 | A 阶段遗留,`+18` 还是 `+18分` 未定 | E 阶段对设计稿定,不影响 B |
|
||||
| 明牌/历史面板 28 张牌每张仅露 14px | A 阶段遗留,视觉偏窄 | E 阶段复核,不影响 B |
|
||||
| 出牌操作条提示按钮 55 vs 63 高度差 | A 阶段遗留 | D 阶段对设计稿定,不影响 B |
|
||||
| `roomtype` 解析 | B 需要它来判「可查牌 / 爬坡 / 傍王」 | B 内实现解析(与服务端 `class.config.js` 的 `parse()` 同规则),F 阶段的建房拼串复用同一份 |
|
||||
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -1,6 +1,11 @@
|
||||
///////////////////////////////////////////////////
|
||||
////////// cls_youle_erqiwang_arith: 算法 /////////
|
||||
///////////////////////////////////////////////////
|
||||
//跨模块依赖:Node 走 require 守卫;友乐/浏览器无 require,由 min_loadJsFile 加载为同名全局(dev-guide 01 §1)
|
||||
if (typeof require !== "undefined"){
|
||||
var youle_erqiwang_shared_cards = require("./shared/cards.js");
|
||||
}
|
||||
|
||||
var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
|
||||
//根据叫分计算基础子数(design §7.1 常规算子 / §7.3.1 爬坡)
|
||||
@@ -59,249 +64,32 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
return 40;
|
||||
},
|
||||
|
||||
//牌id转花色
|
||||
id_to_flower: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 5;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 5;
|
||||
}
|
||||
return parseInt((cardid % 54) / 13) + 1;
|
||||
},
|
||||
|
||||
//牌id转牌面数值
|
||||
id_to_number: function(cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 53;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 54;
|
||||
}
|
||||
return (cardid % 54) % 13 + 1;
|
||||
},
|
||||
|
||||
//牌id转牌编码
|
||||
id_to_code: function(mainflower, cardid){
|
||||
if (cardid == 52 || cardid == 106){
|
||||
//小王
|
||||
return 9553;
|
||||
}
|
||||
if (cardid == 53 || cardid == 107){
|
||||
//大王
|
||||
return 9554;
|
||||
}
|
||||
var flower = cls_youle_erqiwang_arith.id_to_flower(cardid);
|
||||
var number = cls_youle_erqiwang_arith.id_to_number(cardid);
|
||||
var code = flower * 100 + number;
|
||||
//特殊处理
|
||||
switch (number)
|
||||
{
|
||||
case 1:
|
||||
code = code + 13;
|
||||
break;
|
||||
case 2:
|
||||
code = code + 2000;
|
||||
break;
|
||||
case 7:
|
||||
code = code + 7000;
|
||||
break;
|
||||
}
|
||||
//主牌花色处理
|
||||
if (flower == mainflower){
|
||||
code = code + 1000;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
|
||||
//从大到小排序(冒泡排序)
|
||||
order_cards: function(mainflower, aryCardIDs){
|
||||
if (aryCardIDs){
|
||||
for (var i = 0; i < aryCardIDs.length; i++) {
|
||||
for (var j = i + 1; j < aryCardIDs.length; j++) {
|
||||
var i_code = cls_youle_erqiwang_arith.id_to_code(mainflower, aryCardIDs[i]);
|
||||
var j_code = cls_youle_erqiwang_arith.id_to_code(mainflower, aryCardIDs[j]);
|
||||
if (i_code < j_code) {
|
||||
var tmp = aryCardIDs[j];
|
||||
aryCardIDs[j] = aryCardIDs[i];
|
||||
aryCardIDs[i] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return aryCardIDs;
|
||||
},
|
||||
|
||||
//判断两个牌是否是连续的
|
||||
is_continuous: function(bigcode, smallcode){
|
||||
//是小王
|
||||
var is_xiaowang = function(_code){
|
||||
if (_code == 9553){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正7
|
||||
var is_zheng7 = function(_code){
|
||||
if (_code > 8000 && _code < 9000){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负7
|
||||
var is_fu7 = function(_code){
|
||||
if (_code > 7000 && _code < 8000){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是正2
|
||||
var is_zheng2 = function(_code){
|
||||
if (_code > 3000 && _code < 4000){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是负2
|
||||
var is_fu2 = function(_code){
|
||||
if (_code > 2000 && _code < 3000){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是主A
|
||||
var is_zhuA = function(_code){
|
||||
if (_code > 1000 && _code < 2000 && _code % 100 == 14){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是8
|
||||
var is_8 = function(_code){
|
||||
if (_code % 100 == 8){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//是6
|
||||
var is_6 = function(_code){
|
||||
if (_code % 100 == 6){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//普通的连牌
|
||||
if (bigcode - smallcode == 1){
|
||||
return true;
|
||||
}
|
||||
//小王、正7
|
||||
if (is_xiaowang(bigcode) && is_zheng7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正7、负7
|
||||
if (is_zheng7(bigcode) && is_fu7(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负7、正2
|
||||
if (is_fu7(bigcode) && is_zheng2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//正2、负2
|
||||
if (is_zheng2(bigcode) && is_fu2(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//负2、主A
|
||||
if (is_fu2(bigcode) && is_zhuA(smallcode)){
|
||||
return true;
|
||||
}
|
||||
//8、6(design §5.3:7 不与 6/8 相连,6 与 8 相连)
|
||||
//必须是同一花色的 8 与 6:牌编码同花色相差 2(主花色 1408-1406=2、副牌 308-306=2),
|
||||
//跨花色则相差 100 的倍数。只判 %100 会把「♥8 + ♣6」误判为连对,
|
||||
//进而在 get_chongguan 扫描整手牌时让副牌的 6 对错误接续主 8、多算奖(design §8.1 连对链)
|
||||
if (is_8(bigcode) && is_6(smallcode) && (bigcode - smallcode == 2)){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
//获取对子列表,cards为同一花色的排过序的牌列表
|
||||
get_pairlist: function(mainflower, flowercards){
|
||||
var pairlist = [];
|
||||
var i = 0;
|
||||
while (i < flowercards.length - 1){
|
||||
//牌编码,两张牌两张牌一取
|
||||
var code_i = cls_youle_erqiwang_arith.id_to_code(mainflower, flowercards[i]);
|
||||
var code_j = cls_youle_erqiwang_arith.id_to_code(mainflower, flowercards[i + 1]);
|
||||
if (code_i == code_j){
|
||||
pairlist.push([flowercards[i], flowercards[i + 1]]);
|
||||
i = i + 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return pairlist;
|
||||
},
|
||||
|
||||
//获取拖拉机列表,pairlist为同一花色的排过序的对子列表,cardtype为拖拉机牌型
|
||||
get_tuolaji_list: function(mainflower, pairlist, cardtype){
|
||||
var tuolaji = [];
|
||||
var count = cardtype - 300; //拖拉机的对子数量
|
||||
var i = 0;
|
||||
for (var i = 0; i < pairlist.length - count + 1; i++){
|
||||
//每次按拖拉机的对子数量取N对
|
||||
var tmplist = pairlist.slice(i, i + count);
|
||||
//判断是否是cardtype类型的拖拉机
|
||||
var is_tlj = true;
|
||||
for (var j = 0; j < tmplist.length - 1; j++) {
|
||||
var code_j1 = cls_youle_erqiwang_arith.id_to_code(mainflower, tmplist[j][0]);
|
||||
var code_j2 = cls_youle_erqiwang_arith.id_to_code(mainflower, tmplist[j+1][0]);
|
||||
if (!cls_youle_erqiwang_arith.is_continuous(code_j1, code_j2)){
|
||||
is_tlj = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (is_tlj){
|
||||
var tlj = [];
|
||||
for (var k = 0; k < tmplist.length; k++){
|
||||
tlj = tlj.concat(tmplist[k]);
|
||||
}
|
||||
tuolaji.push(tlj);
|
||||
}
|
||||
}
|
||||
return tuolaji;
|
||||
},
|
||||
|
||||
//主牌归一化大小(副7 同级取 7000、副2 同级取 2000,其余用牌编码),仅用于甩牌分量比大小
|
||||
trump_rank: function(mainflower, cardid){
|
||||
var code = cls_youle_erqiwang_arith.id_to_code(mainflower, cardid);
|
||||
if (code > 7000 && code < 8000){ //副7
|
||||
return 7000;
|
||||
}
|
||||
if (code > 2000 && code < 3000){ //副2
|
||||
return 2000;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
//以下 9 项为前后端共享算法,权威实现在 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,
|
||||
group_tractor_runs: youle_erqiwang_shared_cards.group_tractor_runs,
|
||||
get_tuolaji_list: youle_erqiwang_shared_cards.get_tuolaji_list,
|
||||
trump_rank: youle_erqiwang_shared_cards.trump_rank,
|
||||
|
||||
//把一组主牌分解为分量列表(design §5.4.3/§5.4.4)
|
||||
//返回 [{type:'single'|'pair'|'tractor', len, value, cards:[]}...],value 为该分量最大牌的归一化 rank
|
||||
decompose_trump: function(mainflower, cards){
|
||||
var ordered = cls_youle_erqiwang_arith.order_cards(mainflower, cards.concat());
|
||||
//先分出对子与单张
|
||||
var pairs = []; //{code, cards:[id,id]}
|
||||
var singles = []; //{card}
|
||||
//先分出对子与单张(对子形状与 shared get_pairlist 一致:[id, id])
|
||||
var pairs = []; //[[id,id], ...]
|
||||
var singles = []; //[id, ...]
|
||||
var i = 0;
|
||||
while (i < ordered.length){
|
||||
var code_i = cls_youle_erqiwang_arith.id_to_code(mainflower, ordered[i]);
|
||||
if (i + 1 < ordered.length){
|
||||
var code_n = cls_youle_erqiwang_arith.id_to_code(mainflower, ordered[i + 1]);
|
||||
if (code_i == code_n){
|
||||
pairs.push({code: code_i, cards: [ordered[i], ordered[i + 1]]});
|
||||
pairs.push([ordered[i], ordered[i + 1]]);
|
||||
i = i + 2;
|
||||
continue;
|
||||
}
|
||||
@@ -309,26 +97,21 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
singles.push(ordered[i]);
|
||||
i++;
|
||||
}
|
||||
//连续对子组成拖拉机,其余为单独对子
|
||||
//连续对子组成拖拉机,其余为单独对子。极大连续段的扫描是前后端共享算法(shared/cards.js),
|
||||
//此处只负责把段翻译成分量,不再自己实现「什么算连续、几对起算拖拉机」
|
||||
var runs = youle_erqiwang_shared_cards.group_tractor_runs(mainflower, pairs);
|
||||
var comps = [];
|
||||
var j = 0;
|
||||
while (j < pairs.length){
|
||||
var run = [pairs[j]];
|
||||
var k = j;
|
||||
while (k + 1 < pairs.length && cls_youle_erqiwang_arith.is_continuous(pairs[k].code, pairs[k + 1].code)){
|
||||
run.push(pairs[k + 1]);
|
||||
k++;
|
||||
for (var j = 0; j < runs.length; j++){
|
||||
var run = runs[j];
|
||||
var tcards = [];
|
||||
for (var r = 0; r < run.length; r++){
|
||||
tcards = tcards.concat(run[r]);
|
||||
}
|
||||
if (run.length >= 2){
|
||||
var tcards = [];
|
||||
for (var r = 0; r < run.length; r++){
|
||||
tcards = tcards.concat(run[r].cards);
|
||||
}
|
||||
comps.push({type: "tractor", len: run.length, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0].cards[0]), cards: tcards});
|
||||
if (run.length >= youle_erqiwang_shared_cards.TRACTOR_MIN_PAIRS){
|
||||
comps.push({type: "tractor", len: run.length, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0][0]), cards: tcards});
|
||||
} else {
|
||||
comps.push({type: "pair", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0].cards[0]), cards: run[0].cards});
|
||||
comps.push({type: "pair", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, run[0][0]), cards: tcards});
|
||||
}
|
||||
j = k + 1;
|
||||
}
|
||||
for (var s = 0; s < singles.length; s++){
|
||||
comps.push({type: "single", len: 1, value: cls_youle_erqiwang_arith.trump_rank(mainflower, singles[s]), cards: [singles[s]]});
|
||||
@@ -1043,6 +826,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
_inhandcards = cls_youle_erqiwang_arith.order_cards(mainflower, _inhandcards);
|
||||
_followcards = cls_youle_erqiwang_arith.order_cards(mainflower, _followcards);
|
||||
|
||||
//已排序跟牌的完整快照:下面的必出/可出校验会用 min_ary_deduct 削减 _followcards,
|
||||
//而后半段的牌面值(cardvalue)与缺门/无对(noflower/nopair)判定必须基于「完整且已排序」的跟牌。
|
||||
//这里绝不能改用入参 followcards——那是客户端提交的原始顺序,而 get_pairlist / get_tuolaji_list
|
||||
//都按降序相邻取对:乱序提交会漏判对子/拖拉机,把本该压过的主拖拉机算成牌面 0(本轮胜者、
|
||||
//捡分归属、扣底倍数全被客户端的数组顺序左右),也能抹掉缺门标志污染 §9 牌况表。
|
||||
//(dev-guide server 04 §8:前端不是数据源,服务端不得依赖客户端可控的入参顺序)
|
||||
var _sortfollow = _followcards.concat();
|
||||
|
||||
//根据第一个玩家的出牌获取后面玩家必出的和可出的牌
|
||||
var get = cls_youle_erqiwang_arith.get_followcard(mainflower, _inhandcards, startcount, startflower, startcardtype);
|
||||
|
||||
@@ -1113,8 +904,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
|
||||
/////////// 可以跟牌 ///////////
|
||||
//根据跟牌情况获取是否有相同花色的牌,是否有对子
|
||||
//如果第一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
|
||||
//如果最大的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
|
||||
var _flower = parseInt(_code / 100);
|
||||
if (_code > 1000){
|
||||
_flower = mainflower;
|
||||
@@ -1123,8 +914,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
can.noflower = true;
|
||||
can.nopair = true;
|
||||
}
|
||||
//如果最后一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[followcards.length - 1]);
|
||||
//如果最小的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[_sortfollow.length - 1]);
|
||||
var _flower = parseInt(_code / 100);
|
||||
if (_code > 1000){
|
||||
_flower = mainflower;
|
||||
@@ -1135,7 +926,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
}
|
||||
//如果第一家出的是对子或拖拉机牌型,而跟牌没有出对子则认为没有相同花色的对子
|
||||
if (startcardtype > 200){
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
|
||||
if (pairlist.length != startcardtype % 100){
|
||||
can.nopair = true;
|
||||
}
|
||||
@@ -1144,7 +935,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
//根据第一家的出牌计算牌值大小
|
||||
//一张单张
|
||||
if (startcardtype == 101){
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
|
||||
var _flower = parseInt(_code / 100);
|
||||
if (_code > 1000){ //大于1000是主牌
|
||||
_flower = mainflower;
|
||||
@@ -1173,13 +964,13 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
|
||||
//一对
|
||||
if (startcardtype == 201){
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
|
||||
if (pairlist.length != 1){
|
||||
can.cardvalue = 0;
|
||||
return can;
|
||||
}
|
||||
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
|
||||
var _flower = parseInt(_code / 100);
|
||||
if (_code > 1000){
|
||||
_flower = mainflower;
|
||||
@@ -1207,14 +998,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
|
||||
|
||||
//拖拉机
|
||||
if (startcardtype > 300 && startcardtype < 400){
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards);
|
||||
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
|
||||
var tuolaji_list = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, pairlist, startcardtype);
|
||||
if (tuolaji_list.length != 1){
|
||||
can.cardvalue = 0;
|
||||
return can;
|
||||
}
|
||||
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]);
|
||||
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
|
||||
var _flower = parseInt(_code / 100);
|
||||
if (_code > 1000){
|
||||
_flower = mainflower;
|
||||
|
||||
@@ -19,10 +19,13 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
var desk = {};
|
||||
|
||||
desk.o_room = o_room; //桌所属的房间对象
|
||||
desk.seatlist = []; //玩家得分
|
||||
desk.seatlist.push([0, []]); //第一位为累积得分,第二位为每局得分
|
||||
desk.seatlist.push([0, []]);
|
||||
desk.seatlist.push([0, []]);
|
||||
//玩家得分。每个元素:[累积总分, [每局总分...], 基础分累计, 冲关分累计, 傍王分累计]
|
||||
//后三位是大局结算面板要分项展示的累计(design §7 捡分子数 / §8.1 冲关 / §8.3 傍王),
|
||||
//三者之和恒等于累积总分——下发时组装成对象(见 get_desk_account),数组下标只在内部用
|
||||
desk.seatlist = [];
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.seatlist.push([0, [], 0, 0, 0]);
|
||||
desk.prepare = [0,0,0]; //玩家准备状态
|
||||
desk.paiju_list = []; //牌局列表
|
||||
|
||||
@@ -78,7 +81,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
o_desk.prepare = [0,0,0];
|
||||
|
||||
if (o_desk.paiju_list.length > 0){
|
||||
//上一局的结算快照三件套一并清除(新局开始后重连不该再看到上一局的结算面板)
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_aset;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_bottom;
|
||||
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_account;
|
||||
}
|
||||
|
||||
//新开一局
|
||||
@@ -92,6 +98,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
msg.data.success = true;
|
||||
msg.data.asetidx = o_desk.paiju_list.length;
|
||||
msg.data.asetcount = o_desk.o_room.asetcount;
|
||||
//阶段:发完牌即 step1(叫分)。由服务端权威给出、不让前端按 rpc 推——
|
||||
//没有包承载的状态变化等于逼前端去猜(server 03 §1.4 / 红线「状态机唯一在服务端」)
|
||||
msg.data.step = o_desk.method.curr_paiju().step;
|
||||
//控制权:当前等待叫分者。与重连包 CallRun.seat 同一个 get_callgrade_seat()(SSOT)
|
||||
msg.data.seat = o_desk.method.curr_paiju().method.get_callgrade_seat();
|
||||
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
|
||||
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
|
||||
@@ -186,7 +196,17 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
|
||||
}
|
||||
min_ontimeout(do_save_grade, 1000);
|
||||
|
||||
msg.data.account = o_desk.seatlist;
|
||||
//大局结算下发:组装成对象数组,字段自解释(内部 o_desk.seatlist 是数组,下标语义不清,不直接外抛)
|
||||
msg.data.account = [];
|
||||
for (var s = 0; s < o_desk.seatlist.length; s++){
|
||||
var _a = {};
|
||||
_a.score = o_desk.seatlist[s][0]; //累积总分
|
||||
_a.grades = o_desk.seatlist[s][1]; //每局总分列表
|
||||
_a.grade_jf_total = o_desk.seatlist[s][2]; //基础分(捡分子数得分)累计
|
||||
_a.grade_cg_total = o_desk.seatlist[s][3]; //冲关分累计(design §8.1,不含傍王)
|
||||
_a.grade_bw_total = o_desk.seatlist[s][4]; //傍王分累计(design §8.3)
|
||||
msg.data.account.push(_a);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ if (typeof require !== "undefined"){
|
||||
var cls_youle_erqiwang_config = require("./class.config.js");
|
||||
var cls_youle_erqiwang_arith = require("./class.arith.js");
|
||||
var cls_youle_erqiwang_desk = require("./class.desk.js");
|
||||
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
|
||||
}
|
||||
|
||||
var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
@@ -94,6 +95,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
break;
|
||||
case 2: //选主
|
||||
deskinfo.ChooseMain = {};
|
||||
//控制权:选主/投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验以 banker 为准)。
|
||||
//与 shangzhuang 推送的 nextseat 同源同值——两条路径都由服务端算好给出,
|
||||
//前端不再自己套「选主 = 庄家」这条规则(字段名与 CallRun.seat 一致:分组内的 seat = 轮到谁)
|
||||
deskinfo.ChooseMain.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.ChooseMain.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
@@ -102,7 +107,7 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
deskinfo.ChooseMain.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
|
||||
//选主倒计时
|
||||
deskinfo.ChooseMain.countdown = o_room.o_desk.method.get_countdown_xuanzhu();
|
||||
//底牌:暗牌只有庄家可见(70分的3秒亮牌是上庄时的一次性事件,重连不重放)
|
||||
//底牌(发牌时没发给玩家、扣在桌面的 8 张):只有庄家可见(70分的3秒亮牌是上庄时的一次性事件,重连不重放)
|
||||
if (seat == paiju.banker){
|
||||
deskinfo.ChooseMain.bottomcards = paiju.method.get_bottomcards();
|
||||
}
|
||||
@@ -112,9 +117,16 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
} else {
|
||||
deskinfo.ChooseMain.touxiang = 1;
|
||||
}
|
||||
//当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
|
||||
//选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
|
||||
//漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
|
||||
deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
|
||||
break;
|
||||
case 3: //埋牌
|
||||
deskinfo.BuryCards = {};
|
||||
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。
|
||||
//与 xuanzhu 推送的 nextseat 同源同值
|
||||
deskinfo.BuryCards.seat = paiju.banker;
|
||||
//庄家
|
||||
deskinfo.BuryCards.banker = paiju.banker;
|
||||
//庄家叫分
|
||||
@@ -123,12 +135,14 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
deskinfo.BuryCards.multiple = cls_youle_erqiwang_arith.get_base_bycall(paiju.call, cfg.climb);
|
||||
//埋牌倒计时
|
||||
deskinfo.BuryCards.countdown = o_room.o_desk.method.get_countdown_maipai();
|
||||
//底牌:暗牌只有庄家可见
|
||||
//底牌(发牌留桌的 8 张):只有庄家可见
|
||||
if (seat == paiju.banker){
|
||||
deskinfo.BuryCards.bottomcards = paiju.method.get_bottomcards();
|
||||
}
|
||||
//主牌花色
|
||||
deskinfo.BuryCards.flower = paiju.flower;
|
||||
//当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
|
||||
deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
|
||||
break;
|
||||
case 5: //出牌
|
||||
@@ -146,19 +160,39 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
//捡分
|
||||
var jian_grade = paiju.method.get_jian_grade();
|
||||
if (seat == paiju.banker){
|
||||
//埋的牌
|
||||
deskinfo.PushCards.bottomcards = paiju.method.get_burycard();
|
||||
//埋牌底牌(庄家埋下的 8 张)。与「底牌」(发牌留桌 8 张,见 ChooseMain/BuryCards.bottomcards)
|
||||
//是两批不同的牌,字段名刻意区分,勿混用
|
||||
deskinfo.PushCards.burycards = paiju.method.get_burycard();
|
||||
} else {
|
||||
//闲家当前的捡分分牌
|
||||
deskinfo.PushCards.gradecards = jian_grade.cards;
|
||||
}
|
||||
//闲家当前的捡分分数
|
||||
deskinfo.PushCards.grade = jian_grade.grade;
|
||||
//当前的出牌情况
|
||||
deskinfo.PushCards.playproc = paiju.playproc;
|
||||
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
|
||||
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
|
||||
//当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
|
||||
//不把活对象挂进下发包,见 class.paiju.js get_playproc)
|
||||
deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
|
||||
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
|
||||
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
|
||||
if (paiju.playproc && paiju.playproc.currseat == seat){
|
||||
var _must = paiju.method.get_mustcard(seat);
|
||||
if (_must){
|
||||
deskinfo.PushCards.mustcard = _must;
|
||||
}
|
||||
}
|
||||
//报无主:与 chupai1/2/3 的 baozhu 同源同门控(mod.chupai)。
|
||||
//【必须带】它是「余主公示 + 明牌按钮」的开关(design §9,前端
|
||||
//Sprites_Table.js 的明牌按钮据此显隐)。只在 chupai 包里给、重连包不给,
|
||||
//等于重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
|
||||
//不查牌模式恒 0(与 chupai 一致,协议 §11–§13 baozhu 条目)
|
||||
deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0;
|
||||
//座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2)
|
||||
if (!cfg.nocheck){
|
||||
deskinfo.PushCards.seatlist = paiju.seatlist;
|
||||
//【深拷贝】与 maipai / chupai1/2/3 共用同一处快照函数,且不把活表挂进下发包
|
||||
//(paiju.seatlist 是 do_playcard 每手就地改写的活对象,见 class.paiju.js get_seatlist)
|
||||
deskinfo.PushCards.seatlist = cls_youle_erqiwang_paiju.get_seatlist(paiju);
|
||||
if (seat != paiju.banker){
|
||||
var _lp = paiju.method.get_liangpai();
|
||||
if (_lp){
|
||||
@@ -170,45 +204,39 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
//不查牌模式下不带此属性——当前这一轮桌面上的牌由上面的 playproc 恢复,两种模式都有,
|
||||
//否则后出的人无从跟牌(design §9 末尾的边界说明)
|
||||
if (!cfg.nocheck){
|
||||
deskinfo.PushCards.pushlist = [];
|
||||
for (var i = 0; i < paiju.cards.length; i++) {
|
||||
var pai = paiju.cards[i];
|
||||
if (pai.playround > 0){
|
||||
if (deskinfo.PushCards.pushlist.length < pai.playround){
|
||||
deskinfo.PushCards.pushlist.length = pai.playround;
|
||||
}
|
||||
if (!deskinfo.PushCards.pushlist[pai.playround - 1]){
|
||||
deskinfo.PushCards.pushlist[pai.playround - 1] = [[], [], []];
|
||||
}
|
||||
|
||||
var owner = pai.dealowner;
|
||||
if (owner == 0){
|
||||
owner = paiju.banker + 1;
|
||||
}
|
||||
deskinfo.PushCards.pushlist[pai.playround - 1][owner - 1].push(i);
|
||||
}
|
||||
}
|
||||
//按大小排序:外层下标=轮次、内层恒为 3 个座位;排序须按本局主牌花色
|
||||
//(内层曾误用轮次数作上界,会把内层数组撑成轮次长度并填入 undefined;
|
||||
// 且曾误用上一个循环泄漏出来的 pai.flower——那是最后一张牌大王的花色 5,
|
||||
// 等于按「无主牌」排序)
|
||||
for (var i = 0; i < deskinfo.PushCards.pushlist.length; i++) {
|
||||
if (!deskinfo.PushCards.pushlist[i]){
|
||||
continue;
|
||||
}
|
||||
for (var j = 0; j < 3; j++) {
|
||||
deskinfo.PushCards.pushlist[i][j] = cls_youle_erqiwang_arith.order_cards(paiju.flower, deskinfo.PushCards.pushlist[i][j]);
|
||||
}
|
||||
}
|
||||
//【直读归档、不重建】pushlist 的唯一来源是 do_playcard 出牌当场写下的
|
||||
//paiju.playhistory(见 class.paiju.js)。旧实现是从 paiju.cards 的 playround/dealowner
|
||||
//反查再自己排一次序——那是同一份数据「这一手打出的牌」的第二个写入处:
|
||||
//一手多张(甩牌/对子/拖拉机)时它与 chupai1/2/3 的 data.cards 会给出不同顺序,
|
||||
//前端增量回放与重连重建因此对不上(同编码的一对牌尤其:排序无法区分它们)。
|
||||
deskinfo.PushCards.pushlist = paiju.method.get_pushlist();
|
||||
}
|
||||
break;
|
||||
case 6: //结算
|
||||
deskinfo.Balance = {};
|
||||
//玩家的准备状态
|
||||
deskinfo.Balance.readystate = o_room.o_desk.prepare;
|
||||
//【返回副本】o_room.o_desk.prepare 是一张全程就地改写的活数组
|
||||
//(do_prepare 每次准备就地把某一格置 1,do_new_paiju 又就地重建),
|
||||
//把它本体挂进下发包会让「已发出的包」随后续准备操作被回改。
|
||||
//与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准:
|
||||
//平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
|
||||
deskinfo.Balance.readystate = o_room.o_desk.prepare.concat();
|
||||
if (o_room.o_desk.prepare[seat] == 0){
|
||||
//结算面板的三件套:与 jiesuan 推送【同源同值】(都取 get_paiju_account 末尾
|
||||
//冻结的那份快照)。只给 aset 会让「结算面板还开着时断线重连/硬刷新」丢掉
|
||||
//抠底明细与末局大结算——增量路径有、重连路径空白,正是「同一份数据两条路径
|
||||
//给的不一样」(server 红线:发全下发面,漏发修在服务端发包处)。
|
||||
//结算情况
|
||||
deskinfo.Balance.aset = paiju.tmp_jiesuan_aset;
|
||||
//抠底明细:仅正常出牌结算存在(投降/解散没有抠底这一步),与 jiesuan.bottom 同一份
|
||||
if (paiju.tmp_jiesuan_bottom){
|
||||
deskinfo.Balance.bottom = paiju.tmp_jiesuan_bottom;
|
||||
}
|
||||
//大局结算:仅末局或解散时存在,与 jiesuan.account 同一份。
|
||||
//「有就带、没有就不带」与 jiesuan 推送的取舍完全一致,前端两条路径同一套解析
|
||||
if (paiju.tmp_jiesuan_account){
|
||||
deskinfo.Balance.account = paiju.tmp_jiesuan_account;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -217,10 +245,21 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
|
||||
}
|
||||
|
||||
//解散房间
|
||||
//平台在 battlestate==1 时即会调用(rpc.js 开战处 makewar 后立刻置 1),而本游戏的首个牌局
|
||||
//是 makewar 里 min_ontimeout(...,1000) 延迟创建的——这段窗口内 o_desk 已存在但 paiju_list 为空。
|
||||
//此时返回 null(平台对 null 的 _deskfree 有分支:照常发解散包、不带 deskfree),
|
||||
//而不是让 curr_paiju() 的 undefined 解引用抛异常打断整条解散链路。
|
||||
exp.get_disbandRoom = function(o_room){
|
||||
if (!o_room.o_desk){
|
||||
return null;
|
||||
}
|
||||
var paiju = o_room.o_desk.method.curr_paiju();
|
||||
if (!paiju){
|
||||
return null;
|
||||
}
|
||||
var msg = {};
|
||||
return o_room.o_desk.method.curr_paiju().method.get_paiju_account(2, msg);
|
||||
}
|
||||
return paiju.method.get_paiju_account(2, msg);
|
||||
}
|
||||
|
||||
return exp;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
tmpdeal.length = tmpdeal.length - 1;
|
||||
}
|
||||
|
||||
//冻结底牌快照(发牌留桌的 8 张)
|
||||
//——内容在发牌结束时就已确定(dealowner==0 之后不再变),【显示顺序也必须一并冻结】。
|
||||
//理由(SSOT):排序用的 order_cards 依赖主牌花色,而底牌是在【选主之前】的上庄时刻
|
||||
//就翻给庄家看的——那时 paiju.flower 还是 -1,根本还没有主牌。若每次调用都按「当时的
|
||||
//flower」重排,上庄推送(flower=-1)与重连 BuryCards(step3,flower 已定)会对同一份数据
|
||||
//给出两种顺序(实测:主花色的那张 K 被排到队首),前端增量路径与重连路径因此对不上。
|
||||
//故在发牌结束时按【翻底那一刻的口径】算一次并冻结,之后所有下发点只取这份快照的副本。
|
||||
var init_bottomcards = function(){
|
||||
var ary = [];
|
||||
for (var i = 0; i < paiju.cards.length; i++){
|
||||
if (paiju.cards[i].dealowner == 0){
|
||||
ary.push(paiju.cards[i].id);
|
||||
}
|
||||
}
|
||||
//此处 paiju.flower 必为 -1(选主在 step2 才发生),即「无主牌」序 = 翻底时的顺序
|
||||
paiju.bottomcards = cls_youle_erqiwang_arith.order_cards(paiju.flower, ary);
|
||||
}
|
||||
|
||||
//进入叫分阶段
|
||||
var start_call = function(){
|
||||
paiju.step = 1;
|
||||
@@ -149,17 +167,35 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
paiju.call = -1;
|
||||
//主牌花色
|
||||
paiju.flower = -1;
|
||||
//底牌快照(发牌留桌的 8 张,含显示顺序):发牌结束时冻结,见 init_bottomcards
|
||||
paiju.bottomcards = [];
|
||||
//当前出牌情况
|
||||
paiju.playproc = {};
|
||||
//出牌历史归档:外层下标 = 轮次-1,内层恒 3 个数组、下标 = 座位;每一项就是那一手
|
||||
//【实际打出、且已归一成权威顺序】的牌。唯一写入处是 do_playcard 的 doplay()。
|
||||
//【为什么要存】重连包 PushCards.pushlist 需要往轮的出牌历史,而牌一旦落地就只剩
|
||||
//「第几轮 + 属于谁」(cards[i].playround/dealowner),一手之内的顺序无从恢复——
|
||||
//从那里反查就必须自己再排一次序,那是同一份数据的第二个写入处(SSOT 违反),
|
||||
//且遇到同编码的两张牌(一对)时两处必然分岔。改为出牌当场归档、下游只读。
|
||||
paiju.playhistory = [];
|
||||
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
|
||||
paiju.result = null;
|
||||
//结算包,供断线重连时使用,新开一局时清除
|
||||
paiju.tmp_jiesuan_aset = null;
|
||||
//结算包快照,供断线重连时使用,新开一局时清除。三件套与 jiesuan 推送【同源同值】:
|
||||
// tmp_jiesuan_aset 单局结算(三种结算来源恒有)
|
||||
// tmp_jiesuan_bottom 抠底明细(仅正常出牌结算有;投降/解散为 null)
|
||||
// tmp_jiesuan_account 大局结算(仅末局或解散有;其余为 null)
|
||||
//只存 aset 会让「结算面板还开着时重连/硬刷新」丢掉抠底明细与大局结算(增量路径有、
|
||||
//重连路径没有),即同一份数据两条路径给的不一样。唯一写入处是 get_paiju_account 末尾。
|
||||
paiju.tmp_jiesuan_aset = null;
|
||||
paiju.tmp_jiesuan_bottom = null;
|
||||
paiju.tmp_jiesuan_account = null;
|
||||
|
||||
//初始化座位列表
|
||||
init_seatlist();
|
||||
//初始化牌列表并发牌
|
||||
init_cards();
|
||||
//冻结底牌快照(必须在发牌之后、选主之前)
|
||||
init_bottomcards();
|
||||
//进入叫分阶段
|
||||
start_call();
|
||||
|
||||
@@ -256,6 +292,26 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
return cls_youle_erqiwang_paiju.get_liangpai(paiju);
|
||||
}
|
||||
|
||||
//获取当前抓分倍数(design §7.2.0,实时预览)
|
||||
paiju.method.get_curmultiple = function(){
|
||||
return cls_youle_erqiwang_paiju.get_curmultiple(paiju);
|
||||
}
|
||||
|
||||
//获取出牌历史快照(重连包 PushCards.pushlist)
|
||||
paiju.method.get_pushlist = function(){
|
||||
return cls_youle_erqiwang_paiju.get_pushlist(paiju);
|
||||
}
|
||||
|
||||
//获取三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)
|
||||
paiju.method.get_seatlist = function(){
|
||||
return cls_youle_erqiwang_paiju.get_seatlist(paiju);
|
||||
}
|
||||
|
||||
//获取某座位本轮跟牌的必出牌(design §5.2)
|
||||
paiju.method.get_mustcard = function(seat){
|
||||
return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat);
|
||||
}
|
||||
|
||||
return paiju;
|
||||
},
|
||||
|
||||
@@ -435,15 +491,90 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
}
|
||||
},
|
||||
|
||||
//获取底牌
|
||||
//获取底牌(发牌留桌的 8 张):唯一来源是发牌时冻结的快照 o_paiju.bottomcards(见 init_bottomcards)。
|
||||
//【返回副本】——order_cards 是原地排序,把快照本体交出去会被调用方就地重排,冻结即失效。
|
||||
get_bottomcards: function(o_paiju){
|
||||
var aryCardIDs = [];
|
||||
for (var i = 0; i < o_paiju.cards.length; i++){
|
||||
if (o_paiju.cards[i].dealowner == 0){
|
||||
aryCardIDs.push(o_paiju.cards[i].id);
|
||||
return o_paiju.bottomcards.concat();
|
||||
},
|
||||
|
||||
//获取【供下发的】当前出牌情况快照:deskinfo.PushCards 与 chupai1/2/3 共用这一处,
|
||||
//两条路径因此结构完全一致(前端增量回放与重连重建可复用同一份解析)。
|
||||
//必须【深拷贝】:playproc 是一个全程复用的活对象(new_playround 就地重置同一个对象,
|
||||
//do_playcard 也就地改 cards/maxseat/maxcard),直接把它挂进 msg 会让「已发出的包」
|
||||
//随后续出牌被回改;平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
|
||||
//structure:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard/
|
||||
// cards(定长3、下标=座位)/shuai_demand
|
||||
//获取【供下发的】出牌历史快照(重连包 PushCards.pushlist)。
|
||||
//唯一来源是 do_playcard 当场归档的 playhistory,这里只拷贝、不重算也不重排:
|
||||
//它与 chupai1/2/3 的 data.cards、playproc.cards 是同一次 order_playcards 的产物,
|
||||
//因此两条路径逐元素相等(前端增量回放与重连重建必定一致)。
|
||||
//【返回深拷贝】不把活数组挂进下发包(同 get_playproc)。
|
||||
get_pushlist: function(o_paiju){
|
||||
var re = [];
|
||||
for (var i = 0; i < o_paiju.playhistory.length; i++){
|
||||
var _round = o_paiju.playhistory[i];
|
||||
var _one = [];
|
||||
for (var s = 0; s < 3; s++){
|
||||
_one.push(_round[s].concat());
|
||||
}
|
||||
re.push(_one);
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
//获取【供下发的】三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)。
|
||||
//结构:外层定长 3(下标=座位),每家 5 个二元数组——前 4 个是花色1~4 的 [无该花色, 无对],
|
||||
//第 5 个是报副 [剩余主牌数, 剩余主对数]。
|
||||
//【返回深拷贝】o_paiju.seatlist 是一张全程就地改写的活表(do_playcard 里按缺门/无对/报无主
|
||||
//逐格赋值),把它本体挂进下发包会让「已发出的包」随后续出牌被回改;平台真实链路虽然立刻
|
||||
//序列化看不出来,但这是隐患,与 get_playproc / get_pushlist / get_bottomcards 同一条标准。
|
||||
get_seatlist: function(o_paiju){
|
||||
var re = [];
|
||||
for (var s = 0; s < o_paiju.seatlist.length; s++){
|
||||
var _one = [];
|
||||
for (var f = 0; f < o_paiju.seatlist[s].length; f++){
|
||||
_one.push(o_paiju.seatlist[s][f].concat());
|
||||
}
|
||||
re.push(_one);
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
get_playproc: function(o_paiju){
|
||||
var _p = o_paiju.playproc;
|
||||
if (!_p){
|
||||
return null;
|
||||
}
|
||||
var re = {};
|
||||
re.round = _p.round;
|
||||
re.start = _p.start;
|
||||
re.currseat = _p.currseat;
|
||||
re.startcount = _p.startcount;
|
||||
re.startflower = _p.startflower;
|
||||
re.starttype = _p.starttype;
|
||||
re.maxseat = _p.maxseat;
|
||||
re.maxcard = _p.maxcard;
|
||||
//本轮各位置出的牌:定长 3、下标 = 座位序号;尚未出牌的位置留空(序列化后为 null)
|
||||
re.cards = [];
|
||||
re.cards.length = 3;
|
||||
if (_p.cards){
|
||||
for (var i = 0; i < 3; i++){
|
||||
if (_p.cards[i]){
|
||||
re.cards[i] = _p.cards[i].concat();
|
||||
}
|
||||
}
|
||||
}
|
||||
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, aryCardIDs);
|
||||
//首家甩牌的分量需求(§5.4.4);tractors 是数组,同样要拷贝,非甩牌为 null
|
||||
if (_p.shuai_demand){
|
||||
var _sd = {};
|
||||
_sd.tractors = _p.shuai_demand.tractors ? _p.shuai_demand.tractors.concat() : [];
|
||||
_sd.pairs = _p.shuai_demand.pairs;
|
||||
_sd.singles = _p.shuai_demand.singles;
|
||||
re.shuai_demand = _sd;
|
||||
} else {
|
||||
re.shuai_demand = null;
|
||||
}
|
||||
return re;
|
||||
},
|
||||
|
||||
//选主
|
||||
@@ -531,6 +662,23 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
o_paiju.playproc.shuai_demand= null; //首家甩牌的分量需求(供跟牌逐分量匹配),非甩牌为null
|
||||
},
|
||||
|
||||
//【一手出牌的顺序:唯一口径】把一手牌规整成本局的权威展示顺序
|
||||
//(按主牌花色从大到小)。只在 do_playcard 落牌那一刻调一次,之后全体下游只读:
|
||||
// · playproc.cards[座位] → chupai1/2/3 与重连包的 playproc
|
||||
// · do_playcard 返回的 re.cards → chupai1/2/3 的 data.cards
|
||||
// · playhistory[轮次][座位] → 重连包的 PushCards.pushlist
|
||||
//三者同源同数组内容,所以前端“增量回放”与“重连重建”必定逐元素相等。
|
||||
//
|
||||
//为什么不能用客户端提交的原序:那是玩家的点击顺序,同一手牌每次都可能不同,
|
||||
//服务端不得依赖、也不回显(server 04 §8:前端不是数据源)。
|
||||
//【返回新数组】——order_cards 是原地排序,直接排入参会就地改写调用方(含 pack.data.cards)。
|
||||
order_playcards: function(o_paiju, cards){
|
||||
if (!cards){
|
||||
return cards;
|
||||
}
|
||||
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, cards.concat());
|
||||
},
|
||||
|
||||
//在牌列表中获取分牌和分值
|
||||
get_grade_incard: function(o_paiju, cards){
|
||||
var re = {};
|
||||
@@ -547,6 +695,12 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
|
||||
//检查选中的牌是否可出,并出牌
|
||||
do_playcard: function(o_paiju, cards){
|
||||
//【顺序归一:唯一写入处】入参是客户端提交的点击顺序,服务端不采信、不下发。
|
||||
//在这里一次性转成权威顺序(order_playcards),此后 playproc.cards / 返回给下发包的
|
||||
//re.cards / 重连包 pushlist 全都是同一个口径,不会再有第二处排序(SSOT)。
|
||||
//顺带隔离了 can_playcard 的【原地排序】——否则它会就地改写 pack.data.cards。
|
||||
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, cards);
|
||||
|
||||
//出牌
|
||||
var doplay = function(playindex){
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
@@ -554,6 +708,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
o_paiju.cards[cards[i]].playindex = playindex;
|
||||
};
|
||||
o_paiju.playproc.cards[o_paiju.playproc.currseat] = cards;
|
||||
//归档这一手,供重连包 PushCards.pushlist 直读(唯一写入处,见 paiju.playhistory)
|
||||
while (o_paiju.playhistory.length < o_paiju.playproc.round){
|
||||
o_paiju.playhistory.push([[], [], []]);
|
||||
}
|
||||
o_paiju.playhistory[o_paiju.playproc.round - 1][o_paiju.playproc.currseat] = cards.concat();
|
||||
|
||||
//进入出牌阶段
|
||||
o_paiju.step = 5;
|
||||
@@ -605,7 +764,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
if (can.shuaicuo){
|
||||
//甩错惩罚(§5.4.5):收回甩牌,本轮只强制打出最小一张
|
||||
_shuaicuo = true;
|
||||
cards = [can.smallest];
|
||||
cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, [can.smallest]);
|
||||
can = cls_youle_erqiwang_arith.can_playcard(o_paiju.flower, cards, o_paiju.playproc.currseat, o_paiju.seatlist, opp_zhulist);
|
||||
if (!can.result){
|
||||
var reErr = {};
|
||||
@@ -636,7 +795,10 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
re1.count = cards.length;
|
||||
re1.flower = can.flower;
|
||||
re1.cardtype = can.cardtype;
|
||||
re1.cards = cards; //实际打出的牌(甩错时为单张)
|
||||
//实际打出的牌(甩错时为单张),已是权威顺序。
|
||||
//【给副本】cards 本体已挂在 playproc.cards[座位] 上,直接交出去会让「已发出的包」
|
||||
//随后续改动被回改(同 get_playproc 的深拷贝理由)
|
||||
re1.cards = cards.concat();
|
||||
re1.shuaicuo = _shuaicuo;
|
||||
//合法甩牌的分量构成:cardtype 只能表达单一牌型,甩牌(单张/对子/拖拉机自由混搭,
|
||||
//design §5.4.3)会被压平成 1xx/2xx,客户端无从据此还原分组,故另行下发分量需求
|
||||
@@ -688,6 +850,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
var re2 = {};
|
||||
re2.idx = 2;
|
||||
re2.result = true;
|
||||
re2.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
|
||||
return re2;
|
||||
}
|
||||
|
||||
@@ -705,6 +868,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
var re3 = {};
|
||||
re3.result = true;
|
||||
re3.idx = 3;
|
||||
re3.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards)
|
||||
re3.maxseat = o_paiju.playproc.maxseat;
|
||||
if (o_paiju.playproc.maxseat != o_paiju.banker){
|
||||
re3.grade = 0;
|
||||
@@ -752,49 +916,42 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
//手牌来源必须是 get_seat_cards_award 的【静态快照】(埋牌后 28 张,含之后已打出的),
|
||||
//不能用 get_seat_cards(只取未出的牌):出牌开始亮出的这份统计全局固定、不随出牌缩水,
|
||||
//否则闲家断线重连后 get_deskinfo 会按庄家当前剩余手牌重算,亮牌信息缩水甚至变成 null
|
||||
//亮牌(design §8.2):庄家埋牌后、出牌前,若手中【固定主牌】达到门槛,
|
||||
//就向两个闲家亮出【自己全部固定主牌的具体牌面】。
|
||||
// 固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7,【不含】主花色的普通牌 A/K/Q/J/10/9/8/6/5
|
||||
// 门槛(任一满足即亮):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6
|
||||
// 不限叫分;是否真的发给闲家由调用方按查牌模式门控(design §9)
|
||||
//返回 { cards: [牌id...] }(按本局主牌序从大到小),不达标返回 null。
|
||||
//注意:亮出的内容是固定的一份,不随"被哪一条门槛触发"而增减。
|
||||
get_liangpai: function(o_paiju){
|
||||
if (o_paiju.banker < 0){
|
||||
return null;
|
||||
}
|
||||
//静态初始手牌快照(与 8.1 算奖同源):庄家埋牌后的 28 张
|
||||
var cards = cls_youle_erqiwang_paiju.get_seat_cards_award(o_paiju, o_paiju.banker);
|
||||
var wang = 0, qi = 0, er = 0;
|
||||
var zhucards = [];
|
||||
var gudingcards = []; //固定主牌
|
||||
for (var i = 0; i < cards.length; i++){
|
||||
var num = cls_youle_erqiwang_arith.id_to_number(cards[i]);
|
||||
var flw = cls_youle_erqiwang_arith.id_to_flower(cards[i]);
|
||||
if (num >= 53){ wang = wang + 1; }
|
||||
if (num == 7){ qi = qi + 1; }
|
||||
if (num == 2){ er = er + 1; }
|
||||
//主牌 = 主花色普通牌 或 王 或 任意 2/7
|
||||
if (flw == o_paiju.flower || flw == 5 || num == 2 || num == 7){
|
||||
zhucards.push(cards[i]);
|
||||
if (num >= 53){ //大王 54 / 小王 53
|
||||
wang = wang + 1;
|
||||
gudingcards.push(cards[i]);
|
||||
} else if (num == 7){ //任意花色的 7
|
||||
qi = qi + 1;
|
||||
gudingcards.push(cards[i]);
|
||||
} else if (num == 2){ //任意花色的 2
|
||||
er = er + 1;
|
||||
gudingcards.push(cards[i]);
|
||||
}
|
||||
}
|
||||
var guding = wang + qi + er; //固定主牌(王 + 全部 2 + 全部 7)
|
||||
var re = {};
|
||||
var has = false;
|
||||
if (guding >= 10){
|
||||
zhucards = cls_youle_erqiwang_arith.order_cards(o_paiju.flower, zhucards);
|
||||
var pairs = cls_youle_erqiwang_arith.get_pairlist(o_paiju.flower, zhucards);
|
||||
var comps = cls_youle_erqiwang_arith.decompose_trump(o_paiju.flower, zhucards);
|
||||
var tuo = 0;
|
||||
for (var c = 0; c < comps.length; c++){
|
||||
if (comps[c].type == "tractor"){ tuo = tuo + 1; }
|
||||
}
|
||||
re.zhu = zhucards.length; //主牌总数
|
||||
re.zhupair = pairs.length; //主对子数
|
||||
re.zhutuo = tuo; //主拖拉机组数
|
||||
has = true;
|
||||
}
|
||||
if (wang >= 3){ re.wang = wang; has = true; }
|
||||
if (qi >= 6){ re.qi = qi; has = true; }
|
||||
if (er >= 6){ re.er = er; has = true; }
|
||||
if (!has){
|
||||
//门槛判定(design §8.2)
|
||||
if (gudingcards.length < 10 && wang < 3 && qi < 6 && er < 6){
|
||||
return null;
|
||||
}
|
||||
var re = {};
|
||||
re.cards = cls_youle_erqiwang_arith.order_cards(o_paiju.flower, gudingcards);
|
||||
return re;
|
||||
},
|
||||
|
||||
//获取抠底包
|
||||
get_bottom_account: function(o_paiju, msg){
|
||||
msg.data.bottom = {};
|
||||
@@ -827,6 +984,60 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
return re;
|
||||
},
|
||||
|
||||
//当前抓分倍数(design §7.2.0):按【当前累计捡分】实时算出的判定倍率,【带符号】。
|
||||
//供客户端顶部「抓分」列角标显示「若此刻结束是几倍」,以及对局中的判定动画(D-8)。
|
||||
//与结算包 aset.upgrade 同源同算法(get_qvalue + get_upgrade),差别只在:
|
||||
//这里的捡分【不含扣底】——扣底要到最后一轮打完才产生,出牌过程中尚不存在。
|
||||
//叫分未定(call<=0)时返回 0,前端不显示角标。
|
||||
get_curmultiple: function(o_paiju){
|
||||
var _call = o_paiju.call;
|
||||
if (!_call || _call <= 0){
|
||||
return 0;
|
||||
}
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_paiju.o_desk.o_room.roomtype);
|
||||
var _grade = cls_youle_erqiwang_paiju.get_jian_grade(o_paiju).grade;
|
||||
var _q = cls_youle_erqiwang_arith.get_qvalue(_call, _cfg.climb);
|
||||
//【带符号】返回,与结算包 aset.upgrade 完全同口径:
|
||||
// 正数 3/2/1 = 庄家 大光/小光/过庄;负数 -N = 闲家升 N 级。
|
||||
//符号就是「谁赢」的区分,不能取绝对值——否则 3(大光) 与 -3(升3级)、
|
||||
//2(小光) 与 -2(升2级)、1(过庄) 与 -1(升1级) 三对会完全撞在一起,
|
||||
//客户端的判定动画将无从分辨该播哪个(前端清单 S-8)。
|
||||
//只关心倍数大小的用途(如顶部「抓分」角标)自行取绝对值即可。
|
||||
return cls_youle_erqiwang_arith.get_upgrade(_call, _grade, _q);
|
||||
},
|
||||
|
||||
//某座位本轮跟牌的「必出牌」(design §5.2),供客户端自动选中。
|
||||
//与服务端出牌校验用的是同一个 get_followcard,故建议与校验结果天然一致。
|
||||
//返回 null(不下发)的情形:
|
||||
// 1. 非出牌阶段、或该座位就是本轮首家(首家自由出牌,无必出);
|
||||
// 2. 本轮首家【甩牌】——甩牌跟牌走 flush_follow_ok 的逐分量匹配(design §5.4.4),
|
||||
// 与 get_followcard 不是同一条路径,此时给建议会给错,宁可不发;
|
||||
// 3. 算出来的必出牌为空(玩家可自由选)。
|
||||
get_mustcard: function(o_paiju, seat){
|
||||
if (o_paiju.step != 5){
|
||||
return null;
|
||||
}
|
||||
var _proc = o_paiju.playproc;
|
||||
//本轮还没人出牌 / 该座位是首家 → 无必出
|
||||
if (!_proc || _proc.start == seat || _proc.startcount == null || _proc.startcount <= 0){
|
||||
return null;
|
||||
}
|
||||
//甩牌不走这条路径,见上
|
||||
if (_proc.shuai_demand){
|
||||
return null;
|
||||
}
|
||||
var _inhand = cls_youle_erqiwang_paiju.get_seat_cards(o_paiju, seat);
|
||||
if (!_inhand || _inhand.length <= 0){
|
||||
return null;
|
||||
}
|
||||
var _get = cls_youle_erqiwang_arith.get_followcard(
|
||||
o_paiju.flower, _inhand, _proc.startcount, _proc.startflower, _proc.starttype);
|
||||
if (!_get || !_get.mustcard || _get.mustcard.length <= 0){
|
||||
return null;
|
||||
}
|
||||
return _get.mustcard;
|
||||
},
|
||||
|
||||
//获取小局结算包,type 0正常结算 1投降结算 2解散结算
|
||||
get_paiju_account: function(o_paiju, type, msg){
|
||||
o_paiju.endtime = min_now();
|
||||
@@ -847,6 +1058,13 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
//主动推送的 data 必须自带成败标志(dev-guide server 03 §4:成败唯一是 data.success)
|
||||
msg.data.success = true;
|
||||
|
||||
//【阶段唯一写入处】结算包一出,本局即进入 step6:
|
||||
// 正常出牌结算 do_playcard 已置 6、投降结算 mod.touxiang 已置 6,此处是幂等重申;
|
||||
// 解散结算此前没有任何地方推进过 step(可能停在 1/2/3/5),由这里落定。
|
||||
//随后显式下发(见下方 msg.data.step):阶段变更必须有包承载,不让前端按 rpc 猜(server 03 §1.4)
|
||||
o_paiju.step = 6;
|
||||
msg.data.step = o_paiju.step;
|
||||
|
||||
//读取房间可选规则(缺省:常规算子、不傍王;见 class.config.js 位串约定)
|
||||
var o_room = o_paiju.o_desk.o_room;
|
||||
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
|
||||
@@ -926,11 +1144,26 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
];
|
||||
|
||||
//算奖并入结算(design §8.4):玩家i从另两家各收 X×Ni、各付 X×Nj,净得 X×(2Ni−Nj−Nk)
|
||||
var _aw = [
|
||||
_X * (2 * _nlist[0] - _nlist[1] - _nlist[2]),
|
||||
_X * (2 * _nlist[1] - _nlist[0] - _nlist[2]),
|
||||
_X * (2 * _nlist[2] - _nlist[0] - _nlist[1])
|
||||
];
|
||||
var award_pay = function(n){
|
||||
return [
|
||||
_X * (2 * n[0] - n[1] - n[2]),
|
||||
_X * (2 * n[1] - n[0] - n[2]),
|
||||
_X * (2 * n[2] - n[0] - n[1])
|
||||
];
|
||||
}
|
||||
var _aw = award_pay(_nlist);
|
||||
|
||||
//冲关与傍王【分别】再各算一次,供大局结算分项展示(S-6)。
|
||||
//必须在这里拆——N = 冲关 + 傍王 求和后才代入公式,事后无法从 grade_aw 反推各自占比。
|
||||
//公式对 N 是线性的,故 _awcg[i] + _awbw[i] 恒等于 _aw[i](下方 _p 赋值处有断言性注释)。
|
||||
var _ncg = [0, 0, 0]; //冲关分量:只计庄家(design §8.1)
|
||||
var _nbw = [0, 0, 0]; //傍王分量:勾选时庄闲都算(design §8.3)
|
||||
for (var q = 0; q < 3; q++){
|
||||
if (q == o_paiju.banker){ _ncg[q] = _cglist[q].count; }
|
||||
if (_bangwang){ _nbw[q] = _cglist[q].wang; }
|
||||
}
|
||||
var _awcg = award_pay(_ncg);
|
||||
var _awbw = award_pay(_nbw);
|
||||
|
||||
//捡分子数:_win=1庄赢→庄+X×2/闲−X;_win=-1闲赢→庄−X×2/闲+X
|
||||
var _jf = [0, 0, 0];
|
||||
@@ -951,12 +1184,17 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
_p.chongguan = _cglist[i].count; //常规算奖奖数(仅庄家计入 N)
|
||||
_p.wang = _cglist[i].wang; //王数
|
||||
_p.naward = _nlist[i]; //该家总奖数 N
|
||||
_p.grade_aw = _aw[i]; //算奖得分 X×(2Ni−Nj−Nk)
|
||||
_p.grade_jf = _jf[i]; //捡分子数得分
|
||||
_p.grade_aw = _aw[i]; //算奖得分 X×(2Ni−Nj−Nk),= grade_cg + grade_bw
|
||||
_p.grade_cg = _awcg[i]; //其中的冲关分量(design §8.1,不含傍王)
|
||||
_p.grade_bw = _awbw[i]; //其中的傍王分量(design §8.3)
|
||||
_p.grade_jf = _jf[i]; //捡分子数得分(基础分)
|
||||
_p.grade = _p.grade_aw + _p.grade_jf; //本局总得分
|
||||
//大局累积得分
|
||||
//大局累积:总分 + 三项分量(基础/冲关/傍王),供大局结算面板分项展示
|
||||
o_paiju.o_desk.seatlist[i][0] = o_paiju.o_desk.seatlist[i][0] + _p.grade;
|
||||
_p.score = o_paiju.o_desk.seatlist[i][0];
|
||||
o_paiju.o_desk.seatlist[i][2] = o_paiju.o_desk.seatlist[i][2] + _p.grade_jf;
|
||||
o_paiju.o_desk.seatlist[i][3] = o_paiju.o_desk.seatlist[i][3] + _p.grade_cg;
|
||||
o_paiju.o_desk.seatlist[i][4] = o_paiju.o_desk.seatlist[i][4] + _p.grade_bw;
|
||||
//保存每局得分
|
||||
o_paiju.o_desk.seatlist[i][1].push(_p.grade);
|
||||
msg.data.aset.seatlist.push(_p);
|
||||
@@ -966,8 +1204,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
|
||||
msg = o_paiju.o_desk.method.get_desk_account(msg);
|
||||
}
|
||||
|
||||
//保存结算包,供断线重连时使用
|
||||
o_paiju.tmp_jiesuan_aset = msg.data.aset;
|
||||
//保存结算包,供断线重连时使用(get_deskinfo 的 case 6 直读这三份,不重算)。
|
||||
//必须与本次下发的 jiesuan 推送【同源】——取的就是刚组好的同一份 msg.data.*,
|
||||
//这样「增量收到 jiesuan」与「结算面板开着时重连」得到的结算数据逐字段相等。
|
||||
//bottom 只有正常出牌结算才有(get_bottom_account 组的)、account 只有末局/解散才有,
|
||||
//没有的情形显式置 null(不是漏发,是本来就不存在;下发侧据此决定带不带该分组)
|
||||
o_paiju.tmp_jiesuan_aset = msg.data.aset;
|
||||
o_paiju.tmp_jiesuan_bottom = msg.data.bottom ? msg.data.bottom : null;
|
||||
o_paiju.tmp_jiesuan_account = msg.data.account ? msg.data.account : null;
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,165 @@
|
||||
|
||||
---
|
||||
|
||||
## 第五轮核对(design 复验 + 首次以 packet_protocol.md 为核对对象)
|
||||
|
||||
> 本轮做两件事:① 再次独立复核 design.md ↔ 代码;② **首次把 `docs/protocol/packet_protocol.md` 本身当作核对对象**(前四轮只在改代码时顺手同步文档,从未反向验证「文档写的 = 客户端实际会收到的」)。
|
||||
>
|
||||
> **design 维度**:§1~§12 逐条重核,含自行重算 §7 边界档(爬坡 10 分/5 分无小光、70 分打牌、常规 40 分无过庄)与 §8.1 连对链(正 7 起链、副 7 跨花色不成对、非主花色断链)——**规则层未发现新的不一致**,前四轮整改均真实落地。
|
||||
>
|
||||
> **新发现 4 项**(1 项严重缺陷 + 2 项协议文档不准确/不完整 + 1 项健壮性),已全部处置。
|
||||
|
||||
| # | 严重度 | 位置 | 问题 | 处置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F1 | 🟥 严重 | `class.arith.js` `can_followcard` 尾段 | 计算 `cardvalue` / `noflower` / `nopair` 时用的是**入参原始数组 `followcards`**(客户端提交顺序),而不是上面已排好序的副本;`get_pairlist` / `get_tuolaji_list` 都按**降序相邻**取对。端到端实测:庄家出红心 KKQQ、闲家用黑桃 KKQQ 主拖拉机毙牌且为末轮——降序提交 `[51,105,50,104]` → `maxseat=1`、闲家捡分 40、扣底 ×4;打乱成 `[51,50,105,104]` → 对子漏判、`cardvalue=0`、`maxseat=0`(庄家赢)、捡分 0(大光)、不扣底。**同一手合法牌,客户端只靠数组顺序就能翻转本轮胜负、捡分归属、扣底与最终结算**;同源问题还能抹掉 `noflower` 缺门标志、污染 §9 下发给全场的牌况表。踩 server 红线「前端不是数据源」「服务器权威」 | 在 `min_ary_deduct` 削减 `_followcards` **之前**另存完整排序快照 `_sortfollow`,尾段 8 处全部改用它(`_followcards` 会被削减、不能复用);函数内注释固化"绝不能用入参 followcards"的原因。补 6 条回归(`test_follow.js` 入参顺序无关性 5 条 + `test_endgame.js` 端到端 1 条),已验证**回退修复后这 6 条全红** |
|
||||
| F2 | 🟧 中等 | `packet_protocol.md` §14 | 「解散结算」被写成 `route=erqiwang / rpc=jiesuan` 的独立包。实际平台(`server_room/rpc.js`、`server_room/class.room.js`)是把 `get_disbandRoom` 的**返回值整个对象**塞进**房间路由**的解散包:`route=room / rpc=free_room`,`data.deskfree = { rpc:"jiesuan", data:{ success, aset, account } }`——**多一层 `data` 包装**,真实路径是 `data.deskfree.data.aset`。前端照原文档写会取空。另外该包外层 `data.success` 由平台填写,子游戏的 `success` 在 `deskfree.data.success`,与 §0.1 的读法不同 | 新增 §14.1「解散结算的投递方式」:给出真实包结构 JSON、三条取值要点、两层 `success` 的归属,并说明 `deskfree` 可能缺失(见 F4);§14 开头标注原表头只适用于正常出牌结算与投降结算 |
|
||||
| F3 | 🟨 轻微 | `packet_protocol.md` 全篇 | 文档在 `multiple` / `bangwang` / `climb` / `baozhu` / `seatlist` / `liangpai` / `pushlist` 等多处引用「roomtype 位2 / 位3」,但**全文从未给出 roomtype 位串定义**——它只存在于本 compliance 文档里。前端据协议文档无法拼出建房串 | 新增 §0.5「`roomtype` 房间选项位串」:位表(位0局数/位1扣卡/位2傍王/位3爬坡/位4查牌)+ 缺省与示例 + 缺失兜底规则 + 局数/扣卡 4 组合对照,并指明 `class.config.js` `parse()` 是唯一解析入口(SSOT) |
|
||||
| F4 | 🟨 轻微 | `class.export.js` `get_disbandRoom` | 未像 `get_deskinfo` 那样判 `o_room.o_desk` / `curr_paiju()` 为空。平台在 `rpc.js` 开战处 `makewar` 后**立刻**置 `battlestate=1`,而本游戏首局是 `makewar` 里 `min_ontimeout(...,1000)` 延迟创建的——这段窗口内解散会在 `curr_paiju()` 的 `undefined` 上解引用抛异常,打断平台整条解散链路 | 两级空守卫,返回 `null`(平台对 `null` 的 `_deskfree` 已有「照常发解散包、不带 deskfree」分支);补 2 条回归用例 |
|
||||
|
||||
**本轮同时复核并确认无误(勿再重提)**:
|
||||
|
||||
- **协议文档字段面**:跑完整局(含 `mingpai`/`tishi`/失败包/投降局)与重连五个阶段,把**实际下发的 `data` 键集**与文档逐包对齐——`fapai / jiaofen / shangzhuang / xuanzhu / maipai / chupai1,2,3 / jiesuan / mingpai / tishi / zhunbei` 及 `deskinfo` 的 `CallRun / ChooseMain / BuryCards / PushCards / Balance`,**字段名、出现条件、按座位裁剪(暗牌仅庄家、`gradecards` 仅闲家、`liangpai` 仅闲家+可查牌、`cardsinhand` 仅出牌者)全部一致**;`errcode` 码表、§0.1 `success` 约定、§0.3「`countdown` 无定时器」说明亦准确。
|
||||
- **`can_followcard` 首家/跟家牌面值的比较基准不一致(首家取拖拉机最小对、跟家取最大牌)**:因同花色同长拖拉机不可能共用点数(两副牌已被首家占满),两段区间必然不重叠,比较结果恒正确,**属既有写法差异、非缺陷**。
|
||||
- **`get_chongguan` 的 `cards.length < 28` 早退**:属隐式兜底写法,但三个调用点的快照恒为 28(闲家)或 36(70 分投降的庄家),**当前不可触发**。
|
||||
|
||||
### 尚未处置
|
||||
|
||||
**无。** F1/F4 已改代码 + 补回归单测(并已用「回退修复→用例转红」反验有效性),F2/F3 已改协议文档。全套单测 **401 项**全绿。
|
||||
|
||||
### 第五轮·复验(design 维度的穷举/差分取证)
|
||||
|
||||
F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。方法上比前几轮更进一步:不再逐条读代码,而是**按 design 原文另写一份参考实现**,再和代码**穷举对拍**——参考实现只依据 design 的文字与表格,不看 `class.arith.js`。**未发现任何不一致。**
|
||||
|
||||
| design 章节 | 取证方式 | 规模 / 结果 |
|
||||
| --- | --- | --- |
|
||||
| §2 牌局构成 · §6.1 分牌 | 直接清点 `init_cards` 产物 | 108 张牌表 / 去 3-4 共 16 张 / 发 92 张 = 三家 84 + 暗牌 8 / 11 种点数各 8 张 + 大小王各 2 张 / 分值 5-10-K / 全场总分 200 ✅ |
|
||||
| §3 主牌顺序 · 对子判定 | 取全部主牌排序后归并成「等级序列」,与 §3 表逐级比对 | 大王-小王-正7-副7-正2-副2-主A…主5 共 15 级完全一致;副牌 A…5 顺序一致;♥7+♣7 / ♥2+♣2 / 大王+小王 均不成对,同花色两副成对 ✅ |
|
||||
| §5.3 拖拉机相邻 | 15 级相邻链逐段验证 + 反例 | 链全通;7 不与 6/8 相连、6 与 8 同花色相连、♥8+♣6 跨花色不相连、副2 只接主A ✅ |
|
||||
| §5.1 / §5.2 跟牌强制层级 | **穷举差分**:按 §5.2 原文写参考裁定(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),随机手牌枚举**全部** C 张出牌组合逐个对拍 | 4000 手牌 / **86474 个出牌组合,失配 0** ✅ |
|
||||
| §5.4.1-3、§5.4.5 甩牌 | **随机差分**:按 §5.4.2 原文写「对手是否持有能压过任一分量的主牌/主对/同长主拖」的参考判定 | **6000 例,失配 0**;副牌禁甩 4 种组合全拒、副牌单一牌型全放行 ✅<br>(首轮出现的 170 例「最小张不符」经查是**同一等级的两副牌选了不同那张**,非分歧——改按牌面等级比对即归零) |
|
||||
| §5.4.4 强制跟牌分量拆解 | 按 §5.4.4 五条逐条造正反用例 | 同长主拖必出 / 无同长退化为主对 / 主对不够全出+主单补 / 主牌不够全出+垫副 / 无主牌随意垫 / 多组不同长拖拉机逐组匹配,12 条全符合 ✅ |
|
||||
| §5.4.5 甩错惩罚落地 | 走 `do_playcard` 真实链路 | 甩错后 `shuaicuo=true`、实际只打出最小一张、其余收回手牌、本轮牌型退化为 101、不再带 `shuai_demand` ✅ |
|
||||
| §6.2 捡分归属 | 造庄赢/闲赢两轮 | 庄家赢 → 台面分牌作废(闲家捡分 0);闲家赢 → **台面全部分牌(含庄家自己打出的)** 计入闲家 ✅ |
|
||||
| §6.3 扣底倍数 | §6.3 表逐格 | 单张 1 / 主对 2 / 2-6 连对 4·6·8·10·12;混合甩牌取最高规格;末轮用副牌或混合牌赢 → 0 不扣底 ✅ |
|
||||
| §7 算子(全部) | **全量对拍**:把 §7.2.1 / §7.3.3 的判定表转写成参考实现,两种算子模式 × 14 个叫分档 × grade 0~260 逐格比 base / Q / 判定 / 最终子数 | **14672 格失配 0**,另抽查 design 表格里写死的最终子数 **113 格失配 0** ✅ |
|
||||
| §8.1 常规算奖 | 逐组合造手牌 | 三王 1 / 四王 3 / 六·七·八个 7 → 1·2·3 / 六·八个 2 → 1·3;连对链 正7→副7→正2→副2→主A→主K 逐级 2·3·4·5·6·7 奖;链必起于正7(无正7 断链)、缺副7 断于正7、副A 对(非主花色)不入链、无三王则无链奖、固定主 ≥10 不给奖 ✅ |
|
||||
| §8.2 亮牌 | 四档阈值边界(含刚好差 1) | 王≥3 / 7≥6 / 2≥6 / 固定主≥10 分别触发,各自差 1 即不触发;输出只含数量与结构、无具体牌面 ✅ |
|
||||
| §8 快照时间点 | 走真实埋牌/出牌/投降链路 | 庄家=埋牌后 28 张(已埋 8 张不在内、出牌后不缩水)、闲家=发牌后 28 张、投降局庄家 36 张且 `flower=-1` ✅ |
|
||||
| §8.4 算奖并入结算 | §8.4 举例数值 | `X=6, N=[1,2,0]` → `aw=[0,18,-18]`,三条支付线零和;只庄家持奖 → 另两家各付 X ✅ |
|
||||
| §4.2 叫分 | **穷举全部叫分序列**(DFS,叫分集 {70,50,25,10,5}+不叫) | **80 条终局路径**:受理判定、待叫者永不落在已「不叫」者身上、上庄者恒为最低叫分者、上庄条件恒为「叫 5」或「另两家都不叫」,**异常 0**;首家不能不叫、75/71/67/3/-5 全按 PARAM 拒 ✅ |
|
||||
| §4.3 暗牌可见性 · §4.4 投降 | 非 70 分 / 70 分两条链路 + 重连 | 非 70 分暗牌只给庄家(闲家无 `bottomcards`、无 `ancard3s`、无 `cards`);70 分三家都收到 8 张 + `ancard3s=1`;重连一律只给庄家(3 秒亮牌不重放);`touxiang` 仅 70 分为 1;投降 base=1、庄 -2 闲各 +1 ✅ |
|
||||
| §4.6 轮庄 | 三种 result | 庄赢连庄 / 闲赢顺延下家 / 投降顺延下家;首局暂定庄家为 0 号座位 ✅ |
|
||||
| §9 查牌模式 · §10 房间设置 · §11 提示 | 可查牌与不查牌两套房间跑同一段对局 | 可查牌下 `chupai.seatlist` 整表三家、`baozhu` 有值、重连带 `pushlist`/`seatlist`;不查牌下三者全无、`baozhu` 恒 0、明牌回 RULE;**当前轮桌面牌(`playproc`)两种模式都下发**;局数 6/12、房主 2/4、AA 1/2 与加入者扣卡 4 组合正确;提示只转发给对家、庄家发 → RULE、非法 tip → PARAM;不操作则 `step`/`playproc` 不变(无超时托管)✅ |
|
||||
|
||||
> 一句话结论:**就 design.md 而言,服务端实现完整且准确**。本轮两次「疑似失配」(甩错最小张 170 例、闲家赢轮捡分 15 vs 10)经查**都是探针写错**,代码是对的;已在上表注明,后续核对不要再把它们当缺陷重提。
|
||||
>
|
||||
> **已固化两组差分测试入库**(其余取证为一次性探针):`test/test_calc.js`(§7 全量对拍,7308 格 + design 表 141 格)与 `test/test_followdiff.js`(§5.2 穷举差分,约 11 万组),全套单测由 401 项增至 **462 项**,总耗时仍 < 1.5s。
|
||||
>
|
||||
> 两组都做过**变异检验**:关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率 3→4 / 常规 55 档 base 4→5 / 爬坡 40·35 档 Q 20→40 → `test_calc` 精确指出档位与 grade。
|
||||
>
|
||||
> ⚠️ 固化过程中的一个教训(改这两个文件时别踩回去):**差分测试的强度取决于造牌器,不取决于对拍逻辑**。第一版用纯随机抽牌,跑 7.8 万组全绿,但关掉 `follow_tractor_cover_ok` **依然全绿**——因为「同花色三组互不相邻的两连对」(12 张)这种唯一能区分"只出零散对子"与"先凑最长拖拉机"的结构,随机抽根本抽不出来(少于 3 组时任取 3 对必含相邻对,测不出差别)。补上确定性的 `structHands()` 后才转红。同理,掺入的其他花色一度把这手撑到 15 张、超过枚举上限被**静默跳过**,表面照常全绿。**"跑了很多组且全绿"不等于"测到了",新增差分测试必须做变异检验。**
|
||||
|
||||
---
|
||||
|
||||
## 第六轮核对(多局大局 + 整局带牌型模糊)
|
||||
|
||||
> 前五轮的端到端**都只跑单局、且只出单张**。本轮补两个从未被驱动过的维度:① `design §12.1 步骤10 → §12.2` 的**多局大局**(跨局轮庄、累计分、大局结算、战绩);② **带牌型的整局链路**(对子/拖拉机/甩牌/甩错/毙牌/扣底与 `do_playcard`→`mod.chupai`→结算的集成)。**未发现任何不一致。**
|
||||
|
||||
| 维度 | 取证方式 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| §12.1 步骤10 · §4.6 跨局轮庄 | 真实驱动 6 局(RPC 全链路,含每局 `zhunbei` 开新局) | 每局 `firstseat` 与「上局 banker + result」严格对应;另单独构造出 `result=0` 的**庄赢局**验证**连庄**(此前只有 `do_prepare` 的桩单测,端到端从未走过)✅ |
|
||||
| §7 累计分 | 6 局 | 每局零和;`desk.seatlist[i][0]` == 逐局累加;`aset.seatlist[i].score` == 累计分 ✅ |
|
||||
| §12.2 大局结算 | 6 局房与 12 局房各跑满 | `account` **只在末局**出现(6 局房第 6 局、12 局房第 12 局);打满后再 `zhunbei` 回 RULE 拒 ✅ |
|
||||
| §10.1 扣房卡 | 6 局 | 全程只扣一次(第一局结算),`save_grade` 一次 ✅ |
|
||||
| 战绩 | `save_grade` 载荷 | `gameinfo1.asetcount`/`players.score` 与累计分一致;`gameinfo2` 每局 `seatlist`/`banker`/`call`/`flower`/`result` 齐全 ✅ |
|
||||
| §12.2 中途解散 | 打完 2 局后在第 3 局解散 | 解散局 `multiple/upgrade` 均 0、三家得分 `[0,0,0]`、`result=3`;`account` 累计分 == 解散前累计分("按当前累计分结算")✅ |
|
||||
| 牌局隔离 | 每局开新 paiju | 新局重新发满 92 张;上一局的 `tmp_jiesuan_aset` 已清理 ✅ |
|
||||
| §5/§6/§7/§8 整局带牌型 | 160 局 / 约 3800 墩(4 个种子 × 40 局),**每一墩**用独立参考实现重算胜者与服务端比对 | 多张首出、合法甩牌、甩错、毙牌墩、扣底均大量出现;牌张守恒 84/8/16、捡分守恒、结算零和、扣底触发条件、算奖分配公式**全部相符,0 异常** ✅ |
|
||||
|
||||
**本轮两次"疑似缺陷"经查都不是**(勿再重提):
|
||||
|
||||
- 某局结算 `[0,0,0]`:是 `jf=[4,-8,4]` 与 `aw=[-4,8,-4]` 恰好抵消,不是漏算。
|
||||
- 首轮 40 次尝试跑不出庄赢局:是**用例设计反了**——叫 5 分是闲家最容易达标的档(`grade ≥ 5` 即赢),应叫 70 分才易守。改后立刻出现。
|
||||
|
||||
**固化产物**:新增 `test/test_fuzz.js`(整局模糊 + 每墩胜者差分,20 局约 480 墩,8 项聚合断言,0.3s)。多局大局部分为一次性探针未入库(跑满 12 局较慢,且其不变量已由 `test_desk`/`test_endgame`/本轮探针共同覆盖)。全套单测 **511 → 519 项**全绿。
|
||||
|
||||
---
|
||||
|
||||
## 第七轮核对(反方向:代码 → design)
|
||||
|
||||
> 前六轮都是「design → 代码」方向:拿规则去找代码有没有实现。本轮换**反方向**——从代码出发,把服务端每一个规则决策与常量拉出来,逐个追问「design 里有没有明文依据」。找的是两类此前查不到的东西:**代码做了 design 没授权的事**,以及 **design 有歧义时代码替规则做的选择**。
|
||||
>
|
||||
> **结论:没有发现与 design 冲突的行为。** 但列出 6 项「design 未明文规定、由代码自行决定」的项——它们不是缺陷,但也从未被规则设计者确认过,建议逐条拍板后写回 design。
|
||||
|
||||
| # | 代码的决定 | design 依据 | 性质 |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | 四个阶段的倒计时秒数 **15 / 20 / 25 / 30**(`class.desk.js`) | §11 只说「四个阶段都会给出一个倒计时秒数」,**未规定具体值** | 实现选择,需确认 |
|
||||
| R2 | **§5.4.4 拖拉机分量的降级阶梯不要求「最长优先」**:甩牌里有 3 连对分量而闲家只有 2 连对时,代码允许出任意 3 对,不强制先凑出那个 2 连对 | §5.4.4 第 1 条明文写「没有同等连对数的拖拉机,退而求其次用**同等张数的主对子**顶替」——支持代码;但该节开头又说「沿用 §5.2 强制层级同一原则」,而 §5.2 层级 2 要求「能凑多长就必须先凑多长」。**两处措辞可作两种解读** | 歧义,代码按显式的第 1 条实现 |
|
||||
| R3 | 服务端把提交的一组主牌按「**连续对子最大化合并成拖拉机**」分解(`decompose_trump`) | §5.4.3 只说甩牌可自由混搭,**未规定服务端如何分解**。该选择同时影响最大性判定、跟牌分量需求、扣底倍数(合并对甩牌方更有利:`AAKKQQ` 记 3 连对 ×6 而非 2 连对 ×4) | 实现选择,需确认 |
|
||||
| R4 | 两个**同级**的副 7 对(♥7对 + ♣7对)**不构成连对** | §5.3 的链只列一次「副7」,同级即非相邻——代码按此。但 design 未直说 | 解读,合理 |
|
||||
| R5 | `call` 用 `parseInt` 宽松解析:`"65"`(字符串)与 `65.5`(小数截断为 65)都会被接受;而 `cards` 是严格校验(字符串数字/小数一律拒) | design 不管入参类型;协议 §0.2 只对 `cards` 定了严格约束。**两者标准不一致**,但截断后语义正确、不改变规则结果 | 一致性瑕疵,非规则违反 |
|
||||
| R6 | `get_chongguan` 对 `cards.length < 28` 静默返回 0 奖 | 隐式兜底(违反工程总则「显式失败优于隐式兜底」)。三个调用点的快照恒为 28 或 36,**当前不可触发** | 代码风格,非缺陷 |
|
||||
|
||||
### 本轮新增取证:下发面泄露审计
|
||||
|
||||
design §4(暗牌只有庄家可见 / 70 分亮 3 秒)、§9(查牌模式)、§11(结束亮底牌)与 server 红线「**发全 ≠ 发多,按可见性下发**」此前**没有任何测试**——各处门控是逐条手工核对的,从未系统验证过「有没有哪个包把不该看的牌送到了某个座位」。
|
||||
|
||||
本轮把它做成可执行审计(`test/test_leak.js`):跑完整局(可查牌 / 不查牌 × 叫 65 / 70 / 5 共 6 局),对每一个「服务器 → 某座位」的下发面(含 6 个 RPC 的逐座位包 + 各阶段重连快照 + 明牌应答)做深度扫描,取出其中出现的**所有牌 id**,逐个判定该座位此刻是否有权知道这张牌。
|
||||
|
||||
**结果:1814 个下发面 / 18706 次「座位×牌」可见性判定,0 泄露。**
|
||||
|
||||
变异检验(注入真实泄露确认审计有效):
|
||||
|
||||
| 注入的泄露 | 结果 |
|
||||
| --- | --- |
|
||||
| 非 70 分也把 8 张暗牌发给闲家(这正是第三轮修过的历史缺陷) | ✅ 抓住 |
|
||||
| 重连时把庄家埋的底牌发给闲家 | ✅ 抓住 |
|
||||
| 出牌包把出牌者剩余手牌发给所有人 | ✅ 抓住 |
|
||||
|
||||
---
|
||||
|
||||
## 第八轮核对(按 流程 / 牌型 / 玩法 / 算分 四个维度复核)
|
||||
|
||||
> 规则方复述了一条叫分规则:「开局庄家先叫分,不允许不叫分;第一局在没有庄家的情况下,默认座位号 0 玩家开始叫分」。据此按四个维度重新取证,**未发现不一致**。
|
||||
|
||||
**新增取证(此前从未做过):阶段机迁移矩阵。** 前几轮每个 handler 只测了 1~2 条代表性失败路径,从没系统验证过「某个请求在**别的**阶段会不会被误放行、会不会静默丢弃」。本轮把 8 个 RPC × 5 个阶段做成 **40 格矩阵**逐格驱动:
|
||||
|
||||
| | step1 叫分 | step2 选主/投降 | step3 埋牌 | step5 出牌 | step6 结算 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| jiaofen | ✓ 受理 | × STEP | × STEP | × STEP | × STEP |
|
||||
| xuanzhu | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
|
||||
| touxiang | × STEP | ✓ 受理 | × STEP | × STEP | × STEP |
|
||||
| maipai | × STEP | × STEP | ✓ 受理 | × STEP | × STEP |
|
||||
| chupai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| mingpai | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| tishi | × STEP | × STEP | × STEP | ✓ 受理 | × STEP |
|
||||
| zhunbei | × STEP | × STEP | × STEP | × STEP | ✓ 受理 |
|
||||
|
||||
**40 格全部符合**,无误放行、无静默丢弃。(`mingpai` 在 step5 还需「已有人报无主」这一前置,首轮矩阵曾把它标红——是期望表漏了前置条件,不是缺陷。)
|
||||
|
||||
**叫分起始者规则逐条取证**(`design §4.2 + §4.6`,与规则方复述一致):
|
||||
|
||||
| 规则 | 取证 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 第一局默认座位 0 开始叫分 | `makewar → do_new_paiju(0)` → `firstseat=0`、待叫者=0 | ✅ |
|
||||
| 之后每局由**暂定庄家**先叫 | 连打 6 局,每局 `firstseat` 与「上局 banker + result」逐局比对 | ✅ |
|
||||
| 庄赢 → **连庄**(起始仍是该庄家) | 随机对局几乎打不出庄赢,用「叫 70 分 + 首家出最大牌」专门构造出 `result=0` 局 | ✅ |
|
||||
| 庄输 / 投降 → 起始顺延到下家 | 6 局中逐局验证 | ✅ |
|
||||
| **首家不允许"不叫"** | 第一局与其后每一局(含连庄局)的首家发 `call=0`,一律回 `RULE` 且叫分过程不变 | ✅ |
|
||||
|
||||
四个维度的现有取证汇总:
|
||||
|
||||
| 维度 | 取证手段 | 规模 |
|
||||
| --- | --- | --- |
|
||||
| **流程** | 阶段机 40 格矩阵、叫分序列 DFS 80 条终局、多局大局(6 局/12 局)、轮庄含连庄、中途解散、端到端整局 | 本轮新增矩阵 |
|
||||
| **牌型** | §3 等级序列逐级、§5.3 相邻链(副牌同花色 / 主牌不限花色 / 同档位不相邻)、§5.2 跟牌穷举差分 11 万组 | `test_arith`/`test_followdiff` |
|
||||
| **玩法** | §5.4 甩牌最大性 6000 例差分、甩错惩罚、§5.4.4 分量拆解、整局带牌型模糊每墩胜者差分、§9 查牌门控、下发面泄露审计 1.8 万次判定 | `test_fuzz`/`test_leak` |
|
||||
| **算分** | §7 算子全量对拍 7308 格 + design 表 141 格、§6.3 扣底、§8.1 算奖、§8.4 分配公式、结算零和 | `test_calc`/`test_paiju` |
|
||||
|
||||
**固化产物**:新增 `test/test_flow.js`(7 项聚合断言,0.3s)。变异检验:去掉 `chupai`/`maipai` 的阶段校验、把 `jiaofen` 的阶段校验改成静默丢弃——三条全部转红。全套单测 **540 → 547 项**全绿。
|
||||
|
||||
---
|
||||
|
||||
## 结论摘要
|
||||
|
||||
> **注**:下表是**首轮核对**的初始发现快照(保留以记录起点)。其中 §4.4、§5.4、§6.3、§7、§8.1-8.4、§9、§10、§11 等标 🟥/🟧/🟨 的项**后续均已修复并验证**——当前逐节状态以本文档后面的分节详情及全套单测(270 项全绿)为准。
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
## 5. 当前状态小结 —— ✅ DoD 达成,P0/P1/P2 全部完成
|
||||
|
||||
**共 361 项断言全绿**(`test_arith` 106 / `test_callgrade` 6 / `test_config` 19 / `test_deal` 12 / `test_desk` 6 / `test_follow` 50 / `test_input` 34 / `test_paiju` 31 / `test_rpc` 68 / `test_success` 29)。
|
||||
**共 511 项断言全绿**(`test_arith` 122 / `test_calc` 49 / `test_callgrade` 6 / `test_config` 19 / `test_deal` 12 / `test_desk` 6 / `test_endgame` 25 / `test_follow` 58 / `test_followdiff` 12 / `test_input` 34 / `test_paiju` 50 / `test_rpc` 89 / `test_success` 29)。其中 `test_calc`/`test_followdiff` 见 §7、G1~G9 见 §8。
|
||||
|
||||
design 全部服务端可验证章节均有正/反/边界单测:
|
||||
- **§2 构成**:`test_deal` 92张/去3-4/三家28+底8/分值。
|
||||
@@ -182,6 +182,55 @@ design 全部服务端可验证章节均有正/反/边界单测:
|
||||
| §9 出牌历史按查牌模式门控 | `get_deskinfo` | 反:不查牌房无 `pushlist`;正:可查牌房有 `pushlist`;边界:两种模式下当前轮桌面牌 `playproc.cards` 都必须恢复 | `test_rpc` | ✅ 4 |
|
||||
| §3 相邻链花色 | `is_continuous` | 正:同花色 8-6 连;反:三组跨花色 8-6 不连;反:差 2 但非 8/6 不连 | `test_arith` | ✅ 5 |
|
||||
|
||||
**关于超时**:`countdown` 只作展示、服务端不做任何超时动作,这是 design §11 确认的规则(并非缺口),因此**没有也不需要「超时自动操作」的用例**;反过来,若将来有人给服务端加了超时代打,那才是违反 design §11,应由核对拦下。
|
||||
**关于超时**:`countdown` 只作展示、服务端不做任何超时动作,这是 design §11 确认的规则(并非缺口),因此**没有也不需要「超时自动操作」的用例**;反过来,若将来有人给服务端加了超时代打,那才是违反 design §11——现已由 `test_rpc` 的**守卫用例**拦下(见 §8 G9)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 第五轮:按 design 原文另写参考实现的差分测试
|
||||
|
||||
> 前六节的用例都是**手写的场景枚举**——覆盖的是"我想到的情况"。第五轮补两组**差分测试**:把 design 的规则整条转写成参考实现,与代码大面积对拍,覆盖手写用例想不到的组合。
|
||||
|
||||
| 文件 | 覆盖 | 规模 |
|
||||
| --- | --- | --- |
|
||||
| `test_calc.js` | §7 算子全量对拍:参考实现逐字转写自 §7.1/§7.2/§7.3 判定表,比 base / Q / 判定倍率 / 最终子数 | 2 模式 × 14 档 × grade 0~260 = **7308 格**,另按 design 的 17 张表逐格抽查 **141 个写死的最终子数** |
|
||||
| `test_followdiff.js` | §5.2 跟牌强制层级穷举差分:参考裁定按原文写,对每手牌**枚举全部 C 张出牌组合**逐个对拍 | 约 **11 万组** |
|
||||
|
||||
**差分测试的强度取决于造牌器,不取决于对拍逻辑。** 第一版 `test_followdiff` 用纯随机抽牌跑 7.8 万组全绿,但把 `follow_tractor_cover_ok` 整个关掉**依然全绿**——因为唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状是「同花色三组互不相邻的两连对」(12 张),随机抽根本抽不出来。故:
|
||||
|
||||
- 随机源必须是**固定种子 PRNG**(不用 `Math.random`),保证用例集可复现;
|
||||
- 关键结构必须用**确定性手牌**钉死(`structHands()`),不能指望随机覆盖到;
|
||||
- 注意别让掺入的其他花色把手牌撑过枚举上限而被**静默跳过**(踩过一次)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 第五轮:逐规则的正/反/边界补全
|
||||
|
||||
> 对 design §1~§12 重新做了一次"每条规则是否三类用例齐全"的审计。覆盖面本来就广,但仍有 9 处 design 明文规则缺用例(多是**规则本身**而非入参校验,前几轮的矩阵按"被测函数"组织,容易漏掉这类"跨函数才体现"的规则)。
|
||||
|
||||
| # | design | 规则 | 补的用例(正/反/边界) | 文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| G1 | §4.2 | 暂定庄家必须叫、其后才可"更低或不叫" | 边界:下限 5 接受;正:非首家 `call=0` 接受;反:负数 / 缺 `call`(NaN) / 步进外小值 1 / 与当前叫分相同 均拒 | `test_rpc` |
|
||||
| G2 | §4.2 | **"不叫"即退出本局叫分,轮次跳过他** | 正:首家叫 65 后轮到 seat1;正:seat1 不叫后跳到 seat2;反:seat1 反悔再叫 → SEAT 拒、`callproc` 不变、仍轮到 seat2、未产生庄家 | `test_rpc` |
|
||||
| G3 | §4.5 | 埋牌仅庄家、仅埋牌阶段、恰 8 张 | 反:非庄家 → SEAT;反:step2/step5 埋 → STEP;边界:7 张 / 9 张 / 空数组 → PARAM | `test_rpc` |
|
||||
| G4 | §5.1 | **每轮由上一轮牌面最大的一方先出** | 正:闲 1 赢 → 下一轮 `start/currseat` 都是 1;边界:闲 2 赢 → 改由闲 2 先出 | `test_paiju` |
|
||||
| G5 | §6.2 | 闲家赢 → 台面**全部**分牌计入;庄家赢 → 分牌作废 | 反:庄赢 → 下发面无 `grade`、闲家捡分 0;正:闲 1 赢 → 15 分(**含庄家自己打出的 ♥5**);边界:闲 2 赢结果与闲 1 赢一致("两个闲家谁大不影响") | `test_paiju` |
|
||||
| G6 | §8 | 算奖快照的时间点与静态性 | 正:庄家 = 埋牌后 28 张、已埋 8 张不在内;正:闲家 = 发牌后 28 张;边界:投降局庄家 = 发牌+暗牌 36 张;边界:之后打出 10 张,快照仍是同样的 28 张 | `test_paiju` |
|
||||
| G7 | §3 | **对子只认同花色同点数的两副** | 正:同花色两副 ♥7/♥2/大王成对;反:♥7+♣7、♥2+♣2、正7+副7、正2+副2、大王+小王 均不成对;边界:四张跨花色 7/2 组不出拖拉机,而正7对+副7对可以 | `test_arith` |
|
||||
| G8 | §6.3 | 赢末轮的牌不全是主牌就不扣底 | 反:副牌两连对赢 → 0;反:混合出牌(主K+副K)→ 0;边界:主副提交顺序颠倒仍判 0 | `test_arith` |
|
||||
| G9 | §11 | **无超时托管**(守卫) | 守卫:叫分→选主→埋牌→出牌全程 `min_ontimeout` 调用次数为 0。将来若有人加"超时自动出牌/代打",必然要注册定时器,这条即转红 | `test_rpc` |
|
||||
|
||||
**每条都做了变异检验**(在副本上注入违反该规则的改动,确认对应断言转红):
|
||||
|
||||
| 注入的缺陷 | 转红的断言 |
|
||||
| --- | --- |
|
||||
| 去掉 `mod.jiaofen` 的"非当前叫分位"校验 | G2 三条 |
|
||||
| 埋牌张数 `!= 8` 放宽为 `< 1` | G3 两条边界 |
|
||||
| §6.2 改成庄家赢也累计闲家捡分 | G5 一条 |
|
||||
| 下一轮改为固定庄家先出 | G4 两条 |
|
||||
| 算奖快照不排除已埋的牌 | G6 两条 + 亮牌快照一条 |
|
||||
| `get_pairlist` 放宽为"只看点数" | G7 五条 |
|
||||
| 在 `mod.chupai` 里注册一个定时器 | G9 一条 |
|
||||
|
||||
> 过程中的一个教训:M2(埋牌张数)第一次跑出来是**整个文件崩溃**而不是干净的 FAIL——因为 `mkBury` 桩缺 `do_burycard`,校验一被改松就走到成功路径抛异常,后面的断言全不执行。**桩要完整到能走完成功路径**,否则"校验被改松"这类变异只会表现为崩溃,定位与信号都变差。已修。
|
||||
|
||||
> 原列在此处的两项「待外部输入」均已由规则设计者拍板:「不查牌模式是否屏蔽出牌历史」确认屏蔽,已改 design §9 + 代码 + 用例(见上表);「超时托管默认动作」确认维持现状、不做超时动作,已写入 design §11。至此测试计划无待外部输入项。
|
||||
|
||||