基于鸿蒙OS开发附近社交游戏平台(十六)-看谁反应快 Canvas水果卡渲染
看谁反应快 — Canvas水果卡渲染技术文档
目录
- 游戏概述
- Canvas体系
- 5种FruitType枚举
- FruitCard模型
- drawTable()完整实现逐行解读
- 桌面卡布局算法
- 翻牌动画
- fruitCounts实时计数
- Canvas尺寸适配
- 水果计数条UI
- 玩家信息栏
1. 游戏概述
“看谁反应快"是NearPlay平台上一款基于经典桌游Halli Galli(德国心脏病)规则实现的多人实时反应力竞技游戏。Halli Galli自1992年由以色列游戏设计师Haim Shafir发明以来,凭借其极简而紧张刺激的游戏机制风靡全球,被誉为"最令人心跳加速的卡牌游戏”。该游戏的核心魅力在于:规则简单到三分钟就能学会,但反应速度的较量却能让玩家欲罢不能。
1.1 Halli Galli规则精髓
Halli Galli的标准规则非常直白:每位玩家平均分配一副水果卡牌,牌面朝下持于手中;游戏开始后,玩家轮流翻出自己手牌堆顶的一张卡放到桌面中央;当桌面上所有可见卡牌上同一种水果的数量恰好凑齐5个时,第一个发现并拍响桌面中央铃铛的玩家赢得桌面上所有的牌;拍错铃铛的玩家则受罚,需向其他每位玩家各支付一张手牌作为误判代价;当某位玩家手牌耗尽时即被淘汰,最终持有最多牌的玩家获胜。
在NearPlay的实现中,我们做了适度的数字化改编:拍铃动作由"抢拍按钮"和"语音抢拍"两种交互方式替代;翻牌由定时器自动执行以保持游戏节奏的紧凑感;误拍惩罚简化为扣减自身1张手牌而非向每位对手各付1张,这降低了数学复杂度但保留了惩罚力度;淘汰机制保留不变,手牌归零的玩家立即出局。
1.2 五种水果体系
游戏包含五种水果类型,这一设计与Halli Galli原版完全一致。五种水果分别是香蕉(🍌)、苹果(🍎)、橙子(🍊)、葡萄(🍇)和草莓(🍓)。每张卡牌上只出现一种水果,但数量从1到5不等。之所以选择5作为凑齐目标,正是因为卡面上水果数量最大为5,这意味着理论上仅凭一张5水果卡就能触发抢拍条件——但这种情况极其罕见,更多时候需要玩家心算桌面上多张卡片的同种水果数量之和。
五种水果的选择并非随意。Halli Galli原版选用的是香蕉、草莓、柠檬、葡萄和李子,这些水果具有高度视觉辨识度——形状差异大、颜色区分度高,使玩家能在快速扫视中区分不同种类。NearPlay版本用苹果和橙子替换了柠檬和李子,保留了视觉辨识度的同时增加了中国文化语境下的熟悉感。每种水果对应的emoji符号在Canvas渲染中扮演着关键的视觉载体角色,它们需要在极小的卡片面积内清晰传达水果种类信息,这对字号选择和绘制位置提出了精确要求。
1.3 凑5拍铃核心循环
"凑5拍铃"构成了游戏的主循环。在每一轮翻牌之后,玩家需要快速扫描桌面上所有可见卡牌,心算每种水果的总数是否达到5。这个心算过程看似简单,但随着桌面上卡牌数量增加和信息不断累积,认知负荷迅速攀升。人类工作记忆的容量有限(米勒定律指出约7±2个信息块),而5种水果各自的数量追踪恰好逼近这一边界,这正是Halli Galli设计的精妙之处。
在NearPlay的数字实现中,"凑5"判断由checkFiveFruits()函数完成。该函数遍历五种水果,查询fruitCounts记录中每种水果的当前总数,当任意一种恰好等于5时,返回该水果类型并激活抢拍窗口。注意这里的判断条件是严格等于5(=== 5),而非大于等于5。这是因为在正常游戏流程中,由于每张卡的水果数量上限为5且每次翻一张牌,同一水果的总数要么恰好达到5,要么跳过5直接到6以上。严格等于5的判断确保了游戏逻辑的确定性。
抢拍成功后,桌面上所有卡牌被清空归入赢家手中,游戏进入短暂的展示阶段(3秒),随后重新开始翻牌循环。如果误拍(桌面没有任何水果恰好凑齐5),则触发罚牌机制,玩家失去1张手牌并显示误拍提示2秒。当手牌归零时,游戏进入GAME_OVER阶段,该玩家被淘汰。
2. Canvas体系
Canvas是HarmonyOS ArkUI框架提供的2D图形绘制组件,它封装了HTML5 Canvas API的核心子集,使开发者能够在ArkTS声明式UI范式中嵌入命令式绘图逻辑。在"看谁反应快"游戏中,Canvas承担了桌面卡牌渲染的全部职责——绿色桌面背景、白色卡片、水果emoji、数量文字和边框线条,均通过Canvas 2D绘图上下文完成绘制。
2.1 CanvasRenderingContext2D配置
CanvasRenderingContext2D是Canvas组件的核心绘图引擎,它提供了完整的2D图形绘制API:矩形填充(fillRect)、矩形描边(strokeRect)、文本绘制(fillText)、路径操作(beginPath/moveTo/lineTo/arc等)、样式设置(fillStyle/strokeStyle/font/lineWidth等)以及画布操作(clearRect/save/restore等)。在QuickReactGame中,我们使用的绘图API包括:
clearRect(x, y, w, h)— 清除指定矩形区域内的像素fillRect(x, y, w, h)— 用当前fillStyle填充矩形strokeRect(x, y, w, h)— 用当前strokeStyle描边矩形fillText(text, x, y)— 在指定坐标绘制文本fillStyle— 设置填充颜色strokeStyle— 设置描边颜色font— 设置字体大小和字体族lineWidth— 设置线宽
在QuickReactGame组件中,Canvas绘图上下文的声明如下:
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
这行代码创建了绘图上下文实例,并将其存储为组件的私有成员。private修饰符确保该引用仅在组件内部可见,不会被外部访问或意外修改。选择private而非@State是关键的设计决策——Canvas绘图上下文是一个命令式对象,其状态变化(如fillStyle的改变)不应触发ArkUI的声明式UI刷新机制。如果将canvasCtx标记为@State,每次调用绘图方法后框架会尝试检测其"状态变化"并触发不必要的UI重渲染,导致性能损耗甚至渲染异常。
2.2 RenderingContextSettings(true)深度解析
RenderingContextSettings是Canvas渲染上下文的配置对象,其构造函数接受一个布尔参数antialias,控制是否启用抗锯齿渲染。在QuickReactGame中传入true,表示开启抗锯齿。
抗锯齿(Anti-aliasing)是一种图形渲染技术,用于消除几何图元边缘的锯齿状像素化现象。在2D Canvas绘图中,抗锯齿主要影响以下场景:
- 文本渲染 — emoji和文字的笔画边缘会获得亚像素级的平滑过渡,在28vp和20vp的字号下,这种平滑效果对中文数字和emoji的可读性至关重要
- 矩形描边 —
strokeRect绘制的1px边框在不开启抗锯齿时可能出现像素对齐问题,导致某条边比其他边看起来更粗或更细 - 对角线和非水平/垂直线条 — 虽然本游戏未使用对角线,但如果未来添加卡牌倾斜动画,抗锯齿将确保线条平滑
传入true而非false的决策基于以下考量:水果卡牌是游戏的核心视觉元素,玩家需要在极短时间内识别卡面上的水果种类和数量,任何视觉模糊或锯齿都可能影响识别速度,进而影响游戏体验。抗锯齿带来的GPU渲染开销在本游戏的渲染规模下(最多8张卡片、少量文本和矩形)几乎可以忽略不计。
如果不传入true(默认为false),卡牌边框可能出现半像素偏移导致的"发虚"效果,emoji渲染可能出现锯齿边缘,在低密度屏幕上尤其明显。因此,对于任何包含文本和精细图形的Canvas应用,都推荐开启抗锯齿。
2.3 Canvas组件与onReady回调
在ArkUI的声明式构建器build()方法中,Canvas组件的使用方式如下:
Canvas(this.canvasCtx)
.width(this.canvasWidth)
.height(this.canvasHeight)
.margin({ top: 8 })
.onReady(() => { this.drawTable() })
Canvas(this.canvasCtx)将之前创建的canvasCtx与Canvas组件实例绑定。这种绑定是双向的:Canvas组件提供了物理绘图表面(像素缓冲区),而canvasCtx提供了操作该表面的API接口。绑定后,对canvasCtx的所有绘图调用都会反映在该Canvas组件的显示区域上。
.width(this.canvasWidth)和.height(this.canvasHeight)设置了Canvas组件的布局尺寸。这两个值在aboutToAppear()生命周期中通过屏幕尺寸计算得出(详见第9节),确保Canvas宽度适配不同设备的屏幕。
.onReady()是Canvas组件的关键生命周期回调。它在一个极其重要的时机被触发——当Canvas组件完成布局计算、获得有效的宽高值、且底层绘图表面已初始化完毕之后。在onReady触发之前,canvasCtx的绘图操作可能无法正确执行,因为Canvas的物理像素缓冲区尚未就绪,宽高可能为零或未定义。
在QuickReactGame中,onReady回调中调用了this.drawTable(),这确保了第一次绘制发生在Canvas完全就绪之后。后续的绘制(如翻牌时的drawTable()调用)则不需要等待onReady,因为一旦Canvas初始化完成,其绘图表面就持续可用。
onReady回调只触发一次的语义也是需要注意的。它不像aboutToAppear那样在组件每次可见时都触发,而是在Canvas组件首次完成渲染准备时触发一次。这意味着如果在onReady中设置了任何状态,那些状态不会因为组件的可见性变化而重置。在本游戏中,onReady仅用于触发首次绘制,不涉及状态设置,因此不存在此问题。
2.4 Canvas与@State的协作模式
QuickReactGame中Canvas与ArkUI声明式状态系统的协作模式值得深入分析。Canvas本身是命令式绘图——开发者通过一系列过程式调用(clearRect→fillRect→fillText→strokeRect)来绘制画面,而非通过声明式地描述UI结构。但Canvas的触发重绘时机却与声明式状态紧密关联:
- 首次绘制 — 由onReady回调触发
- 翻牌重绘 — 由flipCard()方法在setInterval回调中调用drawTable()触发
- 抢拍后重绘 — playerSnap()清空tableCards后,会在下一轮flipCard中自动重绘
这种混合模式的关键在于:Canvas的绘制内容(卡牌图案)是由tableCards这个@State变量驱动的,但Canvas的重绘并非通过@State变化自动触发,而是通过显式调用drawTable()触发。这种设计赋予了开发者对重绘时机的完全控制权,避免了不必要的Canvas重绘开销。
一个常见的错误模式是将Canvas绘制逻辑放在@State变量的@Watch回调中。虽然这能实现"状态变化自动重绘"的效果,但@Watch回调的执行时机不够精确——它可能在组件尚未完成布局时就触发,导致Canvas绘制到错误的尺寸上。显式调用drawTable()的方式虽然代码稍多,但时机控制更精确。
3. 5种FruitType枚举
FruitType枚举定义了游戏中所有合法的水果类型,是整个水果卡体系的基础类型约束。在QuickReactModel.ets中,它的定义如下:
export enum FruitType {
BANANA = 0,
APPLE = 1,
ORANGE = 2,
GRAPE = 3,
STRAWBERRY = 4
}
3.1 枚举值的数值编码
五种水果分别映射到0至4的连续整数。这种编码方式并非随意选择,而是基于多重考量:
首先,连续整数使得枚举值可以作为数组索引使用。虽然在ArkTS中Record<string, number>是更常见的查找表实现方式,但0-4的连续编码为未来可能的重构(如使用Array替代Record)留下了便利。
其次,数值编码使得水果类型可以参与数学运算和比较操作。在checkFiveFruits()函数中,我们遍历0-4这五个枚举值来检查每种水果的计数,数值编码使得循环遍历成为可能。
第三,数值编码的紧凑性节省了存储空间。每张FruitCard的fruit字段存储的是一个number值(0-4),而非字符串(如’BANANA’),这在大量卡牌序列化和网络传输时具有优势。
3.2 emoji映射表
FruitType到emoji的映射由getFruitEmoji()函数实现:
export function getFruitEmoji(fruit: FruitType): string {
const emojis: Record<string, string> = {
'0': '🍌', '1': '🍎', '2': '🍊', '3': '🍇', '4': '🍓'
}
return emojis[String(fruit)] ?? '❓'
}
这个映射表的实现方式体现了ArkTS的几个重要特性:
Record<string, string>类型 — 由于ArkTS中枚举值在运行时是number类型,而Record的键必须是string类型,因此需要通过String(fruit)将枚举值转换为字符串键。这一转换虽然看似多余,但在ArkTS的类型系统中是必需的——Record<string, string>不接受number类型的键。
空值合并运算符?? — '❓'作为默认返回值,确保了当枚举值超出0-4范围时函数不会返回undefined。这是一种防御性编程策略,虽然在正常游戏流程中不会触发,但在调试或扩展新水果类型时提供了安全网。
函数式映射而非方法式映射 — getFruitEmoji被实现为模块级顶层函数而非FruitType枚举的方法或静态成员,这是因为ArkTS的enum不支持自定义方法(与TypeScript的const enum不同)。将映射逻辑提取为独立函数也便于在不同组件中复用。
3.3 水果名称映射
与emoji映射平行的还有中文名称映射:
export function getFruitName(fruit: FruitType): string {
const names: Record<string, string> = {
'0': '香蕉', '1': '苹果', '2': '橙子', '3': '葡萄', '4': '草莓'
}
return names[String(fruit)] ?? '未知'
}
虽然getFruitName在当前版本的游戏Canvas绘制中未被使用(Canvas上显示的是emoji而非中文名),但它在以下场景中发挥作用:游戏结束时的战报文本、辅助功能(Accessibility)的语音播报、以及调试日志中的可读输出。
3.4 五种水果的视觉辨识度分析
从视觉设计的角度,五种水果的选择和emoji映射需要满足高辨识度要求:
| 枚举值 | 水果 | emoji | 主色调 | 形状特征 |
|---|---|---|---|---|
| BANANA (0) | 香蕉 | 🍌 | 黄色 | 弯曲长条形 |
| APPLE (1) | 苹果 | 🍎 | 红色 | 圆形+茎 |
| ORANGE (2) | 橙子 | 🍊 | 橙色 | 圆形+纹理 |
| GRAPE (3) | 葡萄 | 🍇 | 紫色 | 簇状圆形 |
| STRAWBERRY (4) | 草莓 | 🍓 | 红色+绿 | 心形+籽 |
注意到苹果(红色)和草莓(红色)的主色调相近,这可能在快速扫视中造成混淆。但它们的形状差异足够大(圆形vs心形),加上emoji渲染中苹果带有明显的茎叶、草莓表面有籽的细节,实际辨识度是有保障的。在28vp字号下(约19物理像素),这些细节的呈现依赖于设备字体渲染引擎的质量,开启抗锯齿(RenderingContextSettings(true))对此至关重要。
4. FruitCard模型
FruitCard是游戏中卡牌的数据模型,封装了单张水果卡的全部信息:
export class FruitCard {
fruit: FruitType = FruitType.BANANA
count: number = 1
static of(fruit: FruitType, count: number): FruitCard {
const c = new FruitCard()
c.fruit = fruit; c.count = count
return c
}
}
4.1 字段解析
fruit字段 — 类型为FruitType枚举,标识该卡牌上的水果种类。默认值为FruitType.BANANA(0),即香蕉。选择香蕉作为默认值是随意的(0是枚举的第一个值),在正常游戏流程中,每张卡牌的fruit都由随机生成逻辑显式赋值,默认值不会被使用到。
count字段 — 类型为number,表示该卡牌上该水果的出现次数,取值范围为1-5。默认值为1。count=1是最常见的卡牌(理论上占总牌数的20%),而count=5是最稀有的(也占20%),但因为5是触发抢拍的关键数字,一张count=5的卡可能直接将某种水果从0跳到5,是游戏中需要高度关注的"爆发牌"。
4.2 工厂方法of()
FruitCard使用了static of()工厂方法而非构造函数来创建实例。这是ArkTS中的常见模式,因为ArkTS的类构造函数受限于结构化范式,不支持在构造函数签名中声明并初始化属性(类似TypeScript的constructor parameter properties)。使用工厂方法的优势:
- 命名更具表达力 —
FruitCard.of(fruit, count)比new FruitCard(fruit, count)更清晰地表达了创建意图 - 便于未来扩展 — 可以在不改变调用点的情况下,在of()内部添加缓存、校验或转换逻辑
- 与QuickReactPlayer.of()保持一致 — 整个QuickReactModel的创建风格统一
4.3 不可变性考量
值得注意的是,FruitCard的字段没有使用readonly修饰,这在理论上允许创建后的卡牌被修改(如card.count = 3)。但在实际游戏逻辑中,FruitCard一旦创建就不会被修改——它要么存在于tableCards数组中,要么被清空。将字段设为readonly在TypeScript中是推荐做法,但在ArkTS中,readonly对class字段的支持存在限制(取决于SDK版本),因此当前实现选择了可变字段加约定式不可变的策略。
5. drawTable()完整实现逐行解读
drawTable()是整个Canvas水果卡渲染的核心函数,负责将内存中的tableCards数组转换为Canvas上的可视化卡牌图案。以下是该函数的完整代码及其逐行深度解读:
drawTable(): void {
const ctx = this.canvasCtx
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
ctx.fillStyle = '#2E7D32'
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
const cardWidth = 65
const cardHeight = 90
const startX = 8
const startY = 8
const cols = 4
for (let i = 0; i < this.tableCards.length && i < 8; i++) {
const card = this.tableCards[i]
const col = i % cols
const row = Math.floor(i / cols)
const x = startX + col * (cardWidth + 6)
const y = startY + row * (cardHeight + 6)
ctx.fillStyle = '#FFF8E1'
ctx.fillRect(x, y, cardWidth, cardHeight)
ctx.fillStyle = '#333333'
ctx.font = '28vp sans-serif'
const emoji = getFruitEmoji(card.fruit)
ctx.fillText(emoji, x + 8, y + 36)
ctx.font = '20vp sans-serif'
const countStr = `x${card.count}`
ctx.fillText(countStr, x + 8, y + 70)
ctx.strokeStyle = '#BDBDBD'
ctx.lineWidth = 1
ctx.strokeRect(x, y, cardWidth, cardHeight)
}
}
5.1 第1行:获取绘图上下文引用
const ctx = this.canvasCtx
将组件的私有绘图上下文赋值给局部常量ctx。这一做法有两个好处:一是缩短了后续代码的长度(ctx比this.canvasCtx简洁),二是在频繁调用的绘图函数中,局部变量访问比成员属性访问更快(避免了原型链查找)。虽然现代JavaScript引擎对this.xxx的访问优化已经很好,但在一个可能每2秒调用一次的绘图函数中,养成使用局部引用的习惯是有益的。
5.2 第2行:清空画布
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
clearRect(x, y, width, height)将指定矩形区域内的所有像素重置为透明(RGBA均为0)。这里传入的参数覆盖了整个Canvas区域(从左上角0,0到右下角canvasWidth,canvasHeight),效果是彻底清除上一帧的所有绘制内容。
每次drawTable()调用都从clearRect开始,这是一种"全量重绘"策略——不尝试增量更新(如只擦除发生变化的卡牌区域),而是每次都从空白画布开始重新绘制所有内容。选择全量重绘而非增量更新的理由:
- 简化逻辑 — 卡牌数量和位置的变化模式复杂(新增、移除、重新排列),增量更新的脏矩形计算会增加代码复杂度
- 避免残留 — 当卡牌数量减少时(如抢拍成功清空桌面),增量更新需要精确擦除旧卡牌的像素区域,遗漏会导致画面残留
- 性能充足 — 最多8张卡牌的2D绘制在现代设备上耗时不到1ms,增量优化的收益微乎其微
5.3 第3-4行:绘制绿色桌面背景
ctx.fillStyle = '#2E7D32'
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
#2E7D32是Material Design色板中的Green 800,一种深沉的森林绿色,模拟了真实桌游的绿色毛毡桌面。选择深绿色而非浅绿色的原因:深绿色与白色/浅黄色卡牌形成高对比度,使卡牌在视觉上"浮"于桌面之上;同时深色背景更容易营造桌游的沉浸氛围感。
fillRect在clearRect之后立即执行,用深绿色填充整个Canvas区域。这一步确保了Canvas上没有透明区域——即使桌面上没有卡牌,玩家看到的也是一张绿色桌面而非底层的组件背景色。clearRect→fillRect的组合模式是2D Canvas绘图的常见范式,相当于"先擦黑板再刷绿漆"。
5.4 第5-8行:布局常量定义
const cardWidth = 65
const cardHeight = 90
const startX = 8
const startY = 8
四个常量定义了卡牌的尺寸和起始偏移:
cardWidth = 65— 每张卡牌的宽度65px,约为1.7cm的物理尺寸,在手机屏幕上约占屏幕宽度的1/5cardHeight = 90— 每张卡牌的高度90px,宽高比约为0.72:1,接近标准扑克牌的宽高比(约0.68:1),给人"卡牌"的视觉感受startX = 8— 第一列卡牌相对于Canvas左边距的偏移8px,为卡牌区域左右各留出适当边距startY = 8— 第一行卡牌相对于Canvas上边距的偏移8px
startX和startY的存在确保了卡牌不会紧贴Canvas边缘绘制。8px的偏移量是基于视觉美感的经验值——太小会显得拥挤,太大会浪费宝贵的卡牌排布空间。
5.5 第9行:列数定义
const cols = 4
4列的布局选择是整个网格系统的关键参数。4列×2行=8个网格位,恰好等于桌面最多显示的卡牌数8张,不留空位也不溢出。选择4列而非3列或5列的理由:
- 3列×2行=6位,不足8张卡牌,需要3行才能容纳8张,增加垂直高度
- 5列×2行=10位,浪费2个空位,且5列×65px+4间距×6px=349px,接近canvasWidth(约328-360px),几乎无左右边距
- 4列×2行=8位,4×65+3×6+2×8=290px,在约328px的canvasWidth下左右各留约19px边距,视觉平衡
5.6 第10行:遍历卡牌循环
for (let i = 0; i < this.tableCards.length && i < 8; i++) {
双重上限的循环条件i < this.tableCards.length && i < 8确保了:
- 如果tableCards不足8张,只绘制实际存在的卡牌
- 如果tableCards超过8张(理论上不应发生,因为flipCard()有slice截断保护),最多绘制前8张
循环变量i既是数组索引,也是网格位置计算的依据——通过i % cols和Math.floor(i / cols)分别计算出列号和行号。
5.7 第11-15行:获取卡牌数据与计算坐标
const card = this.tableCards[i]
const col = i % cols
const row = Math.floor(i / cols)
const x = startX + col * (cardWidth + 6)
const y = startY + row * (cardHeight + 6)
这五行代码是布局算法的核心:
col = i % cols — 取模运算将线性索引映射为列号。i=0→col=0, i=1→col=1, i=2→col=2, i=3→col=3, i=4→col=0(第二行开始)…
row = Math.floor(i / cols) — 整除运算将线性索引映射为行号。i=0-3→row=0(第一行),i=4-7→row=1(第二行)
x = startX + col * (cardWidth + 6) — 列坐标 = 起始偏移 + 列号 × (卡宽+间距)。间距6px是相邻卡牌之间的水平间距,既保证卡牌不重叠,又在视觉上形成明确的卡牌边界。
y = startY + row * (cardHeight + 6) — 行坐标 = 起始偏移 + 行号 × (卡高+间距)。垂直间距与水平间距同为6px,保持网格的均匀感。
以i=5为例:col=5%4=1, row=Math.floor(5/4)=1, x=8+1×(65+6)=8+71=79, y=8+1×(90+6)=8+96=104。第6张卡牌(索引5)绘制在坐标(79, 104)处,即第二行第二列的位置。
5.8 第16-17行:绘制白色卡牌背景
ctx.fillStyle = '#FFF8E1'
ctx.fillRect(x, y, cardWidth, cardHeight)
#FFF8E1是Material Design的Amber 50色,一种极浅的暖黄色,而非纯白色。选择暖白色而非纯白色(#FFFFFF)的设计考量:
- 纯白色在深绿色背景上对比过强,可能产生"刺眼"效果,暖白色柔化了视觉冲击
- 暖白色模拟了真实桌游卡牌的微微泛黄的纸质质感,增加沉浸感
- 在低亮度环境下,暖白色比纯白色更舒适
fillRect(x, y, cardWidth, cardHeight)在计算好的坐标处绘制一个65×90的矩形,形成卡牌的背景面。这张"白卡"是后续emoji和文字绘制的基底。
5.9 第18-20行:绘制水果emoji
ctx.fillStyle = '#333333'
ctx.font = '28vp sans-serif'
const emoji = getFruitEmoji(card.fruit)
绘制emoji前的三项准备工作:
fillStyle = ‘#333333’ — 深灰色文本颜色。emoji的实际渲染颜色由系统emoji字体决定(彩色emoji忽略fillStyle),但设置深色fillStyle确保了如果某设备不支持彩色emoji而回退到黑白文本渲染时,文字仍然可见。
font = ‘28vp sans-serif’ — 字号28vp(虚拟像素),字体族为系统默认的无衬线字体。28vp在Canvas的font属性中使用了vp单位而非px,这意味着字号会随设备屏幕密度自动缩放。在高密度屏幕(densityPixels=3)上,28vp约等于84物理像素,确保emoji在任何设备上都有足够的显示面积。sans-serif作为通用字体族,在不同平台上分别映射为:HarmonyOS Sans(鸿蒙)、PingFang SC(iOS)、Noto Sans(Android)。
getFruitEmoji(card.fruit) — 通过之前分析的映射函数,将FruitType枚举值转换为对应的emoji字符。
5.10 第21行:绘制emoji文本
ctx.fillText(emoji, x + 8, y + 36)
fillText(text, x, y)在指定坐标绘制文本。注意Canvas的文本绘制坐标系中,y坐标对应的是文本的基线(baseline)位置,而非文本的顶部。
x + 8 — 在卡牌左边距内偏移8px,为emoji留出左侧内边距。65px宽的卡牌减去8px左边距后,emoji的绘制区域约为57px宽。28vp的emoji在渲染后宽度约为28-30px,因此8px的左偏移使得emoji在卡牌内水平居中偏左,为右侧的数量文字留出空间。
y + 36 — 在卡牌顶部内偏移36px。由于y是基线位置,36px的偏移意味着emoji的视觉中心大约位于卡牌上半部分。90px高的卡牌中,36px基线加上约28px的ascender高度,emoji占据的区域约为y+8至y+36,视觉中心在y+22左右,恰好位于卡牌上半部。
5.11 第22-24行:绘制数量文字
ctx.font = '20vp sans-serif'
const countStr = `x${card.count}`
ctx.fillText(countStr, x + 8, y + 70)
font = ‘20vp sans-serif’ — 数量文字的字号小于emoji(20vp vs 28vp),体现了"图形为主、文字为辅"的视觉层次原则。玩家识别水果种类的速度优先于读取具体数量,emoji应该占据更多视觉权重。
countStr = x${card.count} — 使用模板字符串生成"x1"到"x5"格式的数量文本。前缀"x"是乘号表示法,在游戏卡牌中是通用的数量表示方式(如"x3"表示3个)。这种表示法比纯数字(“3”)或中文(“三个”)更简洁紧凑。
fillText(countStr, x + 8, y + 70) — y+70将数量文字定位在卡牌下半部分。90px高的卡牌中,y+70基线减去约14px的ascender高度,文字视觉中心在y+63左右,位于卡牌下半部的中间偏上位置,与上半部的emoji形成上下对称的布局。
5.12 第25-27行:绘制卡牌边框
ctx.strokeStyle = '#BDBDBD'
ctx.lineWidth = 1
ctx.strokeRect(x, y, cardWidth, cardHeight)
strokeStyle = ‘#BDBDBD’ — 浅灰色边框。#BDBDBD是Material Design的Grey 400色,在暖白色卡牌(#FFF8E1)上形成微妙的边界,不会过于抢眼但足以区分相邻卡牌。
lineWidth = 1 — 1像素的边框宽度。在开启抗锯齿的情况下,1px的线条会被渲染为亚像素宽度(约0.5-1.5物理像素),产生柔和的边界效果。如果不希望边框"吃掉"卡牌内部空间,1px是最优选择。
strokeRect(x, y, cardWidth, cardHeight) — 描边矩形,与fillRect使用相同的坐标和尺寸。注意Canvas的strokeRect遵循"半像素偏移"规则——1px线宽的描边以矩形边界为中心,内外各占0.5px。在抗锯齿模式下,这种偏移不影响视觉效果;但在非抗锯齿模式下,可能导致1px线条看起来模糊,这也是选择开启抗锯齿的另一个理由。
5.13 绘制顺序的艺术
drawTable()中的绘制顺序(背景→卡牌→emoji→文字→边框)遵循了2D绘图的基本原则:后绘制的图形覆盖先绘制的图形。
- 先绘制绿色背景(最底层)
- 在背景上绘制暖白色卡牌矩形(覆盖部分背景)
- 在卡牌上绘制emoji和数量文字(覆盖部分卡牌)
- 最后绘制边框(覆盖在所有内容之上,确保边框完整可见)
如果调整绘制顺序——例如先画边框再画卡牌背景——则fillRect会覆盖strokeRect的内侧0.5px,导致边框看起来比1px更细。这就是边框必须最后绘制的原因。
6. 桌面卡布局算法
桌面卡布局算法决定了多张FruitCard如何在Canvas上排列,是游戏视觉呈现的核心空间逻辑。本节从数据结构、空间计算和边界处理三个维度进行深入分析。
6.1 tableCards数组结构
@State tableCards: FruitCard[] = []
tableCards是一个@State装饰的FruitCard数组,存储当前桌面上可见的所有卡牌。作为@State变量,当tableCards被重新赋值时(注意:必须是整个数组的重新赋值,如this.tableCards = [...this.tableCards, card],而非this.tableCards.push(card)),ArkUI框架会检测到变化并触发依赖该状态的UI组件刷新。
数组采用FIFO(先进先出)的语义——新翻出的卡牌追加到数组末尾,当数组长度超过8时,从数组头部截断保留最后8张。这种语义确保了桌面上始终显示最近翻出的8张卡牌,旧卡牌"滑出"视野。
6.2 最多8张slice截断
if (this.tableCards.length > 8) {
this.tableCards = this.tableCards.slice(this.tableCards.length - 8)
}
slice(this.tableCards.length - 8)从数组末尾截取最后8个元素。例如,当数组有9个元素(索引0-8)时,slice(1)返回索引1-8的元素,丢弃了最旧的索引0元素。
选择8作为上限基于以下空间计算:
4列×2行的网格布局,4×65px宽 + 3×6px间距 + 2×8px边距 = 260+18+16 = 294px,这在约328-360px的canvasWidth下留有约34-66px的富裕空间,确保最后一列右侧有足够边距。2行×90px高 + 1×6px间距 + 2×8px边距 = 180+6+16 = 202px,在280px的canvasHeight下留有78px的底部空间,用于显示阶段描述文字等辅助信息。
如果尝试显示9张卡牌(3行),高度将增加到202+90+6 = 298px,接近280px的canvasHeight,必然导致第三行卡牌被截断。因此8张是当前Canvas尺寸下的最大容量。
6.3 4列2行网格计算
网格位置的计算公式是整个布局算法的数学核心:
列号 col = i % 4
行号 row = Math.floor(i / 4)
x坐标 = 8 + col × (65 + 6) = 8 + col × 71
y坐标 = 8 + row × (90 + 6) = 8 + row × 96
对于8个网格位,完整的坐标映射如下:
| 索引i | 列col | 行row | x坐标 | y坐标 | 屏幕位置 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 8 | 8 | 第一行第一列 |
| 1 | 1 | 0 | 79 | 8 | 第一行第二列 |
| 2 | 2 | 0 | 150 | 8 | 第一行第三列 |
| 3 | 3 | 0 | 221 | 8 | 第一行第四列 |
| 4 | 0 | 1 | 8 | 104 | 第二行第一列 |
| 5 | 1 | 1 | 79 | 104 | 第二行第二列 |
| 6 | 2 | 1 | 150 | 104 | 第二行第三列 |
| 7 | 3 | 1 | 221 | 104 | 第二行第四列 |
从表格可以看出,第一行的4张卡牌占据y=8至y=98的区域,第二行占据y=104至y=194的区域,两行之间的间距为6px(98+6=104)。水平方向同理,相邻卡牌间距为6px。
6.4 cardWidth与cardHeight的尺寸决策
65px×90px的卡牌尺寸是在可用空间与信息展示需求之间的平衡结果:
- 宽度65px — 需要容纳28vp的emoji(约28px渲染宽度)加上"x5"格式的数量文字(约25px渲染宽度),8px左内边距,总需求约61px。65px刚好满足需求并留有4px余量
- 高度90px — emoji区域约36px(从y+8到y+36),数量文字区域约34px(从y+36到y+70),加上y+70到y+90的20px底部内边距。90px的高度使得卡牌上半部分展示水果图案、下半部分展示数量,形成清晰的视觉分区
如果尝试缩小卡牌尺寸(如50×70),emoji和文字将被迫压缩,影响快速识别;如果放大(如80×110),8张卡牌将无法容纳在4×2网格中。65×90是当前设计约束下的最优解。
6.5 间距6px的视觉意义
6px的间距(gap)在视觉上创造了"卡牌之间有空气"的效果,使每张卡牌成为独立的视觉单元。如果间距为0,相邻卡牌的边框将紧贴在一起,形成连续的网格线而非独立的卡牌感。
6px而非更大的间距(如12px或16px)的选择受到空间约束——增大间距意味着每张卡牌占用更多空间,8张卡牌可能无法在Canvas中完整展示。6px在提供视觉分离的同时最小化了空间消耗。
7. 翻牌动画
翻牌是"看谁反应快"游戏节奏的引擎——每隔固定时间,一张新的水果卡牌"翻"到桌面上,更新桌面信息并触发玩家的心算与反应。在NearPlay的实现中,翻牌由定时器驱动的自动化流程完成。
7.1 flipTimer定时器机制
@State flipTimer: number = -1
flipTimer存储了setInterval返回的定时器ID,初始值为-1表示未启动。在ArkTS中,setInterval的返回值是number类型,-1是一个安全的初始值(setInterval永远不会返回负数ID)。
定时器的生命周期管理遵循严格的三段式模式:
- 启动 — 在
startFlipping()中通过setInterval(() => { this.flipCard() }, 2000)启动,返回的ID存储到flipTimer - 停止 — 在
playerSnap()(抢拍成功时)和组件aboutToDisappear()中通过clearInterval(this.flipTimer)停止 - 检查 — 在clearInterval之前通过
this.flipTimer !== -1检查是否处于活跃状态,避免clearInterval(null)或clearInterval(-1)的错误
7.2 setInterval 2s节奏设计
startFlipping(): void {
this.phase = QuickReactPhase.FLIP_PHASE
this.flipTimer = setInterval(() => { this.flipCard() }, 2000)
}
2秒(2000ms)的翻牌间隔是游戏节奏的关键参数。这个数值的选择需要平衡两个对立的需求:
- 太快(如1秒)— 玩家来不及心算桌面上的水果总数,游戏沦为纯随机抢拍,失去了策略性
- 太慢(如4秒)— 玩家有充裕时间完成心算,紧张感不足,游戏节奏拖沓
2秒的间隔意味着:在4张卡牌全部翻出之前(8秒),玩家有足够的扫描时间;但当第4-5张卡牌出现时,需要快速重新计算总数,压力开始累积。这恰好模拟了Halli Galli实体游戏中"紧张感逐级攀升"的体验。
7.3 flipCard()随机水果+随机count
flipCard(): void {
const fruits: FruitType[] = [FruitType.BANANA, FruitType.APPLE, FruitType.ORANGE, FruitType.GRAPE, FruitType.STRAWBERRY]
const randomFruit = fruits[Math.floor(Math.random() * fruits.length)]
const randomCount = Math.floor(Math.random() * 5) + 1
const card = FruitCard.of(randomFruit, randomCount)
this.tableCards = [...this.tableCards, card]
this.updateFruitCounts()
if (this.tableCards.length > 8) {
this.tableCards = this.tableCards.slice(this.tableCards.length - 8)
}
const targetFruit = this.checkFiveFruits()
if (targetFruit !== null) {
this.canSnap = true
}
this.drawTable()
}
随机水果选择 — fruits[Math.floor(Math.random() * fruits.length)]从5种水果中均匀随机选择一种。每种水果被选中的概率均为1/5=20%。Math.random()返回[0,1)的浮点数,乘以5后为[0,5),Math.floor取整后为0-4的整数,恰好覆盖5种水果的索引范围。
随机数量选择 — Math.floor(Math.random() * 5) + 1生成1-5的随机整数。每种数量出现的概率均为1/5=20%。这意味着count=5的"爆发牌"出现概率为20% × 20% = 4%(某种特定水果的5出现概率),同时任何水果的5出现概率为4% × 5 = 20%。
tableCards更新 — this.tableCards = [...this.tableCards, card]使用展开运算符创建新数组,而非直接push。这是ArkTS @State机制的要求——@State检测的是引用变化,push修改原数组但不改变引用,框架无法检测到变化。
drawTable()调用 — 每次flipCard后都调用drawTable()重绘Canvas,这是翻牌"动画"的实现——虽然不是逐帧动画,但每2秒一次的Canvas重绘在视觉上等同于"新卡牌出现"的动画效果。
7.4 dealCards()初始化流程
dealCards(): void {
this.phase = QuickReactPhase.DEAL_CARDS
this.phaseDesc = '发牌中...'
this.myHandCount = 8
this.tableCards = []
this.updateFruitCounts()
setTimeout(() => { this.startFlipping() }, 1500)
}
dealCards()在游戏开始和"再来一局"时调用。它执行完整的重置:阶段重置为发牌中、桌面清空、手牌恢复8张、水果计数归零。1.5秒的setTimeout延迟在"发牌中…"文字显示后给予玩家准备时间,然后自动进入翻牌阶段。这种延迟启动模式模拟了实体游戏中"发完牌、准备开始"的仪式感。
8. fruitCounts实时计数
fruitCounts是游戏逻辑的核心数据结构——它实时追踪桌面上每种水果的出现总数,是判断"凑5"条件的依据,也是辅助玩家决策的参考信息。
8.1 Record<string, number>类型设计
@State fruitCounts: Record<string, number> = {}
fruitCounts的类型是Record<string, number>,即从string键到number值的映射。选择Record而非Array或Map的理由:
- ArkTS兼容性 — Record是ArkTS推荐使用的键值映射类型,Map在某些ArkTS版本中受限于Sendable约束
- @State支持 — @State装饰器支持Record类型的变化检测,当整个Record被重新赋值时触发UI刷新
- 枚举键适配 — FruitType枚举值在运行时是number,需通过String()转为string键,Record<string, number>自然支持这种转换
8.2 updateFruitCounts()实现解析
updateFruitCounts(): void {
const counts: Record<string, number> = {}
counts['0'] = 0; counts['1'] = 0; counts['2'] = 0; counts['3'] = 0; counts['4'] = 0
for (const card of this.tableCards) {
const key = String(card.fruit)
const prev = counts[key] ?? 0
counts[key] = prev + card.count
}
this.fruitCounts = counts
}
初始化 — 先创建新的counts对象并将5种水果的计数全部归零。显式初始化确保了即使桌面上没有某种水果的卡牌,该水果的计数也会显示为0而非undefined。
遍历累加 — 对tableCards中的每张卡牌,将其fruit转为string键,在counts中累加count值。counts[key] ?? 0使用空值合并运算符处理首次访问(值为undefined时回退到0),但由于前面已经显式初始化了0-4的键,这里?? 0实际上是冗余的防御性代码。
整体赋值 — this.fruitCounts = counts将计算结果整体赋值给@State变量。这种"计算新值→整体赋值"的模式确保了fruitCounts在赋值瞬间完成所有更新,避免了中间状态被UI读取的可能性。
8.3 计数更新时机
fruitCounts的更新与tableCards的变化严格同步:
flipCard()— 新卡加入tableCards后立即调用updateFruitCounts()playerSnap()— 抢拍成功清空tableCards后调用updateFruitCounts()dealCards()— 初始化时调用updateFruitCounts()将所有计数归零
这种"数据变更后立即同步"的模式确保了fruitCounts始终反映tableCards的当前状态,不存在延迟或过期的计数。在并发场景下(如定时器回调和用户点击同时触发),ArkUI的单线程事件循环保证了updateFruitCounts()的原子性——它不会在执行到一半时被另一个回调中断。
8.4 checkFiveFruits()判断逻辑
checkFiveFruits(): FruitType | null {
const fruits: FruitType[] = [FruitType.BANANA, FruitType.APPLE, FruitType.ORANGE, FruitType.GRAPE, FruitType.STRAWBERRY]
for (const fruit of fruits) {
const key = String(fruit)
const count = this.fruitCounts[key] ?? 0
if (count === 5) {
return fruit
}
}
return null
}
该函数遍历5种水果,检查是否有任何一种的计数恰好等于5。返回值为FruitType(满足条件的水果类型)或null(没有任何水果凑齐5个)。
严格等于5(=== 5)的判断条件值得深入讨论:由于每张卡的count范围是1-5,桌面上同一水果的总数理论上可以超过5(如两张count=3的香蕉卡,总数为6)。在这种情况下,=== 5不会触发抢拍条件,这意味着玩家"错过"了凑5的时机。这是Halli Galli原版规则的数字化呈现——原版中如果翻牌跳过了恰好5的情况(虽然极少发生),同样不会触发抢拍。
在当前的随机翻牌实现中,由于每次只翻一张卡,总数的变化是增量的(+1到+5),从不足5直接跳到超过5的唯一可能是一张count>=2的卡牌使得总数从4跳到6+。这种情况在概率上是存在的,但发生概率较低,属于游戏的正常随机性。
9. Canvas尺寸适配
移动设备的屏幕尺寸和像素密度千差万别,Canvas的尺寸必须根据实际设备参数进行动态计算,才能确保卡牌布局在不同设备上的一致性和美观性。QuickReactGame通过display.getDefaultDisplaySync()API获取设备屏幕信息,并据此计算Canvas的最佳尺寸。
9.1 display.getDefaultDisplaySync()API
import { display } from '@kit.ArkUI'
const screenInfo = display.getDefaultDisplaySync()
display.getDefaultDisplaySync()是HarmonyOS ArkUI框架提供的同步API,用于获取当前设备的默认显示屏信息。它返回一个Display对象,包含以下关键属性:
- width — 屏幕的物理宽度(单位:物理像素)
- height — 屏幕的物理高度(单位:物理像素)
- densityPixels — 屏幕的密度因子(逻辑像素与物理像素的比值)
该API的Sync后缀表明它是同步调用,不会返回Promise,在调用时立即返回结果。这简化了在aboutToAppear()中的使用——无需async/await,直接在同步上下文中获取屏幕信息。
9.2 densityPixels换算原理
this.canvasWidth = (screenInfo.width / screenInfo.densityPixels) - 32
这行代码执行了物理像素到逻辑像素的转换:
- screenInfo.width — 屏幕的物理像素宽度。例如,一台1080×2400的设备,width=1080
- screenInfo.densityPixels — 密度因子。例如,一台3倍密度屏幕,densityPixels=3
- screenInfo.width / screenInfo.densityPixels — 转换为逻辑像素(vp)。1080/3=360vp
- - 32 — 减去左右各16px的页面内边距,得到Canvas的可用逻辑宽度
densityPixels的换算关系:1vp = densityPixels个物理像素。因此物理像素/密度因子=逻辑像素(vp)。Canvas组件的.width()属性接受vp单位,因此计算出的逻辑像素值可以直接使用。
9.3 Canvas高度固定值
this.canvasHeight = 280
与宽度不同,canvasHeight被设置为固定值280vp而非根据屏幕高度计算。这一选择基于以下考量:
- Canvas高度决定的是"桌面"的垂直空间,280vp足以容纳2行卡牌(2×90+间距+边距≈202vp)并留有底部空间
- 游戏界面下方还需要放置抢拍按钮、语音抢拍按钮、手牌计数等UI元素,Canvas不能占据过多垂直空间
- 不同设备的屏幕高度差异(如16:9 vs 20:9)通过Canvas外部的滚动或弹性布局来适应,而非通过Canvas自身的高度变化
9.4 异常处理
try {
const screenInfo = display.getDefaultDisplaySync()
this.canvasWidth = (screenInfo.width / screenInfo.densityPixels) - 32
this.canvasHeight = 280
} catch (e) {
}
display.getDefaultDisplaySync()在某些环境下可能抛出异常(如无屏幕连接的服务端渲染环境、权限不足等)。catch块为空,意味着异常时使用canvasWidth和canvasHeight的初始默认值(360和300)。这种"优雅降级"策略确保了即使屏幕信息获取失败,游戏仍能以合理的默认尺寸运行。
默认值360vp的选择基于:360vp是HarmonyOS推荐的最小设计宽度(sm breakpoint),绝大多数手机在竖屏模式下的逻辑宽度≥360vp。使用360作为默认值确保了卡牌布局在最窄屏幕上也不会溢出。
9.5 @State驱动的动态尺寸
canvasWidth和canvasHeight被声明为@State变量:
@State canvasWidth: number = 360
@State canvasHeight: number = 300
@State装饰使得Canvas的尺寸变化能够触发UI刷新。当aboutToAppear()中计算出新的canvasWidth后,Canvas组件的.width(this.canvasWidth)会自动使用新值,框架会重新布局Canvas及其父容器。
这一机制在屏幕旋转等场景中尤为重要——如果Canvas尺寸是硬编码的常量,屏幕旋转后Canvas宽度可能不适配新的屏幕方向。虽然当前实现未监听屏幕旋转事件,但@State的响应式特性为未来添加旋转适配提供了基础。
9.6 多设备适配策略
QuickReactGame的Canvas适配遵循"宽度自适应、高度固定"的策略,这是移动端Canvas游戏的常见实践:
| 设备类型 | densityPixels | 物理宽度 | 逻辑宽度 | canvasWidth | 卡牌布局效果 |
|---|---|---|---|---|---|
| 普通手机 | 2.0 | 720px | 360vp | 328vp | 4列宽松排布 |
| 高密度手机 | 3.0 | 1080px | 360vp | 328vp | 4列宽松排布 |
| 超高密度手机 | 3.5 | 1260px | 360vp | 328vp | 4列宽松排布 |
| 平板 | 2.0 | 1280px | 640vp | 608vp | 4列居中排布 |
在平板等大屏设备上,4列卡牌仅占据Canvas宽度的一部分(约48%),剩余空间呈现为绿色桌面。这种"卡牌区域不拉伸"的设计保持了卡牌的视觉比例,避免了在大屏上emoji被过度放大导致的失真。未来可以考虑在大屏上增加列数(如6列3行),但这需要额外的响应式布局逻辑。
10. 水果计数条UI
水果计数条是位于Canvas上方的UI组件,实时显示桌面上5种水果各自的出现总数,并使用颜色高亮提示玩家当前的水果计数状态。它是玩家决策的重要辅助信息源。
10.1 UI实现
Row() {
ForEach(this.fruitList, (fruit: FruitType) => {
Column() {
Text(getFruitEmoji(fruit))
.fontSize(20)
Text(`${this.fruitCounts[String(fruit)] ?? 0}`)
.fontSize(14)
.fontColor((this.fruitCounts[String(fruit)] ?? 0) >= 5 ? '#F44336' : ((this.fruitCounts[String(fruit)] ?? 0) >= 3 ? '#FF9800' : '#333333'))
.fontWeight((this.fruitCounts[String(fruit)] ?? 0) >= 5 ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (fruit: FruitType) => String(fruit))
}
.width('100%')
.padding(8)
.backgroundColor(Color.White)
.margin({ top: 4 })
10.2 ForEach渲染5种水果
ForEach遍历fruitList数组(5种FruitType枚举值),为每种水果生成一个Column组件。每个Column包含两个Text组件:上方的emoji和下方的计数数字。
ForEach的第三个参数(fruit: FruitType) => String(fruit)是键生成函数,它将FruitType枚举转为唯一字符串键,确保ForEach在数组不变时复用已有组件实例,避免不必要的组件销毁和重建。由于FruitType的枚举值是0-4的连续整数,String(fruit)生成的键为"0"到"4",具有唯一性和稳定性。
.layoutWeight(1)使每个Column等分Row的宽度,5种水果各占20%。.alignItems(HorizontalAlign.Center)使emoji和数字在每列中水平居中对齐。
10.3 >=3橙色/>=5红色高亮规则
计数数字的颜色根据当前计数值动态变化:
.fontColor(
(this.fruitCounts[String(fruit)] ?? 0) >= 5 ? '#F44336' :
((this.fruitCounts[String(fruit)] ?? 0) >= 3 ? '#FF9800' : '#333333')
)
三级颜色规则:
| 计数范围 | 颜色 | 色值 | 含义 |
|---|---|---|---|
| 0-2 | 深灰色 | #333333 | 正常/安全 |
| 3-4 | 橙色 | #FF9800 | 警告/接近 |
| 5+ | 红色 | #F44336 | 危险/可抢拍 |
橙色警告(>=3) — 当某种水果计数达到3或4时,距离凑齐5只差1-2个,玩家应该开始高度关注该水果的后续翻牌。橙色在视觉语义中代表"警示"和"接近",与交通灯的黄灯/橙灯含义一致。
红色危险(>=5) — 当某种水果恰好凑齐5个时,canSnap被激活,抢拍窗口打开。红色在视觉语义中代表"紧急"和"立即行动",与canSnap按钮的红色(#F44336)形成呼应,双重强化"现在该抢拍"的信号。
字重加粗 — 当计数>=5时,数字不仅变红还加粗(FontWeight.Bold),在视觉上进一步突出。加粗使得数字的笔画更粗、占面积更大,在快速扫视中更容易捕捉到。
10.4 辅助决策的设计意图
水果计数条的核心设计意图是降低玩家的认知负荷。没有计数条时,玩家需要自行心算桌面上每种水果的总数,随着翻牌速度加快和卡牌数量增多,心算错误率显著上升。计数条将心算过程"外化"为可视化的数字显示,让玩家将注意力集中在"判断何时抢拍"而非"计算水果数量"上。
这一设计在辅助功能层面也具有重要意义:对于心算能力较弱的玩家(如儿童),计数条提供了公平的信息获取渠道,使得游戏的竞争力更多取决于反应速度而非数学能力,更符合Halli Galli"反应力竞技"的初衷。
11. 玩家信息栏
玩家信息栏位于游戏界面顶部,显示所有参与者的头像、昵称和手牌数量。
11.1 UI实现
Row() {
ForEach(this.players, (player: QuickReactPlayer) => {
Column() {
Text(player.avatar)
.fontSize(24)
Text(player.nickname)
.fontSize(10)
Text(`${player.id === this.myId ? this.myHandCount : '?'}张`)
.fontSize(10)
.fontColor('#999999')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (player: QuickReactPlayer) => player.id)
}
.width('100%')
.padding(8)
.backgroundColor(Color.White)
11.2 信息不对称设计
手牌数量的显示体现了"信息不对称"的设计策略:
`${player.id === this.myId ? this.myHandCount : '?'}张`
只有当前玩家(myId匹配)的手牌数量显示为具体数字,其他玩家显示为"?"。这模拟了实体Halli Galli中每位玩家只能看到自己手牌堆厚度的规则——你无法确切知道对手还有多少张牌,只能从牌堆高度做粗略估计。
信息不对称创造了心理博弈的空间:当你的手牌所剩无几时,你会更加谨慎地拍铃(因为误拍的代价更大——失去最后几张牌意味着淘汰);而你的对手看不到你的困境,可能误判你的策略倾向。
11.3 QuickReactPlayer数据模型
QuickReactPlayer类定义了玩家的完整数据结构:
export class QuickReactPlayer {
id: string = ''
nickname: string = ''
avatar: string = ''
handCards: FruitCard[] = []
capturedCards: number = 0
isEliminated: boolean = false
reactTime: number = 0
}
虽然当前UI只使用了avatar、nickname和手牌数量,但完整的模型为未来的功能扩展预留了空间:capturedCards记录赢取的牌数(可用于最终排名)、isEliminated标记淘汰状态、reactTime记录历史反应时间(可用于显示"最快反应"成就)。
11.4 ForEach键生成
(player: QuickReactPlayer) => player.id使用玩家ID作为ForEach的键。ID的唯一性和稳定性确保了玩家列表在状态更新时不会发生组件混乱——即使players数组被重新赋值,ForEach仍能通过ID匹配正确复用现有组件。
附录:核心代码索引
| 文件 | 核心代码 | 行号 |
|---|---|---|
| QuickReactGame.ets | Canvas上下文声明 | 11 |
| QuickReactGame.ets | drawTable()完整实现 | 146-175 |
| QuickReactGame.ets | flipCard()翻牌逻辑 | 71-86 |
| QuickReactGame.ets | updateFruitCounts() | 88-97 |
| QuickReactGame.ets | checkFiveFruits() | 99-109 |
| QuickReactGame.ets | playerSnap()抢拍逻辑 | 111-144 |
| QuickReactGame.ets | Canvas组件+onReady | 251-255 |
| QuickReactGame.ets | 水果计数条UI | 232-249 |
| QuickReactGame.ets | 玩家信息栏UI | 213-231 |
| QuickReactModel.ets | FruitType枚举 | 28-34 |
| QuickReactModel.ets | FruitCard类 | 17-26 |
| QuickReactModel.ets | getFruitEmoji() | 36-41 |
| QuickReactModel.ets | QuickReactPlayer类 | 1-15 |
更多推荐


所有评论(0)