Appearance
Unity 项目追问
角色移动怎么实现的?
角色移动怎么实现的?
面试高分回答
角色移动本质上不是简单地改 transform.position,而是一个流程:先读取输入,再把输入转换成世界方向,再计算速度和位移,再处理碰撞、重力、跳跃、旋转,最后同步动画和角色状态。
在 Unity 里常见有三种做法: Transform 直接移动,适合没有碰撞要求的小物体;CharacterController 适合玩家角色,自己控制重力和跳跃,移动稳定;Rigidbody 适合受物理影响的物体,比如被撞、被推、受力运动。
常见实现流程
Update读取输入,比如 WASD 或摇杆。- 根据相机方向算出角色应该往哪里走。
- 用
Time.deltaTime保证不同帧率下移动速度一致。 - 用
CharacterController.Move或Rigidbody移动。 - 用
Quaternion.LookRotation让角色朝移动方向转身。 - 把速度传给
Animator,播放 Idle、Walk、Run 动画。 - 如果角色处于攻击、受击、死亡状态,就限制或禁止移动。
C# 示例:CharacterController 角色移动
c
using UnityEngine; // 引入 Unity 引擎常用类型
public sealed class PlayerMovement : MonoBehaviour // 定义玩家移动脚本
{ // 类开始
[SerializeField] private CharacterController controller; // 拖入角色身上的 CharacterController
[SerializeField] private Transform cameraTransform; // 拖入主相机 Transform,用来计算相机朝向移动
[SerializeField] private Animator animator; // 拖入 Animator,用来同步移动动画
[SerializeField] private float moveSpeed = 5f; // 角色水平移动速度
[SerializeField] private float turnSpeed = 12f; // 角色转身速度
[SerializeField] private float gravity = -20f; // 重力加速度,负数表示向下
[SerializeField] private float jumpSpeed = 7f; // 跳跃初速度
private float verticalVelocity; // 当前竖直方向速度,用来处理重力和跳跃
private bool canMove = true; // 是否允许移动,比如攻击或受击时可以设为 false
private void Reset() // 脚本挂上时自动尝试获取组件
{ // Reset 开始
controller = GetComponent<CharacterController>(); // 获取同物体上的 CharacterController
} // Reset 结束
private void Update() // 每帧执行,适合读取输入和驱动角色
{ // Update 开始
Vector2 input = ReadMoveInput(); // 读取水平和垂直输入
Vector3 moveDir = GetCameraRelativeDirection(input); // 把输入转换成相机方向下的世界移动方向
RotateToMoveDirection(moveDir); // 让角色朝移动方向转身
ApplyGravityAndJump(); // 处理重力和跳跃
Move(moveDir); // 执行真正的移动
UpdateAnimator(input); // 同步动画参数
} // Update 结束
private Vector2 ReadMoveInput() // 读取移动输入
{ // ReadMoveInput 开始
float x = Input.GetAxisRaw("Horizontal"); // 读取左右输入,A/D 或 左摇杆 X
float y = Input.GetAxisRaw("Vertical"); // 读取前后输入,W/S 或 左摇杆 Y
Vector2 input = new Vector2(x, y); // 组成二维输入向量
return Vector2.ClampMagnitude(input, 1f); // 限制长度不超过 1,避免斜着走更快
} // ReadMoveInput 结束
private Vector3 GetCameraRelativeDirection(Vector2 input) // 计算相机相对方向
{ // GetCameraRelativeDirection 开始
if (!canMove) return Vector3.zero; // 如果当前状态不能移动,直接返回零方向
if (input.sqrMagnitude <= 0.0001f) return Vector3.zero; // 如果没有输入,也返回零方向
Vector3 forward = cameraTransform.forward; // 取相机前方
Vector3 right = cameraTransform.right; // 取相机右方
forward.y = 0f; // 去掉 Y 轴,避免相机俯仰影响角色上下移动
right.y = 0f; // 去掉 Y 轴,只保留水平面方向
forward.Normalize(); // 归一化前方向,保证长度为 1
right.Normalize(); // 归一化右方向,保证长度为 1
return forward * input.y + right * input.x; // 前后输入控制前方,左右输入控制右方
} // GetCameraRelativeDirection 结束
private void RotateToMoveDirection(Vector3 moveDir) // 角色朝移动方向旋转
{ // RotateToMoveDirection 开始
if (moveDir.sqrMagnitude <= 0.0001f) return; // 没有移动方向时不旋转
Quaternion targetRotation = Quaternion.LookRotation(moveDir); // 根据移动方向生成目标旋转
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, turnSpeed * Time.deltaTime); // 平滑转向目标方向
} // RotateToMoveDirection 结束
private void ApplyGravityAndJump() // 处理重力和跳跃
{ // ApplyGravityAndJump 开始
if (controller.isGrounded && verticalVelocity < 0f) verticalVelocity = -2f; // 贴地时给一点向下速度,避免悬空抖动
if (controller.isGrounded && Input.GetButtonDown("Jump")) verticalVelocity = jumpSpeed; // 在地面按跳跃键时设置向上速度
verticalVelocity += gravity * Time.deltaTime; // 每帧累加重力,让角色自然下落
} // ApplyGravityAndJump 结束
private void Move(Vector3 moveDir) // 执行移动
{ // Move 开始
Vector3 horizontal = moveDir * moveSpeed; // 计算水平速度
Vector3 vertical = Vector3.up * verticalVelocity; // 计算竖直速度
controller.Move((horizontal + vertical) * Time.deltaTime); // 用 CharacterController 移动并处理简单碰撞
} // Move 结束
private void UpdateAnimator(Vector2 input) // 更新动画参数
{ // UpdateAnimator 开始
if (animator == null) return; // 没有 Animator 就不处理动画
animator.SetFloat("Speed", canMove ? input.magnitude : 0f); // 把输入强度传给动画机控制 Idle/Walk/Run
animator.SetBool("Grounded", controller.isGrounded); // 把是否在地面传给动画机
} // UpdateAnimator 结束
} // 类结束面试官喜欢听的补充
IMPORTANT
如果是玩家角色,我通常会把移动拆成“输入层、移动层、状态层、动画层”。输入层只负责读按键,移动层负责速度和碰撞,状态层决定当前能不能移动,动画层只根据速度和状态播放动画。这样攻击、受击、翻滚、击退、死亡这些逻辑不会全部堆在一个 Update 里,后期更好维护。
面试高分回答
怪物 AI 一般不是让怪物“真的思考”,而是把行为拆成固定流程:感知、决策、执行、反馈。
感知就是判断玩家在哪里、距离多远、有没有进入视野、有没有被墙挡住;决策就是根据条件切换状态,比如巡逻、追击、攻击、返回、死亡;执行就是移动、转向、播放动画、释放技能;反馈就是受到攻击、死亡、脱战、仇恨变化、攻击冷却这些。
最常见的新手项目实现方式是 FSM 有限状态机。比如怪物默认 Patrol 巡逻,看到玩家切到 Chase 追击,进入攻击距离切到 Attack 攻击,玩家跑远切到 Return 回出生点,血量为 0 切到 Dead。
C# 示例:FSM + NavMeshAgent 怪物 AI
c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.AI; // 引入 NavMeshAgent 导航组件
public sealed class MonsterAI : MonoBehaviour // 定义怪物 AI 脚本
{ // 类开始
private enum AIState // 定义怪物 AI 状态枚举
{ // 枚举开始
Patrol, // 巡逻状态
Chase, // 追击状态
Attack, // 攻击状态
Return, // 返回出生点状态
Dead // 死亡状态
} // 枚举结束
[SerializeField] private Transform target; // 玩家目标
[SerializeField] private NavMeshAgent agent; // 怪物寻路组件
[SerializeField] private Animator animator; // 怪物动画组件
[SerializeField] private float viewDistance = 10f; // 怪物发现玩家的距离
[SerializeField] private float attackDistance = 2f; // 怪物可以攻击玩家的距离
[SerializeField] private float loseDistance = 15f; // 玩家超过这个距离后怪物脱战
[SerializeField] private float attackCooldown = 1.5f; // 两次攻击之间的冷却时间
private AIState state = AIState.Patrol; // 当前 AI 状态
private Vector3 spawnPosition; // 怪物出生点
private float attackTimer; // 攻击冷却计时器
private void Awake() // 对象创建后执行初始化
{ // Awake 开始
spawnPosition = transform.position; // 记录出生点位置
agent = agent == null ? GetComponent<NavMeshAgent>() : agent; // 如果没手动赋值就自动获取 NavMeshAgent
animator = animator == null ? GetComponent<Animator>() : animator; // 如果没手动赋值就自动获取 Animator
} // Awake 结束
private void Update() // 每帧更新 AI
{ // Update 开始
if (state == AIState.Dead) return; // 死亡状态不再执行 AI
attackTimer -= Time.deltaTime; // 每帧减少攻击冷却时间
DecideState(); // 先根据玩家位置和距离决定状态
TickState(); // 再执行当前状态对应的行为
UpdateAnimator(); // 最后同步动画参数
} // Update 结束
private void DecideState() // 决策层:负责状态切换
{ // DecideState 开始
if (target == null) return; // 没有目标时保持当前状态
float distance = Vector3.Distance(transform.position, target.position); // 计算怪物和玩家的距离
if (distance <= attackDistance) ChangeState(AIState.Attack); // 距离足够近就切换到攻击
else if (distance <= viewDistance) ChangeState(AIState.Chase); // 玩家进入视野范围就追击
else if (distance >= loseDistance) ChangeState(AIState.Return); // 玩家离太远就脱战返回
} // DecideState 结束
private void TickState() // 执行层:负责执行当前状态逻辑
{ // TickState 开始
if (state == AIState.Patrol) TickPatrol(); // 当前是巡逻状态就执行巡逻
if (state == AIState.Chase) TickChase(); // 当前是追击状态就执行追击
if (state == AIState.Attack) TickAttack(); // 当前是攻击状态就执行攻击
if (state == AIState.Return) TickReturn(); // 当前是返回状态就执行返回
} // TickState 结束
private void TickPatrol() // 巡逻逻辑
{ // TickPatrol 开始
agent.isStopped = true; // 示例里先让怪物原地待机
} // TickPatrol 结束
private void TickChase() // 追击逻辑
{ // TickChase 开始
agent.isStopped = false; // 允许 NavMeshAgent 移动
agent.SetDestination(target.position); // 设置玩家位置为寻路目标
} // TickChase 结束
private void TickAttack() // 攻击逻辑
{ // TickAttack 开始
agent.isStopped = true; // 攻击时停止移动
LookAtTarget(); // 攻击前朝向玩家
if (attackTimer > 0f) return; // 攻击还在冷却中就不攻击
attackTimer = attackCooldown; // 重置攻击冷却
animator.SetTrigger("Attack"); // 播放攻击动画
} // TickAttack 结束
private void TickReturn() // 返回出生点逻辑
{ // TickReturn 开始
agent.isStopped = false; // 允许怪物移动
agent.SetDestination(spawnPosition); // 设置出生点为寻路目标
if (Vector3.Distance(transform.position, spawnPosition) <= 0.5f) ChangeState(AIState.Patrol); // 回到出生点附近后切回巡逻
} // TickReturn 结束
private void LookAtTarget() // 朝向玩家
{ // LookAtTarget 开始
Vector3 direction = target.position - transform.position; // 计算指向玩家的方向
direction.y = 0f; // 忽略上下方向,只在水平面旋转
if (direction.sqrMagnitude <= 0.001f) return; // 距离太近时不旋转
transform.rotation = Quaternion.LookRotation(direction); // 让怪物面向玩家
} // LookAtTarget 结束
private void ChangeState(AIState nextState) // 切换状态
{ // ChangeState 开始
if (state == nextState) return; // 如果状态没变就不重复切换
state = nextState; // 保存新的 AI 状态
} // ChangeState 结束
private void UpdateAnimator() // 同步动画参数
{ // UpdateAnimator 开始
animator.SetFloat("Speed", agent.velocity.magnitude); // 把移动速度传给动画机
} // UpdateAnimator 结束
public void Die() // 外部调用的死亡接口
{ // Die 开始
ChangeState(AIState.Dead); // 切换到死亡状态
agent.isStopped = true; // 死亡后停止移动
animator.SetTrigger("Die"); // 播放死亡动画
} // Die 结束
} // 类结束面试官喜欢听的补充
TIP
如果怪物行为比较简单,我会用 FSM,因为清晰、好调试、适合小怪和普通敌人。如果怪物行为很多,比如巡逻、警戒、呼叫同伴、找掩体、技能连招,我会考虑行为树。大量怪物时还要做优化,比如远处怪物降低更新频率、分帧检测视野、只让玩家附近的怪物启用完整 AI。
UI 怎么管理的?
面试高分回答
Unity 里的 UI 一般不会让每个系统自己随便 Instantiate、Destroy,而是用一个 UIManager 统一管理。它负责打开界面、关闭界面、缓存界面、控制层级、处理返回键、管理弹窗栈。
我通常会把 UI 分成几层:Normal 普通界面,比如主界面、背包;Popup 弹窗,比如设置、确认框;Toast 提示,比如获得道具;Loading 加载界面。这样不同 UI 不会互相抢层级,显示顺序也更清楚。
每个 UI 面板最好有统一生命周期,比如 OnOpen、OnClose、OnRefresh。打开时初始化数据、注册事件,关闭时注销事件,避免 UI 关了以后还收到消息,导致空引用或内存泄漏。
C# 示例:简单 UIManager
c
using System.Collections.Generic; // 引入 Dictionary 和 Stack 集合
using UnityEngine; // 引入 Unity 基础类型
public abstract class UIPanel : MonoBehaviour // 定义所有 UI 面板的基类
{ // UIPanel 类开始
public virtual void OnOpen(object args) // 面板打开时调用,可以接收外部参数
{ // OnOpen 开始
gameObject.SetActive(true); // 打开面板时显示当前 UI 对象
} // OnOpen 结束
public virtual void OnClose() // 面板关闭时调用
{ // OnClose 开始
gameObject.SetActive(false); // 关闭面板时隐藏当前 UI 对象
} // OnClose 结束
public virtual void OnRefresh(object args) // 面板已经打开时刷新数据
{ // OnRefresh 开始
} // OnRefresh 结束
} // UIPanel 类结束
public sealed class UIManager : MonoBehaviour // 定义 UI 管理器
{ // UIManager 类开始
[SerializeField] private Transform normalLayer; // 普通界面挂载层
[SerializeField] private Transform popupLayer; // 弹窗界面挂载层
[SerializeField] private Transform toastLayer; // 提示界面挂载层
private readonly Dictionary<string, UIPanel> panelCache = new Dictionary<string, UIPanel>(); // 缓存已经创建过的面板
private readonly Stack<UIPanel> popupStack = new Stack<UIPanel>(); // 记录弹窗打开顺序,用于返回键关闭栈顶弹窗
public void OpenPanel(string panelName, object args = null, bool isPopup = false) // 对外提供打开 UI 的统一入口
{ // OpenPanel 开始
UIPanel panel = GetOrCreatePanel(panelName, isPopup); // 获取缓存面板,或者创建新面板
if (panel.gameObject.activeSelf) panel.OnRefresh(args); // 如果面板已经打开,就刷新数据
else panel.OnOpen(args); // 如果面板还没打开,就执行打开逻辑
if (isPopup) popupStack.Push(panel); // 如果是弹窗,就压入弹窗栈
} // OpenPanel 结束
public void ClosePanel(string panelName) // 根据名字关闭指定 UI
{ // ClosePanel 开始
if (!panelCache.TryGetValue(panelName, out UIPanel panel)) return; // 如果缓存里没有这个面板,就直接返回
panel.OnClose(); // 执行面板关闭逻辑
} // ClosePanel 结束
public void CloseTopPopup() // 关闭最上层弹窗
{ // CloseTopPopup 开始
if (popupStack.Count <= 0) return; // 如果没有弹窗,就直接返回
UIPanel panel = popupStack.Pop(); // 取出最后打开的弹窗
panel.OnClose(); // 关闭这个弹窗
} // CloseTopPopup 结束
private UIPanel GetOrCreatePanel(string panelName, bool isPopup) // 获取或创建 UI 面板
{ // GetOrCreatePanel 开始
if (panelCache.TryGetValue(panelName, out UIPanel cachedPanel)) return cachedPanel; // 如果已经创建过,就直接复用
GameObject prefab = Resources.Load<GameObject>("UI/" + panelName); // 从 Resources 加载 UI 预制体,项目中更推荐 Addressables
Transform parent = isPopup ? popupLayer : normalLayer; // 根据是否弹窗决定挂载层级
GameObject instance = Instantiate(prefab, parent); // 创建 UI 实例并挂到对应层
UIPanel panel = instance.GetComponent<UIPanel>(); // 获取面板脚本
panelCache.Add(panelName, panel); // 加入缓存,避免下次重复创建
return panel; // 返回创建好的面板
} // GetOrCreatePanel 结束
} // UIManager 类结束项目里可以怎么说
NOTE
简单项目可以用 UIManager + BasePanel + 分层 Canvas + 弹窗栈。复杂项目会继续拆成 UI 配置表、异步加载、引用计数、界面缓存策略、红点刷新、事件解绑、列表虚拟化。面试时你可以强调一句:UI 管理的重点不是“能打开界面”,而是让界面的创建、层级、生命周期、关闭回收都有统一规则。
资源怎么加载的?
资源怎么加载的?
面试高分回答
Unity 资源加载本质是:业务层发起资源请求,资源系统根据路径或地址找到资源位置,加载资源和它依赖的贴图、材质、Shader、Prefab,然后返回给业务使用。用完之后要释放引用,否则会造成内存占用越来越高。
常见方式有几种:Resources.Load 简单,但不适合大项目,因为资源会被打进包里,热更新和分包管理都不方便;AssetBundle 可以把资源拆包,适合热更新;Addressables 是 Unity 官方更现代的资源管理方案,它本质上帮你管理地址、依赖、异步加载、缓存和释放。
项目里一般会封装一个 ResourceManager,业务层不直接调用加载 API,而是统一走 LoadAsync、Release。这样以后从 Resources 换到 Addressables,业务代码不用大改。
C# 示例:Addressables 异步加载 + 引用计数
c
using System.Collections.Generic; // 引入 Dictionary,用来缓存已经加载过的资源
using System.Threading.Tasks; // 引入 Task,用来支持 async 和 await 异步写法
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.AddressableAssets; // 引入 Addressables 资源加载接口
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步操作句柄
public sealed class SimpleResourceManager : MonoBehaviour // 定义一个简单资源管理器
{ // 类开始
private sealed class CacheItem // 定义缓存项,用来记录一个资源的加载结果
{ // CacheItem 开始
public GameObject Asset; // 缓存加载出来的 Prefab 资源
public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 的加载句柄,释放时要用它
public int RefCount; // 记录这个资源当前被多少地方使用
} // CacheItem 结束
private readonly Dictionary<string, CacheItem> cache = new Dictionary<string, CacheItem>(); // 用 key 缓存资源,避免重复加载
public async Task<GameObject> LoadPrefabAsync(string key) // 异步加载 Prefab 资源
{ // LoadPrefabAsync 开始
if (cache.TryGetValue(key, out CacheItem item)) // 如果资源已经加载过
{ // 缓存命中开始
item.RefCount++; // 引用计数加一,表示多了一个使用者
return item.Asset; // 直接返回缓存里的资源
} // 缓存命中结束
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 通过 Addressables 按 key 异步加载资源
await handle.Task; // 等待资源加载完成,不阻塞主线程逻辑
if (handle.Status != AsyncOperationStatus.Succeeded) // 判断资源是否加载失败
{ // 加载失败开始
Addressables.Release(handle); // 释放失败的句柄,避免句柄泄漏
Debug.LogError("资源加载失败: " + key); // 打印错误,方便定位配置或路径问题
return null; // 返回空,告诉业务层加载失败
} // 加载失败结束
CacheItem newItem = new CacheItem(); // 创建新的缓存项
newItem.Asset = handle.Result; // 保存加载出来的 Prefab
newItem.Handle = handle; // 保存加载句柄,之后释放资源要用
newItem.RefCount = 1; // 第一次加载时引用计数为一
cache.Add(key, newItem); // 把资源加入缓存表
return newItem.Asset; // 返回加载成功的资源
} // LoadPrefabAsync 结束
public async Task<GameObject> InstantiatePrefabAsync(string key, Transform parent) // 异步加载并实例化 Prefab
{ // InstantiatePrefabAsync 开始
GameObject prefab = await LoadPrefabAsync(key); // 先加载 Prefab 资源
if (prefab == null) return null; // 如果加载失败,就不继续实例化
GameObject instance = Instantiate(prefab, parent); // 根据 Prefab 创建场景中的实例
return instance; // 返回创建好的实例
} // InstantiatePrefabAsync 结束
public void ReleasePrefab(string key) // 释放某个 Prefab 资源引用
{ // ReleasePrefab 开始
if (!cache.TryGetValue(key, out CacheItem item)) return; // 如果缓存里没有这个资源,就直接返回
item.RefCount--; // 引用计数减一,表示少了一个使用者
if (item.RefCount > 0) return; // 如果还有其他地方在用,就不能释放
Addressables.Release(item.Handle); // 引用归零后释放 Addressables 资源句柄
cache.Remove(key); // 从缓存表中移除这个资源
} // ReleasePrefab 结束
public void ReleaseInstance(GameObject instance) // 释放场景中的实例对象
{ // ReleaseInstance 开始
if (instance == null) return; // 如果实例为空,就直接返回
Destroy(instance); // 销毁场景里的 GameObject 实例
} // ReleaseInstance 结束
} // 类结束面试里可以这样补充
IMPORTANT
同步加载适合很小、确定不会卡的资源;大资源、UI、角色、场景、特效一般用异步加载。资源管理的重点不是“能加载出来”,而是要处理依赖、缓存、重复加载、引用计数、卸载时机和加载失败兜底。
尤其要记住:Destroy(gameObject) 只是销毁场景实例,不一定等于释放资源内存。真正的资源释放还要配合 Addressables.Release、Resources.UnloadUnusedAssets,或者 AssetBundle 的卸载策略。
数据怎么存档的?
数据怎么存档的?
面试高分回答
游戏存档一般不是把整个对象直接保存,而是把需要持久化的数据整理成一个 SaveData。比如等级、金币、背包、任务进度、设置、关卡状态。然后把 SaveData 序列化成 JSON 或二进制,写到 Application.persistentDataPath 目录里。
项目里通常会有一个 SaveManager。业务系统只负责提供数据和恢复数据,真正的文件路径、序列化、写入、读取、备份、版本兼容,都放在 SaveManager 里统一处理。
PlayerPrefs 适合保存音量、画质、是否开启震动这种小配置,不适合保存完整游戏进度。正式存档更常见的是 JSON、本地文件、加密文件,或者再接云存档。
C# 示例:JSON 本地存档
c
using System.IO; // 引入文件读写相关 API
using UnityEngine; // 引入 Unity 基础类型
[System.Serializable] public sealed class SaveData // 定义可以被 JSON 序列化的存档数据类
{ // SaveData 类开始
public int saveVersion = 1; // 存档版本号,用来做以后版本兼容
public int playerLevel; // 玩家等级
public int gold; // 玩家金币
public string currentScene; // 玩家当前所在场景
} // SaveData 类结束
public sealed class SaveManager : MonoBehaviour // 定义存档管理器
{ // SaveManager 类开始
private string SavePath => Path.Combine(Application.persistentDataPath, "save.json"); // 正式存档文件路径
private string TempPath => Path.Combine(Application.persistentDataPath, "save.tmp"); // 临时存档文件路径
public void SaveGame(SaveData data) // 保存游戏数据
{ // SaveGame 开始
string json = JsonUtility.ToJson(data, true); // 把 SaveData 转成格式化 JSON 字符串
File.WriteAllText(TempPath, json); // 先写入临时文件,避免正式文件写一半损坏
if (File.Exists(SavePath)) File.Delete(SavePath); // 如果旧存档存在,就先删除旧存档
File.Move(TempPath, SavePath); // 把临时文件改名成正式存档文件
Debug.Log("存档成功: " + SavePath); // 打印存档成功日志,方便调试
} // SaveGame 结束
public SaveData LoadGame() // 读取游戏数据
{ // LoadGame 开始
if (!File.Exists(SavePath)) return CreateNewSave(); // 如果没有存档,就创建一个新存档
string json = File.ReadAllText(SavePath); // 读取存档文件里的 JSON 字符串
SaveData data = JsonUtility.FromJson<SaveData>(json); // 把 JSON 字符串反序列化成 SaveData 对象
if (data.saveVersion < 1) data.saveVersion = 1; // 这里可以做旧版本存档升级
return data; // 返回读取到的存档数据
} // LoadGame 结束
private SaveData CreateNewSave() // 创建默认新存档
{ // CreateNewSave 开始
SaveData data = new SaveData(); // 创建新的 SaveData 对象
data.playerLevel = 1; // 默认玩家等级为 1
data.gold = 0; // 默认金币为 0
data.currentScene = "Main"; // 默认场景为 Main
return data; // 返回默认存档
} // CreateNewSave 结束
} // SaveManager 类结束面试官喜欢听的点
NOTE
存档系统最重要的是可靠性:不能写一半坏档,所以一般先写临时文件,再替换正式文件;最好还有备份文件和校验码。其次是版本兼容:存档里要有 saveVersion,新版本字段变了,也能把旧存档迁移到新结构。
配置表怎么读?
配置表怎么读?
面试高分回答
配置表一般是策划在 Excel 里维护,比如道具表、怪物表、技能表、关卡表。客户端不会直接在游戏运行时读 Excel,而是通过导表工具把 Excel 转成 JSON、CSV、二进制或者 Lua 表。
运行时读取配置表的流程通常是:先加载配置文件,再反序列化成 C# 数据对象,然后放进 Dictionary<int, Config> 里。业务层查询时只通过 id 查配置,不关心文件在哪里,也不关心配置表是什么格式。
比如道具表里每一行是一件道具:id、name、price、icon。游戏启动时把整张道具表读进内存,之后背包、商店、掉落系统都通过 GetItemConfig(id) 来查。
为什么要缓存成 Dictionary?
因为配置表经常按 id 查询。如果每次都遍历列表,数据多了会浪费性能;用 Dictionary<int, ItemConfig> 后,查询接近 O(1),非常适合运行时高频读取。
C# 示例:读取 JSON 配置表
c
using System.Collections.Generic; // 引入 Dictionary,用来按 id 缓存配置
using UnityEngine; // 引入 Unity 的 TextAsset 和 MonoBehaviour
[System.Serializable] // 让这个类可以被 Unity 的 JsonUtility 序列化和反序列化
public sealed class ItemConfig // 定义一条道具配置
{ // ItemConfig 类开始
public int id; // 道具唯一 id,业务系统通常通过它查配置
public string name; // 道具名称,比如金币、药水、长剑
public int price; // 道具价格,可以给商店系统使用
public string icon; // 道具图标资源名,可以给 UI 系统使用
} // ItemConfig 类结束
[System.Serializable] // 让配置表包装类可以被 JsonUtility 解析
public sealed class ItemConfigTable // 定义整张道具配置表
{ // ItemConfigTable 类开始
public List<ItemConfig> items; // 保存所有道具配置,JSON 根节点里需要有 items 字段
} // ItemConfigTable 类结束
public sealed class ConfigManager : MonoBehaviour // 定义配置表管理器
{ // ConfigManager 类开始
private readonly Dictionary<int, ItemConfig> itemDict = new Dictionary<int, ItemConfig>(); // 用字典缓存道具配置
public void LoadItemConfig() // 加载道具配置表
{ // LoadItemConfig 开始
TextAsset textAsset = Resources.Load<TextAsset>("Configs/ItemConfig"); // 从 Resources/Configs/ItemConfig.json 加载文本资源
if (textAsset == null) // 判断配置文件是否加载失败
{ // 加载失败分支开始
Debug.LogError("道具配置表加载失败"); // 打印错误,方便检查路径或资源名
return; // 加载失败时直接返回,避免后面空引用
} // 加载失败分支结束
ItemConfigTable table = JsonUtility.FromJson<ItemConfigTable>(textAsset.text); // 把 JSON 文本解析成配置表对象
if (table == null || table.items == null) // 判断 JSON 格式是否正确
{ // 格式错误分支开始
Debug.LogError("道具配置表解析失败"); // 打印错误,方便检查 JSON 结构
return; // 解析失败时直接返回
} // 格式错误分支结束
itemDict.Clear(); // 重新加载前先清空旧缓存,避免旧数据残留
foreach (ItemConfig config in table.items) // 遍历每一条道具配置
{ // foreach 开始
if (itemDict.ContainsKey(config.id)) // 判断是否出现重复 id
{ // 重复 id 分支开始
Debug.LogError("道具配置表 id 重复: " + config.id); // 打印重复 id,方便策划或工具修表
continue; // 跳过重复数据,避免 Add 抛异常
} // 重复 id 分支结束
itemDict.Add(config.id, config); // 把配置按 id 加入字典
} // foreach 结束
} // LoadItemConfig 结束
public ItemConfig GetItemConfig(int id) // 根据 id 获取道具配置
{ // GetItemConfig 开始
itemDict.TryGetValue(id, out ItemConfig config); // 从字典里查找配置
return config; // 找到就返回配置,找不到就返回 null
} // GetItemConfig 结束
} // ConfigManager 类结束面试官喜欢听的补充
TIP
小项目可以用 JSON 或 CSV,简单直观;正式项目一般会有导表工具,自动从 Excel 生成配置文件和 C# 数据类,并在导出时检查字段类型、空值、重复 id、外键引用。
真正运行时不要频繁读文件、不要每次查询都重新解析文本。正确做法是启动或进入场景时统一加载配置,解析后缓存,业务系统只查内存里的数据。配置热更时还要考虑版本号、校验、回滚和旧字段兼容。
对象池怎么写?
对象池怎么写?
面试高分回答
对象池就是:对象用完以后不 Destroy,而是 SetActive(false) 隐藏起来,放回池子;下次再需要时直接取出来复用。它常用于子弹、特效、怪物、飘字、伤害数字、ScrollView Item。
这样做的核心收益是减少频繁 Instantiate 和 Destroy。因为创建和销毁 GameObject 会带来 CPU 开销,还可能产生 GC 和卡顿。对象池相当于用“复用”换“频繁创建销毁”。
C# 示例:Unity GameObject 对象池
c
using System.Collections.Generic; // 引入 Queue 和 HashSet 集合
using UnityEngine; // 引入 Unity 基础类型
public sealed class GameObjectPool : MonoBehaviour // 定义一个 GameObject 对象池
{ // 类开始
[SerializeField] private GameObject prefab; // 要被反复复用的预制体
[SerializeField] private int preloadCount = 20; // 游戏开始时预创建多少个对象
[SerializeField] private int maxCount = 100; // 对象池允许存在的最大对象数量
[SerializeField] private Transform poolRoot; // 回收对象挂载的父节点,方便层级管理
private readonly Queue<GameObject> inactiveObjects = new Queue<GameObject>(); // 保存未使用对象的队列
private readonly HashSet<GameObject> activeObjects = new HashSet<GameObject>(); // 保存正在使用的对象,防止重复回收
private int totalCount; // 记录池子总共创建过多少对象
private void Awake() // 对象池初始化时调用
{ // Awake 开始
if (poolRoot == null) poolRoot = transform; // 如果没有设置回收根节点,就默认用自己
Preload(); // 启动时预加载一批对象
} // Awake 结束
private void Preload() // 预加载对象
{ // Preload 开始
for (int i = 0; i < preloadCount; i++) // 循环创建指定数量的对象
{ // for 开始
GameObject obj = CreateObject(); // 创建一个新对象
ReturnToPool(obj); // 创建后立刻放回池子等待复用
} // for 结束
} // Preload 结束
public GameObject Get(Vector3 position, Quaternion rotation) // 从池子里取出一个对象
{ // Get 开始
if (inactiveObjects.Count == 0 && totalCount >= maxCount) return null; // 池子空且达到上限时返回空
GameObject obj = inactiveObjects.Count > 0 ? inactiveObjects.Dequeue() : CreateObject(); // 优先复用旧对象,不够再创建
obj.transform.SetParent(null); // 取出后脱离池子节点,避免层级混乱
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象生成位置和旋转
obj.SetActive(true); // 激活对象,让它重新出现在场景中
activeObjects.Add(obj); // 记录这个对象正在使用
return obj; // 返回可用对象给业务层
} // Get 结束
public void Release(GameObject obj) // 把对象归还到池子
{ // Release 开始
if (obj == null) return; // 如果传进来的对象为空,直接返回
if (!activeObjects.Remove(obj)) return; // 如果对象不在使用集合里,说明已经回收过了
ReturnToPool(obj); // 把对象真正放回池子
} // Release 结束
private GameObject CreateObject() // 创建新对象
{ // CreateObject 开始
GameObject obj = Instantiate(prefab, poolRoot); // 根据预制体创建对象并挂到池子节点下
obj.name = prefab.name + "_Pooled"; // 修改名字,方便在 Hierarchy 里观察
totalCount++; // 总创建数量加一
return obj; // 返回新创建的对象
} // CreateObject 结束
private void ReturnToPool(GameObject obj) // 执行回收逻辑
{ // ReturnToPool 开始
obj.SetActive(false); // 隐藏对象,停止它的显示和大部分 Unity 回调
obj.transform.SetParent(poolRoot); // 把对象重新挂回池子节点下
inactiveObjects.Enqueue(obj); // 把对象加入未使用队列,等待下次复用
} // ReturnToPool 结束
public int ActiveCount => activeObjects.Count; // 当前正在使用的对象数量
public int InactiveCount => inactiveObjects.Count; // 当前池子里可复用的对象数量
public int TotalCount => totalCount; // 当前池子总对象数量
} // 类结束面试官喜欢听的补充
TIP
对象池不只是把对象藏起来,还要注意“状态重置”。比如子弹回收前要清速度、清目标、清计时器;特效回收前要停止粒子;怪物回收前要清血量、AI 状态、动画状态。否则复用时会出现脏数据。
对象池也不是越大越好,要设置最大容量。否则峰值战斗创建了一堆对象,后面一直不释放,内存会被池子占住。
场景怎么切换?
场景怎么切换?
面试高分回答
Unity 场景切换不要只说 SceneManager.LoadScene。正式项目一般会封装一个 SceneLoader,统一负责显示 Loading、异步加载场景、更新进度条、激活新场景、清理旧资源。
简单场景可以同步切换,但大场景同步加载会卡住主线程,所以项目里更常用 LoadSceneAsync。它返回一个 AsyncOperation,可以通过 progress 获取加载进度。注意 Unity 的异步场景加载进度通常到 0.9 就停住,等你把 allowSceneActivation 设成 true 才会真正进入新场景。
常见流程
- 玩家点击进入副本或切换地图。
SceneLoader显示 Loading 界面,禁用玩家输入。- 调用
SceneManager.LoadSceneAsync(sceneName)。 - 用
operation.progress / 0.9f更新进度条。 - 进度完成后设置
allowSceneActivation = true。 - 新场景激活后隐藏 Loading。
- 清理旧资源、对象池、音效、临时数据。
C# 示例:异步切场景 + 进度条
c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.SceneManagement; // 引入 SceneManager 场景管理 API
using UnityEngine.UI; // 引入 Slider UI 组件
public sealed class SceneLoader : MonoBehaviour // 定义场景加载器
{ // 类开始
[SerializeField] private GameObject loadingPanel; // Loading 界面对象
[SerializeField] private Slider progressSlider; // Loading 进度条
private bool isLoading; // 标记当前是否正在切场景
private void Awake() // 初始化时执行
{ // Awake 开始
DontDestroyOnLoad(gameObject); // 让加载器切场景时不被销毁
if (loadingPanel != null) loadingPanel.SetActive(false); // 默认隐藏 Loading 界面
} // Awake 结束
public void LoadScene(string sceneName) // 对外提供切换场景接口
{ // LoadScene 开始
if (isLoading) return; // 如果已经在加载中,就避免重复切场景
StartCoroutine(LoadSceneCoroutine(sceneName)); // 启动异步切场景协程
} // LoadScene 结束
private IEnumerator LoadSceneCoroutine(string sceneName) // 异步切场景流程
{ // 协程开始
isLoading = true; // 标记进入加载状态
ShowLoading(); // 显示 Loading 界面
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); // 异步加载目标场景并替换旧场景
operation.allowSceneActivation = false; // 暂时不立刻激活场景,方便进度条先走完
while (operation.progress < 0.9f) // Unity 场景加载到 0.9 前表示还在加载资源
{ // while 开始
float progress = Mathf.Clamp01(operation.progress / 0.9f); // 把 0 到 0.9 映射成 0 到 1
SetProgress(progress); // 更新进度条显示
yield return null; // 等待下一帧继续检查进度
} // while 结束
SetProgress(1f); // 进度条显示为百分百
yield return new WaitForSeconds(0.2f); // 稍微停顿一下,避免进度条一闪而过
operation.allowSceneActivation = true; // 允许 Unity 激活新场景
while (!operation.isDone) // 等待场景真正切换完成
{ // while 开始
yield return null; // 下一帧继续等待
} // while 结束
HideLoading(); // 隐藏 Loading 界面
isLoading = false; // 标记加载结束
Resources.UnloadUnusedAssets(); // 请求 Unity 卸载不再使用的资源
} // 协程结束
private void ShowLoading() // 显示 Loading 界面
{ // ShowLoading 开始
if (loadingPanel != null) loadingPanel.SetActive(true); // 激活 Loading 面板
SetProgress(0f); // 进度条重置为零
} // ShowLoading 结束
private void HideLoading() // 隐藏 Loading 界面
{ // HideLoading 开始
if (loadingPanel != null) loadingPanel.SetActive(false); // 关闭 Loading 面板
} // HideLoading 结束
private void SetProgress(float progress) // 设置进度条
{ // SetProgress 开始
if (progressSlider != null) progressSlider.value = progress; // 更新 Slider 的值
} // SetProgress 结束
} // 类结束面试官喜欢听的补充
IMPORTANT
LoadSceneMode.Single 是新场景替换旧场景,适合普通关卡切换。LoadSceneMode.Additive 是叠加加载,适合大世界分块、常驻 UI 场景、战斗场景叠加加载。
切场景常见坑有两个:第一,DontDestroyOnLoad 的管理器重复创建;第二,只切了场景但没清资源,导致内存越来越高。所以项目里通常会有全局入口、Loading 过渡、常驻对象去重、资源卸载和异常兜底。
动画怎么控制?
动画怎么控制?
面试高分回答
Unity 动画一般是用 Animator + Animator Controller 控制。代码不应该直接管理每一帧动画,而是把角色当前逻辑状态同步给 Animator 参数,比如速度、是否落地、是否攻击、是否受击、是否死亡。
Animator Controller 里有状态机,状态机根据参数切换动画状态。比如 Speed > 0 从 Idle 切到 Run,触发 Attack Trigger 进入攻击动画,血量为 0 设置 Dead,进入死亡动画。
移动动画通常用 Float 参数配合 Blend Tree;攻击、受击这种一次性动作通常用 Trigger;是否死亡、是否落地这种持续状态通常用 Bool。
C# 示例:代码驱动 Animator
c
using UnityEngine; // 引入 Unity 基础类型
public sealed class CharacterAnimationController : MonoBehaviour // 定义角色动画控制脚本
{ // 类开始
[SerializeField] private Animator animator; // 引用角色身上的 Animator 组件
private static readonly int SpeedHash = Animator.StringToHash("Speed"); // 缓存 Speed 参数哈希,避免频繁用字符串查找
private static readonly int GroundedHash = Animator.StringToHash("Grounded"); // 缓存 Grounded 参数哈希
private static readonly int AttackHash = Animator.StringToHash("Attack"); // 缓存 Attack 触发器哈希
private static readonly int HurtHash = Animator.StringToHash("Hurt"); // 缓存 Hurt 触发器哈希
private static readonly int DeadHash = Animator.StringToHash("Dead"); // 缓存 Dead 布尔参数哈希
private bool isDead; // 记录角色是否已经死亡
private void Awake() // 初始化时调用
{ // Awake 开始
animator = animator == null ? GetComponent<Animator>() : animator; // 如果没有手动拖 Animator,就自动获取
} // Awake 结束
public void SetMove(float speed, bool grounded) // 同步移动动画参数
{ // SetMove 开始
if (isDead) return; // 死亡后不再更新移动动画
animator.SetFloat(SpeedHash, speed); // 设置速度参数,用来控制 Idle、Walk、Run
animator.SetBool(GroundedHash, grounded); // 设置是否在地面,用来控制跳跃或落地动画
} // SetMove 结束
public void PlayAttack() // 播放攻击动画
{ // PlayAttack 开始
if (isDead) return; // 死亡后不能攻击
animator.ResetTrigger(HurtHash); // 攻击前清掉受击触发器,避免状态互相抢
animator.SetTrigger(AttackHash); // 触发攻击动画
} // PlayAttack 结束
public void PlayHurt() // 播放受击动画
{ // PlayHurt 开始
if (isDead) return; // 死亡后不再播放普通受击
animator.ResetTrigger(AttackHash); // 受击时清掉攻击触发器,避免继续攻击
animator.SetTrigger(HurtHash); // 触发受击动画
} // PlayHurt 结束
public void PlayDead() // 播放死亡动画
{ // PlayDead 开始
if (isDead) return; // 如果已经死亡,就避免重复播放
isDead = true; // 标记角色已经死亡
animator.SetBool(DeadHash, true); // 设置死亡参数,让状态机进入死亡状态
} // PlayDead 结束
public void OnAttackHitFrame() // 动画事件调用:攻击命中帧
{ // OnAttackHitFrame 开始
Debug.Log("在这一帧开启攻击判定或结算伤害"); // 这里可以打开攻击盒或执行伤害检测
} // OnAttackHitFrame 结束
public void OnAttackEnd() // 动画事件调用:攻击结束帧
{ // OnAttackEnd 开始
Debug.Log("攻击动画结束,可以恢复移动或进入下一段连击"); // 这里可以通知战斗状态机攻击结束
} // OnAttackEnd 结束
} // 类结束面试官喜欢听的补充
IMPORTANT
真正项目里,动画层不要反过来决定所有逻辑。更稳的做法是:角色逻辑状态是权威,Animator 负责表现。比如能不能移动、能不能攻击、是否死亡,应该由角色状态机决定;Animator 只是根据这些状态播放对应动画。
攻击判定通常不要简单写在 Update 里猜时间,而是用动画事件、归一化时间,或者状态机通知,在真正挥刀命中帧开启攻击盒。这样动画、特效、伤害会更同步。
你怎么做适配?
你怎么做适配?
面试高分回答
我做适配不会只调一个分辨率,而是分几层处理:UI 分辨率适配、安全区适配、布局适配、资源和性能适配、平台适配。
UI 方面,我一般用 Canvas Scaler,模式选 Scale With Screen Size,设置一个设计分辨率,比如 1920 x 1080。然后 UI 元素尽量用 Anchor、Pivot、LayoutGroup 来布局,不写死坐标。这样 16:9、全面屏、平板都能保持相对位置正常。
刘海屏和底部横条用 Screen.safeArea 处理,把重要按钮、血条、返回键、货币栏放到安全区里面,避免被遮挡。
性能方面会根据机型分档,比如低端机关闭后处理、降低阴影、减少粒子数量、降低贴图质量;高端机开更高画质。资源方面会注意图集、纹理压缩格式、不同平台包体差异,比如 Android 和 iOS 的压缩格式不一样。
C# 示例:刘海屏安全区适配
c
using UnityEngine; // 引入 Unity 基础类型
[RequireComponent(typeof(RectTransform))] // 要求当前对象必须有 RectTransform 组件
public sealed class SafeAreaAdapter : MonoBehaviour // 定义安全区适配脚本
{ // 类开始
private RectTransform rectTransform; // 缓存当前 UI 的 RectTransform
private Rect lastSafeArea; // 记录上一次的安全区,避免每帧重复计算
private Vector2Int lastScreenSize; // 记录上一次的屏幕尺寸,横竖屏变化时需要重新适配
private void Awake() // 初始化时调用
{ // Awake 开始
rectTransform = GetComponent<RectTransform>(); // 获取当前对象的 RectTransform
ApplySafeArea(); // 启动时立刻应用一次安全区
} // Awake 结束
private void Update() // 每帧检查屏幕是否变化
{ // Update 开始
if (lastSafeArea == Screen.safeArea && lastScreenSize.x == Screen.width && lastScreenSize.y == Screen.height) return; // 如果安全区和屏幕尺寸都没变,就不重复刷新
ApplySafeArea(); // 如果屏幕变化或安全区变化,就重新适配
} // Update 结束
private void ApplySafeArea() // 应用安全区
{ // ApplySafeArea 开始
Rect safeArea = Screen.safeArea; // 获取当前设备的安全区域
lastSafeArea = safeArea; // 保存当前安全区
lastScreenSize = new Vector2Int(Screen.width, Screen.height); // 保存当前屏幕尺寸
Vector2 anchorMin = safeArea.position; // 计算安全区左下角像素坐标
Vector2 anchorMax = safeArea.position + safeArea.size; // 计算安全区右上角像素坐标
anchorMin.x /= Screen.width; // 把左边界转换成 0 到 1 的锚点比例
anchorMin.y /= Screen.height; // 把下边界转换成 0 到 1 的锚点比例
anchorMax.x /= Screen.width; // 把右边界转换成 0 到 1 的锚点比例
anchorMax.y /= Screen.height; // 把上边界转换成 0 到 1 的锚点比例
rectTransform.anchorMin = anchorMin; // 设置 UI 的最小锚点到安全区左下角
rectTransform.anchorMax = anchorMax; // 设置 UI 的最大锚点到安全区右上角
rectTransform.offsetMin = Vector2.zero; // 清空左下偏移,避免额外偏移影响布局
rectTransform.offsetMax = Vector2.zero; // 清空右上偏移,避免额外偏移影响布局
} // ApplySafeArea 结束
} // 类结束面试官喜欢听的补充
TIP
我会准备一批测试分辨率,比如 16:9、18:9、19.5:9、平板比例、刘海屏比例。重点检查按钮有没有被遮挡、文字有没有溢出、弹窗有没有超屏、ScrollView 是否能正常滑动。适配的目标不是某一台机器看起来正常,而是规则稳定,换设备也不容易崩。