ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos Creator麻将棋牌游戏开发实战:从牌局逻辑到打包优化

Cocos Creator麻将棋牌游戏开发实战:从牌局逻辑到打包优化 简介这是一份基于Cocos Creator 2D引擎开发的达达麻将棋牌游戏完整项目资源面向有一定JavaScript基础、希望学习网络棋牌游戏前后端架构的开发者适合用作上线运营项目的参考实现。资源覆盖了Cocos Creator场景与UI搭建、JS游戏逻辑编写、Node.js后端服务以及MySQL数据存储等核心环节并包含安全性、性能优化与兼容性测试等运营层面的思考。压缩包共2000个文件大小约51.62MB主要文件类型包括js脚本、json配置、meta资源索引、prefab预制体、png贴图、anim动画、mp3音频以及fire场景文件等可据此还原客户端工程结构与后端模块。目前已有3238人学习下载内容对理解棋牌类游戏的匹配、数据同步与热更新机制具有实际参考价值。1. Cocos Creator 做达达麻将棋牌游戏起手式与方案选型接到一个叫“达达麻将”的棋牌项目时需求文档其实就三行出得了108张牌、吃碰杠胡能跑通、能打包Android和iOS。但真用 Cocos Creator 把第一版跑起来你会发现棋牌这类游戏的技术难点不在渲染而在“规则引擎 状态流转 包体管控”画面反而是最省事的一层。做这种项目Cocos Creator 的价值在于编辑器切场景快、UI 构建顺手、2D 渲染对麻将这种密集型界面几乎不需要额外调优同时资源管理和跨平台导出的链路是现成的。这个方向适合独立开发者、小团队和接外包的人如果你手里同时维护好几个项目Creator 3.x 的 TypeScript 逻辑层也方便沉淀成一套通用麻将框架换皮换个美术资源就能复用。这篇文章会把从场景搭建到打包落地的完整路径拆开讲包括该抄的代码和该躲的坑。2. 牌桌场景搭建节点层级、图集资源和第一版可点的牌2.1 为什么节点层级必须按“桌子”和“区域”切分麻将牌桌如果只是拖一张背景图进去然后把所有节点全挂到 Canvas 下面前期确实很快但一旦开始做发牌动画、手牌扇面、吃碰杠临时展示区直接挂 Canvas 的节点没法统一管理层级和偏移会变成一盘散沙。我一般会把场景结构切分成这样// GameRoot 下的子节点层级划分Creator 3.x this.gameRoot new Node(GameRoot); this.tableArea new Node(TableArea); // 牌桌背景、牌河区 this.playerArea new Node(PlayerArea); // 四个玩家座位 this.actionArea new Node(ActionArea); // 吃碰杠胡按钮 this.effectLayer new Node(EffectLayer); // 特效层 this.uiLayer new Node(UILayer); // 弹窗、倒计时 this.gameRoot.addChild(this.tableArea); this.gameRoot.addChild(this.playerArea); this.gameRoot.addChild(this.actionArea); this.gameRoot.addChild(this.effectLayer); this.gameRoot.addChild(this.uiLayer);这套结构里最容易被忽略的是effectLayer。麻将的出牌、碰杠、胡牌都需要短暂的高亮反馈如果特效节点直接挂在牌节点下节点销毁时特效也跟着消失单独放一层特效生命周期可控也不干扰牌河区的坐标计算。playerArea里四个座位节点我习惯用固定节点名seat_0到seat_3后续逻辑里按座位索引取节点遍历和赋值都方便省去每次find()的字符串查询开销。GameRoot本身不挂任何渲染组件它只负责整体缩放和位移。后面做多分辨率适配时只需要调整GameRoot的 scale而不是改每一个座位节点。2.2 牌面纹理图集方案和 Prefab 实例化麻将的牌面总共是 34 种万条筒各 9 张加上字牌一套完整的还要算牌背和花色条。如果每种牌单独加载一张 PNGdrawcall 必然失控真机上的表现会非常难看。常见做法是把牌面、牌背全部打成一张图集texture atlas运行时按名字取SpriteFrame保证这些 Sprite 能合批渲染。import { resources, AtlasAsset, SpriteFrame, Sprite, Node, instantiate } from cc; // 从 resources 加载图集再按牌名取 SpriteFrame resources.load(table/atlas/mahjong_table, AtlasAsset, (err, atlas) { if (err) { console.error(加载图集失败, err); return; } // 牌名规范pai_wan_1一万、pai_tiao_9九条、pai_feng_dong东风 const sf: SpriteFrame atlas.getSpriteFrame(pai_wan_1); const spriteComp this.tileNode.getComponent(Sprite); spriteComp.spriteFrame sf; // 或者通过 Prefab 实例化 const prefab resources.load(table/prefab/MahjongTile, Prefab, (err, asset) { const tileNode instantiate(asset); this.playerArea.addChild(tileNode); }); });这里关键点是牌名规范必须稳定。后续从后端同步一条牌数据比如数字0代表一万要能通过一个纯函数映射到pai_wan_1不要在每个界面里散落字符串拼接。我习惯把映射函数放在单独模块// 根据牌编码返回图集中对应的 SpriteFrame 名称 export function getTileTextureName(code: number): string { if (code 9) return pai_wan_${code 1}; // 0-8 万 if (code 18) return pai_tiao_${code - 9 1}; // 9-17 条 if (code 27) return pai_tong_${code - 18 1}; // 18-26 筒 return pai_feng_${code - 27}; // 27-33 字牌 }特别注意图集里字牌命名如果用中文“东风”“红中”也会有跨平台编码问题我用拼音或英文缩写看起来麻烦但 fonts 和命名空间都省心。2.3 手牌扇面的弧形排版坐标计算和插值自己手牌 13 张要排成扇形AI 玩家的手牌则要牌背朝上排成紧凑行列。扇形排版最直接的做法是在每张牌之间等分角度然后围绕一个虚拟圆心做极坐标插值// 手牌从左到右呈扇形排列fanCenter 为扇形的圆心 const startAngle -40; // 起始角度左手边第一张的位置 const endAngle 40; // 结束角度右手边最后一张的位置 const radius 420; // 扇形半径需要根据屏幕实际尺寸调整 function layoutFan(tileNodes: Node[], centerPos: Vec3): void { const count tileNodes.length; if (count 0) return; const step (endAngle - startAngle) / (count - 1); for (let i 0; i count; i) { const angle (startAngle step * i) * Math.PI / 180; // 扇形坐标x 向右增长z 方向用负值让中心的手牌更靠前 const x centerPos.x radius * Math.sin(angle); const y centerPos.y - radius * Math.cos(angle) radius; // 这里直接设置位置实际项目里应配合 tween 做发牌动画 tileNodes[i].setPosition(x, y, 0); // 每张牌按角度旋转牌面朝向圆心 tileNodes[i].setRotationFromEuler(0, 0, angle * 180 / Math.PI); } }参数radius和startAngle / endAngle不是拍脑袋出来的要对着设计稿调并且要区分 16:9 和 19.5:9 两种屏幕。牌桌背景一旦锁定扇形的圆心位置就固定了如果你后面发现牌叠在一起优先调radius而不是角度——角度越大边缘牌的倾斜越夸张观感反而差。这一步做完你已经有一个能点、能看、静态可验证的牌桌了。左边手牌能出扇形右侧三个 AI 玩家区各有一排牌背下一步才是让牌真正“活”起来的核心逻辑。3. 核心牌局逻辑编码、洗牌、胡牌判定一锅端3.1 牌型编码为什么用 033 的一维数组麻将牌的编码方式决定了后续所有算法的写法。我一开始在项目里用过{ suit: wan, num: 1 }这种对象结构结果胡牌判定和听牌提示写了满屏的if (tile.suit tiao)。后来直接改成一维整形34 种牌分别对应 033判断花色只需要检查边界值// 牌编码0-8 万9-17 条18-26 筒27-33 字牌 export const WAN_1 0; export const TIAO_1 9; export const TONG_1 18; // 字牌27 东28 南29 西30 北31 中32 发33 白 // 判断是否字牌字牌不能组顺子 export function isZi(code: number): boolean { return code 27; } // 判断同花色且相连用于顺子检测 export function isSameSuit(a: number, b: number): boolean { return Math.floor(a / 9) Math.floor(b / 9); } // 每个花色第一张和最后一张的边界 export function getSuitCount(code: number): number { if (code 9) return code % 9 1; // 万1-9 if (code 18) return code % 9 1; // 条1-9 if (code 27) return code % 9 1; // 筒1-9 return code - 26; // 字牌1-7东到白 }用这种编码之后顺子判断就是“三个连续的整数且第一张牌不是当前花色的第 7、8、9 张”。这句话写进注释里回头自己看代码也不容易错。手牌的数据结构我用number[]下标就是 033值是该牌的张数0 到 4这样胡牌判定可以直接在数组上做减法不用频繁创建临时对象。有了编码回头看牌桌那一层——界面显示只需要把code丢给getTileTextureName()逻辑层完全不关心显示层。这也是麻将项目里最常见的前后端数据协议格式一局牌就是一个Uint8Array或普通数字数组压缩和调试都方便。3.2 洗牌与发牌Fisher-Yates 和可复现的随机种子洗牌用 Fisher-Yates这不需要犹豫。可犹豫的是“要不要做可复现的随机种子”。实际开发中线上用户报“这局牌有问题”你让运营拉 replay 数据发现牌序在本地死活复现不了就是因为Math.random()没种子。我在达达麻将里用一个极轻量的 mulberry32 算法做随机// 可复现伪随机数生成器mulberry32 function mulberry32(seed: number): () number { return function () { seed | 0; seed (seed 0x6D2B79F5) | 0; let t Math.imul(seed ^ (seed 15), 1 | seed); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; } // Fisher-Yates 洗牌使用可复现随机源 function shuffle(tiles: number[], rand: () number): number[] { for (let i tiles.length - 1; i 0; i--) { const j Math.floor(rand() * (i 1)); [tiles[i], tiles[j]] [tiles[j], tiles[i]]; } return tiles; } // 初始化一局牌108 张素麻将 function newDeck(): number[] { const deck: number[] []; for (let code 0; code 34; code) { const copies code 27 ? 4 : (code 33 ? 0 : 0); // 示例只取万条筒 for (let c 0; c copies; c) deck.push(code); } return deck; } // 实际使用时const rand mulberry32(20240415); const deck shuffle(newDeck(), rand);mulberry32的核心价值不是效率高而是“同一个 seed 永远产出同一串牌序”。测试阶段把 seed 写死发牌、碰杠、胡牌全部走同一局牌bug 复现率近乎 100%线上环境再用时间戳 seed。newDeck()里字牌的数量按规则调整标准麻将 136 张就把字牌那行注释打开。发牌流程相对简单庄家 14 张、闲家 13 张从洗好的牌堆顶部依次弹出。这里有个容易错的地方是座位顺序与发牌顺序、出牌顺序必须统一我建议在逻辑层维护一个roundIndex每次出牌后(roundIndex 1) % 4不要让界面层的“视觉上的下一个座位”来驱动逻辑。3.3 胡牌判定递归回溯拆牌法含七对胡牌是麻将算法绕不开的核心。标准胡牌条件是“一对将 四组顺子或刻子”因此最直接的做法是递归回溯。先把 14 张手牌转成 34 宽计数数组cnt[]然后递归拆解// 手牌数量数组 cnt[34]已经累计了 14 张 function canWin(cnt: number[], hasPair: boolean): boolean { // 找到第一个有牌的牌型 let i 0; while (i 34 cnt[i] 0) i; // 所有牌都拆完了已经有一对将就胡 if (i 34) return hasPair; // 1) 拆刻子三张一样的牌 if (cnt[i] 3) { cnt[i] - 3; if (canWin(cnt, hasPair)) { cnt[i] 3; return true; } cnt[i] 3; } // 2) 拆对子作为将只能有一对 if (!hasPair cnt[i] 2) { cnt[i] - 2; if (canWin(cnt, true)) { cnt[i] 2; return true; } cnt[i] 2; } // 3) 拆顺子同花色三张连续牌字牌不能参与 // 必须是该花色的第 1-7 张才能往后连 const suitIndex Math.floor(i / 9); const numInSuit i % 9; if (suitIndex 3 numInSuit 6 cnt[i 1] 0 cnt[i 2] 0) { cnt[i]--; cnt[i 1]--; cnt[i 2]--; if (canWin(cnt, hasPair)) { cnt[i]; cnt[i 1]; cnt[i 2]; return true; } cnt[i]; cnt[i 1]; cnt[i 2]; } return false; }这段代码有三个边界特别容易踩。第一hasPair参数必须贯穿递归否则同一副手牌会拆出两个将误判胡牌。第二顺子条件numInSuit 6漏了的话cnt[7]一花色第 8 张会被错误拿去和“下一花色的第一张”组顺子跨花色胡牌 bug 非常隐蔽。第三递归回调后必须恢复cnt数组原值否则下一次分支判断拿到的是脏数据。七对是特殊情况在进入canWin之前单独判断function isSevenPairs(cnt: number[]): boolean { let pairCount 0; for (let i 0; i 34; i) { if (cnt[i] % 2 ! 0) return false; pairCount cnt[i] / 2; } return pairCount 7; }有了canWin听牌提示就是“枚举 34 种牌各加一张然后判断是否能胡”这点代码量在整个逻辑模块里占比很小function getTingCodes(cnt: number[]): number[] { const ting: number[] []; for (let t 0; t 34; t) { if (cnt[t] 4) continue; // 手里已有 4 张不能再胡 cnt[t]; if (canWin(cnt, false) || isSevenPairs(cnt)) ting.push(t); cnt[t]--; } return ting; }听牌枚举的耗时在 13 张手牌规模下是微秒级不需要优化真到了 56 张的“一锅牌全在手”测试场景才发现递归调用会膨胀到时候再加哈希缓存也不迟。4. 流程管理吃碰杠的动作仲裁与状态机轮转4.1 动作优先级胡 碰 杠 吃谁说了算麻将的流程控制核心是先到先得还是优先级仲裁实际规则是一个人出牌后其他三家可能同时具备多个动作资格比如一家能碰、另一家能吃但碰的优先级高于吃。所以必须做“先收集、后仲裁”。我定义了一个统一消息结构enum MahjongAction { PASS 0, // 过 HU 1, // 胡 PENG 2, // 碰 GANG 3, // 杠 CHI 4, // 吃 } interface ActionRequest { playerIndex: number; // 哪个玩家的动作 action: MahjongAction; tile: number; // 要碰/杠/吃/胡的牌 priority: number; // 计算后的优先级 } // 动作优先级权重表胡最高吃最低 const ACTION_PRIORITY: RecordMahjongAction, number { [MahjongAction.PASS]: 0, [MahjongAction.HU]: 100, [MahjongAction.PENG]: 70, [MahjongAction.GANG]: 70, // 碰杠并列再比较座位顺序 [MahjongAction.CHI]: 40, };一个常见误用是把GANG优先级设得比PENG高但竞技麻将规则里暗杠和碰的冲突按“一家胡优先、多家抢杠看顺序”处理杠本身不比碰高。优先级的真正作用是先确定“这一轮有谁可以操作”再决定“只有一家能吃/碰时直接结算多家时弹操作菜单”。4.2 模式化状态机Idle、WaitDiscard、WaitAction 与结算牌局流程本质是一个有限状态机。这章不加状态机后面联机时你就会发现所有房间同步都变成一团乱麻。我常用的枚举状态是enum GameState { Idle IDLE, // 空闲等待开局 Dealing DEALING, // 发牌动画中 WaitDiscard WAIT_DISCARD,// 等待当前玩家出牌 WaitAction WAIT_ACTION, // 有人出牌后等待其他玩家操作 Draw DRAW, // 出牌后摸牌 Settling SETTLING, // 一局结束 }每次状态切换时我会打印一行带gameId state playerIndex的日志。线上排查非常靠它比如用户反映“我碰不了”日志显示他在WaitAction阶段发的碰请求被拒原因是服务端认为他的动作窗口已经过期这样问题定位就从“玄学”变成了流程文件。WaitAction是最容易出 bug 的阶段。我把它细分成一个等待窗口出牌后清理上一轮所有按钮启动倒计时常见设定 15 秒并收集各玩家发来的ActionRequest15 秒到或者所有幸存玩家都回 PASS才结束窗口。判定动作最快的方式不是等所有请求都回来而是“拿到一个胡请求就立即结算”因为胡优先级最高且不和其他动作并行。// WaitAction 阶段的请求处理 class ActionStage { private requests: ActionRequest[] []; onActionRequest(req: ActionRequest): void { if (req.action MahjongAction.HU) { this.finishWith(req); // 胡最高优先级直接结算 return; } this.requests.push(req); if (this.requests.length this.waitPlayersCount) { this.arbitrate(); // 所有玩家请求齐了做仲裁 } } private arbitrate(): void { // 按优先级排序相同优先级按座位顺序决定 this.requests.sort((a, b) b.priority - a.priority || a.playerIndex - b.playerIndex); const winner this.requests.find(r r.action ! MahjongAction.PASS); if (winner) this.finishWith(winner); else this.moveToNextDiscard(); } }这里排序不是绝对安全的因为碰和杠同时发生时顺序应该看“离出牌人最近的下家”还是“动作发起时间”不同规则有不同解释我一般把这块做成可配置的仲裁函数而不是硬编码排序。4.3 超时兜底玩家不点按钮流程不能卡死本地测试时你最恨的 bug 不是逻辑错而是“轮到某个 AI 玩家出牌它没反应”。如果你已经把逻辑和表现完全分离那这个 bug 多半是状态事件没派发出去而不是 AI 没决策。为了兜底我在WaitDiscard阶段挂了一个setTimeout// 进入 WaitDiscard 时启动超时 private startDiscardTimer(timeoutMs: number): void { this.clearDiscardTimer(); this.discardTimer setTimeout(() { console.warn(WaitDiscard 超时自动出牌player, this.currentPlayer); // 自动打出当前手牌里最靠左的那张 const tile this.getAutoDiscardTile(); this.discardTile(tile); }, timeoutMs); } private clearDiscardTimer(): void { if (this.discardTimer) { clearTimeout(this.discardTimer); this.discardTimer null; } }超时时长在在线游戏一般设 2030 秒本地 AI 测试可以设 5 秒。用这种统一超时还有一个额外好处性能测试时把超时设短可以快速循环牌局压测状态机稳定性。4.4 手牌选中与出牌交互触摸中断和二次确认手牌交互的坑往往出在“快速点击两张牌”上。玩家点第一张牌选中在扇形动画还在播放时又点第二张旧牌选中态没有清除。解决方案是在触摸事件入口加一个全局 release 检查public onTileTouch(tileNode: Node, event: EventTouch): void { // 防止在动画播放中的误触tween 未结束不允许重复选中 if (this.isAnimating) return; const tile tileNode.getComponent(TileComponent).data; if (this.selectedTileCode tile) { // 二次点击同一张牌确认出牌 this.requestDiscard(tile); this.selectedTileCode -1; this.clearTileHighlight(); } else { // 第一次点击先清理上一次选中再高亮新牌 this.clearTileHighlight(); this.selectedTileCode tile; this.highlightTile(tileNode); } }isAnimating这个开关很关键。不加它手牌从扇形“归位到出牌区”的过程中玩家连点两张牌会让牌飞到错误的位置加了它最多就是玩家手速快时感觉偶尔“卡一下”但动画本身就是从手牌区飞向牌河停留时间不超过 200ms完全可接受。到这里本地单机一局的骨架已经能完整跑起来。下一步是把它往多人方向推——网络层怎么接这就进入棋牌项目最头疼的环节。5. 避坑清单达达麻将开发中踩过的 4 个真实大坑5.1 多分辨率适配一张牌桌图在刘海屏上被截断现象同一张 1280×720 的牌桌背景图在 19.5:9 全面屏手机上左右两边被裁掉近 10%座位区按钮直接挤到屏幕外。原因场景的 Canvas 分辨率设置和实际构建时的fit width/fit height策略不一致GameRoot用了固定setPosition没有做 widget 对齐。解决把 Canvas 设计分辨率定成1280×720勾选fit width代码里拿到实际屏幕尺寸后再调整GameRoot的 scale。推荐方式不是在整个场景上挂 widget而是把牌桌背景节点设为 “Center 等比放大”让背景图始终填满屏幕短边。// 在启动时统一计算 GameRoot 缩放 const designWidth 1280; const designHeight 720; const screenSize view.getVisibleSize(); const scale Math.max(screenSize.width / designWidth, screenSize.height / designHeight); this.gameRoot.setScale(scale);用Math.max意味着画面可能超出设计稿的某一维但保证牌桌完整可见剩余部分靠背景边缘的渐变色兜底。测试阶段必须覆盖 iPhone 老款16:9、主流全面屏19.5:9和折叠屏展开状态这个坑在只用一个浏览器预览窗口时发现不了。5.2 手牌扇形动画帧率不同导致牌位抖动现象发牌动画在编辑器里流畅但在低端 Android 机上手牌落到扇形位置时明显“抖”一下像被弹了一下。原因发牌动画用了每帧直接setPosition且依赖dt手动累加位移不同帧率下每帧位移步长不同低帧率时步长过大最后一步和多边形排版终点有落差。解决统一改用tween缓动设置固定的easing和duration不要自己写更新循环import { tween, Vec3 } from cc; // 用 tween 从当前坐标飞到目标扇形点位时长 280ms const targetPos new Vec3(fanX, fanY, 0); tween(tileNode) .to(0.28, { position: targetPos }, { easing: quadOut }) .call(() { // 动画结束后做一次坐标对齐消除浮点残差 tileNode.setPosition(targetPos); }) .start();用tween后动画由引擎的定时器统一驱动在所有帧率下都是按插值曲线走目标终点一致。这里的教训是“动画交给引擎逻辑只给起点和终点”不要自己在update里做运动的最后一公里调度。5.3 DrawCall 失控牌一多瞬间掉到 20 帧现象四人牌桌场上同时存在手牌、牌河、按钮、特效drawcall 从 15 飙到 60 多低端机发烫明显。原因牌面 SpriteFrame 来自多个不同图集按钮加了独立 shadow 材质特效层的 MotionStreak 纹理参与了合批。解决第一步把所有牌面和牌背收进同一张 2048×2048 图集确保所有牌 Sprite 共用同一个材质和纹理第二步统一整理 UI 资源的打包分组不让按钮和牌面混图集第三步给特效层设置独立不参与合批的优先级免得它的纹理把牌桌的批次全部截断。优化后 4 人牌桌总 drawcall 稳定在 25 到 30 之间和空场景几乎一致。提示麻将项目里真正的 drawcall 瓶颈往往不是“牌的数量”而是“不同纹理的切换”。只要牌、牌背、操作按钮、特效各自归属不同的图集游戏切换时产生 45 次批次重置就说明资源归类合理一旦超过 8 次优先调整资源布局而不是加图集。5.4 断线重连后牌桌状态对不上现象联机调试时玩家 A 碰完一手牌后闪退重连进来发现自己手牌数变成了 14 张牌河少了一张牌。原因客户端在断线期间只靠本地事件累加状态没有向服务端拉取全量同步本机state已经走完了“碰→出牌”服务端却因为断线只推进到“碰完未出牌”。解决重连时不能只补增量消息必须由服务端下发给客户端完整的 “牌局快照”客户端整个状态覆盖。快照至少要包含四家手牌数组、当前牌河、当前状态机状态、当前操作玩家、最后一张出的牌。客户端收到快照后重建整个场景废弃掉本地所有中间状态。第 5 章的四个坑前两个偏客户端表现第三个偏渲染性能第四个偏网络架构。做联机棋牌时前两个是体验问题第三个是沉没成本第四个是致命伤。如果只挑一个在项目第一天就定方案我会选第四个的状态快照协议——它决定了你的游戏是“能玩的 demo”还是“能上线的产品”。6. 打包 APK 与 MotionStreak 实战调优打包这一步Creator 3.x 的构建面板已经比 2.x 省心不少但默认配置并不能拿来直接跑真机。我先说构建 Android APK 的三个关键点。第一构建面板里选择“资源优化”的纹理压缩格式。麻将牌面是颜色鲜明的默认 RGBA 8888 在低端机上加载慢我一般选 ETC2 或 ASTC 6x6包体体积能降近一半观感损失在可接受范围。第二注意构建模板里的API Level设置。现在主流应用商店都要求 target API 31 以上Creator 构建时把这个值拉高避免华为、小米应用市场审核直接拒包。第三主包和资源分包如果麻将项目带了多套皮肤比如节日限定牌面把非必要的皮肤资源和音效放远程包首包控制在 60MB 以内用assetManager.loadBundle在进大厅后动态加载。MotionStreak 在这类棋牌里的实战场景通常是出牌拖尾。一局出牌飞向牌河时给牌加一小段拖尾能明显改善操作反馈。我挂到effectLayer上的节点写法如下import { MotionStreak, Vec3, Node } from cc; const trailNode new Node(discardTrail); trailNode.addComponent(MotionStreak); const trail trailNode.getComponent(MotionStreak); // 关键参数fadeTime 拖尾淡出时间minSeg 最小分割距离strokeWidth 宽度 trail.fadeTime 0.3; // 0.3 秒拖尾消失手感干净不拖沓 trail.minSeg 5; // 像素小于这个值不追加节点避免顶点过多 trail.strokeWidth 18; // 出牌牌体宽度的 1/3太宽会遮住牌河 trail.texture this.discardTrailTexture; // 用一张横向渐变的白色软线条纹理 this.effectLayer.addChild(trailNode);minSeg这个参数是血泪经验得来的。默认值太小时牌飞得慢会堆积大量顶点一张好的拖尾消耗十几个 drawcall调到 5 之后低端机上也没有明显的拖尾断裂感。验证阶段我的习惯是每改一次 MotionStreak 参数就在真机上连续打出 30 手牌数帧率顺便用旧机型对照。性能不是靠眼睛看出来的要盯着 profiler 看 drawcall 和顶点数。最后说一个整体性的习惯牌局逻辑和表现层分离的程度决定这个项目的长期价值。达达麻将的核心逻辑模块你不应该看到任何Node、Sprite、tween它只接收code数组和返回结果只有从底层就把逻辑和表现完全切开后面的换皮肤、接后端、做机器人 AI 才能都变成“配置问题”而非“重构问题”。这个习惯我是在第三个棋牌项目上才想明白的前两个都死在“界面没调完就写玩法玩法没写完又去改界面”的循环里。希望帮到你。本文还有配套的精品资源点击获取
返回列表