Skip to content

Unity 项目追问

角色移动怎么实现的?

角色移动怎么实现的?

character-movement-implementation-csharp

面试高分回答

角色移动本质上不是简单地改 transform.position,而是一个流程:先读取输入,再把输入转换成世界方向,再计算速度和位移,再处理碰撞、重力、跳跃、旋转,最后同步动画和角色状态。

在 Unity 里常见有三种做法: Transform 直接移动,适合没有碰撞要求的小物体;CharacterController 适合玩家角色,自己控制重力和跳跃,移动稳定;Rigidbody 适合受物理影响的物体,比如被撞、被推、受力运动。

常见实现流程

  1. Update 读取输入,比如 WASD 或摇杆。
  2. 根据相机方向算出角色应该往哪里走。
  3. Time.deltaTime 保证不同帧率下移动速度一致。
  4. CharacterController.MoveRigidbody 移动。
  5. Quaternion.LookRotation 让角色朝移动方向转身。
  6. 把速度传给 Animator,播放 Idle、Walk、Run 动画。
  7. 如果角色处于攻击、受击、死亡状态,就限制或禁止移动。

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 里,后期更好维护。

monster-ai-implementation-csharp

面试高分回答

怪物 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 怎么管理的?

ui-management-implementation-csharp

面试高分回答

Unity 里的 UI 一般不会让每个系统自己随便 InstantiateDestroy,而是用一个 UIManager 统一管理。它负责打开界面、关闭界面、缓存界面、控制层级、处理返回键、管理弹窗栈。

我通常会把 UI 分成几层:Normal 普通界面,比如主界面、背包;Popup 弹窗,比如设置、确认框;Toast 提示,比如获得道具;Loading 加载界面。这样不同 UI 不会互相抢层级,显示顺序也更清楚。

每个 UI 面板最好有统一生命周期,比如 OnOpenOnCloseOnRefresh。打开时初始化数据、注册事件,关闭时注销事件,避免 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 管理的重点不是“能打开界面”,而是让界面的创建、层级、生命周期、关闭回收都有统一规则。

资源怎么加载的?

资源怎么加载的?

resource-loading-implementation-csharp

面试高分回答

Unity 资源加载本质是:业务层发起资源请求,资源系统根据路径或地址找到资源位置,加载资源和它依赖的贴图、材质、Shader、Prefab,然后返回给业务使用。用完之后要释放引用,否则会造成内存占用越来越高。

常见方式有几种:Resources.Load 简单,但不适合大项目,因为资源会被打进包里,热更新和分包管理都不方便;AssetBundle 可以把资源拆包,适合热更新;Addressables 是 Unity 官方更现代的资源管理方案,它本质上帮你管理地址、依赖、异步加载、缓存和释放。

项目里一般会封装一个 ResourceManager,业务层不直接调用加载 API,而是统一走 LoadAsyncRelease。这样以后从 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.ReleaseResources.UnloadUnusedAssets,或者 AssetBundle 的卸载策略。

数据怎么存档的?

数据怎么存档的?

save-system-implementation-csharp

面试高分回答

游戏存档一般不是把整个对象直接保存,而是把需要持久化的数据整理成一个 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,新版本字段变了,也能把旧存档迁移到新结构。

配置表怎么读?

配置表怎么读?

config-table-reading-csharp

面试高分回答

配置表一般是策划在 Excel 里维护,比如道具表、怪物表、技能表、关卡表。客户端不会直接在游戏运行时读 Excel,而是通过导表工具把 Excel 转成 JSON、CSV、二进制或者 Lua 表。

运行时读取配置表的流程通常是:先加载配置文件,再反序列化成 C# 数据对象,然后放进 Dictionary<int, Config> 里。业务层查询时只通过 id 查配置,不关心文件在哪里,也不关心配置表是什么格式。

比如道具表里每一行是一件道具:idnamepriceicon。游戏启动时把整张道具表读进内存,之后背包、商店、掉落系统都通过 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、外键引用。

真正运行时不要频繁读文件、不要每次查询都重新解析文本。正确做法是启动或进入场景时统一加载配置,解析后缓存,业务系统只查内存里的数据。配置热更时还要考虑版本号、校验、回滚和旧字段兼容。

对象池怎么写?

对象池怎么写?

object-pool-implementation-csharp

面试高分回答

对象池就是:对象用完以后不 Destroy,而是 SetActive(false) 隐藏起来,放回池子;下次再需要时直接取出来复用。它常用于子弹、特效、怪物、飘字、伤害数字、ScrollView Item。

这样做的核心收益是减少频繁 InstantiateDestroy。因为创建和销毁 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 状态、动画状态。否则复用时会出现脏数据。

对象池也不是越大越好,要设置最大容量。否则峰值战斗创建了一堆对象,后面一直不释放,内存会被池子占住。

场景怎么切换?

场景怎么切换?

scene-switching-implementation-csharp

面试高分回答

Unity 场景切换不要只说 SceneManager.LoadScene。正式项目一般会封装一个 SceneLoader,统一负责显示 Loading、异步加载场景、更新进度条、激活新场景、清理旧资源。

简单场景可以同步切换,但大场景同步加载会卡住主线程,所以项目里更常用 LoadSceneAsync。它返回一个 AsyncOperation,可以通过 progress 获取加载进度。注意 Unity 的异步场景加载进度通常到 0.9 就停住,等你把 allowSceneActivation 设成 true 才会真正进入新场景。

常见流程

  1. 玩家点击进入副本或切换地图。
  2. SceneLoader 显示 Loading 界面,禁用玩家输入。
  3. 调用 SceneManager.LoadSceneAsync(sceneName)
  4. operation.progress / 0.9f 更新进度条。
  5. 进度完成后设置 allowSceneActivation = true
  6. 新场景激活后隐藏 Loading。
  7. 清理旧资源、对象池、音效、临时数据。

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 过渡、常驻对象去重、资源卸载和异常兜底。

动画怎么控制?

动画怎么控制?

animation-control-implementation-csharp

面试高分回答

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 里猜时间,而是用动画事件、归一化时间,或者状态机通知,在真正挥刀命中帧开启攻击盒。这样动画、特效、伤害会更同步。

你怎么做适配?

你怎么做适配?

adaptation-implementation-csharp

面试高分回答

我做适配不会只调一个分辨率,而是分几层处理:UI 分辨率适配、安全区适配、布局适配、资源和性能适配、平台适配

UI 方面,我一般用 Canvas Scaler,模式选 Scale With Screen Size,设置一个设计分辨率,比如 1920 x 1080。然后 UI 元素尽量用 AnchorPivotLayoutGroup 来布局,不写死坐标。这样 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:918:919.5:9、平板比例、刘海屏比例。重点检查按钮有没有被遮挡、文字有没有溢出、弹窗有没有超屏、ScrollView 是否能正常滑动。适配的目标不是某一台机器看起来正常,而是规则稳定,换设备也不容易崩。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0