Appearance
客户端设计题
设计一个背包系统
核心解释
背包系统不要只理解成一个 List。真正项目里要分成:物品配置、运行时背包数据、背包业务服务、UI 显示、存档同步、事件通知。
模块设计
ItemConfig:静态配置。保存 itemId、名字、图标、品质、类型、最大堆叠数、使用效果 ID 等。
BagSlot:运行时格子。保存 slotIndex、itemId、count、是否绑定、过期时间等。
BagService:背包核心逻辑。所有添加、删除、移动、交换、排序、使用物品都走这里。
BagUI:只负责显示和交互。比如格子刷新、拖拽、右键使用、红点显示。
SaveData:只保存必要运行时数据,比如每个槽位的 itemId + count,不要保存图标、名字这种配置数据。
核心流程
添加物品:先查配置,判断是否可叠加;先补满已有同类格子,再放入空格;如果背包满了,返回剩余数量或提示失败。
移动物品:目标格为空就移动;目标格同物品且可叠加就合并;目标格不同物品就交换。
使用物品:先校验物品类型和数量,再触发效果,比如回血、开宝箱、装备穿戴,最后扣数量并通知 UI 刷新。
存档读档:存档只存槽位数据;读档时根据 itemId 回查配置表还原显示。
面试高分回答
TIP
我会说:我会把背包系统拆成配置层、数据层、业务层和表现层。配置层保存物品静态信息,数据层保存槽位运行时状态,业务层 BagService 统一处理添加、删除、移动、使用和排序,UI 层只订阅背包变化事件并刷新显示。这样 UI 不直接改数据,后续接入仓库、装备、交易、红点、存档都比较容易扩展。
关键细节
背包变化后用事件通知 UI,例如 OnBagChanged(slotIndex),避免每次全量刷新。
大量格子可以配合虚拟列表,减少 UI Item 数量。
服务端游戏里,重要背包操作要由服务端校验,客户端只做表现和请求。
一句话记忆
背包系统 = 配置管规则,槽位管数据,服务管操作,UI 管显示,事件管刷新。
设计一个技能系统
核心解释
技能系统不要写成“按按钮就扣血”的单函数。更好的设计是:配置决定技能规则,释放上下文保存本次释放数据,技能服务统一校验和调度,目标筛选器找目标,效果执行器结算伤害、Buff、击退,表现层只负责动画特效。
模块设计
SkillConfig:技能配置。保存技能 ID、CD、蓝耗、施法距离、前摇、后摇、目标类型、范围参数、效果列表。
SkillContext:一次释放技能的上下文。保存施法者、技能配置、目标点、命中目标、释放时间等。
SkillService:技能释放入口。负责校验 CD、蓝量、状态、距离,然后启动技能流程。
TargetSelector:目标筛选。负责单体、圆形、扇形、矩形、射线等范围检测。
EffectExecutor:效果执行。负责伤害、治疗、Buff、击退、召唤、位移等效果。
CooldownManager:CD 管理。记录每个技能的冷却结束时间。
View/Animation:表现层。播放动画、特效、音效,但不直接决定伤害。
释放流程
玩家点击技能。
SkillService 检查能不能释放:是否在 CD、蓝量够不够、是否死亡、是否被沉默、距离是否够。
创建 SkillContext。
进入前摇,播放动画和起手特效。
到命中帧时,调用 TargetSelector 找目标。
调用 EffectExecutor 对目标执行效果。
进入后摇,限制移动或限制下一个技能。
技能进入 CD,通知 UI 刷新。
面试高分回答
NOTE
我会说:我会把技能系统设计成配置驱动的流水线。技能配置只描述规则,比如 CD、消耗、范围、效果;运行时释放时创建 SkillContext,保存施法者、目标点和命中目标。释放统一走 SkillService,先校验状态和消耗,再进入前摇、命中帧、后摇流程。命中帧由动画事件或时间轴触发,目标筛选和效果结算由独立模块处理。这样以后新增技能时,尽量只加配置和效果类型,不到处改业务代码。
关键扩展点
目标筛选可以扩展为单体、圆形、扇形、矩形、链式弹射。
效果可以扩展为伤害、治疗、Buff、击退、吸血、召唤、位移。
多人游戏里,客户端只负责表现和请求,真正伤害、CD、命中要服务端校验。
一句话记忆
技能系统 = 配置定规则,上下文存现场,服务管流程,筛选找目标,效果做结算,表现只同步。
设计一个 Buff 系统
核心解释
Buff 系统要分清两件事:BuffConfig 是静态规则,BuffInstance 是运行时状态。同一个“中毒 Buff”配置,可以被不同角色、不同施法者、不同剩余时间、不同层数生成多个实例。
模块设计
BuffConfig:配置层。保存 Buff ID、名字、持续时间、最大层数、Tick 间隔、叠加规则、效果列表。
BuffInstance:运行时实例。保存当前剩余时间、当前层数、施法者、拥有者、下次 Tick 时间。
BuffComponent:挂在角色身上,管理这个角色当前拥有的 Buff。负责添加、刷新、更新、移除。
BuffEffect:Buff 效果。比如加属性、减速、持续伤害、持续回血、沉默、眩晕、禁疗。
AttributeSystem:属性系统。Buff 不建议直接永久修改最终属性,而是添加一个属性修饰器,让属性系统统一重算。
Event/UI:表现层。监听 Buff 添加、层数变化、移除事件,刷新图标、特效、飘字、战斗日志。
添加 Buff 流程
技能命中目标。
根据 buffId 查 BuffConfig。
目标身上的 BuffComponent 判断是否已经有同类 Buff。
根据叠加规则处理:刷新时间、增加层数、替换旧 Buff、拒绝添加。
创建或更新 BuffInstance。
执行 OnAdd,比如添加属性修饰器、播放特效、通知 UI。
更新 Buff 流程
每帧或逻辑帧遍历角色身上的 Buff。
减少剩余时间。
如果到达 Tick 间隔,执行周期效果,比如中毒扣血、回血光环。
如果剩余时间小于等于 0,执行移除。
移除时撤销属性修饰器、停止特效、通知 UI。
叠加策略
刷新时间:再次施加同类 Buff,只把持续时间重置,比如减速。
增加层数:每次施加层数 +1,到最大层数为止,比如中毒 5 层。
替换旧 Buff:新 Buff 强度更高时替换旧 Buff,比如高级护盾覆盖低级护盾。
独立共存:同 ID 或不同来源可以同时存在,比如多个施法者的灼烧效果分别结算。
面试高分回答
CAUTION
我会说:Buff 系统我会设计成配置驱动。配置层描述 Buff 的持续时间、最大层数、Tick 间隔和效果类型;运行时用 BuffInstance 记录剩余时间、层数、施法者和拥有者。每个角色身上有 BuffComponent 管理自己的 Buff 列表。添加 Buff 时先判断叠加规则,更新时处理 Tick 和过期,移除时撤销属性影响并通知表现层。这样新增 Buff 通常只需要加配置和效果类型,不需要到处改战斗代码。
一句话记忆
Buff 系统 = 配置定规则,实例存状态,组件管生命周期,效果改战斗,事件刷表现。
设计一个任务系统
核心解释
任务系统的核心是:配置描述任务规则,运行时实例记录玩家进度,事件系统驱动任务更新。不要让任务系统每帧扫描全世界,这样会又乱又耗性能。
模块设计
QuestConfig:任务配置。保存任务 ID、标题、描述、前置任务、接取条件、完成条件、奖励。
QuestInstance:运行时任务实例。保存当前状态、每个目标的进度、是否已领奖。
QuestService:任务核心服务。负责接取任务、更新进度、判断完成、发放奖励、保存任务状态。
QuestCondition:任务条件。比如击杀怪物、收集物品、完成对话、进入区域、角色升级。
RewardSystem:奖励系统。负责发金币、经验、道具,防止重复领奖。
QuestUI:任务表现。显示任务列表、任务追踪、完成提示、领奖按钮。
SaveData:存档数据。只保存任务状态和进度,不保存任务描述文本,因为描述来自配置表。
任务状态流转
Locked:未解锁。
CanAccept:可接取。
Doing:进行中。
Completed:已完成但未领奖。
Rewarded:已领奖。
有些游戏还会加 Failed,比如限时任务失败。
更新流程
玩家击杀怪物、获得物品、进入区域、完成对话时,业务系统发事件。
任务系统监听事件,比如 OnMonsterKilled(monsterId)。
QuestService 找到正在进行、且条件匹配的任务。
更新对应目标进度,比如 6 / 10 变成 7 / 10。
如果所有目标完成,任务状态变成 Completed。
UI 收到任务变化事件,刷新任务追踪栏和红点。
玩家领奖时,任务系统校验状态,再调用奖励系统发奖励,并把状态改成 Rewarded。
面试高分回答
IMPORTANT
我会说:我会把任务系统设计成配置驱动和事件驱动。配置层定义任务条件和奖励,运行时层保存玩家当前任务状态和进度,任务服务统一处理接取、更新、完成和领奖。任务进度不靠每帧轮询,而是监听游戏事件,比如击杀怪物、获得道具、进入区域、完成对话。事件触发后只更新相关任务,完成后通知 UI 刷新,领奖时再调用奖励系统并防止重复领取。
关键扩展点
主线、支线、每日任务、成就任务可以共用同一套条件和奖励框架。
任务链可以通过 preQuestId 或 nextQuestId 串起来。
复杂任务可以支持多个目标:比如“击杀 10 个怪 + 收集 3 个材料”。
多人或联网游戏里,领奖和关键进度要服务端校验。
一句话记忆
任务系统 = 配置定规则,实例存进度,事件推更新,服务管流转,奖励防重复。
设计一个成就系统
核心解释
成就系统和任务系统很像,但成就更偏“长期统计”和“达成纪念”。任务通常有接取和完成流程,成就更多是后台累计,比如击杀 100 个怪、通关 10 次副本、收集 50 件装备。
模块设计
AchievementConfig:成就配置。保存成就 ID、名称、描述、分类、目标类型、目标值、是否隐藏、奖励。
AchievementData:玩家成就数据。保存当前进度、是否完成、是否已领奖。
AchievementService:成就服务。统一监听事件、更新进度、判断完成、发放奖励。
EventSystem:事件系统。比如击杀怪物、通关副本、获得装备、升级、登录、强化成功。
AchievementUI:成就界面。显示分类、进度条、完成状态、领奖按钮、隐藏成就。
Save / Server:存档或服务器同步。保存进度、完成状态、领奖状态。
核心流程
游戏发生事件,比如玩家击杀怪物。
事件系统发出 OnMonsterKilled(monsterId)。
成就服务收到事件,找到和击杀相关的成就。
检查条件是否匹配,比如怪物类型、地图 ID、难度。
更新进度,比如 86 / 100 变成 87 / 100。
如果进度达到目标值,成就状态变成已完成。
通知 UI 显示弹窗、红点、完成提示。
玩家点击领奖,成就服务校验未领取后发奖励,并标记已领奖。
成就类型
累计型:击杀 1000 个怪、获得 100000 金币。
一次性:第一次通关副本、第一次获得 SSR。
阶段型:击杀 100、500、1000 个怪,每阶段一个奖励。
隐藏型:达成前不显示条件,比如“不受伤通关”。
限时型:活动期间完成,过期不能继续推进。
组合型:多个条件同时满足,比如“困难难度 + 5 分钟内 + 无死亡通关”。
面试高分回答
TIP
我会说:成就系统我会设计成配置驱动和事件驱动。配置层定义成就类型、目标值、奖励和是否隐藏;运行时数据记录玩家当前进度、完成状态和领奖状态。成就服务监听游戏事件,比如击杀、通关、收集、升级,事件发生时只更新相关成就,而不是每帧扫描所有条件。达成后通知 UI 刷新红点和弹窗,领奖时再校验状态并发放奖励,防止重复领取。
关键注意点
不要每帧遍历所有成就条件,事件驱动更清晰也更省性能。
本地单机可以存 JSON,联网游戏最好服务端校验关键成就。
隐藏成就可以未完成前只显示“???”,完成后再显示真实名字和描述。
阶段成就可以设计成同一组配置,避免写很多重复逻辑。
一句话记忆
成就系统 = 配置定目标,事件推进度,服务判完成,UI 做反馈,领奖防重复。
设计一个红点系统
核心解释
红点系统的本质是一个“树形状态系统”。最底层的叶子节点由业务条件决定,比如未读邮件、可领取任务、可强化装备;上层父节点不自己算业务,只汇总子节点有没有红点。
模块设计
RedDotConfig:配置红点树结构,比如 Root/Bag/Equip、Root/Mail/Unread。
RedDotNode:运行时节点,保存 count、visible、parent、children。
RedDotService:红点系统统一入口,负责更新节点、父节点聚合、事件通知。
RedDotBinder:UI 绑定组件,只绑定某个红点路径,比如背包按钮绑定 Root/Bag。
EventSystem:业务事件来源,比如邮件变化、背包变化、任务完成、成就达成。
核心流程
业务发生变化,比如收到新邮件。
邮件系统发事件:OnMailChanged。
红点服务收到事件,更新叶子节点:Root/Mail/Unread = 1。
叶子节点变化后,向父节点冒泡刷新:Mail 有红点,Root 也有红点。
红点服务通知绑定了这些节点的 UI。
UI 刷新红点显示、数字、动画。
父子聚合规则
父节点是否显示红点:只要任意子节点显示,父节点就显示。
父节点红点数量:可以等于所有子节点数量之和。
有些父节点只需要小红点,不显示数字;有些节点需要显示数量,比如邮件未读数。
为什么要事件驱动
不要每帧遍历所有系统去算红点。 正确做法是:业务数据变化时发事件,红点系统只在变化发生时刷新相关节点。这样性能更好,结构也更清晰。
面试高分回答
NOTE
我会说:我会把红点系统设计成一棵树。叶子节点对应具体业务条件,比如未读邮件、可领取奖励、装备可强化;父节点通过子节点聚合得出状态。业务系统不直接操作 UI,而是通知 RedDotService 更新对应节点。UI 只绑定红点路径,监听节点变化后刷新显示。这样红点逻辑集中管理,不会散落在各个按钮脚本里,后续加新红点也只需要加配置和事件绑定。
一句话记忆
红点系统 = 叶子业务驱动,父节点自动聚合,UI 只绑定路径和监听变化。
设计一个新手引导系统
核心解释
新手引导系统不要写成“某个按钮里硬编码下一步”。更好的设计是:配置驱动步骤,事件触发引导,执行器控制遮罩、手指、对话和点击限制,最后保存进度,支持断点恢复。
模块设计
GuideConfig:引导配置。保存引导 ID、步骤列表、触发条件、优先级、是否强制。
GuideStepConfig:单步配置。保存目标 UI 路径、提示文案、手指方向、是否挖孔、等待条件。
GuideService:引导调度中心。负责判断能否启动、执行下一步、完成引导、保存进度。
GuideStepExecutor:步骤执行器。负责执行不同类型步骤,比如点击按钮、显示对话、等待事件、移动镜头。
GuideView:表现层。负责遮罩、挖孔、高亮、手指动画、对话框。
GuideSaveData:存档数据。保存已完成引导、当前引导步骤、版本号。
核心流程
业务系统发事件,比如玩家升到 3 级、打开背包、完成主线任务。
GuideService 检查有没有符合条件的新手引导。
读取 GuideConfig,创建运行时状态。
执行当前步骤:显示遮罩、挖孔、高亮目标按钮、显示提示文字。
等待玩家点击目标,或者等待某个事件完成。
步骤完成后进入下一步。
全部步骤完成后,记录已完成,通知 UI 清理表现。
关键细节
强制引导:屏蔽其他点击,只允许点目标区域。
弱引导:只显示提示,不阻塞玩家操作。
目标 UI 可能还没创建,所以要支持等待 UI 出现,不能直接空引用报错。
切场景、断线重连、重进游戏后,要能根据存档恢复或跳过已完成步骤。
引导配置要带版本号,避免更新后旧存档卡在不存在的步骤上。
面试高分回答
CAUTION
我会说:我会把新手引导设计成配置驱动的步骤系统,而不是写死在 UI 逻辑里。引导配置描述触发条件和每一步行为,比如高亮哪个按钮、显示什么文案、等待什么操作。运行时由 GuideService 监听游戏事件,判断是否启动引导,再由步骤执行器执行遮罩、挖孔、手指和对话框。每完成一步都更新进度,完成整条引导后写入存档。这样后续改引导流程主要改配置,代码只扩展新的步骤类型。
一句话记忆
新手引导系统 = 配置定步骤,事件触发启动,执行器跑表现,等待条件推进,完成后存档。
设计一个剧情对话系统
核心思路
剧情对话系统不要写成“一堆 if/else 播台词”,而要设计成数据驱动的节点流程系统。
一句话说:对话配置决定剧情走向,运行时只负责解释节点,UI 只负责显示文本和选项。
系统模块怎么拆
DialogueConfig:对话配置表,保存 NPC、节点、文本 key、选项、条件、行为。 DialogueNode:一个对话节点,比如普通台词、玩家选项、条件分支、执行行为、结束节点。 DialogueService:对话系统核心,负责开始对话、跳转节点、处理选项。 DialogueRuntime:运行时上下文,记录当前对话 ID、当前节点 ID、玩家选择、临时变量。 DialogueView:UI 层,只显示头像、名字、文本、选项按钮。 ConditionChecker:判断条件,比如任务是否完成、好感度是否足够、背包是否有道具。 ActionExecutor:执行结果,比如接任务、给奖励、播放动画、切镜头、设置剧情变量。 DialogueSaveData:保存玩家已经选过什么、剧情变量是什么、哪些分支已经触发。
节点类型怎么设计
普通对话节点:NPC 或玩家说一句话,然后进入下一个节点。 选项节点:玩家看到多个选项,点击不同选项进入不同分支。 条件节点:根据任务状态、好感度、道具数量等决定走哪个节点。 行为节点:执行某个游戏逻辑,比如接任务、给物品、播放动画。 结束节点:关闭对话 UI,恢复玩家控制。
一次完整流程
玩家靠近 NPC,按交互键。 系统根据 NPC ID 找到对应的 DialogueConfig。 DialogueService 从起始节点开始运行。 如果是普通台词,就让 UI 显示文本、头像、角色名。 如果是选项节点,就生成多个按钮。 玩家点击选项后,系统检查条件并跳到下一个节点。 如果节点带行为,就调用 ActionExecutor 执行任务、奖励、镜头、动画等逻辑。 对话结束后,保存玩家选择和剧情变量。
简单 C# 核心结构
c
using System; // 引入 Action、Func 等基础委托类型
using System.Collections.Generic; // 引入 List、Dictionary 等集合类型
public enum DialogueNodeType // 定义对话节点类型
{ // 枚举开始
Talk, // 普通说话节点
Choice, // 玩家选项节点
Condition, // 条件分支节点
Action, // 执行业务行为节点
End // 对话结束节点
} // 枚举结束
public class DialogueOption // 定义一个玩家选项
{ // 类开始
public string TextKey; // 选项文本的多语言 key
public string NextNodeId; // 点击该选项后跳转到哪个节点
public string ConditionKey; // 该选项是否可见或可点的条件 key
} // 类结束
public class DialogueNode // 定义一个对话节点
{ // 类开始
public string Id; // 节点唯一 ID
public DialogueNodeType Type; // 当前节点类型
public string Speaker; // 说话人名字或角色 ID
public string TextKey; // 对话文本的多语言 key
public string NextNodeId; // 默认下一个节点 ID
public string ConditionKey; // 条件节点使用的条件 key
public string ActionKey; // 行为节点使用的行为 key
public List<DialogueOption> Options = new List<DialogueOption>(); // 当前节点拥有的选项列表
} // 类结束
public class DialogueConfig // 定义一份完整对话配置
{ // 类开始
public string DialogueId; // 对话配置 ID
public string StartNodeId; // 起始节点 ID
public Dictionary<string, DialogueNode> Nodes = new Dictionary<string, DialogueNode>(); // 所有节点字典
} // 类结束
public class DialogueService // 定义对话系统核心服务
{ // 类开始
private DialogueConfig _config; // 当前正在运行的对话配置
private DialogueNode _currentNode; // 当前正在执行的节点
public void StartDialogue(DialogueConfig config) // 开始一段对话
{ // 方法开始
_config = config; // 保存当前对话配置
ShowNode(config.StartNodeId); // 从起始节点开始显示
} // 方法结束
public void ChooseOption(DialogueOption option) // 玩家点击一个选项
{ // 方法开始
ShowNode(option.NextNodeId); // 跳转到选项对应的下一个节点
} // 方法结束
private void ShowNode(string nodeId) // 显示或执行指定节点
{ // 方法开始
_currentNode = _config.Nodes[nodeId]; // 根据节点 ID 找到节点数据
if (_currentNode.Type == DialogueNodeType.End) // 判断是否是结束节点
{ // if 开始
CloseDialogueUI(); // 关闭对话界面
return; // 停止继续执行
} // if 结束
if (_currentNode.Type == DialogueNodeType.Action) // 判断是否是行为节点
{ // if 开始
ExecuteAction(_currentNode.ActionKey); // 执行节点绑定的行为
ShowNode(_currentNode.NextNodeId); // 行为执行完继续跳到下一个节点
return; // 停止本次方法
} // if 结束
RefreshDialogueUI(_currentNode); // 普通节点或选项节点交给 UI 显示
} // 方法结束
private void ExecuteAction(string actionKey) // 执行对话行为
{ // 方法开始
Console.WriteLine("执行行为:" + actionKey); // 示例:真实项目里会接任务、给奖励或改剧情变量
} // 方法结束
private void RefreshDialogueUI(DialogueNode node) // 刷新对话 UI
{ // 方法开始
Console.WriteLine("显示文本:" + node.TextKey); // 示例:真实项目里会交给 UI 和多语言系统显示
} // 方法结束
private void CloseDialogueUI() // 关闭对话 UI
{ // 方法开始
Console.WriteLine("对话结束"); // 示例:真实项目里会关闭窗口并恢复玩家控制
} // 方法结束
} // 类结束面试高分说法
IMPORTANT
我会把剧情对话设计成节点图 + 条件系统 + 行为系统 + UI 展示层。配置表负责描述剧情,运行时负责推进节点,UI 只负责显示。这样策划可以改配置,不需要程序频繁改代码;后续要接任务、好感度、语音、本地化、打字机效果、剧情回放,也都能扩展进去。
一句话记忆
剧情对话系统的重点不是“显示一句话”,而是:数据控制分支,节点推进流程,条件决定走向,行为影响游戏世界。
设计一个音频管理系统
核心解释
音频管理系统的目标不是“能播声音”这么简单,而是把BGM、音效、UI 声、语音、环境音统一管理起来,让业务代码只关心“播放哪个声音”,不用关心 AudioSource、加载、音量、回收、淡入淡出这些细节。
模块怎么拆
AudioManager:音频系统统一入口,提供 PlayBGM、PlaySFX、StopBGM、SetVolume 等方法。 AudioConfig:音频配置表,记录音频 ID、路径、类型、默认音量、是否循环、优先级。 AudioLoader:负责加载音频资源,可以接 Addressables、AssetBundle 或本地资源。 AudioSourcePool:管理一组 AudioSource,音效播放完回收,避免频繁创建销毁。 AudioMixer:管理音量分组,比如总音量、音乐、音效、语音、UI。 AudioSettingData:保存玩家音量设置和静音状态。 AudioCache:缓存常用音频,比如按钮声、战斗音效、常驻 BGM。
不同声音怎么处理
BGM:通常同时只播放一首,切换时用两个 AudioSource 做淡入淡出。 SFX:可以同时播放很多个,比如攻击声、爆炸声、脚步声,所以适合用对象池。 UI 音效:通常是 2D 声音,不受角色距离影响,播放频率高,可以常驻缓存。 Voice 语音:常和字幕、剧情系统同步,还要考虑跳过、打断、下一句。 Ambient 环境音:比如风声、雨声、水流声,可以按区域进入退出做淡入淡出。
简单代码结构
c
using UnityEngine; // 引入 Unity 引擎基础类型
public class AudioManager : MonoBehaviour // 定义音频管理器
{ // 类开始
public static AudioManager Instance; // 全局唯一入口
[SerializeField] private AudioSource bgmSource; // 专门播放背景音乐的 AudioSource
[SerializeField] private AudioSource sfxSourcePrefab; // 用来创建音效播放器的预制体
private void Awake() // 对象初始化时调用
{ // 方法开始
Instance = this; // 保存单例引用
DontDestroyOnLoad(gameObject); // 切场景时不销毁音频管理器
} // 方法结束
public void PlayBGM(AudioClip clip, float volume, bool loop) // 播放背景音乐
{ // 方法开始
bgmSource.clip = clip; // 设置要播放的音乐资源
bgmSource.volume = volume; // 设置音乐音量
bgmSource.loop = loop; // 设置是否循环播放
bgmSource.Play(); // 开始播放音乐
} // 方法结束
public void StopBGM() // 停止背景音乐
{ // 方法开始
bgmSource.Stop(); // 停止当前音乐
} // 方法结束
public void PlaySFX(AudioClip clip, float volume) // 播放一次性音效
{ // 方法开始
AudioSource source = Instantiate(sfxSourcePrefab); // 创建一个临时音效播放器
source.clip = clip; // 设置音效资源
source.volume = volume; // 设置音效音量
source.loop = false; // 音效默认不循环
source.Play(); // 播放音效
Destroy(source.gameObject, clip.length); // 播放结束后销毁对象
} // 方法结束
} // 类结束这段代码能说明基本思路,但真实项目里 PlaySFX 不建议每次 Instantiate 和 Destroy,而是应该换成 AudioSourcePool 对象池。面试时你可以说:示例代码是最小版本,项目里我会把 SFX 改成池化,避免频繁分配和 GC。
面试高分回答
TIP
我会把音频系统设计成配置驱动。业务层只传音频 ID,比如 PlaySFX("attack_hit"),AudioManager 根据配置找到路径、类型、音量和播放规则,然后通过加载模块拿到 AudioClip,再从 AudioSourcePool 里取播放器播放。BGM 做淡入淡出,SFX 做数量限制和对象池,Voice 和字幕系统联动,音量交给 AudioMixer 分组控制,玩家设置保存到本地。
一句话记忆
音频管理系统就是:AudioManager 统一入口,Config 管数据,Loader 管加载,SourcePool 管播放,Mixer 管音量,Cache 管生命周期。
设计一个跨场景 UI 管理系统
核心解释
跨场景 UI 管理系统的重点是:UIManager 和全局 Canvas 常驻,场景切换时只清理“场景绑定 UI”,保留加载页、弹窗、Toast、通用顶层 UI。
系统怎么拆
UIManager:统一入口,负责打开、关闭、返回、查找 UI。 UIRoot:跨场景常驻对象,通常 DontDestroyOnLoad。 GlobalCanvas:全局 Canvas,里面按层级放 UI。 UILayer:分层管理,比如 Normal、Popup、Toast、Loading。 UIConfig:记录 UI 名字、Prefab 路径、层级、是否缓存、是否跟随场景销毁。 BasePanel:所有 UI 面板的基类,提供 Init、Show、Hide、Close。 UIStack:窗口栈,用来处理返回键、弹窗关闭顺序。 UILoader:负责异步加载 UI Prefab,可以接 Addressables 或 AssetBundle。
为什么要跨场景管理
比如你从主城切到副本,加载页不能跟着旧场景销毁,否则中间会黑屏。 比如网络断线弹窗、系统提示、Toast、GM 面板,这些应该任何场景都能弹。 但战斗血条、关卡倒计时、副本结算面板,应该跟当前场景绑定,切场景就清理掉。
所以 UI 要分成两类:
全局 UI:加载页、Toast、系统弹窗、网络等待、顶层遮罩。 场景 UI:战斗 HUD、关卡目标、场景交互面板、小地图、Boss 血条。
一次打开 UI 的流程
业务层调用:UIManager.Open("BagPanel", data)。 UIManager 查 UIConfig,知道这个 UI 的路径、层级、缓存策略。 如果缓存里有,就直接取出来。 如果没有,就异步加载 Prefab。 实例化后挂到对应层级,比如 NormalLayer 或 PopupLayer。 调用面板的 Init(data)。 播放打开动画并显示。 关闭时根据配置决定隐藏缓存,还是销毁并释放资源。
简单代码结构
c
using UnityEngine; // 引入 Unity 引擎基础类型
using UnityEngine.SceneManagement; // 引入场景管理相关 API
using System.Collections.Generic; // 引入 Dictionary 等集合类型
public class UIManager : MonoBehaviour // 定义跨场景 UI 管理器
{ // 类开始
public static UIManager Instance; // 定义全局单例入口
[SerializeField] private Transform normalLayer; // 普通界面层
[SerializeField] private Transform popupLayer; // 弹窗界面层
[SerializeField] private Transform toastLayer; // 提示界面层
[SerializeField] private Transform loadingLayer; // 加载界面层
private readonly Dictionary<string, BasePanel> openedPanels = new Dictionary<string, BasePanel>(); // 保存已经打开的界面
private void Awake() // 初始化时调用
{ // 方法开始
Instance = this; // 设置单例引用
DontDestroyOnLoad(gameObject); // 让 UIManager 切场景不销毁
SceneManager.sceneLoaded += OnSceneLoaded; // 监听场景加载完成事件
} // 方法结束
public void OpenPanel(string panelName, BasePanel panel, bool sceneBound) // 打开一个界面
{ // 方法开始
panel.SceneBound = sceneBound; // 标记这个界面是否跟随场景销毁
panel.transform.SetParent(normalLayer, false); // 把界面挂到普通 UI 层
panel.Show(); // 显示界面
openedPanels[panelName] = panel; // 记录已经打开的界面
} // 方法结束
public void ClosePanel(string panelName) // 关闭指定界面
{ // 方法开始
if (!openedPanels.TryGetValue(panelName, out BasePanel panel)) // 判断界面是否存在
{ // if 开始
return; // 不存在就直接返回
} // if 结束
panel.Hide(); // 隐藏界面
openedPanels.Remove(panelName); // 从打开列表中移除
} // 方法结束
private void OnSceneLoaded(Scene scene, LoadSceneMode mode) // 场景加载完成后调用
{ // 方法开始
List<string> needClose = new List<string>(); // 创建需要关闭的 UI 名字列表
foreach (var pair in openedPanels) // 遍历所有打开的 UI
{ // foreach 开始
if (pair.Value.SceneBound) // 如果这个 UI 是场景绑定 UI
{ // if 开始
needClose.Add(pair.Key); // 记录它需要关闭
} // if 结束
} // foreach 结束
foreach (string panelName in needClose) // 遍历所有需要关闭的 UI
{ // foreach 开始
ClosePanel(panelName); // 关闭场景绑定 UI
} // foreach 结束
} // 方法结束
} // 类结束
public class BasePanel : MonoBehaviour // 定义 UI 面板基类
{ // 类开始
public bool SceneBound; // 标记 UI 是否跟随场景销毁
public virtual void Show() // 显示界面
{ // 方法开始
gameObject.SetActive(true); // 激活界面对象
} // 方法结束
public virtual void Hide() // 隐藏界面
{ // 方法开始
gameObject.SetActive(false); // 隐藏界面对象
} // 方法结束
} // 类结束面试高分回答
NOTE
我会让 UIManager 在启动场景创建,并通过 DontDestroyOnLoad 常驻。所有跨场景 UI 挂到一个全局 Canvas 下,再用层级区分普通界面、弹窗、Toast、Loading。每个 UI 通过配置表声明路径、层级、是否缓存、是否场景绑定。切场景时,系统监听 SceneManager.sceneLoaded,自动关闭场景绑定 UI,但保留 Loading、网络弹窗、Toast 等全局 UI。同时要注意只保留一个 EventSystem,使用 Screen Space - Camera 时切场景后要重新绑定 UI Camera。
一句话记忆
跨场景 UI 管理就是:UIManager 常驻,Canvas 分层,窗口走栈,场景 UI 跟场景清理,全局 UI 跨场景保留。