第 29 课
作品说明牌交互功能开发
本课把作品说明牌加入现有的语音解说交互链。你会先为 ArtWorkIntro 预制体创建 InfoCardContainer,配置不受光照影响并支持透明圆角的材质;再读懂脚本如何维护靠近、朝向和语音许可三项本地状态,如何响应 AudioManager 的当前解说变化,以及如何使用 MaterialPropertyBlock(材质属性块)为共享材质的每个说明牌设置不同贴图。
让作品说明牌在正确的时机显示正确的内容
为作品介绍预制体增加说明牌面片,并完成 ArtWorkIntroController:程序会按解说音频名称自动找到同名图片,再用材质属性块把图片交给当前说明牌;作品先按语音许可、距离和朝向申请解说,只有被 AudioManager 选为当前解说的作品才显示说明牌。
学完你将能够
学习目标
- 说明作品说明牌按需显示对画面整洁、可读性和移动设备负担的帮助。
- 在 ArtWorkIntro 预制体中创建 Quad 面片,并正确设置名称、朝向、位置与比例。
- 配置 URP/Unlit 透明材质,让说明图片不受场景灯光影响,并正确显示 PNG 的透明圆角。
- 设置说明图片的 Alpha Is Transparency、Mipmap、Non-Power of 2 与 Max Size,并观察透明信息、比例和清晰度。
- 说明 ArtWorkIntroController 中场景引用、运行状态、事件入口和统一输出各自负责什么。
- 说明 Awake、OnEnable、Start、OnDisable 的职责和事件订阅、取消订阅的对应关系。
- 写出作品申请解说的三项条件,以及说明牌显示还需满足的当前解说条件。
- 理解同名贴图自动匹配与单张后备贴图的先后顺序。
- 理解 MaterialPropertyBlock 为什么能在不复制共享材质的情况下为不同实例显示不同图片。
- 完成预制体引用配置,并验证显示、隐藏、内容匹配和原有解说功能。
开始操作前
准备工作
- 打开主场景,确认每件作品附近已经放置 ArtWorkIntro 预制体实例。
- 在随玩家身体移动的对象上准备合适的 Collider(碰撞体),让它能够进入作品的感应范围。Collider 所在对象使用 Player 标签;碰撞双方至少一方具备 Rigidbody 或 CharacterController,并确认双方所在 Layer 允许碰撞。
- 确认作品对象带有 CalloutGazeController,并能发出面向进入与面向离开事件。
- 确认 AudioManager 可以播放作品解说,设置页面中的语音解说开关能够正常工作。
- 在项目资源中准备每件作品的说明图片,并尽量让图片名称与对应解说音频名称一致。
- 打开 ArtWorkIntro 预制体、ArtWorkIntroController.cs、层级面板、属性面板和控制台。
- 退出运行状态后再调整预制体或脚本,完成一组设置后及时保存。
本课学习路线
按照学习模块逐步完成
每个模块都包含理解、跟随操作和独立实践。信息在需要时出现,离开关注时收起
先理解说明牌为什么要按需显示
虚拟博物馆可以像真实博物馆一样提供作品介绍,但不必让所有说明牌始终占据画面。本项目把说明牌与现有解说状态连接在一起,让信息出现得更自然。按需显示同时解决三个问题
第一是画面整洁。展厅中有多件作品,如果所有说明牌一直显示,远处会出现大量小字和白色卡片,反而会遮挡作品并分散注意力。
第二是可读性。移动 VR 设备的显示分辨率与计算能力有限,距离很远时仍绘制带文字的图片,文字容易出现模糊和锯齿。让说明牌在体验者真正关注作品时出现,阅读距离更合适。
第三是状态一致。本项目中的作品解说先使用“语音许可、进入感应范围、面向作品”三项条件判断一件作品是否有申请资格,再由 AudioManager 从所有候选中统一选择当前解说。说明牌必须跟随最终选择,不能只看单件作品自己的条件。
这意味着说明牌有两层判断。第一层三项条件决定是否登记或撤销这件作品;第二层比较 audioManager.CurrentIntroClip 与本作品 introClip。只有本作品确实正在解说时,它的卡片才显示。
若两个作品的触发范围和朝向条件同时成立,AudioManager 会选择最近登记的作品。另一件作品虽然仍保留候选资格,但卡片保持隐藏;当前作品退出后,管理器重选仍有效的上一件并通知所有卡片刷新。
隐藏说明牌会减少当前画面中的可见物体、绘制工作和视觉干扰,但这不是“按需加载”。预制体的 infoCardTextures 数组仍引用21张候选图片,它们仍属于运行所需资源;SetActive(false) 只是停止显示卡片,不会自动从内存卸载这些贴图。
沿箭头观察:三项本地条件先决定登记或撤销,AudioManager 再选择当前解说并发出变化通知;卡片只有在本作品就是 CurrentIntroClip 时才显示。
手机上可左右滑动;点击图片可以查看完整图三项本地状态、当前选择与结果
前三项决定是否申请解说,最后还要确认这件作品被统一选中。
左右滑动表格,可查看完整内容
| 语音许可 | 位于范围内 | 正在面向作品 | 是否当前解说 | 结果 |
|---|---|---|---|---|
| 开启 | 是 | 是 | 是 | 显示说明牌并播放对应解说 |
| 开启 | 是 | 是 | 否 | 保留候选资格,但隐藏卡片 |
| 关闭 | 是 | 是 | 否 | 撤销候选、隐藏卡片并停止解说 |
| 开启 | 否 | 是 | 否 | 撤销候选并隐藏卡片 |
| 开启 | 是 | 否 | 否 | 撤销候选并隐藏卡片 |
预测三种操作后的状态
保持语音解说开关开启,分别改变距离和朝向,先预测结果再进入运行状态观察。
- 站在范围外面向作品,记录说明牌是否显示。
- 进入范围但转向别处,记录说明牌和解说是否启动。
- 留在范围内并重新面向作品,确认视觉和声音是否同时启动。
- 关闭语音解说开关,确认即使距离与朝向合格,说明牌也会隐藏。
图片需要一个能在三维空间中显示的载体
为预制体制作说明牌面片和材质
说明内容虽然是一张二维图片,但它要出现在三维展厅中。这里使用 Quad(面片)作为载体:它只有一个平面,结构轻量,适合承载面向体验者的图片。先分清面片、材质与贴图
Quad 决定卡片在场景中的位置、旋转和尺寸,可以把它理解为一块空白展板。Mesh Renderer(网格渲染器)负责把这块展板绘制到画面上,因此不能移除;Quad 自动附带的 Mesh Collider(网格碰撞器)在本功能中不参与交互,可以移除。
Material(材质)决定展板采用怎样的绘制规则,Texture(贴图)则是要显示在展板上的图片。一个 InfoMat 材质可以被所有说明牌共享,脚本再为各个渲染器提供不同图片。
本课使用 URP/Unlit。URP 是项目采用的通用渲染管线,Unlit 表示材质不计算场景光照。这样作品说明文字不会因展厅灯光明暗而忽明忽暗,更容易阅读。
在场景环境中编辑 ArtWorkIntro
- 01
在场景中选择一个 ArtWorkIntro 实例,点击层级面板中的预制体箭头,进入带周围环境参照的预制体编辑状态。
- 02
选择预制体根对象,执行 3D Object → Quad,新建一个面片。
- 03
把面片命名为 InfoCardContainer,使名称直接说明它承担信息卡容器的职责。
- 04
先把本地位置设为 X=0.284、Y=-0.157、Z=0,再结合展品与墙面位置微调。
- 05
把 Y 轴旋转设为 90 度,使面片正面朝向体验者。
- 06
移除不需要的 Mesh Collider,保留 Mesh Filter 和 Mesh Renderer。
- 07
把本地缩放设置为 X=0.8、Y=0.3、Z=1,使比例与约 800×300 的说明图片一致。
- 08
确认修改发生在预制体中,退出预制体编辑后查看各个实例是否都获得了这个子对象。

重点观察创建位置:Quad 必须成为 ArtWorkIntro 的子对象。创建后将它命名为 InfoCardContainer,再调整朝向、位置和比例。
点击图片可以查看完整截图创建并连接 InfoMat
- 01
在 Materials 文件夹中执行 Create → Material,创建材质并命名为 InfoMat。
- 02
把 InfoMat 拖到 InfoCardContainer 上,确认 Mesh Renderer 的 Materials 列表中出现该材质。
- 03
把 Shader 设为 Universal Render Pipeline/Unlit,让文字图片不受灯光影响。
- 04
把 Surface Type 设为 Transparent,让材质在绘制时使用 PNG 的 Alpha 透明信息;本课不需要开启 Alpha Clipping。
- 05
保持材质主体颜色为不透明白色,避免额外颜色把说明图片染色。完成后从圆角外侧观察,确认面片没有残留矩形底色。

先用截图定位 Shader、Surface Type 与 Base Map。截图中的 Surface Type 仍是 Opaque,继续把它切换为 Transparent,才能让说明图片的透明圆角生效。
点击图片可以查看完整截图完成一个可承载图片的说明牌
在预制体中完成 InfoCardContainer 与 InfoMat,并用一张图片临时放入 Base Map 观察比例和朝向。
- 从作品正面观察,确认卡片没有背对体验者。
- 确认面片比例接近图片比例,文字不会明显拉伸。
- 临时改变场景灯光观察卡片,确认 Unlit 材质亮度基本不变。
- 比较 Opaque 与 Transparent 的边缘差异,最后保留 Transparent;观察完成后把共享 InfoMat 的 Base Map 恢复为 None,具体作品图片交给脚本设置。
先让 Unity 读懂图片 Alpha,再决定材质是否使用它
导入说明图片并正确识别透明信息
PNG 图片可以保存 Alpha 通道,也就是每个像素的透明程度。但文件里有透明信息,不等于 Unity 会自动用正确方式显示它。为什么需要两层透明设置
Alpha Is Transparency 告诉 Unity:图片 Alpha 通道中的透明像素要按透明内容处理。这一步属于图片导入设置。
InfoMat 的 Surface Type=Transparent 告诉渲染器:绘制材质时允许使用贴图透明度。这一步属于材质设置。图片导入与材质绘制两层都正确,圆角外侧才会真正透明。
Max Size 是 Unity 导入后允许使用的最大边长,但它不决定非二次幂图片是否重新采样。当前 21 张资源保存的是 Non-Power of 2=ToNearest,而 800×300 的两条边都不是 2 的整数次幂,Unity 可能把它转换为接近的二次幂尺寸,例如 1024×256,从而改变比例和文字细节。先把 Non-Power of 2 改为 None,再把 Max Size 设为 1024,才能在最大边不超过上限时保留 800×300 的原始尺寸。Mipmap 会生成多级缩小图片,远处显示时选用合适级别,能够减少闪烁。
21 张图片以作品名称命名。脚本会使用解说音频的名称在这些图片中查找同名项,所以扩展名可以不同,但 Unity 资源名称必须准确对应。
一次设置全部说明图片
- 01
把作品说明 PNG 图片放入项目的 Textures/ArtworkIntroTexture 文件夹。
- 02
在项目窗口中选中全部 21 张说明图片,在属性面板确认 Texture Type 为 Default。
- 03
勾选 Alpha Is Transparency,让圆角之外的像素按透明处理。
- 04
确认 Generate Mip Maps 已开启,让不同观看距离可以使用合适的缩小级别。
- 05
展开 Advanced,把 Non-Power of 2 从当前的 ToNearest 改为 None,避免 800×300 图片被重新采样成其他宽高比。
- 06
把默认平台的 Max Size 设置为 1024,并保持项目使用的常规压缩设置。此时原图最大边 800 没有超过上限。
- 07
点击 Apply,等待全部图片重新导入。
- 08
在属性面板底部展开预览,选择几张图片确认导入尺寸仍为 800×300、圆角外侧透明且文字没有异常模糊。

截图中可以观察 Alpha Is Transparency、Mipmap 与 Max Size 1024;还要展开 Advanced,把 Non-Power of 2 改为 None。点击 Apply 后,从底部预览确认透明信息、800×300 尺寸与文字细节。
点击图片可以查看完整截图下载作品说明牌图片包(21张)
图片已经按作品名称整理,可导入项目的 ArtworkIntroTexture 文件夹,并用于脚本的同名自动匹配。
- 压缩包包含21张作品说明 PNG 图片。
- 导入后统一设置 Alpha Is Transparency、Mipmap、Non-Power of 2=None 与默认平台 Max Size 1024。
- 保持图片资源名称与对应解说音频资源名称一致。
- 把全部图片加入预制体的 Info Card Textures 数组。
图片显示异常时先看哪一层
根据现象定位图片导入、材质或比例设置,避免同时改动多个参数。
左右滑动表格,可查看完整内容
| 现象 | 优先确认 | 原因 |
|---|---|---|
| 圆角外仍有矩形底色 | 图片 Alpha 与材质 Transparent | 透明信息没有在两层同时生效 |
| 文字明显模糊 | 原图、Non-Power of 2 与 Max Size | 重新采样或导入尺寸过小会损失细节 |
| 图片被拉宽或压扁 | Non-Power of 2 与 Quad 的 X:Y 比例 | 图片可能先被重新采样,面片比例也可能与图片不一致 |
| 内容上下颠倒或背面不可见 | Quad 朝向与贴图方向 | 面片正面没有朝向体验者 |
验证一张图片的完整透明链
选择一张圆角明显的说明图片,从资源预览到场景面片逐层确认。
- 在资源预览中确认圆角外是透明区域。
- 确认 Advanced 中的 Non-Power of 2 为 None,预览尺寸仍是 800×300。
- 把图片临时放入 InfoMat 的 Base Map,先用 Opaque 观察矩形底色,再切换为 Transparent 观察圆角外侧消失。
- 比较完成后保留 Transparent,并确认材质主体颜色为白色且 Alpha 不为零。
- 把共享 InfoMat 的 Base Map 恢复为 None,再保存预制体,让脚本负责设置每件作品的具体图片。
写代码前先把职责和执行路线分清
先认识控制器中的数据、事件与统一出口
ArtWorkIntroController 负责一件作品的说明牌与解说。先分清它保存哪些数据、接收哪些事件、在哪里统一判断,后面的每段代码才不会变成互不相干的方法集合。类、字段、方法和事件各是什么
类可以理解为一张功能设计图,ArtWorkIntroController 规定每个作品介绍对象都具备哪些数据和行为。把它挂到不同预制体实例后,每个实例都会保存自己的作品引用、解说音频和运行状态。
字段是类中持续保存的数据。例如 introClip 保存解说音频,collidersInside 保存范围内玩家碰撞体的数量,isFacing 保存体验者是否正面向作品。方法是一段有名字的操作,例如 ConfigureInfoCard 负责准备说明图片,UpdatePlayback 负责判断最终结果。
事件是变化发生时送来的通知。玩家进入 Trigger、视线转向作品、语音解说开关变化时,Unity 或其他组件会调用相应入口。前三类入口更新自己负责的状态,随后调用 UpdatePlayback 登记或撤销作品。
AudioManager 选择的当前解说变化时,会另外发出 ActiveIntroChanged。这个回调只调用 UpdateInfoCard 刷新卡片,不再次提交播放请求,避免改变候选顺序。
完整调用链是:本地事件发生 → 更新状态 → UpdatePlayback 检查三项申请条件 → AudioManager 登记或撤销并选择 CurrentIntroClip → ActiveIntroChanged → UpdateInfoCard 只显示匹配的卡片。
控制器中的六类职责
先看每一类负责什么,再进入具体语句。
左右滑动表格,可查看完整内容
| 职责 | 代表内容 | 解决的问题 |
|---|---|---|
| 场景引用 | introClip、artwork、infoCard、图片数组 | 知道要控制哪件作品、哪段声音和哪张卡片 |
| 运行状态 | collidersInside、isFacing、audioManagerReady | 记住体验者当前是否满足交互条件 |
| 生命周期 | Awake、OnEnable、Start、OnDisable | 按正确时机准备引用、连接事件和清理状态 |
| 事件入口 | OnTriggerEnter、OnFacingEntered、OnNarrationEnabledChanged | 接收距离、朝向和开关的变化 |
| 请求决策 | UpdatePlayback | 集中计算本作品是否具有解说资格 |
| 当前选择 | AudioManager.CurrentIntroClip、ActiveIntroChanged | 在多个候选中确定唯一当前作品并通知变化 |
| 画面输出 | ConfigureInfoCard、UpdateInfoCard、SetInfoCardVisible | 选择正确图片并只显示当前解说的卡片 |
先口述一次从启动到交互的过程
- 01
对象启动时,Awake 找到作品注视组件、配置说明图片,并把说明牌设为隐藏。
- 02
对象启用后,OnEnable 连接当前已经可用的事件;Start 再取得 AudioManager,并进行第一次状态计算。
- 03
体验者靠近、离开、转向作品或改变语音解说开关时,相应事件入口只修改自己的状态。
- 04
每次本地状态变化都进入 UpdatePlayback,由它登记或撤销作品;当前解说变化后,UpdateInfoCard 再决定卡片是否显示。
- 05
对象停用时,OnDisable 取消两类管理器事件、撤销作品候选、清零旧状态并隐藏说明牌。
把方法放回正确职责
根据方法名和功能,为下面四组内容分别写出“保存数据、接收通知、统一判断、改变画面”。
- introClip、collidersInside、isFacing 属于哪一类,为什么要持续保存?
- OnTriggerEnter 和 OnFacingExited 在什么变化发生时执行?
- 为什么三个事件入口都不直接各写一套播放条件?
- UpdatePlayback 提交请求后,UpdateInfoCard 还要核对哪个统一结果?
先把场景资源、运行状态和渲染入口分清
声明说明牌字段与着色器属性编号
字段是类中用来保存数据的变量。完整脚本同时需要属性面板可配置的数据、只供运行时使用的数据,以及着色器属性的快速编号。 [SerializeField] private AudioClip introClip;
[SerializeField, Tooltip("对应的艺术品,例如 Painting7")]
private GameObject artwork;
[SerializeField, Tooltip("解说时显示的信息卡对象")]
private GameObject infoCard;
[SerializeField, Tooltip("与解说音频同名的作品信息卡图片")]
private Texture infoCardTexture;
[SerializeField, Tooltip("可用的信息卡图片,运行时按解说音频名称自动匹配")]
private Texture[] infoCardTextures;
private int collidersInside;
private bool isFacing;
private CalloutGazeController gazeController;
private AudioManager audioManager;
private bool audioManagerReady;
private MaterialPropertyBlock infoCardProperties;
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");SerializeField 让私有字段仍能出现在属性面板中;Tooltip 会在鼠标停留时说明该字段用途。这只是完整类中的一段,先阅读并继续完成后续方法,不要单独保存回 Unity 编译。
说明牌字段各自保存什么
同样是字段,有些由学生在属性面板连接,有些由代码在运行时创建。
左右滑动表格,可查看完整内容
| 字段 | 类型 | 来源 | 用途 |
|---|---|---|---|
| infoCard | GameObject | 属性面板 | 要显示或隐藏的 InfoCardContainer |
| infoCardTexture | Texture | 属性面板 | 自动匹配失败时使用的单张后备图片 |
| infoCardTextures | Texture[] | 属性面板 | 参与同名自动匹配的图片数组 |
| infoCardProperties | MaterialPropertyBlock | 运行时创建 | 保存当前渲染器专属的材质属性 |
| BaseMapId | int | 类初始化时计算 | URP 材质主贴图属性 _BaseMap 的编号 |
| MainTexId | int | 类初始化时计算 | 兼容使用 _MainTex 的材质属性编号 |
MaterialPropertyBlock 是什么
多个 InfoCardContainer 可以共享同一个 InfoMat。如果直接修改 sharedMaterial 的主贴图,所有使用这个材质的说明牌可能一起变成同一张图片。为每件作品复制一个材质又会增加资源和运行负担。
MaterialPropertyBlock(材质属性块)是一组只覆盖当前 Renderer 属性的数据。共享材质继续提供相同的 Unlit 与表面绘制规则,而每个渲染器可以拥有自己的说明图片。
Shader.PropertyToID 会把字符串属性名转换成整数编号。脚本只在类加载时计算一次 BaseMapId 和 MainTexId,后续设置贴图时直接使用编号,避免反复用字符串查找。
同时写入 _BaseMap 与 _MainTex 是兼容处理:当前 URP/Unlit 主要使用 _BaseMap,而一些材质或渲染路径仍识别 _MainTex。若某个属性不存在,Unity 会忽略不适用的覆盖,不需要复制材质。
在属性面板与代码间建立对应关系
选择 ArtWorkIntro 预制体根对象,指出哪些字段需要拖入资源,哪些字段不会显示在属性面板。
- 把 InfoCardContainer 与 infoCard 字段对应起来。
- 指出 infoCardTextures 为什么是数组,以及数组中的元素类型。
- 说明 infoCardProperties 为什么不能提前在属性面板中拖入。
- 指出 BaseMapId 和 MainTexId 为什么声明为 static readonly。
每个 Unity 生命周期函数只承担适合它的职责
按生命周期准备贴图、事件与初始状态
生命周期方法是 Unity 在特定阶段自动调用的方法。本脚本用 Awake 准备本地引用和贴图,用 OnEnable 连接事件,用 Start 获取单例管理器并首次计算状态,用 OnDisable 解除连接、撤销候选、清零状态并隐藏卡片。 private void Awake()
{
if (artwork != null)
{
gazeController = artwork.GetComponent<CalloutGazeController>();
}
ConfigureInfoCard();
SetInfoCardVisible(false);
}
private void OnEnable()
{
if (audioManagerReady)
{
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged += OnActiveIntroChanged;
}
if (gazeController == null)
{
return;
}
gazeController.FacingEnteredEvent.AddListener(OnFacingEntered);
gazeController.FacingExitedEvent.AddListener(OnFacingExited);
}
private void Start()
{
audioManager = AudioManager.Instance;
audioManagerReady = true;
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged += OnActiveIntroChanged;
UpdatePlayback();
}Awake 先取得作品的注视控制器、配置说明图片并隐藏卡片;OnEnable 订阅可用事件;Start 再取得 AudioManager,连接语音许可与当前解说变化两个事件并执行第一次 UpdatePlayback。这只是完整类中的一段,还需补上退出清理与交互方法,不要单独保存回 Unity 编译。
private void OnDisable()
{
if (audioManager != null)
{
audioManager.NarrationEnabledChanged -= OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged -= OnActiveIntroChanged;
audioManager.PauseArtWorkIntro(introClip);
}
collidersInside = 0;
isFacing = false;
if (gazeController != null)
{
gazeController.FacingEnteredEvent.RemoveListener(OnFacingEntered);
gazeController.FacingExitedEvent.RemoveListener(OnFacingExited);
}
SetInfoCardVisible(false);
}组件或整个游戏对象被停用时,OnDisable 会撤销两类管理器事件和两个凝视事件,向 AudioManager 撤销当前作品,清零距离与朝向,并隐藏说明牌。停用后的 Trigger 回调还会用 isActiveAndEnabled 保护阻止重新登记。
一次启用过程中的先后关系
顺序决定哪些引用已经可用,也解释了 audioManagerReady 这道保护。
左右滑动表格,可查看完整内容
| 阶段 | 主要动作 | 完成后的状态 |
|---|---|---|
| Awake | 取得 gazeController、配置贴图、隐藏 infoCard | 卡片内容已准备但不可见 |
| OnEnable | 首次启用时连接两个面向事件;再次启用时还会重新连接两类管理器事件 | 当前已经可用的事件能够通知本组件 |
| Start | 取得 AudioManager、标记已就绪、首次连接两类管理器事件并更新 | 申请条件与当前选择都能安全读取 |
| OnDisable | 取消事件、撤销候选、清零状态并隐藏卡片 | 不会把停用前的解说请求和交互状态留在系统中 |
三种通知方式怎样连接
它们都会让方法在变化发生时执行,但连接方式不同。
左右滑动表格,可查看完整内容
| 通知来源 | 连接与解除方式 | 本课用途 |
|---|---|---|
| Unity 触发器回调 | 无需手动订阅,由 Unity 调用 OnTriggerEnter/Exit | 接收玩家进入与离开感应范围 |
| UnityEvent | AddListener / RemoveListener | 接收 CalloutGazeController 的面向进入与离开 |
| C# 事件 | += / -= | 接收 AudioManager 的语音许可变化与当前解说变化 |
为什么 OnEnable 中有 audioManagerReady 判断
对象第一次启用时,OnEnable 通常早于 Start。此时 audioManager 还没有在 Start 中赋值,所以不能直接访问它。audioManagerReady 为 false 时跳过这次语音事件连接。
Start 随后取得 AudioManager.Instance,设置 audioManagerReady=true,并完成两类管理器事件的第一次连接。以后对象先禁用再重新启用时,Start 不会重复执行,但 OnEnable 会再次执行;这时 ready 已为 true,于是 OnEnable 负责重新连接事件。
OnDisable 中使用 -= 和 RemoveListener 取消连接。如果只增加不移除,每次重新启用都可能多连接一次,同一个开关变化就会重复调用 UpdatePlayback。
只取消脚本组件勾选时,Unity 仍可能发送触发器回调。事件入口中的 isActiveAndEnabled 会确认游戏对象和脚本都处于启用状态;只要任一被停用就立即 return,不会重新登记候选。
沿生命周期口述一次启动
不要只背方法名,按实际执行顺序说明引用、贴图、隐藏、事件和首次状态计算何时完成。
- 指出 ConfigureInfoCard 为什么放在 Awake,而不是等到首次显示时才执行。
- 说明第一次 OnEnable 为什么不会访问尚未准备的 AudioManager。
- 说明 Start 中 UpdatePlayback 执行前已经具备哪些状态。
- 模拟停用整个游戏对象再重新激活,说明事件怎样先移除再重新连接。
- 比较停用整个对象与只停用脚本组件,确认两种方式都会撤销声音、清除旧状态并忽略后续触发器消息。
本地条件决定能否申请,管理器选择决定谁能显示
用本地事件维护候选,再按当前解说刷新卡片
距离、朝向和语音许可变化后,UpdatePlayback 统一登记或撤销作品;AudioManager 的当前解说变化后,UpdateInfoCard 只刷新卡片。两层方法分工清楚,范围重叠时也不会显示两张卡片。 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 OnActiveIntroChanged()
{
UpdateInfoCard();
}Trigger 事件先确认组件启用、筛选 Player 标签并维护计数;面向事件修改 isFacing;语音许可变化重新提交资格;当前解说变化只刷新卡片。这些入口还依赖后续方法,先继续完成,不要单独编译。
private void UpdatePlayback()
{
if (!audioManagerReady)
{
SetInfoCardVisible(false);
return;
}
bool shouldPlay =
audioManager.NarrationEnabled && collidersInside > 0 && isFacing;
if (shouldPlay)
{
audioManager.PlayArtWorkIntro(introClip);
}
else
{
audioManager.PauseArtWorkIntro(introClip);
}
UpdateInfoCard();
}
private void UpdateInfoCard()
{
bool shouldShow =
audioManagerReady &&
audioManager.NarrationEnabled &&
collidersInside > 0 &&
isFacing &&
audioManager.CurrentIntroClip == introClip;
SetInfoCardVisible(shouldShow);
}UpdatePlayback 只计算本作品是否应该申请解说并调用 Play 或 Pause;UpdateInfoCard 再检查本地条件与 CurrentIntroClip,只有真正被选中的作品才显示。
一次事件回调的执行顺序
当玩家碰撞体进入 Trigger 时,Unity 调用 OnTriggerEnter。!isActiveAndEnabled 表示游戏对象或脚本已停用;!other.CompareTag("Player") 表示进入者没有 Player 标签。任一条件成立都会 return,后面的计数和判断都不执行。
进入者是玩家时,collidersInside++ 会把原来的数量加一,再调用 UpdatePlayback。离开时,Mathf.Max(0, collidersInside - 1) 先减一,再保证结果不会小于零。
UpdatePlayback 首先确认 AudioManager 是否已准备。未准备时隐藏说明牌并 return;准备好后计算 shouldPlay,再向 AudioManager 登记或撤销本作品。管理器可能同步改变 CurrentIntroClip,并通过 ActiveIntroChanged 通知所有作品。
OnActiveIntroChanged 只调用 UpdateInfoCard,不调用 UpdatePlayback。UpdateInfoCard 同时检查三项本地条件和 CurrentIntroClip == introClip,再把结果交给 SetInfoCardVisible。这样刷新卡片不会重复提交请求或改变候选顺序。
距离条件写成 collidersInside > 0,意思是范围内至少还有一个玩家碰撞体。collidersInside 使用整数而不是单个布尔值,是为了应对玩家身上多个带 Player 标签的碰撞体先后进入。退出时用 Mathf.Max(0, collidersInside - 1) 防止计数变成负数;只要计数仍大于零,就认为玩家仍在范围内。
两个玩家碰撞体依次进出的计数
只要计数大于零,距离条件就仍然成立。
左右滑动表格,可查看完整内容
| 发生的事件 | 计算前 | 计算后 | 是否仍在范围内 |
|---|---|---|---|
| 第一个碰撞体进入 | 0 | 1 | 是 |
| 第二个碰撞体进入 | 1 | 2 | 是 |
| 第一个碰撞体离开 | 2 | 1 | 是 |
| 第二个碰撞体离开 | 1 | 0 | 否 |
| 异常重复离开 | 0 | 0 | 否,Mathf.Max 阻止负数 |
状态变化到最终效果的调用链
不同入口最终汇合,保证规则只有一份。
左右滑动表格,可查看完整内容
| 变化来源 | 更新内容 | 统一调用 | 最终效果 |
|---|---|---|---|
| Player 进入或离开 Trigger | collidersInside 加一或减一 | UpdatePlayback | 登记或撤销候选,再按当前选择显示 |
| 面向进入或离开事件 | isFacing=true 或 false | UpdatePlayback | 登记或撤销候选,再按当前选择显示 |
| 语音解说开关变化 | AudioManager 已保存新许可;关闭时先 Stop | UpdatePlayback | 重新提交资格;关闭时保持隐藏 |
| 当前解说变化 | CurrentIntroClip 已更新 | UpdateInfoCard | 仅匹配的作品卡片显示 |
这是由条件驱动的状态机
状态机是根据当前状态决定下一步行为的规则。本课没有另外声明枚举状态,而是用 NarrationEnabled、collidersInside 和 isFacing 三份数据表示本作品是否具有申请资格。
表达式中的 && 表示“并且”。三项本地条件都成立时 shouldPlay 才为 true;卡片还要增加 CurrentIntroClip == introClip,确认统一选择结果确实是本作品。
OnNarrationEnabledChanged 接收一个 bool 参数,是为了匹配 AudioManager 的事件格式。方法本身不直接使用参数,而是在事件发出后重新读取 AudioManager.NarrationEnabled,再与距离和朝向一起计算,确保统一出口只相信管理器保存的最新状态。
逐步验证四条事件链
每次只改变一项条件,观察说明牌和解说是否同时响应。
- 保持面向作品,从范围外走入,确认 Trigger 入口改变结果。
- 保持站在范围内,先转开再转回,确认两个面向事件改变结果。
- 保持距离和朝向不变,切换语音解说开关,确认开关事件改变结果。
- 让另一件作品成为当前解说,确认 ActiveIntroChanged 只切换卡片,不重复登记本作品。
- 让两个玩家碰撞体先后进出,确认一个仍在范围内时计数不会错误归零。
优先自动匹配,失败时使用明确的后备图片
按音频名称匹配图片并写入当前渲染器
每个作品实例已经有对应解说音频。脚本把音频名称当作匹配键,在图片数组中寻找同名资源,减少逐个实例手动连接图片的重复工作。 private void ConfigureInfoCard()
{
Texture texture = FindMatchingInfoCardTexture();
if (infoCard == null || texture == null)
{
return;
}
Renderer cardRenderer = infoCard.GetComponent<Renderer>();
if (cardRenderer == null)
{
return;
}
infoCardProperties = new MaterialPropertyBlock();
cardRenderer.GetPropertyBlock(infoCardProperties);
infoCardProperties.SetTexture(BaseMapId, texture);
infoCardProperties.SetTexture(MainTexId, texture);
cardRenderer.SetPropertyBlock(infoCardProperties);
}ConfigureInfoCard 依次确认贴图、卡片和 Renderer 存在,再读取原有属性、覆盖两个主贴图属性并写回当前 Renderer。这一片段还依赖匹配方法和字段,先继续完成,不要单独编译。
private Texture FindMatchingInfoCardTexture()
{
if (introClip != null && infoCardTextures != null)
{
foreach (Texture texture in infoCardTextures)
{
if (texture != null && texture.name == introClip.name)
{
return texture;
}
}
}
return infoCardTexture;
}只有 introClip 和图片数组都存在时才遍历。找到 texture.name == introClip.name 就立即返回;全部未命中或缺少数组时,最后返回 infoCardTexture。请把它与前面的配置方法一起理解,等完整类完成后再统一编译。
从左向右观察:音频名称先在图片数组中寻找同名贴图;匹配失败才读取单张后备贴图;找到图片后,材质属性块只覆盖当前 Renderer 的 _BaseMap 与 _MainTex。
手机上可左右滑动;点击图片可以查看完整图ConfigureInfoCard 的防错顺序
第一步先调用 FindMatchingInfoCardTexture。如果没有任何可用图片,或者 infoCard 没有连接,方法直接 return。这样不会继续访问空引用。
第二步从 infoCard 取得 Renderer。找不到 Renderer 也会提前结束,因为没有渲染器就没有可以接收贴图属性的对象。
第三步创建 MaterialPropertyBlock,并先用 GetPropertyBlock 读取当前 Renderer 已有的属性覆盖。这样增加贴图时不会无意清掉其他已经存在的覆盖值。
第四步分别设置 _BaseMap 与 _MainTex,然后用 SetPropertyBlock 写回同一个 Renderer。配置只在 Awake 执行一次,不会在每次靠近或每一帧重复创建对象与遍历数组。
验证自动匹配与后备路线
选择一个作品实例,分别制造匹配成功和匹配失败两种情况,观察最终显示内容。
- 确认 introClip 的资源名称与某张说明图片完全相同,运行后观察自动匹配结果。
- 临时把对应图片移出 infoCardTextures,但在 infoCardTexture 中连接正确图片,确认后备路线生效。
- 再临时清空后备贴图,确认脚本安全结束而不是产生空引用错误,然后立即恢复。
- 恢复同名图片数组配置,确认多个实例共享 InfoMat 时仍显示各自内容。
代码完整并编译通过后,再连接场景对象
组合完整脚本并连接作品引用
前面的代码片段用于逐步理解,不能逐段重复粘贴。现在使用完整文件统一保存并等待 Unity 编译;控制台没有红色错误后,再把公共内容放入预制体,把作品与解说等专属内容连接到各个实例。using UnityEngine;
using Unity.VRTemplate;
[RequireComponent(typeof(Collider))]
public class ArtWorkIntroController : MonoBehaviour
{
[SerializeField] private AudioClip introClip;
[SerializeField, Tooltip("对应的艺术品,例如 Painting7")]
private GameObject artwork;
[SerializeField, Tooltip("解说时显示的信息卡对象")]
private GameObject infoCard;
[SerializeField, Tooltip("与解说音频同名的作品信息卡图片")]
private Texture infoCardTexture;
[SerializeField, Tooltip("可用的信息卡图片,运行时按解说音频名称自动匹配")]
private Texture[] infoCardTextures;
private int collidersInside;
private bool isFacing;
private CalloutGazeController gazeController;
private AudioManager audioManager;
private bool audioManagerReady;
private MaterialPropertyBlock infoCardProperties;
private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
private void Awake()
{
if (artwork != null)
{
gazeController = artwork.GetComponent<CalloutGazeController>();
}
ConfigureInfoCard();
SetInfoCardVisible(false);
}
private void OnEnable()
{
if (audioManagerReady)
{
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged += OnActiveIntroChanged;
}
if (gazeController == null)
{
return;
}
gazeController.FacingEnteredEvent.AddListener(OnFacingEntered);
gazeController.FacingExitedEvent.AddListener(OnFacingExited);
}
private void Start()
{
audioManager = AudioManager.Instance;
audioManagerReady = true;
audioManager.NarrationEnabledChanged += OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged += OnActiveIntroChanged;
UpdatePlayback();
}
private void OnDisable()
{
if (audioManager != null)
{
audioManager.NarrationEnabledChanged -= OnNarrationEnabledChanged;
audioManager.ActiveIntroChanged -= OnActiveIntroChanged;
audioManager.PauseArtWorkIntro(introClip);
}
collidersInside = 0;
isFacing = false;
if (gazeController != null)
{
gazeController.FacingEnteredEvent.RemoveListener(OnFacingEntered);
gazeController.FacingExitedEvent.RemoveListener(OnFacingExited);
}
SetInfoCardVisible(false);
}
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 OnActiveIntroChanged()
{
UpdateInfoCard();
}
private void UpdatePlayback()
{
if (!audioManagerReady)
{
SetInfoCardVisible(false);
return;
}
bool shouldPlay =
audioManager.NarrationEnabled && collidersInside > 0 && isFacing;
if (shouldPlay)
{
audioManager.PlayArtWorkIntro(introClip);
}
else
{
audioManager.PauseArtWorkIntro(introClip);
}
UpdateInfoCard();
}
private void UpdateInfoCard()
{
bool shouldShow =
audioManagerReady &&
audioManager.NarrationEnabled &&
collidersInside > 0 &&
isFacing &&
audioManager.CurrentIntroClip == introClip;
SetInfoCardVisible(shouldShow);
}
private void ConfigureInfoCard()
{
Texture texture = FindMatchingInfoCardTexture();
if (infoCard == null || texture == null)
{
return;
}
Renderer cardRenderer = infoCard.GetComponent<Renderer>();
if (cardRenderer == null)
{
return;
}
infoCardProperties = new MaterialPropertyBlock();
cardRenderer.GetPropertyBlock(infoCardProperties);
infoCardProperties.SetTexture(BaseMapId, texture);
infoCardProperties.SetTexture(MainTexId, texture);
cardRenderer.SetPropertyBlock(infoCardProperties);
}
private Texture FindMatchingInfoCardTexture()
{
if (introClip != null && infoCardTextures != null)
{
foreach (Texture texture in infoCardTextures)
{
if (texture != null && texture.name == introClip.name)
{
return texture;
}
}
}
return infoCardTexture;
}
private void SetInfoCardVisible(bool isVisible)
{
if (infoCard != null && infoCard.activeSelf != isVisible)
{
infoCard.SetActive(isVisible);
}
}
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
}
下面是可以直接使用的完整类,字段、生命周期、事件入口、统一判断、贴图匹配与辅助方法已经组合在一起。请只保留这一份完整代码,统一保存后等待 Unity 编译。
下载完整 ArtWorkIntroController.cs
可以下载完整脚本,用它核对或替换项目中同名文件的内容。
- 文件包含语音解说、玩家范围计数、面向作品事件和说明牌控制。
- 文件会跟随 AudioManager 的 CurrentIntroClip,只显示当前解说作品的卡片。
- 文件会按解说音频名称匹配说明图片,并通过材质属性块写入当前渲染器。
- 文件名与类名必须继续使用 ArtWorkIntroController.cs 和 ArtWorkIntroController。
- 不要重复创建第二个同名类;替换内容后等待编译完成,再连接预制体和实例引用。
先确认完整脚本可以被 Unity 使用
- 01
打开项目中的 ArtWorkIntroController.cs,只保留上方完整代码,不要重复保留前面用于讲解的片段。
- 02
确认文件名、类名和挂载组件名称都为 ArtWorkIntroController。
- 03
保存文件并返回 Unity,等待右下角编译提示结束。
- 04
打开控制台,确认没有红色编译错误;如果有错误,先根据首条错误检查括号、类名和重复代码。
- 05
编译成功后再开始下面的属性面板连接,避免在脚本字段尚未生成时连接场景对象。
先配置所有实例都需要的内容
- 01
打开 ArtWorkIntro 预制体,选择挂载 ArtWorkIntroController 的根对象。
- 02
把子对象 InfoCardContainer 拖到 Info Card 字段。
- 03
展开 Info Card Textures 数组,把 21 张作品说明图片全部放入数组;数组顺序不负责决定匹配结果,资源名称才是关键。
- 04
在 Info Card Texture 中保留一张合理的后备图片,避免个别实例名称不匹配时完全没有内容。
- 05
确认根对象上的 Collider 已勾选 Is Trigger,子对象初始状态可以保持隐藏。
- 06
保存预制体,让场景中的所有实例继承这些公共设置。
逐件核对作品与解说音频
- 01
回到主场景,在 ArtworkInfo 父对象下依次选择每个 ArtWorkIntro 实例。
- 02
在 Intro Clip 中连接当前作品的解说音频。
- 03
在 Artwork 中连接当前展品对象,确保该对象带有 CalloutGazeController。
- 04
核对解说音频资源名称与对应说明图片资源名称完全相同,包括汉字、空格和符号。
- 05
对名称无法统一的个别实例,可在 Info Card Texture 中覆盖为正确图片。
- 06
保存场景,进入运行状态逐件确认图片内容与展品相符。
字段缺失时会发生什么
脚本包含提前结束保护,但保护只避免报错,不会替你补上功能。
左右滑动表格,可查看完整内容
| 缺失内容 | 脚本表现 | 学生应怎样处理 |
|---|---|---|
| infoCard | 配置方法提前结束,说明牌无法显示 | 连接 InfoCardContainer |
| Renderer | 无法写入贴图,配置方法提前结束 | 确认卡片保留 Mesh Renderer |
| 同名图片 | 尝试使用 infoCardTexture | 统一名称或设置正确后备图片 |
| artwork | 无法取得 gazeController | 连接带注视控制器的对应作品 |
| 玩家触发体 | 当前 Player 根对象没有 Collider、Rigidbody 或 CharacterController,Trigger 事件无法发生 | 为随玩家身体移动的对象配置 Collider;Collider 所在对象使用 Player 标签,双方至少一方参与触发检测,并允许双方 Layer 碰撞 |
private void SetInfoCardVisible(bool isVisible)
{
if (infoCard != null && infoCard.activeSelf != isVisible)
{
infoCard.SetActive(isVisible);
}
}
private void Reset()
{
GetComponent<Collider>().isTrigger = true;
}
}这段代码已经包含在上方完整文件中,这里只用于理解,不要再次粘贴。SetInfoCardVisible 只在目标状态与 activeSelf 不同时调用 SetActive;Reset 则负责 Collider 的编辑器默认设置。
这里的 Reset 不是运行时重置按钮
Reset 是 Unity 的编辑器生命周期方法。把组件第一次添加到对象上,或在组件菜单中执行 Reset 时,Unity 会调用它,并把同一对象上的 Collider 设为 Trigger。它不会每帧执行,也不会在玩家靠近作品时执行。
这个方法与设置页面按钮调用的 ResetSettings 没有关系。运行状态下的说明牌显示仍由 Trigger、朝向事件和语音许可共同进入 UpdatePlayback 决定。
类上方的 RequireComponent(typeof(Collider)) 表示这个控制器必须与 Collider 一起使用。添加控制器时 Unity 会确保对象具备 Collider,Reset 再把它设置为感应范围所需的触发器。
完成三件作品的交叉核对
选择名称差异明显的三件作品,确认公共配置、专属引用和最终显示内容没有串位。
- 逐个记录作品对象、Intro Clip 名称与实际显示图片标题。
- 确认每个实例都引用同一个预制体子对象结构,但显示内容不同。
- 走近第一件作品后再走向第二件,确认上一张卡片隐藏、下一张卡片出现。
- 修正任何名称或引用错误,保存预制体和场景。
从资源准备到三条件状态做一次完整验证
综合验证说明牌与解说的完整交互
完整脚本已经编译,预制体和实例引用也已连接。最后按照固定顺序验证显示时机、图片内容、暂停与停止的区别,以及多件作品之间是否互相影响。六条操作路线的完整调用链
先按顺序推演,再进入运行状态验证现象。
左右滑动表格,可查看完整内容
| 操作 | 调用顺序 | 最终结果 |
|---|---|---|
| 场景启动 | Awake 选图并隐藏 → OnEnable 连接注视事件 → Start 连接两类管理器事件并首次判断 | 说明牌准备好,但条件不足时保持隐藏 |
| 玩家进入范围 | OnTriggerEnter → 计数加一 → UpdatePlayback | 若朝向或语音许可仍不满足,继续隐藏 |
| 玩家面向作品 | OnFacingEntered → isFacing=true → UpdatePlayback → 管理器选中 → UpdateInfoCard | 三项条件满足且成为当前解说,卡片与声音同时开启 |
| 玩家转开或离开 | 更新状态 → UpdatePlayback 撤销候选 → 管理器重选 → UpdateInfoCard | 本卡片隐藏;若有其他候选则切换到对应作品 |
| 关闭再开启语音解说 | AudioManager 先停止 → 发出许可变化事件 → UpdatePlayback;重新开启后再次判断 | 关闭时隐藏并停止,重新开启且另外两项满足时从头播放 |
| A、B 范围重叠 | A 登记 → B 登记并成为当前 → 退出 B 后撤销 B → 重选 A | 始终只有当前解说对应的一张卡片显示 |
按条件矩阵测试一件作品
- 01
进入运行状态前确认控制台没有红色错误,InfoCardContainer 初始隐藏;同时确认实际玩家触发体具备 Collider,碰撞体所在对象本身使用 Player 标签,双方至少一方能参与触发检测,并且双方 Layer 允许碰撞。
- 02
保持语音解说开启,在范围外面向作品,确认卡片和解说都未启动。
- 03
进入范围但转开视线,确认两者仍未启动。
- 04
在范围内面向作品,确认正确说明图片出现,对应解说开始播放。
- 05
保持站位不变并转开视线,确认卡片隐藏、解说暂停;重新面向后确认从暂停位置继续。
- 06
保持距离和朝向合格,关闭语音解说开关,确认卡片隐藏且解说停止;重新开启后确认从音频开头重新播放。
- 07
离开范围,确认卡片隐藏且没有残留;再测试另外两件作品的图片标题是否正确。
- 08
保持仍在作品 A 的有效范围内进入并面向 B,确认只显示 B;离开 B 后确认只显示并重新播放 A。
- 09
退出运行状态,保存预制体和场景,再进入运行状态快速复测。
常见现象与处理顺序
先从现象判断是资源、引用、状态还是渲染环节,再处理对应位置。
左右滑动表格,可查看完整内容
| 现象 | 优先确认 | 处理方法 |
|---|---|---|
| 始终不显示 | infoCard、Renderer、三项条件 | 连接卡片,保留渲染器,并分别验证语音许可、范围和朝向 |
| 走近后仍不触发 | Collider、Player 标签、物理组件与 Layer | 补齐随玩家身体移动的 Collider;Collider 所在对象本身使用 Player 标签,双方至少一方参与触发检测,Layer 允许碰撞,作品感应 Collider 保持 Is Trigger |
| 解说播放但卡片不见 | infoCard 引用与对象朝向 | 连接正确子对象,并从正面确认面片位置与朝向 |
| 不同作品显示同一内容 | 资源命名、数组与后备贴图 | 统一音频和图片名称,确认同名项已在数组中 |
| 所有共享材质一起变图 | 是否直接修改 sharedMaterial | 使用 MaterialPropertyBlock,只覆盖当前 Renderer |
| 圆角外有底色 | Alpha 与 Surface Type | 图片启用透明处理,InfoMat 设为 Transparent |
| 卡片隐藏但贴图仍占内存 | 可见性与资源加载的区别 | SetActive(false) 只停止显示;需要进一步节省内存时应另行设计按需加载与释放 |
| 重新启用后响应多次 | 事件取消订阅 | 确认 OnDisable 中 -= 与 RemoveListener 都存在 |
| 停用后声音继续或卡片又出现 | OnDisable 与 isActiveAndEnabled | 确认停用时撤销候选并清零,Trigger 入口先检查组件是否启用 |
| 两张卡同时出现但只有一段声音 | CurrentIntroClip 比较与 ActiveIntroChanged | 卡片必须核对当前音频,并在管理器选择变化时刷新 |
| 离开 B 后 A 没有恢复 | AudioManager 候选列表 | 确认 B 被移除后会重新选择仍有效的 A,并发出当前解说变化事件 |
| 文字不够清楚或比例变化 | Non-Power of 2、Max Size 与观看距离 | 先设 Non-Power of 2=None,再保持合理 Max Size 与卡片距离 |
完成本课成果展示
连续展示三件作品,用可观察结果证明资源匹配、三条件交互和原有解说共同工作。
- 依次走近并面向三件作品,读出卡片标题并确认与作品一致。
- 对其中一件作品演示转开视线、重新面向、关闭开关和重新开启。
- 让两件作品的条件短暂重叠,展示卡片随当前解说从 A 切到 B、再回到 A。
- 说明每个动作改变了哪项状态,以及事件如何进入 UpdatePlayback。
- 确认三个说明牌共享 InfoMat,但没有互相覆盖图片。
- 确认控制台无红色错误,预制体与场景均已保存。
把知识变成作品
本课练习
请按顺序完成以下任务,并保存自己的学习成果。- 01
不查看代码,画出语音解说开关、Trigger、面向事件、UpdatePlayback、说明牌和 AudioManager 之间的完整调用链。
- 02
任选一件作品,依次制造三项条件中的每一种不成立状态,并记录说明牌和解说结果。
- 03
为一张新作品说明图片设置 Alpha、Mipmap、Non-Power of 2=None 与 Max Size,把图片与解说音频改成相同名称并加入匹配数组。
- 04
比较直接修改 sharedMaterial 与使用 MaterialPropertyBlock 的影响,观察后立即恢复正确实现。
- 05
临时让音频名称与图片名称不一致,验证后备贴图,再恢复同名自动匹配。
- 06
分别停用并重新激活整个 ArtWorkIntro 游戏对象和脚本组件,确认两种方式都会撤销候选、清除旧状态并隐藏卡片。
- 07
逐件核对全部作品的音频名与图片名,修正空格、符号或汉字不一致的问题。
- 08
在模拟器或头戴设备中完成一次连续参观,确认说明牌不会在远处堆叠,阅读距离和尺寸合适。
- 09
让两件相邻作品同时满足本地条件,验证卡片按当前解说 A→B→A 切换且不会同时出现。
把本课进度保存下来
登录后可以保存浏览内容、有效学习时间、完成状态和答题参与记录。