基于鸿蒙OS开发附近社交游戏平台(十八)-你画我猜:Canvas绘图与猜词回合
你画我猜:Canvas绘图与猜词回合
一、游戏概述与社交价值
你画我猜(DrawGuess)是NearPlay中最具创意互动性的游戏,也是六款社交小游戏中唯一一款以"非语言表达"为核心机制的作品。一名玩家作为画者,在Canvas画板上绘制题目词组,其他玩家作为猜者,通过文字或语音输入猜测画的内容。画者不能写字或给出文字提示,猜者在限定时间内尽可能快地猜出答案。这种非语言沟通方式打破了社交隔阂,特别适合线下聚会场景中Nearby发现的陌生人快速破冰。
1.1 非语言沟通的心理学基础
你画我猜的核心机制——“只能画不能说”——并非简单的规则限制,而是基于深层心理学原理的社交设计。在人际沟通研究中,Albert Mehrabian的经典"7-38-55法则"指出:情感传递中约55%来自视觉线索(面部表情、手势、图像),38%来自声调,仅7%来自语言内容本身。你画我猜强制剥离了语言通道,迫使玩家完全依赖视觉表达,这恰恰激活了人类最原始、最直觉的沟通模式——图像表达。
从进化心理学角度看,人类在语言出现之前的漫长岁月里,就已经通过图形和手势进行交流。岩壁上的壁画是最早的"你画我猜"——绘制者通过图像传递信息,观者通过解读理解含义。你画我猜的游戏机制重新唤醒了这种深层能力,让玩家回归到前语言的沟通状态,这种"去文明化"的体验本身就能降低社交防备。
在NearPlay的场景中,玩家通常是物理距离很近但社交距离很远的陌生人——同一咖啡馆、同一等候区的Nearby发现者。传统社交游戏(如狼人杀、谁是卧底)依赖语言表达能力,对社恐或语言能力弱的玩家不够友好。你画我猜的伟大之处在于:绘画能力与社交能力几乎无关。一个不善言辞的人可能画出一幅清晰生动的猫,而一个口若悬河的人可能画出完全无法辨认的抽象涂鸦。这种"能力重分配"让社交场域中的权力结构被重新洗牌——沉默寡言的人突然成为焦点,而善于表达的人反而需要"忍住不说",角色反转带来的喜剧效果是天然的破冰催化剂。
1.2 "误解"的社交价值
你画我猜最有趣的时刻几乎都来自于"误解"——画者画了一只猫,猜者看成了一只狗;画者试图表达"雨伞",猜者看到的是"蘑菇";画者精心绘制了一架飞机,猜者确信那是一条鱼。这些误解并非游戏的失败,恰恰相反,它们是游戏最有价值的社交产出。
认知心理学中的"框架效应"(Framing Effect)解释了为什么误解如此普遍:每个人对同一图像的解读都受自身经验、文化背景和认知框架的影响。当你看到两条弯曲的线条时,如果你的认知框架倾向于动物,你会想到猫的胡须;如果倾向于植物,你会想到花的茎。你画我猜将这些隐性的认知差异显性化了——猜测的过程本身就是一次对彼此思维方式的探索。
在NearPlay的面对面场景中,误解的社交效果被进一步放大。当猜者大声喊出离谱的答案时,周围的玩家会忍不住笑出声;当画者拼命修改画作试图纠正误解方向时,旁观者能同时看到画者的焦急和猜者的困惑——这种"信息不对称"产生的喜剧张力,比任何精心设计的破冰活动都更自然、更有效。笑声本身就是最好的社交润滑剂,而你画我猜几乎保证了笑声的产生。
1.3 游戏特色
NearPlay的你画我猜与市面同类产品相比具有以下特色:
语音猜词:集成VoiceInputHelper语音识别,猜者可以说话代替打字,提升社交互动体验。语音输入自动填入猜测框并提交,无需手动点击。在面对面聚会场景中,语音猜词更符合自然交互习惯——大家本来就在一起说话,直接说出猜测比低头打字更顺畅、更有氛围感。
回合轮转:每轮一名画者,4轮后游戏结束。画者按玩家顺序轮换,确保每个人都能体验画和猜两种角色。轮转机制消除了"总是某人画"的不公平感,也避免了画技好的人独占画板的社交尴尬。
实时画板:Canvas触摸绘图支持多颜色、多线宽,触摸轨迹实时渲染。画者的每一笔都即时显示在画板上。虽然当前MVP版本画板仅在本机渲染,但DrawPoint数据模型已经为未来的WebSocket同步预留了完整的信息维度(坐标、颜色、线宽、笔画分段)。
社交破冰:绘画过程中的误解和搞笑猜测是天然的社交话题,配合GameChatPanel聊天面板,玩家可以边猜边聊。GameChatPanel与猜词系统是两个独立通道——聊天中的"猜测"不会被正式判定,这给了猜者一个"试探性聊天"的空间,降低了猜错的社交压力。
1.4 核心数据流
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ ROUND_START │───▶│ DRAWING │───▶│ROUND_RESULT│───▶│ GAME_OVER │ │ 3s展示题目 │ │ 20s画+猜 │ │ 3s结果展示 │ │ 结算+重玩 │ └────────────┘ └─────┬──────┘ └────────────┘ └────────────┘ │ 猜对/时间到 │ ┌─────▼──────┐ │ endRound() │ └────────────┘
数据流简洁清晰:ROUND_START负责初始化本轮状态并展示题目(仅画者可见),DRAWING是核心交互阶段(画者绘图+猜者猜词并行),ROUND_RESULT展示本轮结果并计算下一轮画者,GAME_OVER在4轮结束后显示结算界面。每个阶段的转换都有明确的触发条件——ROUND_START→DRAWING由3秒setTimeout触发,DRAWING→ROUND_RESULT由"猜对"或"时间到"触发,ROUND_RESULT→下一轮ROUND_START或GAME_OVER由3秒setTimeout和roundNumber判断决定。
二、DrawGuessModel完整解读
DrawGuessModel.ets定义了你画我猜游戏的全部数据模型,包括玩家实体、绘图点、猜测记录、游戏阶段枚举和模拟数据。这个文件虽然只有62行代码,但承载了游戏的核心领域逻辑。
2.1 DrawGuessPlayer——玩家实体
` ypescript
export class DrawGuessPlayer {
id: string = ‘’ // 玩家唯一标识
nickname: string = ‘’ // 显示昵称
avatar: string = ‘’ // 头像emoji
score: number = 0 // 累计分数
isDrawing: boolean = false // 是否正在画
static of(id: string, nick: string, avatar: string): DrawGuessPlayer {
const p = new DrawGuessPlayer()
p.id = id; p.nickname = nick; p.avatar = avatar
return p
}
}
`
DrawGuessPlayer包含5个字段,每个字段都有明确职责:
id字段:玩家唯一标识,格式为u1、u2等。id是玩家身份的核心标识符,用于三个关键场景:判断当前画者(currentDrawerId === myId),查找玩家信息(players.find(p => p.id === this.myId)),以及GuessRecord中记录猜者身份。id的格式虽简单但在MVP阶段足够可靠——未来迁移到真实用户系统时,id将替换为设备唯一标识或用户账号。
nickname字段:显示昵称,如"小明"、“阿花”。昵称用于两个场景:猜测列表中显示"小明: 猫",GameChatPanel聊天消息中显示发送者。昵称与id解耦——id用于逻辑判断,昵称用于显示,这种分离让昵称可以随时修改而不影响游戏逻辑。
avatar字段:头像emoji,如👦、👧。当前版本头像仅在玩家列表中使用emoji字符,未来可扩展为图片URL。使用emoji而非图片的原因是MVP阶段需要零依赖的快速实现,且emoji在所有平台上一致渲染,不存在资源加载问题。
score字段:累计分数,初始为0。当前MVP版本未实现完整的计分系统——猜对后仅结束当前轮次,未计算具体分数。score字段预留了未来计分的空间:可能的计分规则包括"猜对者+10分,画者+5分"、“越早猜对分数越高"或"根据剩余时间计算奖励”。
isDrawing字段:是否正在画,布尔标记。当前版本此字段仅作为数据标记存在,实际判断画者身份的逻辑在DrawGuessGame中使用isMyTurn(基于currentDrawerId计算),而非读取isDrawing字段。isDrawing的存在是为了在玩家列表UI中标记当前画者——未来可在玩家头像旁显示画笔图标。这种"冗余"字段的设计是MVP阶段常见的策略:先预留在数据模型中,后续UI需要时直接读取,无需改模型。
of()工厂方法:DrawGuessPlayer使用静态of()方法创建实例,而非在构造函数中传参。这是ArkTS中推荐的工厂模式——ArkTS的class构造函数受到一定限制,工厂方法更灵活且可以避免在构造函数中写复杂逻辑。of()方法接收3个参数(id、nick、avatar),score默认0、isDrawing默认false,调用者无需关心默认值。
2.2 DrawGuessPhase——游戏阶段枚举
ypescript export enum DrawGuessPhase { ROUND_START = 0, // 回合开始,展示题目/角色 DRAWING = 1, // 绘图+猜词阶段 ROUND_RESULT = 2, // 回合结果展示 GAME_OVER = 3 // 游戏结束 }
DrawGuessPhase只有4个枚举值,与狼人杀的8+阶段形成鲜明对比。这种简洁性源于你画我猜的游戏机制本身——不需要复杂的昼夜循环、投票流程或角色技能触发,只需要"准备→画猜→结果→下一轮或结束"的线性流程。
四个枚举值使用数字赋值(0、1、2、3),这在ArkTS中是枚举的标准写法。数字枚举相比字符串枚举在运行时性能更好(整数比较 vs 字符串比较),且占用的内存更小。phase字段在@State声明后,ArkUI框架会在每次phase变更时触发UI重渲染——build()方法中的if-else分支根据phase值决定渲染哪个@Builder。
枚举值的设计遵循"最小状态集"原则——每个阶段都是不可再分的原子状态,不存在"既是DRAWING又是ROUND_RESULT"的模糊地带。这避免了状态机中的"非法状态"问题,让阶段转换逻辑清晰可靠。
2.3 DrawPoint——绘图数据模型概览
` ypescript
export class DrawPoint {
x: number = 0
y: number = 0
isNewStroke: boolean = false
color: string = ‘#000000’
width: number = 3
static of(x: number, y: number, newStroke: boolean, color: string, width: number): DrawPoint {
const p = new DrawPoint()
p.x = x; p.y = y; p.isNewStroke = newStroke; p.color = color; p.width = width
return p
}
}
`
DrawPoint的5个字段构成了绘图的最小信息单元——每个触摸点都携带了渲染所需的全部信息,不依赖外部状态。这种"自包含"设计是关键架构决策,它使得DrawPoint数组可以独立于游戏状态进行序列化和传输,为未来的WebSocket画板同步奠定了基础。各字段详解见第三章"Canvas触摸绘图体系详解"。
2.4 GuessRecord——猜测记录概览
` ypescript
export class GuessRecord {
userId: string = ‘’
nickname: string = ‘’
text: string = ‘’
isCorrect: boolean = false
static of(uid: string, nick: string, text: string, correct: boolean): GuessRecord {
const g = new GuessRecord()
g.userId = uid; g.nickname = nick; g.text = text; g.isCorrect = correct
return g
}
}
`
GuessRecord包含4个字段,记录每次猜测的完整信息。各字段详解见第五章"猜词系统完整实现"。
2.5 MockDrawGuessData——模拟数据
` ypescript
export class MockDrawGuessData {
static getWords(): string[] {
return [‘苹果’, ‘太阳’, ‘房子’, ‘猫’, ‘飞机’, ‘花’, ‘鱼’, ‘树’, ‘月亮’, ‘汽车’, ‘蛋糕’, ‘雨伞’]
}
static getPlayers(): DrawGuessPlayer[] {
return [
DrawGuessPlayer.of(‘u1’, ‘小明’, ‘👦’),
DrawGuessPlayer.of(‘u2’, ‘阿花’, ‘👧’),
DrawGuessPlayer.of(‘u3’, ‘大壮’, ‘🧑’),
DrawGuessPlayer.of(‘u4’, ‘小美’, ‘👩’),
]
}
}
`
MockDrawGuessData提供两个静态方法:getWords()返回词库数组,getPlayers()返回默认玩家列表。详见第十一章。
三、Canvas触摸绘图体系详解
Canvas触摸绘图是你画我猜的技术核心,也是NearPlay六款游戏中唯一使用Canvas 2D绘图API的游戏。整个绘图体系由三个层次组成:数据层(DrawPoint)、事件层(onTouch + handleTouch)、渲染层(drawCanvas)。三层之间的数据流是单向的——触摸事件生成DrawPoint,DrawPoint驱动drawCanvas渲染,渲染结果不反馈到数据层。
3.1 DrawPoint——5字段完整解读
ypescript export class DrawPoint { x: number = 0 y: number = 0 isNewStroke: boolean = false color: string = '#000000' width: number = 3 }
x和y字段:触摸点的坐标,相对于Canvas组件的左上角原点。TouchEvent的touches[0].x和touches[0].y直接提供了相对于组件的坐标,无需额外转换。坐标单位是逻辑像素(vp),与Canvas的width/height属性一致——Canvas内部使用CSS像素坐标系,display.getDefaultDisplaySync()获取的densityPixels已用于计算逻辑宽度,所以触摸坐标和渲染坐标系天然对齐,不存在缩放偏差问题。
isNewStroke字段:是否是新笔画的起点,这是DrawPoint最重要的语义字段。Canvas绘图需要区分"抬笔"(开始新线条)和"续笔"(延续当前线条)。当手指按下时(TouchType.Down),isNewStroke=true标记新笔画开始;手指移动时(TouchType.Move),isNewStroke=false标记笔画延续。渲染算法遍历DrawPoint数组时,根据isNewStroke分段——遇到true开始新路径(beginPath + moveTo),遇到false延续当前路径(lineTo + stroke)。这种分段渲染的正确性完全依赖于isNewStroke的准确性,任何一个点的标记错误都会导致线段连接错乱。
isNewStroke的设计也隐含了一个约束:DrawPoint数组必须保持时序——后添加的点必须出现在数组末尾。如果乱序,渲染结果将完全错误。当前实现通过[...this.drawPoints, point]追加保证时序性,未来如果引入撤销功能,必须从数组末尾删除而非任意位置,否则可能破坏笔画分段逻辑。
color字段:笔画颜色,格式为CSS颜色字符串(如#000000、#FF0000)。color在handleTouch()中从currentColor复制到DrawPoint,而非渲染时从currentColor读取。这种"快照"设计确保了颜色变更只影响后续笔画——如果在画红色线条的过程中切换为蓝色,之前的红色线条不会变为蓝色。如果渲染时直接读取currentColor,则所有已绘制的笔画都会变为当前颜色,这显然不是期望的行为。
color字段也解释了为什么drawCanvas()在遍历DrawPoint时需要在每个isNewStroke=true的点上重新设置strokeStyle——因为不同笔画可能有不同颜色,渲染算法必须"记住"每个笔画的颜色。color存储在DrawPoint中使得这种"记忆"成为可能。
width字段:笔画线宽,数值类型,默认3。width与color同理,在创建DrawPoint时从currentWidth快照。当前版本currentWidth固定为3,未在UI中暴露线宽调节,但width字段已预留了多线宽支持——未来添加线宽选择UI后,无需修改DrawPoint模型和渲染算法,只需在UI层增加currentWidth的修改入口。
width的设计还考虑了视觉效果:较粗的线条适合勾勒轮廓(width=5-8),较细的线条适合画细节(width=1-2)。当前默认3px是一个折中值——既不太粗(避免遮挡细节),也不太细(保证手指触摸绘制时线条清晰可见)。
of()工厂方法:DrawPoint.of()接收5个参数,全部赋值给对应字段。工厂方法而非构造函数的原因与DrawGuessPlayer相同——ArkTS推荐模式,且工厂方法可以有语义化的参数名(newStroke比构造函数的位置参数更清晰)。在handleTouch()中的调用DrawPoint.of(x, y, isNew, this.currentColor, this.currentWidth)清晰地表达了"创建一个携带当前颜色和线宽的绘图点"的意图。
3.2 onTouch事件处理——三种TouchType
Canvas组件的onTouch回调是触摸绘图的入口,捕获三种触摸事件类型:
ypescript .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down) { const touch = event.touches[0] this.handleTouch(touch.x, touch.y, true) // 新笔画 } else if (event.type === TouchType.Move) { const touch = event.touches[0] this.handleTouch(touch.x, touch.y, false) // 续笔画 } })
TouchType.Down——手指按下:触摸开始事件,手指首次接触屏幕时触发。Down事件标记新笔画的起点——handleTouch的第三个参数传入true,创建isNewStroke=true的DrawPoint。在渲染时,这个点会触发beginPath + moveTo,设置新的路径起点。
Down事件的特点是"每按一次只触发一次"——手指按下后持续接触屏幕不会重复触发Down,直到手指抬起再次按下。这保证了每个新笔画只有一个起点。但如果画者使用多指触摸(如一指画线条、另一指按住屏幕),touches数组会包含多个触摸点。当前实现只取touches[0](第一个触摸点),忽略其他触摸点。这是合理的简化——你画我猜是单指绘图场景,多指触摸通常是非故意的误操作。
TouchType.Move——手指移动:触摸移动事件,手指在屏幕上滑动时持续触发。Move事件的触发频率取决于设备的触摸采样率,通常为60-120Hz,即每秒60-120次。高频触发意味着Move事件生成的DrawPoint数量可能很多——一条5厘米的线条可能产生30-60个DrawPoint。这是drawCanvas()全量重绘的性能考量之一,但在20秒短回合场景下,总DrawPoint数量通常不超过500个,全量重绘性能完全可接受。
Move事件标记笔画延续——handleTouch的第三个参数传入false,创建isNewStroke=false的DrawPoint。渲染时,这些点通过lineTo连接成连续线段。
TouchType.Up——手指抬起:触摸结束事件,手指离开屏幕时触发。当前实现未处理Up事件——手指抬起时不需要额外操作,因为下一笔的Down事件会通过isNewStroke=true自然开始新线段。Up事件的"不处理"是一个正确的设计决策:如果试图在Up事件中做路径结束处理(如closePath),反而可能导致笔画提前闭合,画不出开放线条(如一条不闭合的弧线)。
不处理Up事件也有一个隐含的副作用:如果画者在Move过程中手指移出Canvas区域(超出了Canvas组件的边界),Move事件会停止触发但Up事件可能不会触发(因为触摸点已不在Canvas区域内)。这不会造成实际问题——下一笔的Down事件仍然会正确开始新笔画——但会导致"悬空"的线段:最后几个Move点到Canvas边界的线段可能看起来突然截断。未来可考虑在Canvas外层添加touch事件监听来处理这种边界情况。
3.3 handleTouch()——权限检查与点生成
ypescript handleTouch(x: number, y: number, isNew: boolean): void { if (!this.isMyTurn) { return // 非画者不能绘图 } const point = DrawPoint.of(x, y, isNew, this.currentColor, this.currentWidth) this.drawPoints = [...this.drawPoints, point] // 追加到点集 this.drawCanvas() // 重新渲染画板 }
handleTouch()是触摸事件到绘图数据的转换函数,包含四个关键步骤:
步骤一:权限检查——if (!this.isMyTurn) return是游戏规则的代码体现。你画我猜的核心规则是"只有画者能画",isMyTurn在每个回合开始时根据currentDrawerId === myId计算。猜者的触摸事件虽然也会触发onTouch回调(Canvas组件对所有人可见),但在handleTouch入口被拦截,不会生成DrawPoint,也不会触发drawCanvas()。这种"前端拦截"而非"组件隐藏"的设计选择值得讨论——为什么不让猜者看不到Canvas组件?
答案在于用户体验:猜者需要看到画者的绘图过程,Canvas组件必须对所有人可见。如果猜者看不到Canvas,就无法根据画面猜测答案,游戏无法进行。因此Canvas必须渲染在所有玩家的屏幕上,只是猜者的触摸操作被逻辑层拦截。这种"看得到但摸不到"的设计在社交游戏中很常见——信息展示与操作权限的分离。
权限检查放在handleTouch()而非onTouch()中的原因是关注点分离:onTouch()负责事件分发(Down→新笔画、Move→续笔画),不关心权限;handleTouch()负责业务逻辑(权限+数据+渲染),不关心事件类型。这种分离使得权限规则变化(如"多人同时画"模式)只需修改handleTouch()而非onTouch()。
步骤二:点生成——DrawPoint.of(x, y, isNew, this.currentColor, this.currentWidth)创建携带完整信息的绘图点。注意currentColor和currentWidth在此时"快照"到DrawPoint中,此后DrawPoint的颜色和线宽不再受currentColor/currentWidth变化的影响。这种快照语义确保了"画红线条时切换蓝色,之前的红色线条不会变色"。
步骤三:不可变数组更新——this.drawPoints = [...this.drawPoints, point]创建新数组而非push到原数组。这是ArkTS @State刷新的必要条件——ArkUI框架通过引用比较(referential equality)检测@State数组变更,修改原数组(push/splice/shift)不改变数组引用,框架检测不到变更,不会触发UI刷新。展开运算符创建新数组,引用改变,框架检测到变更后触发drawCanvas()的重渲染链路。
不可变更新的代价是每次触摸都创建新数组,O(n)的内存复制。在DrawPoint数量较大时(如500+个点),这可能产生性能问题。但20秒回合+手指绘制速度的限制下,实际DrawPoint数量通常在100-400之间,O(n)复制的性能开销微乎其微(亚毫秒级),远低于Canvas渲染本身的开销。
步骤四:即时渲染——this.drawCanvas()在每次添加点后立即重绘整个画板。虽然全量重绘性能不如增量绘制,但在短回合+有限点数场景下完全够用。即时渲染的另一个好处是"所见即所得"——画者的每一笔都立即在屏幕上可见,没有任何延迟感。
3.4 drawCanvas()——三阶段渲染算法
drawCanvas()是Canvas绘图的核心渲染函数,分三个阶段执行:清空画板、绘制参考网格、绘制触摸轨迹。每次调用都完整执行三个阶段,不跳过任何阶段。
阶段一:清空画板并绘制白色背景
ypescript ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) ctx.fillStyle = '#FFFFFF' ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
clearRect()清除画布上所有像素(设为透明),fillRect()填充白色背景。两步操作而非一步的原因是:clearRect()清除为透明而非白色——如果Canvas有背景图或非白色底色,仅clearRect()会露出底色。fillRect()显式填充白色确保背景一致。虽然Canvas组件设置了.backgroundColor(Color.White),但Canvas绘制层与组件背景层是分离的——Canvas绘制在透明层上,组件背景在透明层下方。clearRect()清除绘制层后,如果不在绘制层重新填充白色,网格线和笔画的抗锯齿边缘可能与透明区域产生视觉伪影。显式填充白色避免了这个问题。
每次重绘都从清空开始——这是"清除"功能的基础。clearCanvas()只需清空drawPoints数组然后调用drawCanvas(),因为drawCanvas()总是从头开始渲染。无需额外的"清除画布"逻辑,也不需要维护"哪些点已被清除"的状态。这种"全量重绘"模式简化了状态管理,代价是每次渲染都需要遍历所有DrawPoint。
阶段二:绘制参考网格
ypescript ctx.strokeStyle = '#E0E0E0' ctx.lineWidth = 1 for (let i = 0; i < this.canvasWidth; i += 20) { ctx.beginPath() ctx.moveTo(i, 0) ctx.lineTo(i, this.canvasHeight) ctx.stroke() } for (let i = 0; i < this.canvasHeight; i += 20) { ctx.beginPath() ctx.moveTo(0, i) ctx.lineTo(this.canvasWidth, i) ctx.stroke() }
20px间距的浅灰色网格线,辅助画者定位和构图。网格不影响触摸坐标——触摸事件返回的是Canvas坐标系中的精确位置,不受网格对齐影响。网格纯粹是视觉辅助,类似于方格纸的作用。
网格颜色选择#E0E0E0(浅灰色)而非更深的颜色,是为了不干扰笔画辨识——网格应该是"若隐若现"的,提供空间参考但不会与笔画混淆。lineWidth=1保证网格线足够细,不占用过多视觉空间。
网格绘制的性能可以优化——每条线都单独beginPath + stroke,可以合并为两个大的Path(所有竖线一个Path、所有横线一个Path),减少stroke()调用次数。但在当前场景下(网格线数量约20+14=34条),优化收益微乎其微,代码可读性更重要。
阶段三:绘制触摸轨迹
ypescript for (const point of this.drawPoints) { if (point.isNewStroke) { ctx.beginPath() ctx.moveTo(point.x, point.y) ctx.strokeStyle = point.color ctx.lineWidth = point.width ctx.lineCap = 'round' ctx.lineJoin = 'round' } else { ctx.lineTo(point.x, point.y) ctx.stroke() ctx.beginPath() ctx.moveTo(point.x, point.y) } }
这是渲染算法的核心,遍历所有DrawPoint,按isNewStroke分段渲染:
新笔画起点处理(isNewStroke=true):调用beginPath()开始新路径,moveTo()设置起点坐标,设置strokeStyle(颜色)、lineWidth(线宽)、lineCap和lineJoin。注意新笔画起点只设置路径参数,不调用stroke()——因为此时只有一个点,没有线段需要绘制。真正的线段在下一个续笔点(isNewStroke=false)的lineTo + stroke中绘制。
续笔画处理(isNewStroke=false):lineTo()从当前位置画线到新点,stroke()渲染线段。然后立即beginPath() + moveTo()将路径起点重置到当前点。这种"画一段+重置起点"的模式确保每段线都是独立绘制的,避免了"长路径重绘"的性能问题——如果所有线段都在同一个Path中,stroke()每次都需要从路径起点重新绘制所有线段,复杂度从O(n)退化为O(n^2)。
lineCap = ‘round’:线条端点形状设为圆形。round端点在线段两端绘制半圆,使线条端点圆润平滑。与’butt’(平截,默认值)和’square’(方形延伸)相比,round端点的视觉效果最自然,最接近真实画笔的效果。特别是短线条(如点缀效果),round端点让每个点看起来是一个圆点而非短矩形。
lineJoin = ‘round’:线条拐角形状设为圆形。当两条线段以钝角相交时,round拐角会绘制弧形过渡,避免出现尖角。虽然当前实现中每段线都是独立Path(lineJoin实际上不生效,因为拐角只在同一Path内连续lineTo时才产生),但设置lineJoin = 'round’是防御性编程——未来如果优化渲染算法(将同一笔画合并为一个Path),lineJoin设置已经就位,无需额外修改。
3.5 Canvas onReady回调
ypescript .onReady(() => { this.drawCanvas() })
Canvas组件就绪后首次绘制——渲染白色背景和网格线。onReady是Canvas组件的生命周期回调,在CanvasRenderingContext2D绑定到Canvas DOM节点后触发。这是必要的,因为aboutToAppear()中调用drawCanvas()时,Canvas可能尚未完成DOM挂载,canvasCtx尚未绑定到实际渲染目标,此时调用绘制API不会有任何效果(也不会报错,只是静默失败)。
onReady的执行时机在aboutToAppear之后、首帧渲染之前。这保证了用户看到的Canvas第一帧就是"白色背景+网格线"而非空白或闪烁。如果不在onReady中初始化Canvas,用户可能短暂看到Canvas组件的.backgroundColor(白色)但无网格线的状态,然后突然出现网格线,造成视觉闪烁。
四、颜色调色板与线宽控制
4.1 五色调色板设计哲学
ypescript ForEach(['#000000', '#FF0000', '#2196F3', '#4CAF50', '#FF9800'], (color: string) => { Column() .width(28) .height(28) .borderRadius(14) .backgroundColor(color) .border({ width: this.currentColor === color ? 3 : 0, color: '#FF6B35' }) .margin({ left: 4, right: 4 }) .onClick(() => { this.currentColor = color }) }, (color: string) => color)
五种颜色看似简单,但选择经过仔细考量——覆盖了最基本也是最实用的绘画色彩需求:
#000000 黑色:默认颜色,最常用的勾勒色。黑色用于画轮廓、描边、写文字(虽然规则禁止写字,但画一个箭头或标注是允许的灰色地带)。选择纯黑而非深灰的原因是对比度——在白色背景上,纯黑的视觉对比最强,线条最清晰。深灰(如#333333)在远处看可能不够清晰,特别是在手机屏幕上。
#FF0000 红色:标记色,用于画重点和情感元素。红色在绘画中最常见的用途包括:心形(表达"爱"相关题目)、火焰、嘴唇、标记箭头等。红色在白色背景上的视觉冲击力仅次于黑色,是"必选色"——几乎所有你画我猜的词库中都有需要红色表达的词汇(如"心形"、“火”、“苹果”)。
#2196F3 蓝色:天空/水色,Material Design标准蓝。蓝色用于画天空、海洋、河流、眼泪等。选择Material Blue而非其他蓝色调的原因是辨识度——#2196F3是标准的"天空蓝",所有用户都能立即理解为"蓝色",不会与青色或紫色混淆。
#4CAF50 绿色:自然色,Material Design标准绿。绿色用于画草地、树木、叶子、青蛙等。绿色与蓝色的组合可以表达大部分自然场景——天空(蓝)+草地(绿)是最常见的你画我猜背景构图。
#FF9800 橙色:暖色,Material Design标准橙。橙色用于画太阳、火焰、胡萝卜、橙子等。橙色是红色和黄色的中间色——虽然词库中可能需要黄色(如"香蕉"、“向日葵”),但橙色可以近似表达,且橙色比黄色在白色背景上的可见度更高(浅黄色在白底上几乎不可见)。
五色的选择遵循"最少颜色覆盖最多词汇"原则。更多颜色(如紫色、粉色、黄色、棕色)会增加调色板复杂度,需要更大的UI空间和更小的色块,降低触摸操作精度。五色是颜色数量与操作便利性的最佳平衡点——一只手的拇指可以轻松在五个色块间切换。
4.2 选中态边框设计
ypescript .border({ width: this.currentColor === color ? 3 : 0, color: '#FF6B35' })
选中颜色显示3px橙色边框(#FF6B35),未选中无边框(width: 0)。这种"有/无"而非"粗/细"的二元设计使得选中态一目了然——3px与0px的视觉差异远大于2px与1px。边框颜色使用NearPlay的主题橙色#FF6B35,与"猜"按钮、画者标识等UI元素保持一致的视觉语言。
边框实现使用border属性而非outline或box-shadow,因为ArkUI的border支持borderRadius圆角裁剪——28x28的Column设置borderRadius=14后是正圆形,3px边框会沿圆形边缘绘制,而非出现方形边框与圆形色块不匹配的问题。
currentColor是@State字段,修改后触发ForEach重渲染。ArkUI的ForEach在检测到@State变更时,会重新计算每个Item的UI属性——currentColor === color的条件重新求值,选中色块的border width变为3,非选中色块变为0。这种"条件式UI属性"是ArkUI声明式编程的核心模式。
4.3 currentColor与currentWidth的@State响应
ypescript @State currentColor: string = '#000000' @State currentWidth: number = 3
currentColor和currentWidth都是@State字段,修改后自动触发UI刷新。currentColor在调色板onClick中更新,currentWidth暂未在UI中暴露(使用默认值3),但DrawPoint.of()已经支持不同width值,数据层和渲染层已完全准备好。
currentColor的变更会触发两个渲染链路:
- 调色板重渲染——ForEach重新计算每个色块的border属性,选中态切换
- 后续DrawPoint颜色变更——新创建的DrawPoint使用新的currentColor值,但已有DrawPoint不受影响(因为颜色已在创建时快照)
currentWidth的@State声明看似多余(当前无人修改它),但这是面向未来的设计——未来添加线宽选择UI(如"细/中/粗"三个按钮或滑块),只需在onClick中修改currentWidth,DrawPoint和drawCanvas()无需任何修改。
4.4 清除按钮
ypescript Button('清除') .fontSize(12) .height(28) .type(ButtonType.Capsule) .backgroundColor('#EEEEEE') .fontColor('#333333') .margin({ left: 12 }) .onClick(() => { this.clearCanvas() })
清除按钮使用中性灰色调(#EEEEEE背景、#333333文字),而非主题橙色——这是有意的视觉层级设计。颜色选择是高频操作(每笔画都可能切换颜色),清除是低频操作(只在画错时使用)。灰色按钮在视觉上退居次要位置,橙色则保留给更重要的"猜"按钮。
clearCanvas()的实现极其简洁:
ypescript clearCanvas(): void { this.drawPoints = [] this.drawCanvas() }
清空drawPoints数组并重绘。drawCanvas()在空数组下只执行阶段一(白色背景)和阶段二(网格),阶段三的for循环因数组为空而跳过。结果是"干净的画板+网格线",与初始状态一致。
清除是"全量清除"而非"橡皮擦"——一次清除所有笔画。这是MVP阶段的简化设计,未来可添加橡皮擦功能(局部擦除)。全量清除的好处是简单可靠,不存在"部分擦除导致线段断裂"的复杂问题。
4.5 工具栏条件显示
颜色调色板和清除按钮只在画者视图中显示:
ypescript if (this.isMyTurn) { Row() { /* 调色板 + 清除按钮 */ } }
猜者看到的是猜词输入栏,看不到画者工具。条件渲染而非"灰色禁用"的选择原因是空间——Canvas下方的空间有限,同时显示调色板和猜词输入栏会使UI过于拥挤。画者和猜者看到的Canvas下方区域完全不同,通过if互斥渲染。
五、猜词系统完整实现
5.1 GuessRecord——4字段深度解读
ypescript export class GuessRecord { userId: string = '' nickname: string = '' text: string = '' isCorrect: boolean = false }
每条猜测记录包含4个字段,分别回答"谁猜的"、“叫什么名字”、“猜了什么”、"猜对了吗"四个问题。
userId:猜者ID,关联DrawGuessPlayer.id。userId在当前版本的主要用途是ForEach的key生成——userId_idx组合确保同一个人的多次猜测有不同的key,避免ArkUI ForEach的key冲突。userId的未来用途包括:高亮当前用户的猜测(myId === guess.userId时使用不同背景色),统计个人命中率(isCorrect=true的数量 / 总猜测数量),以及"只看自己的猜测"筛选功能。
nickname:猜者昵称,冗余存储。为什么不通过userId运行时查找?因为GuessRecord需要高频渲染——每次新猜测都追加到guesses数组,触发ForEach重绘,如果每次渲染都执行players.find(p => p.id === guess.userId),4个玩家x N条猜测的查找成本是O(4N),虽然不大但完全不必要。将nickname直接存入GuessRecord,渲染时直接读取,O(1)访问,空间换时间的经典权衡。
text:猜测文本,存储trim()后的值。text字段的两个用途:UI显示(“小明: 猫"中的"猫”)和正确性判断(与currentWord比较)。text存储的是用户输入经过trim()处理后的结果——前后空格被移除,但内部空格保留。这意味着"猫 “和"猫"被视为相同猜测(trim后都是"猫”),但"小 猫"和"小猫"被视为不同猜测(内部空格未被移除)。
isCorrect:是否猜对。isCorrect在submitGuess()中通过严格字符串比较计算,存入GuessRecord后不再变更。UI渲染根据isCorrect决定样式——正确猜测的text显示绿色(#4CAF50)并附加✅标记,错误猜测的text显示灰色(#666666)。isCorrect的"计算一次、存储结果"模式避免了每次渲染时重复执行字符串比较——虽然比较成本极低,但"逻辑与显示分离"是更好的架构实践。
5.2 submitGuess()——6步完整流程

ypescript submitGuess(): void { if (this.guessInput.trim() === '') { return // 步骤1:空输入拦截 } const me = this.players.find((p: DrawGuessPlayer) => p.id === this.myId) const isCorrect = this.guessInput.trim() === this.currentWord // 步骤3:精确匹配 const guess = GuessRecord.of(this.myId, me?.nickname ?? '我', this.guessInput.trim(), isCorrect) // 步骤4 this.guesses = [...this.guesses, guess] // 步骤5 this.guessInput = '' // 步骤6 if (isCorrect) { if (this.timerId !== -1) { clearInterval(this.timerId) } this.endRound(true) } }
步骤1:空输入拦截——guessInput.trim() === ''检查空输入。使用trim()而非直接比较空字符串,是因为用户可能只输入空格(有意或误触)。空输入不提交、不生成GuessRecord、不触发任何UI变更,静默忽略。
步骤2:查找玩家信息——players.find(p => p.id === this.myId)在玩家列表中查找当前用户的记录,获取nickname。使用可选链me?.nickname防止find返回undefined时崩溃(虽然理论上myId一定在players中,但防御性编程总是好的)。如果找不到玩家记录,使用默认昵称’我’——这个默认值在MVP阶段足够,未来应替换为从用户配置中读取的昵称。
步骤3:精确匹配判断——guessInput.trim() === currentWord严格字符串相等。当前版本不支持模糊匹配——“猫"不匹配"小猫”,“苹果"不匹配"平果”。精确匹配的优点是简单可靠、无歧义;缺点是语音猜词和拼写错误会导致误判。这个trade-off在MVP阶段是可接受的——精确匹配保证"猜对"一定是真正猜对,不会出现"错误答案被接受"的争议。
步骤4:创建GuessRecord——GuessRecord.of()创建包含完整信息的猜测记录。注意text参数传入的是this.guessInput.trim()而非this.guessInput——存储trim后的值,确保GuessRecord.text中没有前后空格。isCorrect参数传入步骤3的计算结果,使GuessRecord成为"不可变"的数据对象——创建后不再需要修改。
步骤5:追加猜测列表——[...this.guesses, guess]创建新数组触发@State刷新。与drawPoints的不可变更新同理,ArkUI框架通过引用比较检测数组变更。新数组创建后,猜测列表的ForEach会重新渲染,新的GuessRecord出现在列表底部。
步骤6:清空输入框——this.guessInput = ''重置输入内容,准备下一次猜测。guessInput是@State字段,修改后TextInput的text属性同步更新,输入框恢复为空状态。这个"提交后清空"的模式在几乎所有表单场景中都是标准做法。
猜对后的额外处理:如果isCorrect为true,执行两个操作——clearInterval(timerId)停止倒计时,endRound(true)结束当前轮次。clearInterval必须在endRound之前,否则endRound中的setTimeout可能与仍在运行的interval产生竞态条件——interval的回调可能在新轮次开始后仍然触发,导致roundTimer被错误递减。
5.3 猜测列表渲染
ypescript List({ space: 4 }) { ForEach(this.guesses, (guess: GuessRecord) => { ListItem() { Row() { Text( + guess.nickname + ‘: ‘)
.fontSize(13)
.fontWeight(FontWeight.Medium)
Text(guess.text)
.fontSize(13)
.fontColor(guess.isCorrect ? ‘#4CAF50’ : ‘#666666’)
if (guess.isCorrect) {
Text(’ ✅’).fontSize(13)
}
}
.padding({ left: 16, right: 16 })
}
}, (guess: GuessRecord, idx: number) => guess.userId + ‘_’ + idx)
}
`
ForEach的key生成使用userId_idx组合,而非仅userId或仅idx。仅userId的问题是同一用户多次猜测会产生相同key;仅idx的问题是数组插入/删除时idx可能错位。userId_idx组合保证每条GuessRecord的key唯一且稳定。
猜测列表的视觉设计刻意简洁——每行只显示"昵称: 猜测文本"和可选的✅标记。没有头像、没有时间戳、没有猜测序号。简洁的原因是列表需要快速扫描——猜者在20秒内需要快速浏览其他人的猜测,避免重复猜测同一答案。过多的装饰信息会干扰快速阅读。
正确猜测的绿色文字+✅标记在灰色错误猜测中非常醒目,视觉焦点自然落在猜对的记录上。这也是一种"剧透"——猜对记录的出现意味着本轮即将结束,其他猜者看到绿色✅后可能放弃继续猜。但这恰恰是期望的行为——猜对即结束,无需其他人继续猜测。
5.4 精确匹配vs模糊匹配讨论
当前submitGuess()使用严格字符串匹配(===),这导致了几个已知问题:
同义词不匹配:题目是"猫",猜"小猫"被判错误。在中文语境中,"猫"和"小猫"在语义上几乎等价,但严格匹配下被视为不同答案。这是最常见的不匹配场景,也是用户抱怨最多的点。
语音识别误差:题目是"苹果",语音识别为"平果"或"苹国",被判错误。语音识别的精度受环境噪音、口音、方言影响,误识率在嘈杂环境下可能高达20-30%。
大小写和全半角差异:虽然当前词库全是中文,但如果未来引入英文词汇,"Apple"和"apple"将被视为不同答案。全角数字"1"和半角数字"1"也有类似问题。
模糊匹配的几种实现方案详见第十三章"边界情况与未来扩展"。在MVP阶段,严格匹配的优点是:实现简单、无歧义、不会出现"错误答案被接受"的争议。模糊匹配虽然更"人性化",但引入了"多少差异可以接受"的主观判断,可能导致玩家对判定结果产生分歧。
六、语音猜词特殊设计
6.1 VoiceInputHelper→guessInput→submitGuess()自动提交链路
ypescript Button('\u{1F3A4}') .onClick(() => { this.voiceHelper.startListening((text: string) => { this.guessInput = text this.submitGuess() }) })
语音猜词的核心设计在于回调链路:VoiceInputHelper.startListening()启动语音识别,识别完成后回调函数接收text字符串,将其赋值给guessInput(@State字段,触发TextInput更新显示),然后立即调用submitGuess()将语音结果作为正式猜测提交。
这个链路的关键特征是"自动提交"——语音识别完成后无需用户点击"猜"按钮,直接提交。这与文字输入的"手动提交"模式形成对比:文字输入需要用户先在TextInput中打字,然后点击"猜"按钮才会调用submitGuess()。
6.2 自动提交的设计合理性
为什么要自动提交?原因有三:
第一,语音用户的意图更明确。打字过程中用户可能在"犹豫"——先打了一个字,又删掉重打,反复修改后才点击提交。但语音用户说出的话就是最终意图——在面对面场景中,人们不会"说半句话再收回"。自动提交尊重了语音用户的自然交互习惯。
第二,减少操作步骤提升体验。语音识别完成后如果还要求用户手动点击"猜"按钮,就增加了不必要的操作步骤。用户可能会困惑——"我已经说了答案,为什么还要点按钮?"自动提交消除了这个认知负担。
第三,竞速公平性。20秒倒计时下,速度是关键。文字用户需要"打字+点击"两步,语音用户只需"说话"一步。自动提交使语音猜词的操作步骤与说话本身一致(一步),而非比文字用户多一步(“说话+点击"vs"打字+点击”)。
6.3 与手动提交的差异
自动提交和手动提交的差异不仅是"少点一次按钮",还有几个微妙的区别:
输入确认时机不同:手动提交有"确认窗口"——用户可以在点击"猜"按钮前修改输入内容(删除错字、更改答案)。自动提交没有确认窗口——语音识别结果一旦生成立即提交,无法修改。如果语音识别产生了明显的错误(如"苹果"识别为"平果"),用户无法纠正,只能接受错误判定。
guessInput的中间状态不同:手动提交过程中,guessInput随用户打字逐步变化(每输入一个字符触发onChange),TextInput实时显示当前内容。自动提交过程中,guessInput在语音识别完成前不变(保持之前的值),识别完成后一次性更新为完整文本,然后立即清空(submitGuess中guessInput = ‘’)。用户可能看不到TextInput中闪过的语音识别文本——因为guessInput被赋值后立即被submitGuess清空,时间间隔极短。
错误恢复方式不同:手动提交猜错后,用户可以修改输入再次提交。自动提交猜错后,用户需要再次点击🎤按钮重新录音。如果语音识别持续出错(如环境噪音导致反复误识),用户体验会很差——需要反复"点击🎤→说→等待识别→看到猜错→再点击🎤"的循环。
6.4 语音按钮布局
语音按钮与TextInput和"猜"按钮在同一行:
[ 输入你的猜测 ] [猜] [🎤]
三个控件水平排列在Row中:TextInput占layoutWeight(1)剩余空间,"猜"按钮36px高度橙色胶囊(ButtonType.Capsule),🎤按钮36x36圆形橙色(ButtonType.Circle)。三个控件的大小经过仔细平衡——TextInput足够宽以显示中文输入,"猜"按钮足够大以方便点击,🎤按钮圆形设计使麦克风图标一目了然。
🎤按钮使用Unicode字符\u{1F3A4}(麦克风emoji)而非文字"语音",原因是空间——圆形按钮内放不下两个汉字,而emoji图标是全球通用的"麦克风"视觉符号,无需文字说明。fontSize=16使emoji在36x36按钮内居中显示,大小适中。
6.5 语音识别精度问题与缓解
语音识别可能产生与目标词不完全匹配的结果。例如目标是"苹果",语音可能识别为"平果"或"苹国"。当前版本使用严格字符串匹配,未来可考虑:
拼音匹配:将语音结果和目标词都转为拼音后比较。如"苹果"→"pingguo",“平果"→"pingguo”,匹配成功。拼音匹配解决了大部分同音误识问题,但引入了新的歧义——"苹果"和"平果"拼音相同,但语义不同。在语音场景中,同音误识的概率远高于语义歧义,拼音匹配的净收益为正。
编辑距离匹配:计算Levenshtein编辑距离,distance <= 1视为正确。如"猫"→"描"(1次替换),匹配。编辑距离解决了少量字符错误,但无法处理整词替换(如"苹果"→"香蕉")。
关键词包含匹配:语音结果包含目标词即视为正确。如"我觉得是猫"→包含"猫"→匹配。这种宽松策略减少了漏判,但增加了误判风险——“猫头鹰"包含"猫”,但题目可能不是"猫"。
在MVP阶段,最实际的缓解方案是"拼音匹配"——实现简单(中文→拼音库很小),覆盖了最常见的误识场景(同音字),且误判率低(同音且同场景的概率极低)。
七、DrawGuessPhase四阶段完整流程图
7.1 阶段定义与枚举值
ypescript export enum DrawGuessPhase { ROUND_START = 0, DRAWING = 1, ROUND_RESULT = 2, GAME_OVER = 3 }
与狼人杀的8+阶段不同,你画我猜只有4个阶段,流程简洁清晰。四个阶段形成一个线性状态机,每个阶段只有一个后续阶段(ROUND_RESULT有条件分支:下一轮或GAME_OVER),不存在回退或跳跃。
7.2 完整阶段流程图
游戏启动 (aboutToAppear → startRound) │ ▼ ┌─────────────────────────────────────────────────┐ │ ROUND_START (0) │ │ │ ┌───────────────┐ ┌───────────────┐ │ │ │ 画者视图 │ │ 猜者视图 │ │ │ │ "你来画!" │ │ "你来猜!" │ │ │ │ 题目: 猫(红色) │ │ (无题目显示) │ │ │ └───────────────┘ └───────────────┘ │ │ │ │ 初始化: currentWord随机选词, isMyTurn计算, │ │ guesses=[], drawPoints=[], roundTimer=20 │ │ 持续: 3秒 (setTimeout) │ └──────────────────┬───────────────────────────────┘ │ 3秒后 ▼ ┌────────────────────────────────────────────────┐ │ DRAWING (1) │ │ │ │ 画者: Canvas可绘图 + 调色板 + 清除 │ │ 猜者: Canvas只读 + 猜词输入 + 🎤 + 猜测列表 │ │ │ 计时: setInterval每秒roundTimer-- │ │ roundTimer<=0 → endRound(false) │ │ 猜对 → endRound(true) │ │ 持续: 最长20秒 │ └──────────────────┬───────────────────────────────┘ │ 猜对/时间到 ▼ ┌────────────────────────────────────────────────┐ │ ROUND_RESULT (2) │ │ │ "猜对了!答案是: 猫" 或 "时间到!答案是: 猫" │ │ 处理: roundNumber++, 计算下一轮画者 │ │ 持续: 3秒 (setTimeout) │ └───────┬───────────────────────────┬──────────────┘ │ roundNumber<=4 │ roundNumber>4 ▼ ▼ startRound() ┌──────────────┐ (下一轮) │ GAME_OVER(3) │ │ 🎨 游戏结束 │ │ [再来一局][返回]│ └──────────────┘
7.3 ROUND_START阶段详解
ypescript startRound(): void { this.phase = DrawGuessPhase.ROUND_START const words = MockDrawGuessData.getWords() this.currentWord = words[Math.floor(Math.random() * words.length)] this.isMyTurn = this.currentDrawerId === this.myId this.guesses = [] this.drawPoints = [] this.roundTimer = 20 this.phaseDesc = this.isMyTurn ? 你来画: : '猜猜画的是什么' setTimeout(() => { this.phase = DrawGuessPhase.DRAWING this.timerId = setInterval(() => { this.roundTimer-- if (this.roundTimer <= 0) { clearInterval(this.timerId) this.endRound(false) } }, 1000) }, 3000) }
ROUND_START阶段的职责是"回合初始化+角色公示":
题目随机选择:从MockDrawGuessData.getWords()返回的词库数组中,使用Math.random() * words.length随机索引选词。Math.random()返回[0, 1)的伪随机浮点数,乘以数组长度后取整(Math.floor),得到[0, length-1]的随机索引。伪随机性在游戏场景下足够——不需要加密级别的随机性,但需要"看起来随机"的感觉。
角色判断:isMyTurn = currentDrawerId === myId计算当前玩家是否为画者。isMyTurn是@State字段,变更后触发build()重渲染——ROUND_START阶段的UI根据isMyTurn显示不同内容:画者看到"你来画!“+题目红色大字,猜者看到"你来猜!”+无题目。
状态清空:guesses和drawPoints两个数组重置为空,确保新回合不受上一轮残留数据影响。特别是drawPoints——如果不清空,上一轮的画作会出现在新回合的Canvas上,这是严重的BUG。
3秒展示时间:setTimeout 3000ms后进入DRAWING阶段。3秒是给画者"阅读题目+准备画笔"的缓冲时间,也是给猜者"确认角色+准备猜词"的等待时间。3秒的长度经过实践验证——太短(1秒)画者来不及看清题目,太长(5秒)等待感明显。
倒计时启动:进入DRAWING阶段的同时启动setInterval,每秒递减roundTimer。roundTimer初始值为20,递减到0时触发endRound(false)——时间到,没人猜对。
题目保密:画者看到你来画: 猫,猜者只看到猜猜画的是什么。题目只在画者视图中显示,保证游戏公平性。这是你画我猜最基本的信息不对称——画者拥有猜者不知道的信息(答案),猜者拥有画者不知道的信息(自己的猜测思路)。这种信息不对称是游戏紧张感的来源。
7.4 DRAWING阶段详解
DRAWING阶段是游戏的核心交互期,画者绘图和猜者猜词并行进行。阶段持续时间最长20秒,可能因猜对而提前结束。
画者交互路径:onTouch → handleTouch → DrawPoint.of() → drawPoints更新 → drawCanvas()重绘
猜者交互路径:TextInput输入 + "猜"按钮 → submitGuess() → GuessRecord → guesses更新 → 列表渲染 → (可能)endRound(true)
两条路径并行不阻塞——画者不需要等猜者猜,猜者也不需要等画者画。这种"异步并行"的设计使得游戏节奏紧凑,20秒内画者和猜者都有持续的事情做,没有"等待对方"的空闲期。
画者视图顶部显示画: 猫(14px橙色中粗字体),Canvas画板占据主要区域,底部颜色调色板+清除按钮。猜者视图顶部显示猜猜画的是什么(14px灰色字体),Canvas画板只读(onTouch被isMyTurn拦截),底部猜词输入栏+🎤语音按钮,猜测列表实时显示所有猜测记录。
7.5 ROUND_RESULT阶段详解
ypescript endRound(guessedCorrectly: boolean): void { this.phase = DrawGuessPhase.ROUND_RESULT if (guessedCorrectly) { this.phaseDesc = 猜对了!答案是: } else { this.phaseDesc = 时间到!答案是: } setTimeout(() => { this.roundNumber++ if (this.roundNumber > 4) { this.phase = DrawGuessPhase.GAME_OVER } else { const playerIds = this.players.map((p: DrawGuessPlayer) => p.id) const currentIdx = playerIds.indexOf(this.currentDrawerId) const nextIdx = (currentIdx + 1) % playerIds.length this.currentDrawerId = playerIds[nextIdx] this.startRound() } }, 3000) }
两种结束方式产生不同的phaseDesc:
- 猜对(guessedCorrectly=true):
猜对了!答案是:——正面反馈,猜对者有成就感 - 时间到(guessedCorrectly=false):
时间到!答案是:——揭晓答案,满足猜者的好奇心
3秒展示后进入下一逻辑:roundNumber++递增轮次,超过4轮则GAME_OVER,否则轮换画者并startRound()。3秒的结果展示时间给玩家消化本轮结果——看答案、回顾画作、讨论误解,这些"赛间讨论"是游戏社交体验的重要组成部分。
7.6 GAME_OVER阶段详解
游戏结束界面提供两个选项:
ypescript Button('再来一局') .onClick(() => { this.roundNumber = 1 this.currentDrawerId = 'u1' this.startRound() }) Button('返回大厅') .onClick(() => { this.getUIContext().getRouter().back() })
"再来一局"重置roundNumber为1、currentDrawerId为’u1’(第一个玩家),然后调用startRound()重新开始4轮游戏。注意"再来一局"没有清空scoreBoard——如果未来实现计分系统,多局游戏的总分可以累积。
"返回大厅"通过router.back()返回上一页,同时aboutToDisappear()清理timerId和voiceHelper。两个按钮使用不同的颜色——"再来一局"橙色主题色(鼓励继续游戏),"返回大厅"灰色中性色(退出的视觉权重较低)。
GAME_OVER界面居中显示64px的🎨emoji图标、"游戏结束"24px粗体文字,以及两个并排按钮。居中布局使用justifyContent(FlexAlign.Center) + alignItems(HorizontalAlign.Center),典型的"结束页"设计模式。
更多推荐


所有评论(0)