
简介这是纯JavaScript实现的视频扑克游戏项目面向前端初学者或对网页游戏开发感兴趣的程序员演示了如何不依赖后端仅用浏览器原生技术构建完整可玩的扑克牌娱乐应用。压缩包共6个文件包含HTML入口页面、两个CSS样式文件、一个JavaScript逻辑脚本、一个Markdown说明文档及一张扑克牌背景图整体仅8KB结构紧凑、便于快速阅读源码。已有185人学习下载。项目中可深入理解DOM操作、事件监听、洗牌发牌算法、手牌替换逻辑、状态管理及错误处理等关键知识点适合通过逐行分析核心脚本来掌握JavaScript在交互场景中的实际运用。由于完整实现了从发牌到胜负判定的流程学习者还能借鉴其模块化封装思路迁移到卡牌类或回合制小游戏的开发中。 这次要分享的videopokerwebapp是我最近用纯 JavaScript 写的一个视频扑克游戏。项目本身没依赖任何框架也没有后端参与就是一个 HTML 文件加一个 JavaScript 文件组成的单页应用浏览器直接打开就能玩。视频扑克Video Poker可能有些同学没接触过简单说就是用五张扑克牌去凑牌型按照赔率表兑换分数的单人扑克游戏赌场里那种带屏幕的机器玩的就是这套规则。如果你正想找一个“小而完整”的原生 JavaScript 项目来练手这个项目非常适合拿来拆解。里面涉及牌型判断、洗牌算法、按钮状态管理、本地持久化存储几乎把前端日常开发会碰到的核心逻辑都过了一遍。即使你对扑克玩法本身不感兴趣光看代码结构和算法实现也能提取出不少可复用的思路。1. 项目概述与核心设计思路1.1 视频扑克的基本规则视频扑克的规则并不复杂游戏开始后你先选择押注分数然后拿到五张牌。看完这五张牌你可以决定保留其中任意几张把不要的牌点掉系统会从剩余牌堆里补足五张。补牌结束之后系统根据最终牌型支付对应的奖金牌型越好倍数越高。这个游戏叫“视频扑克”是因为它最初出现在实体视频终端机上。玩家面对一个屏幕通过按钮操作不需要荷官也没有其他玩家本质上是一个人对战概率系统。而我做的这个版本相当于把虚拟机器的核心流程搬到了浏览器里。需要特别说明的是这个项目完全是本地单机的虚拟玩法没有真实资金流通纯粹是为了学习 JavaScript 和游戏逻辑这点先讲清楚大家在参考的时候心里有数就行。1.2 为什么用纯 JavaScript 实现选型的时候我几乎没有犹豫——原生 JavaScript 是这个项目最合适的选择。首先视频扑克的核心逻辑全在本地洗牌、发牌、记分、渲染这些东西用原生 JS 处理绰绰有余引入框架反而是负担。其次零依赖带来的最大好处是启动成本趋近于零不需要 npm install不需要打包配置双击 HTML 文件就能跑这在分享代码的时候体验极好。也有朋友问我为什么不用 Canvas 绘制牌面或者用 CSS 画出更花哨的牌桌特效。我的想法是这个项目的核心价值在于游戏逻辑而不是视觉表现。用 HTML CSS 渲染扑克牌每张牌就是一个 div配合 CSS 类名控制翻转和选中状态逻辑直观、调试方便浏览器开发者工具里能直接看到 DOM 结构。等以后想做更复杂的动效再换 Canvas 也不迟游戏逻辑本身是和渲染方式解耦的。1.3 代码结构与模块划分为了避免代码全都堆在一个函数里我把游戏拆成了四个模块每个模块负责一块独立职责Dealer负责创建牌组、洗牌、发牌、补牌。HandEvaluator负责解析五张牌的点数、花色判断最终牌型。PlayerState负责管理金币、当前押注、游戏阶段等玩家状态。Renderer负责把游戏状态同步到界面更新 DOM。模块之间通过简单的对象引用来通信所有状态都收敛到 PlayerState 里渲染层只负责读取状态、更新界面不直接修改核心数据。这种分层方式虽然不是特别高级但能把逻辑理得很清楚后面想加功能也不会牵一发动全身。实际写的时候我用了一个 IIFE 包裹整个项目避免把变量暴露到全局这是原生 JS 项目最容易忽略的细节。2. 核心算法实战洗牌与牌型判断2.1 牌组的数据结构与初始化一个标准的扑克牌组是 52 张牌没有大小王我需要给每张牌设计一个合适的数据结构。const suits [hearts, diamonds, clubs, spades]; const ranks [2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A]; function createDeck() { const deck []; for (const suit of suits) { for (let i 0; i ranks.length; i) { deck.push({ suit: suit, rank: ranks[i], value: i 2, }); } } return deck; }这里我额外存了一个 value 字段把 J、Q、K、A 映射成 11、12、13、14。这个字段在后面判断顺子的时候特别有用不用再每次把字符串转成数字。A 的特殊性在于它既可以当 14 组成 A-K-Q-J-10 的皇家同花顺也可以当 1 组成 A-2-3-4-5 的顺子所以判断顺子时需要对 A 做单独处理。2.2 Fisher-Yates 洗牌算法洗牌是这类游戏的核心直接决定发牌的公平性。这里我用了经典的 Fisher-Yates 算法也叫 Knuth 洗牌法代码非常简洁function shuffle(deck) { for (let i deck.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [deck[i], deck[j]] [deck[j], deck[i]]; } return deck; }有的同学可能会图省事写成deck.sort(() Math.random() - 0.5)但这种方式有严重问题排序算法的随机性并不均匀而且排序次数取决于引擎内部实现最终排列的概率分布会偏向某些顺序。Fisher-Yates 从后往前遍历每次在当前剩余池里随机取一个位置交换每个排列出现的概率都是 1/52!数学上完全均匀。这个点也是面试中经常被问到的做这个项目正好把它吃透。2.3 牌型判断的完整实现判断牌型是最能体现逻辑功力的部分。视频扑克常见的牌型从高到低分别是皇家同花顺、同花顺、四条、葫芦、同花、顺子、三条、两对、一对、高牌。function evaluateHand(hand) { const values hand.map(card card.value).sort((a, b) a - b); const suits hand.map(card card.suit); const isFlush suits.every(suit suit suits[0]); // 判断顺子注意 A-2-3-4-5 的情况 let isStraight false; if (new Set(values).size 5) { if (values[4] - values[0] 4) { isStraight true; } else if (values.join(,) 2,3,4,5,14) { isStraight true; } } // 统计点数出现频率 const freq new Map(); values.forEach(v freq.set(v, (freq.get(v) || 0) 1)); const counts [...freq.values()].sort((a, b) b - a); // 按频率匹配牌型 if (isFlush isStraight values[4] 14) return royalFlush; if (isFlush isStraight) return straightFlush; if (counts[0] 4) return fourOfAKind; if (counts[0] 3 counts[1] 2) return fullHouse; if (isFlush) return flush; if (isStraight) return straight; if (counts[0] 3) return threeOfAKind; if (counts[0] 2 counts[1] 2) return twoPair; if (counts[0] 2) return jacksOrBetter; return highCard; }这个实现的关键在于把问题拆成两个维度花色是否相同、点数是否连续再用点数频率排除重复牌的情况。判断顺子时需要注意两点一是 5 张牌点数都必须不同否则有一对时values[4] - values[0]也可能等于 4二是 A-2-3-4-5 这个边界情况它的最大点数 14 减最小点数 2 等于 12不在正常范围内要单独判断。3. 游戏流程与界面交互3.1 页面布局与状态机设计游戏界面我设计成三个区域顶部是信息栏显示金币余额、当前押注和上把牌型中间是牌桌区域五张牌横向排列底部是操作按钮区包含“押注”“发牌”“换牌”三个按钮。整个游戏流程是一个状态机分为三个阶段BETTING等待玩家押注并发牌。HOLDING玩家选择保留哪些牌然后点击“换牌”。RESULT补牌完成展示结果并结算奖金。状态机用变量currentPhase来标记按钮的可用性完全由当前状态决定。比如在 HOLDING 阶段发牌按钮不可用押注按钮不可用只有牌桌可以点击。这样做的最大好处是防止玩家误操作导致状态错乱。实际开发里我见过不少新手直接在事件回调里写各种 if 判断结果越写越乱状态机是把这类交互逻辑理清的最好方式。const phases { BETTING: BETTING, HOLDING: HOLDING, RESULT: RESULT, };3.2 发牌、保留与替换的交互逻辑发牌时系统从洗好的牌堆里取出五张渲染到界面上。每张牌渲染成一个 div点击可以切换held状态。在 HOLDING 阶段玩家选中的牌会带高亮边框表示这些牌会被保留没有选中的牌在点击“换牌”按钮后会被丢弃然后从牌堆里补新牌。这里有个细节值得提一下视频扑克里玩家不需要把想丢的牌先“点掉”只需要标记“要保留哪几张”。我一开始也纠结过交互方式后来参考了传统视频扑克机器的习惯——玩家通过按钮锁定牌锁定之后就不参与重抽。这样的好处是很多玩家会下意识保留自己认为有用的牌而不是费力去选要丢的体验上更顺手。换牌的逻辑其实很简单function drawReplacement(hand, heldFlags, deck) { const newHand []; for (let i 0; i hand.length; i) { newHand.push(heldFlags[i] ? hand[i] : deck.pop()); } return newHand; }这种设计把“是否替换”的职责从牌组里抽离出来用布尔数组和原手牌索引一一对应。只要是heldFlags[i]为 false 的牌直接从牌堆 pop 一张新牌顶上逻辑清晰也方便测试。3.3 金币系统的 localStorage 持久化金币系统我用 localStorage 做持久化这样刷新页面之后玩家还能保留之前的余额不用每次重新开始。const STORAGE_KEY videopoker_balance; function loadBalance() { const stored localStorage.getItem(STORAGE_KEY); return stored ? parseInt(stored, 10) : 1000; } function saveBalance(balance) { localStorage.setItem(STORAGE_KEY, balance.toString()); }用 localStorage 而不是 Cookie 的原因很简单这个数据只存在浏览器本地不需要发送到服务器localStorage 的 API 更简洁、容量限制也更大。需要注意的是localStorage 只能存字符串所以读取后要记得转回数字类型否则后续做加减法时会酿成字符串拼接的 bug。另外写入频率没必要太高我是在每次结算后才调用 saveBalance避免频繁操作本地存储。4. 赔率设计与数学期望4.1 标准赔率表视频扑克有很多版本我实现的是最常见的 Jacks or Better 规则意思是只有拿到“J 对或更高”的一对才算得分。赔率表如下牌型赔率倍数皇家同花顺250同花顺50四条25葫芦9同花6顺子4三条3两对2J 对及以上1其他0这个赔率表不是随便拍的它是传统 Jacks or Better 视频扑克的标准赔率。设计时最重要的原则是赔率要和牌型出现概率成反比越难拿的牌补偿越高同时还要保证整体回报率处于合理区间既不能让玩家稳赢也不能让奖金虚到没有追求。不同的赔率表会直接改变游戏的“松紧度”这也是视频扑克玩法多样性的来源之一。4.2 理论概率与期望值计算五张随机牌里拿到特定牌型的概率是固定的我整理了一下大致数值牌型理论概率皇家同花顺约 0.000154%同花顺约 0.00139%四条约 0.0240%葫芦约 0.144%同花约 1.309%顺子约 0.392%三条约 2.112%两对约 12.0%J 对及以上约 21.4%如果只看这个静态概率表把所有牌型的概率乘以对应赔率再加起来期望回报大约在 64% 左右看起来是个很亏的游戏。但真实的视频扑克不是一张一张随机发的玩家可以通过选择保留牌来提高胜率这也是这个游戏最有意思的地方。根据最优策略操作Jacks or Better 的理论回报率可以接近 99.5%但由于策略推导非常复杂我在这个项目里不做完整的最优策略提示只是把赔率和概率展示出来让玩家有自己的判断空间。我在代码里加了一个简单的统计模块用大量的自动模拟对局来验证牌型分布是否正常。比如跑十万把随机换牌的局统计皇家同花顺出现次数再和理论概率做对比如果偏差过大说明洗牌算法或者牌型判断可能有 bug这是一种非常好用的自测手段。4.3 在代码中如何校验与控制预期赔率表在代码里实现起来非常简答就是一个查表过程const PAYTABLE { royalFlush: 250, straightFlush: 50, fourOfAKind: 25, fullHouse: 9, flush: 6, straight: 4, threeOfAKind: 3, twoPair: 2, jacksOrBetter: 1, highCard: 0, }; function calculateWinnings(bet, handType) { return bet * PAYTABLE[handType]; }因为牌型判断代码返回的是固定的字符串标识奖金计算完全依赖查表逻辑非常独立想调整任何赔率都只需要改 PAYTABLE 这一个对象不需要动其他代码。这对以后做“不同版本赔率表”的功能来说是刚需。5. 常见问题与调试经验5.1 新手最容易踩的三个坑我在开发这个项目的过程中踩了几个坑第一个是全局变量污染。因为原生 JS 没有模块系统一开始我把所有函数都写在全局作用域里结果浏览器控制台报出各种变量冲突提示。后来我用 IIFE 把整个游戏包装起来所有模块变成私有变量窗口上只暴露一个window.VideoPoker给外部调试用问题就解决了。第二个坑是数组引用导致的意外修改。判断牌型的时候我直接在排序后的数组上做操作结果原来的手牌顺序也被改了界面上的牌全乱了。原因是sort()是原地排序会修改原数组。解决方法是写逻辑前先slice()一份副本或者用 ES6 的展开运算符[...hand]。第三个坑是按钮状态没锁住玩家在换牌动画还没结束的时候就狂点“换牌”导致重复消耗牌堆、发牌数量异常。后来我只能用状态机把三个阶段严格区分开并在动画期间给按钮加disabled这是所有带交互动画的游戏都必须注意的问题。5.2 调试技巧与后续扩展方向除了常规的 console.log我给项目做了一个隐藏的调试模式在 URL 后加参数?debug1时界面上会多出一个按钮可以直接把当前手牌强制设置成指定牌型比如皇家同花顺。这样一来测试结算动画和奖金显示就非常方便不用真的去等一个 64 万分之一的概率。调试模式在开发阶段帮我省了大量时间。做这个项目的过程中很多人都以为核心难点是游戏逻辑其实真正花时间的是如何把代码拆得清晰、让各个环节容易测试和扩展。后来我还往里面加了一个简单的历史记录功能记录每次发牌的结果次数用对象统计各牌型出现频次这个功能反过来又能验证赔率表的合理性。如果接下来你有兴趣做类似项目可以考虑扩展的方向还有自动提示最优保留牌、声音特效、多人在线对战、牌桌动画升级等思路都是从现在的状态机里延伸出去的。本文还有配套的精品资源点击获取