剧本杀剧本系统与NPC TTS朗读

1 游戏概述

1.1 剧本杀核心玩法

剧本杀(Script Kill / Murder Mystery Game)是一种以剧情推理为核心的多人角色扮演游戏。在一局游戏中,每位玩家领取一个剧本角色,该角色拥有独特的背景故事、人物关系和不可公开的秘密。游戏的目标是通过阅读剧本、搜集线索、与其他玩家交流讨论,最终推理出隐藏在所有角色中的凶手,或者作为凶手成功隐藏自己的身份。

与传统的狼人杀等社交推理游戏不同,剧本杀更注重剧情的沉浸体验和逻辑推理的严谨性。每个剧本都是一个完整的叙事世界,角色的动机、时间线、人物关系交织成一个复杂的推理网络。玩家不仅需要找出"谁做了什么",还需要理解"为什么这么做",这种深层推理使得剧本杀拥有远超普通社交游戏的叙事深度。

1.2 角色扮演与NPC引导

在NearPlay的剧本杀实现中,角色扮演体验被划分为两个维度:玩家角色与NPC角色。玩家角色由真人玩家操控,每个角色拥有独立的背景故事(backstory)和秘密(secrets),这些信息只在角色分配阶段向对应玩家展示,形成信息不对称的核心博弈基础。

NPC角色则由系统控制,承担剧情引导和线索释放的关键职责。在传统线下剧本杀中,DM(主持人)负责推动剧情、朗读NPC台词、释放线索和掌控节奏。NearPlay通过NPC台词的自动播放与TTS语音朗读来替代DM的角色,使游戏可以在没有真人主持的情况下流畅运行。NPC在每个幕(Act)中按照预设的顺序逐句播放台词,引导玩家关注关键线索、推进剧情发展,并在适当的时间节点释放公共线索和私密线索。

1.3 游戏流程总览

NearPlay剧本杀的完整游戏流程由ScriptPhase枚举定义,包含九个阶段:

  • SELECT_SCRIPT(选择剧本):玩家选择内置剧本或导入外部剧本文件
  • ROLE_ASSIGN(角色分配):系统为每位玩家分配角色,展示角色背景和私密秘密
  • ACT_INTRO(幕介绍):展示当前幕的标题和描述,营造氛围
  • NPC_SPEAK(NPC台词播放):NPC按顺序逐句朗读台词,引导剧情
  • FREE_DISCUSS(自由讨论):玩家轮流发言,交换信息、质疑推理
  • CLUE_REVEAL(线索揭示):展示当前幕的公开线索和私密线索
  • ACT_SUMMARY(幕间过渡):过渡动画,准备进入下一幕
  • FINAL_VOTE(最终投票):所有角色轮流被投票,选出嫌疑人
  • RESULT_REVEAL(真相揭晓):揭示凶手身份,展示所有角色的完整故事和秘密

这一流程设计将线下剧本杀的完整体验数字化,每个阶段都有对应的UI界面和状态管理逻辑,确保游戏体验的连贯性和沉浸感。

1.4 技术架构定位

剧本杀系统在NearPlay的整体架构中属于游戏模块的核心子系统。它依赖ArkUI的状态管理机制(@State装饰器)驱动UI更新,使用ScriptKillModel中定义的数据模型承载剧本内容,通过HarmonyOS的文件选择器API实现外部剧本导入,并利用@kit.CoreSpeechKit的TTS能力为NPC台词提供语音朗读。整个系统以ScriptKillGame组件为入口,将数据模型、文件IO、语音合成、UI渲染等模块有机整合,形成一个完整的剧本杀游戏引擎。


2 剧本数据模型深度解析

2.1 ScriptData——剧本容器

ScriptData是整个剧本杀系统的顶层数据容器,承载一个完整剧本的所有结构化信息。其定义如下:

export class ScriptData {
  id: string = ''
  title: string = ''
  background: string = ''
  characters: ScriptCharacter[] = []
  npcs: ScriptNPC[] = []
  acts: ScriptAct[] = []
}

字段解析:

  • id:剧本的唯一标识符,用于在剧本库中索引和区分不同剧本。当前内置剧本使用's1'作为标识。在未来的剧本商店或在线剧本库中,id将作为远程同步和版本管理的基础键值。设计为string类型而非number,是为了兼容UUID、短链接ID等多种标识方案。

  • title:剧本的显示标题,直接渲染在页面顶栏Text('📜 ${this.script.title}')中。标题是玩家选择剧本时最直观的识别信息,需要兼具吸引力和概括性,如内置剧本的"消失的画家"就暗示了悬疑和艺术的双重主题。

  • background:剧本的背景简介,为整个故事设定时空框架。在当前的UI实现中,background内容被展示在选择剧本页面的描述区域。一段好的背景简介应当包含时间、地点、事件概要和悬念钩子,让玩家在选择阶段就能产生沉浸感。

  • characters:角色数组,类型为ScriptCharacter[]。这是玩家扮演的核心数据,每个元素代表一个可被分配给玩家的角色。角色的数量直接决定了游戏的参与人数上限,4人本意味着最多4名玩家同时参与。

  • npcs:NPC数组,类型为ScriptNPC[]。NPC是系统控制的角色,不分配给玩家,而是通过自动播放台词来推动剧情。一个剧本可以有多个NPC,每个NPC拥有独立的台词序列。

  • acts:幕数组,类型为ScriptAct[]。幕是剧本的时间结构单元,将整个故事划分为若干阶段,每幕有独立的剧情描述、NPC台词和线索集。

工厂方法:

static of(id: string, title: string, bg: string): ScriptData

ScriptData.of()采用静态工厂模式创建实例,而非直接使用构造函数。这一设计选择有两个考量:第一,ArkTS的class不支持重载构造函数,使用工厂方法可以提供多种创建路径;第二,工厂方法的命名参数使创建意图更清晰,ScriptData.of('s1', '消失的画家', '背景...')new ScriptData()后逐一赋值更具可读性。

设计考量: ScriptData采用扁平化的聚合结构,而非深层的继承体系。这是基于HarmonyOS ArkTS的运行时特性考虑——ArkTS对class继承和泛型有严格的限制(arkts-no-unrestricted-generics等规则),扁平的数据类更符合ArkTS的类型系统约束。同时,所有字段都有默认值初始化(空字符串、空数组),确保在任何阶段访问字段都不会产生undefined错误,这是ArkTS空安全设计的重要组成部分。

2.2 ScriptCharacter——玩家角色

export class ScriptCharacter {
  id: string = ''
  name: string = ''
  gender: string = ''
  age: number = 0
  backstory: string = ''
  secrets: string = ''
}

字段解析:

  • id:角色的唯一标识,格式为'c1''c2'等。在角色分配时通过this.myCharacterId匹配对应角色,在投票阶段用于标识投票目标,在真相揭晓阶段作为ForEach的key。id与玩家的映射关系是剧本杀信息不对称机制的实现基础——每个玩家只能看到自己角色的secrets,而无法看到其他角色的secrets。

  • name:角色姓名,如"陈馆长"、“林助理”。姓名是玩家在游戏中的身份标识,出现在讨论区的发言标签、投票列表和真相揭晓页面。命名通常与角色的社会身份相匹配,帮助玩家快速建立角色印象。

  • gender:角色性别,取值为'男''女'。在角色分配UI中与name和age组合展示:Text('🎭 ${name}(${gender},${age}岁)')。性别信息不仅服务于角色扮演的沉浸感,在某些剧本中也可能与剧情逻辑相关。

  • age:角色年龄,number类型。与gender配合构成角色的基本人口学画像。在推理剧本中,年龄可能与时间线推理相关——例如某个角色在案发时是否已成年、是否有可能在特定年份参与过某事件。

  • backstory:角色的公开背景故事。这是玩家建立角色认知的主要信息来源,包含角色的社会身份、与死者的关系、来此的目的等。backstory对所有人可见(在角色分配阶段展示),是玩家讨论时的公共信息基础。在UI中,backstory通过Text(this.script.characters[0].backstory)直接渲染,占据角色卡的大部分空间。

  • secrets:角色的私密秘密。这是剧本杀的核心机制数据——每个角色都有不为人知的秘密,这些秘密既是推理的线索,也可能是被推理的目标。secrets只在角色分配阶段向对应玩家展示(包裹在红色背景的"你的秘密"区域中),在真相揭晓阶段才会对所有玩家公开。在当前实现中,mySecret状态变量存储了当前玩家的secrets,通过this.mySecret = me.secrets赋值获取。

工厂方法:

static of(id: string, name: string, gender: string, age: number, bg: string, secrets: string): ScriptCharacter

6参数工厂方法,按语义顺序排列:身份标识→显示信息→人口学特征→公开信息→私密信息。注意bg参数对应的是backstory字段,命名简化是为了避免工厂方法参数名过长。

设计考量: ScriptCharacter没有isKiller字段。在当前的模型设计中,凶手身份通过硬编码的方式在submitVote()中揭示(this.killerRevealed = '赵收藏家'),而非通过数据模型标识。这一设计是有意为之的——将凶手标识从数据模型中移除,可以防止通过数据遍历或调试工具提前获取凶手信息,增强了游戏的公平性。在未来的版本中,可以考虑将凶手标识加密存储或在服务端管理。

2.3 ScriptNPC——系统角色

export class ScriptNPC {
  id: string = ''
  name: string = ''
  lines: NPCLine[] = []
}

字段解析:

  • id:NPC的唯一标识,如内置剧本中的'n1'。在台词播放逻辑中,通过this.script.npcs.find((n: ScriptNPC) => n.lines.includes(line))反向查找台词所属的NPC,id为这一查找提供了额外的索引能力。

  • name:NPC的显示名称,如"管家老张"。在NPC台词播放UI中,名称显示在说话气泡的头部:Text(this.script.npcs[0].name),在台词文本前以[npc?.name]格式前缀展示。NPC命名通常反映其在故事中的社会角色,帮助玩家理解NPC与案件的关联。

  • lines:NPC的台词数组,类型为NPCLine[]。这是NPC的核心数据——一个NPC可以拥有跨越多个幕的台词,每条台词通过actId关联到具体的幕,通过order定义在幕内的播放顺序。

设计考量: ScriptNPC与ScriptCharacter的根本区别在于控制主体——Character由玩家操控,其发言内容完全由玩家决定;NPC由系统操控,其台词在剧本创作阶段就已预设。这种区分使得NPC可以作为"准DM"使用,在关键剧情节点释放信息、引导推理方向。lines数组的设计允许一个NPC在不同幕中拥有不同数量的台词,提供了灵活的剧情编排能力。

2.4 NPCLine——NPC台词单元

export class NPCLine {
  actId: string = ''
  order: number = 0
  content: string = ''
  emotion: string = 'normal'
}

字段解析:

  • actId:台词所属幕的ID,如'a1'表示第一幕、'a2'表示第二幕。在playNpcLines()方法中,actId是台词过滤的核心依据——只有line.actId === act.id的台词才会在当前幕播放。这一设计使得台词与幕的关系是松耦合的:台词不存储在ScriptAct内部,而是通过actId关联,使得同一个NPC的所有台词集中存储在ScriptNPC.lines中,便于管理和编辑。

  • order:台词在幕内的播放顺序,number类型。在playNpcLines()中,台词首先按actId过滤,然后按order排序:npcLines.sort((a: NPCLine, b: NPCLine) => a.order - b.order)。排序确保了台词的叙事连贯性——NPC必须先说"昨晚十点之后,我就没见过李先生了",再说"我听到二楼工作室传来争吵声",顺序颠倒会破坏叙事逻辑。

  • content:台词的文本内容,这是玩家实际看到和听到的信息。content的设计需要兼顾两个维度:叙事性(推动剧情、营造氛围)和信息性(释放推理线索)。好的NPC台词应当在叙事中隐含线索,而非直接告知答案。内置剧本中的"我听到二楼工作室传来争吵声,但没听清内容"就是一个典型例子——它确认了争吵的发生,但不揭示争吵的内容和参与者,留下推理空间。

  • emotion:台词的情感标签,默认值为'normal'。可选值包括'normal''nervous'(紧张)、'scared'(恐惧)、'serious'(严肃)等。emotion字段为未来的UI增强提供了数据基础——可以根据情感标签调整气泡颜色、字体样式、甚至TTS的语调和语速。当前实现中emotion已存储在数据中,但UI层面尚未根据emotion差异化渲染,这是一个可扩展的方向。

工厂方法:

static of(actId: string, order: number, content: string, emotion: string): NPCLine

4参数工厂方法,参数顺序遵循"定位→排序→内容→修饰"的逻辑层次:actId定位所属幕,order确定播放顺序,content提供台词文本,emotion附加情感标签。

设计考量: NPCLine作为独立的数据类而非ScriptNPC的内部类型,是基于复用和扩展的考虑。独立的NPCLine可以被不同的NPC引用(虽然当前实现中lines存储在NPC内部),可以被排序算法统一处理,也可以在未来的编辑器中独立增删改查。emotion字段的引入则为多模态表达(视觉+听觉+情感)预留了数据通道。

2.5 ScriptAct——幕结构

export class ScriptAct {
  id: string = ''
  title: string = ''
  description: string = ''
  publicClues: string[] = []
  privateClues: Record<string, string> = {}
  npcLineIds: string[] = []
  duration: number = 600
}

字段解析:

  • id:幕的唯一标识,如'a1''a2'。id是NPCLine.actId的关联键,在playNpcLines()中用于过滤当前幕的NPC台词。

  • title:幕的标题,如"第一幕:失踪"、“第二幕:线索”。标题在ACT_INTRO阶段全屏展示:this.phaseTitle = act.title,是每幕开场的氛围营造元素。幕标题的命名通常遵循"序号+主题"的格式,既标明时间顺序,又暗示本幕的核心事件。

  • description:幕的描述文本,在ACT_INTRO阶段居中展示。描述的作用是DM的开场白——为玩家设定当前幕的时空背景和关键事件。内置剧本第一幕的描述"画展开幕当天清晨,众人发现李墨不在房间,工作室的门半开着…"既交代了时间(清晨)、事件(失踪)和细节(工作室门半开),又制造了悬念(门为什么开着?)。

  • publicClues:公开线索数组,类型为string[]。公开线索在CLUE_REVEAL阶段向所有玩家展示,是推理的公共信息基础。内置剧本第一幕的公开线索包括"李墨的床铺是整整齐齐的,没有睡过的痕迹"和"工作室地面上有打翻的颜料"。每条线索都是一个独立的推理支点——"没有睡过的痕迹"暗示李墨可能深夜离开或被带走,"打翻的颜料"暗示工作室中可能发生过争执或意外。

  • privateClues:私密线索字典,类型为Record<string, string>。key为角色ID,value为该角色在本幕获得的私密线索。privateClues实现了剧本杀的另一个核心机制——信息不对称。不同角色可能在不同幕中获得不同的私密线索,这些线索只有持有者知晓,玩家需要在讨论中决定是否分享自己的私密线索。当前UI实现中,私密线索通过mySecret统一展示,未来可以按幕区分展示。

  • npcLineIds:NPC台词ID引用数组,类型为string[]。这是ScriptAct与NPCLine之间的关联桥梁——记录了本幕中应播放的NPC台词的ID序列。在当前实现中,playNpcLines()通过遍历所有NPC的lines并按actId过滤来获取当前幕的台词,npcLineIds提供了另一种更直接的关联方式,可以在需要精确控制台词播放顺序时使用。

  • duration:幕的持续时长,单位为秒,默认值为600(10分钟)。duration为未来的自动幕切换机制提供了时间参数——当讨论时间耗尽时自动进入下一阶段。当前实现中,讨论阶段使用固定的倒计时(speakerTimer),duration字段为更灵活的时间控制预留了接口。

工厂方法:

static of(id: string, title: string, desc: string, duration: number): ScriptAct

4参数工厂方法,仅初始化核心字段。publicClues、privateClues和npcLineIds在创建后单独赋值,这种分步初始化的方式使得数据构建更加灵活——特别是publicClues和privateClues的数量因幕而异,无法在工厂方法中统一参数化。

设计考量: ScriptAct同时持有publicClues和privateClues两种线索,而非将线索统一存储在ScriptData层级。这一设计使得线索与幕的关联关系更加明确——每幕的线索是独立的,不同幕的线索不会混淆。这也符合线下剧本杀的实际玩法——DM在每幕结束时释放本幕的线索,而非一次性释放所有线索。线索的渐进释放是剧本杀推理节奏控制的关键手段:过早释放关键线索会使推理失去悬念,过晚释放则会使玩家缺乏推理素材。

2.6 ScriptPhase——游戏阶段枚举

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
}

ScriptPhase是整个剧本杀游戏的状态机定义。9个枚举值按数值递增排列,严格对应游戏的推进顺序。在build()方法中,phase作为条件分支的依据:

if (this.phase === ScriptPhase.SELECT_SCRIPT) {
  this.SelectScriptUI()
} else if (this.phase === ScriptPhase.ROLE_ASSIGN) {
  this.RoleAssignUI()
} // ... 其他阶段

每个phase的转换都有明确的触发条件和时序约束。例如,NPC_SPEAK阶段只有在ACT_INTRO阶段的3秒延迟之后才会进入,这种延迟设计模拟了DM开场白后的自然停顿,增强了沉浸感。阶段之间的转换不是即时的,而是通过setTimeout实现有节奏的推进,这是将线下剧本杀的"节奏感"数字化的关键实现。

2.7 MockScriptData——内置剧本工厂

export class MockScriptData {
  static getScript(): ScriptData
}

MockScriptData的getScript()方法是内置剧本"消失的画家"的完整数据构建入口。它通过级联调用各数据类的工厂方法,构建出包含4个角色、1个NPC、2幕剧情的完整剧本实例。这一设计将剧本数据与游戏逻辑完全解耦——更换剧本只需要提供新的getScript()实现或导入外部剧本文件,而无需修改任何游戏逻辑代码。


3 内置剧本"消失的画家"完整内容展示

3.1 剧本概览

标题:消失的画家

背景:在一座古堡中,著名画家李墨在画展开幕前夜离奇消失……

这是一个4人本悬疑推理剧本,围绕画家李墨的神秘失踪展开。故事发生在古堡美术馆,时间跨度为画展开幕前一晚至开幕当天。4位玩家分别扮演与李墨有不同关联的角色,1位NPC(管家老张)负责引导剧情和释放线索。

3.2 角色详情

角色一:陈馆长(c1)

  • 性别:男,年龄:45岁
  • 公开背景:你是美术馆馆长,与李墨有多年合作。你知道他的画作中隐藏着秘密。
  • 私密秘密:你曾伪造过李墨的早期作品。
  • 推理定位:陈馆长是与李墨关系最深的人物之一,"多年合作"暗示了利益纠葛,"画作中隐藏着秘密"则提供了推理方向——画中到底藏着什么秘密?而他的私密秘密"伪造过李墨的早期作品"则是一个强烈的杀人动机:如果李墨发现了伪造行为,陈馆长将身败名裂。

角色二:林助理(c2)

  • 性别:女,年龄:28岁
  • 公开背景:你是李墨的私人助理,跟随他已有3年。
  • 私密秘密:你暗恋李墨,当晚你去过他的工作室。
  • 推理定位:林助理看似是最接近李墨的人,"跟随3年"和"暗恋"构成了情感动机。而"当晚去过工作室"直接将她置于案发现场,成为重要嫌疑人。但暗恋动机通常不足以构成杀人行为,这为推理增加了复杂性——她去工作室的真实目的是什么?

角色三:赵收藏家(c3)

  • 性别:男,年龄:55岁
  • 公开背景:你是知名收藏家,出天价想买李墨的新作。
  • 私密秘密:你曾经威胁过李墨,如果不卖画就揭露他的秘密。
  • 推理定位:赵收藏家是本剧本的真凶(在submitVote中硬编码为this.killerRevealed = '赵收藏家')。他的公开背景看似只是商业行为,但私密秘密揭示了他的威胁行为——"揭露秘密"的威胁可能升级为灭口行为。年龄55岁暗示了他在艺术圈的资历和影响力,威胁的可信度由此增强。

角色四:周记者(c4)

  • 性别:女,年龄:30岁
  • 公开背景:你是艺术杂志记者,来采访画展。
  • 私密秘密:你发现了李墨画中的暗号,正在调查。
  • 推理定位:周记者作为局外人角色,其"调查"身份使她成为线索发现者。她的私密秘密"发现画中暗号"与陈馆长的"画作中隐藏着秘密"形成了交叉验证——画中确实有秘密,但暗号的内容和指向需要通过推理揭示。周记者的推理定位更接近侦探角色,而非嫌疑人。

3.3 幕描述与NPC台词全文

第一幕:失踪(a1)

描述:画展开幕当天清晨,众人发现李墨不在房间,工作室的门半开着……

NPC台词(管家老张):

  • Order 1(nervous):“各位,昨晚十点之后,我就没见过李先生了。”
  • Order 2(scared):“我听到二楼工作室传来争吵声,但没听清内容。”

公开线索:

  1. 李墨的床铺是整整齐齐的,没有睡过的痕迹
  2. 工作室地面上有打翻的颜料

第二幕:线索(a2)

描述:管家在工作室发现了更多线索,每个人也开始有了自己的发现……

NPC台词(管家老张):

  • Order 1(serious):“我在李先生的书桌里发现了这封信,似乎是写给某人的。”
  • Order 2(nervous):“还有,画室的那幅画…好像被人动过了。”

公开线索:

  1. 李墨手机中最后一条消息是发给一个未存储号码的
  2. 画框背面贴着一张纸条

3.4 线索分析与推理链

第一幕线索分析:

"床铺整整齐齐"这一线索与NPC台词"昨晚十点之后没见过"形成呼应——李墨可能根本就没有就寝,或者是在十点前就已离开房间。"打翻的颜料"与"争吵声"形成因果链:争吵过程中可能打翻了颜料,说明工作室是案发现场或争执现场。

第二幕线索分析:

"最后一条消息发给未存储号码"指向李墨在失踪前与某个不为人知的联系人通信,这个号码可能属于四位角色之一。"画框背面的纸条"则与周记者发现的"画中暗号"和陈馆长知道的"画作中隐藏秘密"形成三重线索链——画作是本案的核心证据载体。

"信件"与"画被动过"两个NPC台词线索揭示了案发后的关键行为:有人在李墨失踪后搜查过他的物品(动过画),而信件可能是李墨留下的关键证词或遗书。

推理结论: 赵收藏家威胁李墨(私密秘密)→李墨拒绝卖画→赵收藏家在当晚前往工作室争吵(对应NPC听到的争吵声)→争吵升级为暴力行为(打翻颜料)→赵收藏家带走或威胁李墨→李墨失踪。信件可能是李墨预感危险后留下的线索,画中的暗号则是他一贯的信息保护方式。


4 外部剧本导入实现

4.1 需求背景

内置剧本虽然提供了即开即用的体验,但剧本杀的核心吸引力在于剧本的多样性。同一个剧本只能玩一次——知道凶手后便失去了推理的悬念。因此,支持外部剧本导入是剧本杀系统长期运营的必备能力。

NearPlay通过HarmonyOS的文件选择器API(picker.DocumentViewPicker)和文件读取API(fileIo)实现了从设备存储中选择并读取文本格式的剧本文件,为用户提供了自定义剧本内容的通道。

4.2 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) {
  }
}

逐步解析:

  1. 获取上下文getContext(this) as common.UIAbilityContext获取当前组件的UIAbility上下文,这是创建DocumentViewPicker的前提条件。在ArkUI组件中,getContext(this)返回的是与当前组件关联的上下文对象,需要显式转型为UIAbilityContext以使用其完整功能。

  2. 创建选择器new picker.DocumentViewPicker()创建文档选择器实例。DocumentViewPicker是HarmonyOS提供的系统级文件选择组件,它调用了系统自带的文件管理器UI,用户可以在其中浏览和选择文件。这一方式的优势在于无需自行实现文件浏览器,直接复用系统组件,确保了交互体验的一致性和文件访问权限的合规性。

  3. 配置选项new picker.DocumentSelectOptions()创建默认选择配置。当前未对文件类型进行过滤(如限定.txt后缀),这是一个可优化的方向——未来可以通过设置selectOptions的文件类型过滤,引导用户选择正确的剧本文件格式。

  4. 发起选择docPicker.select(selectOptions)启动文件选择流程。这是一个异步操作,返回Promise<Array>,其中string数组包含用户选中文件的URI。URI是HarmonyOS沙箱文件系统的资源定位符,格式通常为file://...

  5. 处理结果:在then回调中,首先检查uris.length > 0确保用户确实选择了文件而非取消操作。然后取第一个URI(uris[0])进行后续处理。

4.3 fileIo.readTextSync的URI陷阱

文件读取部分是外部剧本导入中最容易出错的环节:

const file = fs.openSync(uris[0], fs.OpenMode.READ_ONLY)
const content = fs.readTextSync(uris[0])
fs.closeSync(file)

关键陷阱:readTextSync的参数是URI字符串,而非文件描述符fd。

在HarmonyOS的fileIo API中,readTextSync有两个重载签名:

  • readTextSync(filePath: string, ...):接受文件路径字符串
  • readTextSync(fd: number, ...):接受文件描述符

当传入uris[0](一个URI字符串如file:///data/.../script.txt)时,readTextSync将其解析为文件路径而非fd。这与openSync返回的file对象中的fd属性是不同的概念。

常见的错误写法是:

const file = fs.openSync(uris[0], fs.OpenMode.READ_ONLY)
const content = fs.readTextSync(file.fd)  // 错误!fd是number类型,但readTextSync期望的fd参数签名可能有不同的offset/length要求

在当前实现中,代码选择了readTextSync(uris[0])的方式,直接将URI作为路径参数传入。这种写法之所以可行,是因为DocumentViewPicker返回的URI在当前应用的沙箱权限范围内可以被解析为有效路径。

但值得注意的是,代码同时调用了openSyncreadTextSync(uris[0])——openSync打开文件获取file对象,但readTextSync并没有使用file.fd,而是重新通过URI读取。这实际上进行了两次文件打开操作。file对象仅用于closeSync调用,确保文件描述符被正确释放。更高效的写法可以是:

const content = fs.readTextSync(uris[0])
// 无需open/close

但保留open/close的写法更为安全,因为它确保了文件资源在使用后被正确释放,避免文件描述符泄漏。

4.4 预览与确认流程

文件读取成功后,进入预览确认流程:

this.importedScriptText = content
this.showImportPreview = true

importedScriptText状态变量存储了读取到的完整剧本文本。这一变量同时用于两个用途:预览展示和后续解析。在UI中,预览展示通过Text(this.importedScriptText.substring(0, 200))截取前200个字符,配合.maxLines(5).textOverflow({ overflow: TextOverflow.Ellipsis })确保预览区域不会过长。

showImportPreview是一个布尔标志位,控制"确认导入"按钮和提示文本的可见性。在初始状态下为false,文件读取成功后设为true,用户确认导入后通过confirmImport()重置为false。

预览确认流程的设计考量:

  1. 防止误导入:用户可能选错文件,预览机制让用户在导入前确认内容正确性。
  2. 内容感知:前200字符的预览通常足够展示剧本标题和开头,用户可以据此判断是否为正确的剧本文件。
  3. 状态隔离:importedScriptText在SELECT_SCRIPT阶段使用,与script数据变量隔离,确认导入后才将文本解析为ScriptData,避免了不完整数据污染游戏状态。

4.5 确认导入与阶段切换

confirmImport(): void {
  this.showImportPreview = false
  this.phase = ScriptPhase.ROLE_ASSIGN
  this.phaseTitle = '角色分配'
}

confirmImport()的实现目前较为简洁——它直接切换到ROLE_ASSIGN阶段,而没有将importedScriptText解析为ScriptData。这意味着当前的外部剧本导入流程尚处于基础框架阶段,完整的实现还需要添加文本解析逻辑,将纯文本格式的剧本转换为结构化的ScriptData对象。

文本解析的设计方向:

未来的解析器需要定义剧本文本的格式规范,例如:

#title: 剧本标题
#background: 背景描述

##characters
- id:c1 name:角色A gender:男 age:30 backstory:背景 secrets:秘密

##acts
- id:a1 title:第一幕 description:描述
  publicClues: 线索1|线索2
  npc:n1 lines: 台词1|台词2

这种基于标记的文本格式既便于人工编写,也便于程序解析。解析器的核心任务是将文本中的结构化标记映射到ScriptData的字段,构建完整的数据对象。解析过程应当具备容错能力——对格式错误的文本给出明确的错误提示,而非静默失败。


5 NPC TTS朗读系统

5.1 TTS在剧本杀中的角色

在传统线下剧本杀中,DM(主持人)不仅是规则执行者,更是叙事的核心载体——DM朗读NPC台词时的语气、停顿和情感表达,直接影响玩家的沉浸感。将DM的语音功能数字化,是剧本杀线上化的关键挑战之一。

NearPlay通过HarmonyOS的@kit.CoreSpeechKit提供的TTS(Text-to-Speech)能力,为NPC台词实现了语音朗读功能。当TTS可用时,NPC台词不仅以文本形式展示,还会同步以语音方式朗读出来,模拟DM的口头叙述。这一功能显著提升了游戏的沉浸感和便利性——玩家无需一直盯着屏幕阅读文本,而是可以像听故事一样跟随NPC的叙述。

5.2 initTts()——TTS引擎初始化

NPC TTS朗读

private ttsEngine: Object | null = null

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
  }
}

参数解析:

  • language: ‘zh-CN’:设置TTS引擎的语言为简体中文。剧本杀的剧本内容是中文的,zh-CN确保语音合成的发音规则和分词逻辑适用于中文文本。语言设置直接影响文字到音素的转换、声调处理和断句逻辑,设置错误会导致语音输出不可理解。

  • person: 0:选择语音角色编号。person参数对应TTS引擎预置的不同音色,0通常为默认音色。在剧本杀场景中,音色的选择应考虑叙事风格——沉稳的音色更适合推理叙述,而活泼的音色更适合喜剧剧本。未来可以根据ScriptNPC的特征(性别、年龄)动态选择音色,为不同NPC赋予不同的声音特征。

  • online: 1:启用在线语音合成模式。online=1表示优先使用云端语音合成服务,其语音质量远高于离线模式。云端TTS支持更丰富的音色、更自然的韵律和更准确的发音,但需要网络连接。离线模式(online=0)作为降级方案,语音质量较低但无需网络。

  • extraParams:扩展参数字典,包含三个子参数:

    • style: ‘interaction-broadcast’:语音风格设置为"交互广播"模式。这一风格适合叙述性内容的朗读,语速适中、韵律平稳,与DM朗读剧本的场景匹配。
    • locate: ‘CN’:区域设置为中国,影响数字、日期、货币等特殊文本的读法。
    • name: ‘NPCReader’:为TTS引擎实例命名,便于在多引擎场景中管理和区分。

try-catch与ttsAvailable标志:

initTts()的核心设计是"尽力尝试,优雅降级"。TTS引擎的初始化可能因多种原因失败:设备不支持CoreSpeechKit、网络不可用(online模式需要网络)、系统资源不足等。通过try-catch包裹,确保初始化失败不会导致应用崩溃,而是将ttsAvailable设为false,后续逻辑将根据此标志选择纯文本模式。

ttsAvailable是@State装饰的状态变量,其变更会触发UI刷新。在NPC台词播放UI中:

if (this.ttsAvailable) {
  Text('🔊 正在朗读...')
    .fontSize(12)
    .fontColor('#2196F3')
    .margin({ top: 8 })
}

"正在朗读"提示仅在TTS可用时显示,纯文本模式下不显示该提示,避免误导用户。

ttsEngine的类型设计:

private ttsEngine: Object | null = null

ttsEngine的类型被声明为Object | null,而非具体的TTS引擎类型。这一设计出于ArkTS类型系统的考虑——CoreSpeechKit的TTS引擎类型可能在不同的SDK版本中有不同的接口定义,使用Object类型配合运行时类型断言(this.ttsEngine as Record<string, Function>)可以适配不同版本。在aboutToDisappear()中:

const engine = this.ttsEngine as Record<string, Function>
engine['shutdown']()

通过Record<string, Function>类型的断言,动态调用shutdown方法,避免了静态类型检查对SDK特定接口的依赖。

5.3 speakText()——文本朗读实现

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

语速计算公式深度解析:

text.length * 200 + 500是speakText中最关键的设计决策。这个公式计算了NPC台词的"朗读时长"——即isNpcSpeaking保持为true的毫秒数。

公式拆解:

  • text.length * 200:每个字符200毫秒。中文文本的朗读速度通常为每分钟150-300字,换算为每字200-400毫秒。200ms/字对应每分钟300字的语速,属于中等偏快的朗读速度,适合信息密度较高的剧本杀台词。
  • +500:固定500毫秒的缓冲时间。这个缓冲有两个作用:第一,为TTS引擎的启动延迟留出余量——从调用speak到实际出声可能有几百毫秒的初始化时间;第二,为台词之间的停顿提供自然间隔——说完一句话后短暂的停顿比连续播放更有叙事节奏感。

示例计算:

  • “各位,昨晚十点之后,我就没见过李先生了。”(18字符)→ 18 * 200 + 500 = 4100ms ≈ 4.1秒
  • “我听到二楼工作室传来争吵声,但没听清内容。”(19字符)→ 19 * 200 + 500 = 4300ms ≈ 4.3秒

这些时长与正常朗读速度基本匹配,确保了动画状态与语音输出的同步性——isNpcSpeaking在TTS朗读期间保持true,朗读完成后设为false。

isNpcSpeaking动画状态:

isNpcSpeaking是控制NPC说话动画的状态变量。在UI中,它有两个作用:

  1. 顶栏指示器if (this.isNpcSpeaking) { Text('🔊') }——当NPC正在说话时,页面顶栏显示扬声器图标,提醒玩家注意NPC台词。
  2. 说话气泡if (this.isNpcSpeaking)控制整个NPC说话气泡区域的显示,包含NPC名称、台词文本和朗读状态提示。

isNpcSpeaking的true→false切换由setTimeout触发,确保动画状态与实际朗读时长同步。如果TTS引擎的朗读速度与公式计算不一致,可能出现动画提前结束(语音仍在播放但气泡已消失)或动画延迟结束(语音已结束但气泡仍显示)的问题。200ms/字的参数可以在实际测试中根据TTS引擎的真实语速进行微调。

5.4 TTS调用与降级逻辑

在当前的speakText()实现中,TTS的实际调用逻辑被简化——方法主要负责设置UI状态和计算时序,而非直接调用TTS引擎的speak方法。完整的TTS调用链路应该是:

speakText(text: string): void {
  this.npcSpeakingText = text
  this.isNpcSpeaking = true
  if (this.ttsAvailable && this.ttsEngine !== null) {
    try {
      const engine = this.ttsEngine as Record<string, Function>
      engine['speak'](text)
    } catch (e) {
      // TTS调用失败,降级为纯文本
    }
  }
  setTimeout(() => {
    this.isNpcSpeaking = false
    this.npcSpeakingText = ''
  }, text.length * 200 + 500)
}

这一实现模式体现了三层降级策略:

  1. 第一层:检查ttsAvailable标志,如果TTS不可用则跳过语音调用
  2. 第二层:检查ttsEngine是否为null,如果引擎未初始化则跳过
  3. 第三层:try-catch包裹实际调用,如果运行时出错则静默降级

无论TTS是否成功调用,UI层面的文本展示和时序控制都会正常执行,确保了视觉体验的一致性。TTS只是视觉体验的增强,而非替代——即使没有语音,玩家仍然可以通过阅读文本获取完整的游戏信息。

5.5 VoiceInputHelper——语音输入辅助

除了TTS语音输出,剧本杀系统还集成了语音输入能力:

private voiceHelper: VoiceInputHelper = new VoiceInputHelper()

VoiceInputHelper封装了语音识别(Speech-to-Text)功能,在自由讨论阶段为玩家提供语音输入选项:

Button('🎤')
  .onClick(() => {
    this.voiceHelper.startListening((text: string) => {
      this.handleVoiceResult(text)
    })
  })

语音输入与TTS语音输出构成了剧本杀的双向语音交互:NPC通过TTS向玩家输出信息,玩家通过语音识别向系统输入发言内容。这种双向语音交互使得NearPlay的剧本杀体验更接近线下的面对面交流,减少了对键盘输入的依赖,特别适合移动端的使用场景。


6 NPC台词播放流程

6.1 playNpcLines()递归播放机制

NPC台词的播放是剧本杀游戏中最具技术复杂度的环节之一。它需要在正确的幕中、按照正确的顺序、以正确的节奏逐句播放NPC台词,同时协调TTS语音朗读、UI动画状态和阶段切换等多个子系统。

playNpcLines(): void {
  const act = this.script.acts[this.currentActIndex]
  if (act === undefined) {
    this.startDiscuss()
    return
  }
  const npcLines: NPCLine[] = []
  for (const npc of this.script.npcs) {
    for (const line of npc.lines) {
      if (line.actId === act.id) {
        npcLines.push(line)
      }
    }
  }
  const sorted = npcLines.sort((a: NPCLine, b: NPCLine) => a.order - b.order)
  if (this.currentNpcLineIndex < sorted.length) {
    const line = sorted[this.currentNpcLineIndex]
    const npc = this.script.npcs.find((n: ScriptNPC) => n.lines.includes(line))
    this.speakText(`[${npc?.name ?? 'NPC'}]: ${line.content}`)
    setTimeout(() => {
      this.currentNpcLineIndex++
      this.playNpcLines()
    }, line.content.length * 200 + 1500)
  } else {
    this.startDiscuss()
  }
}

流程拆解:

  1. 幕验证:首先获取当前幕act,如果act为undefined(currentActIndex越界),直接进入讨论阶段。这是递归的终止条件之一。

  2. 台词收集:遍历所有NPC的所有台词,按line.actId === act.id过滤出属于当前幕的台词。这种跨NPC的全局收集方式确保了当有多个NPC在同一幕中说话时,台词可以被统一排序和播放。

  3. 排序npcLines.sort((a, b) => a.order - b.order)按order升序排列。排序是必要的,因为台词来自不同NPC的lines数组,收集后的顺序取决于NPC的遍历顺序而非台词的逻辑顺序。

  4. 逐句播放:通过currentNpcLineIndex索引当前播放位置。每次播放一条台词,调用speakText()展示文本并触发TTS朗读。

  5. 递归调度:通过setTimeout在当前台词的播放时长(line.content.length * 200 + 1500)后递归调用playNpcLines(),播放下一条台词。1500ms的延迟大于speakText中的500ms缓冲,为台词间留出了1秒的自然停顿。

  6. 播放结束:当currentNpcLineIndex达到sorted.length时,所有当前幕的台词已播放完毕,调用startDiscuss()进入自由讨论阶段。

6.2 延迟计算分析

playNpcLines中的延迟公式为line.content.length * 200 + 1500,而speakText中的延迟公式为text.length * 200 + 500。两者的差异在于:

  • speakText的500ms缓冲仅考虑单条台词内的时序
  • playNpcLines的1500ms缓冲考虑了台词间的停顿——额外的1000ms为玩家消化上一条台词的信息提供了阅读时间

内置剧本台词时序计算:

第一幕(a1):

  • 台词1:“各位,昨晚十点之后,我就没见过李先生了。”(18字)→ 18*200+1500 = 5100ms = 5.1秒后播放下一条
  • 台词2:“我听到二楼工作室传来争吵声,但没听清内容。”(19字)→ 19*200+1500 = 5300ms = 5.3秒后进入讨论

第二幕(a2):

  • 台词1:“我在李先生的书桌里发现了这封信,似乎是写给某人的。”(22字)→ 22*200+1500 = 5900ms
  • 台词2:“还有,画室的那幅画…好像被人动过了。”(16字)→ 16*200+1500 = 4700ms

6.3 NPC反向查找

const npc = this.script.npcs.find((n: ScriptNPC) => n.lines.includes(line))

在播放每条台词时,需要确定该台词属于哪个NPC,以便在播放文本前添加[NPC名称]前缀。由于NPCLine存储在ScriptNPC.lines数组中,而非直接持有对NPC的引用,因此需要通过反向查找来确定归属关系。

n.lines.includes(line)使用引用相等性比较——因为NPCLine对象是通过工厂方法创建的独立实例,存储在特定NPC的lines数组中,同一实例不会出现在多个NPC的lines中,因此引用比较是可靠的。

npc?.name ?? 'NPC'的nullish coalescing提供了防御性处理——如果查找失败(理论上不应发生),使用默认的’NPC’名称,确保播放文本始终包含说话者标识。


7 TTS降级策略

7.1 三方应用无TTS能力的现实

HarmonyOS的TTS能力由@kit.CoreSpeechKit提供,但这一能力并非在所有设备和应用环境中都可用。以下是常见的TTS不可用场景:

  1. 设备限制:部分低端设备或IoT设备可能不搭载CoreSpeechKit运行时,无法提供TTS服务。
  2. 网络依赖:online=1模式需要网络连接访问云端TTS服务,离线环境下语音合成不可用。
  3. 权限问题:三方应用可能因权限配置不足而无法初始化TTS引擎——CoreSpeechKit的使用可能需要在module.json5中声明特定权限。
  4. SDK版本差异:不同版本的HarmonyOS SDK中CoreSpeechKit的API可能有变化,导致运行时初始化失败。
  5. 资源竞争:其他应用可能正在占用TTS引擎,导致当前应用无法获取引擎实例。

7.2 ttsAvailable标志驱动的降级

NearPlay的TTS降级策略以ttsAvailable为核心标志位,贯穿整个TTS使用链路:

  • 初始化阶段:initTts()在try-catch中初始化TTS引擎,成功则ttsAvailable=true,失败则ttsAvailable=false
  • 播放阶段:speakText()检查ttsAvailable,决定是否调用TTS引擎
  • UI展示阶段:NpcSpeakUI中根据ttsAvailable条件渲染"正在朗读"提示

这种"标志位驱动"的降级策略简单而有效——在代码的任何位置,只需检查ttsAvailable即可知道TTS是否可用,无需重复探测或维护复杂的状态机。

7.3 纯文本fallback的实现

当TTS不可用时,NPC台词仍然以纯文本方式完整展示。speakText()方法的核心逻辑——设置npcSpeakingText、切换isNpcSpeaking状态、计算时序——在TTS不可用时同样执行,只是跳过了实际的语音合成调用。

speakText(text: string): void {
  this.npcSpeakingText = text        // 始终设置文本
  this.isNpcSpeaking = true          // 始终启动动画
  setTimeout(() => {                 // 始终计算时序
    this.isNpcSpeaking = false
    this.npcSpeakingText = ''
  }, text.length * 200 + 500)
}

这意味着在纯文本模式下:

  • NPC说话气泡正常显示,包含NPC名称和台词文本
  • 顶栏的🔊图标正常显示(表示NPC正在说话)
  • 台词的播放时序保持不变,为玩家提供充足的阅读时间
  • 唯一缺失的是"正在朗读…"的蓝色提示文字

纯文本fallback确保了TTS不可用时游戏仍可完整进行——玩家通过阅读获取NPC台词中的所有信息,推理和讨论流程不受影响。这种"功能降级而非体验降级"的设计原则是NearPlay在多设备、多环境适配中的核心策略。

7.4 未来增强方向

当前的TTS降级策略是二元的——要么TTS可用,要么纯文本。未来可以考虑更细粒度的降级:

  1. 离线TTS:当online模式不可用时,尝试切换到offline模式,提供低质量但可用的语音输出
  2. 预录制音频:为内置剧本的NPC台词预录制音频文件,作为TTS的高质量替代
  3. 用户选择:在设置中提供TTS模式选项(自动/仅在线/仅离线/关闭),让用户根据网络和偏好自主选择
  4. 实时降级:在TTS播放过程中检测到错误时,自动切换到纯文本模式,而非等待下一次初始化

8 aboutToDisappear清理

8.1 资源清理的必要性

在ArkUI组件的生命周期中,aboutToDisappear()是组件销毁前的最后一个回调,也是释放资源的最后机会。在ScriptKillGame组件中,有三类资源需要在组件销毁时清理:

  1. 定时器:speakerTimerId对应的setInterval定时器
  2. TTS引擎:ttsEngine持有的语音合成引擎实例
  3. 语音输入:voiceHelper持有的语音识别资源

如果不在aboutToDisappear中清理这些资源,可能导致:

  • 定时器在组件销毁后继续运行,尝试访问已销毁的状态变量,导致运行时异常
  • TTS引擎未正确关闭,占用系统音频资源,影响其他应用的语音功能
  • 语音识别资源泄漏,持续占用麦克风权限

8.2 清理实现

aboutToDisappear(): void {
  if (this.speakerTimerId !== -1) {
    clearInterval(this.speakerTimerId)
  }
  if (this.ttsEngine !== null) {
    try {
      const engine = this.ttsEngine as Record<string, Function>
      engine['shutdown']()
    } catch (e) {
    }
  }
  this.voiceHelper.destroy()
}

定时器清理:speakerTimerId初始值为-1,表示定时器未激活。在startPlayerSpeech()中,每次创建新的setInterval前都会先清除旧的定时器。aboutToDisappear中的清理是最终保障——确保无论组件在哪个阶段被销毁,活跃的定时器都会被清除。

TTS引擎关闭:engine.shutdown()是TTS引擎的标准关闭方法,释放引擎占用的系统资源。这一调用被try-catch包裹,因为shutdown()可能因引擎状态异常而抛出异常(例如引擎已在其他地方被关闭)。在清理代码中使用空的catch块是合理的——清理阶段的异常不应影响组件的销毁流程,忽略这些异常是最安全的处理方式。

语音输入销毁:voiceHelper.destroy()释放语音识别器占用的麦克风权限和系统资源。这一调用没有try-catch包裹,因为VoiceInputHelper的destroy()方法应当内部处理异常,确保调用者无需关心内部错误。

8.3 清理顺序的设计

清理的顺序遵循"从活跃到静态"的原则:

  1. 先清除定时器——定时器是最活跃的资源,每秒触发一次回调,优先清除可以最快停止资源消耗
  2. 再关闭TTS引擎——TTS引擎可能在朗读过程中被销毁,shutdown需要等待当前朗读完成或中断
  3. 最后销毁语音输入——语音输入是被动资源,仅在用户主动点击麦克风时激活,销毁操作最轻量

这一顺序确保了在清理过程中,最可能导致运行时异常的资源(频繁触发的定时器)最先被停止,降低了清理过程中出现竞态条件的风险。

8.4 注意事项

  • 空catch块的合理性:在aboutToDisappear中使用空catch块是被接受的实践。清理阶段的异常通常不影响应用的后续运行,记录日志即可,无需向上传播。
  • private vs @State:speakerTimerId、ttsEngine和voiceHelper都是private成员而非@State变量,它们不会触发UI更新,也不需要在清理时重置为初始值。组件销毁后,这些private成员会随组件实例一起被垃圾回收。
  • 多次调用安全:aboutToDisappear在组件生命周期中只会被调用一次,因此无需考虑重复清理的问题。但各个清理操作本身应当是幂等的——clearInterval对无效ID无副作用,shutdown对已关闭的引擎应安全返回,destroy对已销毁的helper应无操作。

附录:核心类与接口速查

类名 用途 关键字段
ScriptData 剧本容器 id, title, background, characters, npcs, acts
ScriptCharacter 玩家角色 id, name, gender, age, backstory, secrets
ScriptNPC 系统角色 id, name, lines
NPCLine NPC台词 actId, order, content, emotion
ScriptAct 幕结构 id, title, description, publicClues, privateClues, npcLineIds, duration
ScriptPhase 游戏阶段枚举 SELECT_SCRIPT~RESULT_REVEAL (0~8)
MockScriptData 内置剧本工厂 getScript(): ScriptData

核心方法速查:

方法 功能 关键逻辑
initTts() 初始化TTS引擎 try-catch, ttsAvailable标志
speakText(text) 播放NPC台词 text.length*200+500ms时序
playNpcLines() 递归播放NPC台词 actId过滤+order排序+递归setTimeout
importScriptFile() 导入外部剧本 DocumentViewPicker+readTextSync(URI)
confirmImport() 确认导入 showImportPreview→ROLE_ASSIGN
aboutToDisappear() 资源清理 clearInterval+engine.shutdown()+voiceHelper.destroy()
Logo

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

更多推荐