news 2026/10/13 7:31:54

斗地主HTML5源码拆解:牌型引擎、AI与前端状态机

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
斗地主HTML5源码拆解:牌型引擎、AI与前端状态机

简介:《斗地主HTML5游戏源代码》是一份面向前端开发者和游戏学习者的完整实践资源,以人机对弈为核心玩法,展现从页面结构、界面美化到游戏规则判定与AI决策的完整开发链路,适合用作HTML5游戏入门及进阶的参考项目。压缩包共15个文件,其中10张PNG图片负责游戏场景与牌面素材,2个JS脚本承载核心逻辑与AI算法,另有1个HTML入口页面、1份说明文档和1个MP3音效,整体仅2.53MB,轻量便于部署。该资源目前已有3144人学习使用。从源码中可重点研读JS文件里人机AI的策略设计(如模拟对手出牌逻辑),以及CSS3动画、响应式布局如何配合游戏交互;说明.txt还梳理了目录结构和运行方法,降低了上手门槛。无论想理解HTML5游戏整体架构,还是借鉴卡牌游戏交互实现,这份源码都提供了清晰完整的参考,值得动手实践与二次开发。

1. 斗地主HTML5源代码:你找的其实是一套牌型引擎,不只是界面

搜“斗地主html5源代码”的人,十有八九是带着一个具体念头来的:想拿一个能跑、能改、能直接上线对战的H5项目。界面和美术反而不值钱,真正决定一套斗地主代码能不能用的,是藏在底层的那套牌型引擎——十七张牌怎么洗、怎么识别顺子和飞机、AI该不该拆牌、什么时候才舍得炸。这篇文章把这些拆成五段来写:牌型建模、出牌AI、前端状态管理、常见坑、最后怎么自测。你适合在哪个环节复用、要改哪些参数,都会给到;与其说它介绍某份源码,不如说把斗地主H5最常见的工程方案又完整走了一遍。

2. 先从牌型引擎下手:把牌编码、识别和比较写稳

斗地主最容易被低估的就是牌型识别。很多人拿到一份HTML5源码后,第一件事去换皮肤、改音频,等到点“出牌”按钮发现三带一和四带二总在打架,才回头补规则。所以我的习惯是先不看交互层,直接打开它src/core/下的牌型模块,先把“这手牌到底是什么”跑明白。

2.1 牌编码与排序:为什么我用“点数字符串”而不是 int

常见的错误做法是把每张牌编码成 0~53 的整数,花色用%4取,点数用Math.floor(i/4)取。这样写库存最省,但调试时你要对着数字猜这张是红桃A还是梅花2,非常痛苦。我常用的是“点数字符串加权重表”:每张牌就是'3'、'10'、'K'、'SJ'这样的字符串,花色不参与斗地主比较,直接丢给另一张表存展示用。

const RANK_WEIGHT = { '3': 0, '4': 1, '5': 2, '6': 3, '7': 4, '8': 5, '9': 6, '10': 7, 'J': 8, 'Q': 9, 'K': 10, 'A': 11, '2': 12, 'SJ': 13, 'BJ': 14 }; const RANK_BY_WEIGHT = Object.keys(RANK_WEIGHT).reduce((acc, r) => { acc[RANK_WEIGHT[r]] = r; return acc; }, {}); function sortHand(hand) { return hand.slice().sort((a, b) => (RANK_WEIGHT[a] - RANK_WEIGHT[b]) || (a > b ? 1 : -1) ); }

权重表是整个牌型系统的基石。RANK_WEIGHT把 A 放在 2 之前、2 放在王之前,这样顺子、连对、飞机在做“连续性判断”时可以直接比较权重差,不需要单独把 3~A 再抽一层。RANK_BY_WEIGHT用于把 AI 算出的权重数组转回可渲染的字符串牌面,省掉来回打补丁。

需要注意两点:第一,这里把大小王写成'SJ'和'BJ',色值上和 UI 层分开存,避免和'K'这类字母混淆;第二,排序里最后补了一个(a > b)的比较,是为了让A和'A'这种同权重但不同字符串的情况排序稳定,否则浏览器之间结果会不一致。权重范围从 0 到 14,后面判王炸时把它的 mainWeight 设成 99,天然压过所有牌型。

2.2 牌型识别:一个detectCardType覆盖常见牌型

识别核心逻辑并不复杂:先把手牌按点数聚合,数出“有几个单张、几对、几张三张、几组四张”,然后按数量特征归类。真正的难点在顺子和飞机这种需要连续点数的组合上,所以我把它们单拎出来做连续性校验。

function isConsecutive(weights) { for (let i = 1; i < weights.length; i++) { if (weights[i] !== weights[i - 1] + 1) return false; } return true; } function detectCardType(cards) { const n = cards.length; const count = new Map(); for (const r of cards) count.set(r, (count.get(r) || 0) + 1); // 王炸必须最先判断,两张王一起出是全场最大的牌型 if (n === 2 && count.has('SJ') && count.has('BJ')) { return { type: 'rocket', mainWeight: 99, length: 1, size: 2 }; } const entries = [...count.entries()] .sort((a, b) => RANK_WEIGHT[a[0]] - RANK_WEIGHT[b[0]]); const cnt1 = [], cnt2 = [], cnt3 = [], cnt4 = []; for (const [r, c] of entries) { if (c === 1) cnt1.push(r); if (c === 2) cnt2.push(r); if (c === 3) cnt3.push(r); if (c === 4) cnt4.push(r); } if (n === 1) { return { type: 'single', mainWeight: RANK_WEIGHT[cards[0]], length: 1, size: 1 }; } if (n === 2 && cnt2.length === 1) { return { type: 'pair', mainWeight: RANK_WEIGHT[cnt2[0]], length: 1, size: 2 }; } if (n === 3 && cnt3.length === 1) { return { type: 'triple', mainWeight: RANK_WEIGHT[cnt3[0]], length: 1, size: 3 }; } if (n === 4 && cnt3.length === 1 && cnt1.length === 1) { return { type: 'triple_single', mainWeight: RANK_WEIGHT[cnt3[0]], length: 1, size: 4 }; } if (n === 5 && cnt3.length === 1 && cnt2.length === 1) { return { type: 'triple_pair', mainWeight: RANK_WEIGHT[cnt3[0]], length: 1, size: 5 }; } if (n === 4 && cnt4.length === 1) { return { type: 'bomb', mainWeight: RANK_WEIGHT[cnt4[0]], length: 1, size: 4 }; } // 四带二统一处理,带两单或两对都算 if ((n === 6 || n === 8) && cnt4.length === 1) { return { type: 'four_two', mainWeight: RANK_WEIGHT[cnt4[0]], length: 1, size: n }; } // 顺子 if (n >= 5 && cnt3.length === 0 && cnt4.length === 0 && n === cnt1.length) { const weights = cnt1.map(r => RANK_WEIGHT[r]).sort((a, b) => a - b); if (isConsecutive(weights) && weights[weights.length - 1] <= RANK_WEIGHT['A']) { return { type: 'straight', mainWeight: weights[weights.length - 1], length: n, size: n }; } } // 连对 if (n >= 6 && n % 2 === 0 && cnt2.length === n / 2 && cnt3.length === 0 && cnt4.length === 0) { const weights = cnt2.map(r => RANK_WEIGHT[r]).sort((a, b) => a - b); if (isConsecutive(weights) && weights[weights.length - 1] <= RANK_WEIGHT['A']) { return { type: 'pair_straight', mainWeight: weights[weights.length - 1], length: cnt2.length, size: n }; } } // 飞机及其带牌 if (cnt3.length >= 2) { const tw = cnt3.map(r => RANK_WEIGHT[r]).sort((a, b) => a - b); if (isConsecutive(tw) && tw[tw.length - 1] <= RANK_WEIGHT['A']) { const L = cnt3.length; if (n === 3 * L) return { type: 'airplane', mainWeight: tw[tw.length - 1], length: L, size: n }; if (n === 4 * L && cnt1.length === L) { return { type: 'airplane_single', mainWeight: tw[tw.length - 1], length: L, size: n }; } if (n === 5 * L && cnt2.length === L) { return { type: 'airplane_pair', mainWeight: tw[tw.length - 1], length: L, size: n }; } } } return null; }

这个函数的产物是一个统一的牌型对象:type表示是哪种牌型,mainWeight表示参与比较的主牌权重,length用于顺子/连对/飞机这类“等长牌型”之间的比较,size是总张数。后面对战时判断“管不管得上”全看这四个字段。

实现上值得注意的边界:顺子和连对都要求cnt3和cnt4为空,这一刀切免得3334455这种带三张的畸形组合混进顺子判定;同时用weights[weights.length - 1] <= RANK_WEIGHT['A']把 2 和双王挡在顺子之外。飞机带牌这里只做了“每个三张带一个单张 / 带一对”的标准写法,像3334445556677这种带牌不完全规则的变体,需要你自己再扩一套解绑逻辑。

2.3 牌型比较:把“管不管得上”浓缩成一个函数

牌型识别只回答“这手牌是什么”,玩家点“出牌”时还需要回答下一句:“这手牌能不能压过上家”。比较规则一句话能说清:王炸最大,炸弹大于所有普通牌型,同类型比主牌权重,不同类型直接不能压。难点不是比较本身,而是别把四带二和炸弹混为一谈。

function canBeat(current, last) { if (!last) return true; if (last.type === 'rocket') return false; if (current.type === 'rocket') return true; if (current.type === 'bomb' && last.type !== 'bomb') return true; if (current.type === 'bomb' && last.type === 'bomb') { return current.mainWeight > last.mainWeight; } if (current.type !== last.type) return false; if (current.length !== last.length) return false; return current.mainWeight > last.mainWeight; }

第一次写的时候很容易把四带二当成一种能压别的牌型,结果一张 6666+KK 把对面的顺子压掉了,直接翻车。这里current.type === 'bomb'只在cnt4.length === 1 && n === 4时成立,四带二在 2.2 节里被标记成four_two,所以它在canBeat中会走“不同类型 → false”的流程,正确表现。

另一个易错点是顺子和连对:比较除了看mainWeight还要看length。比如上家出 34567,这属于 length=5 的顺子,你出一张长顺子 7891011,mainWeight虽然更大,但 length=6,两种顺子都不能互相压制,所以这里加了length !== last.length的保护。若要支持“长顺子压短顺子”的变体规则,就删掉这个判断并把比较改成只看上家所报 class 的主牌权重即可。

3. 出牌AI与托管逻辑:从“能出”到“会出”

牌型引擎能保证机制不出错,问题是玩家在单机模式里想打电脑对手,总不能让 AI 拿着炸弹开局就丢。这一步我一般单独写一个ai.js,把“找候选牌”和“选哪手”拆开:前者解决合法性,后者解决策略,测试也更好做。

3.1 跟牌候选:没有任何同类型牌时,才考虑炸弹

跟牌的朴素想法是挨个枚举手牌子集,看哪个能压过 last。枚举确实最稳,可手牌一多就是组合爆炸,普通机器上会卡顿。我更倾向于按 last 的牌型分类去精准构造候选:单张找最小能压过的单张,对子找最小对子,三带单独拆出主牌和带牌。下面这段是核心分支,排序后的手牌能保证在同类型里选出的永远是最小可压选项。

function countRanks(hand) { const counts = new Map(); for (const r of hand) counts.set(r, (counts.get(r) || 0) + 1); return counts; } function sortedRanks(hand) { return sortHand(hand).filter((r, i, arr) => i === 0 || r !== arr[i - 1]); } function findSameTypeBeat(hand, last) { const counts = countRanks(hand); const singles = sortedRanks(hand); if (last.type === 'single') { const r = singles.find(x => RANK_WEIGHT[x] > last.mainWeight); return r ? [r] : null; } if (last.type === 'pair') { const r = singles.find(r => counts.get(r) >= 2 && RANK_WEIGHT[r] > last.mainWeight); return r ? [r, r] : null; } if (last.type === 'triple') { const r = singles.find(r => counts.get(r) >= 3 && RANK_WEIGHT[r] > last.mainWeight); return r ? [r, r, r] : null; } if (last.type === 'triple_single') { for (const r of singles) { if (counts.get(r) === 3 && RANK_WEIGHT[r] > last.mainWeight) { const kicker = singles.find(x => x !== r); if (kicker) return [r, r, r, kicker]; } } return null; } if (last.type === 'triple_pair') { for (const r of singles) { if (counts.get(r) === 3 && RANK_WEIGHT[r] > last.mainWeight) { const kicker = singles.find(x => x !== r && counts.get(x) >= 2); if (kicker) return [r, r, r, kicker, kicker]; } } return null; } if (last.type === 'straight') return findStraightBeat(hand, last); if (last.type === 'pair_straight') return findPairStraightBeat(hand, last); if (last.type === 'airplane' || last.type === 'airplane_single' || last.type === 'airplane_pair') { return findPlaneBeat(hand, last); } if (last.type === 'four_two') { for (const [r, c] of counts) { if (c === 4 && RANK_WEIGHT[r] > last.mainWeight) { const rest = sortedRanks(hand).filter(x => x !== r); if (rest.length >= 2) return [r, r, r, r, ...rest.slice(0, 2)]; } } return null; } return null; }

findSameTypeBeat里把单张、对子、三张、三带一、三带二做实,顺子和连对单独走窗口扫描,飞机单独处理。这样做的收益是就算 AI 找不到候选,也能立刻判定“要不起”,不会因为枚举超时把页面卡掉。三带一带牌选择上,策略是先找主牌(三张一致),再取主牌之外的最小单张,这符合一般“不浪费大牌”的思路;主牌相同时,带牌并不参与比较,所以带哪张都不影响合法性,这地方很多人会绕进去。

找到候选之后,还需要一个总调度来决定用不用炸弹。常见做法是:只有当手牌只剩 4 张以内,或者对手只剩 2 张时,才把炸弹当普通压制牌丢出去;否则让过。你要硬编码“有炸必炸”也行,只是会被人机玩家骂傻。

3.2 顺子、连对和飞机的跟牌窗口

顺子的跟牌固定是“同样长度、主权重更高”。我按权重把从 3 到 A 的连续窗口拉出来,从 last 的顶权重 +1 开始向上滑动,检查当前手牌是否涵盖窗口内所有点数。因为排序后是数组,直接查权重集合即可。

function findStraightBeat(hand, last) { const L = last.length; const has = new Set(hand.map(r => RANK_WEIGHT[r])); for (let top = last.mainWeight + 1; top <= RANK_WEIGHT['A']; top++) { const start = top - L + 1; if (start < RANK_WEIGHT['3']) continue; const need = []; for (let w = start; w <= top; w++) need.push(w); if (need.every(w => has.has(w))) { return need.map(w => RANK_BY_WEIGHT[w]); } } return null; }

连对逻辑相同,唯一区别是每个权重需要手牌里至少有两张。飞机我采用一个快速实现:先找连续三张组,再从手牌挑同样数量的单张或对子作为翅膀,翅膀的分配顺序不影响主牌。

function findPlaneBeat(hand, last) { const L = last.length; const counts = countRanks(hand); for (let top = last.mainWeight + 1; top <= RANK_WEIGHT['A']; top++) { const start = top - L + 1; if (start < RANK_WEIGHT['3']) continue; const main = []; let ok = true; for (let w = start; w <= top; w++) { const r = RANK_BY_WEIGHT[w]; if ((counts.get(r) || 0) < 3) { ok = false; break; } main.push(r, r, r); } if (!ok) continue; if (last.type === 'airplane_pair') { const pairs = sortedRanks(hand).filter(r => !main.includes(r) && counts.get(r) >= 2); if (pairs.length < L) continue; return [...main, ...pairs.slice(0, L).flatMap(r => [r, r])]; } const rest = sortedRanks(hand).filter(r => !main.includes(r)); if (rest.length < L) continue; return [...main, ...rest.slice(0, L)]; } return null; }

findStraightBeat和findPlaneBeat的窗口每次都会从 +1 位置重新扫描,数据量小的时候毫秒级返回。如果以后要支持“一手牌拆两段出顺子”,就得在这里退回枚举剪枝,而不是窗口扫描。对绝大多数休闲棋牌项目来说,这个窗口策略已经能打了。

3.3 先手出牌:把“最卡手”的牌型先丢出去

AI 先手出牌不能无脑丢最小单张。实战里最常见的失误是把顺子留到最后,结果手里 34567 对家死活不要、自己也过不去。我用一个简单但不蠢的优先级:先看能不能一口气出完,再找最长顺子,其次连对,再然后是最小三条带一,最后才轮得到单张和对子。

function findLongestStraight(hand) { const weights = [...new Set(hand.map(r => RANK_WEIGHT[r]))] .filter(w => w >= RANK_WEIGHT['3'] && w <= RANK_WEIGHT['A']) .sort((a, b) => a - b); let best = [], cur = []; for (const w of weights) { if (cur.length && cur[cur.length - 1] + 1 === w) cur.push(w); else cur = [w]; if (cur.length >= 5 && cur.length > best.length) best = cur.slice(); } return best.length >= 5 ? best.map(w => RANK_BY_WEIGHT[w]) : null; } function suggestLead(hand) { if (detectCardType(hand)) return hand; const straight = findLongestStraight(hand); if (straight) return straight; const counts = countRanks(hand); const singles = sortedRanks(hand); const tri = singles.find(r => counts.get(r) === 3); if (tri) { const kicker = singles.find(r => r !== tri); return kicker ? [tri, tri, tri, kicker] : [tri, tri, tri]; } const pair = singles.find(r => counts.get(r) >= 2); if (pair) return [pair, pair]; return [singles[0]]; }

这里的detectCardType(hand)直接判断整手牌是否是合法一次性出完的组合,当地主只剩 4 张时尤其好用,避免 AI 傻乎乎地把四带二拆成两张一张去打。suggestLead的“找最长顺子”用的是最长连续区间,但长度优先不考虑权重,然后最短三条带一。实战中如果前排散牌太多,会把长处留在手里,你可以给 3.1 节里那个findStraightBeat加一个“先出大顺子还是小顺子”的偏好参数,用last的出牌压力来调。

4. H5前端落地:状态机、渲染与交互怎么和牌型引擎对接

引擎写完,接下来是把规则接到 H5 上。我见过很多源码把“谁该出牌”“上家出了什么”“还剩几张”散落在各个按钮事件里,改一个按钮就四处冒 bug。正确做法是先写一个与 UI 无关的游戏状态机,让 UI 只做“读取状态、渲染、触发动作”三件事。

4.1 游戏状态机:三个字段管住谁出牌、能不能出、何时局终

状态机的核心是turn(当前出牌人)、lastPlay(上家出牌)、passCount(连续过牌次数),外加一个phase标记明牌和叫地主。回合推进逻辑最容易出错:当有两个人连续过牌,新一轮应由“上一手实际出牌的人”先出,而不是顺着轮到第三个人。

class GameRoom { constructor() { this.turn = 0; this.lastPlay = null; this.passCount = 0; this.phase = 'bidding'; } startRound(players) { // 15张 × 2 + 17张×1 的分配逻辑按实际房间来 this.hands = players.map(p => p.hand); this.phase = 'playing'; this.turn = 0; this.lastPlay = null; this.passCount = 0; } submitPlay(playerIndex, cards) { if (playerIndex !== this.turn) { return { ok: false, reason: '还没轮到你' }; } if (cards.length === 0) { if (!this.lastPlay) return { ok: false, reason: '首手不能过牌' }; this.passCount += 1; const next = (this.turn + 1) % 3; if (this.passCount >= 2) { // 两人过牌后,轮到上一手出牌者重新领出 this.lastPlay = null; this.passCount = 0; this.turn = (next + 1) % 3; } else { this.turn = next; } return { ok: true, pass: true }; } const result = canPlay(cards, this.lastPlay); if (!result) return { ok: false, reason: '管不上' }; this.lastPlay = result; this.removeCards(this.hands[playerIndex], cards); this.passCount = 0; if (this.hands[playerIndex].length === 0) { this.phase = 'over'; } else { this.turn = (this.turn + 1) % 3; } return { ok: true, pass: false }; } removeCards(handArr, cards) { for (const c of cards) { const i = handArr.lastIndexOf(c); if (i >= 0) handArr.splice(i, 1); } } }

submitPlay的空数组表示“要不起”,这里没有用pass字符串,是因为前端点击“不出”时本来也是传空数组,统一入口更干净。注意removeCards用lastIndexOf逐个删,防止删除过程中下标错乱;千万别直接filter(c => !cards.includes(c)),会因为两张相同牌面重复删除出错。canPlay返回的是detectCardType的牌型对象,这样lastPlay直接就是标准结构,UI 层的“上家出了什么”也能直接拿它来画颜色块。

4.2 Canvas 渲染手牌与拖牌命中检测

渲染手牌最常见的做法是每张牌一个 div,优点是排 CSS 方便,缺点是一局要频繁操作几十个 DOM,低端安卓机上拖动卡到掉帧。我更倾向在牌桌用 Canvas 画,手牌用 div 做点击标签,这样把“高频交互”和“低频重绘”分离。下面的例子是纯 Canvas 渲染整手牌,同时提供命中测试:

const CARD_W = 96; const CARD_H = 136; const HAND_Y = 440; const SPREAD = 8; function drawHand(ctx, hand, selectedSet) { ctx.clearRect(0, HAND_Y - 30, 1200, CARD_H + 40); hand.forEach((rank, i) => { const x = (CARD_W + SPREAD) * i; const y = HAND_Y - (selectedSet.has(i) ? 24 : 0); drawCard(ctx, rank, x, y); }); } function hitHandCard(mx, my, hand, selectedSet) { for (let i = hand.length - 1; i >= 0; i--) { const x = (CARD_W + SPREAD) * i; const y = HAND_Y - (selectedSet.has(i) ? 24 : 0); if (mx >= x && mx <= x + CARD_W && my >= y && my <= y + CARD_H) return i; } return -1; }

drawCard里只画一个圆角矩形、点数和花色小图标,背板花色先不处理。命中测试从右往左遍历,是为了让压在上层(鼠标位置左侧可遮挡)的牌优先命中,视觉上更自然。drawHand每次重绘前只clearRect手牌区域,而不是clearRect整个画布,避免每次都把背景和对手手牌也重画一遍。这里HAND_Y - 30之所以空出一段,是因为选中牌要上移 24px,得给动效留出余量。

4.3 触摸交互与移动端适配:用 pointer 事件,别再用 click

移动端最典型的坑是 click 事件在部分国产浏览器内置 WebView 里会延迟 150~300ms,点一张牌像隔了一层水。正确姿势是用pointerdown+pointermove+pointerup实现整段拖拽和点击判定,同时给画布设touch-action: none,把浏览器的手势识别彻底让渡给业务代码。

let dragState = null; canvas.addEventListener('pointerdown', (e) => { const pos = canvasHitPos(e); const idx = hitHandCard(pos.x, pos.y, hand, selectedSet); dragState = { startX: pos.x, startY: pos.y, cardIndex: idx }; }); canvas.addEventListener('pointermove', (e) => { if (!dragState || dragState.cardIndex === -1) return; const pos = canvasHitPos(e); const dy = pos.y - dragState.startY; if (dy > 10) { selectedSet.add(dragState.cardIndex); drawHand(ctx, hand, selectedSet); } }); canvas.addEventListener('pointerup', () => { dragState = null; renderHandArea(); });

判定“向上拖起一张牌”的阈值设为 10 像素,小于它仍算点击,大于它才把牌抬起来。这套设计把“点选出牌”和“拖选出牌”合并成了同一套手势,玩家既可以用手指快速点两张 A 再点“出牌”,也可以把想出的牌一把拖上去。真正上生产时还要处理pointercancel(来电、手势冲突时会触发),否则拖到一半系统夺走指针事件,牌就悬在原地,需要补一句canvas.addEventListener('pointercancel', resetDrag)。

5. 避坑与常见问题排查:斗地主H5现场翻车的五个高发点

这一章是给已经动手改代码的人看的。以下五个问题是我在几套斗地主 H5 上反复见到的高频坑,请对着自己的项目逐条排查。

5.1 顺子把 2 和大小王放进去了

现象:玩家手里有10、J、Q、K、A、2,程序认为这是六张顺子,点出牌竟然出了。

原因:权重表里2的权重是 12,比A的 11 还大,而 2.2 节里isConsecutive只检查了相邻差为 1,没检查顶权重是否超出 A。

解决:在顺子和连对分支里加weights[weights.length - 1] <= RANK_WEIGHT['A']判断。双王因为cnt1统计时是两张不同的王,不会被误判进单张顺子里,但如果你把王也编码成同点数,就要单独过滤。

5.2 三带一的“带牌”被拿去参与比较

现象:AI 用888 + Q压掉了上家的777 + K,明明该赢却提示“管不上”。

原因:比较时把带牌Q和K的权重也放进了mainWeight计算,导致主牌更大也过不了比较。

解决:三带一、三带二、四带二统一只比主三张/主四张的权重。在canBeat里对于这三个类型,直接拿current.mainWeight和last.mainWeight比较,别把剩余牌的权重综合进去。可以在单测里显式断言777+Q能压777+K,防止后续重构又改回去。

5.3 两个 AI 互相过牌导致死循环

现象:某玩家出了 A,另两人都“要不起”,程序没有把出牌权交回第一个玩家,而是让第三个人再次领出,结果三个机器人围着空lastPlay来回传递,永远打不完一局。

原因:passCount >= 2时,重置轮次逻辑用了next而不是上一手实际出牌者,漏记了lastActivePlayer。

解决:在submitPlay里维护lastActivePlayer,每次有效出牌时更新它;当passCount >= 2时,把turn直接赋给lastActivePlayer,同时lastPlay = null。我在状态机里写的是(next + 1) % 3,它依赖三个玩家编号排序,如果机器人数量不是 3,建议改成显式存在lastActivePlayer字段的方式。

5.4 移动端点击出牌总有一截延迟

现象:点击手牌后要等 200 多毫秒才选中,快速连点两张时第二张点击丢失。

原因:click 事件在触摸屏上会有 300ms 延迟,很多旧 WebView 还会在两次点击之间合成dblclick。

解决:换成 pointer 事件并设置canvas.style.touchAction = 'none';如果项目用的是老式 click 绑定,逐个改成 4.3 节的pointerdown/up。对于纯按钮出牌,也可以给按钮加ontouchstart提升响应。全站统一用 pointer 是代价最小的一条路。

5.5 低端机反复重绘整副手牌掉帧

现象:手里有 17 张牌,点出一张就重绘一次,安卓千元机上明显掉帧。

原因:drawHand每次clearRect后把全部 17 张都重画,并且没有做脏矩形,连背景层和下家头像也跟着重绘。

解决:手牌区单独一个 canvas 层,与牌桌背景层分离;每次只重绘selectedSet涉及的牌区域,或者在pointermove中只更新“被拖起的那一张牌”。更彻底的做法是记录上一次渲染的牌状态,用requestAnimationFrame合并连续多次修改,把高频状态合并成每帧一次绘制。

6. 进阶验证:给斗地主H5建一套能自证“没写错”的回归体系

引擎写完了不代表能上线,真正要证明“这手牌识别得对、AI 不会乱来”,最好在开发期加自动化验证。我的习惯是每次改完牌型逻辑,先跑一批标准用例,再丢进去 1000 局机器人自对局,用数据说话。

const cases = [ { hand: ['3','3','3','4','5'], expect: 'straight' }, { hand: ['K','K','K','2'], expect: 'triple_single' }, { hand: ['SJ','BJ'], expect: 'rocket' }, { hand: ['9','9','9','9','Q','J'], expect: 'four_two' }, { hand: ['3','4','5','6','7','8'], expect: 'straight' } ]; function runTypeCases() { for (const c of cases) { const t = detectCardType(c.hand); if (!t || t.type !== c.expect) { console.warn(`识别失败: ${c.hand.join(',')} => ${t && t.type}`); } else { console.log(`通过: ${c.hand.join(',')} => ${t.type}`); } } }

除了牌型识别,我还会写一个simulate(rounds)函数,让三个 AI 随机发牌自动打,统计平均每局耗时、炸弹使用次数、最长连续过牌次数。如果某次改动后平均耗时从 2ms 涨到 30ms,十有八九在 AI 的候选枚举里多了个指数级循环,回去看findSameTypeBeat的窗口扫描是否跑成了全组合遍历。这套回归跑完,剩下的主要工作基本只剩美术资源的接入和房间算法替换。我吃过没回归就上线的亏:一把牌打到最后 AI 用四带二去压单张,直接把玩家心态搞崩。后来每次都先跑用例再出门,这成了我这几年改棋牌代码的一个固定习惯,算是花最少时间换最多安全感的手段了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 7:31:53

Flutter同步方案鸿蒙化适配:CRDT冲突治理实践

如果你和我一样&#xff0c;正被“把一套 Flutter 同步方案搬到鸿蒙”这件事搞得焦头烂额&#xff0c;那这篇文章基本是为你准备的。最近我正好完成了一个基于 crdt_sync 的鸿蒙化适配项目&#xff0c;过程中把“同步资产”这几个字从口号拆成了实实在在的类、方法、通道和存储…

作者头像 李华
网站建设 2026/10/13 7:31:49

AI日报从0到1:信息源管理、选题逻辑与自动化工具链实战

1. 一份“AI 日报”到底在记录什么每天早上九点前&#xff0c;我会把过去二十四小时里值得关注的模型发布、产品更新、开源项目、行业动向、论文亮点和工具链变化&#xff0c;压缩成一份十分钟以内能读完的简报。这份简报就是“AI 日报”。它不是新闻搬运&#xff0c;也不是简单…

作者头像 李华
网站建设 2026/10/13 7:31:21

SpringBoot+Vue构建战舰世界百科系统开发实践

1. 项目概述《战舰世界》作为一款深受玩家喜爱的海战题材网游&#xff0c;其复杂的舰船数据、装备系统和战术玩法催生了大量第三方辅助工具需求。这个基于SpringBootVue的百科信息系统正是为解决这一需求而生&#xff0c;它通过结构化数据存储和可视化展示&#xff0c;为玩家提…

作者头像 李华
网站建设 2026/10/13 7:30:10

ABAQUS盾构管片精细化建模:单环/多环CAE源文件及录屏教程

许多刚接触盾构隧道数值模拟的朋友&#xff0c;一开始最头疼的不是ABAQUS软件本身怎么操作&#xff0c;而是“盾构管片这个模型到底怎么建才靠谱”。网上零散的教程不少&#xff0c;但大多是某个片段、某一步操作&#xff0c;很少有能直接跟着做完、还能理清背后为什么这么建的…

作者头像 李华
网站建设 2026/10/13 7:30:04

RAID10单盘失效降级处理全流程:从故障确认到重建监控

凌晨两点手机突然开始连续震动&#xff0c;不用看都知道是服务器告警&#xff1a;某台存储节点的一块磁盘掉线&#xff0c;RAID10阵列进入降级状态。说实话&#xff0c;干了这么多年运维&#xff0c;这种告警永远让人心头一紧——不是怕数据丢&#xff0c;而是怕处理顺序错了&a…

作者头像 李华
网站建设 2026/10/13 7:30:02

OpenCode接入GLM完整指南:配置、避坑与本地部署实践

最近这段时间&#xff0c;我基本把日常的编码辅助从各种图形化 IDE 插件迁回到了终端里&#xff0c;主力工具是 OpenCode&#xff0c;一个大模型驱动的开源编程智能体。它的特别之处在于模型层完全开放&#xff0c;你可以把后端接到任何支持工具调用的模型上。折腾了一圈之后&a…

作者头像 李华