剧本杀剧本系统与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):“我听到二楼工作室传来争吵声,但没听清内容。”
公开线索:
- 李墨的床铺是整整齐齐的,没有睡过的痕迹
- 工作室地面上有打翻的颜料
第二幕:线索(a2)
描述:管家在工作室发现了更多线索,每个人也开始有了自己的发现……
NPC台词(管家老张):
- Order 1(serious):“我在李先生的书桌里发现了这封信,似乎是写给某人的。”
- Order 2(nervous):“还有,画室的那幅画…好像被人动过了。”
公开线索:
- 李墨手机中最后一条消息是发给一个未存储号码的
- 画框背面贴着一张纸条
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) {
}
}
逐步解析:
-
获取上下文:
getContext(this) as common.UIAbilityContext获取当前组件的UIAbility上下文,这是创建DocumentViewPicker的前提条件。在ArkUI组件中,getContext(this)返回的是与当前组件关联的上下文对象,需要显式转型为UIAbilityContext以使用其完整功能。 -
创建选择器:
new picker.DocumentViewPicker()创建文档选择器实例。DocumentViewPicker是HarmonyOS提供的系统级文件选择组件,它调用了系统自带的文件管理器UI,用户可以在其中浏览和选择文件。这一方式的优势在于无需自行实现文件浏览器,直接复用系统组件,确保了交互体验的一致性和文件访问权限的合规性。 -
配置选项:
new picker.DocumentSelectOptions()创建默认选择配置。当前未对文件类型进行过滤(如限定.txt后缀),这是一个可优化的方向——未来可以通过设置selectOptions的文件类型过滤,引导用户选择正确的剧本文件格式。 -
发起选择:
docPicker.select(selectOptions)启动文件选择流程。这是一个异步操作,返回Promise<Array>,其中string数组包含用户选中文件的URI。URI是HarmonyOS沙箱文件系统的资源定位符,格式通常为file://...。 -
处理结果:在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在当前应用的沙箱权限范围内可以被解析为有效路径。
但值得注意的是,代码同时调用了openSync和readTextSync(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。
预览确认流程的设计考量:
- 防止误导入:用户可能选错文件,预览机制让用户在导入前确认内容正确性。
- 内容感知:前200字符的预览通常足够展示剧本标题和开头,用户可以据此判断是否为正确的剧本文件。
- 状态隔离: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引擎初始化

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中,它有两个作用:
- 顶栏指示器:
if (this.isNpcSpeaking) { Text('🔊') }——当NPC正在说话时,页面顶栏显示扬声器图标,提醒玩家注意NPC台词。 - 说话气泡:
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)
}
这一实现模式体现了三层降级策略:
- 第一层:检查ttsAvailable标志,如果TTS不可用则跳过语音调用
- 第二层:检查ttsEngine是否为null,如果引擎未初始化则跳过
- 第三层: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()
}
}
流程拆解:
-
幕验证:首先获取当前幕
act,如果act为undefined(currentActIndex越界),直接进入讨论阶段。这是递归的终止条件之一。 -
台词收集:遍历所有NPC的所有台词,按
line.actId === act.id过滤出属于当前幕的台词。这种跨NPC的全局收集方式确保了当有多个NPC在同一幕中说话时,台词可以被统一排序和播放。 -
排序:
npcLines.sort((a, b) => a.order - b.order)按order升序排列。排序是必要的,因为台词来自不同NPC的lines数组,收集后的顺序取决于NPC的遍历顺序而非台词的逻辑顺序。 -
逐句播放:通过currentNpcLineIndex索引当前播放位置。每次播放一条台词,调用speakText()展示文本并触发TTS朗读。
-
递归调度:通过setTimeout在当前台词的播放时长(
line.content.length * 200 + 1500)后递归调用playNpcLines(),播放下一条台词。1500ms的延迟大于speakText中的500ms缓冲,为台词间留出了1秒的自然停顿。 -
播放结束:当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不可用场景:
- 设备限制:部分低端设备或IoT设备可能不搭载CoreSpeechKit运行时,无法提供TTS服务。
- 网络依赖:online=1模式需要网络连接访问云端TTS服务,离线环境下语音合成不可用。
- 权限问题:三方应用可能因权限配置不足而无法初始化TTS引擎——CoreSpeechKit的使用可能需要在module.json5中声明特定权限。
- SDK版本差异:不同版本的HarmonyOS SDK中CoreSpeechKit的API可能有变化,导致运行时初始化失败。
- 资源竞争:其他应用可能正在占用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可用,要么纯文本。未来可以考虑更细粒度的降级:
- 离线TTS:当online模式不可用时,尝试切换到offline模式,提供低质量但可用的语音输出
- 预录制音频:为内置剧本的NPC台词预录制音频文件,作为TTS的高质量替代
- 用户选择:在设置中提供TTS模式选项(自动/仅在线/仅离线/关闭),让用户根据网络和偏好自主选择
- 实时降级:在TTS播放过程中检测到错误时,自动切换到纯文本模式,而非等待下一次初始化
8 aboutToDisappear清理
8.1 资源清理的必要性
在ArkUI组件的生命周期中,aboutToDisappear()是组件销毁前的最后一个回调,也是释放资源的最后机会。在ScriptKillGame组件中,有三类资源需要在组件销毁时清理:
- 定时器:speakerTimerId对应的setInterval定时器
- TTS引擎:ttsEngine持有的语音合成引擎实例
- 语音输入: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 清理顺序的设计
清理的顺序遵循"从活跃到静态"的原则:
- 先清除定时器——定时器是最活跃的资源,每秒触发一次回调,优先清除可以最快停止资源消耗
- 再关闭TTS引擎——TTS引擎可能在朗读过程中被销毁,shutdown需要等待当前朗读完成或中断
- 最后销毁语音输入——语音输入是被动资源,仅在用户主动点击麦克风时激活,销毁操作最轻量
这一顺序确保了在清理过程中,最可能导致运行时异常的资源(频繁触发的定时器)最先被停止,降低了清理过程中出现竞态条件的风险。
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() |
-剧本杀剧本系统与NPC TTS朗读&spm=1001.2101.3001.5002&articleId=163126101&d=1&t=3&u=d2d352125f5144158ba92fccb31734ba)
1626

被折叠的 条评论
为什么被折叠?



