第 28 课
实现重置功能
本课在 UIManager 中实现设置重置。程序会在 Start 阶段对六项默认状态拍下一次快照,随后由 ResetSettings 同时恢复界面显示和真正生效的声音、材质与后处理参数。完成后,ResetButton 的 On Click 事件将调用 UIManager.ResetSettings,六项设置能够稳定回到本次运行的起点。
一键把六项设置恢复到本次运行的初始状态
为设置页面的重置按钮建立一条完整调用链:先保存声音、风格和画面的六项初始值,再让按钮同时恢复界面控件与实际功能。你会理解为什么静默修改控件后还要主动调用功能方法,以及后处理真实值怎样反向换算回滑块位置。
学完你将能够
学习目标
- 区分界面控件显示的状态与场景中真正生效的功能状态。
- 说出本课保存的六项默认状态,以及每一项使用的数据类型。
- 理解默认状态是本次运行在 Start 阶段取得的一次快照,而不是不断变化的当前值。
- 说明 ResetButton、UIManager、AudioManager、材质对象和 Volume Profile 各自负责什么。
- 按顺序说明后处理初始化、默认值保存、功能应用和滑块同步的启动过程。
- 读懂公开方法 ResetSettings 的入口保护和完整调用顺序。
- 使用静默赋值恢复 Toggle 与 Dropdown,并显式更新实际功能。
- 理解 Dropdown.SetValueWithoutNotify 会刷新显示,以及显式 RefreshShownValue 的作用边界。
- 使用反向范围换算把后处理真实值恢复成正确的滑块位置。
- 把 ResetButton 的 On Click 事件连接到 UIManager.ResetSettings,并完成六项联动测试。
开始操作前
准备工作
- 打开第27课保存的主场景,确认系统菜单能够正常显示和关闭。
- 确认背景音乐开关、语音解说开关、风格下拉框和三个画面滑块都能独立工作。
- 在设置页面中找到 ResetButton,并确认它带有 Button 组件。
- 在层级面板中找到挂载 UIManager 的 Scripts 对象。
- 在场景中找到 PostProcessing 对象,确认 Volume 已连接可用的 Profile。
- 打开 UIManager.cs、层级面板、属性面板和控制台,退出运行状态后再修改脚本。
- 记下进入运行状态时六项设置的起始值,稍后用它们判断重置是否正确。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。按钮看起来恢复了,不代表功能真的恢复了
先理解重置为什么要同时恢复两层状态
设置页面中的控件只是学生看得见的操作入口,声音、材质和后处理系统才保存真正影响体验的状态。可靠的重置必须让这两层重新一致。什么是界面状态与功能状态
Toggle、Dropdown 和 Slider 都保存一个界面数值。例如背景音乐 Toggle 的 isOn 表示开关勾选状态,Dropdown 的 value 表示当前选项编号,Slider 的 value 表示滑块当前位置。这些数值决定控件怎样显示。
真正产生效果的是另一层状态。背景音乐是否允许播放由 AudioManager 管理,博物馆使用哪组材质由渲染器的 sharedMaterial 决定,色温、曝光和饱和度则保存在 Volume Profile 的后处理参数中。
如果重置只改控件,按钮和滑块可能回到原位,但声音、材质或画面仍保持修改后的效果;如果只改功能,场景恢复了,界面却仍显示旧值。学生下一次操作时就会分不清当前状态。
因此每项重置都采用同一思路:先把控件恢复到默认显示,再显式调用已有功能方法,让实际状态跟上。这样界面是状态入口,功能系统是效果执行者,两者始终成对变化。
六项设置中的两层状态
观察每一行:左侧控件负责显示,右侧对象负责真正产生效果。
左右滑动表格,可查看完整内容
| 设置 | 界面状态 | 实际功能状态 | 重置目标 |
|---|---|---|---|
| 背景音乐 | Toggle.isOn | AudioManager 的背景音乐许可状态 | 恢复开关并重新应用许可 |
| 语音解说 | Toggle.isOn | AudioManager 的语音解说许可状态 | 恢复开关并重新应用许可 |
| 博物馆风格 | Dropdown.value | 地面和墙面的 sharedMaterial | 恢复选项、标签和材质 |
| 色温 | Slider.value | WhiteBalance.temperature | 恢复滑块、文字和冷暖效果 |
| 曝光 | Slider.value | ColorAdjustments.postExposure | 恢复滑块、文字和亮度 |
| 饱和度 | Slider.value | ColorAdjustments.saturation | 恢复滑块、文字和颜色浓度 |
从 ResetButton 向右观察:ResetSettings 读取启动时保存的默认快照,先恢复控件显示,再调用声音、材质和画面方法,最终让两层状态重新一致。
手机上可左右滑动;点击图片可以查看完整图为每项设置找到状态配对
打开设置页面并观察场景,把六个控件分别与真正产生效果的系统对应起来。
- 指出两个 Toggle 对应 AudioManager 中的哪两类许可状态。
- 指出 Dropdown 选择后最终改变了哪些渲染器的材质。
- 指出三个 Slider 分别写入 Volume Profile 的哪个参数。
- 任选一项,说明只恢复界面而不恢复功能会出现什么矛盾。
重置不是统一归零,而是回到本次运行的起点
保存六项默认状态的一次快照
程序必须先记住“要回到哪里”。本课在 Start 中读取一次初始状态,并把六个值保存在 UIManager 的私有字段中。 private bool defaultBackgroundMusicEnabled;
private bool defaultNarrationEnabled;
private int defaultVisualStyle;
private float defaultColorTemperature;
private float defaultExposure;
private float defaultSaturation;
private bool settingsDefaultsCaptured;六个 default 字段分别保存声音、风格与画面的初始状态;settingsDefaultsCaptured 只记录是否已经保存,不是第七项设置。此时类还没有完整,请继续完成后续方法后再统一编译。
默认值字段为什么使用不同类型
字段类型由它保存的信息决定,类型不匹配时就无法安全恢复。
左右滑动表格,可查看完整内容
| 字段 | 类型 | 保存的初始状态 |
|---|---|---|
| defaultBackgroundMusicEnabled | bool | 背景音乐是否允许播放 |
| defaultNarrationEnabled | bool | 语音解说是否允许播放 |
| defaultVisualStyle | int | 风格下拉框的选项编号 |
| defaultColorTemperature | float | 后处理色温真实值 |
| defaultExposure | float | 后处理曝光真实值 |
| defaultSaturation | float | 后处理饱和度真实值 |
| settingsDefaultsCaptured | bool | 是否已经完成默认快照 |
private void CaptureDefaultSettings()
{
defaultBackgroundMusicEnabled =
backgroundMusicToggle != null && backgroundMusicToggle.isOn;
defaultNarrationEnabled = narrationToggle != null && narrationToggle.isOn;
defaultVisualStyle = visualStyleDropdown != null ? visualStyleDropdown.value : 0;
defaultColorTemperature = whiteBalance != null
? whiteBalance.temperature.value
: GetCurrentPostProcessValue(colorTemperatureSlider, colorTemperatureRange);
defaultExposure = colorAdjustments != null
? colorAdjustments.postExposure.value
: GetCurrentPostProcessValue(exposureSlider, exposureRange);
defaultSaturation = colorAdjustments != null
? colorAdjustments.saturation.value
: GetCurrentPostProcessValue(saturationSlider, saturationRange);
settingsDefaultsCaptured = true;
}CaptureDefaultSettings 只负责读取并保存。它优先读取真正的后处理参数;如果效果引用缺失,才从滑块位置换算出后备值。这仍是完整类的一部分,请继续完成后续方法,不要单独保存回 Unity 编译。
读懂采集方法中的三个关键写法
表达式 backgroundMusicToggle != null && backgroundMusicToggle.isOn 中,&& 表示“并且”。程序先判断开关引用是否存在;如果前半句为假,就不会继续读取 isOn,这种提前停止可以避免访问不存在的对象。
表达式 条件 ? 值一 : 值二 称为条件运算符。条件成立时选择问号后的值,否则选择冒号后的值。例如 visualStyleDropdown 存在时保存它的 value,不存在时使用 0 作为后备编号。
声音和风格的初始值直接来自三个界面控件。色温、曝光和饱和度则优先读取运行时后处理效果中的真实参数;只有效果引用缺失时,才根据 Slider 当前位置推导后备真实值。
settingsDefaultsCaptured = true 必须放在六项读取之后。它表示这次快照已经完成,而不是表示六项设置都为 true;以后点击重置时,程序会读取这份快照,不会用学生修改后的状态覆盖它。
TryGet 用来尝试从运行时 Profile 中取得 WhiteBalance 和 ColorAdjustments。Profile 对象存在并不代表其中一定包含这两个效果,因此画面不响应时还要确认两个效果确实存在并已启用覆盖。
一次快照到底意味着什么
正常运行时,CaptureDefaultSettings 在 Start 中只执行一次。它记录的是进入本次运行时的六项状态。学生随后怎样拖动滑块、切换开关或选择风格,都不会改写这些 default 字段。
停止运行再重新进入时,场景会重新创建 UIManager,Start 也会再次执行,因此会根据新一轮启动时的场景配置取得新的快照。这里没有把默认值写入磁盘,也不会跨运行永久保存。
settingsDefaultsCaptured 在快照完成后设为 true。ResetSettings 仍保留一次保护:如果快照尚未完成,它会先补做初始化与保存。但按正常生命周期,按钮可操作时 Start 已经完成。
postProcessVolume.profile 指向运行时使用的 Profile 实例。Unity 在访问 profile 时会为运行创建实例,因此脚本修改的是本次运行中的后处理状态,不会把拖动结果直接写回项目中的共享配置资源。
核对六项默认值的读取位置
在代码中逐项找到值的来源,并判断它属于控件初始值还是后处理真实值。
- 圈出两个 Toggle 的 isOn 和 Dropdown 的 value。
- 圈出 temperature、postExposure 与 saturation 的 value。
- 说明 whiteBalance 或 colorAdjustments 缺失时采用什么后备路径。
- 说明学生第一次改动滑块后,为什么默认快照不会跟着改变。
按钮发出请求,UIManager 统一协调,各功能对象完成执行
分清重置流程中各对象的职责
重置会同时经过界面、声音、材质和后处理系统。先分清每个对象负责什么,后面阅读方法调用时就不会把所有逻辑混在一起。为什么由 UIManager 负责统一协调
ResetButton 只负责在学生点击时发出一次 On Click 事件。它不保存默认值,也不直接控制声音、材质或画面。
UIManager 持有两个 Toggle、一个 Dropdown、三个 Slider、材质数组和 Volume 引用,因此它能够保存六项默认值,并把一次重置请求分发给正确的功能方法。
AudioManager 继续决定声音是否允许播放;地面和墙面渲染器负责呈现场景风格;Volume Profile 中的 WhiteBalance 与 ColorAdjustments 负责最终画面。UIManager 只负责把同一个默认目标准确交给它们。
GameManager 继续提供玩家引用和菜单输入,不承担设置快照。让每个对象保持单一职责,可以避免同一状态被多个脚本重复保存,也让问题更容易沿调用链定位。
重置流程中的对象分工
从上到下观察一次按钮点击怎样被逐层传递,最后形成学生能够看到和听到的结果。
左右滑动表格,可查看完整内容
| 对象 | 主要职责 | 接收的信息 | 产生的结果 |
|---|---|---|---|
| ResetButton | 发送点击事件 | 学生点击 | 调用 UIManager.ResetSettings |
| UIManager | 保存默认快照并协调六项恢复 | 按钮请求与六项默认值 | 更新控件,并调用声音、材质和画面方法 |
| AudioManager | 处理声音许可与播放状态 | 两个默认布尔值 | 背景音乐与语音解说恢复到允许或关闭状态 |
| 地面和墙面渲染器 | 应用风格材质 | 默认风格编号 | 地面与墙面恢复同一套材质 |
| Volume Profile | 保存并应用后处理参数 | 默认色温、曝光和饱和度 | 最终画面恢复到启动效果 |
| GameManager | 提供玩家引用与菜单输入 | 玩家和输入状态 | 继续完成原有职责,不保存设置默认值 |
沿一项设置读懂完整协作链
- 01
以背景音乐为例:ResetButton 调用 ResetSettings。
- 02
UIManager 读取 defaultBackgroundMusicEnabled,并静默恢复 Toggle。
- 03
UIManager 把同一个布尔值交给 SetBackgroundMusicEnabled。
- 04
AudioManager 接收许可状态,再结合播放请求和解说状态决定是否播放。
- 05
最后同时观察 Toggle 显示和实际声音,确认两层状态一致。
为另外两类设置补全调用链
参照背景音乐示例,分别为场景风格和曝光写出从按钮到最终效果的传递顺序。
- 场景风格链必须包含 Dropdown、ApplyVisualStyle、地面和墙面渲染器。
- 曝光链必须包含默认真实值、Slider、ApplyExposure 和 ColorAdjustments。
- 每条链最后都要同时写出界面结果与真实效果。
- 说明为什么不需要让 ResetButton 自己保存六项默认值。
Start 的先后关系决定默认快照是否可信
按正确顺序初始化、保存和应用状态
同样四组调用,如果顺序不同,就可能把错误的值保存成默认状态。这里要沿着一帧中的实际执行顺序逐行阅读 Start。 private void Start()
{
InitializePostProcessing();
CaptureDefaultSettings();
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
if (visualStyleDropdown != null)
{
ApplyVisualStyle(visualStyleDropdown.value);
}
SyncSlidersWithPostProcessing();
}Start 在对象启用后的初始化阶段执行一次。它不会逐帧运行。此时其他方法仍需继续组合,完成整份脚本后再统一编译。
private void InitializePostProcessing()
{
if (postProcessVolume == null || postProcessVolume.profile == null)
{
Debug.LogWarning("UIManager:没有指定可用的后处理 Volume。", this);
return;
}
postProcessVolume.profile.TryGet(out whiteBalance);
postProcessVolume.profile.TryGet(out colorAdjustments);
}InitializePostProcessing 是 Start 的第一步。TryGet 会尝试取得 WhiteBalance 与 ColorAdjustments,并把结果保存到字段。Profile 存在不代表两个效果一定存在;整份脚本尚未完成,请继续后面的重置方法后再统一编译。
Start 中依次发生的四个阶段
- 01
InitializePostProcessing 访问 Volume.profile 的运行时实例,并尝试取得 WhiteBalance 与 ColorAdjustments。
- 02
CaptureDefaultSettings 立即读取两个开关、风格编号和三个后处理真实值,完成一次默认快照。
- 03
三个 if 分支把当前控件状态应用到声音与材质系统;引用为空时跳过对应项,不影响其他设置。
- 04
SyncSlidersWithPostProcessing 根据真实后处理值定位三个滑块,再调用画面方法更新数值文字和最终效果。
为什么保存默认值必须排在应用和同步之前
后处理默认值应该来自运行时 Profile 中的真实参数。InitializePostProcessing 必须先执行,否则 whiteBalance 与 colorAdjustments 仍为空,只能退回滑块换算值。
CaptureDefaultSettings 随后锁定起点。它排在 SyncSlidersWithPostProcessing 之前,可以明确保证快照来自启动配置,而不是同步过程可能写回的结果。
声音和风格在快照之后才应用。这样 UIManager 先记住学生进入体验时看到的控件状态,再让功能系统与这些状态一致。
Awake 已经添加控件监听,Start 才做上述初始化。Start 结束后不再重复保存;下一帧继续运行时,只有用户操作、菜单定位或按钮事件会进入各自的方法。
复述启动调用链
不看代码,用四个动词复述 Start,并说明交换前两步会造成什么风险。
- 依次说出:取得、保存、应用、同步。
- 说明先保存再取得后处理效果时,三个画面默认值可能来自哪里。
- 说明 Start 完成后拖动滑块为什么不会再次调用 CaptureDefaultSettings。
- 确认控制台没有后处理 Volume 缺失警告。
一个入口负责调度,已有方法负责执行
建立按钮可以调用的公开重置入口
ResetButton 不需要认识六个字段和多个系统,只调用一个 public 方法。ResetSettings 再按固定顺序把默认快照交给各类恢复逻辑。 public void ResetSettings()
{
if (!settingsDefaultsCaptured)
{
InitializePostProcessing();
CaptureDefaultSettings();
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.SetIsOnWithoutNotify(defaultBackgroundMusicEnabled);
SetBackgroundMusicEnabled(defaultBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.SetIsOnWithoutNotify(defaultNarrationEnabled);
SetNarrationEnabled(defaultNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.SetValueWithoutNotify(defaultVisualStyle);
visualStyleDropdown.RefreshShownValue();
ApplyVisualStyle(defaultVisualStyle);
}
ResetColorTemperature();
ResetExposure();
ResetSaturation();
}把 public ResetSettings 放在 ToggleMenu 后面。public 表示 Unity 的 Button 事件可以在属性面板中找到并调用它。后面的三个画面恢复方法还要继续补全。
从按钮点击到六项恢复的执行顺序
点击按钮后,Unity 先调用 ResetSettings。方法首先检查 settingsDefaultsCaptured。正常情况下它已经为 true;保护分支只在快照尚未建立时补做后处理初始化与默认值保存。
条件中的感叹号 ! 表示“不是”。因此 !settingsDefaultsCaptured 可以读成“还没有保存默认快照”。只有这个条件成立时,大括号中的两个准备方法才会执行;快照已经存在时,程序会直接继续恢复六项设置。
接下来依次处理背景音乐 Toggle、语音 Toggle 和风格 Dropdown。每组代码都先检查引用是否存在,因此某一个控件漏连时,只会跳过对应设置,不会阻止后面的画面恢复。
方法最后调用 ResetColorTemperature、ResetExposure 和 ResetSaturation。三个小方法分别负责一条画面链,让公开入口保持清楚,不把所有换算与赋值都堆在同一个方法里。
ResetSettings 没有返回值,因为按钮只需要发出动作,不需要从方法取得计算结果。它也不接收参数,因为所有目标值都已经保存在 UIManager 的默认字段中。
一次点击进入哪些方法
- 01
ResetButton 的 On Click 调用 UIManager.ResetSettings。
- 02
若默认快照缺失,先调用 InitializePostProcessing 和 CaptureDefaultSettings。
- 03
恢复两个声音开关并调用两项声音设置方法。
- 04
恢复风格选项、刷新标签并调用 ApplyVisualStyle。
- 05
依次调用三个画面恢复方法,更新滑块、文字和后处理。
比较三类控件的共同规则:先静默恢复控件,避免重复触发监听;再显式调用功能方法,让界面与实际效果保持一致。Slider 还要先把后处理真实值换算成滑块位置。
手机上可左右滑动;点击图片可以查看完整图检查公开入口的边界
逐段指出 ResetSettings 负责调度什么,又把哪些具体工作交给已有方法。
- 圈出 public,并说明它与按钮事件连接的关系。
- 圈出默认快照保护分支,说明正常运行时是否会重复保存。
- 找到三处空引用保护和三个画面恢复调用。
- 说明为什么 ResetSettings 不需要参数和返回值。
静默改界面后,功能方法不会自动执行
恢复两个开关,并显式更新声音系统
Toggle 的 SetIsOnWithoutNotify 可以避免触发 onValueChanged,但也意味着 AudioManager 不会收到自动通知。因此代码必须紧接着主动调用声音设置方法。 if (backgroundMusicToggle != null)
{
backgroundMusicToggle.SetIsOnWithoutNotify(defaultBackgroundMusicEnabled);
SetBackgroundMusicEnabled(defaultBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.SetIsOnWithoutNotify(defaultNarrationEnabled);
SetNarrationEnabled(defaultNarrationEnabled);
}
private void SetBackgroundMusicEnabled(bool isEnabled)
{
AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
}
private void SetNarrationEnabled(bool isEnabled)
{
AudioManager.Instance.SetNarrationEnabled(isEnabled);
}代码框把 ResetSettings 中的两个分支与类中已有的声音转发方法放在一起讲解,它们在完整文件中位于不同位置。请理解调用关系,不要把这段内容重复粘贴;整份脚本完成后再统一保存编译。
为什么不直接给 isOn 赋值
backgroundMusicToggle 和 narrationToggle 在 Awake 中都订阅了 onValueChanged。直接执行 isOn = value 会进入 Toggle.Set;当新值与旧值不同时,它默认会调用 onValueChanged。若随后又手动调用声音方法,同一次恢复就会重复更新声音状态;若值本来相同,Set 会提前结束,不发送事件。
SetIsOnWithoutNotify 会更新 Toggle 保存的值和勾选图形,但把发送回调的参数设为 false,因此不会调用 onValueChanged。它适合程序主动同步界面,可以让本次恢复只有一条明确调用链。
静默赋值的代价是监听方法不会自动运行。因此下一行必须显式调用 SetBackgroundMusicEnabled 或 SetNarrationEnabled,把相同的默认布尔值交给 AudioManager。
两组代码彼此独立。即使一个 Toggle 引用为空,另一个仍能正常恢复。布尔值 true 表示允许对应功能,false 表示关闭对应功能。
声音开关恢复的两步动作
每一项都先处理可见控件,再把同一个值交给实际系统。
左右滑动表格,可查看完整内容
| 顺序 | 背景音乐 | 语音解说 | 是否触发 Toggle 监听 |
|---|---|---|---|
| 1 恢复界面 | SetIsOnWithoutNotify(defaultBackgroundMusicEnabled) | SetIsOnWithoutNotify(defaultNarrationEnabled) | 否 |
| 2 恢复功能 | SetBackgroundMusicEnabled(defaultBackgroundMusicEnabled) | SetNarrationEnabled(defaultNarrationEnabled) | 直接调用,不依赖监听 |
跟踪一次背景音乐恢复
先关闭背景音乐,再点击重置,并沿代码观察界面与 AudioManager 的两步变化。
- 确认 Toggle 先显示默认勾选状态。
- 确认下一行把同一个布尔值传入 SetBackgroundMusicEnabled。
- 如果语音解说正在播放,说明为什么背景音乐即使恢复为允许也可能暂不播放。
- 再测试语音 Toggle,确认两项声音状态互不串联。
Dropdown 的数值、标签与材质是三个需要同步的结果
恢复风格选项、显示文字和场景材质
风格重置不只要把 value 改回默认编号,还要保证选项文字正确,并把同一编号应用到地面和墙面材质。本课使用的 uGUI 组件会在静默赋值内部刷新文字,代码又显式刷新一次来强调同步意图。 if (visualStyleDropdown != null)
{
visualStyleDropdown.SetValueWithoutNotify(defaultVisualStyle);
visualStyleDropdown.RefreshShownValue();
ApplyVisualStyle(defaultVisualStyle);
}
private void ApplyVisualStyle(int styleIndex)
{
ApplyMaterial(floorRenderers, floorMaterials, styleIndex);
ApplyMaterial(wallRenderers, wallMaterials, styleIndex);
}代码框把 ResetSettings 中的风格分支与类中已有的 ApplyVisualStyle 放在一起讲解,它们在完整文件中位于不同位置。请沿调用关系阅读,不要重复粘贴;该方法会按相同索引为地面和墙面选择材质。
同一个默认编号怎样传递
- 01
SetValueWithoutNotify 进入 Set(defaultVisualStyle, false):编号变化时会限制索引范围并在内部调用 RefreshShownValue,但不触发 onValueChanged。
- 02
下一行显式调用 RefreshShownValue,再次要求 Dropdown 按当前编号刷新选项文字。编号确实变化时会重复刷新一次,但没有功能副作用;编号相同时,前一步会提前结束,这一行仍能主动刷新显示。
- 03
ApplyVisualStyle 把 defaultVisualStyle 传入材质切换逻辑。
- 04
ApplyMaterial 使用相同索引分别从 floorMaterials 和 wallMaterials 取得材质,再赋给目标渲染器。
内部已经刷新,为什么还显式调用一次
在本课使用的 uGUI Dropdown 中,SetValueWithoutNotify 会调用内部 Set(input, false)。只要编号发生变化,Set 就会更新 m_Value,并在返回前自动执行 RefreshShownValue。因此静默赋值本身已经能够刷新标签。
紧接着再写一行 RefreshShownValue,并不是完成正常刷新所必需的步骤。它强化了“重置后确保标签同步”的代码意图,也为控件实现或调用条件发生变化时提供一层保险。编号变化时虽然会刷新两次,但第二次只重绘显示,不会触发值变化事件,最终结果相同。
还有一个边界:运行时如果传入的编号与控件中的编号相同,Dropdown.Set 会提前返回;后面的显式 RefreshShownValue 仍会执行。无论哪种情况,刷新只处理可见标签,不会替换场景材质。
ApplyVisualStyle 只接收一个 int 索引。只要地面材质数组、墙面材质数组和 Dropdown 选项保持相同顺序,默认编号就能同时恢复两组材质。
若 Dropdown 引用为空,整个风格分支会跳过,两个声音和三个画面设置仍继续恢复。若标签恢复但材质没变,应先核对材质数组,而不是反复修改 RefreshShownValue。
验证风格恢复的三个观察点
先切换到非默认风格,再点击重置,按界面到场景的顺序检查。
- 观察 Dropdown.value 是否回到启动时保存的编号。
- 观察折叠状态下的选项文字是否同步刷新。
- 观察地面与两侧墙面是否一起恢复默认材质。
- 若只有标签恢复,检查两组材质数组的相同索引是否对应同一风格。
默认值保存在效果范围中,控件却工作在滑块范围中
把后处理真实值反向换算回滑块
三个画面默认值来自 Volume Profile,不能直接当作 Slider.value。代码先做反向范围换算定位滑块,再调用已有 Apply 方法完成正向换算和效果更新。 private void ResetColorTemperature()
{
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.SetValueWithoutNotify(
GetSliderValue(colorTemperatureSlider, defaultColorTemperature, colorTemperatureRange));
ApplyColorTemperature(colorTemperatureSlider.value);
}
else if (whiteBalance != null)
{
whiteBalance.temperature.value = defaultColorTemperature;
}
}
private void ResetExposure()
{
if (exposureSlider != null)
{
exposureSlider.SetValueWithoutNotify(
GetSliderValue(exposureSlider, defaultExposure, exposureRange));
ApplyExposure(exposureSlider.value);
}
else if (colorAdjustments != null)
{
colorAdjustments.postExposure.value = defaultExposure;
}
}
private void ResetSaturation()
{
if (saturationSlider != null)
{
saturationSlider.SetValueWithoutNotify(
GetSliderValue(saturationSlider, defaultSaturation, saturationRange));
ApplySaturation(saturationSlider.value);
}
else if (colorAdjustments != null)
{
colorAdjustments.saturation.value = defaultSaturation;
}
}正常路径先把真实默认值换成 Slider.value,再调用对应 Apply 方法。代码也保留缺少引用时的后备分支,但后备分支只能恢复仍然存在的那部分状态。此时完整类尚未结束,请继续完成范围换算与其余方法后再统一编译。
private static float GetPostProcessValue(Slider slider, float sliderValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(slider.minValue, slider.maxValue))
{
return valueRange.x;
}
float normalizedValue = Mathf.InverseLerp(slider.minValue, slider.maxValue, sliderValue);
return Mathf.Lerp(valueRange.x, valueRange.y, normalizedValue);
}
private static float GetCurrentPostProcessValue(Slider slider, Vector2 valueRange)
{
return slider != null
? GetPostProcessValue(slider, slider.value, valueRange)
: valueRange.x;
}
private static float GetSliderValue(Slider slider, float postProcessValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(valueRange.x, valueRange.y))
{
return 0f;
}
float normalizedValue = Mathf.InverseLerp(valueRange.x, valueRange.y, postProcessValue);
return Mathf.Lerp(slider.minValue, slider.maxValue, normalizedValue);
}GetSliderValue 完成“后处理真实值→滑块位置”,GetPostProcessValue 完成“滑块位置→后处理真实值”。GetCurrentPostProcessValue 是默认快照的后备读取路径。先完成理解,下一模块核对完整文件后再统一保存编译。
用三个真实数值走一遍换算
三个 Slider 的范围都是 0 到 1。色温目标范围是 -100 到 100,默认真实值为 2。InverseLerp 得到 (2 - -100) ÷ 200 = 0.51,因此色温滑块回到 0.51。
曝光目标范围是 -5 到 5,默认真实值为 1.5。归一化位置是 (1.5 - -5) ÷ 10 = 0.65,因此曝光滑块回到 0.65。
饱和度目标范围是 -100 到 100,默认真实值为 0。归一化位置是 (0 - -100) ÷ 200 = 0.5,因此饱和度滑块回到正中间。
SetValueWithoutNotify 只改变滑块位置,不触发 onValueChanged。下一行显式调用 ApplyColorTemperature、ApplyExposure 或 ApplySaturation,方法会更新数值文字,把滑块值正向换算回真实值,并写入运行时 Volume Profile。
三个默认值的反向换算结果
表中结果以 Slider 范围 0 到 1 为前提。
左右滑动表格,可查看完整内容
| 设置 | 后处理范围 | 默认真实值 | 归一化滑块位置 | 重新应用后的真实值 |
|---|---|---|---|---|
| 色温 | -100 到 100 | 2 | 0.51 | 2 |
| 曝光 | -5 到 5 | 1.5 | 0.65 | 1.5 |
| 饱和度 | -100 到 100 | 0 | 0.5 | 0 |
手算并观察一次往返
选择曝光链,先按公式计算,再在运行状态中核对滑块、文字与画面。
- 用 InverseLerp 思路把曝光真实值 1.5 换算为 0.65。
- 把曝光滑块拖离 0.65,观察文字和画面发生变化。
- 点击重置,确认 Slider.value、ValueText 和 postExposure 同时恢复。
- 连续点击两次,确认第二次不会继续改变数值。
先完成编译,再让场景按钮发出调用
组合完整脚本并连接重置按钮
前面的分段用于理解调用关系,现在用完整文件补齐所有依赖并统一编译。编译成功后,再通过 Button 的 On Click 把 ResetButton 连接到公开重置方法。using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
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;
[Header("风格设置")]
[SerializeField] private Dropdown visualStyleDropdown;
[SerializeField] private Renderer[] floorRenderers;
[SerializeField] private Renderer[] wallRenderers;
[SerializeField] private Material[] floorMaterials;
[SerializeField] private Material[] wallMaterials;
[Header("后处理设置")]
[SerializeField] private Volume postProcessVolume;
[SerializeField] private Slider colorTemperatureSlider;
[SerializeField] private Slider exposureSlider;
[SerializeField] private Slider saturationSlider;
[Header("后处理滑块对应范围")]
[SerializeField] private Vector2 colorTemperatureRange = new Vector2(-100f, 100f);
[SerializeField] private Vector2 exposureRange = new Vector2(-5f, 5f);
[SerializeField] private Vector2 saturationRange = new Vector2(-100f, 100f);
private Text colorTemperatureValueText;
private Text exposureValueText;
private Text saturationValueText;
private WhiteBalance whiteBalance;
private ColorAdjustments colorAdjustments;
private bool defaultBackgroundMusicEnabled;
private bool defaultNarrationEnabled;
private int defaultVisualStyle;
private float defaultColorTemperature;
private float defaultExposure;
private float defaultSaturation;
private bool settingsDefaultsCaptured;
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);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.AddListener(ApplyVisualStyle);
}
colorTemperatureValueText = FindValueText(colorTemperatureSlider);
exposureValueText = FindValueText(exposureSlider);
saturationValueText = FindValueText(saturationSlider);
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.AddListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.AddListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.AddListener(ApplySaturation);
}
}
private void Start()
{
InitializePostProcessing();
CaptureDefaultSettings();
if (backgroundMusicToggle != null)
{
SetBackgroundMusicEnabled(backgroundMusicToggle.isOn);
}
if (narrationToggle != null)
{
SetNarrationEnabled(narrationToggle.isOn);
}
if (visualStyleDropdown != null)
{
ApplyVisualStyle(visualStyleDropdown.value);
}
SyncSlidersWithPostProcessing();
}
private void OnDestroy()
{
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.onValueChanged.RemoveListener(SetBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.onValueChanged.RemoveListener(SetNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.onValueChanged.RemoveListener(ApplyVisualStyle);
}
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.onValueChanged.RemoveListener(ApplyColorTemperature);
}
if (exposureSlider != null)
{
exposureSlider.onValueChanged.RemoveListener(ApplyExposure);
}
if (saturationSlider != null)
{
saturationSlider.onValueChanged.RemoveListener(ApplySaturation);
}
}
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);
}
public void ResetSettings()
{
if (!settingsDefaultsCaptured)
{
InitializePostProcessing();
CaptureDefaultSettings();
}
if (backgroundMusicToggle != null)
{
backgroundMusicToggle.SetIsOnWithoutNotify(defaultBackgroundMusicEnabled);
SetBackgroundMusicEnabled(defaultBackgroundMusicEnabled);
}
if (narrationToggle != null)
{
narrationToggle.SetIsOnWithoutNotify(defaultNarrationEnabled);
SetNarrationEnabled(defaultNarrationEnabled);
}
if (visualStyleDropdown != null)
{
visualStyleDropdown.SetValueWithoutNotify(defaultVisualStyle);
visualStyleDropdown.RefreshShownValue();
ApplyVisualStyle(defaultVisualStyle);
}
ResetColorTemperature();
ResetExposure();
ResetSaturation();
}
private void SetBackgroundMusicEnabled(bool isEnabled)
{
AudioManager.Instance.SetBackgroundMusicEnabled(isEnabled);
}
private void SetNarrationEnabled(bool isEnabled)
{
AudioManager.Instance.SetNarrationEnabled(isEnabled);
}
private void ApplyVisualStyle(int styleIndex)
{
ApplyMaterial(floorRenderers, floorMaterials, styleIndex);
ApplyMaterial(wallRenderers, wallMaterials, styleIndex);
}
private void InitializePostProcessing()
{
if (postProcessVolume == null || postProcessVolume.profile == null)
{
Debug.LogWarning("UIManager:没有指定可用的后处理 Volume。", this);
return;
}
postProcessVolume.profile.TryGet(out whiteBalance);
postProcessVolume.profile.TryGet(out colorAdjustments);
}
private void CaptureDefaultSettings()
{
defaultBackgroundMusicEnabled =
backgroundMusicToggle != null && backgroundMusicToggle.isOn;
defaultNarrationEnabled = narrationToggle != null && narrationToggle.isOn;
defaultVisualStyle = visualStyleDropdown != null ? visualStyleDropdown.value : 0;
defaultColorTemperature = whiteBalance != null
? whiteBalance.temperature.value
: GetCurrentPostProcessValue(colorTemperatureSlider, colorTemperatureRange);
defaultExposure = colorAdjustments != null
? colorAdjustments.postExposure.value
: GetCurrentPostProcessValue(exposureSlider, exposureRange);
defaultSaturation = colorAdjustments != null
? colorAdjustments.saturation.value
: GetCurrentPostProcessValue(saturationSlider, saturationRange);
settingsDefaultsCaptured = true;
}
private void ResetColorTemperature()
{
if (colorTemperatureSlider != null)
{
colorTemperatureSlider.SetValueWithoutNotify(
GetSliderValue(colorTemperatureSlider, defaultColorTemperature, colorTemperatureRange));
ApplyColorTemperature(colorTemperatureSlider.value);
}
else if (whiteBalance != null)
{
whiteBalance.temperature.value = defaultColorTemperature;
}
}
private void ResetExposure()
{
if (exposureSlider != null)
{
exposureSlider.SetValueWithoutNotify(
GetSliderValue(exposureSlider, defaultExposure, exposureRange));
ApplyExposure(exposureSlider.value);
}
else if (colorAdjustments != null)
{
colorAdjustments.postExposure.value = defaultExposure;
}
}
private void ResetSaturation()
{
if (saturationSlider != null)
{
saturationSlider.SetValueWithoutNotify(
GetSliderValue(saturationSlider, defaultSaturation, saturationRange));
ApplySaturation(saturationSlider.value);
}
else if (colorAdjustments != null)
{
colorAdjustments.saturation.value = defaultSaturation;
}
}
private void ApplyColorTemperature(float value)
{
float temperature = GetPostProcessValue(colorTemperatureSlider, value, colorTemperatureRange);
UpdateValueText(colorTemperatureValueText, temperature);
if (whiteBalance != null)
{
whiteBalance.temperature.overrideState = true;
whiteBalance.temperature.value = temperature;
}
}
private void ApplyExposure(float value)
{
float exposure = GetPostProcessValue(exposureSlider, value, exposureRange);
UpdateValueText(exposureValueText, exposure);
if (colorAdjustments != null)
{
colorAdjustments.postExposure.overrideState = true;
colorAdjustments.postExposure.value = exposure;
}
}
private void ApplySaturation(float value)
{
float saturation = GetPostProcessValue(saturationSlider, value, saturationRange);
UpdateValueText(saturationValueText, saturation);
if (colorAdjustments != null)
{
colorAdjustments.saturation.overrideState = true;
colorAdjustments.saturation.value = saturation;
}
}
private void SyncSlidersWithPostProcessing()
{
if (colorTemperatureSlider != null && whiteBalance != null)
{
colorTemperatureSlider.SetValueWithoutNotify(
GetSliderValue(colorTemperatureSlider, whiteBalance.temperature.value, colorTemperatureRange));
}
if (exposureSlider != null && colorAdjustments != null)
{
exposureSlider.SetValueWithoutNotify(
GetSliderValue(exposureSlider, colorAdjustments.postExposure.value, exposureRange));
}
if (saturationSlider != null && colorAdjustments != null)
{
saturationSlider.SetValueWithoutNotify(
GetSliderValue(saturationSlider, colorAdjustments.saturation.value, saturationRange));
}
if (colorTemperatureSlider != null)
{
ApplyColorTemperature(colorTemperatureSlider.value);
}
if (exposureSlider != null)
{
ApplyExposure(exposureSlider.value);
}
if (saturationSlider != null)
{
ApplySaturation(saturationSlider.value);
}
}
private static float GetPostProcessValue(Slider slider, float sliderValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(slider.minValue, slider.maxValue))
{
return valueRange.x;
}
float normalizedValue = Mathf.InverseLerp(slider.minValue, slider.maxValue, sliderValue);
return Mathf.Lerp(valueRange.x, valueRange.y, normalizedValue);
}
private static float GetCurrentPostProcessValue(Slider slider, Vector2 valueRange)
{
return slider != null
? GetPostProcessValue(slider, slider.value, valueRange)
: valueRange.x;
}
private static float GetSliderValue(Slider slider, float postProcessValue, Vector2 valueRange)
{
if (slider == null || Mathf.Approximately(valueRange.x, valueRange.y))
{
return 0f;
}
float normalizedValue = Mathf.InverseLerp(valueRange.x, valueRange.y, postProcessValue);
return Mathf.Lerp(slider.minValue, slider.maxValue, normalizedValue);
}
private static Text FindValueText(Slider slider)
{
if (slider == null || slider.transform.parent == null)
{
return null;
}
Transform parent = slider.transform.parent;
Text textAfterSlider = null;
foreach (Transform sibling in parent)
{
Text siblingText = sibling.GetComponent<Text>();
if (siblingText == null)
{
continue;
}
if (sibling.name.IndexOf("Value", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
return siblingText;
}
if (sibling.GetSiblingIndex() > slider.transform.GetSiblingIndex() && textAfterSlider == null)
{
textAfterSlider = siblingText;
}
}
return textAfterSlider;
}
private static void UpdateValueText(Text valueText, float value)
{
if (valueText == null)
{
return;
}
valueText.text = value.ToString("0.##");
}
private static void ApplyMaterial(Renderer[] renderers, Material[] materials, int styleIndex)
{
if (renderers == null || materials == null ||
styleIndex < 0 || styleIndex >= materials.Length ||
materials[styleIndex] == null)
{
return;
}
foreach (Renderer targetRenderer in renderers)
{
if (targetRenderer != null)
{
targetRenderer.sharedMaterial = materials[styleIndex];
}
}
}
private void PlaceMenuInFrontOfPlayer(Transform player)
{
menuUI.transform.position =
player.position + player.forward * distanceFromPlayer;
menuUI.transform.forward = player.forward;
}
}
这份完整脚本同时保留菜单、声音、风格和画面功能,并加入重置逻辑。现在所有字段与方法都已完整,可以统一保存脚本并等待 Unity 编译。
下载完整 UIManager.cs
如果不手动组合分段代码,可以下载完整脚本,用它核对或替换现有 UIManager.cs 的文件内容。
- 文件同时包含菜单定位、声音设置、风格切换、画面调节和六项重置。
- 文件名与类名必须继续使用 UIManager.cs 和 UIManager。
- 只替换现有脚本内容,不要新建第二个 UIManager 组件。
- 不需要下载或替换 .meta 文件,保留项目中原有的脚本资源信息。
- 保存脚本并等待编译完成,再连接 ResetButton 的 On Click 并保存场景。
确认完整脚本已经可以使用
- 01
用完整文件补齐 UIManager.cs,确认类名和文件名保持一致。
- 02
保存脚本并返回 Unity,等待右下角的编译过程结束。
- 03
打开控制台,先解决所有红色编译错误;存在错误时不要继续连接按钮。
- 04
选中 Scripts,确认菜单、两个开关、风格控件、材质数组、Volume 和三个滑块引用仍然完整。
- 05
编译与引用都正常后,再进入下面的按钮连接步骤。
检查 ResetButton 的 On Click
- 01
退出运行状态,在层级面板中选择设置页面下的 ResetButton。
- 02
在属性面板找到 Button 组件,展开 On Click 列表。
- 03
确认事件目标是挂载 UIManager 的 Scripts 对象,而不是 ResetButton 自己。
- 04
确认方法选择为 UIManager → ResetSettings,不选择 ToggleMenu 或其他方法。
- 05
如果列表为空,点击加号,把 Scripts 对象拖入目标槽,再选择 UIManager.ResetSettings。
- 06
保存当前场景,等待保存完成后再进入运行状态测试。

重点观察 On Click 的两处信息:目标对象是挂载 UIManager 的 Scripts,右侧方法是 UIManager.ResetSettings。完成相同连接后,记得保存场景。
点击图片可以查看完整截图On Click 保存的是什么
Button 组件不会按方法名在全场景搜索。On Click 会保存一个目标对象引用,再保存这个对象上某个组件的公开方法。两者必须同时正确。
这里拖入 Scripts,是因为 UIManager 组件挂载在它上面。选择 UIManager.ResetSettings 后,点击按钮才会进入本课写好的公开入口。
按钮连接属于场景数据。即使脚本编译正确,如果没有保存场景,下次打开项目时刚建立的连接仍可能丢失。因此核对现有连接后也要执行保存。
如果方法下拉列表中看不到 ResetSettings,先检查控制台是否有红色编译错误,再确认方法是 public void ResetSettings(),没有参数。
完成按钮连接并证明调用成功
先修改至少两项设置,再点击 ResetButton,通过可见结果证明事件已经进入 ResetSettings。
- 把背景音乐开关改为与启动状态相反。
- 把曝光滑块拖到明显不同的位置。
- 点击 ResetButton,确认开关、滑块文字和画面同时恢复。
- 退出运行状态,确认 On Click 连接仍存在,并再次保存场景。
从一次点击到六项结果,逐层观察状态变化
推演完整调用链并验证六项重置
脚本与按钮已经连接完成。最后沿生命周期和按钮调用链推演一次重置,再用有顺序的操作确认每一层状态都回到本次运行的起点。一次点击怎样依次恢复六项设置
- 01
学生点击 ResetButton,Button.onClick 调用 UIManager.ResetSettings。
- 02
ResetSettings 先判断默认快照是否已经建立;正常情况下直接继续,只有尚未建立时才补做初始化与采集。
- 03
两个 Toggle 先静默恢复,再分别把默认布尔值交给 AudioManager。
- 04
Dropdown 静默恢复编号并刷新标题,再把默认编号交给 ApplyVisualStyle,恢复地面和墙面材质。
- 05
三个 Reset 方法把后处理真实默认值反向换算到 Slider,再调用对应 Apply 方法更新文字、覆盖状态和画面。
- 06
方法执行结束后,控件显示、声音许可、场景材质和后处理参数都与启动默认快照一致。
UIManager 在不同阶段做什么
这些阶段的触发时机不同。重置由一次按钮事件触发,不会在后续每一帧自动重复。
左右滑动表格,可查看完整内容
| 阶段 | 触发时机 | 主要工作 | 是否重复执行 |
|---|---|---|---|
| Awake | 组件初始化时先执行 | 寻找对象、查找数值文字并注册六个控件监听 | 每个组件实例执行一次 |
| Start | Awake 之后、对象开始工作时 | 取得后处理效果、保存默认快照并同步初始状态 | 每次进入运行状态执行一次 |
| onValueChanged | 学生操作某个控件时 | 只应用这一个控件对应的功能 | 每次有效操作触发 |
| Button.onClick | 学生点击重置按钮时 | 调用一次 ResetSettings 并依次恢复六项设置 | 每次点击触发一次 |
| OnDestroy | 组件被销毁时 | 解除六个控件监听 | 每个组件实例销毁时执行一次 |
为什么连续点击仍得到同一结果
第一次点击会把修改后的六项设置恢复为同一份默认快照。第二次、第三次点击仍然读取这份没有改变的快照,因此最终状态不会继续偏移、累加或轮换。
这种“执行一次或多次,最终状态相同”的性质称为幂等性。重置采用直接赋值,而不是在当前值上加减,所以具备这一性质。
幂等性不表示第二次点击会跳过方法。每次点击仍会再次进入 ResetSettings,并调用声音、材质和画面方法;它保证的是执行完成后的六项状态相同。
ResetSettings 是按钮事件方法,不是 Update 或 LateUpdate。一次点击处理结束后,下一帧不会因为这个方法本身自动再次重置,除非学生再次点击或其他代码再次调用它。
按固定顺序完成六项联动测试
- 01
进入运行状态,先记录两个 Toggle、Dropdown 和三个画面数值文字的起始状态。
- 02
把两个声音开关都改为与起始状态不同,并确认声音功能作出对应反应。
- 03
选择另一个博物馆风格,确认下拉文字、地面和墙面材质一起改变。
- 04
拖动色温、曝光和饱和度滑块,确认三个文字与最终画面都明显改变。
- 05
点击 ResetButton,逐项核对六个控件、两项声音许可、地面墙面材质和三个后处理参数。
- 06
再次点击 ResetButton,确认所有结果保持不变,没有继续偏移、反向切换或生成新的默认值。
- 07
退出运行状态,保存当前场景,并再次进入运行状态做一次快速复测。
常见现象与定位顺序
先根据现象确定断在哪一层,再检查对应引用或调用,不要同时改动多处。
左右滑动表格,可查看完整内容
| 现象 | 优先检查 | 处理方法 |
|---|---|---|
| 点击按钮完全无反应 | ResetButton 的 On Click | 确认目标为 Scripts,方法为 UIManager.ResetSettings,并保存场景 |
| 开关恢复但声音状态不对 | 显式声音方法调用 | 确认静默赋值后仍调用对应 Set...Enabled,并检查 AudioManager 当前播放条件 |
| 风格文字恢复但材质没变 | ApplyVisualStyle 与材质数组 | 核对地面、墙面数组及同一索引的材质对应关系 |
| 材质恢复但下拉文字没变 | Dropdown 选项与 Caption Text | SetValueWithoutNotify 会刷新显示;再核对选项内容、标签引用和控件状态 |
| 滑块回位但画面没恢复 | Apply 方法与 Volume 引用 | 确认 SetValueWithoutNotify 后调用 Apply,并检查运行时 Profile 中的目标效果 |
| 画面恢复但数值文字没变 | ValueText 查找结构 | 确认文字与 Slider 是同级对象,名称或排列符合查找规则 |
| 第二次点击又改变结果 | 默认值是否被重复保存 | 确认 CaptureDefaultSettings 只在启动快照阶段执行,重置时不覆盖已保存值 |
完成成果确认
用一次连续演示证明重置入口、六项设置和原有菜单功能可以共同工作。
- 展示六项设置全部被修改后的状态。
- 只点击一次 ResetButton,逐项指出恢复结果。
- 再点击两次,证明相同输入始终得到相同默认状态。
- 关闭并重新打开系统菜单,确认菜单定位、拖拽和导航仍正常。
- 确认控制台没有红色错误,并保存场景。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不查看代码,画出 ResetButton、ResetSettings、六项默认字段、三个功能系统和最终效果之间的完整调用顺序。
- 02
把六项设置全部改离启动状态,只点击一次重置,并逐项记录控件显示与实际效果是否同时恢复。
- 03
手算色温 2、曝光 1.5、饱和度 0 在滑块范围 0 到 1 中的位置,再与运行结果比较。
- 04
在语音解说播放期间重置背景音乐许可,观察 AudioManager 为什么仍可能暂时保持背景音乐暂停。
- 05
阅读“风格文字恢复但材质没变”的现象,不改动场景引用,沿 Dropdown、ApplyVisualStyle 和两组材质数组写出定位顺序。
- 06
观察 Slider 与 ValueText 的同级结构,说明文字对象改变层级后为什么可能无法被 FindValueText 找到,但不要破坏已经完成的场景结构。
- 07
连续点击三次重置,确认默认快照没有被当前状态覆盖,六项结果保持完全一致。
- 08
退出运行状态后核对 ResetButton 的 On Click、Volume 与六个控件引用,保存场景并完成最终复测。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。