对白 | 基于Wwise与Unreal Engine的语音设计

Wwise 技巧和工具

语音对白是当今电子游戏的基础内容之一,它不仅可以让玩家将角色与特定声线相关联,通过语音还可以实现玩家与角色的共情。复杂的对话系统,更可以实现通过玩家行为来动态影响角色的对话内容或方式,从而进一步增强沉浸感。

比如玩家乐善好施,NPC们则以礼相待;反之玩家丧尽天良,NPC们揭竿而起。这就是传说中的动态对话。

在本演示中,我们将重点围绕叙述性语音对白,以及在WwiseUnreal Engine中的整合而展开Wwise有一套专门的动态对话整合功能,但在此,我们将尝试用其他方式对语音进行驱动。本贴主要涉及以下三方面内容:

1、通过Sequence Containers,实现不受玩家行为影响的基本对话;

2、适用于多语言版本游戏的Wwise语音本地化系统;

3、通过Switch Containers,实现受玩家行为影响的动态对话。

通过本演示,您将了解Wwise Sequence和Switch Containers相关功能,Unreal Engine的Wwise相关蓝图,如:Event Posting、Delays、Switch、State切换等。

另外,本人也撰写了Unity版本博客,更参阅这里

Wwise - Sequence Containers & 本地化语音对白

演示一,我们来设计一个指挥官角色旁白,为玩家提供引导。一共有5句相关语音,需要按顺序依次播放。

1、“喂?能听到我说话吗?”

2、“听着,有些要事儿需要你执行。”

3、“请务必克服困难。”

4、“翻过栅栏,到拱门下,然后进入绿色通道。注意别搞错了,别去红色通道。”

5、“如果你能搞定,那就帮了我们大忙了。通话完毕。”

不管怎样,这段对话在游戏开始阶段触发时都不会有任何变化,所以可以借助Sequence Containers来实现。同时,利用Wwise的语音本地化系统可以让玩家按照各自偏好,设置不同语言。

首先,我们进入Wwise的Audio选项卡。在Actor-Mixer Hierarchy下,新建一个名为MissionBriefing的Sequence Container (Ctrl + Shift + Alt + Q)。接着,将语音文件添加到Container内。您可通过右键点击Container并选择"Import Audio Files…" (Shift + i)或直接将语音资源拖入Container中完成操作。

在Audio File Importer中,将"Import as"选项从"Sound SFX"改为"Sound Voice"。然后点击"Import"。

Sound Voices与Sound SFX类似,区别在于前者允许单个对象导入多种语言的语音文件。当更改语言设置时,所有Sound Voices将随之切换到对应语言的语音资源。

我们已经导入了所有的英语语音文件,接下来要为工程添加其他语言。执行此操作,请移至顶部栏的Project,选择Languages (Shift + J)。在Language Manager中,您可以添加、删除和重命名语言,甚至可以全局更改各语言的Gain设置。点击Add按钮,在弹框中输入西班牙语。点击“Okay”,并在随后的警告提示中确认所做的更改。

点击任意Sound Voices对象,在Contents Editor中可以看到,现在已有英语和西班牙语两个部分。前面已经导入了英语语音文件,接下来通过将对应语音资源拖到Contents Editor的西班牙语字段来导入西班牙语语音。对以上5个文件执行相同操作。

播放任意Sound Voices对象,将触发默认语言设置下的语音文件。若将默认语言切换为西班牙语(Wwise界面左上角处),再次播放对象,则将触发西班牙语字段中导入的语音资源。这是语言切换的一种快捷方式。

接下来让我们看一下Sequence Container。选择对象后,查看Contents Editor及其右侧的Playlist部分。默认情况下,Sequence Containers并不会直接播放其内的声音对象,而只会按照规则触发位于Playlist中的对象。按您希望的播放顺序将Sound Voices从Contents Editor拖到Playlist中。

除非想进一步编辑声音,在Audio模块的设置告一段落。进入Events选项卡,在Default Work Unit中新建两个事件:

1、一个“Play”行为事件,用来触发我们的Sequence Container,命名为Play_MissionBriefing。

2、一个“Reset Playlist”行为事件,该行为将把Sequence Container的播放列表重置回起始的Sound Object,命名为Reset_MissionBriefing。

注意:本演示中实践的动态对话系统,并没有使用Events选项卡的Dynamic Dialogue模块。

第二个事件的目的是确保每当这段对话触发时,总是从Sequence Container的起始语音开始播放。它主要作为保险存在。

接下来新建一个Soundbank,将以上两个事件导入其中。在顶部栏“Layouts”下选择Soundbank布局,或按快捷键F7。在Soundbank Manager中,选择New新建Soundbank并命名为Main。

在Soundbank Manager中选择新建的Soundbank,并将事件Play_MissionBriefing和Reset_MissionBriefing从Event Viewer拖到Soundbank Editor中。

在Soundbank Manager中,选中新建的Soundbank,平台(Windows,Mac等)以及语言复选框。最后,点击“Generate Selected”按钮。

Wwise - 动态对话

在玩家被指挥官赋予任务后,他们要在一定时间内完成任务。而根据任务的完成情况,指挥官会给予不同的回应。开始之前,让我们先来锁定变量,这些变量将决定指挥官对玩家说什么。首先,看一下玩家的3个任务目标:

1、越过障碍;

2、穿过拱门;

3、进入绿色通道,不要进入红色通道。

综上所述,这项任务的完成情况共有5种组合方式。

1、玩家没有完成任何任务目标;(Fail、Fail、Fail)

2、玩家只越过障碍;(Pass、Fail、Fail)

3、玩家越过障碍,抵达拱门;(Pass、Pass、Fail)

4、玩家越过障碍,穿过拱门,进入Red通道;(Pass、Pass、Red)

5、玩家越过障碍,穿过拱门,进入Green通道。(Pass、Pass、Green)

据此得出我们要定位的三个变量:障碍、拱门和通道。同时这也是Switches和States功能的落脚点。

若工程仍位于Soundbank布局,请选择“Layouts -> Designer”或按F5返回Designer布局。

进入Project Explorer的Game Syncs选项卡,在Switch模块的Default Work Unit中新建3个Switch Groups: Hurdle、Archway、和Path。对于Hurdle和Archway,分别新增两个Switches: Fail和Pass。对于Path Switch Group,新增3个Switches: Fail、Red、和Green。

返回Audio选项卡。在Actor-Mixer Hierarchy的Default Work Unit (Ctrl + Shift + Alt + W)下新建一个Switch Container,命名为MissionResult。

选择新建的Switch Container并查看Property Editor右侧的“Switch”设置。我们要用这个Container检查玩家是否通过了Hurdle测试。若失败,玩家将听到指挥官表示失望的语音;若通过,玩家将进入接下来的Archway测试。

为此,首先将Switch Group设置为Hurdle,并将Default Switch/State设置为Fail。接下来需要用合适的“对象”来分别指派给Assigned Objects下的Pass和Fail Switches。

将“Hurdle Failed”条件需要触发的音频文件导入MissionResult Switch Container中。然后在MissionResult Container中新建另一个嵌套Switch Container,命名为Archway。

接下来将这两个对象从Contents Editor分别移到对应的Assigned Objects中:“Hurdle Failed”到“Fail”,“Archway” Container到“Pass”。

同上我们将在Archway Switch Container中重复此过程。将Switch Group设置为Archway,并将Default Switch/State设置为Fail。将“Archway Failed”音频导入Archway Container,并创建另一个名为Path的嵌套Switch Container。将这两个对象分别指派到对应的Assigned Objects中:“Archway Failed”到“Fail”,“Path” Container到“Pass”。

再次重复以上整合过程,区别是最后这组共有3个Switches。在Path Switch Container内,将Switch Group设置为Path,Default Switch/State设置为Fail。

导入最终的3个音频文件:Path Failed,Path Red,Path Green。并将3个文件分别指派给对应Assigned Objects。

让我们简单回顾一下以上逻辑。若您未通过Hurdle测试,则不会触发其他Switch Containers;将直接播放失败消息。若通过,则会进入下一轮Archway测试。若在这一轮失败,将不会触发Path Switch Container,而是直接播放Archway失败消息。若通过了Archway测试,则将触发最终的Path Switch Container,该Container将检查玩家是否有进入红色或绿色通道,抑或是完全没有进入任何通道。最终所有三种结果都将触发特定的音频文件。

完成以上设计后,准备对应的事件就很简单了。可以直接在Audio选项卡中新建事件。只需右键单击MissionResult Switch Container,进入New Event,选择Play。这将自动生成一个名为Play_MissionResult的Play行为事件。

按F7返回Soundbank布局,并将新的Play_MissionResult事件从Event Viewer拖到Soundbank Editor中。同上生成Soundbank,并确保您的Wwise工程已保存。

Unreal Engine - 序列语音对白整合

接下来将对之前组织在Sequence Container中的语音对白进行整合。通常来说,这个工作并不复杂;将语音按顺序依次触发5次即可。但如何才能知道当前处在播放状态的音频将在何时结束,以便进行下一个文件的触发?又如何在每个音频之间加入适当的延迟呢?我们可以通过事件回调和协同路由来实现以上两个诉求。

首先,加载Bank。右键点击Unreal Engine的Content Browser,选择“Audiokinetic -> Audiokinetic Bank”。新建Bank的命名需与Wwise工程保持一致(Main)。

接下来,确保Wwise Picker窗口可见。若没有,请移至顶部栏并选择“Window -> Wwise Picker”。在该窗口,您可以检索所有Wwise组件。若窗口内容未与Wwise工程同步,请确保在Wwise中重新生成Soundbank,保存Wwise工程,并点击Unreal Engine Wwise Picker窗口中的“Populate”按钮。

进入Event文件夹并将3个相关事件拖到Content Browser中。为了使文件结构清晰有序,将事件放置在Content Browser中统一的文件夹内维护管理会很有帮助。右键点击所有事件,选择“Group into Soundbank”。搜索选择刚才新建的Bank,并点击Select。

最后,右键点击Bank,选择Generate Selected Soundbank,并点击Generate。

现在可以开始脚本编写了。在视口上方的Blueprints选项卡下,选择“Open Level Blueprint”。在Event视图中,您会注意到多个带有红色标题的框。我们将只使用“Event BeginPlay”,其余部分可以忽略。

在Event BeginPlay右侧的白色箭头“Exec Pin”处点击并牵引出一个新节点。选择“Audiokinetic -> Actor -> Post Event”,也可以在搜索栏中直接搜索“Post Event”。

生成新节点后,我们需要将两个值与之关联:需要触发的Ak Event以及Event注册所在的Actor。事件简单 – 只需进入“Select Asset”下拉列表并选择之前新建的“Reset_MissionBriefing”事件即可。

下面添加Actor,考虑到在本演示的所有可能情况中,Actor都将是玩家本身。那么接下来就需要获取玩家信息,点击蓝色“Actor” 引脚并牵引出一个新节点,搜索选择“Get Player Character”。

重复类似步骤。点击Post Event节点的Exec引脚并牵引出一个新节点,搜索选择“Post and Wait for End of Event”。这个便携功能将等待配置事件执行完成后再继续执行脚本的其余部分。

AkEvent选择“Play_MissionBriefing”事件,并可将前面的“Get Player Character”节点与此功能的Actor相连接。

到目前为止,我们实现了对Sequence Container播放列表的重置,并触发了该Container的第一句语音对白。接下来还需要触发事件4次(且只触发4次)。其次,需要在每句语音之间增加一个合适的延迟 —— 让我们从这个功能继续展开。

在“Post and Wait For End of Event”的Exec引脚处,搜索选择Delay功能。这个简易功能会将脚本的其余部分延迟一定Duration,将其值设为1,当然也可按需取任意值。

下面我们需要新建一个整数变量。用此变量作为计数器来记录事件的触发次数。一旦事件触发满五次,则需要停止后续的触发。

要新建变量,请移至My Blueprint窗口的Variables选项卡,点击 + 号。将新变量命名为NarrationCount。在每次新建变量后,及时“Compile” Blueprint是个好习惯。通过编译新变量可以进一步编辑更多变量的值。

在右侧的Details窗口中,更改Variable Type。在Variable Type的下拉列表中选择Integer。变量编辑完成。

从Variables选项卡中,将新变量拖到Event Graph中,并将其放置在Delay节点下方。接下来可以选择Get或Set变量的值。

1、GET变量的当前值(在本例中,值为0)。

2、SET变量为NEW值(可将值设为1526434568)。

这里我们选择Get Narration Count。接下来需要将变量的值增加一,表示事件已触发了一次。要执行此操作,请从Narration Count的引脚处牵引出一个新节点,搜索选择“Increment Integer”。您将得到一个显示为 ++ 的节点。其功能含义为“Current Value + 1”。

整合工作接近尾声。下面需要检查验证事件的总触发次数。从 ++ 引脚处牵引出一个新节点,搜索选择“Integer < Integer”。在 < 功能的空引脚处,输入值5。

在 < 功能的红色引脚处牵引出一个新节点,搜索选择“Branch”宏。分支主要是用来检查语句的真假。在我们的例子中,则是用来判断Narration Count是否小于5。若小于5,则返回并再次触发事件。若不小于5,则语音对白结束。

要实现此操作,请将Branch的True Exec引脚直接牵引至“Post and Wait for End of Event”功能最左侧的Exec引脚处。

注意:这样做将创建一条贯穿其他节点的白线,视觉上也许会不太美观。请尝试双击白线新建Reroute Node,可利用它来合理绕过其他节点保持调理。

整合完成!以下为蓝图截图:

在返回主编辑器之前,请编译蓝图并保存。按Play检查测试,以确保功能正常。若您觉得语音对白之间的延迟不太合适,也可以对Delay功能的Duration值做进一步优化调整。

Unreal Engine - 任务结果整合

玩家已获取了任务信息并继续执行任务。而客户端需要搞清楚玩家是否真的越过了障碍,抵达了拱门,进入了绿色或红色通道。为了实现以上判定,我们需要为障碍、拱门以及两条通道分别整合触发体。

在“Place Actors”窗口Basic选项卡底部,选择Box Trigger对象拖动到视口中,并将其放置在玩家的必经之路上。利用Translate (w),Rotate (e),Scale (r)工具,可以自定义长方体。

对于Hurdle触发体,请在Details窗口选择蓝色的“Blueprint/Add Script”按钮。在弹框中,可任意命名Blueprint,在这里我将其命名为BP_SetSwitch。点击Select,生成新的Blueprint。

完成后,请移至Event Graph选项卡。在这里设置Hurdle的Wwise Switch为Pass,以表示玩家确实通过了障碍。Event ActorBeginOverlap是本例中用到的唯一Event,它会在目标对象进入Box Trigger时激活。在此处,用来检测碰到触发体的对象是否为玩家。

在Exec引脚处,搜索选择“Cast to PlayerController”。我们还需要将Other Actor引脚与“Cast to PlayerController”的Object引脚相连。

然后在Cast的Exec引脚处,搜索选择“Set Switch”功能。将“As Player Controller”引脚与“Actor”引脚相连。

接下来,将不再从下拉菜单中选择Switch Value,而是要把Switch定义为一个单独的变量。这是因为这个蓝图同样也会用来设置Archway的Switch,所以需要Switch能够设为不同的值。

要执行此操作,请在Switch Value引脚处牵引出一个新节点并选择“Promote to Variable”。此举将自动命名变量,并将变量类型设置为Ak Switch Variable。与我们的需求契合。现在,只需按下变量右侧的图标即可将其启用(也可在Details窗口中选中“Instance Editable”复选框,以达成相同的效果)。

Instance Editable (眼球) 可将变量设置为Public。此设置可实现:

1、该变量可被其他蓝图接收和编辑。

2、如果当前场景中存在该蓝图的多个实例,则可以(在编辑器中)为该变量的每个实例设置不同的值。

确保在返回主编辑器之前编译并保存蓝图。现在可从Viewport或World Outliner中选择BP_SetSwitch,同时您可能会注意到Switch Value变量已在Details窗口中可用。在这里,将其设为Hurdle-Pass。

接下来对Archway进行类似操作。将BP_SetSwitch从Content Browser拖到Viewport中,参考之前Hurdle的设置对Archway进行编辑。唯一的区别在于Switch Value需要设置为Archway-Pass。

下面处理红色和绿色通道触发体,这两个触发体需要分别实现不同的功能。区别于之前设置Switch的情况,在这里我们需要的是当检测到玩家进入任意通道时,立即结束任务。所以我们需要定制新的蓝图。

拖动调整触发体,使其可以覆盖任意通道的整个上方区域(这里用红色通道演示),触发体需要略高于通道。然后参考BP_SetSwitch的流程新建蓝图,并将其命名为BP_Path。

在Event Graph中,我们需要新建两个变量:

- NarrationEnd和MissionComplete

- 两个变量都为Booleans。

- 两个变量都为公共变量,因此请激活眼球图标或勾选Details面板中的“Instance Editable”复选框。

这些变量的功能正如其名。判断语音对白是否结束?判断任务是否完成?首先它可以确保任务不会在语音结束前完成。其次能够保证任务结局只会有一种可能。

编译并保存蓝图。接下来从主编辑器的Blueprints选项卡选择“Open Level Blueprint”进入我们的关卡蓝图。

首先需要告知Unreal Engine语音对白已经结束。然而,Narration End变量在另一个蓝图中,因此需要一个访问该变量的方法。

要执行此操作,请在Branch的False Exec引脚处牵引出一个新节点,搜索选择“Get All Actors Of Class”。在Actor Class字段中,找到刚才新建的蓝图:BP_Path。接下来,在Out Actors引脚处牵引出一个新节点,搜索选择“Get (a copy of)”。重复此操作,再建另一个“Get (a copy of)”。将第一个GET功能的值设为0,第二个GET功能的值设为1。

这两个“GETS”将分别获取游戏中的2个BP_Path对象;红色通道和绿色通道。

我们会在该蓝图中多次获取这些通道对象,因此将其封装成单独的功能将非常有用。选择“Get All Actors of Class”和两个“GETS”,右键单击选择Collapse to Function。在My Blueprint窗口Functions选项卡列表中,将新功能重命名为Get Paths。双击Get Paths功能进入其Event Graph。

功能使用前还需要一些额外的编辑。首先需要Return Node,否则将无法访问两个GET功能的结果。在Event Graph中任意位置单击鼠标右键,搜索选择Add Return Node。将Get All Actors of Class和Return节点的Exec引脚相连。最后,将2个Get功能的蓝色节点分别牵引至Return Node。这将在Return节点中新建2个变量槽。通过选择Return Node,在Outputs选项卡中重命名变量,可以编辑引脚的名称。

最后选择“Get Paths”节点。在Details窗口中,勾选“Pure”复选框。

Pure Functions承诺不以任何方式修改状态或类的成员。鉴于我们只需要获取这些Actor变量而不会改变它们,所以这里需要勾选“Pure”复选框。

关闭“Get Paths”蓝图并返回Level Blueprint的Event Graph。现在应该可以从Get Path功能中看到2个蓝色Path引脚。在这两个引脚处分别牵引并创建“Set Narration End”节点。连接所有Exec引脚后,最终结果应如下所示:

在这里的编辑告一段落。编译保存此蓝图并返回BP_Path蓝图。与BP_SetSwitch一样,我们使用Event ActorBeginOverlap。在事件的Exec引脚处牵引并创建“Cast to PlayerController”功能,然后将蓝色“Other Actor”引脚牵引至Cast的对象引脚。

接下来检查语音对白是否已经结束。将NarrationEnd变量从Variables选项卡拖到Event Graph中,选择“Get NarrationEnd”。然后在“Cast to PlayerController”功能的Exec引脚处牵引并创建Branch功能。将NarrationEnd变量与Branch的条件相连。

参考NarrationEnd变量并重复类似操作,来检查任务是否已经完成:

分支已设置好,接下来需要将MissionComplete变量设为true,并将Switch值设为玩家进入的任意通道,然后Post “Play_MissionResult” Event给玩家。

如果忘了如何将Set Switch的Switch Value设置为公共变量,请查看您在BP_SetSwitch处的相关操作。

确保在返回主编辑器之前编译并保存蓝图。移至Path并将Switch设为“Path-Red”。复制此通道(Ctrl + W)并将其粘贴适配到Green Path。最后,将其Switch Value设为“Path-Green”。

齐活!测试一下,听取任务引导并执行任务。分别测一下绿色和红色通道,确保两种情况的Mission Result语音都触发无误。

至此,流程验证可行。然而,还存在一个问题。我们现在只有当玩家进入红色和绿色通道时才会有结局反馈。而Wwise中设置的其他3种结局该怎么处理呢?

为此,可在引导语音结束后新增一个5秒计时器。若该计时器在任务结束前启动,那么将直接发布任务结果,并将Mission Complete变量设置为true,以防止玩家进入任意通道再次完成任务。

首先,返回Level Blueprint。在Narration End变量设为true后,我们将启动带有Delay功能的计时器。在第二个“Set Narration End”的Exec引脚处牵引并创建Delay功能。

之后,进入其中任意通道来检查任务是否已经完成。从Functions列表中将GetPaths功能拖入。然后,分别在两个Path引脚处牵引并创建“Get Mission Complete”。

如果Mission Complete布尔值都不为true,那么我们就可以发布Mission Result事件。通过使用“OR boolean”节点和Branch来验证,如下所示:

 

在False Exec引脚处,新增在Player上发布“Play_MissionResult”的功能。与前面一样,通过将“Post Event”功能的Actor引脚与“Get Player Character”功能相连来实现这一点。

发布事件后,我们要将Mission Complete变量设为true。可以通过再次使用Get Paths功能来实现,需要注意的是这里需要SETTING Mission Complete,而不是获取它。

 

整个关卡蓝图如下所示:

  

保存成果并分别测一下5种不同的情景,看看它们是否工作正常。若发现问题,请仔细排查疏漏。若一切正常,那么恭喜你!动态对话系统创建成功。

特别鸣谢本文翻译:刘畅(腾讯北极光工作室,音频设计师)

杰克•盖米林 (Jake Gamelin)

作曲家 | 音乐老师 | 声音设计师

杰克•盖米林 (Jake Gamelin)

作曲家 | 音乐老师 | 声音设计师

杰克•盖米林 (Jake Gamelin) 是一名作曲家、音乐老师、技术音频设计师,目前居住在美国南加州。在圣地亚哥州立大学接受音乐教育之后,杰克参与制作了很多小型电子游戏项目。与此同时,他还积极从事互动音频设计和音乐的研究与教学。一方面希望能拓展自身的声音设计技能,另外也想培养有意从事游戏音频的新人。

https://www.jakegamelin.com/

杰克的 YouTube 频道

 @CharaComposer

评论

留下回复

您的电子邮件地址将不会被公布。

更多文章

Wwise 2018.1 正式发布了!彩色外观和更多…

Wwise 2018.1 发布了,您可以从 Wwise Launcher 中下载。 全新的彩色 UI 选项自透露以来已经出了不少风头!所以,虽然我们原本想写篇博客来讲讲 UI...

6.8.2018 - 作者:Audiokinetic (音频动能)

关于结合 Wwise 在 UE4 中运用音频工具的尝试:利用 Spline Based Audio Emitter 创建自定义形状

大家好!我计划撰写一系列短文来探讨关于结合 Wwise 在 Unreal Engine 4...

3.6.2019 - 作者:特罗尔斯.尼加德(TROELS NYGAARD)

“零代码”开发小游戏—UE4蓝图与Wwise结合的设计思路 — Part 1

大家好,我叫伍岚珊(Coffee...

6.1.2020 - 作者:伍岚珊

人人都能用 WAAPI(二)wwise.core 分支

大家好,我是溪夜。 在《人人都能用 WAAPI(一)概述》中,我们用思维导图对 WAAPI 进行了重新归纳,并在配置好开发环境后,一起用 Python 写了几个简单的小程序,体验了 WAAPI...

29.10.2020 - 作者:汪洋

Wwise 2021.1 中值得一试的 10 项新增功能

在不久前,我们推出了 Wwise 2021.1 以供通过 Launcher 下载。该版本增添了基于对象的管线、Radial Emitter、Impacter 插件、WAQL...

3.8.2021 - 作者:麦斯·麦雷蒂·桑德鲁普 (Mads Maretty Sønderup)

开发ReaWwise | 第一部分 - 预生产

10.11.2022 - 作者:伯纳德 罗德里格 (Bernard Rodrigue)

更多文章

Wwise 2018.1 正式发布了!彩色外观和更多…

Wwise 2018.1 发布了,您可以从 Wwise Launcher 中下载。 全新的彩色 UI 选项自透露以来已经出了不少风头!所以,虽然我们原本想写篇博客来讲讲 UI...

关于结合 Wwise 在 UE4 中运用音频工具的尝试:利用 Spline Based Audio Emitter 创建自定义形状

大家好!我计划撰写一系列短文来探讨关于结合 Wwise 在 Unreal Engine 4...

“零代码”开发小游戏—UE4蓝图与Wwise结合的设计思路 — Part 1

大家好,我叫伍岚珊(Coffee...