把一副牌洗得明明白白——在 HarmonyOS 上写一个公平的扑克发牌器

发布时间:2026/5/28 14:48:35

把一副牌洗得明明白白——在 HarmonyOS 上写一个公平的扑克发牌器 前言春节回老家亲戚们围在一起打牌。我负责发牌手一滑牌撒了一地捡起来随便拢了拢就接着发结果那一局小姨连抓三把“王炸”被怀疑作弊。我说这是手气但心里也犯嘀咕到底怎样才算真的把一副牌“洗乱”了后来学编程才知道有一种叫 Fisher-Yates 的洗牌算法专治各种“洗不匀”。正好手边 DevEco Studio 开着模拟器里那台 Pura X Max 亮着屏我心想不如用 HarmonyOS 写一个发牌器把洗牌的每一步都摊开在屏幕上。这篇文章就把这个过程记录下来——一副扑克牌怎么在代码里生成、怎么洗得公平、又怎么用网格一张张摆到模拟器的屏幕上。一、Fisher-Yates 洗牌怎样才算“随机”很多人以为洗牌就是随便交换几次位置比如循环几十次随机取两张牌互换。这种方法叫“随机交换法”听起来没问题但实际上会导致某些排列出现的概率比其他排列高。举例来说如果每次随机取两张牌交换重复 N 次结果并不均匀——接近初始顺序的排列更可能出现。真要保证 52 张牌的每一种排列都是等概率的需要一个严谨的算法。Fisher-Yates 洗牌算法也叫 Knuth 洗牌就是干这个的。它的思路特别简单从最后一位置开始随机选一个位置包括当前位置交换两张牌然后往前挪一个位置重复直到第二个位置。写成代码也就四行for (let i cards.length - 1; i 0; i--) { let j Math.floor(Math.random() * (i 1)); [cards[i], cards[j]] [cards[j], cards[i]]; }为什么这样能保证公平因为第一次交换最后一张牌有同等机会来自任何位置第二次交换倒数第二张牌有同等机会来自剩下的位置……这样每一张牌最终停在任何位置的概率都是一样的。时间复杂度 O(n)干净利落。在我们的应用里开局前生成一副牌52 张对象每张有花色 suit 和点数 rank然后跑一遍 Fisher-Yates牌堆就彻底乱了。发牌只是从数组顶部按顺序取因为牌堆已经是随机排列直接取就是公平的。二、在鸿蒙里用 Grid 摆牌牌洗好了怎么摆到屏幕上扑克牌是一张张矩形卡片天然适合用网格布局。HarmonyOS 的 ArkUI 提供了Grid组件配合GridItem能快速把数据数组渲染成卡片阵列。需要做的就是确定每行放几张。考虑到手机屏幕宽度5 张牌一行比较合适所以设置columnsTemplate(1fr 1fr 1fr 1fr 1fr)。遍历已发出的牌数组每个牌用一个GridItem包裹里面是自定义的卡片样式。卡片设计得很简单白色背景圆角中间显示花色符号和点数花色用颜色区分——♠ 黑色♥ 红色♦ 红色♣ 黑色。为了更像真牌点数在左上角和右下角各放一个中间放一个大花。不过为了简洁我直接用一个大字把点数和花色拼在一起比如“A♠”、“10♥”。字号调大居中显示。发牌张数用一个Slider控制范围 1 到 52。每次拖动滑块就重新从牌堆顶部取相应张数的牌显示。另外加一个“重新洗牌”按钮点击后重新洗牌并更新展示。状态管理方面我们维护三个State变量deck整副牌对象数组、dealtCards当前发出的牌数组、dealCount发牌张数。洗牌时更新deck发牌时根据dealCount从deck头部截取更新dealtCards。界面自动刷新。三、完整代码——一副公平的扑克牌在模拟器上摊开下面是能在 DevEco Studio 6.1.1 Beta1、SDK22 下直接运行的完整Index.ets代码。新建 Empty Ability 项目替换这个文件即可。不需要任何权限纯本地逻辑。/* * 扑克牌随机发牌器 — Fisher-Yates 洗牌 Grid 展示 * 功能洗牌、发牌滑块调节发牌张数 * 环境DevEco Studio 6.1.1 Beta1Pura X Max 模拟器SDK22 */ interface Card { suit: string; rank: string; color: string; } Entry Component struct Index { State deck: Card[] []; State dealtCards: Card[] []; State dealCount: number 5; State remainCount: number 52; private createDeck(): Card[] { const suits [♠, ♥, ♦, ♣]; const ranks [A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K]; let newDeck: Card[] []; for (let suit of suits) { for (let rank of ranks) { let color (suit ♥ || suit ♦) ? #D32F2F : #212121; newDeck.push({ suit, rank, color }); } } return newDeck; } private shuffle(cards: Card[]): Card[] { let shuffled [...cards]; for (let i shuffled.length - 1; i 0; i--) { let j Math.floor(Math.random() * (i 1)); [shuffled[i], shuffled[j]] [shuffled[j], shuffled[i]]; } return shuffled; } private deal(): void { let count Math.min(this.dealCount, this.deck.length); this.dealtCards this.deck.slice(0, count); this.remainCount this.deck.length - count; } private resetAndDeal(): void { let orderedDeck this.createDeck(); this.deck this.shuffle(orderedDeck); this.deal(); } async aboutToAppear(): Promisevoid { this.resetAndDeal(); } private onDealCountChange(value: number): void { this.dealCount value; this.deal(); } build() { Column() { Text(扑克发牌器) .fontSize(28) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 8 }) Text(剩余牌数${this.remainCount} 张) .fontSize(16) .fontColor(#888) .margin({ bottom: 10 }) if (this.dealtCards.length 0) { Grid() { ForEach(this.dealtCards, (card: Card, index: number) { GridItem() { Column() { Text(${card.rank}${card.suit}) .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(card.color) .fontFamily(monospace) } .width(100%) .height(80) .backgroundColor(#FFFFFF) .borderRadius(8) .shadow({ radius: 4, color: #10000000, offsetY: 2 }) .justifyContent(FlexAlign.Center) } }) } .columnsTemplate(1fr 1fr 1fr 1fr 1fr) .rowsGap(10) .columnsGap(10) .width(90%) .height(this.dealtCards.length 10 ? 300 : 200) .padding(10) .margin({ bottom: 20 }) } else { Text(点击洗牌按钮发牌) .fontSize(16) .fontColor(#999) .margin({ bottom: 20 }) } Row() { Text(发牌张数).fontSize(16).width(70) Slider({ value: this.dealCount, min: 1, max: 52, step: 1, style: SliderStyle.OutSet }) .layoutWeight(1) .onChange((value: number) { this.onDealCountChange(value); }) Text(${this.dealCount}).fontSize(16).width(40).textAlign(TextAlign.End) } .width(88%) .margin({ bottom: 10 }) Button(重新洗牌) .type(ButtonType.Capsule) .backgroundColor(#1976D2) .fontColor(Color.White) .fontSize(18) .onClick(() { this.resetAndDeal(); }) .margin({ top: 10, bottom: 15 }) Text( Fisher-Yates 算法保证每种排列等概率洗牌公平无偏) .fontSize(13) .fontColor(#AAA) .width(90%) .textAlign(TextAlign.Center) } .width(100%) .height(100%) .backgroundColor(#F5F5F5) } }代码的结构很清楚createDeck生成有序的 52 张牌shuffle用 Fisher-Yates 洗乱deal取前 N 张显示。界面用Grid自动排列成五列每张卡片圆角带阴影花色通过颜色区分。滑块调节发牌张数1 到 52 无级变化洗牌按钮重新打乱并发牌。运行效果把代码粘贴到 DevEco StudioRun 到 Pura X Max 模拟器上。屏幕出现五张牌红黑相间点数各异排成一行。拖动滑块到 10瞬间多出一排牌拖到 20三排扑克整齐码放。点“重新洗牌”牌面全部刷新花色点数随机打乱再也没有上一局的影子。每次洗牌后剩余牌数也更新。整个过程没有任何网络请求洗牌和发牌都在一瞬间完成流畅得像在手上切牌。总结这个发牌器麻雀虽小但把几个实用技能串了起来Fisher-Yates 洗牌算法用 O(n) 的时间实现真正的随机排列理解了它为什么比随机交换法更公平。Grid 布局利用columnsTemplate快速搭建响应式卡片网格数据驱动视图自动换行。状态管理State变量控制牌堆、已发牌、张数改动即刷新逻辑和 UI 彻底解耦。纯前端数据处理不依赖任何外部服务洗牌、发牌全部在本地完成适合各种离线场景。往后如果想玩点花样可以给牌面加上切牌动画或者记录发牌历史做成牌局复盘。一副牌、一个算法、一个网格就能生出无数玩法。洗牌发牌这件事以后再也不怕被人说作弊了。

相关新闻