看谁反应快 — Canvas水果卡渲染技术文档

目录

  1. 游戏概述
  2. Canvas体系
  3. 5种FruitType枚举
  4. FruitCard模型
  5. drawTable()完整实现逐行解读
  6. 桌面卡布局算法
  7. 翻牌动画
  8. fruitCounts实时计数
  9. Canvas尺寸适配
  10. 水果计数条UI
  11. 玩家信息栏

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绘图中,抗锯齿主要影响以下场景:

  1. 文本渲染 — emoji和文字的笔画边缘会获得亚像素级的平滑过渡,在28vp和20vp的字号下,这种平滑效果对中文数字和emoji的可读性至关重要
  2. 矩形描边strokeRect绘制的1px边框在不开启抗锯齿时可能出现像素对齐问题,导致某条边比其他边看起来更粗或更细
  3. 对角线和非水平/垂直线条 — 虽然本游戏未使用对角线,但如果未来添加卡牌倾斜动画,抗锯齿将确保线条平滑

传入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的触发重绘时机却与声明式状态紧密关联:

  1. 首次绘制 — 由onReady回调触发
  2. 翻牌重绘 — 由flipCard()方法在setInterval回调中调用drawTable()触发
  3. 抢拍后重绘 — 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)。使用工厂方法的优势:

  1. 命名更具表达力 — FruitCard.of(fruit, count)new FruitCard(fruit, count)更清晰地表达了创建意图
  2. 便于未来扩展 — 可以在不改变调用点的情况下,在of()内部添加缓存、校验或转换逻辑
  3. 与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。这一做法有两个好处:一是缩短了后续代码的长度(ctxthis.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开始,这是一种"全量重绘"策略——不尝试增量更新(如只擦除发生变化的卡牌区域),而是每次都从空白画布开始重新绘制所有内容。选择全量重绘而非增量更新的理由:

  1. 简化逻辑 — 卡牌数量和位置的变化模式复杂(新增、移除、重新排列),增量更新的脏矩形计算会增加代码复杂度
  2. 避免残留 — 当卡牌数量减少时(如抢拍成功清空桌面),增量更新需要精确擦除旧卡牌的像素区域,遗漏会导致画面残留
  3. 性能充足 — 最多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/5
  • cardHeight = 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 % colsMath.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)的设计考量:

  1. 纯白色在深绿色背景上对比过强,可能产生"刺眼"效果,暖白色柔化了视觉冲击
  2. 暖白色模拟了真实桌游卡牌的微微泛黄的纸质质感,增加沉浸感
  3. 在低亮度环境下,暖白色比纯白色更舒适

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绘图的基本原则:后绘制的图形覆盖先绘制的图形

  1. 先绘制绿色背景(最底层)
  2. 在背景上绘制暖白色卡牌矩形(覆盖部分背景)
  3. 在卡牌上绘制emoji和数量文字(覆盖部分卡牌)
  4. 最后绘制边框(覆盖在所有内容之上,确保边框完整可见)

如果调整绘制顺序——例如先画边框再画卡牌背景——则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)。

定时器的生命周期管理遵循严格的三段式模式:

  1. 启动 — 在startFlipping()中通过setInterval(() => { this.flipCard() }, 2000)启动,返回的ID存储到flipTimer
  2. 停止 — 在playerSnap()(抢拍成功时)和组件aboutToDisappear()中通过clearInterval(this.flipTimer)停止
  3. 检查 — 在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的理由:

  1. ArkTS兼容性 — Record是ArkTS推荐使用的键值映射类型,Map在某些ArkTS版本中受限于Sendable约束
  2. @State支持 — @State装饰器支持Record类型的变化检测,当整个Record被重新赋值时触发UI刷新
  3. 枚举键适配 — 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的变化严格同步:

  1. flipCard() — 新卡加入tableCards后立即调用updateFruitCounts()
  2. playerSnap() — 抢拍成功清空tableCards后调用updateFruitCounts()
  3. 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而非根据屏幕高度计算。这一选择基于以下考量:

  1. Canvas高度决定的是"桌面"的垂直空间,280vp足以容纳2行卡牌(2×90+间距+边距≈202vp)并留有底部空间
  2. 游戏界面下方还需要放置抢拍按钮、语音抢拍按钮、手牌计数等UI元素,Canvas不能占据过多垂直空间
  3. 不同设备的屏幕高度差异(如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
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐