NearPlay 剧本杀:多幕叙事与推理投票系统

真相揭晓
幕间开场

1. 多幕叙事设计哲学

剧本杀作为一种融合了角色扮演、推理演绎与社交博弈的游戏形式,其叙事结构的本质是"信息的分层释放"。不同于传统推理小说的线性叙事,也不同于密室逃脱的即时解谜,剧本杀的叙事体验建立在"时间维度上的信息不对称"之上——每位玩家在不同时间点掌握着不同的信息片段,而游戏的设计目标正是让这些碎片化的认知在交互中逐步碰撞、重组,最终指向唯一的真相。多幕叙事机制,就是实现这一设计目标的核心技术手段。

多幕叙事的哲学根源可以追溯到戏剧理论中的"幕"概念。在经典戏剧结构中,幕是叙事的基本单位,每一幕承担着特定的叙事功能:第一幕建置世界观与人物关系,第二幕制造冲突与悬念,第三幕推向高潮并揭示真相。剧本杀继承了这一结构,但赋予了它新的含义——在剧本杀中,"幕"不仅是叙事的分割线,更是信息释放的时间闸门。每一幕的开场为玩家注入新的背景信息与NPC证词,每一幕的讨论环节让玩家基于当前信息进行推理交锋,而每一幕的线索揭示则为下一幕的推理注入新的变量。这种"信息注入→推理交锋→线索揭示→新一轮注入"的循环,构成了剧本杀独特的认知节奏。

从技术实现的角度看,多幕叙事需要解决三个核心问题:第一,如何在代码层面表达"幕"这一抽象概念,使其能够承载独立的叙事内容、NPC台词、线索数据与时间参数;第二,如何控制幕与幕之间的推进节奏,确保玩家在每一幕中有足够的交互时间,同时又不因某一幕的拖沓而破坏整体叙事张力;第三,如何在最后一幕结束时优雅地切换到投票与揭晓阶段,而不是生硬地中断叙事流。NearPlay的 currentActIndex 机制与 ScriptPhase 状态机的协同,正是对这三个问题的系统性回答。

在NearPlay的设计中,“幕"不仅是数据的容器,更是游戏节奏的调节器。内置剧本"消失的画家"包含两幕——“失踪"与"线索”,这两幕的信息密度与推理难度呈递进关系:第一幕提供的是"现象级"线索(床铺整齐、颜料打翻),引导玩家形成初步假设;第二幕提供的是"证据级"线索(手机消息、画框纸条),要求玩家验证或推翻第一幕的假设。这种"假设→验证"的认知循环,是多幕叙事最核心的教学价值——它教会玩家"推理不是一次性的判断,而是在不断更新的信息中持续修正的过程”。

多幕叙事的设计还涉及一个微妙的平衡:幕数过少(如单幕本),信息释放缺乏层次,推理体验趋于扁平;幕数过多(如五幕以上),玩家在漫长的时间跨度中容易遗忘早期线索,讨论的连续性被打断。经验表明,二至四幕是剧本杀的最佳叙事区间——既保证了信息的分层释放,又维持了推理的连贯性。NearPlay的架构对此没有任何限制:script.acts 数组的长度完全由剧本数据决定,推进逻辑会自动适配任意幕数。

另一个值得深思的设计哲学是"幕间讨论的独立性"。在NearPlay中,每一幕的讨论阶段都会清空上一幕的聊天记录(chatMessages = []),这意味着玩家无法在UI上回溯历史讨论。这一设计是有意为之的——它迫使玩家在每一幕中充分表达自己的推理,而非依赖"翻聊天记录"来回忆。在真实的线下剧本杀中,玩家同样无法回溯已经说出口的话,这种"不可回溯性"正是剧本杀社交博弈的核心张力来源:你必须记住他人说过的话,也要为自己说过的话负责。当然,这种设计也带来了信息遗忘的风险,未来可通过"笔记本"功能让玩家自主记录关键信息,而非系统性地保留聊天历史。

多幕叙事还承载着"情感节奏"的调控功能。每一幕的开场(ACT_INTRO)与幕间过渡(ACT_SUMMARY)在叙事节奏上起着"呼吸"的作用——紧张的讨论之后,玩家需要一个短暂的缓冲期来消化信息、整理思路。ActIntroUIActSummaryUI 的极简设计(仅展示标题与描述),正是为了营造这种"幕间呼吸"的空间感。在戏剧理论中,这被称为"叙事留白"——不说话的时刻,与说话的时刻同样重要。

从更宏观的视角看,NearPlay的多幕叙事设计体现了"数据驱动叙事"的架构理念。ScriptData 的纯数据模型使得叙事内容与游戏逻辑完全解耦——同一个游戏引擎可以驱动完全不同的剧本,只需替换 script 对象的数据即可。这种解耦为未来的剧本商店、社区创作等扩展奠定了基础:新增一个剧本,不需要修改任何游戏代码,只需要提供一份符合 ScriptData 结构的数据。多幕叙事的哲学,最终指向的是"内容与引擎分离"的软件架构原则——引擎负责节奏与交互,内容负责叙事与推理,二者各司其职,互不侵入。


2. ScriptPhase 九阶段详解

剧本杀游戏的核心驱动是一个名为 ScriptPhase 的枚举状态机,定义于 ScriptKillModel.ets,其完整枚举值如下:

export enum ScriptPhase {
  SELECT_SCRIPT = 0,
  ROLE_ASSIGN = 1,
  ACT_INTRO = 2,
  NPC_SPEAK = 3,
  FREE_DISCUSS = 4,
  CLUE_REVEAL = 5,
  ACT_SUMMARY = 6,
  FINAL_VOTE = 7,
  RESULT_REVEAL = 8
}

这九个阶段构成了剧本杀从选本到真相揭晓的完整生命周期。每个阶段承载着特定的叙事功能与交互职责,阶段之间的转换并非简单的线性递进,而是根据剧本幕数进行嵌套循环。状态机的设计选择体现了"显式状态"优于"隐式条件"的架构原则——通过枚举值明确表达游戏当前所处的阶段,比通过多个布尔标志的组合推断阶段更清晰、更安全。下面逐一详解每个阶段的职责、状态转换条件与UI呈现。

2.1 SELECT_SCRIPT(选择剧本)

SELECT_SCRIPT 是整个游戏流程的起点,对应枚举值0。玩家进入剧本杀模式后,首先看到的是剧本选择界面。此阶段由 SelectScriptUI() 构建器渲染,提供两种剧本获取途径:内置剧本与外部文件导入。

内置剧本通过 startWithBuiltIn() 方法触发,该方法直接调用 MockScriptData.getScript() 获取预置剧本数据,随后将 phase 置为 ROLE_ASSIGN。外部文件导入则通过 importScriptFile() 方法调用 HarmonyOS 的 DocumentViewPicker 选择 .txt 文件,读取内容后展示预览,玩家确认后通过 confirmImport() 进入角色分配阶段。

在此阶段,组件状态 importedScriptTextshowImportPreview 协同工作:前者暂存从文件读取的原始文本内容,后者控制预览区域的显示与隐藏。预览区展示前200个字符,帮助玩家确认文件内容是否正确。整个选择流程的设计理念是降低进入门槛——既提供开箱即用的内置剧本,又支持自定义内容扩展,让新手玩家与资深剧本杀爱好者都能找到适合自己的入口。

SELECT_SCRIPT 阶段的UI设计采用了"推荐+扩展"的双轨布局:内置剧本以精美的卡片形式呈现,包含标题、简介与人数标签,视觉上吸引用户直接选择;外部导入则以功能性卡片呈现,强调"自定义"的可能性。这种布局将"大多数用户的选择"(内置剧本)与"少数用户的需求"(自定义导入)分层排列,符合"渐进式暴露"的交互设计原则。

2.2 ROLE_ASSIGN(角色分配)

对应枚举值1,角色分配阶段是玩家从"旁观者"转变为"剧中人"的关键时刻。RoleAssignUI() 构建器渲染角色卡,展示玩家所扮演角色的姓名、性别、年龄与背景故事。

assignRoles() 方法根据 myCharacterIdscript.characters 数组中查找对应角色,将该角色的 secrets 字段赋值给 mySecret 状态变量。这一设计确保每位玩家拥有仅自己可见的秘密信息——这是剧本杀游戏"信息不对称"核心体验的技术实现基础。角色分配完成后,系统通过 setTimeout 延迟3秒自动进入 ACT_INTRO 阶段,给予玩家阅读角色信息的时间缓冲。

角色卡设计上采用了视觉层次分明的布局:角色基本信息使用大号加粗橙色字体突出显示,背景故事使用常规灰色字体,秘密区域则使用红色标签配合浅红背景,在视觉上强调其私密性与重要性。这种视觉编码帮助玩家快速区分公开信息与个人秘密,降低了认知负荷。

在多人在线场景中,角色分配应由服务端随机执行,确保每位玩家获得不同角色且无法提前获知他人的角色信息。当前的单机实现中,myCharacterId 硬编码为 ‘c1’,即玩家始终扮演"陈馆长"。这一简化是原型阶段的合理选择——角色分配的网络逻辑不影响单机阶段的UI与交互验证。

2.3 ACT_INTRO(幕间开场)

对应枚举值2,每幕的开场阶段。showActIntro() 方法首先检查 currentActIndex 是否在有效范围内,若有效则将当前幕的 title 设为 phaseTitle,随后延迟3秒进入 NPC_SPEAK 阶段。

ActIntroUI() 构建器渲染幕标题与该幕的 description 描述文字,采用居中布局营造叙事仪式感。这段3秒的展示时间起到了"幕布拉开"的戏剧效果——玩家在阅读背景描述后,自然地进入该幕的剧情推进。背景描述不仅提供了叙事场景的视觉想象,更重要的是为后续的NPC台词与线索提供了上下文锚点。例如第一幕的描述"画展开幕当天清晨,众人发现李墨不在房间,工作室的门半开着…",为后续"床铺整齐"和"颜料打翻"两条线索建立了空间认知框架。

currentActIndex 的初始值在 assignRoles() 中被设为0,确保游戏从第一幕开始。每次幕间过渡时,该值递增,驱动整个多幕叙事的时间轴前进。

2.4 NPC_SPEAK(NPC叙述)

对应枚举值3,NPC叙述阶段是剧本杀叙事推进的核心环节之一。playNpcLines() 方法实现了完整的NPC台词播放引擎:首先遍历 script.npcs 中所有NPC的台词,筛选出属于当前幕(通过 actId 匹配)的台词,按 order 字段排序后依次播放。

每条NPC台词通过 speakText() 方法处理,该方法将NPC名称与台词内容拼接为格式化文本 [NPC名]: 内容,设为 npcSpeakingText,同时将 isNpcSpeaking 置为 true,触发UI上的语音播放指示器。播放时长根据台词字符数动态计算:text.length * 200 + 500,即每个汉字约200毫秒,外加500毫秒的缓冲间隔,模拟自然语速的节奏感。

NpcSpeakUI() 构建器在检测到 isNpcSpeaking 为真时,渲染蓝色背景的NPC对话气泡,展示NPC头像emoji、名称与台词内容。如果TTS可用(ttsAvailable 为真),还会显示"正在朗读…"提示。当所有NPC台词播放完毕,系统自动调用 startDiscuss() 进入自由讨论阶段。

NPC台词的排序播放机制确保了叙事的逻辑顺序——剧本作者通过 order 字段精确控制信息释放的节奏,避免线索在错误的时机提前暴露,破坏推理体验。NPCLine 的 emotion 字段(如’nervous’、‘scared’、‘serious’)为未来的情感化播报预留了扩展空间,可通过TTS引擎的语速与音调参数实现不同情绪的语音表达。

2.5 FREE_DISCUSS(自由讨论)

对应枚举值4,自由讨论阶段是玩家间信息交换的核心场景。DiscussUI() 构建器渲染完整的讨论界面,包含当前发言者指示器、倒计时显示、聊天消息列表与输入区域。此阶段是九阶段中交互最密集的环节,也是剧本杀"社交推理"体验的直接载体——玩家在此试探他人、保护自己、拼凑真相。

startDiscuss() 方法初始化讨论环境:将 speakerOrderIndex 归零、清空 chatMessages,然后立即调用 startPlayerSpeech() 启动逐人发言轮转。讨论阶段结束后,系统自动过渡到线索揭示阶段。

2.6 CLUE_REVEAL(线索揭示)

对应枚举值5,线索揭示阶段是每幕的信息释放节点。ClueUI() 构建器渲染当前幕的公开线索列表与玩家的私密线索。showClues() 方法在讨论结束后被调用,将 phase 切换为 CLUE_REVEAL,随后延迟5秒后进行幕间判断:若当前不是最后一幕,则递增 currentActIndex 并进入 ACT_SUMMARY;若已是最后一幕,则直接进入 FINAL_VOTE

公开线索通过 ForEach 遍历当前幕的 publicClues 数组渲染,每条线索以搜索emoji开头,白色背景卡片呈现。私密线索则仅展示当前玩家的 mySecret,使用红色强调标签区分。这种"公私分明"的线索呈现机制,确保了信息不对称的游戏体验。

2.7 ACT_SUMMARY(幕间总结)

对应枚举值6,幕间总结阶段是一个过渡性节点,由 ActSummaryUI() 构建器渲染。界面显示"幕间过渡…"与"即将进入下一幕"的提示文字,采用居中布局。

showClues() 方法中,当检测到 currentActIndex < script.acts.length - 1 时,将 phase 设为 ACT_SUMMARY,并通过 setTimeout 延迟2秒后调用 showActIntro() 进入下一幕。这2秒的过渡时间为玩家提供了从线索揭示到新幕开始的认知缓冲。

2.8 FINAL_VOTE(最终投票)

对应枚举值7,最终投票阶段仅在最后一幕的线索揭示之后触发。FinalVoteUI() 构建器渲染角色列表,每位角色以卡片形式呈现,玩家点击选中后卡片背景变为浅橙色并显示勾选标记。底部"确认投票"按钮仅在 voteTarget 非空时可点击,点击后调用 submitVote() 方法。

2.9 RESULT_REVEAL(真相揭晓)

对应枚举值8,真相揭晓阶段是整个游戏流程的终点。ResultRevealUI() 构建器渲染凶手揭晓与各角色完整故事。凶手角色以红色名称标注,所有角色的背景故事与秘密一一揭晓,让玩家理解每个角色的行为动机与案件全貌。

从宏观视角看,九阶段的设计遵循了剧本杀游戏的自然节奏:选择→代入→叙事→交流→推理→裁决→揭晓。每个阶段的停留时间与信息密度经过精心调配,确保玩家始终处于"信息饥饿→信息获取→信息重组"的认知循环中。


3. 幕间推进机制

多幕叙事是剧本杀区别于其他推理游戏的核心特征。NearPlay 通过 currentActIndexscript.acts 数组的协同,实现了灵活的幕间推进机制。

3.1 核心状态变量

currentActIndex 是幕间推进的驱动指针,类型为 number,初始值0。它指向 script.acts 数组中的当前幕。script.acts 的类型为 ScriptAct[],每个 ScriptAct 包含 idtitledescriptionpublicCluesprivateCluesnpcLineIdsduration 字段。

3.2 幕循环流程

一幕的完整流程为:ACT_INTRONPC_SPEAKFREE_DISCUSSCLUE_REVEAL。当线索揭示完成后,系统执行幕间判断逻辑:

if (this.currentActIndex < this.script.acts.length - 1) {
  this.currentActIndex++
  this.phase = ScriptPhase.ACT_SUMMARY
  setTimeout(() => { this.showActIntro() }, 2000)
} else {
  this.phase = ScriptPhase.FINAL_VOTE
  this.phaseTitle = '最终投票'
}

条件 currentActIndex < script.acts.length - 1 判断当前是否为最后一幕。若不是,则递增 currentActIndex,进入 ACT_SUMMARY 过渡,2秒后调用 showActIntro() 开始新的一轮幕循环。若是最后一幕,则跳过幕间过渡,直接进入最终投票。

3.3 递增时机与安全性

currentActIndex 的递增仅发生在 showClues() 方法内部,这是唯一修改该指针的位置。递增前已完成线索展示(5秒延迟),递增后立即进入过渡阶段,确保玩家不会错过任何信息。showActIntro() 方法内部也进行了边界检查:if (this.currentActIndex < this.script.acts.length),防止数组越界访问。

3.4 多幕嵌套的节奏设计

以内置剧本"消失的画家"为例,它包含两幕:第一幕"失踪"与第二幕"线索"。完整的阶段推进序列为:

SELECT_SCRIPT → ROLE_ASSIGN → ACT_INTRO(0) → NPC_SPEAK(0) → FREE_DISCUSS(0) → CLUE_REVEAL(0)
→ ACT_SUMMARY → ACT_INTRO(1) → NPC_SPEAK(1) → FREE_DISCUSS(1) → CLUE_REVEAL(1)
→ FINAL_VOTE → RESULT_REVEAL

每一幕的NPC台词、公开线索、讨论内容都是独立的,由 currentActIndex 间接引用对应 ScriptAct 的数据。这种设计使得剧本作者可以自由定义幕数——系统会自动适配,无论剧本是2幕、3幕还是5幕,推进逻辑完全一致。

3.5 最后一幕的特殊处理

最后一幕结束时,系统不再进入 ACT_SUMMARY,而是直接跳转到 FINAL_VOTE。这一跳转的条件判断至关重要:如果误判最后一幕,玩家将错过后续幕的内容;如果漏判,则会在线索揭示后卡在过渡状态。当前实现使用严格小于比较(< .length - 1),在索引从0开始的前提下,确保仅当 currentActIndex 等于最后一个有效索引时才触发投票,逻辑正确无误。


4. 线索系统与ClueItem

线索系统是剧本杀推理体验的基石。NearPlay 将线索分为公开线索与私密线索两个维度,通过不同的揭示时机与呈现方式,构建了信息不对称的游戏体验。线索不仅是推理的素材,更是社交博弈的筹码——在讨论阶段,玩家围绕线索的解读、质疑与隐瞒,构成了剧本杀最核心的互动张力。

4.1 数据模型

线索数据分布在两个层级:ScriptAct.publicClues 存储每幕的公开线索,类型为 string[]ScriptCharacter.secrets 存储每位角色的私密信息,类型为 string。此外,ScriptAct 还定义了 privateClues: Record<string, string> 字段,用于存储以角色ID为键的私密线索映射,为未来扩展预留了数据结构。这种"公开+私密"的双轨线索体系,是剧本杀信息架构的经典模式——公开线索创造了讨论的"共同话题",私密线索则制造了"个体差异",二者的交互驱动了推理过程。

在数据模型的设计中,publicClues 选择了 string[] 而非自定义 ClueItem 类,这反映了当前原型的简化策略。每条线索目前仅是一个纯文本字符串,没有额外的类型标签(如"物证"、“证词”、"时间线"等)、发现位置、关联角色等元数据。未来若引入 ClueItem 数据类,可扩展为包含 type: 'physical' | 'testimony' | 'timeline'location: stringrelatedCharacterIds: string[]discoverCondition: string 等字段,使线索系统具备更丰富的语义表达能力。例如,一条"物证"类型的线索可以在UI上用放大镜图标呈现,而一条"证词"类型的线索则用对话气泡呈现,视觉编码帮助玩家更快地分类记忆。

4.2 publicClues 公开展示

公开线索在 CLUE_REVEAL 阶段通过 ClueUI() 构建器展示。渲染逻辑如下:

if (this.currentActIndex < this.script.acts.length) {
  ForEach(this.script.acts[this.currentActIndex].publicClues, (clue: string, idx: number) => {
    Row() {
      Text('🔍').fontSize(16)
      Text(clue).fontSize(14).margin({ left: 8 }).layoutWeight(1)
    }
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(8)
    .width('100%')
    .margin({ top: 8 })
  }, (clue: string, idx: number) => `clue_${idx}`)
}

每条公开线索以搜索图标+文本的行布局呈现,白色圆角卡片样式。ForEach 的键生成函数使用 clue_${idx} 格式,确保列表更新的正确性。

以内置剧本为例,第一幕的公开线索为:“李墨的床铺是整整齐齐的,没有睡过的痕迹"和"工作室地面上有打翻的颜料”。这两条线索分别暗示了两种可能:李墨并非正常入睡(可能遭胁迫或主动离开),以及工作室发生过冲突。玩家需要结合自身秘密与公开线索,在讨论中试探他人、拼凑真相。

4.3 mySecret 私密线索

私密线索通过 mySecret 状态变量管理,在角色分配阶段由 assignRoles() 方法设置:

const me = this.script.characters.find((c: ScriptCharacter) => c.id === this.myCharacterId)
if (me !== undefined) {
  this.mySecret = me.secrets
}

私密线索在 ClueUI() 中以独立区块展示,使用红色"你的私密线索"标签与浅红背景,视觉上与公开线索明确区分。私密线索是玩家在讨论中的"底牌"——何时透露、透露多少、是否隐瞒甚至歪曲,构成了剧本杀社交推理的核心博弈。

以"消失的画家"剧本为例,四位角色的秘密分别为:陈馆长曾伪造过李墨的早期作品;林助理暗恋李墨,当晚去过他的工作室;赵收藏家曾经威胁过李墨;周记者发现了李墨画中的暗号。每个秘密都既是该角色的嫌疑来源,也是其行为动机的解释。玩家需要判断他人的秘密与案件的关系,同时保护自己的秘密不被过度解读。

4.4 线索揭示时机的叙事考量

线索揭示的时机选择经过精心设计。它被安排在自由讨论之后而非之前,这意味着玩家在讨论时只能基于NPC叙述与自身秘密进行推理,公开线索的延迟揭示迫使玩家在信息不完整的情况下做出初步判断,增加了推理的挑战性与讨论的张力。这一设计遵循了"先推理后验证"的认知模型——人类对"验证自己推理"的渴望远强于"被动接收信息",将线索揭示放在讨论之后,恰好利用了这一心理特征。

4.5 privateClues 预留字段

ScriptAct.privateClues: Record<string, string> 是为未来扩展预留的字段,它允许剧本作者为每个角色定义幕级私密线索——不同于角色级的 secrets(贯穿全剧),幕级私密线索只在特定幕中揭示给特定角色。例如,第二幕可以为"林助理"添加一条私密线索:“你在工作室门口闻到了松节油的味道”,这条线索只对扮演林助理的玩家可见,为讨论增加新的信息维度。


5. 投票推理与 voteForTarget()

最终投票是剧本杀游戏的高潮节点——所有推理与讨论汇聚为一个决定性的选择。投票机制的核心状态变量是 voteTarget,它存储玩家选中的角色ID,其赋值逻辑与UI交互构成了完整的投票体验。

5.1 投票界面设计

FinalVoteUI() 构建器渲染完整的投票界面。顶部标题"最终投票 - 谁是凶手?"以大号加粗字体醒目展示。下方是角色列表,每位角色以卡片形式呈现:

ForEach(this.script.characters, (ch: ScriptCharacter) => {
  ListItem() {
    Row() {
      Text('👤').fontSize(28)
      Text(ch.name).fontSize(16).margin({ left: 12 }).layoutWeight(1)
      if (this.voteTarget === ch.id) {
        Text('✓').fontSize(20).fontColor('#FF6B35')
      }
    }
    .padding(12)
    .backgroundColor(this.voteTarget === ch.id ? '#FFF3E0' : Color.White)
    .borderRadius(8)
    .onClick(() => { this.voteTarget = ch.id })
  }
}, (ch: ScriptCharacter) => ch.id)

点击角色卡片将 voteTarget 设为该角色的 id。选中状态的视觉反馈包括:卡片背景变为浅橙色与右侧出现橙色勾选标记。这种即时视觉反馈让玩家清晰确认自己的选择。

5.2 voteForTarget 的单选机制

voteTarget 的赋值是覆盖式的——每次点击新角色卡片都会替换之前的选择,确保最终只有一个投票目标。这符合传统剧本杀"每人一票"的规则设计。覆盖式赋值的实现极为简洁:.onClick(() => { this.voteTarget = ch.id }),无需先清除旧选择再设置新选择,因为字符串赋值本身就是原子性的替换操作。

在多人在线场景中,投票机制需要扩展为:收集所有玩家的投票、统计票数、处理平票等。当前的单机实现是完整多人投票的子集——投票UI与选择逻辑可复用,仅需扩展提交与统计环节。submitVote() 方法目前将 killerRevealed 硬编码为"赵收藏家",未来应从剧本数据中读取 script.killerId 字段。

5.3 确认投票的防误触设计

底部"确认投票"按钮仅在 voteTarget 非空时可用(.enabled(this.voteTarget !== '')),防止误触空提交。按钮点击触发 submitVote() 方法,将 phase 切换为 RESULT_REVEAL,设置 killerRevealed 为凶手名称,并更新 phaseTitle 为"真相揭晓"。这种"先选择后确认"的两步操作模式,给予了玩家"犹豫与反悔"的空间——在投票这件具有不可逆感的事情上,双确认是必要的交互仪式。

5.4 投票的社交心理学

投票环节在剧本杀中的意义远超"选择凶手"这一表面功能。它是一个社交压力的集中释放点——每位玩家必须公开表态,无法回避。这种公开表态迫使玩家将此前模糊的推理倾向转化为明确的判断,而判断的对错在真相揭晓时将得到验证。投票结果不仅揭示凶手,更揭示了每位玩家的推理能力与社交影响力——投对了的人获得认知成就感,投错了的人则需要反思是被误导还是推理失误。这种"判断→验证→反思"的心理闭环,是剧本杀最持久的情感记忆。


6. NPC-TTS 语音播报

NPC台词的语音播报是剧本杀沉浸感的重要增强手段。在NearPlay中,initTts() 方法尝试初始化 HarmonyOS 的文字转语音引擎,使NPC的叙述不仅以文字形式呈现,更能以语音形式"朗读"给玩家,营造更强的临场感。

6.1 TTS引擎初始化

initTts() 方法在组件的 aboutToAppear() 生命周期中被调用:

initTts(): void {
  try {
    const extraParam: Record<string, Object> = {
      'style': 'interaction-broadcast',
      'locate': 'CN',
      'name': 'NPCReader'
    }
    const initParams: Record<string, string | number | Record<string, Object>> = {
      'language': 'zh-CN', 'person': 0, 'online': 1, 'extraParams': extraParam
    }
    this.ttsAvailable = true
  } catch (e) {
    this.ttsAvailable = false
  }
}

初始化参数指定了中文语言、交互播报风格与在线模式。ttsAvailable 标志位记录TTS引擎的可用性——如果设备不支持TTS或初始化失败,系统优雅降级为纯文字展示模式,不影响游戏核心流程。这种"功能增强而非功能依赖"的设计原则,确保了游戏在不同设备上的一致体验。

6.2 speakText() 播报机制

speakText() 方法是NPC台词播放的核心驱动:

speakText(text: string): void {
  this.npcSpeakingText = text
  this.isNpcSpeaking = true
  setTimeout(() => {
    this.isNpcSpeaking = false
    this.npcSpeakingText = ''
  }, text.length * 200 + 500)
}

该方法执行三个操作:设置显示文本、标记NPC正在说话、启动定时器在播放结束后清除状态。播放时长的计算基于"每字200毫秒"的估算语速,外加500毫秒缓冲。这一时长设计既保证了文字展示的足够阅读时间,又模拟了语音朗读的自然节奏。在TTS可用时,实际语音播放时长可能与估算略有偏差,但差异在可接受范围内——UI上的文字展示时间略长于语音播放,确保玩家不会在语音结束后立即失去文字参考。

6.3 NPCLine 的 emotion 字段

每条NPC台词都携带一个 emotion 字段,取值如’nervous’、‘scared’、‘serious’、‘normal’。在当前实现中,该字段未被TTS引擎消费——所有台词以相同的语调播放。但这一预留字段为未来的情感化播报奠定了基础:通过将emotion映射到TTS引擎的语速、音调与音量参数,可以实现"紧张的NPC说话更快更急促"、"害怕的NPC声音颤抖"等戏剧化效果。

6.4 TTS引擎的生命周期管理

aboutToDisappear() 中对TTS引擎的清理至关重要:

if (this.ttsEngine !== null) {
  try {
    const engine = this.ttsEngine as Record<string, Function>
    engine['shutdown']()
  } catch (e) {
  }
}

TTS引擎占用系统音频资源,若不在组件销毁时释放,可能导致后续页面的音频功能异常(如语音输入无法录音)。try-catch 包裹确保了即使引擎状态异常,清理过程也不会崩溃。ttsEngine 使用 Object | null 类型而非具体的引擎类型,是因为ArkTS的严格模式对动态类型创建有限制——引擎实例在 initTts() 中通过间接方式创建,类型信息无法静态确定。

6.5 语音播报的沉浸感价值

在剧本杀的叙事体验中,NPC不仅是信息的传递者,更是"故事世界"的在场者。纯文字的NPC台词虽然传达了信息内容,但缺乏"声音"这一维度的情感渲染。语音播报的引入,使NPC从"屏幕上的文字"变为"空间中的声音",显著提升了叙事的沉浸感。尤其在多人围坐一设备、或佩戴耳机的场景下,NPC的语音播报能够创造"旁白"式的叙事氛围,让玩家更自然地代入故事情境。


7. 剧本文件导入 fileIo.readTextSync()

剧本杀的核心内容是剧本本身,而一个封闭的内置剧本库无法满足玩家对多样化剧本的需求。NearPlay 通过 importScriptFile() 方法实现了外部剧本文件的导入功能,让玩家可以使用自定义剧本进行游戏。

7.1 DocumentViewPicker 文件选择

importScriptFile() 的第一步是调用 HarmonyOS 的 DocumentViewPicker 让用户选择文件:

importScriptFile(): void {
  try {
    const context = getContext(this) as common.UIAbilityContext
    const docPicker = new picker.DocumentViewPicker()
    const selectOptions = new picker.DocumentSelectOptions()
    docPicker.select(selectOptions).then((uris: Array<string>) => {
      if (uris.length > 0) {
        const file = fs.openSync(uris[0], fs.OpenMode.READ_ONLY)
        const content = fs.readTextSync(uris[0])
        fs.closeSync(file)
        this.importedScriptText = content
        this.showImportPreview = true
      }
    })
  } catch (e) {
  }
}

DocumentViewPicker 是 HarmonyOS 提供的系统级文件选择器,它以模态界面的形式呈现,让用户浏览设备上的文件并选择目标文件。select() 方法返回一个Promise,resolve值为选中文件的URI数组。当前实现未对文件类型进行过滤(DocumentSelectOptions 使用默认配置),理想情况下应添加后缀过滤(如仅显示 .txt.json 文件),避免用户误选非文本文件导致读取失败。

7.2 fileIo.readTextSync() 同步读取

获取文件URI后,使用 fs.readTextSync() 同步读取文件内容为字符串。这是 @kit.CoreFileKit 提供的便捷方法,直接将文件内容解码为UTF-8文本。readTextSync 的同步特性在当前场景下是可接受的——剧本文件通常在数十KB以内,读取速度足以在用户无感知的情况下完成。对于超大文件,应改用异步读取并添加进度指示。

读取后立即通过 fs.closeSync(file) 关闭文件描述符,避免资源泄漏。这是一个容易忽略但至关重要的步骤——未关闭的文件描述符会占用系统资源,在低内存设备上可能导致后续文件操作失败。

7.3 导入预览与确认

读取的内容暂存于 importedScriptText,同时 showImportPreview 被设为 true,触发预览UI的渲染:

Text(this.importedScriptText.substring(0, 200))
  .fontSize(12)
  .fontColor('#666666')
  .maxLines(5)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

预览展示前200个字符,帮助玩家确认文件内容是否正确。maxLines(5)textOverflow 确保长文件不会撑爆预览区域。确认按钮调用 confirmImport(),将 phase 切换为 ROLE_ASSIGN,进入角色分配阶段。

7.4 未来:结构化剧本格式

当前的文件导入仅读取原始文本,未进行结构化解析。importedScriptText 的内容并未被转化为 ScriptData 对象——confirmImport() 只是简单地切换阶段,游戏仍使用 MockScriptData 的内置数据。完整的导入流程需要定义标准化的剧本描述格式(如JSON Schema),导入时解析为 ScriptData 对象,验证必填字段与数据完整性。这是多剧本库扩展的关键前置工作。


8. 回合轮转与 phaseDesc

剧本杀讨论阶段的回合轮转机制,是游戏从"自由混沌"走向"有序交流"的技术保障。没有轮转约束的讨论容易陷入沉默或某位玩家独占话语权的极端情况,而严格的轮转计时确保每位参与者拥有平等的发言机会。

8.1 startPlayerSpeech() 轮转引擎

startPlayerSpeech() 是逐人讨论计时的核心驱动方法:

startPlayerSpeech(): void {
  if (this.speakerTimerId !== -1) {
    clearInterval(this.speakerTimerId)
  }
  const characters = this.script.characters
  if (this.speakerOrderIndex >= characters.length) {
    this.showClues()
    return
  }
  const current = characters[this.speakerOrderIndex]
  if (current !== undefined) {
    this.currentSpeakerName = current.name
    this.speakerTimer = 15
  }
  this.speakerTimerId = setInterval(() => {
    this.speakerTimer--
    if (this.speakerTimer <= 0) {
      clearInterval(this.speakerTimerId)
      this.speakerTimerId = -1
      this.speakerOrderIndex++
      this.startPlayerSpeech()
    }
  }, 1000)
}

方法执行流程分为四个步骤:清理旧定时器、轮转终止判断、设置当前发言者、启动倒计时。这种递归式设计避免了复杂的循环控制逻辑,每位发言者的计时管理完全独立。

8.2 15秒计时的权衡

15秒的单人发言时长是经过权衡的设计选择。在剧本杀讨论中,过短的时间(如5秒)无法充分表达推理思路,过长的时间(如60秒)则导致其他玩家等待疲劳。15秒大致对应3-4句完整陈述,既足以表达一个推理观点,又保持了讨论的紧凑节奏。speakerTimer 从15递减到0时,颜色从灰色变为红色,利用人类对红色的注意力偏向形成紧迫感暗示。

8.3 phaseDesc 与阶段描述

phaseTitle 状态变量在每个阶段转换时更新,为UI提供当前阶段的标题文字。各阶段的 phaseTitle 值为:SELECT_SCRIPT → “选择剧本”、ROLE_ASSIGN → “角色分配”、ACT_INTRO → 幕标题、FINAL_VOTE → “最终投票”、RESULT_REVEAL → “真相揭晓”。这一变量将阶段的语义信息与UI呈现解耦——同一个 ScriptPhase 值可以对应不同的中文描述,取决于剧本数据的具体内容。


9. VoiceInputHelper 在剧本杀中的应用

NearPlay 剧本杀集成了 HarmonyOS 原生语音识别能力,通过 VoiceInputHelper 类实现语音转文字功能,让玩家在讨论阶段可以用语音代替键盘输入。在剧本杀的讨论环节,语音输入具有独特的体验价值——它更接近"说话"这一自然表达方式,比键盘打字更符合"当面讨论"的沉浸感。

9.1 集成方式

语音输入按钮的点击事件触发以下调用链:

this.voiceHelper.startListening((text: string) => {
  this.handleVoiceResult(text)
})

handleVoiceResult() 接收识别文本,修剪空白后通过 ChatMsg.of() 创建消息对象,追加到 chatMessages 数组。消息的 sender 使用 currentSpeakerName,确保语音输入在聊天记录中被正确归因于当前发言者。

9.2 语音输入的条件限制

语音按钮仅在 speakerOrderIndex === 0 时显示,即仅当前玩家(第一位发言者)拥有语音输入权限。这一条件限制反映了单机模式的简化假设——当前设备仅代表一位玩家。在多人在线场景中,应改为判断当前发言者是否为本地玩家,使每位轮到的发言者都能使用语音输入。

9.3 VoiceInputHelper 的安全机制

VoiceInputHelper 内置了多项安全机制:canSpeak 标志控制是否允许启动录音、isListening 标志防止重复启动、60秒最大录音时长限制、引擎创建与操作的 try-catch 包裹、以及 destroy() 方法确保组件销毁时释放所有资源。这些机制共同保障了语音输入功能的稳定性与资源安全性。destroy()ScriptKillGameaboutToDisappear() 中被调用,确保页面退出时语音引擎被正确关闭。


10. 逐人讨论计时机制

自由讨论阶段的逐人发言计时是剧本杀游戏"秩序感"的技术保障。

10.1 定时器清理

方法入口首先检查 speakerTimerId !== -1,若为真则调用 clearInterval() 清除上一位发言者的计时器。这一防御性清理至关重要——若前一轮计时器未被正确清除,将导致多个计时器并行运行,造成倒计时加速或跨发言者计时混乱。

10.2 轮转终止与阶段过渡

if (this.speakerOrderIndex >= characters.length) 判断当前轮转是否已遍历所有角色。当 speakerOrderIndex 递增到等于角色数组长度时,方法调用 showClues() 进入线索揭示阶段。

10.3 讨论启动与初始化

startDiscuss() 是讨论阶段的入口方法:

startDiscuss(): void {
  this.phase = ScriptPhase.FREE_DISCUSS
  this.speakerOrderIndex = 0
  this.chatMessages = []
  const characters = this.script.characters
  if (characters.length > 0) {
    this.startPlayerSpeech()
  }
}

聊天记录清空确保每幕讨论独立——不同幕的发言记录不会混淆,玩家需要依靠记忆和笔记跨幕整理线索。


11. 讨论阶段UI

讨论阶段是玩家交互最密集的环节。

11.1 整体布局

DiscussUI() 采用垂直 Column 布局,从上到下分为四个区域:标题行(含倒计时)、发言者指示行、聊天消息列表、输入区域。

11.2 倒计时显示

Text(`${this.speakerTimer}s`)
  .fontSize(16)
  .fontColor(this.speakerTimer < 10 ? '#F44336' : '#999999')

当剩余时间低于10秒时,字体颜色从灰色变为红色,形成紧迫感的视觉暗示。

11.3 ChatMsg 数据模型

class ChatMsg {
  sender: string = ''
  content: string = ''

  static of(sender: string, content: string): ChatMsg {
    const m = new ChatMsg()
    m.sender = sender; m.content = content
    return m
  }
}

简洁的双字段设计满足当前需求。工厂方法 of() 提供了便捷的对象创建方式。


12. 计时器管理

剧本杀游戏涉及两类定时器:发言计时器(speakerTimerId)与NPC播放定时器(隐式 setTimeout)。

12.1 speakerTimerId 生命周期

speakerTimerId 的完整生命周期为:初始值-1 → setInterval 赋值 → clearInterval 归零 → 回收为-1。组件销毁时的清理至关重要——aboutToDisappear() 中的 clearInterval(this.speakerTimerId) 确保了资源安全释放。

12.2 双重管理的挑战

两类定时器的管理策略不同,增加了管理复杂度。未来应考虑建立统一的定时器注册表,在 aboutToDisappear() 中批量清除。


13. 边界情况

在剧本杀游戏的生命周期中,多种边界情况需要妥善处理,以确保游戏体验的稳定性与一致性。

13.1 空剧本数据

如果 script.acts 为空数组,currentActIndex 初始值0已经越界,showActIntro() 中的边界检查 if (this.currentActIndex < this.script.acts.length) 会阻止越界访问,但游戏会卡在 ROLE_ASSIGN 阶段无法推进。应在 assignRoles() 中添加空剧本检测,提示用户选择有效剧本。

13.2 NPC无台词

如果 script.npcs 为空或某幕没有匹配的NPC台词,playNpcLines()sorted 数组为空,currentNpcLineIndex 从0开始即超出范围,方法直接调用 startDiscuss(),跳过NPC叙述阶段。这是合理的降级行为——没有NPC的幕直接进入讨论,但应在UI上给出"本幕无NPC证词"的提示。

13.3 单角色剧本

如果 script.characters 只有一个角色,讨论阶段的轮转毫无意义——只有一人发言,没有交互对象。startDiscuss() 中的 characters.length > 0 检查允许单角色进入讨论,但体验上应给出"此剧本为单人本"的提示或跳过讨论。

13.4 组件销毁时序

玩家在任意阶段退出页面,aboutToDisappear() 必须清理所有资源:speakerTimerId 的定时器、ttsEngine 的语音引擎、voiceHelper 的语音识别引擎。NPC播放使用的 setTimeout 未被追踪,在组件销毁后仍可能触发回调,访问已失效的组件状态。未来应引入定时器注册表,统一管理所有异步回调。

13.5 重复点击防护

在投票阶段,玩家可能快速点击多个角色卡片。voteTarget 的覆盖式赋值保证了最终值是最后一次点击的角色ID,不会产生多选。但"确认投票"按钮点击后缺乏防重复提交机制——submitVote() 可能被连续调用多次。应添加 isVoted 标志,提交后禁用按钮。

13.6 TTS初始化失败

在某些设备上,TTS引擎可能不可用。initTts()try-catchttsAvailable 设为 false,后续UI仅显示文字不显示语音播放提示。游戏核心流程不依赖TTS,降级为纯文字模式后仍可正常运行。

13.7 文件导入异常

importScriptFile() 中的 try-catch 捕获了所有异常,但未向用户展示错误信息。如果用户选择了非文本文件(如图片),readTextSync() 可能抛出编码错误,此时应弹出提示"不支持的文件格式"而非静默失败。


14. 未来:多剧本库

当前 NearPlay 剧本杀内置了一个示例剧本"消失的画家"。完整的剧本杀体验需要一个丰富的剧本库支撑。

14.1 剧本数据持久化

当前剧本数据通过 MockScriptData.getScript() 硬编码返回。多剧本库需要将剧本数据持久化存储,可选用 HarmonyOS 的关系型数据库或首选项存储。

14.2 剧本格式标准化

外部导入功能已支持 .txt 文件读取,但缺乏结构化解析。应定义标准化的剧本描述格式(如JSON Schema),包含 ScriptData 的所有字段结构。

14.3 剧本商店与社区

远期可构建剧本商店功能:创作者上传自定义剧本,经过审核后发布,玩家评分与评论。

14.4 剧本难度与推荐

基于玩家历史数据,可构建推荐算法:新手推荐低难度本,资深玩家推荐硬核本。

14.5 剧本编辑器

为剧本创作者提供可视化编辑器,支持角色设定、NPC台词编排、幕结构设计、线索分配等操作。编辑器应提供实时预览功能,让创作者在编辑的同时看到玩家视角的效果——NPC台词的播放节奏、线索的揭示时机、角色秘密的分配方式等。此外,编辑器还应内置剧本验证逻辑:检查每个角色是否分配了秘密、每幕是否至少有一条公开线索、NPC台词是否按幕正确分组、线索与角色之间是否存在逻辑冲突等。这种"编辑器即测试工具"的设计理念,能显著降低剧本创作中的逻辑错误率,提升社区内容的质量基线。

14.6 剧本复盘与教学

真相揭晓阶段目前仅展示凶手身份与各角色故事,缺乏系统性的推理复盘。理想的复盘应包括:每条线索指向的逻辑推理链、各角色秘密与案件的关联分析、正确的推理路径与常见推理误区的对比。这种"复盘教学"不仅能帮助玩家提升推理能力,还能增强剧本杀的"教育价值"——玩家在享受推理乐趣的同时,也在锻炼逻辑思维与信息整合能力。复盘数据可以预置于剧本的 ScriptData 中,通过新增 analysis: ScriptAnalysis 字段承载,在真相揭晓后自动展示。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐