第 25 课
实现背景音乐和语音解说的设置功能
设置开关不能直接决定所有声音结果:视频播放时要暂时让出背景音乐,作品解说开始时也要暂停背景音乐,而用户关闭背景音乐后,任何功能都不能擅自把它恢复。本课把长期设置和临时占用分开保存,由 AudioManager 统一计算最终状态。代码学习采用“先看前后差异、再讲新增部分、最后组合完整文件”的顺序。
让两个声音开关服从同一套状态规则
把背景音乐和语音解说开关连接到真实声音系统。你会先对照已经完成的代码,再逐段理解新增状态、事件通知和统一播放判断,最后完成场景绑定与组合测试。
学完你将能够
学习目标
- 区分用户设置、视频播放意图和作品解说占用状态。
- 读懂代码差异中的新增、删除和上下文标记。
- 说明 UIManager 如何监听 Toggle 的 onValueChanged 并转发 bool 状态。
- 解释 AddListener 为什么不能代替 Start 中的初始状态同步。
- 理解 Action<bool>、Action 事件、只读属性和订阅生命周期。
- 说明候选列表、当前解说属性和当前解说变化通知怎样协作。
- 说出背景音乐真正播放必须同时满足的三个条件。
- 解释作品控制器为什么始终记录距离和朝向,不能在触发事件开头丢弃状态。
- 说明 VideoController 为什么无需读取设置页面,也能服从背景音乐开关。
- 组合三份完整脚本,并完成两个 Toggle 的场景引用。
- 通过视频、解说和两个开关的交叉操作验证声音结果。
开始操作前
准备工作
- 打开主场景,确认 SettingsPage 已有 BackgroundMusicToggle 和 VoiceNarrationToggle。
- 确认 Scripts 对象已经挂载 UIManager,AudioSource 对象已经挂载 AudioManager。
- 确认 AudioManager 的 Music Source 指向 BGAudioSource,Intro Source 指向 IntroAudioSource。
- 确认作品感应区能够记录玩家是否进入范围,并能判断玩家是否看向作品。
- 打开代码编辑器、层级面板、属性面板和控制台。
- 退出运行状态并保存场景,再开始修改代码。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。收到播放请求,不代表最终一定播放
分清用户设置、播放请求和声音占用
背景音乐同时受到用户开关、视频播放和作品解说影响。先把这些状态分开,才能避免一个功能结束后错误覆盖另一个功能的选择。为什么一个布尔变量不够
用户关闭背景音乐是一项持续设置;视频开始时暂停背景音乐只是临时要求。两者虽然都能造成暂停,却代表不同事实。如果只用一个 bool 保存,视频结束时就无法判断用户是否曾主动关闭音乐。
当前实现让 UIManager 只接收输入,让 VideoController 和 ArtWorkIntroController 只表达当前需求,所有真实状态都由 AudioManager 保存并合并。
- backgroundMusicEnabled:用户是否允许背景音乐。
- backgroundMusicShouldPlay:视频等功能当前是否允许背景音乐。
- narrationEnabled:用户是否允许语音解说。
- introIsActive:当前是否有作品解说占用声音。
- activeIntroClips:仍满足距离、朝向和语音设置的作品候选列表。
四个布尔状态与一个候选列表的职责
四个 bool 回答开关或占用问题,候选列表保存可能成为当前解说的作品,不能随意合并。
左右滑动表格,可查看完整内容
| 状态 | 类别 | 它回答的问题 | 主要修改者 |
|---|---|---|---|
| backgroundMusicEnabled | 用户设置 | 用户允许背景音乐吗? | 背景音乐开关 |
| backgroundMusicShouldPlay | 临时意图 | 视频流程当前允许背景音乐吗? | VideoController |
| narrationEnabled | 用户设置 | 用户允许语音解说吗? | 语音解说开关 |
| introIsActive | 占用状态 | 解说当前正在占用声音吗? | AudioManager |
| activeIntroClips | 候选集合 | 哪些作品目前仍满足解说条件? | 各作品控制器提交请求,AudioManager 统一维护 |
把三个条件写成一条规则
背景音乐实际播放,需要同时满足:用户打开背景音乐、视频等功能当前允许播放、作品解说没有占用声音。用变量表示就是 backgroundMusicEnabled && backgroundMusicShouldPlay && !introIsActive。
符号 && 表示“并且”,三个条件缺少任何一个都不能播放;符号 ! 表示“取反”,所以 !introIsActive 的意思是“当前没有作品解说占用声音”。
观察重点:界面和场景控制器都把状态交给 AudioManager,两个 AudioSource 只执行统一判断后的最终结果。
手机上可左右滑动;点击图片可以查看完整图背景音乐的八种状态组合
逐行比较三个条件。只有最后一行同时满足全部条件,背景音乐才会播放。
左右滑动表格,可查看完整内容
| 背景音乐设置 | 当前允许播放 | 解说正在占用 | 最终结果 |
|---|---|---|---|
| 关 | 是 | 否 | 暂停:用户已经关闭背景音乐 |
| 关 | 否 | 否 | 暂停:用户设置和视频状态都在阻止 |
| 关 | 是 | 是 | 暂停:用户设置和解说状态都在阻止 |
| 关 | 否 | 是 | 暂停:三个阻止条件都存在 |
| 开 | 否 | 否 | 暂停:视频等功能暂时不允许播放 |
| 开 | 否 | 是 | 暂停:视频和解说都在占用声音 |
| 开 | 是 | 是 | 暂停:作品解说正在占用声音 |
| 开 | 是 | 否 | 播放:这是唯一允许播放的组合 |
判断每个状态属于哪一类
把四个变量分别归入用户设置、临时意图或占用状态,并说明它们由谁修改。
- 指出哪两个状态直接来自设置页面。
- 指出视频开始和停止会改变哪个状态。
- 说出唯一允许背景音乐播放的状态组合。
先确认从哪里改,再解释为什么改
找到可运行起点并学会看代码差异
代码差异常写作 diff,指前后两份代码的不同之处。本页把关键变化整理成便于阅读的摘要:以减号开头的是旧版本删除行,以加号开头的是当前版本新增行,没有标记的行用于定位上下文。它不是可以直接运行的 C# 文件。从已经完成的脚本继续
每个文件都从最近已经学完并能运行的版本继续,这样可以保留前面课程已经完成的功能。
左右滑动表格,可查看完整内容
| 脚本 | 继续使用的版本 | 本课要加入的功能 |
|---|---|---|
| UIManager.cs | 第 21 课 | 监听并转发两个声音开关 |
| AudioManager.cs | 第 16 课 | 保存设置并统一裁决声音 |
| ArtWorkIntroController.cs | 第 17 课 | 响应解说设置变化并保留真实交互状态 |
| VideoController.cs | 完成第 16 课后的可运行版本 | 保持原有声音调用,继续提交播放与暂停请求 |
本课固定阅读顺序
每个文件都先展示关键变化摘要,让你知道本课需要处理哪些位置;随后只拆解新增或变化的代码;最后再给出完整文件。旧代码已经在前面的课中解释过,不需要从第一行重复学习。
分段代码用于理解和组合。三个相互依赖的文件尚未全部完成时,不要要求 Unity 编译;等完整代码都准备好后再统一保存。
- 加号:当前版本新增。
- 减号:旧版本删除。
- 普通行:帮助定位的上下文。
- 复制到 .cs 文件时不包含差异标记。
为三个文件找到正确起点
按照表格说明三个脚本为什么分别从第 21、16、17 课完成的版本继续。
- 说明为什么 AudioManager 不从更早的单音频版本开始比较。
- 指出本课没有代码变化的控制器。
- 说明差异代码为什么不能直接粘贴编译。
界面管理器只接收输入,不直接控制 AudioSource
对比 UIManager,接收两个开关输入
UIManager 相对第 21 课只增加声音设置相关代码,原有菜单显示、隐藏和定位逻辑全部保留。先看完整差异,再理解字段与事件注册。--- 第21课 UIManager.cs
+++ 第25课 UIManager.cs
@@
using UnityEngine;
+using UnityEngine.UI;
@@
[SerializeField] private GameObject menuUI;
[SerializeField] private float distanceFromPlayer = 1.5f;
+ [Header("声音设置")]
+ [SerializeField] private Toggle backgroundMusicToggle;
+ [SerializeField] private Toggle narrationToggle;
@@ Awake
+ if (backgroundMusicToggle != null)
+ backgroundMusicToggle.onValueChanged.AddListener(SetBackgroundMusicEnabled);
+
+ if (narrationToggle != null)
+ narrationToggle.onValueChanged.AddListener(SetNarrationEnabled);
@@
+ private void Start()
+ {
+ if (backgroundMusicToggle != null)
+ SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
+
+ if (narrationToggle != null)
+ SetNarrationEnabled(narrationToggle.isOn);
+ }
+
+ private void OnDestroy()
+ {
+ if (backgroundMusicToggle != null)
+ backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
+
+ if (narrationToggle != null)
+ narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
+ }
@@
+ private void SetBackgroundMusicEnabled(bool isEnabled)
+ {
+ AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
+ }
+
+ private void SetNarrationEnabled(bool isEnabled)
+ {
+ AudioManager.Instance.SetNarrationEnabled(isEnabled);
+ }减号是旧行,加号是新增行;这是帮助定位变化的阅读摘要,不可直接粘贴编译。
using UnityEngine;
using UnityEngine.UI;
public class UIManager : Singleton<UIManager>
{
[SerializeField] private GameObject menuUI;
[SerializeField] private float distanceFromPlayer = 1.5f;
[Header("声音设置")]
[SerializeField] private Toggle backgroundMusicToggle;
[SerializeField] private Toggle narrationToggle;
protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (menuUI == null)
{
menuUI = GameObject.Find("MenuUI");
}
if (menuUI != null)
{
menuUI.SetActive(false);
}
else
{
Debug.LogError("UIManager:没有找到 MenuUI。", this);
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.AddListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.AddListener(SetNarrationEnabled);
}
}当前只完成 UIManager 的前半部分,请继续学习后续模块,暂不回 Unity 编译。
逐项理解新增内容
using UnityEngine.UI 让编译器认识 Toggle。Toggle 是界面开关组件,它在值变化时发送新的 bool:true 表示开启,false 表示关闭。
两个字段使用 [SerializeField] private:其他脚本不能随意修改引用,但你仍能在属性面板中拖入具体 Toggle。声音设置本身不保存在 UIManager,避免与 AudioManager 出现两份可能不同步的状态。
onValueChanged.AddListener(...) 把处理方法登记为监听者。背景音乐与语音解说开关分别连接到不同方法,因此两项功能可以独立设置。
- 空引用保护避免字段未连接时立即报错,但不代表可以省略引用。
- 同一个 Toggle 的外观控制器负责旋钮动画,UIManager 负责真实声音功能。
- 事件把新的 isOn 状态作为参数传给监听方法。
沿输入路径说出每一步
从点击背景音乐开关开始,口述 Toggle、onValueChanged、UIManager 方法和 AudioManager 的顺序。
- 指出事件传入的 bool 表示什么。
- 说明 UIManager 为什么不直接调用 musicSource.Pause。
- 确认两个字段都是 Toggle 组件引用,而不是整个 SettingsPage。
监听未来变化,也要主动发送当前状态
补全 UIManager 的初始同步与监听生命周期
AddListener 不会自动触发一次当前值,所以场景开始时必须读取 isOn。对象销毁时还要移除监听,使注册和解除保持成对。 private void Start()
{
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
}
private void OnDestroy()
{
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
}
}先理解初始同步和解除监听,UIManager 仍未展示完整,暂不编译。
private void SetBackgroundMusicEnabled(bool isEnabled)
{
AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
}
private void SetNarrationEnabled(bool isEnabled)
{
AudioManager.Instance.SetNarrationEnabled(isEnabled);
}方法只把 isEnabled 交给 AudioManager,不保存第二份状态。完整文件将在模块九提供。
UIManager 的声音设置生命周期
每个阶段都解决一个不同问题。
左右滑动表格,可查看完整内容
| 阶段 | 执行内容 | 不能省略的原因 |
|---|---|---|
| Awake | 给两个 onValueChanged 添加监听 | 建立之后的输入通道 |
| Start | 主动读取两个 Toggle 的当前 isOn | 登记监听不会自动发送初始值 |
| 运行中 | 把每次新 bool 转发给 AudioManager | 开关操作需要立即改变声音 |
| OnDestroy | 移除两个监听 | 避免失效回调或重复注册 |
Start 的主动同步使默认值真正生效
两个自定义开关默认 Is On 为开启。Start 读取该值并调用与运行时相同的 Set... 方法,使初始设置和之后点击始终经过同一条逻辑路径。
以后如果把某个开关默认设为关闭,也不需要增加另一套初始化代码;Start 会把新的默认值直接同步给声音系统。
模拟一次完整生命周期
按 Awake、Start、用户点击、OnDestroy 的顺序说明监听关系和状态变化。
- 说明 AddListener 为什么不能替代 Start。
- 说明 RemoveListener 与 AddListener 如何一一对应。
- 指出两个转发方法最终调用的 AudioManager 方法。
把用户设置与临时播放意图放在同一个裁决入口
对比 AudioManager,建立统一声音状态中心
AudioManager 相对第 16 课增加两个设置、两个只读属性和两个事件,并把背景音乐设置纳入最终判断。先看完整差异。--- 第16课 AudioManager.cs
+++ 第25课 AudioManager.cs
@@
+using System;
using System.Collections.Generic;
using UnityEngine;
@@
private bool backgroundMusicShouldPlay = true;
+ private bool backgroundMusicEnabled = true;
+ private bool narrationEnabled = true;
private bool introIsActive;
@@
+ public bool NarrationEnabled => narrationEnabled;
+ public AudioClip CurrentIntroClip =>
+ introIsActive && introSource != null ? introSource.clip : null;
+ public event Action<bool> NarrationEnabledChanged;
+ public event Action ActiveIntroChanged;
@@ Awake
+ if (introSource != null)
+ narrationEnabled = !introSource.mute;
@@ Update
+ AudioClip previousIntroClip = CurrentIntroClip;
if (introSource != null)
activeIntroClips.Remove(introSource.clip);
introIsActive = false;
ApplyBackgroundMusicState();
+ NotifyActiveIntroChanged(previousIntroClip);
@@
+ public void SetBackgroundMusicEnabled(bool isEnabled)
+ {
+ backgroundMusicEnabled = isEnabled;
+ ApplyBackgroundMusicState();
+ }
+
+ public void SetNarrationEnabled(bool isEnabled)
+ {
+ AudioClip previousIntroClip = CurrentIntroClip;
+ narrationEnabled = isEnabled;
+ if (introSource != null)
+ {
+ introSource.mute = false;
+ if (!narrationEnabled)
+ {
+ activeIntroClips.Clear();
+ introSource.Stop();
+ }
+ }
+ if (!narrationEnabled)
+ {
+ introIsActive = false;
+ ApplyBackgroundMusicState();
+ NotifyActiveIntroChanged(previousIntroClip);
+ }
+ NarrationEnabledChanged?.Invoke(narrationEnabled);
+ }
@@ PlayArtWorkIntro
- if (introSource == null || introClip == null)
+ if (!narrationEnabled || introSource == null || introClip == null)
@@ ApplyActiveIntroState
+ AudioClip previousIntroClip = CurrentIntroClip;
@@
introIsActive = true;
ApplyBackgroundMusicState();
+ NotifyActiveIntroChanged(previousIntroClip);
+ }
+
+ private void NotifyActiveIntroChanged(AudioClip previousIntroClip)
+ {
+ if (previousIntroClip != CurrentIntroClip)
+ ActiveIntroChanged?.Invoke();
}
@@ ApplyBackgroundMusicState
- if (!backgroundMusicShouldPlay || introIsActive)
+ if (!backgroundMusicEnabled || !backgroundMusicShouldPlay || introIsActive)这是帮助定位变化的阅读摘要,不可直接粘贴编译;重点观察新增状态、设置方法和两个修改条件。
using System;
using System.Collections.Generic;
using UnityEngine;
public class AudioManager : Singleton<AudioManager>
{
[SerializeField] private AudioSource musicSource;
[SerializeField] private AudioSource introSource;
private bool backgroundMusicShouldPlay = true;
private bool backgroundMusicEnabled = true;
private bool narrationEnabled = true;
private bool introIsActive;
private readonly List<AudioClip> activeIntroClips = new();
public bool NarrationEnabled => narrationEnabled;
public AudioClip CurrentIntroClip =>
introIsActive && introSource != null ? introSource.clip : null;
public event Action<bool> NarrationEnabledChanged;
public event Action ActiveIntroChanged;
protected override void Awake()
{
base.Awake();
musicSource ??= GetComponent<AudioSource>();
if (introSource != null)
{
narrationEnabled = !introSource.mute;
}
}当前只展示 AudioManager 的状态部分,请继续完成后续方法,暂不编译。
private void Update()
{
if (introIsActive && (introSource == null || !introSource.isPlaying))
{
AudioClip previousIntroClip = CurrentIntroClip;
if (introSource != null)
{
activeIntroClips.Remove(introSource.clip);
}
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
}
public void PlayBackgroundMusic()
{
backgroundMusicShouldPlay = true;
ApplyBackgroundMusicState();
}
public void PauseBackgroundMusic()
{
backgroundMusicShouldPlay = false;
ApplyBackgroundMusicState();
}
public void SetBackgroundMusicEnabled(bool isEnabled)
{
backgroundMusicEnabled = isEnabled;
ApplyBackgroundMusicState();
}Update 负责发现解说自然结束;Play/Pause 只改变临时播放意图,设置方法改变用户许可,三者都调用同一个状态汇总。
只读属性和事件是什么
NarrationEnabled => narrationEnabled 是只读属性:其他控制器能查看当前设置,却不能绕过 AudioManager 随意修改字段。箭头可以理解为“读取属性时返回右侧值”。
CurrentIntroClip 也是只读属性:只有解说确实活跃且播放器存在时,才返回当前音频;否则返回 null。后续说明牌可以用它确认自己是否真的是管理器选中的作品。
Action<bool> 表示订阅方法接收一个 bool 参数且不返回结果;Action 表示订阅方法不接收参数也不返回结果。event 让外部对象只能订阅或取消订阅,真正的通知只能由 AudioManager 发出。
Awake 先从 introSource.mute 读取初始解说状态;随后 UIManager.Start 会用开关的真实 isOn 再同步一次。musicSource ??= GetComponent<AudioSource>() 表示:只有 musicSource 还没有连接时,才尝试从当前对象取得 AudioSource;如果属性面板已经连接,就保留原来的引用。
Update() 是 Unity 每一帧都会自动调用的方法。本课用它判断作品解说是否自然播放完毕;一旦发现结束,就从候选列表移除该音频、清除 introIsActive、重新判断背景音乐,并发出当前解说变化通知。
代入三个允许条件
选择视频播放、作品解说和用户关闭背景音乐三个场景,分别代入状态。
- 说明 PlayBackgroundMusic 为什么只是请求。
- 指出背景音乐设置变化后为什么要立即调用 ApplyBackgroundMusicState。
- 说出唯一能够播放的组合。
关闭解说会同时改变音频、占用状态和作品判断
补全解说设置、事件通知与背景音乐裁决
语音开关不是只改一个 bool。关闭时要立即停止解说、清空候选列表、清除声音占用、重新计算背景音乐,并通知所有作品控制器。 public void SetNarrationEnabled(bool isEnabled)
{
AudioClip previousIntroClip = CurrentIntroClip;
narrationEnabled = isEnabled;
if (introSource != null)
{
introSource.mute = false;
if (!narrationEnabled)
{
activeIntroClips.Clear();
introSource.Stop();
}
}
if (!narrationEnabled)
{
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
NarrationEnabledChanged?.Invoke(narrationEnabled);
}按保存设置、清空候选、停止解说、清除占用、重算背景音乐、广播事件的顺序阅读。
public void PlayArtWorkIntro(AudioClip introClip)
{
if (!narrationEnabled || introSource == null || introClip == null)
{
return;
}
if (!activeIntroClips.Contains(introClip))
{
activeIntroClips.Add(introClip);
}
ApplyActiveIntroState();
}
public void PauseArtWorkIntro(AudioClip introClip)
{
if (introClip == null)
{
return;
}
activeIntroClips.Remove(introClip);
ApplyActiveIntroState();
}
private void ApplyActiveIntroState()
{
AudioClip previousIntroClip = CurrentIntroClip;
AudioClip nextIntroClip = activeIntroClips.Count > 0
? activeIntroClips[activeIntroClips.Count - 1]
: null;
if (introSource == null || nextIntroClip == null)
{
if (introSource != null && introSource.isPlaying)
{
introSource.Pause();
}
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
return;
}
if (introSource.clip != nextIntroClip)
{
introSource.clip = nextIntroClip;
introSource.Play();
}
else
{
introSource.UnPause();
if (!introSource.isPlaying)
{
introSource.Play();
}
}
introIsActive = true;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
private void NotifyActiveIntroChanged(AudioClip previousIntroClip)
{
if (previousIntroClip != CurrentIntroClip)
{
ActiveIntroChanged?.Invoke();
}
}播放入口先检查 narrationEnabled,再登记候选;暂停入口撤销候选;ApplyActiveIntroState 选择列表最后一项,并在结果改变时发出 ActiveIntroChanged。
private void ApplyBackgroundMusicState()
{
if (musicSource == null)
{
return;
}
if (!backgroundMusicEnabled || !backgroundMusicShouldPlay || introIsActive)
{
if (musicSource.isPlaying)
{
musicSource.Pause();
}
return;
}
if (musicSource.isPlaying)
{
return;
}
musicSource.UnPause();
if (!musicSource.isPlaying)
{
musicSource.Play();
}
}
}AudioManager 的剩余部分已经完整,但其它两个脚本仍在学习中,暂不回 Unity 编译。
按实际执行顺序读懂统一裁决
方法先判断 musicSource 是否存在。若没有连接音频源,return 会立刻结束本次方法调用,下面的播放判断都不再执行;以后再次调用这个方法时,仍会从第一行重新判断。
接着的 if 使用 || 连接三个阻止条件,|| 表示“或者”:只要用户关闭背景音乐、视频暂时不允许播放,或者作品解说正在占用声音,就执行 Pause() 并 return。
三个阻止条件都不成立时,先调用 UnPause() 尝试从暂停位置继续。isPlaying 表示音频源此刻是否真的在播放;如果继续后仍未播放,再调用 Play() 从头开始。
观察重点:任何阻止条件成立都会暂停并提前结束;只有全部允许时才尝试继续或开始播放。
手机上可左右滑动;点击图片可以查看完整图Pause、Stop 与 UnPause 的区别
不同方法表达不同的用户意图和恢复方式。
左右滑动表格,可查看完整内容
| 方法 | 播放位置 | 本课用途 |
|---|---|---|
| Pause | 保留 | 暂时让出声音,之后可从原位置继续 |
| Stop | 停止当前播放 | 用户关闭整个解说功能 |
| UnPause | 从暂停位置继续 | 背景音乐重新满足条件时优先续播 |
| Play | 开始或从头播放 | UnPause 后仍未播放时作为补充 |
事件广播的逐段含义
NarrationEnabledChanged?.Invoke(narrationEnabled) 中,?. 表示只有存在订阅者时才继续,Invoke 会依次调用所有订阅方法,并把最新设置作为参数发送出去。
ActiveIntroChanged 不需要参数。订阅者收到通知后,可通过 CurrentIntroClip 读取统一选择的结果。NotifyActiveIntroChanged 只在前后音频不同的时候通知,避免重复刷新。
关闭解说时使用 Stop 并清空候选列表,所以重新开启且距离与朝向仍满足时会由各作品重新登记并从头播放。清除 introIsActive 后必须重新计算背景音乐,否则系统仍可能把解说误认为正在占用声音。
给关闭解说的六个动作排序
按真实执行顺序整理保存设置、清空候选、停止声音、清除占用、重算背景音乐、通知作品。
- 说明漏掉 introIsActive = false 会发生什么。
- 说明为什么播放入口还要再次判断 narrationEnabled。
- 说明 Pause 和 Stop 在重新开启后的差别。
始终记录真实交互状态,最后再判断是否允许播放
对比作品解说控制器,让设置变化立即生效
玩家可能在关闭解说期间进入作品范围,也可能原地切换设置。控制器必须持续保存距离和朝向,并订阅设置变化事件。--- 第17课 ArtWorkIntroController.cs
+++ 第25课 ArtWorkIntroController.cs
@@ fields
+ private bool audioManagerReady;
@@ OnEnable
+ if (audioManagerReady)
+ audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
@@ Start
+ audioManagerReady = true;
+ audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
+ UpdatePlayback();
@@ OnDisable
+ if (audioManager != null)
+ audioManager.NarrationEnabledChanged -= OnNarrationEnabledChanged;
@@
+ private void OnNarrationEnabledChanged(bool isEnabled)
+ {
+ UpdatePlayback();
+ }
@@ UpdatePlayback
+ if (!audioManagerReady)
+ return;
+
- if (collidersInside > 0 && isFacing)
+ if (audioManager.NarrationEnabled && collidersInside > 0 && isFacing)这是帮助定位变化的阅读摘要,不可直接粘贴编译;重点观察缓存、事件订阅和第三个播放条件。
using UnityEngine;
using Unity.VRTemplate;
[RequireComponent(typeof(Collider))]
public class ArtWorkIntroController : MonoBehaviour
{
[SerializeField] private AudioClip introClip;
[SerializeField, Tooltip("对应的艺术品,例如 Painting7")]
private GameObject artwork;
private int collidersInside;
private bool isFacing;
private CalloutGazeController gazeController;
private AudioManager audioManager;
private bool audioManagerReady;
private void Awake()
{
if (artwork != null)
{
gazeController = artwork.GetComponent<CalloutGazeController>();
}
}
private void OnEnable()
{
if (audioManagerReady)
{
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
}
if (gazeController == null)
{
return;
}
gazeController.FacingEnteredEvent.AddListener(OnFacingEntered);
gazeController.FacingExitedEvent.AddListener(OnFacingExited);
}
private void Start()
{
audioManager = AudioManager.Instance;
audioManagerReady = true;
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
UpdatePlayback();
}
private void OnDisable()
{
if (audioManager != null)
{
audioManager.NarrationEnabledChanged -= OnNarrationEnabledChanged;
audioManager.PauseArtWorkIntro(introClip);
}
collidersInside = 0;
isFacing = false;
if (gazeController != null)
{
gazeController.FacingEnteredEvent.RemoveListener(OnFacingEntered);
gazeController.FacingExitedEvent.RemoveListener(OnFacingExited);
}
}第一次 OnEnable 早于 Start,所以使用 audioManagerReady 区分首次启用和之后重新启用;OnDisable 会撤销设置事件、作品候选和旧的距离朝向状态。
private void OnTriggerEnter(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside++;
UpdatePlayback();
}
private void OnTriggerExit(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside = Mathf.Max(0, collidersInside - 1);
UpdatePlayback();
}
private void OnFacingEntered()
{
isFacing = true;
UpdatePlayback();
}
private void OnFacingExited()
{
isFacing = false;
UpdatePlayback();
}
private void OnNarrationEnabledChanged(bool isEnabled)
{
UpdatePlayback();
}
private void UpdatePlayback()
{
if (!audioManagerReady)
{
return;
}
if (audioManager.NarrationEnabled && collidersInside > 0 && isFacing)
{
audioManager.PlayArtWorkIntro(introClip);
}
else
{
audioManager.PauseArtWorkIntro(introClip);
}
}
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
}三个依赖文件的变化已经讲完,下一模块核对完整调用链,模块九再统一使用完整文件编译。
首次启用与再次启用
Start 只执行一次,OnEnable 会在每次启用时执行。
左右滑动表格,可查看完整内容
| 情况 | OnEnable | Start | 设置事件订阅位置 |
|---|---|---|---|
| 首次启用 | 管理器尚未准备,只注册凝视事件 | 取得管理器并首次订阅 | Start |
| 禁用后再启用 | audioManagerReady 已为 true | 不会再次执行 | OnEnable |
| 禁用 | 不执行 | 不执行 | OnDisable 使用 -= 解除,并撤销作品请求、清零交互状态 |
为什么不能在触发事件开头直接结束
如果解说关闭时 OnTriggerEnter 直接 return,玩家进入范围的事实就不会记录。玩家原地重新打开解说时不会产生新的进入事件,程序仍会误认为玩家在范围外。
当前实现无论设置是否开启,都更新 collidersInside 和 isFacing;只有 UpdatePlayback 的最终条件同时检查 NarrationEnabled、范围计数和朝向。这样设置变化事件一到,就能用完整真实状态重新判断。
对象真的被停用时情况不同:OnDisable 会撤销候选并清零状态。对象重新启用后,必须等待新的范围和朝向事件,不能沿用停用前的旧事实。
模拟原地切换解说设置
假设玩家已进入范围并看向作品,依次关闭和重新开启语音解说。
- 指出关闭时哪个事件触发 UpdatePlayback。
- 确认 collidersInside 和 isFacing 没有被清空。
- 说明重新开启后为什么无需再次进入触发区。
视频提交意图,声音管理器执行用户规则
串联完整调用链,并确认 VideoController 保持原有调用
VideoController 继续使用原有 PlayBackgroundMusic 和 PauseBackgroundMusic,不读取设置页面。新版 AudioManager 会把这些请求与用户设置合并。// Start():场景开始时允许背景音乐播放
AudioManager.Instance.PlayBackgroundMusic();
// PlayVideo():视频开始时暂时占用声音
AudioManager.Instance.PauseBackgroundMusic();
// StopVideo():视频停止后不再阻止背景音乐
AudioManager.Instance.PlayBackgroundMusic();
// OnVideoError():播放失败后同样释放视频占用
AudioManager.Instance.PlayBackgroundMusic();完成第 16 课后,VideoController 已有下面四个调用位置。它们只表达视频流程是否允许背景音乐,不会直接覆盖用户设置。
VideoController 的四个声音调用位置
开始、停止和失败都把临时播放意图交还给 AudioManager,最终是否播放仍要经过三个条件。
左右滑动表格,可查看完整内容
| 方法 | 发生时机 | 提交给 AudioManager 的意图 |
|---|---|---|
| Start() | 场景开始 | 允许背景音乐播放 |
| PlayVideo() | 视频开始 | 暂停背景音乐 |
| StopVideo() | 视频停止 | 重新允许背景音乐播放 |
| OnVideoError() | 视频播放失败 | 释放视频占用并重新允许播放 |
三条运行调用链
从触发入口一直追到实际 AudioSource,才能判断系统为何得到当前声音结果。
左右滑动表格,可查看完整内容
| 触发 | 调用链 | 最终状态 |
|---|---|---|
| 背景音乐开关 | Toggle → UIManager → SetBackgroundMusicEnabled → ApplyBackgroundMusicState | BGAudioSource 播放或暂停 |
| 语音解说开关 | Toggle → UIManager → SetNarrationEnabled → 清空候选与停止 → 事件 → 所有作品重新判断 | 解说停止,或重新登记后按三条件选择 |
| 距离或凝视变化 | 作品控制器 → 登记或撤销候选 → 选择 CurrentIntroClip → introIsActive → 统一裁决 | 解说与背景音乐协调 |
视频停止后的真实执行过程
StopVideo 调用 PlayBackgroundMusic,只把 backgroundMusicShouldPlay 改为 true。随后 ApplyBackgroundMusicState 仍会判断 backgroundMusicEnabled 和 introIsActive。
因此用户关闭背景音乐时,视频停止后的请求不会让音乐重新响起;解说仍在播放时也不会发生声音抢占。规则只写在一个地方,视频脚本无需知道菜单结构。
自然播放结束也会释放解说占用
AudioManager.Update 每帧先判断 introIsActive,再检查 introSource 是否已经不播放。自然结束时移除已播完的候选、清除占用、重算背景音乐并通知当前解说变化。下一帧仍会执行 Update,但因为 introIsActive 已为 false,不会重复进入该分支。
代入背景音乐关闭后的停止视频流程
从 StopVideo 开始逐步写出 backgroundMusicShouldPlay、backgroundMusicEnabled 和最终结果。
- 确认视频只把播放意图改回 true。
- 确认用户设置仍保持 false。
- 说明最终为什么保持暂停。
三个依赖文件完整后,再统一编译和连接场景
使用完整脚本并连接两个 Toggle
现在三份差异和新增机制都已经解释完成。请使用下面的完整文件替换对应脚本,统一编译通过后,再连接两个 Toggle。编译前确认这些前置功能已经存在
三份脚本会调用前面课程已经完成的类型、对象和标签。先确认这些前置功能存在,再统一保存三个文件,可以避免把缺少依赖误认为本课代码错误。
- 第 15 课的 Singleton<T> 单例基类。
- GameManager.Instance.Player 能取得玩家对象。
- 项目已经启用 UnityEngine.UI.Toggle。
- 作品对象已经使用 Unity.VRTemplate.CalloutGazeController。
- 玩家碰撞体使用 Player 标签。
- AudioManager 已准备 Music Source 与 Intro Source 两个引用。
using System;
using System.Collections.Generic;
using UnityEngine;
public class AudioManager : Singleton<AudioManager>
{
[SerializeField] private AudioSource musicSource;
[SerializeField] private AudioSource introSource;
private bool backgroundMusicShouldPlay = true;
private bool backgroundMusicEnabled = true;
private bool narrationEnabled = true;
private bool introIsActive;
private readonly List<AudioClip> activeIntroClips = new();
public bool NarrationEnabled => narrationEnabled;
public AudioClip CurrentIntroClip =>
introIsActive && introSource != null ? introSource.clip : null;
public event Action<bool> NarrationEnabledChanged;
public event Action ActiveIntroChanged;
protected override void Awake()
{
base.Awake();
musicSource ??= GetComponent<AudioSource>();
if (introSource != null)
{
narrationEnabled = !introSource.mute;
}
}
private void Update()
{
if (introIsActive && (introSource == null || !introSource.isPlaying))
{
AudioClip previousIntroClip = CurrentIntroClip;
if (introSource != null)
{
activeIntroClips.Remove(introSource.clip);
}
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
}
public void PlayBackgroundMusic()
{
backgroundMusicShouldPlay = true;
ApplyBackgroundMusicState();
}
public void PauseBackgroundMusic()
{
backgroundMusicShouldPlay = false;
ApplyBackgroundMusicState();
}
public void SetBackgroundMusicEnabled(bool isEnabled)
{
backgroundMusicEnabled = isEnabled;
ApplyBackgroundMusicState();
}
public void SetNarrationEnabled(bool isEnabled)
{
AudioClip previousIntroClip = CurrentIntroClip;
narrationEnabled = isEnabled;
if (introSource != null)
{
introSource.mute = false;
if (!narrationEnabled)
{
activeIntroClips.Clear();
introSource.Stop();
}
}
if (!narrationEnabled)
{
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
NarrationEnabledChanged?.Invoke(narrationEnabled);
}
public void PlayArtWorkIntro(AudioClip introClip)
{
if (!narrationEnabled || introSource == null || introClip == null)
{
return;
}
if (!activeIntroClips.Contains(introClip))
{
activeIntroClips.Add(introClip);
}
ApplyActiveIntroState();
}
public void PauseArtWorkIntro(AudioClip introClip)
{
if (introClip == null)
{
return;
}
activeIntroClips.Remove(introClip);
ApplyActiveIntroState();
}
private void ApplyActiveIntroState()
{
AudioClip previousIntroClip = CurrentIntroClip;
AudioClip nextIntroClip = activeIntroClips.Count > 0
? activeIntroClips[activeIntroClips.Count - 1]
: null;
if (introSource == null || nextIntroClip == null)
{
if (introSource != null && introSource.isPlaying)
{
introSource.Pause();
}
introIsActive = false;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
return;
}
if (introSource.clip != nextIntroClip)
{
introSource.clip = nextIntroClip;
introSource.Play();
}
else
{
introSource.UnPause();
if (!introSource.isPlaying)
{
introSource.Play();
}
}
introIsActive = true;
ApplyBackgroundMusicState();
NotifyActiveIntroChanged(previousIntroClip);
}
private void NotifyActiveIntroChanged(AudioClip previousIntroClip)
{
if (previousIntroClip != CurrentIntroClip)
{
ActiveIntroChanged?.Invoke();
}
}
private void ApplyBackgroundMusicState()
{
if (musicSource == null)
{
return;
}
if (!backgroundMusicEnabled || !backgroundMusicShouldPlay || introIsActive)
{
if (musicSource.isPlaying)
{
musicSource.Pause();
}
return;
}
if (musicSource.isPlaying)
{
return;
}
musicSource.UnPause();
if (!musicSource.isPlaying)
{
musicSource.Play();
}
}
}声音状态中心、两个设置入口、解说协调和背景音乐统一裁决都包含在此文件中。
下载完整 AudioManager.cs
可直接用于核对或替换声音管理脚本。
- 共 197 行
- 与本课完整代码保持一致
using UnityEngine;
using Unity.VRTemplate;
[RequireComponent(typeof(Collider))]
public class ArtWorkIntroController : MonoBehaviour
{
[SerializeField] private AudioClip introClip;
[SerializeField, Tooltip("对应的艺术品,例如 Painting7")]
private GameObject artwork;
private int collidersInside;
private bool isFacing;
private CalloutGazeController gazeController;
private AudioManager audioManager;
private bool audioManagerReady;
private void Awake()
{
if (artwork != null)
{
gazeController = artwork.GetComponent<CalloutGazeController>();
}
}
private void OnEnable()
{
if (audioManagerReady)
{
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
}
if (gazeController == null)
{
return;
}
gazeController.FacingEnteredEvent.AddListener(OnFacingEntered);
gazeController.FacingExitedEvent.AddListener(OnFacingExited);
}
private void Start()
{
audioManager = AudioManager.Instance;
audioManagerReady = true;
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
UpdatePlayback();
}
private void OnDisable()
{
if (audioManager != null)
{
audioManager.NarrationEnabledChanged -= OnNarrationEnabledChanged;
audioManager.PauseArtWorkIntro(introClip);
}
collidersInside = 0;
isFacing = false;
if (gazeController != null)
{
gazeController.FacingEnteredEvent.RemoveListener(OnFacingEntered);
gazeController.FacingExitedEvent.RemoveListener(OnFacingExited);
}
}
private void OnTriggerEnter(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside++;
UpdatePlayback();
}
private void OnTriggerExit(Collider other)
{
if (!isActiveAndEnabled || !other.CompareTag("Player"))
{
return;
}
collidersInside = Mathf.Max(0, collidersInside - 1);
UpdatePlayback();
}
private void OnFacingEntered()
{
isFacing = true;
UpdatePlayback();
}
private void OnFacingExited()
{
isFacing = false;
UpdatePlayback();
}
private void OnNarrationEnabledChanged(bool isEnabled)
{
UpdatePlayback();
}
private void UpdatePlayback()
{
if (!audioManagerReady)
{
return;
}
if (audioManager.NarrationEnabled && collidersInside > 0 && isFacing)
{
audioManager.PlayArtWorkIntro(introClip);
}
else
{
audioManager.PauseArtWorkIntro(introClip);
}
}
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
}包含范围、凝视、设置事件、停用清理和三条件播放判断。
下载完整 ArtWorkIntroController.cs
可直接用于核对或替换作品解说控制脚本。
- 共 129 行
- 保留原有范围与凝视逻辑
using UnityEngine;
using UnityEngine.UI;
public class UIManager : Singleton<UIManager>
{
[SerializeField] private GameObject menuUI;
[SerializeField] private float distanceFromPlayer = 1.5f;
[Header("声音设置")]
[SerializeField] private Toggle backgroundMusicToggle;
[SerializeField] private Toggle narrationToggle;
protected override void Awake()
{
base.Awake();
if (Instance != this)
{
return;
}
if (menuUI == null)
{
menuUI = GameObject.Find("MenuUI");
}
if (menuUI != null)
{
menuUI.SetActive(false);
}
else
{
Debug.LogError("UIManager:没有找到 MenuUI。", this);
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.AddListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.AddListener(SetNarrationEnabled);
}
}
private void Start()
{
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
}
private void OnDestroy()
{
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
}
}
private void LateUpdate()
{
Transform player = GameManager.Instance.Player;
if (menuUI == null || menuUI.activeSelf || player == null)
{
return;
}
PlaceMenuInFrontOfPlayer(player);
}
public void ToggleMenu()
{
if (menuUI == null)
{
return;
}
bool shouldShow = !menuUI.activeSelf;
if (shouldShow && GameManager.Instance.Player != null)
{
PlaceMenuInFrontOfPlayer(GameManager.Instance.Player);
}
menuUI.SetActive(shouldShow);
}
private void SetBackgroundMusicEnabled(bool isEnabled)
{
AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
}
private void SetNarrationEnabled(bool isEnabled)
{
AudioManager.Instance.SetNarrationEnabled(isEnabled);
}
private void PlaceMenuInFrontOfPlayer(Transform player)
{
menuUI.transform.position =
player.position + player.forward * distanceFromPlayer;
menuUI.transform.forward = player.forward;
}
}包含原有菜单显示与定位逻辑,以及本课新增的两个声音开关输入。
下载完整 UIManager.cs
可直接用于核对或替换界面管理脚本。
- 共 118 行
- 保留原有菜单位置逻辑
三个文件完整后再回到 Unity
- 01
确认三个类名与文件名完全一致。
- 02
保存 AudioManager.cs、ArtWorkIntroController.cs 和 UIManager.cs。
- 03
回到 Unity,等待脚本编译完成。
- 04
打开控制台并先处理所有红色编译错误。
- 05
编译通过后,选择层级面板中的 Scripts 对象。
把两个 Toggle 交给 UIManager
- 01
把 BackgroundMusicToggle 拖入 Background Music Toggle。
- 02
把 VoiceNarrationToggle 拖入 Narration Toggle。
- 03
确认两个开关初始 Is On 都为开启。
- 04
再次核对 AudioManager 的 Music Source 与 Intro Source。
- 05
保存场景,再进入运行状态。

观察重点:Background Music Toggle 指向 BackgroundMusicToggle,Narration Toggle 指向 VoiceNarrationToggle。页面先聚焦显示右侧属性面板,点击图片可以查看完整截图。
点击图片可以查看完整截图两个 AudioSource 的关键设置
本课不重新创建音频源,但完整运行依赖这些设置。
左右滑动表格,可查看完整内容
| 对象 | 自动播放 | 循环 | 音量 |
|---|---|---|---|
| BGAudioSource | 开启 | 开启 | 0.8 |
| IntroAudioSource | 关闭 | 关闭 | 1 |
完成代码与引用
使用完整文件统一编译,并在 Scripts 对象上连接两个 Toggle。
- 控制台没有 C# 编译错误。
- 两个声音开关字段都显示具体 Toggle,不是 None。
- 两个音频源引用仍然正确。
- 保存场景后重新选择对象,引用没有丢失。
用交叉状态验证规则,而不是只听一次开关效果
完成组合测试与常见问题处理
本课最容易遗漏的是视频结束、解说结束和原地切换设置后的结果。按照组合矩阵逐项操作,才能确认用户选择不会被其它功能覆盖。必须完成的组合测试
每一项都对应一个明确机制,得到其它结果时按下一张问题表定位。
左右滑动表格,可查看完整内容
| 操作 | 预期结果 | 证明的机制 |
|---|---|---|
| 两个开关都开启 | 背景音乐正常播放 | 初始状态同步 |
| 关闭背景音乐 | 立即暂停 | 设置变化立即裁决 |
| 背景音乐关闭时播放并停止视频 | 背景音乐不恢复 | 视频请求不覆盖用户设置 |
| 背景音乐关闭、解说开启时靠近作品 | 解说可以播放 | 两项设置彼此独立 |
| 离开上述作品 | 解说停止,背景音乐仍不恢复 | 解说结束仍服从音乐设置 |
| 解说播放时关闭语音解说 | 当前解说立即停止 | Stop 与事件通知生效 |
| 解说关闭时靠近并看向作品 | 作品解说保持安静 | 播放判断包含用户设置 |
| 保持位置和朝向再开启解说 | 无需移动便从头播放 | 设置事件触发完整重算 |
| 视频播放期间打开背景音乐 | 背景音乐仍保持暂停 | 临时播放意图仍有效 |
| 视频结束且没有解说占用 | 背景音乐恢复 | 三个允许条件同时成立 |
连续完成一次声音状态演示
- 01
运行场景,打开系统菜单并进入设置页面。
- 02
关闭背景音乐,播放并停止视频,确认音乐没有恢复。
- 03
保持背景音乐关闭、解说开启,靠近并看向一件作品。
- 04
离开作品,确认解说停止且背景音乐仍不恢复。
- 05
再次让解说播放,然后关闭语音解说,确认立即停止。
- 06
保持位置与朝向,重新开启解说,确认无需移动即可从头播放。
- 07
保持作品 A 有效,再进入作品 B;退出 B 后确认系统重新选择 A。
- 08
离开作品并打开背景音乐,确认没有视频和解说占用时音乐恢复。
常见现象与处理
先根据现象定位缺少的状态或连接。
左右滑动表格,可查看完整内容
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 开关能动但声音不变 | UIManager 的 Toggle 引用为空 | 重新连接两个 Toggle 组件 |
| 视频结束后音乐误恢复 | 统一条件漏掉 backgroundMusicEnabled | 恢复三个阻止条件 |
| 关闭解说后音乐一直不恢复 | 未清除 introIsActive 或未重算 | 清除占用并调用 ApplyBackgroundMusicState |
| 原地重新开启解说无反应 | 未订阅设置事件或触发器丢失状态 | 事件调用 UpdatePlayback,并始终记录范围 |
| 一次设置变化触发多次 | 订阅后没有对应取消订阅 | 核对 Add/Remove 与 +=/-= 是否成对 |
| 停用作品对象后仍残留解说 | OnDisable 没有撤销候选或清零状态 | 停用时调用 PauseArtWorkIntro,并把距离和朝向恢复初始值 |
| 退出作品 B 后没有回到仍有效的 A | 候选列表没有正确移除 B 或保留 A | 核对 Contains、Add、Remove 与 ApplyActiveIntroState 的顺序 |
| 视频播放时音乐抢先响起 | 漏掉 backgroundMusicShouldPlay | 恢复临时播放意图判断 |
完成声音设置成果确认
逐项确认下面的结果,并解释关键操作经过了哪些状态。
- UIManager 的两个 Toggle 引用都已连接。
- AudioManager 的两个 AudioSource 引用都已连接。
- 说明四个声音状态和作品候选列表各自含义。
- 说出背景音乐实际播放的三个同时成立条件。
- 关闭背景音乐后,声音立即暂停。
- 背景音乐关闭时,视频结束不会让它自动恢复。
- 关闭语音解说后,进入作品范围也不会播放解说。
- 解说播放期间关闭开关,当前解说立即停止。
- 保持位置不动重新开启解说,系统会重新判断并从头播放。
- 视频播放期间打开背景音乐,音乐仍保持暂停。
- 视频与解说都结束且设置允许时,背景音乐能够恢复。
- 交叉进入两件作品后,离开后进入的作品会重新选择仍有效的上一件。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不查看代码,写出背景音乐实际播放的三个同时成立条件。
- 02
说明 AddListener、Start 初始同步和 RemoveListener 分别解决什么问题。
- 03
模拟玩家在解说关闭期间进入作品范围,再原地开启解说的完整状态变化。
- 04
把 VideoController 的播放请求与 AudioManager 的最终裁决画成简短调用链。
- 05
故意移除 backgroundMusicEnabled 条件,观察错误后恢复并解释原因。
- 06
连续完成背景音乐开关、视频播放、作品解说和原地切换解说的组合演示。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。