最近在折腾 Flutter for OpenHarmony 这套东西,把之前做的一个小游戏集合 App 往鸿蒙生态上搬。整个集合里已经跑了扫雷、数独和五子棋,轮到蜘蛛纸牌的时候,我本来以为最麻烦的是规则引擎,结果真正卡了我最久的,反而是看起来最简单的“牌面显示”这一层。标题里写的《Flutter for OpenHarmony游戏集合App实战之蜘蛛纸牌牌面显示》,就是这期要聊的内容。
牌面显示听着不起眼,但它其实是整个卡牌游戏的骨架:既要准确把牌的数据画出来,又要承载翻牌、拖拽这些交互反馈,还得在 OpenHarmony 这种非主流 Flutter 平台上保持流畅。如果你也在做 Flutter 游戏、或者正在把现有 Flutter 工程适配到 OpenHarmony,这篇文章里的布局方案、状态管理思路、以及那些渲染器踩坑记录,应该能帮你少走不少弯路。
1. 整体设计:牌面显示到底在解决什么问题
1.1 为什么牌面显示值得单独拆出来做
很多人写卡牌游戏,喜欢把“牌面显示”和“游戏逻辑”混在一起,觉得不就是把一张图片或者一段文字摆上去吗?实际做下来完全不是这么回事。牌面显示至少同时承担了三层职责:
第一层是数据渲染。蜘蛛纸牌用两副牌,一共 104 张,每张牌有花色、点数和正反面状态。你需要在屏幕上把这些信息准确、清晰地画出来,而且画出来的样子要让玩家一眼就认出“这是黑桃 9 还是红桃 9”。
第二层是交互反馈。玩家点一张牌要能选中它,拖动时要能看到牌组跟着手指走,移动到合法位置时要有高亮提示。这一层如果做得糙,玩家就会觉得游戏“很生硬”。
第三层是动画载体。发牌动画、翻牌动画、收牌动画,全都是在牌面这个 Widget 上做文章。没有一套合理的牌面结构,后面每一个动画都会变成灾难。
我当时的做法是先把“牌面”抽象成一个独立的 UI 层,数据层通过接口喂给它,交互层通过回调通知逻辑层。这样牌面显示这部分只管“怎么画”“怎么动”,不用关心“能不能这么移”这种规则问题。这样做的好处是,后续如果要换主题皮肤、加动画特效,都不需要动规则引擎。
1.2 技术选型:为什么用代码绘制而不是贴图
做扑克牌牌面,摆在我面前的有三条路:用图片资源、用代码绘制、或者两者混合。我在项目里最终选择了“代码绘制为主、字体符号辅助”的方案,原因很实在。
图片方案最直观,找一套漂亮的高清扑克牌素材,每个花色点数一张图,再加一张牌背图。但蜘蛛纸牌摸牌时,每张牌要单独显示,104 张牌就要 53 张图(52 张正面加 1 张背面),打包体积会变大。而且图片资源有分辨率问题,屏幕缩放后容易发虚,后期想换一套牌面风格还得重新做一套图。
代码绘制的核心思路是用 Flutter 原生组件搭牌面:白色圆角矩形是牌底,左上角放点数和花色,中间放大花色。花色符号用 Unicode 字符(黑桃 ♠、红桃 ♥、方块 ♦、梅花 ♣)来画,点和数字用 Text 来画。这样做的好处是资源体积几乎为零,任意分辨率下都是矢量渲染,换主题只需要改颜色和字体。
代价就是字体兼容性需要额外处理。后面我会详细说在 OpenHarmony 上字体符号踩的坑。
1.3 布局方案:Stack 加 Positioned,而不是 CustomMultiChildLayout
牌桌布局有三种常见选择:Stack 加 Positioned、CustomMultiChildLayout、以及直接用 Column 嵌套。
我试了一圈,最后用的是 Stack 加 Positioned。原因很简单:蜘蛛纸牌的牌桌结构其实是个静态棋盘。左上角是发牌堆(Stock),右上角是收牌区(Foundation),中间下方是 10 个牌列(Tableau)。每个牌列里的牌需要按照一定偏移堆叠,这不是常规的线性布局能搞定的,Stack 加 Positioned 刚好能描述这种“自由定位”的需求。
CustomMultiChildLayout 也能做,它的优势是可以通过 delegate 统一计算每个子组件的位置,但代价是代码复杂度明显上升,而且每次布局变化都要重跑 delegate。对于 104 张牌的规模,Stack 加 Positioned 在性能上完全够用,代码还更容易读。后面在性能调优章节我会提一下 RepaintBoundary 怎么配合 Stack 使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 牌面数据模型与状态管理
2.1 扑克牌的数据结构与为什么不用单副牌模型
牌面显示的开始,不是 Widget,而是数据结构。一个干净的数据模型,能让后面所有 UI 代码都变得顺手。
我在项目里定义了一个 CardModel 类,核心字段如下:
dart复制enum CardSuit { spade, heart, diamond, club }
class CardModel {
final CardSuit suit;
final int rank; // 1=A, 2-10, 11=J, 12=Q, 13=K
bool faceUp;
bool isDragging;
CardModel({
required this.suit,
required this.rank,
this.faceUp = false,
this.isDragging = false,
});
bool get isRed => suit == CardSuit.heart || suit == CardSuit.diamond;
String get rankLabel {
switch (rank) {
case 1: return 'A';
case 11: return 'J';
case 12: return 'Q';
case 13: return 'K';
default: return '$rank';
}
}
String get suitSymbol {
switch (suit) {
case CardSuit.spade: return '♠';
case CardSuit.heart:
