GameChatPanel 回合制聊天组件
一、@Component 可复用设计论证
1.1 为什么要提取为独立组件
在 NearPlay 项目中,我们面临着一个非常典型的架构选择问题:6款派对游戏------狼人杀、剧本杀、谁是卧底、看谁反应快、你画我猜、真心话大冒险------每一款都需要一个聊天面板来承载玩家之间的交流。如果不提取组件,而是在每个游戏页面中独立实现聊天功能,那么我们将面临以下几个严重的工程问题。
首先是代码重复问题。6个游戏页面每个都实现一次消息列表渲染、一次输入框、一次发送按钮、一次语音录制交互,就意味着同样的 UI 结构至少重复6次。以每个聊天面板约80行代码计算,6次就是480行纯重复代码。而这还不包括未来需求变更时的同步修改成本------当我们要给聊天面板增加新功能时,必须逐一修改6个页面,任何遗漏都会导致行为不一致。
其次是行为一致性问题。独立的实现必然导致细微差异:狼人杀的聊天面板可能用的是12号字体,你画我猜的用的是13号;真心话大冒险的消息列表高度是100px,而剧本杀的是120px。这些看似微小的差异在用户体验层面会造成割裂感------玩家切换不同游戏时会觉得"这个聊天面板怎么跟上一个长得不一样"。统一的组件天然地消除了这种不一致性。
第三是禁言逻辑的统一管控。回合制游戏的核心特征是"不是每个人随时都能说话",禁言条件的判断和禁言状态的 UI 反馈必须高度统一。如果各自实现,禁言时的 placeholder 提示文案可能各不相同("未轮到你""禁言中""等待中"等),这会极大损害产品体验的专业感。统一的组件确保了相同的禁言交互范式。
第四是维护效率的显著提升。当发现一个聊天相关的 bug 时------比如语音录制时切换页面导致定时器泄漏------我们只需要在 GameChatPanel 中修复一次,6个游戏同时受益。如果每个游戏各自实现,排查和修复的工作量将成倍增加,而且极容易遗漏某些页面。
第五是测试成本的大幅降低。单元测试和 UI 测试只需要针对一个组件编写,覆盖一次即可保证所有游戏场景的聊天行为正确。分散实现则意味着每个游戏页面都需要独立的聊天相关测试用例,测试矩阵呈指数增长。
最后是设计师与开发者的协作效率。设计稿中聊天面板是一个统一的 UI 规范,对应一个组件比对应六个零散实现更直观、更易沟通。设计师只需审阅一个组件的实现效果,而非六个。
1.2 六个游戏共用的聊天需求分析
我们来逐一分析6款游戏的聊天需求,证明它们的核心交互模式高度一致:
| 游戏名称 | 需要消息列表 | 需要文字输入 | 需要语音功能 | 需要禁言控制 | 需要展开/收起 |
|---|---|---|---|---|---|
| 狼人杀 | 是 | 是 | 是 | 是 强制 | 是 |
| 剧本杀 | 是 | 是 | 是 | 是 强制 | 是 |
| 谁是卧底 | 是 | 是 | 是 | 是 强制 | 是 |
| 看谁反应快 | 是 | 是 | 是 | 弱 | 是 |
| 你画我猜 | 是 | 是 | 是 | 是 反转 | 是 |
| 真心话大冒险 | 是 | 是 | 是 | 是 强制 | 是 |
可以看到,6款游戏在消息列表展示、文字输入发送、语音录制、禁言控制、展开/收起这五个维度上的需求完全一致。差异仅存在于禁言条件的判断逻辑------而这正是通过 canSpeak 这个动态 Prop 实现了外部注入,从而将差异从组件内部剥离到各游戏页面。
这种"共性内聚、差异外提"的组件化思路,正是 GameChatPanel 得以存在的根本设计依据。组件封装了所有6款游戏共享的聊天交互范式,而将每款游戏独特的"何时能说话"这一策略决策留给了页面层。
从具体代码来看,GameChatPanel 的 build() 方法输出的 UI 结构包含了:标题栏(聊天图标 + 最新消息预览/收起按钮)、消息列表(ForEach渲染ChatMsg数组)、录音中状态行(红色录音指示 + 发送/取消按钮)、输入行(TextInput + 麦克风按钮 + 发送按钮)。这个结构对6款游戏完全适用,没有任何需要条件分支才能适配的部分------canSpeak 仅仅是控制这些 UI 元素的可用状态,而非改变它们的结构。
从实际使用代码来看,各游戏页面调用 GameChatPanel 的方式极其简洁。WerewolfGame 中:GameChatPanel({ canSpeak: this.phase === WerewolfPhase.DAY_DISCUSS && this.currentSpeaker === this.myId })。DrawGuessGame 中:GameChatPanel({ canSpeak: !this.isMyTurn && this.phase === DrawGuessPhase.DRAWING })。TruthOrDareGame 中:GameChatPanel({ canSpeak: this.isMyTurn })。UndercoverGame 中:GameChatPanel({ canSpeak: this.phase === UndercoverPhase.DESCRIBE_TURN && this.isMyDescTurn })。ScriptKillGame 中:GameChatPanel({ canSpeak: this.phase === ScriptPhase.FREE_DISCUSS })。QuickReactGame 中:GameChatPanel({ canSpeak: this.phase !== QuickReactPhase.GAME_OVER })。一行调用,差异仅在传入的 canSpeak 表达式。这就是良好组件化设计的力量------用最小的接口参数化最大的行为差异。
二、组件字段设计
2.1 @State 响应式状态字段
GameChatPanel 定义了6个 @State 修饰的状态变量,每一个都承担着驱动 UI 刷新的关键职责。ArkUI 框架的 @State 装饰器会在变量值发生变化时自动触发依赖该变量的 UI 组件重新渲染,这是实现响应式界面的基础机制。
@State messages: ChatMsg[] = []
消息数组是聊天面板的核心数据源。它存储了当前游戏会话中的所有聊天消息,包括文字消息和语音消息。每当新消息加入时,通过 this.messages = [...this.messages, msg] 的方式创建新数组引用,确保 ArkUI 的变更检测机制能够正确触发 ForEach 重新渲染。这里采用的是展开运算符创建新数组而非直接 push,是因为 ArkUI 的 @State 对数组的变更检测基于引用比较------直接修改数组元素不会触发 UI 更新,必须创建新的数组引用。这种不可变数据更新模式在 ArkTS 开发中是非常重要且必须遵守的范式。消息列表在展开状态下通过 List + ForEach 渲染,每条消息根据 msgType 字段区分为文字(显示 msg.content)或语音(显示时长标记),而在收起状态下仅展示最后一条消息的预览。
@State inputText: string = ''
输入框绑定变量,与 TextInput 组件双向绑定。用户每次键入字符时,onChange 回调更新此值;消息发送成功后,将其清空为空字符串。它的值同时决定了"发送"按钮的可见性------只有当 this.inputText.trim() !== '' 且 this.canSpeak 为 true 时,发送按钮才会出现。这种条件渲染避免了空白按钮的视觉浪费,也防止了空消息的发送。
@State isRecording: boolean = false
语音录制状态标志。当用户点击麦克风按钮触发 startVoiceRecord() 时,此值被置为 true,UI 切换到录音中状态------显示录音中文字、橙色"发送"按钮和灰色"取消"按钮,整个输入行背景变为淡橙色(#FFF3E0),营造一种"正在录音"的视觉氛围。当录音结束(手动发送或取消)时,此值回到 false,恢复到正常的文字输入行。
@State recordDuration: number = 0
录音时长计数器,以秒为单位。在 startVoiceRecord() 中被重置为0,然后通过 setInterval 每秒递增。当达到60秒时自动调用 stopVoiceRecord() 强制结束录音,防止过长的语音消息。这个值实时显示在录音中状态行上,给用户明确的时长反馈。语音消息发送后,此值作为 ChatMsg.voice() 工厂方法的 duration 参数,被记录到消息对象中。
@State isExpanded: boolean = false
面板展开/收起状态。收起时,面板仅显示一行标题栏(图标 + 最新消息预览),占据极小的屏幕空间,不影响游戏主界面的视觉焦点。展开时,面板显示完整消息列表(高度120px)和输入区域,用户可以正常发消息和查看历史。这个设计的核心思想是"游戏优先、聊天辅助"------在大多数游戏阶段,玩家的注意力应该集中在游戏操作上,聊天面板不应喧宾夺主。收起状态下仅展示最新一条消息的预览,足够让玩家感知到有人在说话,而不会干扰游戏操作。
@State canSpeak: boolean = true
发言权限标志,这是 GameChatPanel 最关键的设计决策之一。作为 @State 而非 @Prop,它允许组件内部在特殊场景下修改此值(虽然当前实现中主要由外部传入),同时更重要的是确保当外部游戏状态变化导致 canSpeak 改变时,所有依赖此值的 UI 元素------TextInput 的 enabled、麦克风按钮的 backgroundColor 和 enabled、placeholder 文案------都能即时响应更新。canSpeak 为 false 时,输入框变灰且不可聚焦,麦克风按钮从橙色变为灰色,placeholder 文案从"发言..."变为"未轮到你,禁言中",全方位传达"当前不能发言"的信息。
2.2 @Prop 单向传入字段
@Prop myId: string = 'me'
当前用户的唯一标识符,默认值 'me'。在消息创建时作为 fromUserId 传入 ChatMsg.text() 和 ChatMsg.voice() 工厂方法,标识消息的发送者。各游戏页面通过自身状态提供此值,如 WerewolfGame 中的 this.myId(默认 'u1'),确保聊天消息能正确归属到当前玩家。使用 @Prop 而非 @State 的原因是:用户ID在整个游戏会话中不会改变,它是一个由父组件(游戏页面)确定的、只需单向传入的配置值。
@Prop myNickname: string = '我'
当前用户的昵称,默认值 '我'。用于在发送的消息中显示发送者名称,让其他玩家知道这条消息是谁发的。在 ChatMsg.text() 和 ChatMsg.voice() 中作为 fromNickname 参数。
@Prop myAvatar: string = '😊'
当前用户的头像表情符号,默认值 '😊'。用于消息列表中每条消息前的头像显示,让聊天界面更加生动直观。在 ChatMsg.text() 和 ChatMsg.voice() 中作为 fromAvatar 参数。NearPlay 项目使用 emoji 作为头像系统,这是一种轻量级的头像方案,无需图片资源,渲染性能优秀。
2.3 private 非响应式字段
private recordTimerId: number = -1
录音定时器的ID,初始值 -1 表示未启动。这个字段不使用 @State 或 @Prop 修饰,因为它是纯粹的内部实现细节,不需要驱动 UI 渲染。在 startVoiceRecord() 中通过 setInterval 获取定时器ID并保存到此字段;在 stopVoiceRecord() 中通过 clearInterval(this.recordTimerId) 清除定时器并重置为 -1。特别重要的是 aboutToDisappear() 生命周期方法中的清理逻辑------当组件被销毁时(比如用户退出游戏页面),如果录音定时器仍在运行,必须清除它,否则会造成定时器泄漏,导致内存浪费和潜在的异常回调。这是 ArkTS 组件生命周期管理的标准实践:在 aboutToDisappear 中清理所有定时器和异步资源。
2.4 字段设计的整体考量
整个字段体系的设计遵循了"最小化响应式"原则------只有真正需要驱动 UI 刷新的数据才使用 @State,只由父组件决定且不会在组件内部修改的数据使用 @Prop,纯粹内部使用的实现细节使用 private。这种分层设计既保证了 UI 的正确响应性,又避免了不必要的渲染开销。6个 @State + 3个 @Prop + 1个 private,总计10个字段,精简而完备地支撑了聊天面板的全部功能。
三、canSpeak 动态 Prop --- 6种游戏 canSpeak 逻辑对比大表
3.1 canSpeak 的设计哲学
canSpeak 是 GameChatPanel 与6款游戏之间的核心契约接口。它是一个布尔值,由各游戏页面根据自身当前的游戏状态动态计算,在每次渲染时传入 GameChatPanel。这种设计将"发言权限的判断逻辑"完全从聊天组件中解耦出来------GameChatPanel 不需要知道狼人杀有哪些阶段、你画我猜的画手和猜手有什么区别,它只需要知道一个简单的布尔结果:当前能不能说话。
这种"策略外提"的模式使得 GameChatPanel 成为一个真正纯粹的 UI 组件------它不包含任何业务逻辑,只负责根据输入数据渲染 UI。而复杂的游戏规则逻辑则留在各自的游戏页面中,由页面根据自身的 phase 状态和玩家角色来计算 canSpeak 的值。
3.2 六种游戏 canSpeak 逻辑完整对比
| 维度 | 狼人杀 | 剧本杀 | 谁是卧底 | 看谁反应快 | 你画我猜 | 真心话大冒险 |
|---|---|---|---|---|---|---|
| canSpeak表达式 | phase=DAY_DISCUSS && currentSpeaker=myId | phase===FREE_DISCUSS | phase===DESCRIBE_TURN && isMyDescTurn | phase!==GAME_OVER | !isMyTurn && phase===DRAWING | isMyTurn |
| 发言阶段 | 白天讨论阶段 | 自由讨论阶段 | 描述回合 | 全阶段(除游戏结束) | 猜测阶段(非画手时) | 回合内(被提问者) |
| 禁言阶段 | 夜晚所有回合、白天投票、非自己发言时 | NPC叙述、线索展示、幕间过渡、投票阶段 | 投票阶段、非自己描述时 | 仅游戏结束后 | 画手回合 | 非自己回合 |
| 发言者判定 | currentSpeaker逐人轮转 | 所有存活角色同时 | currentDescIndex逐人轮转 | 所有人同时 | 非isMyTurn者(猜手) | isMyTurn(当前被提问者) |
| 发言模式 | 严格轮转式(一人30s) | 自由式(全员同时) | 严格轮转式(一人10s) | 自由式(全员同时) | 自由式(猜手群聊) | 回合式(仅当前玩家) |
| 禁言严格度 | 极严(5星) | 中等(3星) | 严格(4星) | 最弱(1星) | 严格反转(4星) | 严格(4星) |
3.3 各游戏 canSpeak 逻辑详解
狼人杀:phase === DAY_DISCUSS && currentSpeaker === myId
狼人杀是所有游戏中禁言最严格的。游戏分为夜晚和白天两个大阶段。夜晚阶段(NIGHT_START、WOLF_TURN、SEER_TURN、WITCH_TURN、GUARD_TURN)所有玩家闭眼,绝对禁止聊天------这是狼人杀游戏规则的核心机制,夜晚的任何信息泄露都会破坏游戏公平性。白天阶段又分为讨论(DAY_DISCUSS)和投票(DAY_VOTE),讨论阶段也不是自由讨论,而是严格的一人一轮发言制:currentSpeaker 逐人轮转,每轮30秒,只有当前发言者才能说话。
这种设计的原因在于狼人杀的信息博弈本质。游戏中,每个角色掌握的信息是极度不对称的------狼人知道同伴是谁,预言家知道查验结果,女巫知道用药情况。如果允许自由讨论,信息交换会过快,狼人伪装的难度会大幅降低,游戏平衡性会被打破。严格轮转发言迫使每个玩家在有限时间内组织语言,既要说足够的信息来帮助好人阵营分析,又不能暴露自己的角色身份(如果你是狼人的话),这种信息控制的紧张感正是狼人杀的核心游戏体验。
从实际代码来看,WerewolfGame 维护了 currentSpeaker: string 和 speakerOrderIndex: number 两个状态变量来追踪轮转。startPlayerSpeech() 方法按存活玩家顺序逐人推进,speakerTimerId 控制每人30秒的发言时限。当 currentSpeaker === this.myId 时,canSpeak 为 true,GameChatPanel 的输入区域可用;否则显示"未轮到你,禁言中"。
剧本杀:phase === FREE_DISCUSS
剧本杀的禁言逻辑相对简单,只在自由讨论阶段(FREE_DISCUSS)允许发言。在剧本杀的流程中,玩家要经历选择剧本(SELECT_SCRIPT)、角色分配(ROLE_ASSIGN)、幕间介绍(ACT_INTRO)、NPC叙述(NPC_SPEAK)、线索展示(CLUE_REVEAL)、幕间总结(ACT_SUMMARY)、最终投票(FINAL_VOTE)、真相揭晓(RESULT_REVEAL)等多个阶段,其中只有 FREE_DISCUSS 阶段允许聊天。
这种设计的原因是剧本杀的叙事驱动本质。在NPC叙述阶段,玩家需要专注聆听剧情信息;在线索展示阶段,需要仔细阅读公开和私密线索;在投票阶段,应该基于已有证据做出判断而非被他人的言语影响。只有在自由讨论阶段,才是剧本杀的核心社交环节------玩家们带着各自掌握的信息和秘密进行讨论、辩论、试探和推理。当前的实现中,FREE_DISCUSS 阶段 canSpeak 直接为 true,意味着讨论阶段所有角色都可以同时发言。虽然实际剧本杀中通常也是轮流发言制,但当前的简化实现选择了更宽松的自由讨论模式。
值得注意的是,ScriptKillGame 的实际讨论区 UI 并没有使用 GameChatPanel 的消息列表来展示讨论内容,而是在页面内部维护了一个独立的 chatMessages 数组和本地 ChatMsg 类(注意这与 ChatModel 中的 ChatMsg 是不同的类)。GameChatPanel 在剧本杀页面中更多扮演的是一个"旁路聊天"的角色------玩家在讨论阶段的闲聊,而非正式的推理发言。这是一个设计上的取舍,未来可以考虑统一消息模型。
谁是卧底:phase === DESCRIBE_TURN && isMyDescTurn
谁是卧底采用了与狼人杀类似的严格轮转制,但轮转的依据不是 currentSpeaker 而是描述索引 currentDescIndex。游戏的核心机制是:每轮每个存活玩家依次用一句话描述自己拿到的词语(不能直接说出词语本身),平民和卧底拿到的词语相似但不同(如"火锅"vs"麻辣烫"),通过描述的细微差异来推理谁是卧底。
isMyDescTurn 的值由 current.id === this.myId 计算,即当前描述者是否是我。描述阶段每人10秒(descTimer 初始为10),超时自动轮转到下一位。只有在自己的描述回合才能发言,这防止了玩家在别人描述时插话------这种插话可能包含提示性信息,破坏推理的公平性。
投票阶段(VOTE_PHASE)所有人禁言,避免投票过程中互相影响。这也是现实谁是卧底游戏的标准规则------投票时不能讨论,必须基于之前听到的描述独立做出判断。
看谁反应快:phase !== GAME_OVER
看谁反应快是6款游戏中禁言最宽松的。canSpeak 仅在游戏结束时为 false,其他所有阶段都可以自由发言。这是因为看谁反应快是一款即时反应类游戏(类似德国心脏病的抢拍游戏),游戏的核心交互是观察桌面上的水果卡牌并快速判断是否满足抢拍条件,聊天并不会影响游戏公平性。
实际上,在抢拍游戏中加入聊天功能反而增加了社交乐趣------玩家可以在抢拍成功后互相调侃、在误拍后自嘲、在激烈时刻互相加油。这种轻松的社交氛围正是派对游戏的魅力所在。因此,看谁反应快的 canSpeak 几乎始终为 true,GameChatPanel 更多充当一个"实时弹幕"的角色。
值得注意的是,QuickReactGame 的 canSpeak 逻辑 phase !== QuickReactPhase.GAME_OVER 意味着即使在发牌、翻牌、反应窗口、反应结果、惩罚等所有阶段,玩家都可以自由聊天。这种设计是有意为之------与狼人杀的信息博弈不同,抢拍游戏不存在"信息泄露"的风险,因为所有信息(桌面上的卡牌)对所有玩家都是公开的。
你画我猜:!isMyTurn && phase === DRAWING
你画我猜的 canSpeak 逻辑是所有游戏中最反直觉的------它使用了 !isMyTurn,即"不是我的回合时才能说话"。这是因为你画我猜的角色分为画手(drawer)和猜手(guesser):画手需要专注于绘画,不能通过聊天透露答案;猜手则需要在聊天中输入自己的猜测。
这种"反转"的禁言逻辑是游戏机制的内在要求。如果画手能打字聊天,他们完全可以输入答案的文字,这就破坏了"画"和"猜"的核心玩法。同样,画手也不应该发语音消息------虽然语音比文字更难精确传递答案,但依然存在作弊风险。因此,当 isMyTurn 为 true(我是画手)时,!isMyTurn 为 false,canSpeak 为 false,画手被完全禁言。当 isMyTurn 为 false(我是猜手)时,canSpeak 为 true,猜手可以自由使用 GameChatPanel 来交流猜测。
这里有一个微妙的设计考量:猜手的猜测输入实际上走的是 DrawGuessGame 页面内部的 guessInput + submitGuess() 机制(直接判断是否正确),而非通过 GameChatPanel。GameChatPanel 在你画我猜中更多是猜手之间的社交互动通道------互相讨论、提示、调侃,而非正式的猜测提交入口。这种双轨制的设计让"游戏核心交互"(猜词判定)和"社交互动"(聊天)各走各的通道,互不干扰。
真心话大冒险:isMyTurn
真心话大冒险的 canSpeak 逻辑简洁直接------只有当前轮到的玩家才能发言。游戏流程是:每人轮流选择真心话或大冒险,其他人出题,被选中的玩家回答或执行。在 isMyTurn 为 true 的回合中,该玩家需要回应问题,因此需要聊天面板来表达自己的回答。
其他玩家在非自己回合时被禁言,这符合真心话大冒险的标准规则------回答者是焦点人物,其他人不应在聊天中干扰或抢先回答。不过,出题阶段(CROWD_SOURCE)其他人确实需要提交题目,但这个交互走的是页面内部的 mySubmission + submitQuestion() 机制,不通过 GameChatPanel。这里再次体现了"游戏核心交互与聊天社交分离"的设计原则。
值得注意的是,TruthOrDareGame 的 canSpeak 实际上传入的是 this.isMyTurn,但在整个游戏流程中,isMyTurn 在 TURN_START、CHOOSE_TYPE、CROWD_SOURCE、SHOW_QUESTION、RESPOND 所有阶段都保持不变(它只在 startTurn() 中根据 current.id === this.myId 计算一次)。这意味着被提问者即使在出题阶段和展示问题阶段也被允许聊天,这可以理解为被提问者对题目的即时反应和讨论。
3.4 canSpeak 统一设计模式总结
纵观6款游戏的 canSpeak 逻辑,我们可以提炼出三种模式:
-
阶段+身份双重条件模式 (狼人杀、谁是卧底):
phase === X && identityCondition。需要同时满足"正确的阶段"和"正确的身份"才能发言,是最严格的禁言模式,适用于信息博弈类游戏。 -
阶段单一条件模式 (剧本杀、看谁反应快):
phase === X或phase !== X。仅根据游戏阶段决定发言权,不考虑玩家身份差异,适用于全员平等参与的讨论或社交环节。 -
身份反转条件模式 (你画我猜)或身份单一条件模式 (真心话大冒险):
!isMyTurn或isMyTurn。直接根据玩家角色/轮次决定发言权,不涉及复杂阶段判断。
这三种模式涵盖了回合制/派对游戏的所有禁言需求,而 GameChatPanel 通过单一的 canSpeak 布尔接口优雅地适配了所有模式,无需为不同游戏做任何组件内部的特殊处理。
四、禁言 UI 设计
4.1 禁言提示文案设计
当 canSpeak 为 false 时,GameChatPanel 的 TextInput 的 placeholder 从"发言..."变为"未轮到你,禁言中"。这短短七个字的文案选择经过了仔细考量:
- "未轮到你"------明确告知了禁言的原因是"轮次"问题,而非被惩罚或被禁言。这在回合制游戏中非常重要,因为禁言是正常的游戏机制,而非处罚。如果用"禁言中"作为唯一提示,可能会让新手玩家误以为自己被惩罚了。加上"未轮到你"这个前置说明,消除了误解的可能。
- "禁言中"------作为后半段的补充说明,用简短的两个字传达了当前的状态。这个词语在中文游戏语境中被广泛理解,不会产生歧义。
从源码来看,placeholder 的条件切换逻辑位于 GameChatPanel.ets:147:
TextInput({ placeholder: this.canSpeak ? '发言...' : '未轮到你,禁言中', text: this.inputText })
这是一个简洁的三元表达式,根据 canSpeak 的值动态切换 placeholder 文案。当 canSpeak 为 true 时显示"发言..."------这是一种邀请性的提示,暗示用户"你可以在这里发言了";当 canSpeak 为 false 时显示"未轮到你,禁言中"------这是一种解释性的提示,告知用户"当前不是你的发言时间"。
4.2 TextInput.enabled(this.canSpeak) 的交互禁用
禁言不仅仅是一个视觉提示,更是一种交互约束。TextInput.enabled(this.canSpeak) 将输入框的可用状态与 canSpeak 绑定------当 canSpeak 为 false 时,输入框不仅显示为灰色,而且完全不可聚焦、不可输入、不可响应任何触摸事件。这从交互层面杜绝了用户在禁言状态下尝试输入的可能。
这种"视觉+交互双重禁用"的设计比单纯的视觉禁用更可靠。如果只改变外观而不禁用交互,用户可能通过某种方式(如辅助功能、意外触碰)在禁言状态下成功输入文字,这会导致逻辑混乱------消息可能被发送出去,破坏游戏规则。enabled(false) 从框架层面阻断了这种可能性。
ArkUI 的 TextInput 组件在 enabled 为 false 时会自动应用禁用样式:背景色变灰、文字颜色变淡、不可聚焦。这与我们手动设置的 placeholder 文案配合,形成了三层禁言反馈:视觉上的灰色外观、文案上的"禁言中"提示、交互上的不可操作。
4.3 麦克风按钮的 backgroundColor 绑定
语音按钮的背景色同样与 canSpeak 动态绑定:
Button('麦克风')
.backgroundColor(this.canSpeak ? '#FF6B35' : '#BDBDBD')
.enabled(this.canSpeak)
当 canSpeak 为 true 时,麦克风按钮显示为 #FF6B35(NearPlay品牌橙色),传达"可以语音发言"的积极信号;当 canSpeak 为 false 时,背景色切换为 #BDBDBD(中性灰色),与 TextInput 的禁用状态视觉一致。同时 enabled(this.canSpeak) 确保按钮在禁言状态下不可点击,防止用户尝试开始录音。
这种颜色绑定的设计遵循了交通灯式的直观反馈原则:橙色 = 可以操作(绿灯的变体),灰色 = 不可操作(红灯的变体)。在游戏这种需要快速反应的场景中,用户往往不会仔细阅读文字提示,而是依赖颜色直觉来判断当前状态。橙/灰二色的对比足够明显,即使在快速浏览中也能被立即识别。
特别值得注意的是,麦克风按钮同时绑定了 backgroundColor 和 enabled,这是必要的双重保障。如果只改变颜色而不禁用交互,用户点击灰色按钮仍会触发 startVoiceRecord(),虽然方法内部有 if (!this.canSpeak) { return } 的保护,但这种"视觉禁用但交互未禁用"的体验是不专业的------用户点击后没有任何反馈,会以为按钮坏了。enabled(false) 会让框架自动处理禁用态的触摸反馈(如不响应点击、不触发按下动画),提供更自然的交互体验。
4.4 发送按钮的条件显示
发送按钮采用了更严格的条件------只有当输入框有内容且可以发言时才显示:
if (this.inputText.trim() !== '' && this.canSpeak) {
Button('发送')
...
}
这意味着即使 canSpeak 为 true,如果输入框为空,发送按钮也不会出现;反之,即使输入框有内容,如果 canSpeak 为 false,按钮同样不会出现。这种双重条件的条件渲染比简单的 enabled 控制更加极致------按钮不仅不可操作,而且完全不可见。在禁言状态下不显示发送按钮,避免了"看到按钮但不能点"的挫败感,进一步强化了"当前不能发言"的心理暗示。
4.5 禁言 UI 的多层级反馈体系
综合来看,GameChatPanel 的禁言反馈形成了一个从显式到隐式的多层级体系:
- 文案层(最显式):placeholder 文案从"发言..."变为"未轮到你,禁言中"
- 颜色层:TextInput 背景变灰、麦克风按钮从橙色变灰色
- 交互层:TextInput 和 麦克风按钮 enabled=false,不可操作
- 组件层(最隐式):发送按钮条件渲染消失
这四层反馈同时生效,确保无论用户通过哪种感知通道获取信息,都能明确知道当前处于禁言状态。这种"冗余反馈"的设计在游戏 UI 中尤为重要------玩家在紧张的游戏过程中,注意力往往集中在游戏主界面上,对聊天面板的关注是间歇性的。多层级反馈确保了即使玩家只是余光扫过,也能捕捉到至少一个禁言信号。
五、展开/收起设计
5.1 isExpanded 状态与 UI 切换

GameChatPanel 采用了展开/收起的双态设计,由 isExpanded 布尔值控制。收起时,面板仅显示一行标题栏,高度极低(约40px),几乎不占用游戏主界面的空间;展开时,面板展示完整消息列表(120px高度)和输入区域,提供完整的聊天交互能力。
这种双态设计的核心驱动力是"游戏优先"原则。在6款游戏中,聊天都是辅助功能而非核心玩法。如果聊天面板始终占据大量屏幕空间,会挤压游戏主界面的展示区域,影响核心游戏体验。收起态的设计让玩家在不需要聊天时几乎感受不到面板的存在,而在需要交流时又可以通过一次点击快速展开。
5.2 收起态:最新消息预览
收起态的标题栏设计包含三个元素:聊天图标、最新消息预览、"收起"按钮。其中最新消息预览是收起态的关键功能------当存在消息且面板未展开时,显示最后一条消息的内容摘要。如果是文字消息则显示其 content 文本,如果是语音消息则显示语音标记。预览文本设置了 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }),确保只显示一行且超出部分用省略号代替。配合 layoutWeight(1) 让预览文本占据图标和右侧之间的全部可用空间,最大化预览内容的可见长度。
这种"1行 truncated 预览"的设计是空间效率与信息量的最佳平衡。完整的消息可能很长,但在收起态下我们没有空间展示全部内容。一行预览足够传达"有人在说话"的事实,以及消息的大致内容(如"我觉得3号很可疑..."而不是整段推理)。语音消息则用语音图标标记代替,虽然没有显示具体内容,但与文字消息的视觉差异足够让玩家区分消息类型。
5.3 聊天图标点击切换
面板的展开/收起切换完全由聊天图标的 onClick 事件驱动:
Text('💬')
.fontSize(20)
.onClick(() => { this.isExpanded = !this.isExpanded })
点击图标时,isExpanded 取反------true变false或false变true,触发 ArkUI 的响应式渲染,切换面板的显示状态。这种单图标双功能的交互模式极其精简:不需要额外的展开/收起按钮,不需要复杂的动画,一个20px的emoji图标就承担了全部切换职责。
当面板展开后,标题栏右侧会出现"收起"文字按钮(12号灰色字体),提供更直观的收起操作入口。同时图标仍然可以点击收起,形成了两个收起入口,降低了操作的认知成本。展开态的消息列表高度固定为120px,这个值经过权衡------太低(如80px)只能看到1-2条消息,缺乏上下文;太高(如200px)会严重挤压游戏主界面。120px约可显示4-5条消息,足够让玩家回顾最近的讨论内容。
5.4 展开态与收起态的视觉差异
两种状态的视觉差异非常明显:收起态是白色一行条状,内容为"图标 + 最新消息预览";展开态是白色圆角卡片,包含标题行(图标 + 空白 + "收起")、消息列表、以及输入区域。背景色统一为 Color.White,borderRadius(12) 赋予圆角效果,与 NearPlay 的整体设计语言保持一致。
这种差异化的视觉呈现让玩家可以一眼区分面板状态,不会在收起时误以为面板出了问题(因为看不到消息列表),也不会在展开时忘记可以收起(因为"收起"按钮始终可见)。状态切换是即时的,没有过渡动画,这在游戏场景中是合理的选择------玩家不希望等待动画完成才能发消息或查看内容,即时响应比视觉美化更重要。
六、语音录制
6.1 startVoiceRecord --- 录音启动流程

语音录制是 GameChatPanel 内置的核心功能之一。当用户点击麦克风按钮时,startVoiceRecord() 方法被调用,启动一次语音录制会话。方法的完整实现如下:
startVoiceRecord(): void {
if (!this.canSpeak) {
return
}
this.isRecording = true
this.recordDuration = 0
this.recordTimerId = setInterval(() => {
this.recordDuration++
if (this.recordDuration >= 60) {
this.stopVoiceRecord()
}
}, 1000)
}
方法的第一行是一个安全检查 if (!this.canSpeak) { return }------即使麦克风按钮已经通过 enabled(this.canSpeak) 禁用了点击,方法内部仍然进行了二次校验。这种防御性编程是多层级安全保障的一环:UI层的 enabled 可能因为框架bug或状态同步延迟而失效,方法内部的校验作为最后一道防线,确保禁言状态下绝对不会开始录音。
录音启动后,三个状态变量同步更新:isRecording 置为 true 触发 UI 切换到录音中状态,recordDuration 置为0开始计时,recordTimerId 记录定时器ID供后续清理。定时器通过 setInterval 每秒递增 recordDuration,同时检查是否达到60秒上限。60秒自动停止的设计考虑了以下因素:语音消息太长(超过1分钟)会严重影响其他玩家的收听意愿,尤其在快节奏的派对游戏中;同时60秒也足够表达一段完整的推理(狼人杀)或词语描述(谁是卧底)。
6.2 stopVoiceRecord --- 录音结束与消息发送
录音结束有两个触发路径:用户主动点击"发送"按钮(调用 stopVoiceRecord()),或录音时长达到60秒自动触发。方法实现:
stopVoiceRecord(): void {
if (this.recordTimerId !== -1) {
clearInterval(this.recordTimerId)
this.recordTimerId = -1
}
if (this.isRecording && this.recordDuration > 0) {
const msg = ChatMsg.voice('game', this.myId, this.myNickname, this.myAvatar, this.recordDuration)
this.messages = [...this.messages, msg]
}
this.isRecording = false
this.recordDuration = 0
}
方法首先清理定时器,这是资源管理的标准操作。然后判断 isRecording && recordDuration > 0------只有当确实在录音且录制了至少1秒时,才生成语音消息。这防止了两种边界情况:用户点击麦克风后立即点击"发送"(duration为0,不生成消息);以及非录音状态下误调用 stopVoiceRecord()(isRecording为false,跳过消息生成)。
语音消息通过 ChatMsg.voice() 工厂方法创建,参数依次为:会话ID(固定为 'game')、发送者ID、昵称、头像、录音时长。注意这里只传递了 duration 而没有传递实际的音频数据------这是当前实现的简化设计,语音消息只记录了时长,不包含可播放的音频内容。在消息列表中,语音消息显示为时长标记格式(蓝色文字),N为录音秒数。
最后,三个状态变量被重置:isRecording 回到 false 触发 UI 恢复,recordDuration 清零,recordTimerId 置为 -1。
6.3 取消录音
用户也可以取消正在进行的录音。取消按钮的 onClick 逻辑直接内联在 build() 方法中------取消操作与发送操作的区别在于:不生成语音消息。定时器被清除,状态被重置,但跳过了 ChatMsg.voice() 的调用。这给用户一个"反悔"的机会------如果开始录音后发现说错了或环境太吵,可以取消而不产生一条无意义的语音消息。
取消逻辑没有封装为独立方法(如 cancelVoiceRecord()),而是直接内联在 onClick 中。这是因为取消逻辑只在这一个地方使用,且逻辑足够简单(4行),提取为方法并不会带来明显的可读性或复用性提升。相比之下,startVoiceRecord() 和 stopVoiceRecord() 被多处调用(麦克风按钮点击、60秒自动触发、发送按钮点击),因此值得提取为方法。
6.4 录音中的 UI 状态
录音中状态(isRecording === true)时,输入区域完全切换为录音控制面板:
- 左侧:红色文字"录音中 Ns",实时显示录音时长
- 右侧:橙色"发送"按钮 + 灰色"取消"按钮
- 背景:
#FFF3E0(淡橙色),与正常状态白色背景形成明显区分
整个输入行背景变色是一个重要的视觉信号------玩家可能没有注意到输入框区域的文字变化,但整个行的颜色变化是难以忽略的。淡橙色既传达了"正在录音"的活跃状态,又不会过于刺眼干扰游戏主界面。
"发送"按钮使用 #FF6B35(NearPlay品牌橙色),与正常状态下麦克风按钮和发送按钮的颜色一致,保持品牌色彩的一致性。"取消"按钮使用 #EEEEEE(淡灰色),弱化其视觉权重------在录音流程中,"发送"是主要操作,"取消"是次要操作,颜色对比度差异引导用户的注意力优先放在"发送"上。
6.5 60秒限制的设计考量
60秒的录音时长限制是一个重要的用户体验设计决策。在真实游戏中,语音消息通常在5-30秒之间------狼人杀一轮发言30秒,谁是卧底描述回合10秒,大多数自然语言的完整表达也很少超过1分钟。60秒的限制既给用户充足的发言空间,又防止了"忘了关录音"导致的超长无效消息。
自动停止的实现方式是在 setInterval 回调中检查 this.recordDuration >= 60,当条件满足时调用 stopVoiceRecord()。这确保了即使玩家在录制过程中被其他事情打断(如来电话、被游戏中的事件吸引注意力),录音也不会无限持续下去。自动停止后的消息会被正常发送(因为 stopVoiceRecord() 中 isRecording && recordDuration > 0 条件满足),这比"自动取消"更合理------用户说了1分钟的话,内容应该是有效的,不应该被默默丢弃。
6.6 生命周期中的定时器清理
aboutToDisappear() 是 ArkUI 组件生命周期的关键钩子,在组件从组件树中移除时调用:
aboutToDisappear(): void {
if (this.recordTimerId !== -1) {
clearInterval(this.recordTimerId)
}
}
这段代码确保了组件销毁时不会有定时器泄漏。定时器泄漏是一个严重的资源问题------如果用户在录音过程中退出游戏页面,组件被销毁,但 setInterval 创建的定时器仍然在运行,持续递增 recordDuration 并可能尝试调用已销毁组件的 stopVoiceRecord() 方法,导致未定义行为。aboutToDisappear 中的清理逻辑是防止这类问题的标准模式。
七、文字输入
7.1 TextInput + 发送按钮
文字输入是 GameChatPanel 最基础的交互方式。输入行由三个元素组成:TextInput(输入框)、麦克风按钮(语音入口)、发送按钮(条件显示)。
TextInput 使用 layoutWeight(1) 占据大部分宽度,高度32px,字号13px,这些尺寸在手机屏幕上既能保证输入的舒适度,又不会占用过多空间。onChange 回调实时更新 this.inputText,确保组件状态与用户输入同步。placeholder 根据 canSpeak 动态切换,enabled 属性与 canSpeak 绑定,实现了完整的禁言控制。
发送按钮的条件显示逻辑 if (this.inputText.trim() !== '' && this.canSpeak) 既防止了空消息的发送,又在禁言时隐藏了按钮,避免了"看到但不能点"的负面体验。
7.2 sendText() 方法
sendText(): void {
if (this.inputText.trim() !== '') {
const msg = ChatMsg.text('game', this.myId, this.myNickname, this.myAvatar, this.inputText.trim())
this.messages = [...this.messages, msg]
this.inputText = ''
}
}
sendText() 方法的实现简洁明了:校验输入非空、创建文字消息、追加到消息数组、清空输入框。四个步骤,逻辑清晰,无副作用。消息创建使用 ChatMsg.text() 工厂方法,参数包括会话ID('game')、发送者信息(myId、myNickname、myAvatar)和消息内容(trim后的文本)。
this.inputText = '' 在消息追加之后执行,确保了状态更新的正确顺序------先创建消息对象(此时 inputText 的值已被读取),再清空输入框。由于 ArkUI 的状态更新是同步的,这两个赋值操作会在同一次渲染周期中生效,不会出现"消息已发送但输入框还没清空"的中间状态。
八、ChatModel 复用
8.1 ChatMsg 工厂方法设计
ChatModel(entry/src/main/ets/model/ChatModel.ets)为 GameChatPanel 提供了统一的消息数据模型。ChatMsg 类定义了7个字段:id(唯一标识)、conversationId(会话ID)、fromUserId(发送者ID)、fromNickname(发送者昵称)、fromAvatar(发送者头像)、msgType(消息类型)、content(文字内容)、duration(语音时长)、timestamp(时间戳)。
ChatMsg 提供了三个静态工厂方法,每个方法对应一种消息类型:
ChatMsg.text(convId, fromId, fromNick, fromAvatar, text) --- 创建文字消息。设置 msgType 为 ChatMsgType.TEXT,content 为传入的文本内容,duration 保持默认值0。这个工厂方法被 GameChatPanel 的 sendText() 调用,参数直接映射到组件的 @Prop 字段:convId 固定为 'game',fromId 来自 myId,fromNick 来自 myNickname,fromAvatar 来自 myAvatar,text 来自 inputText.trim()。
ChatMsg.voice(convId, fromId, fromNick, fromAvatar, duration) --- 创建语音消息。设置 msgType 为 ChatMsgType.VOICE,duration 为录音秒数,content 保持默认空字符串。这个工厂方法被 GameChatPanel 的 stopVoiceRecord() 调用,duration 参数来自 this.recordDuration。
ChatMsg.image(convId, fromId, fromNick, fromAvatar, uri) --- 创建图片消息。设置 msgType 为 ChatMsgType.IMAGE,content 为图片URI。当前 GameChatPanel 未使用此方法,但 ChatModel 已经预留了图片消息的支持,为未来的表情/图片发送功能奠定了数据模型基础。
8.2 ChatMsgType 枚举
export enum ChatMsgType {
TEXT = 0,
VOICE = 1,
IMAGE = 2
}
ChatMsgType 枚举定义了三种消息类型,简洁而完备。TEXT 和 VOICE 是 GameChatPanel 当前使用的两种类型,IMAGE 是预留的扩展类型。在消息列表的 ForEach 渲染中,通过 msg.msgType === ChatMsgType.TEXT 的判断来区分文字和语音消息的展示方式------文字消息显示 msg.content(灰色12号字体),语音消息显示时长标记(蓝色12号字体)。
这种基于枚举的类型判断比基于字符串的比较更安全、更高效。枚举值在编译时被替换为数字常量(TEXT=0, VOICE=1, IMAGE=2),比较操作是简单的整数比较,比字符串比较快得多。同时,枚举的值域是有限的,TypeScript/ArkTS 的类型系统可以在编译时捕获无效的消息类型值,避免运行时错误。
8.3 工厂方法的消息ID生成
所有三个工厂方法都使用 msg_${Date.now()} 格式生成消息ID。Date.now() 返回当前时间的毫秒级时间戳,确保了ID的时序唯一性------在正常使用场景下,两条消息不太可能在同一毫秒内被创建。虽然在极端情况下(如程序化快速发送多条消息)可能产生重复ID,但对于游戏聊天这种人工操作的场景,碰撞概率可以忽略不计。
消息ID在 ForEach 的 key 函数中被使用:(msg: ChatMsg, idx: number) => msg.id + '_' + idx。这里额外拼接了 idx 作为额外的唯一性保障,即使两条消息的 Date.now() 值相同,它们的索引也必然不同,确保了 ForEach 的 key 绝对唯一。ArkUI 的 ForEach 要求每个元素的 key 在列表内唯一,否则会导致渲染异常。msg.id + idx 的组合策略既保证了唯一性,又让 key 包含了消息的语义信息(便于调试)。
8.4 ChatModel 的复用范围
ChatModel 不仅服务于 GameChatPanel 组件,还服务于 ChatPage(私聊/群聊页面)和 ActivityModel(活动讨论)。ChatConversation 类和 ChatType 枚举定义了会话的元数据,ChatMsg 类定义了消息的数据结构,MockChatData 提供了开发阶段的测试数据。这种跨场景的数据模型复用是架构层面的重要设计------不同功能模块(游戏聊天、私聊、活动讨论)共享同一套消息模型,确保了数据格式的一致性,也为未来可能的"跨模块消息搜索"、"消息转发"等高级功能奠定了基础。
在 GameChatPanel 中,我们只使用了 ChatModel 的部分功能:ChatMsg 的 TEXT 和 VOICE 工厂方法、ChatMsgType 枚举的 TEXT 和 VOICE 值。ChatConversation、ChatType、MockChatData 以及 ChatMsg.image() 在当前组件中未使用,但它们的存在并不增加组件的复杂度------ArkTS 的 tree-shaking 机制会在编译时移除未使用的导出,不影响最终包体积。
九、VoiceInput vs GameChatPanel 内置语音
9.1 两种语音方案的本质区别
NearPlay 项目中存在两种语音交互方案:GameChatPanel 的内置简单录音,以及独立的 VoiceInput 组件。理解这两者的本质区别,是理解整个语音设计架构的关键。
GameChatPanel 的内置语音 = 语音录制(Voice Recording)。它只做一件事:记录用户说话的时长,生成一条包含时长的语音消息。它不识别语音内容,不将语音转换为文字,消息列表中显示的是时长标记,而非语音的文字内容。这是典型的"留声机"模式------录制声音、记录时长、展示时长标记。用户看到的是原始音频的时长表征(在当前实现中实际上不播放音频,只展示时长),不是转写文字。
VoiceInput = 语音识别(Speech Recognition) 。它使用 HarmonyOS 的 @kit.CoreSpeechKit 中的 speechRecognizer API,将用户的语音实时转换为文字。用户说话后,得到的是一段文字结果,而非一段音频。这是典型的"听写机"模式------采集声音、识别内容、输出文字。用户看到的是语音转写的文字,不是音频播放器。
这两种方案的目标完全不同:内置录音解决的是"快速发送一条语音消息"的需求,VoiceInput 解决的是"不想打字时用语音代替键盘输入"的需求。前者输出的是语音消息(时长标记),后者输出的是文字消息。
9.2 VoiceInput 的实现分析
VoiceInput 组件(entry/src/main/ets/components/VoiceInput.ets)封装了 HarmonyOS 的语音识别能力,核心依赖是 VoiceInputHelper(entry/src/main/ets/model/VoiceInputHelper.ets)。VoiceInputHelper 使用 speechRecognizer.createEngine() 创建语音识别引擎,配置了以下参数:
language: 'zh-CN'--- 中文识别online: 1--- 在线模式(需要网络,识别精度更高)recognizerMode: 'short'--- 短语音模式audioType: 'pcm'--- PCM音频格式sampleRate: 16000--- 16kHz采样率
识别结果通过 RecognitionListener.onResult 回调获取,result.result 包含识别出的文字,result.isLast 标识是否是最终结果。识别完成后,文字通过 onVoiceResult 回调传递给父组件。
VoiceInput 同样有60秒的时长限制,通过 durationTimerId 实现自动停止。它还实现了200ms轮询的 refreshTimer,定期从 VoiceInputHelper 同步 isListening、recognizedText、listenDuration 三个状态到组件的 @State 变量,驱动 UI 刷新。这种轮询模式虽然不够优雅,但在 ArkTS 的异步编程约束下(无法使用 Promise 直接监听原生回调),是一个实用的折中方案。
9.3 设计取舍:为什么不用 VoiceInput 替代内置录音
一个自然的问题是:既然 VoiceInput 功能更强大(语音转文字),为什么不直接在 GameChatPanel 中使用 VoiceInput 替代内置的简单录音?
原因有三个:
第一,功能定位不同。 GameChatPanel 的语音按钮目的是发送"语音消息"------一种与文字消息并列的消息类型,它在消息列表中显示为时长标记,传达"这个人说了N秒的话"这一信息。如果用 VoiceInput 替代,语音按钮的效果变成了"用语音输入文字并发送文字消息",这完全改变了消息的类型和展示方式。在某些游戏场景中,语音消息比文字消息更合适------比如狼人杀发言时,其他玩家需要听到说话者的语气、停顿和情感,这些信息无法通过文字转写传达。
第二,依赖和复杂度。 VoiceInput 依赖 @kit.CoreSpeechKit,需要网络连接(在线识别模式),可能因为网络问题或权限问题而无法使用。内置录音只需要 setInterval 这个最基本的 API,零外部依赖,100%可靠。在游戏这种对稳定性要求极高的场景中,核心交互功能不应该依赖可能失败的第三方服务。如果语音识别失败,玩家将无法发言,这会严重影响游戏体验。
第三,交互模式差异。 GameChatPanel 的内置录音是一个"两步操作":点击麦克风开始录音,再点击发送结束录音并发送。这种明确的起止控制让用户对"我在录音"这件事有清晰的感知。VoiceInput 的语音识别是"一步操作":点击开始,说话,系统自动识别并返回结果。识别过程中用户不知道系统"听到了"什么,直到识别结果出现。在游戏场景中,玩家可能需要在录音过程中思考、停顿、重新组织语言,两步操作的模式更符合这种使用习惯。
9.4 两种方案在游戏中的实际使用场景
在实际游戏页面中,两种方案各司其职:
GameChatPanel 的内置录音被用于所有6款游戏的聊天面板中,作为消息发送的一种方式。用户在聊天面板中点击麦克风按钮录制语音消息,消息以语音类型出现在消息列表中。这是"社交聊天"场景的语音功能。
VoiceInput 被嵌入在游戏页面的主交互区域中,作为文字输入的语音替代方案。例如:WerewolfGame 的讨论区域中,当 currentSpeaker === this.myId 时显示 VoiceInput,玩家可以用语音发言,识别出的文字作为正式发言内容。UndercoverGame 的描述回合中,VoiceInput 帮助玩家用语音输入词语描述。DrawGuessGame 的猜测区域中,VoiceInput 让猜手用语音说出猜测。QuickReactGame 中,VoiceInput 甚至被用于语音抢拍------识别出"拍""抢""有"等关键词时自动触发抢拍操作。
这种分工模式可以总结为:GameChatPanel 内置录音 = 轻量级语音消息,VoiceInput = 重量级语音输入。前者追求简单可靠,后者追求功能强大。在游戏应用中,两种方案互补而非互斥,共同构成了完整的语音交互体验。
9.5 架构层面的设计统一
虽然两种方案的实现完全不同,但它们在架构层面保持了统一:
- 都有60秒的时长限制,防止无限录音/识别
- 都有
canSpeak的控制机制------GameChatPanel 通过startVoiceRecord()内的检查,VoiceInput 通过canSpeakprop 和enabled属性 - 都有定时器清理机制------GameChatPanel 在
aboutToDisappear中清理,VoiceInput 在aboutToDisappear中清理refreshTimer并调用helper.destroy()销毁识别引擎 - 都使用 NearPlay 品牌橙色(#FF6B35)作为主操作按钮颜色,保持视觉一致性
这种"实现各异、约束统一"的架构设计,使得两种方案可以在同一个应用中和谐共存,不会给用户带来认知负担。
十、未来:消息类型扩展
10.1 系统消息
当前 GameChatPanel 只支持玩家发送的文字和语音消息。未来最自然的扩展是引入系统消息(System Message)------由游戏系统自动生成的、不属于任何玩家的通知性消息。例如:
- 狼人杀中"天黑请闭眼""第1天开始"等阶段切换通知
- 谁是卧底中"投票阶段开始""小明被淘汰"等游戏事件通知
- 你画我猜中"新的一轮开始""猜对了!"等状态变更通知
- 真心话大冒险中"轮到小明选择""题目已确定"等回合事件通知
系统消息的 ChatMsgType 可以扩展为 SYSTEM = 3,在消息列表中以居中、小号、灰色/特殊颜色的样式呈现,与玩家消息的左对齐样式明确区分。系统消息不需要 fromUserId/fromNickname/fromAvatar 字段,可以通过 ChatMsg.system(convId, content) 工厂方法创建,只填充 content 和 timestamp。
10.2 游戏事件消息
比系统消息更进一步的是游戏事件消息(Game Event Message)。这类消息不仅包含文字描述,还包含结构化的游戏数据,可以被 UI 以更丰富的方式渲染。例如:
- 狼人杀的投票结果消息:包含每位玩家的投票对象,可以渲染为投票关系图
- 谁是卧底的词语揭示消息:包含平民词和卧底词,可以渲染为对比卡片
- 你画我猜的猜词记录:包含猜测者、猜测内容、是否正确,可以渲染为带状态标记的列表
游戏事件消息的 ChatMsgType 可以定义为 GAME_EVENT = 4,在 content 字段中存储 JSON 格式的结构化数据,在消息列表的 ForEach 渲染中通过 msgType === ChatMsgType.GAME_EVENT 的判断走专门的渲染分支。这种设计需要在 ChatMsg 中新增一个 eventType: string 字段来标识具体的游戏事件类型,以便渲染分支做进一步分发。
10.3 表情消息
表情(Emoji/Sticker)是社交聊天中最受欢迎的消息类型之一。NearPlay 当前已经使用 emoji 作为玩家头像,扩展为表情消息是自然的选择。
表情消息有两种实现路径:
简单路径:使用标准 emoji。 复用 ChatMsg.text() 工厂方法,将 emoji 作为文字内容发送。这是最简单的实现,不需要新增消息类型,但缺点是表情和文字混在一起,无法对表情做特殊渲染(如放大显示、动画效果)。
完整路径:新增 EMOJI = 5 消息类型。 通过 ChatMsg.emoji(convId, fromId, fromNick, fromAvatar, emojiCode) 工厂方法创建,在消息列表中以大号(32px或更大)居中显示,与文字消息的12px字号形成视觉对比。这种实现还可以支持自定义贴纸(Sticker),通过 content 字段存储贴纸的资源路径。
从 ChatModel 的现有设计来看,ChatMsg.image() 工厂方法和 ChatMsgType.IMAGE 枚举值已经预留了图片消息的支持。表情消息可以直接复用 IMAGE 类型------将 emoji 的资源路径作为 content 传入,在渲染时根据内容判断是普通图片还是表情贴纸,选择不同的渲染尺寸和样式。
10.4 消息类型扩展的架构保障
GameChatPanel 的现有架构为消息类型扩展提供了良好的基础:
- ChatMsgType 枚举可扩展:当前定义了 TEXT(0)、VOICE(1)、IMAGE(2),新增 SYSTEM(3)、GAME_EVENT(4)、EMOJI(5) 不影响现有值
- ForEach 渲染可分支 :当前通过
msg.msgType === ChatMsgType.TEXT的 if-else 判断区分文字和语音,新增类型只需增加 else-if 分支 - ChatMsg 工厂方法可新增 :静态工厂方法的模式使得新增
ChatMsg.system()、ChatMsg.gameEvent()等方法不影响现有方法 - 消息数组结构不变 :所有类型的消息都存储在同一个
messages: ChatMsg[]数组中,新增类型不需要改变数据结构
这种"对扩展开放、对修改封闭"的设计是良好架构的标志。未来的消息类型扩展只需在三个地方添加代码:ChatMsgType 枚举新增值、ChatMsg 新增工厂方法、GameChatPanel 的 ForEach 新增渲染分支,而不会影响任何现有功能的代码。
10.5 扩展优先级建议
基于6款游戏的实际需求和实现难度,建议的扩展优先级为:
- 系统消息(优先级最高)------所有6款游戏都需要,实现简单(新增枚举值+工厂方法+渲染分支),效果显著(让聊天面板从纯社交工具升级为游戏信息中心)
- 表情消息(优先级中等)------社交需求强烈,但需要设计表情选择器UI,实现复杂度中等
- 游戏事件消息(优先级最低)------需要为每种游戏设计专门的事件渲染器,实现复杂度最高,且每种游戏的渲染器互不通用
系统消息的引入将是一个重要的里程碑------它标志着 GameChatPanel 从"纯聊天组件"进化为"游戏信息面板",在聊天的社交属性之外增加了信息通知的功能属性。这将进一步提升组件在6款游戏中的价值,让展开/收起的状态切换更加有意义------即使玩家不打算发言,也会为了查看系统通知而展开面板。