网狐荣耀版十三水UI实战:分墩交互、拖拽与避坑指南
简介网狐荣耀版十三水UI组件包面向棋牌游戏开发者与界面设计师以13水UI为核心标签用于解决十三水罗宋牌对局中牌面分组、按钮布局、视觉风格与触控反馈统一的问题。压缩包大小约8.15MB以zip格式封装文件类型与数量未在资源页完整展示解压后通常可得到牌面与按钮的图片素材、界面布局描述、音频配置及交互参数方便在既有框架中替换资源或按项目需求扩展。已有1458人学习、下载适合需要快速搭建十三水游戏界面或进行二次定制的团队参考。通过拆解资源内thirteenx目录可梳理控件层级关系、主题配色、比牌动画与得分栏展示的组织方式对双屏适配、操作提示和音效触发等模块均有直接借鉴价值。这套设计兼顾静态排版与动态反馈既照顾了牌区分组的清晰度又在按钮尺寸、按压状态和动画节奏上匹配移动端操作习惯能减少从零绘制UI的时间尤其适用于快速原型验证、风格改版或整合到网狐荣耀版项目中的场景。1. 网狐荣耀版做十三水难点不在画牌桌而在「分墩」这件小事十三水的UI第一眼看上去就是一张牌桌加13张牌比斗地主还简单。但真在网狐荣耀版上动手做一次你会发现最难的不是画牌桌而是「分墩」玩家必须在3张头墩、5张中墩、5张尾墩之间反复调整牌型判定、拖拽交互、动画时序全压在这一个动作上。这篇文章把十三水UI从布局、分墩交互到比牌动画、断线重连完整过一遍重点讲参数怎么设、坑在哪。适合两类人一是在网狐荣耀版上接棋牌定制需求的客户端开发二是想搞清楚十三水UI技术难点在哪里的产品、测试或刚转行的新人。2. 十三水UI的牌桌布局先定三墩结构再写摆放参数2.1 网狐荣耀版UI层的基本结构场景节点与牌桌容器网狐荣耀版的客户端UI层常见做法是基于Cocos或Laya这类引擎搭H5场景。整张牌桌是一个场景节点下面挂背景、座位区、手牌区、操作按钮、弹窗等子节点。节点树的组织方式决定了后面加动画、做重连时改代码的顺手程度。table-scene ├── background // 背景图固定在最底层 ├── seat-layer // 四个座位对手的下牌区、自己的三墩区都挂这里 │ ├── seat-1 │ ├── seat-2 │ └── seat-3 ├── hand-card-layer // 自己的13张手牌位于屏幕底部 ├── op-btn-layer // “理牌”“确认”“提示”按钮 └── popup-layer // 比牌结果弹窗、断线提示等这段节点树和真实项目的差异在于有的团队会把三墩区挂在每个座位节点下有的会单独建一个 zone-layer。我一般建议把「自己的三墩区」独立出来因为十三水的核心交互都发生在这里座位节点只负责显示对手的牌背和结果避免后续改动画时把座位逻辑一起拖下水。节点层级定了之后剩下就是坐标和尺寸的约定。设计分辨率一般按 1280x720 做后续换安卓、iOS、Web端只用做等比缩放。我这个项目里第一版直接按960x640做结果换到平板整个牌桌偏小最后全部重排所以一开始就把设计分辨率定成主流档位后面能少走很多弯路。2.2 三墩区域与手牌区的坐标规划头3中5尾5怎么摆三墩区的位置是十三水UI的第一个关键参数。常规做法是从屏幕底部往上依次放手牌区13张牌、尾墩区5张、中墩区5张、头墩区3张。为什么头墩放最上面因为头墩牌最少视觉上放在顶部最轻尾墩是比牌的胜负关键放最下面离手牌最近方便玩家把大牌往上推。以 1280x720 设计分辨率为基准我常用的参数是这样// 十三水牌桌核心布局参数设计分辨率 1280x720 const tableLayout { cardW: 100, // 牌宽 cardH: 150, // 牌高 handY: 80, // 手牌区中心Y坐标 tailY: 230, // 尾墩区中心Y坐标 midY: 350, // 中墩区中心Y坐标 headY: 470, // 头墩区中心Y坐标 zoneGap: 30, // 相邻墩位区的最小间距用于判定拖拽落点 handGap: -55, // 手牌区相邻牌间距负值重叠 };先说明倒数第二行 zoneGap 的作用三墩区之间的垂直间距是拖拽手感的决定因素。间距太小手指头一粗就拖到隔壁墩间距太大屏幕高度不够用。30像素是我在720p分辨率下的常用值如果你的牌更大适当加到40牌小就压到20。再说明手牌区为什么用负间距 -5513张牌如果摊开放13×1001300像素超出1280屏宽所以必须重叠。-55的间距让手牌区实际宽度约等于 100 12×(100-55)640 像素居中显示既放得下又不会太挤。每张牌叠压在下一张上面视觉上像收拢的扇形。这里还要注意发牌顺序和排序的关系。我见过很多团队在开局就把13张牌按点数排好然后再让玩家分墩这个顺序其实是错的。玩家习惯看到自己拿到的牌就像现实中摸牌一样先按发牌顺序展示点「理牌」按钮时才排序。新手的常见误解是以为开局就要整理实际上一开局就排序会让玩家对「系统动过我的牌」产生不信任感。注意这里的Y坐标是「节点中心位置」不是锚点底部。Cocos/Laya 的节点默认锚点是 (0.5, 0.5)如果锚点改了坐标要对应调整。这是新手最容易翻车的地方我调试时被这个坑过整整一个下午。2.3 三墩内部牌间距为什么头墩要正间距、尾墩要负间距三墩的张数不同——头墩3张、中墩5张、尾墩5张。如果都按手牌区那套负间距头墩3张会挤成一团尾墩5张又会超出屏幕。我按「区域视觉等宽」来调每个墩内部的牌间距区域张数单牌宽牌间距区域总宽头墩310012324中墩5100-6476尾墩5100-6476手牌区13100-55640头墩3张用正间距12看起来像三张牌「摊开」中墩和尾墩用负间距-6五张牌轻轻叠在一起既不超出区域范围又能看清每一张的牌角——牌角是点数标记的位置必须露出来否则玩家认不出牌。这里有个工程上的处理细节每个墩内部牌从左到右排列后一张牌叠在前一张上面。叠放顺序z-order必须是「后加入的牌在前面」否则点牌时会点到被压住的那张。// 把牌加入某个墩位区并保持正确的叠放顺序 function addCardToZone(cardNode, zoneNode) { zoneNode.addChild(cardNode); cardNode.setSiblingIndex(zoneNode.children.length - 1); // 置于顶层 }z-order这行注释是重点。大部分十三水UI的「点不到牌」「点了没反应」问题都出在这里而不是出在触摸事件没绑上。排这块的时候我习惯先做一个「点击牌就打印牌ID」的调试开关把所有牌叠放顺序打一遍肉眼就能看出哪张被哪张盖住了。关于自适应不同分辨率下这套参数要整体缩放。我一般只缩放 cardW 和 cardH间距跟着牌宽等比走不单独改。如果新手非要单独调结果就是某台手机上牌间距怪怪的这属于自找的别问我是怎么知道的。3. 分墩交互自动分墩算法与拖拽换牌的落地实现3.1 自动分墩算法从13张牌里切出三墩的判定逻辑自动分墩是十三水UI的「标配中的标配」。玩家开局点一下「理牌」系统立刻给出一个合法且牌型尽量大的三墩排列。这个功能的难点有两个一是合法性判断头墩 ≤ 中墩 ≤ 尾墩二是选最优解还是选「够用解」。我先写一个可读性优先的解法# 自动分墩穷举头墩3张 中墩5张的所有组合 from itertools import combinations def auto_split(cards): best None best_score -1 for head in combinations(range(13), 3): rest1 [i for i in range(13) if i not in head] for mid in combinations(rest1, 5): tail [i for i in rest1 if i not in mid] # 合法性头墩牌型 中墩 尾墩 if cmp_hand(cards, head, mid) 0: continue if cmp_hand(cards, mid, tail) 0: continue # 评分三墩牌型分相加取最大 score calc_score(cards, head, mid, tail) if score best_score: best_score score best (head, mid, tail) return best这个穷举的复杂度是 C(13,3)×C(10,5)72072 次在手机上纯JS跑会有几百毫秒的卡顿能接受但体验不好。所以工程上一般不直接这么写而是先排序再做启发式裁剪。常见做法是先把13张牌按点数降序排好优先从尾部挑出最大的5张组成尾墩再从剩余8张里挑最大的5张组成中墩剩下的3张做头墩如果发现中墩比尾墩大之类的非法情况再做局部交换。两种方案各有用处穷举能拿到「当前手牌下最大牌型」适合玩家点一下就不管的场景启发式更快但偶尔会给出「虽然合法但头墩太弱」的结果。我一般把启发式作为默认穷举只用在玩家点「提示」按钮时因为此时玩家期待的是最优解。这里有个参数值得单独说cmp_hand的比较顺序必须按十三水规则来。先比牌型级别同级别再比点数牌型级别值至尊清龙16一条龙15十二皇族14三同花13三顺子12六对半11报到10同花顺9四条8葫芦7同花6顺子5三条4两对3对子2单张1A2345 这种顺子视为最小顺子10JQKA 是最大顺子这两个边界在写比较函数时最容易漏。我见过线上版本把 A2345 判成非法牌型玩家截图骂了一个礼拜。3.2 拖拽换牌与墩位交换触摸事件的三步处理自动分墩只是起点玩家大概率还要手动调整。拖拽交互的完整流程是按下浮起 → 拖动跟随 → 落点判定。落点判定之后还有两个分支落点区域未满就直接加入已满就把区域内最靠边的一张牌挤回手牌区或者交换当前拖拽牌与该区域内最后一张牌。// 拖拽事件处理Cocos Creator 风格Laya 逻辑一致 onTouchStart(event: EventTouch) { const card this.hitTestCard(event.getUILocation()); if (!card) return; this.dragCard card; card.y 25; // 浮起高度25px视觉上明显但不脱离手牌区 } onTouchMove(event: EventTouch) { if (!this.dragCard) return; // 节点跟随手指移动注意坐标空间必须是同一个 this.dragCard.setPosition(event.getUILocation()); } onTouchEnd(event: EventTouch) { if (!this.dragCard) return; const zone this.hitTestZone(event.getUILocation()); if (zone zone.cards.length zone.maxCount) { // 区域已满把最外侧的牌挤回手牌区当前牌放进来 const backCard zone.cards[zone.cards.length - 1]; this.backToHand(backCard); } if (zone) { this.addCardToZone(this.dragCard, zone); } else { this.dragCard.y - 25; // 落点无效回到原位 } this.dragCard null; }浮起高度25px是手感调出来的太小玩家看不出选中太大牌会遮挡其他牌。落点检测这里用的是AABB矩形判定检测的矩形要比实际牌区域放大20px左右因为手指点的位置和牌的视觉中心有偏差。墩位已满的「挤回」策略有一个细节被挤回的牌不一定是拖进来的那张牌的替换对象。更通用的做法是交换——把拖进来的牌放进区域末尾同时把区域里离落点最近的那张牌换出来回手牌区。这个「离落点最近」的判断代码上就是遍历区域内每张牌的中心点算欧氏距离取最小。还有一个手感玄学拖拽时手指会挡住牌面。我习惯在 onTouchMove 里把被拖的牌往上偏移30px让牌的底部露出来玩家能看到自己拖的是什么。这个小改动放到真机上玩家反馈明显变好。3.3 提交协议与回滚本地合法不等于服务器合法分墩完成后客户端要把结果提交给服务端。这一步的协议结构各家不同但核心信息一致牌ID 它属于哪个墩位。{ cmd: submit_zones, seatId: 0, zones: [ { zoneId: 0, cards: [1001, 1002, 1003] }, { zoneId: 1, cards: [1004, 1005, 1006, 1007, 1008] }, { zoneId: 2, cards: [1009, 1010, 1011, 1012, 1013] } ] }服务端收到后会重新校验这13张牌是否属于当前玩家、三墩牌型是否合法。校验失败返回错误码UI要做两件事一是回滚到提交前的墩位状态二是给玩家一个明显提示。回滚的工程实现我习惯在提交前保存一份「墩位快照」就是每个zoneId到cards数组的映射。收到失败消息后把快照还原到UI同时播放一个轻微的抖动动画提示玩家。这里不需要重新算牌因为本地数据其实没变过只是界面状态被玩家拖乱了。注意千万不要在收到服务器校验结果前就清空本地状态。有些实现为了「快」提交成功就把手牌区置空结果服务器返回失败时手牌已经没了只能重新开局拉数据这属于低级但常见的翻车现场。我后来是直接在提交按钮的回调里加了一个isSubmitting锁提交期间按钮置灰服务端没回包之前什么都不许点。4. 比牌动画、结果展示与状态同步别让时序毁了整局体验4.1 比牌数据结构谁赢了哪一墩所有玩家提交分墩后服务端计算比牌结果并按约定下发。比牌结果的数据结构一般包含三墩的胜负和总分胜负{ cmd: compare_result, roundId: 128, zones: [ { zoneId: 0, winner: 1 }, { zoneId: 1, winner: -1 }, { zoneId: 2, winner: 2 } ], totalWinner: 0 }winner字段用座位号标识赢家-1表示平局。UI消费这份数据时要注意每个墩的winner指的是「这墩谁赢了」而不是「谁牌最大」。因为平局在十三水里意味着「没赢也没输」展示上比一般扑克游戏多一个「平」的状态。UI拿到这份数据后要做的是先亮三墩的牌再在每墩附近弹出胜者标记最后弹总分结果。这个展示顺序不能乱。从玩家视角看头墩、中墩、尾墩的胜负是分开的偶尔出现「头墩我输了尾墩我赢了总分我赢」的情况如果顺序乱了玩家根本理解不了总分为什么是这个结果。这里还要考虑四个座位同时都要展示的问题。服务端下发的数据里通常每个座位都会有一份 zones 对比结果。UI可以在比牌阶段把所有座位的手牌一次性翻开然后按座位逐个播三墩对比。做之前先确认清楚服务端的数据是全局的还是视角相关的我在这上面栽过一开始只处理了自己的数据结果其他三个座位的比牌结果全是默认值。4.2 串行动画序列先翻墩、再亮结果比牌动画最忌讳并行播放。如果三墩的亮牌和胜负标记同时出现玩家的视线完全不知道聚焦哪里。正确做法是串行从头墩开始先翻转该墩的牌从牌背翻成牌面停顿再显示该墩胜者标记停顿然后进入下一墩尾墩播完再弹总胜负。// 串行播放三墩的比牌动画 async function playCompare(result: CompareResult) { const seq [0, 1, 2]; // 播放顺序头墩、中墩、尾墩 for (const zoneId of seq) { await flipZoneCards(zoneId); // 该墩所有牌翻面 await sleep(600); // 停留600ms让玩家看清牌型 await showWinnerMark(zoneId, result.zones[zoneId].winner); await sleep(400); } await showTotalWinner(result.totalWinner); await sleep(800); // 总结果停留久一点 UIManager.close(ComparePopup); }两个时间参数是经验值翻牌后停600ms是为了让玩家在这段时间里自己先判断「这墩谁大」然后再亮胜者标记如果没这个停顿玩家会觉得结果是被硬塞进来的。总结果停留800ms后自动关闭但实际项目里这个弹窗一般需要玩家点击确认才关闭因为紧接着就是下一局的发牌。动画播放期间还有一个工程细节引擎的节点操作大多是异步帧驱动的如果中途玩家点了「跳过」要能立刻把动画走到终态。实现方式是每步动画都检查一个isSkipped标志播完当前帧就跳出循环直接设置三墩的最终显示状态。我见过有人把跳过实现成「把动画速度调成100倍」结果动画系统直接崩溃属于血泪经验。4.3 超时、托管与断线重连UI状态机的三个边界分墩交互有时限一般15到30秒。倒计时是服务端权威客户端不能只在本地起一个定时器因为断线重连、网络延迟都会造成本地时间与服务端不一致。常见做法是服务端在开局消息里带上「剩余秒数」客户端收到后以这个值为基准做本地递减每收到一次心跳或同步消息就校正一次。// 倒计时的本地推进逻辑 onRecvDealMsg(msg) { this.countdown msg.timeLimit; // 以服务端下发的剩余秒数为准 this.schedule(this.tick, 1); // 每秒推进 } tick() { this.countdown - 1; if (this.countdown 0) { // 超时本地不做提交等待服务端自动分墩结果 this.unschedule(this.tick); this.showAutoSubmitTip(); } }超时的UI表现各项目不同。有的在倒计时归零时直接置灰操作按钮等服务端广播「已自动分墩」后再进入比牌有的会先本地触发自动分墩给玩家看一眼。我建议前者——超时的处理完全交给服务端客户端只负责「变灰」和「等待」避免本地自动分墩的结果和服务端不一致导致界面闪一下又回滚。断线重连是十三水UI最容易漏的状态。重连成功后服务端会下发一整套牌桌场景数据当前阶段、剩余倒计时、手牌、三墩状态、所有对手的牌状态。UI拿到后要能整体重建而不是增量修改。这个重建过程最容易出错的是重建时不能触发拖拽事件绑定、不能重置倒计时定时器导致时间跳跃。我的做法是重连消息进来时先设一个isReconstructing标记跳过所有玩家的输入事件处理。这里还要多说一句托管状态。有些房间规则里超时后服务端直接下发托管分墩客户端不要自作主张再上一套「自动分墩」逻辑。托管分墩的结果是服务端算的客户端只管展示。5. 十三水UI开发的血泪避坑记录5个高频问题5.1 手牌排序「看着乱」排序与花色优先级现象自动分墩后手牌区13张牌的排列忽大忽小明明有对子却被拆开玩家一眼就觉得牌序不对。原因排序只按点数排没把花色纳入比较或者排序发生在分墩之后把墩位内的相对顺序也打乱了。解决排序做两级——先按点数降序点数相同按花色降序黑桃红心梅花方块排序只作用于手牌区展示层不改变分墩数据结构。代码上就是 sort 的比较函数里写两个维度// 手牌排序点数优先花色次之 cards.sort((a, b) { if (a.point ! b.point) return b.point - a.point; return b.suit - a.suit; // suit 值黑桃3 红心2 梅花1 方块0 });另一个容易被带偏的实现有人会在排序的同时把牌重新发给牌节点导致牌节点的触摸事件丢失。排序只动数组和节点位置不要重建节点。5.2 本地判定合法提交后被服务器打回现象玩家手动把三墩摆好了本地显示一切正常点击确认后服务器返回「分墩不合法」UI回滚玩家一脸懵。原因最常见的两种——A2345顺子被本地当成了「顺子」实际上十三水的规则里A2345是合法顺子且是最小顺子但有些实现漏了这个特例另一种是本地没有做「头墩 ≤ 中墩 ≤ 尾墩」的完整链式比较只比较了相邻两墩。解决把牌型判定抽成一个独立模块同时被UI校验和提交前二次校验复用在代码里显式处理A2345特例提交前重新执行一次完整的三墩合法性检查不让玩家把不合法的布局发出去而不是等服务器打回。这个独立模块我一般叫hand-rule.js只放纯函数不依赖任何引擎API。这样UI层和提交协议层都能引用也方便写单元测试。5.3 动画播放中收到新消息时序错乱现象比牌动画正播到中墩下一局的发牌广播就到了结果动画还在翻牌界面出现「牌叠着牌」「胜者标记飘在半空」的错乱。原因动画是异步串行消息处理是同步顺序执行两者没有互斥保护。解决加一个状态锁动画开始时置位动画结束复位动画期间收到的UI类消息全部压入队列动画结束后统一取出来处理。// 动画期间屏蔽业务消息 if (this.isAnimating) { this.pendingMessages.push(msg); return; } // 动画结束后统一补处理 async playCompareDone() { this.isAnimating false; while (this.pendingMessages.length 0) { const msg this.pendingMessages.shift(); this.handleMsg(msg); } }注意这个锁只能锁「业务消息」像心跳、断线重连这种系统级消息不能压队列否则重连逻辑会被无限期拖住。我一开始把所有消息都压队列结果断线重连的消息排在发牌后面界面白屏了十秒才恢复。5.4 特殊牌型检测被普通牌型短路现象玩家手牌明明是「三同花」甚至「一条龙」点提示时UI只提示普通牌型最佳策略没出来。原因检测顺序写反了。代码里先判断普通牌型一旦命中就用 else if 跳过后面的特殊牌型检测特殊牌型根本没机会执行。解决严格先特殊后普通并且特殊牌型之间也要有优先级——至尊清龙 一条龙 十二皇族 三同花 三顺子 六对半 报到。报到在十三水规则里是特殊牌型里的最小档但它仍优先于所有普通牌型。检测函数写成一张有序规则表逐条匹配而不是散落的 if/else。这个问题隐蔽在大多数测试数据都是普通牌型特殊牌型要凑才能测到。我后来在测试工具里加了一个内置发牌器可以一键发出指定牌型专门用来测这条链路。5.5 开局手牌先空白再闪烁纹理加载顺序现象进入牌桌瞬间手牌区先显示一片空白然后13张牌「啪」一下同时出现偶尔还闪白屏。原因牌面的纹理贴图是异步加载的开局时13张牌同时创建节点各自的纹理没加载完就上屏。引擎的回调顺序不可控叠加的过程被玩家看到了。解决开局前先预加载所有牌面纹理加载完成再创建牌桌节点。牌节点要做对象池——每局结束不要销毁回收复用避免反复创建节点带来的GC卡顿。如果项目用远程图库加载牌面记得把「加载完成再开局」处理成倒计时的一部分而不是等到加载失败再重试。玩家看到牌桌黑屏几秒的耐心是有限的。6. 十三水UI的验证清单上线前花一小时自测这些点这套自测清单是我每次交付十三水UI前必跑的六项快的话一小时能跑完倒计时边界进入分墩阶段后等倒计时自然归零确认按钮必须置灰且不能出现「超时后还能提交成功」的报错同时确认超时后服务端下发的自动分墩结果正确展示。拖拽落点边界手牌区与尾墩区的交界线附近松手看落点是否稳定归位到某一侧不能出现「半只牌在墩里、半只牌在手牌区」的悬空状态。墩位满员交换三墩全满时从手牌区拖入新牌看被挤回的牌是否正确回手牌区、顺序是否对。重点测尾墩满5张时拖第6张。断线重连在分墩阶段杀进程重进验证倒计时、手牌、三墩全部恢复再在比牌动画播放到一半时断网验证动画状态锁能正常兜住。快速连点连续快速点两次「确认」不能发出两次提交连续点两次「理牌」自动分墩不能把已经摆好的牌打乱。特殊牌型展示用测试工具分别发出至尊清龙、一条龙、报到、三同花这几手牌验证提示文案、牌型标记、比牌时的特效都正确。每跑完一项截一张图或者录一段视频存档。我自己的习惯是每接手一个十三水UI需求先把上面6条录成一段操作视频再动代码。倒计时、动画状态锁、纹理预加载这三块几乎每轮迭代都会有人改出错视频能让你快速定位是回归还是新问题。玩棋牌UI的都知道这类交互不需要多花哨稳定不闪就是最好的体验。希望帮到你。本文还有配套的精品资源点击获取