简介:《欢乐斗地主》HTML5 游戏源码是一份面向网页游戏学习者与开发者的完整实战案例,基于 HTML、CSS3 与 JavaScript 实现人机对弈。压缩包共 15 个文件,涵盖 PNG 界面素材、JavaScript 逻辑脚本、HTML 页面、说明文档及 MP3 音效,整体仅 2.53MB,结构精简,适合快速阅读源码;目前已有 3144 人学习使用。从源码中可看到 HTML 搭建页面骨架、CSS3 完成牌面翻转与响应式布局、JavaScript 负责规则判断与 AI 策略,音频文件则演示了 HTML5 Audio 的简单集成,说明文档对目录结构和关键逻辑作了梳理,方便按模块拆解研究。对想入门 HTML5 游戏开发、理解前端交互或算法落地的读者,这份代码能提供从页面布局、牌型判断到胜负结算的直观参考。
1. 斗地主HTML5源代码怎么读才对:别急着画界面,先把牌型和状态机焊死
从零写斗地主HTML5源代码,很多开发者一上来就找UI框架、画桌面、做发牌动画,结果写到出牌校验那一步全线崩盘。我复盘了一个模拟项目X后确认,真正让人翻车的从来不是界面,而是牌型识别和回合状态机这两块逻辑。只要状态机有漏洞,界面再漂亮也白搭,测试点几下就会死锁或乱序。
这篇文章把一条能跑通的落地方案完整拆给你:先讲54张牌怎么编码,再给牌型识别和出牌校验的核心代码,然后搭出Canvas手牌渲染和最小状态机,接着讲联网同步时最容易踩的四个坑,最后给一个能看能玩的AI出牌策略和调试技巧。
适合谁看:想做一个能跑、能改、能演示的斗地主Demo的前端开发者;想理解棋牌状态机怎么设计的后端转前端开发者;以及教学场景下需要一份逻辑完整、结构清晰的源代码做二次改造的人。看完你能直接照着敲,而不是对着花瓶截图空想。
2. 牌值编码与牌型判断:斗地主的“模式匹配”核心怎么写
2.1 54张牌用数字编码:字符串比较在竞技场里是死路
斗地主的牌力完全由点数决定,花色只在极少数自定规则里有意义。所以工程上最稳的做法是把牌面映射成连续整数,让比较大小退化成数值比较,让统计张数退化成数组下标计数。
我维护一张映射表,所有牌进入逻辑层前先转成数字数组:
| 牌面 | 编码 | 说明 |
|---|---|---|
| 3 ~ 10 | 3 ~ 10 | 数字牌直接按面值 |
| J / Q / K / A | 11 / 12 / 13 / 14 | 按序递增 |
| 2 | 15 | 点数大于A |
| 小王 | 16 | 只存在一张 |
| 大王 | 17 | 只存在一张 |
const POINT_MAP = { '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10, 'J': 11, 'Q': 12, 'K': 13, 'A': 14, '2': 15, 'joker': 16, 'JOKER': 17 }; function parseHand(str) { // 入参样例:'3 4 5 6 7 joker JOKER' const list = str.trim().split(/\s+/); return list .map(s => POINT_MAP[s]) .filter(v => v !== undefined) .sort((a, b) => a - b); // 统一升序,后续牌型判断依赖有序数组 } function pointToText(p) { if (p === 16) return '小王'; if (p === 17) return '大王'; if (p <= 10) return String(p); return ['J', 'Q', 'K', 'A'][p - 11]; }逻辑说明:parseHand把外部传入的字符串手牌转成升序数字数组。这里的排序不是可有可无,analyzeHand里的顺子判断、连对判断全依赖有序数组。我在牌进入逻辑层之前就统一排序,之后所有函数都假设输入是升序的,不必每层都做防御。
参数说明:POINT_MAP是小写'joker'和大写'JOKER',这是项目里定的传输约定。如果你从后端接口直接拿牌数组,可以跳过parseHand,但要保证数组元素本身就是3~17的整数。逻辑层宁可多转一次类型,也不要让字符串混进判断代码。
2.2 牌型识别:用计数数组替代复杂组合数学
斗地主的所有牌型,本质上就是对“每个点数出现的张数”做分组统计。单张、对子、三张、炸弹都是“一组同点数牌”;顺子是“多个点数各一张且连续”;三带一/二是“一组三张加若干单牌或对子”;连对是“多个点数各两张且连续”。用计数数组就能搞定,不需要排列组合。
function analyzeHand(cards) { if (!Array.isArray(cards) || cards.length === 0) return null; // 统计每个点数的张数,下标3~17 const counts = new Array(18).fill(0); for (const c of cards) counts[c]++; const n = cards.length; // 王炸:只有两张且分别为16、17 if (n === 2 && counts[16] === 1 && counts[17] === 1) { return { type: 'ROCKET', main: 17, len: 2 }; } // 炸弹:四张同点数 if (n === 4 && counts[cards[0]] === 4) { return { type: 'BOMB', main: cards[0], len: 4 }; } const groups = []; for (let p = 3; p <= 17; p++) { if (counts[p] > 0) groups.push({ point: p, count: counts[p] }); } // 单张 / 对子 / 三张 if (groups.length === 1) { const g = groups[0]; if (g.count === 1) return { type: 'SINGLE', main: g.point, len: 1 }; if (g.count === 2) return { type: 'PAIR', main: g.point, len: 2 }; if (g.count === 3) return { type: 'TRIPLE', main: g.point, len: 3 }; } // 三带一:三张牌点数相同 + 一张单牌 if (groups.length === 2 && n === 4) { const triple = groups.find(g => g.count === 3); const extra = groups.find(g => g.count !== 3); if (triple && extra && extra.count === 1) { return { type: 'TRIPLE_ONE', main: triple.point, len: 4, sub: extra.point }; } } // 三带二:三张牌点数相同 + 一对 if (groups.length === 2 && n === 5) { const triple = groups.find(g => g.count === 3); const pair = groups.find(g => g.count === 2); if (triple && pair) { return { type: 'TRIPLE_TWO', main: triple.point, len: 5, sub: pair.point }; } } // 顺子:至少5张,全是单张,点数连续,2和王不参与 if (n >= 5 && groups.every(g => g.count === 1)) { const pts = groups.map(g => g.point); const isConsecutive = pts[pts.length - 1] - pts[0] === pts.length - 1; if (isConsecutive && pts[pts.length - 1] <= 14) { return { type: 'STRAIGHT', main: pts[pts.length - 1], len: n, sub: pts[0] }; } } // 连对:至少3对连续点数 if (n >= 6 && n % 2 === 0 && groups.every(g => g.count === 2)) { const pts = groups.map(g => g.point); const isConsecutive = pts[pts.length - 1] - pts[0] === pts.length - 1; if (isConsecutive && pts[pts.length - 1] <= 14) { return { type: 'CONSECUTIVE_PAIR', main: pts[pts.length - 1], len: n, sub: pts[0] }; } } // 四带二:四张同点数 + 两张单牌或一对 if (n === 6 && groups.some(g => g.count === 4)) { const four = groups.find(g => g.count === 4); return { type: 'FOUR_TWO', main: four.point, len: 6 }; } return null; // 无法识别,调用方提示“牌型不合法” }逻辑说明:返回对象里type是牌型枚举,main是主点数,同牌型比较大小只比main;len是张数,用于校验“牌型一致才可互压”;sub在某些牌型里表示最小点数,用于前端提示。炸弹和王炸最先判断,因为它们的比较逻辑和普通牌型完全不同,提前分流能避免后面的groups逻辑误判。
参数说明:cards必须是升序数字数组。四条2的编码是[15,15,15,15],counts[15] === 4直接命中炸弹。顺子上限A我写在pts[pts.length - 1] <= 14里,如果你玩的地方允许2进顺子,把这个条件改成<= 15即可,但常规斗地主不允许。这段代码没有覆盖飞机带翅膀,完整规则需要在groups上再枚举连续三张组合,套路一样,优先级是:先找连三张,再按带单/带对分组。
2.3 出牌校验:canBeat与“要不起”判定
牌型识别完成后,出牌校验变成一件很机械的事:先比牌型类别,再比主点数。唯一需要特殊处理的是炸弹和王炸的压制关系。
function canBeat(prev, curr) { if (!prev) return true; // 首出或新一轮,随便出 if (curr.type === 'ROCKET') return true; // 王炸压一切 if (prev.type === 'ROCKET') return false; // 王炸被之前出了,压不了 if (curr.type === 'BOMB') { return prev.type !== 'BOMB' || curr.main > prev.main; } if (prev.type === 'BOMB') return false; // 普通牌型不能压炸弹 if (curr.type !== prev.type || curr.len !== prev.len) return false; return curr.main > prev.main; // 同牌型比主点数 } function isPassLegal(prev) { // 上一手为空说明当前是新一轮首出,不允许“不出” return prev !== null; }逻辑说明:canBeat的顺序很重要,必须把王炸放在炸弹之前判断,因为王炸可以压炸弹,而普通牌型不能压任何炸弹。三带一压三带一比较的是三张的点数,不看带的牌,这个逻辑体现在main字段上。isPassLegal是配套函数,防止玩家在第一手就点“不出”导致回合空转。
参数说明:调用姿势是const prev = analyzeHand(lastPlay); const curr = analyzeHand(selectedCards); if (canBeat(prev, curr))。lastPlay在服务端权威状态里维护,客户端不自行保存。注意analyzeHand返回null时,要先提示“牌型不合法”,不要直接丢进canBeat,否则在curr.type上抛错。
3. 搭出能跑的HTML5工程:Canvas手牌渲染、点选交互与最小状态机
3.1 工程目录:逻辑与渲染分离,方便后面换UI框架
我实际落地时会把目录切成四块,核心逻辑完全不碰DOM和Canvas,这样既能用Node做服务端,也能在浏览器直接跑单机版,后面换Vue或React只需要替换渲染层。
dou-dizhu-html5/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── core/ │ │ ├── cards.js # 牌编码、牌型识别 │ │ ├── rules.js # 出牌校验 │ │ └── game.js # 状态机 │ ├── render/ │ │ └── table.js # Canvas渲染 │ ├── net/ │ │ └── ws.js # WebSocket封装 │ ├── ai/ │ │ └── bot.js # 机器人出牌 │ └── main.js # 入口,串起逻辑与渲染逻辑说明:core目录是纯逻辑模块,cards.js、rules.js、game.js之间是单向依赖:game.js引用rules.js,rules.js引用cards.js。这样做的好处是我可以先用Node把整局游戏跑通,再写浏览器端,排查问题时完全不用打开DevTools。
参数说明:如果你只做单机Demo,net目录可以先留空,main.js里直接实例化三个GameCore玩家模拟。等做联机时再补net/ws.js,不影响已经写好的逻辑。我强烈建议这种渐进式路线,否则逻辑和网络bug混在一起,排错成本翻倍。
3.2 Canvas手牌渲染:扇形布局与点选位移
手牌渲染我会直接开Canvas,原因是跨浏览器行为一致、不用被CSS动画的兼容性反复折腾。核心是维护一个记录选中索引的Set,点击时切换状态,渲染时对应牌向上位移。
function drawHand(ctx, hand, selected, W, H) { const CARD_W = 72; const CARD_H = 96; const GAP = 24; const total = hand.length; const startX = (W - (CARD_W * total + GAP * (total - 1))) / 2; const baseY = H - 150; // 只清理手牌区域,避免整屏闪烁 ctx.clearRect(0, baseY - 30, W, 150); for (let i = 0; i < total; i++) { const x = startX + i * (CARD_W + GAP); const y = baseY - (selected.has(i) ? 24 : 0); drawCard(ctx, x, y, CARD_W, CARD_H, hand[i]); } } function drawCard(ctx, x, y, w, h, card) { ctx.fillStyle = '#fff'; ctx.strokeStyle = '#666'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, w, h, 6); ctx.fill(); ctx.stroke(); const label = pointToText(card); const red = card === 16 || card === 17 || [11, 12, 13, 14, 15].includes(card); ctx.fillStyle = red ? '#d32f2f' : '#333'; ctx.font = 'bold 22px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.fillText(label, x + 10, y + 32); // 花色角标,只做视觉装饰,不参与逻辑 ctx.font = '14px sans-serif'; ctx.fillText(['♠', '♥', '♣', '♦'][card % 4], x + 46, y + 54); }逻辑说明:点选交互对应的是canvas.onclick里做坐标反查,判断点击位置落在哪张牌的矩形区间内,然后selected.has(i) ? selected.delete(i) : selected.add(i),最后调用drawHand重绘。这里强调一点:存的是手牌索引而不是牌值,因为同一张牌可能出现多次,用牌值做key会互相覆盖。
参数说明:CARD_W、CARD_H、GAP是手牌大小的核心参数。手机竖屏建议缩到CARD_W=56、GAP=14,否则一手20张牌会超出屏幕。roundRect需要较新的浏览器支持,老项目里我自己画过圆角路径,这里直接用原生API是为了代码可读性。花色角标纯粹是为了视觉,card % 4取模会让同一张牌每次画出来花色不一致,不影响规则但如果你介意,可以把花色编进牌值里。
3.3 出牌与跟牌的状态流转:叫地主到结算的最小状态机
状态机是斗地主最核心的部分。我把状态拆成两层:宏观的phase负责“发牌-叫地主-出牌-结算”的切换;回合内的current指针负责“当前轮到谁”,配合passStreak处理连续不出后的重新出牌。
class GameCore { constructor(playerIds) { this.playerIds = playerIds; this.phase = 'DEAL'; // DEAL -> BIDDING -> PLAYING -> END this.landlordId = null; this.hands = {}; this.current = 0; this.lastPlay = null; this.lastPlayBy = -1; this.passStreak = 0; } setHands(hands) { this.hands = hands; for (const id of this.playerIds) { this.hands[id].sort((a, b) => a - b); } } startBidding() { this.current = Math.floor(Math.random() * 3); this.phase = 'BIDDING'; return this.snapshot(); } // 简化版叫地主:第一个叫分的成为地主 runBid(playerId, points) { if (this.phase !== 'BIDDING' || this.playerIds[this.current] !== playerId) return null; if (points > 0) { this.landlordId = playerId; this.phase = 'PLAYING'; this.current = this.playerIds.indexOf(playerId); this.lastPlay = null; this.passStreak = 0; return this.snapshot(); } this.advanceTurn(); return this.snapshot(); } runPlay(playerId, selectedCards) { if (this.phase !== 'PLAYING' || this.playerIds[this.current] !== playerId) return null; const hand = this.hands[playerId]; const temp = hand.slice(); for (const c of selectedCards) { const idx = temp.indexOf(c); if (idx === -1) return { ok: false, msg: '选中的牌不在手牌中' }; temp.splice(idx, 1); } const curr = analyzeHand(selectedCards); if (!curr) return { ok: false, msg: '牌型不合法' }; if (!canBeat(this.lastPlay, curr)) return { ok: false, msg: '压不过上一手' }; for (const c of selectedCards) { const idx = this.hands[playerId].indexOf(c); this.hands[playerId].splice(idx, 1); } this.lastPlay = curr; this.lastPlayBy = this.current; this.passStreak = 0; if (this.hands[playerId].length === 0) { this.phase = 'END'; return { ok: true, winner: playerId, snapshot: this.snapshot() }; } this.advanceTurn(); return { ok: true, snapshot: this.snapshot() }; } runPass(playerId) { if (this.phase !== 'PLAYING' || this.playerIds[this.current] !== playerId) return null; if (this.passStreak >= 2 || this.lastPlayBy === this.current) { return { ok: false, msg: '当前不能不出,必须出牌' }; } this.passStreak++; this.advanceTurn(); return { ok: true, snapshot: this.snapshot() }; } advanceTurn() { this.current = (this.current + 1) % 3; if (this.passStreak >= 2) { // 两家都没出,下一家重新自由出牌 this.lastPlay = null; this.lastPlayBy = this.current; this.passStreak = 0; } } snapshot() { return { phase: this.phase, landlordId: this.landlordId, hands: this.hands, current: this.playerIds[this.current], lastPlay: this.lastPlay, passStreak: this.passStreak, }; } }逻辑说明:runPlay先做“选牌在手”校验,再做牌型校验,顺序不能反。如果把牌型校验放在前面,玩家选了别人手里的牌也能分析出牌型,这在逻辑上就漏了。出牌成功后从手牌里移除,如果手牌清空则直接进入END。runPass里lastPlayBy === this.current这层判断很关键:上一手就是自己出的,不允许“不出”。
参数说明:passStreak的设计解决了斗地主“两家不出后重新自由出牌”的规则。注意不同平台的实现有差异,有的平台是“出牌者的下家重新自由出牌”,我这里直接把它重置为当前玩家自由出牌,对应大多数平台的玩法。
提示:
runBid是简化版叫地主,第一个叫分的直接当地主。完整规则要三轮叫分、最高分者当地主,等主流程跑通后再扩展即可,状态机的骨架不受影响。
3.4 把逻辑跑通的最小可玩版本:Node单机模拟三人对局
写界面之前,我先在Node里模拟一局,确保状态机不会死锁。这个方法帮我躲过了很多次逻辑翻车,强烈建议你也这么做。
// core/simulate.js const { GameCore } = require('./game'); const { analyzeHand, canBeat } = require('./cards'); function randomPlay(core, playerId) { const hand = core.hands[playerId]; const single = [hand[0]]; const res = core.runPlay(playerId, single); if (res && res.ok) return; core.runPass(playerId); } const core = new GameCore(['A', 'B', 'C']); core.setHands({ A: [3, 5, 6, 8, 9, 11, 14], B: [4, 5, 7, 8, 10, 12, 15], C: [6, 7, 9, 10, 11, 13, 16, 17], }); core.startBidding(); let guard = 0; while (core.phase !== 'END' && guard < 1000) { const cur = core.playerIds[core.current]; randomPlay(core, cur); guard++; } console.log('winner index:', core.lastPlayBy);逻辑说明:这个脚本的价值在于它能逼你在写界面之前发现状态机的死角。我第一次跑的时候卡在“A出牌,B过,C过,然后A自由出牌但手里没有能压过上一手的牌,又因为passStreak重置逻辑没写对导致死循环”。如果没有模拟脚本,这种问题要等接到浏览器再调试,会很痛苦。
参数说明:guard是保护变量,防止死循环。真实模拟应该用完整洗牌和随机策略,我这里用固定手牌是为了让你直接复现状态机流程。等GameCore升级为完整叫分逻辑后,再随机发牌跑几千局做压力验证。
4. 联网对战:WebSocket同步方案与四个必踩的避坑记录
4.1 先本地后联网:客户端只发意图,服务器才是唯一权威
联网后的核心变化不是通信,而是“谁说了算”。本地单机时每个客户端都是自己的上帝,联机时如果客户端各自执行逻辑,必然出现状态分裂。正确做法是:服务器持有唯一权威GameCore实例,客户端只把动作意图发给服务器,服务器执行完把snapshot广播给所有人。
// net/ws.js(客户端侧) class WsClient { constructor(url, onSnapshot) { this.ws = new WebSocket(url); this.onSnapshot = onSnapshot; this.ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.type === 'SNAPSHOT') { this.onSnapshot(msg.payload); // 服务器下发的权威状态 } }; } sendAction(action, payload) { // 只发意图,不本地执行 this.ws.send(JSON.stringify({ action, payload })); } play(cards) { this.sendAction('PLAY', { cards }); } pass() { this.sendAction('PASS', {}); } bid(points) { this.sendAction('BID', { points }); } }逻辑说明:WsClient的职责非常单一,只做两件事:把玩家动作发给服务器、把服务器SNAPSHOT交给渲染层。所有“能不能出”“出了之后轮到谁”的判断都在服务端完成。客户端不维护current、不维护lastPlay,界面上的状态完全由SNAPSHOT驱动。
参数说明:onSnapshot在收到消息时会被调用,渲染层拿payload.hands、payload.current直接重绘画布。初版可以每次全量下发三家手牌,数据量不大;等在线用户多了,再优化成“只广播当前玩家手牌+出牌记录,其余玩家只广播张数”。
4.2 对局消息协议:动作、广播与错误回执
消息协议我用最朴素的JSON,客户端到服务器的消息只有三类动作,服务器到客户端只有SNAPSHOT和ERROR两种类型。不要把协议设计复杂,等业务跑通再升级。
// 客户端 -> 服务器 { "action": "PLAY" | "PASS" | "BID", "payload": { "cards": [3, 5, 8] } } // 服务器 -> 客户端 { "type": "SNAPSHOT", "payload": { "phase": "PLAYING", "hands": {...}, "current": "room_2" } }服务器端核心逻辑:
// net/server.js(Node端,使用 ws 库) const { GameCore } = require('./core/game'); class ServerCore { constructor() { this.game = null; this.sockets = new Map(); // playerId -> ws } handleMessage(playerId, rawMsg) { const msg = JSON.parse(rawMsg); let result = null; switch (msg.action) { case 'PLAY': result = this.game.runPlay(playerId, msg.payload.cards); break; case 'PASS': result = this.game.runPass(playerId); break; case 'BID': result = this.game.runBid(playerId, msg.payload.points); break; default: return; } if (result && result.ok) { this.broadcast(result.snapshot); } else { // 把错误回给发起者,用于界面提示 this.sockets.get(playerId).send(JSON.stringify({ type: 'ERROR', msg: result ? result.msg : '非法请求' })); } } broadcast(snapshot) { const data = JSON.stringify({ type: 'SNAPSHOT', payload: snapshot }); for (const ws of this.sockets.values()) { ws.send(data); } } }逻辑说明:服务器只认action,不信任客户端任何状态。runPlay返回ok: false时被包装成ERROR发回发起者,用于弹提示。这里有个容易忽略的细节:payload.cards必须是数字数组,如果客户端不小心把它序列化成了字符串,服务端indexOf就会匹配不上,表现为“明明牌在手却提示不在手牌”。
参数说明:如果要支持观战,把broadcast改成按房间分组发送,观战者只收SNAPSHOT不收ERROR。result.snapshot是GameCore.snapshot()的返回值,里面包含三方手牌、地主、当前轮次,数据规模可控。
4.3 避坑记录:本地逻辑正确,一联机房就崩的四个典型问题
坑1:手牌数组还原后乱序,牌型识别突然失效现象:本地单机一切正常,联机后A出的牌,B收到后analyzeHand返回null,无法跟牌。 原因:我在传输层把cards用JSON序列化,B收到后没执行排序,而analyzeHand的顺子、连对判断依赖升序数组,乱序的[7, 5, 6]被判为非法。 解决:在WsClient.play()发送前和服务器runPlay()入口各做一次cards.slice().sort((a,b)=>a-b),双保险。顺序是:发送前排序、接收后排序。
坑2:两个玩家几乎同时出牌,服务器出现双重出牌现象:A和B同时点“出牌”,服务端收到了两条合法出牌,但实际上一轮只能出一个人。 原因:客户端各自维护了“当前轮到我”的UI标志,网络延迟让两个人都觉得该自己出。真正的回合权威只在服务器,客户端不能自己判断“能不能出”。 解决:服务器runPlay开头校验this.playerIds[this.current] !== playerId,不匹配直接返回{ ok:false }。客户端收到ERROR后重新拉一次SNAPSHOT,用权威状态纠正界面。
坑3:断线重连后,重连玩家手牌缺失或重复现象:玩家网络抖动掉线重连,回到牌桌发现自己手牌少了一张或多了一张。 原因:增量同步只传了“出牌动作”,没传全量手牌状态。重连客户端拿旧状态叠加新动作,必然对不上。 解决:重连时服务器下发完整SNAPSHOT,包含三方手牌数组、地主id、当前轮次、上一手牌。客户端整体覆盖,不做任何merge。
坑4:连续两家“不出”后,第三个玩家卡死现象:A出牌,B点“不出”,C点“不出”,然后没有出现“A重新自由出牌”的提示,界面卡在轮空状态。 原因:passStreak在重置lastPlay时没有同步清零,导致advanceTurn里判断失效,游戏不知道谁该出。 解决:在advanceTurn()里严格实现“passStreak >= 2时lastPlay = null、lastPlayBy = current、passStreak = 0”,并在单机模拟里专门加一个“两家过”的测试用例。
5. 让Demo做出质感:AI出牌策略、渲染优化与对局回放
5.1 一个能看的AI出牌:从无脑出最小到稍稍带脑
// ai/bot.js function botChoose(hand, prev) { hand = hand.slice().sort((a, b) => a - b); // 能一手走完直接走 const whole = analyzeHand(hand); if (whole && (!prev || canBeat(prev, whole))) return hand; // 拆成单张从小到大试探 for (let i = 0; i < hand.length; i++) { const curr = analyzeHand([hand[i]]); if (!prev || canBeat(prev, curr)) return [hand[i]]; } return null; // 返回 null 表示要不起 }这个AI只做最小合法出牌,但它的价值是给本地模拟提供真实出牌压力。等你要做更强的AI,再往里加“留炸弹”“尽量不拆对子”“三带一优先带最小单”这些启发式逻辑。!prev || canBeat(prev, whole)这个写法同时处理了“自由出牌”和“跟牌压制”两种情况,比单独判断更稳。
5.2 渲染优化:脏矩形与局部刷新
Canvas里最容易犯的错是每帧clearRect(0, 0, W, H)全量重绘,牌桌上有几十个元素,手机端直接掉帧。我的习惯是只对手牌区做局部刷新,出牌区在动作发生时单独重绘那一块。核心是维护一个dirtyRects数组,每次requestAnimationFrame只重绘dirty区域:
let dirtyRects = []; function markDirty(rect) { dirtyRects.push(rect); } function frame() { requestAnimationFrame(frame); for (const rect of dirtyRects) { ctx.clearRect(rect.x, rect.y, rect.w, rect.h); // 只重绘rect对应的桌面元素 } dirtyRects = []; }循环一直在跑,但没有dirty区域时什么都不做,CPU占用接近0。点击选牌时markDirty(手牌区),出牌广播到达时markDirty(桌面出牌区)。这个方案比全量重绘省非常多。
5.3 对局回放:给状态机加一剂“后悔药”
我调试状态机翻车最多的手段是对局回放。服务器每执行一个动作,就把{ action, playerId, playedCards }压进日志数组;回放时从头到尾重放一遍GameCore,界面就恢复成当时的模样:
function recordAction(log, playerId, action, cards) { log.push({ playerId, action, cards }); } function replay(log, playerIds) { const core = new GameCore(playerIds); for (const item of log) { if (item.action === 'PLAY') core.runPlay(item.playerId, item.cards); if (item.action === 'PASS') core.runPass(item.playerId); } return core.snapshot(); }把异常时刻的服务器日志和客户端日志一对,马上能看到从哪一步开始分叉。我做这个Demo最大的教训就是:界面上的一次诡异卡顿,九成是逻辑层的状态机出了问题,而回放是定位这类问题最快的路径。先把回放跑通,再谈观战、录像这些高级功能,你会发现后面的路顺很多。希望帮到你。
本文还有配套的精品资源,点击获取