Appearance
Unity 手写
写一个角色 WASD 移动脚本
核心解释
WASD 移动脚本的核心就是:Update 里读取键盘输入,把输入变成方向向量,再乘速度和 Time.deltaTime,最后让角色移动。
推荐写法
这里用 CharacterController 写。它适合第三人称、第一人称、普通 RPG 角色移动,因为它可以处理基础碰撞,但又不需要你用 Rigidbody.AddForce 去做物理推动。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间。
[RequireComponent(typeof(CharacterController))] // 要求挂这个脚本的物体必须带 CharacterController 组件。
public class WasdCharacterMove : MonoBehaviour // 定义一个 WASD 角色移动脚本。
{ // 类开始。
[Header("移动参数")] // 在 Inspector 中显示移动参数标题。
[SerializeField] private float moveSpeed = 5f; // 定义角色水平移动速度。
[SerializeField] private float gravity = -9.81f; // 定义角色受到的重力加速度。
[SerializeField] private float groundedDownForce = -2f; // 定义贴地时的轻微向下速度,防止角色离地抖动。
[Header("相机参数")] // 在 Inspector 中显示相机参数标题。
[SerializeField] private Transform cameraTransform; // 定义相机 Transform,用来实现相机朝向移动。
private CharacterController controller; // 保存 CharacterController 组件引用。
private float verticalVelocity; // 保存角色当前的竖直速度。
private void Awake() // Unity 生命周期函数,脚本实例加载时调用。
{ // Awake 方法开始。
controller = GetComponent<CharacterController>(); // 获取当前物体上的 CharacterController 组件。
if (cameraTransform == null && Camera.main != null) // 如果没有手动拖相机,并且场景中存在主相机。
{ // if 代码块开始。
cameraTransform = Camera.main.transform; // 自动使用主相机的 Transform。
} // if 代码块结束。
} // Awake 方法结束。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
float horizontal = Input.GetAxisRaw("Horizontal"); // 读取 A/D 或 左右方向键输入。
float vertical = Input.GetAxisRaw("Vertical"); // 读取 W/S 或 上下方向键输入。
Vector3 moveInput = new Vector3(horizontal, 0f, vertical); // 把输入转换成一个三维方向向量。
if (moveInput.sqrMagnitude > 1f) // 如果是斜向输入,向量长度会大于 1。
{ // if 代码块开始。
moveInput.Normalize(); // 归一化方向,避免斜着走比直着走更快。
} // if 代码块结束。
Vector3 moveDirection = GetCameraRelativeDirection(moveInput); // 把输入方向转换成相机朝向下的移动方向。
Vector3 horizontalMove = moveDirection * moveSpeed; // 计算水平移动速度。
ApplyGravity(); // 更新竖直方向的重力速度。
Vector3 finalMove = horizontalMove + Vector3.up * verticalVelocity; // 合成水平移动和竖直移动。
controller.Move(finalMove * Time.deltaTime); // 执行移动,并乘 deltaTime 保证帧率无关。
} // Update 方法结束。
private Vector3 GetCameraRelativeDirection(Vector3 inputDirection) // 根据相机朝向计算移动方向。
{ // GetCameraRelativeDirection 方法开始。
if (cameraTransform == null) // 如果没有相机引用。
{ // if 代码块开始。
return inputDirection; // 直接使用世界坐标方向移动。
} // if 代码块结束。
Vector3 cameraForward = cameraTransform.forward; // 获取相机前方向。
Vector3 cameraRight = cameraTransform.right; // 获取相机右方向。
cameraForward.y = 0f; // 去掉相机前方向的上下倾斜,只保留水平面方向。
cameraRight.y = 0f; // 去掉相机右方向的上下倾斜,只保留水平面方向。
cameraForward.Normalize(); // 归一化相机前方向。
cameraRight.Normalize(); // 归一化相机右方向。
Vector3 result = cameraRight * inputDirection.x + cameraForward * inputDirection.z; // 用相机右方向和前方向合成最终移动方向。
return result; // 返回最终移动方向。
} // GetCameraRelativeDirection 方法结束。
private void ApplyGravity() // 处理角色重力。
{ // ApplyGravity 方法开始。
if (controller.isGrounded && verticalVelocity < 0f) // 如果角色在地面上,并且当前正在向下掉。
{ // if 代码块开始。
verticalVelocity = groundedDownForce; // 给一个轻微向下速度,让角色稳定贴地。
} // if 代码块结束。
else // 如果角色不在地面上。
{ // else 代码块开始。
verticalVelocity += gravity * Time.deltaTime; // 按时间累加重力速度。
} // else 代码块结束。
} // ApplyGravity 方法结束。
} // 类结束。为什么要乘 Time.deltaTime
因为 Update 是每帧执行一次,而不同电脑帧率不一样。 如果不乘 deltaTime,高帧率机器每秒移动次数更多,角色就会更快。
正确理解是:
距离 = 速度 * 时间所以代码里要写:
c
controller.Move(finalMove * Time.deltaTime);面试高分回答
NOTE
我会把角色移动拆成四步:第一步在 Update 读取输入,因为输入是每帧变化的;第二步把 WASD 输入合成方向向量,并归一化避免斜向移动变快;第三步乘移动速度和 Time.deltaTime,保证不同帧率下位移一致;第四步交给 CharacterController.Move 执行移动和基础碰撞。CharacterController 不会自动处理重力,所以竖直速度需要自己维护。
一句话记忆
WASD 移动 = 读输入、合方向、归一化、乘速度和 deltaTime、交给控制器移动。
写一个相机平滑跟随脚本
核心解释
相机平滑跟随就是:先根据玩家位置算出相机“应该在的位置”,然后不要瞬间移动过去,而是用 SmoothDamp 慢慢靠近,这样画面不会硬切、不会抖。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间。
public class SmoothCameraFollow : MonoBehaviour // 定义相机平滑跟随脚本。
{ // 类开始。
[Header("跟随目标")] // 在 Inspector 中显示跟随目标标题。
[SerializeField] private Transform target; // 保存相机要跟随的目标,一般是玩家角色。
[Header("跟随参数")] // 在 Inspector 中显示跟随参数标题。
[SerializeField] private Vector3 offset = new Vector3(0f, 5f, -7f); // 保存相机和目标之间的偏移量。
[SerializeField] private float smoothTime = 0.2f; // 保存平滑时间,越小越跟手,越大越柔和。
[SerializeField] private bool lookAtTarget = true; // 是否让相机始终看向目标。
private Vector3 currentVelocity; // 保存 SmoothDamp 内部使用的当前速度。
private void LateUpdate() // Unity 生命周期函数,在 Update 之后调用,适合处理相机跟随。
{ // LateUpdate 方法开始。
if (target == null) // 如果没有设置跟随目标。
{ // if 代码块开始。
return; // 没有目标就直接退出,避免空引用报错。
} // if 代码块结束。
Vector3 desiredPosition = target.position + offset; // 计算相机希望到达的位置。
transform.position = Vector3.SmoothDamp(transform.position, desiredPosition, ref currentVelocity, smoothTime); // 平滑移动相机到目标位置。
if (lookAtTarget) // 如果需要让相机看向目标。
{ // if 代码块开始。
transform.LookAt(target); // 让相机朝向目标。
} // if 代码块结束。
} // LateUpdate 方法结束。
} // 类结束。怎么挂到 Unity 里
把这个脚本挂到 Main Camera 上。
把玩家角色的 Transform 拖到 target 字段。
调 offset 控制相机位置,比如:
(0, 5, -7):相机在角色后上方。
(0, 8, -10):相机更远,更像俯视第三人称。
调 smoothTime 控制跟随手感:
0.05:很跟手。
0.2:比较自然。
0.5:很柔和,但会有明显延迟。
为什么放在 LateUpdate
因为玩家通常在 Update 中移动。 如果相机也在 Update 里跟随,可能相机先动、玩家后动,画面会有轻微抖动。 放在 LateUpdate,意思是:等角色这一帧移动完,相机再根据最终位置跟上。
面试高分回答
TIP
我会把相机跟随拆成三步:第一步拿到目标位置,第二步加上偏移量得到相机期望位置,第三步用 Vector3.SmoothDamp 从当前位置平滑移动到期望位置。相机逻辑一般放在 LateUpdate,因为角色移动大多在 Update,相机后执行可以减少跟随抖动。
一句话记忆
相机平滑跟随 = target.position + offset 算目标点,SmoothDamp 平滑靠近,LateUpdate 里更新。
写一个子弹对象池
核心解释
子弹对象池就是:子弹不要每次 Instantiate,也不要每次命中后 Destroy。提前创建一批子弹,发射时取出来,命中或超时后隐藏并放回池子,下次继续复用。
BulletPool.cs
c
using System.Collections.Generic; // 引入 Queue 队列集合。
using UnityEngine; // 引入 Unity 引擎命名空间。
public class BulletPool : MonoBehaviour // 定义子弹对象池类。
{ // 类开始。
[SerializeField] private Bullet bulletPrefab; // 保存子弹预制体引用。
[SerializeField] private int preloadCount = 30; // 保存预创建子弹数量。
[SerializeField] private int maxCount = 100; // 保存对象池允许创建的最大子弹数量。
private readonly Queue<Bullet> inactiveBullets = new Queue<Bullet>(); // 保存当前空闲的子弹队列。
private int totalCount; // 保存已经创建过的子弹总数量。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
for (int i = 0; i < preloadCount; i++) // 循环预创建指定数量的子弹。
{ // for 代码块开始。
Bullet bullet = CreateBullet(); // 创建一个新子弹。
inactiveBullets.Enqueue(bullet); // 把新子弹加入空闲队列。
} // for 代码块结束。
} // Awake 方法结束。
public Bullet Spawn(Vector3 position, Vector3 direction, float speed, float lifeTime, int damage) // 从对象池中取出一个子弹并发射。
{ // Spawn 方法开始。
Bullet bullet = GetBullet(); // 从对象池获取一个可用子弹。
if (bullet == null) // 如果没有可用子弹。
{ // if 代码块开始。
return null; // 返回空,表示发射失败。
} // if 代码块结束。
Quaternion rotation = Quaternion.LookRotation(direction.normalized); // 根据飞行方向计算子弹朝向。
bullet.transform.SetPositionAndRotation(position, rotation); // 设置子弹出生位置和朝向。
bullet.gameObject.SetActive(true); // 激活子弹对象。
bullet.Launch(this, direction.normalized, speed, lifeTime, damage); // 初始化子弹飞行参数。
return bullet; // 返回已经发射的子弹。
} // Spawn 方法结束。
private Bullet GetBullet() // 获取一个可用子弹。
{ // GetBullet 方法开始。
if (inactiveBullets.Count > 0) // 如果空闲队列里有子弹。
{ // if 代码块开始。
return inactiveBullets.Dequeue(); // 直接取出一个空闲子弹。
} // if 代码块结束。
if (totalCount < maxCount) // 如果当前创建数量还没有超过最大数量。
{ // if 代码块开始。
return CreateBullet(); // 动态创建一个新子弹。
} // if 代码块结束。
return null; // 没有空闲子弹且达到上限,就返回空。
} // GetBullet 方法结束。
private Bullet CreateBullet() // 创建一个新的子弹对象。
{ // CreateBullet 方法开始。
Bullet bullet = Instantiate(bulletPrefab, transform); // 实例化子弹并挂到对象池节点下面。
bullet.gameObject.SetActive(false); // 创建后先隐藏,表示它处于空闲状态。
totalCount++; // 已创建子弹数量加一。
return bullet; // 返回创建好的子弹。
} // CreateBullet 方法结束。
public void Release(Bullet bullet) // 回收一个子弹。
{ // Release 方法开始。
if (bullet == null) // 如果传入的子弹为空。
{ // if 代码块开始。
return; // 直接退出,避免空引用。
} // if 代码块结束。
bullet.ResetState(); // 重置子弹内部状态。
bullet.gameObject.SetActive(false); // 隐藏子弹对象。
inactiveBullets.Enqueue(bullet); // 把子弹放回空闲队列。
} // Release 方法结束。
} // 类结束。Bullet.cs
c
using UnityEngine; // 引入 Unity 引擎命名空间。
public class Bullet : MonoBehaviour // 定义子弹类。
{ // 类开始。
private BulletPool ownerPool; // 保存这个子弹所属的对象池。
private Vector3 direction; // 保存子弹飞行方向。
private float speed; // 保存子弹飞行速度。
private float lifeTimer; // 保存子弹剩余生命周期。
private int damage; // 保存子弹伤害。
private bool isFlying; // 保存子弹是否正在飞行。
public void Launch(BulletPool pool, Vector3 flyDirection, float flySpeed, float lifeTime, int bulletDamage) // 初始化并发射子弹。
{ // Launch 方法开始。
ownerPool = pool; // 保存所属对象池。
direction = flyDirection; // 保存飞行方向。
speed = flySpeed; // 保存飞行速度。
lifeTimer = lifeTime; // 保存生命周期。
damage = bulletDamage; // 保存伤害值。
isFlying = true; // 标记子弹开始飞行。
} // Launch 方法结束。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
if (!isFlying) // 如果子弹没有处于飞行状态。
{ // if 代码块开始。
return; // 直接退出。
} // if 代码块结束。
transform.position += direction * speed * Time.deltaTime; // 按方向和速度移动子弹。
lifeTimer -= Time.deltaTime; // 扣除本帧经过的时间。
if (lifeTimer <= 0f) // 如果生命周期结束。
{ // if 代码块开始。
Recycle(); // 回收子弹。
} // if 代码块结束。
} // Update 方法结束。
private void OnTriggerEnter(Collider other) // Unity 触发器回调,子弹碰到触发器时调用。
{ // OnTriggerEnter 方法开始。
if (!isFlying) // 如果子弹已经不是飞行状态。
{ // if 代码块开始。
return; // 直接退出,避免重复命中。
} // if 代码块结束。
IDamageable target = other.GetComponent<IDamageable>(); // 尝试从碰到的对象上获取受击接口。
if (target != null) // 如果碰到的对象可以受伤。
{ // if 代码块开始。
target.TakeDamage(damage); // 对目标造成伤害。
} // if 代码块结束。
Recycle(); // 命中后回收子弹。
} // OnTriggerEnter 方法结束。
private void Recycle() // 回收当前子弹。
{ // Recycle 方法开始。
if (!isFlying) // 如果子弹已经回收过。
{ // if 代码块开始。
return; // 直接退出,避免重复回收。
} // if 代码块结束。
isFlying = false; // 标记子弹不再飞行。
ownerPool.Release(this); // 通知对象池回收自己。
} // Recycle 方法结束。
public void ResetState() // 重置子弹状态。
{ // ResetState 方法开始。
ownerPool = null; // 清空对象池引用。
direction = Vector3.zero; // 清空飞行方向。
speed = 0f; // 清空速度。
lifeTimer = 0f; // 清空生命周期。
damage = 0; // 清空伤害。
isFlying = false; // 标记为非飞行状态。
} // ResetState 方法结束。
} // 类结束。IDamageable.cs
c
public interface IDamageable // 定义一个可以受伤的接口。
{ // 接口开始。
void TakeDamage(int damage); // 定义受到伤害的方法。
} // 接口结束。发射示例
c
using UnityEngine; // 引入 Unity 引擎命名空间。
public class Gun : MonoBehaviour // 定义开枪脚本。
{ // 类开始。
[SerializeField] private BulletPool bulletPool; // 保存子弹对象池引用。
[SerializeField] private Transform firePoint; // 保存枪口位置。
[SerializeField] private float bulletSpeed = 20f; // 保存子弹速度。
[SerializeField] private float bulletLifeTime = 3f; // 保存子弹生命周期。
[SerializeField] private int bulletDamage = 10; // 保存子弹伤害。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
if (Input.GetMouseButtonDown(0)) // 如果按下鼠标左键。
{ // if 代码块开始。
bulletPool.Spawn(firePoint.position, firePoint.forward, bulletSpeed, bulletLifeTime, bulletDamage); // 从对象池取出子弹并发射。
} // if 代码块结束。
} // Update 方法结束。
} // 类结束。面试高分回答
WARNING
我会说:子弹是高频创建、高频销毁对象,如果每次发射都 Instantiate,每次命中都 Destroy,会造成 CPU 开销和 GC 压力。对象池的做法是提前创建一批子弹,发射时从空闲队列取出并激活,设置位置、方向、速度、伤害;命中或生命周期结束后,不销毁,而是重置状态、隐藏对象、放回队列等待复用。
一句话记忆
子弹对象池 = 预创建,发射取出,命中或超时回收,避免频繁创建销毁。
写一个血条跟随世界坐标目标
核心解释
血条跟随世界坐标目标,本质是坐标转换:世界坐标 不能直接赋给 UI,需要先用相机转成 屏幕坐标,再转成 Canvas 本地坐标,最后设置血条的 RectTransform.anchoredPosition。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间。
using UnityEngine.UI; // 引入 Unity UI 命名空间。
[RequireComponent(typeof(RectTransform))] // 要求挂这个脚本的对象必须有 RectTransform。
public class WorldHealthBarFollow : MonoBehaviour // 定义血条跟随世界目标的脚本。
{ // 类开始。
[Header("跟随目标")] // 在 Inspector 显示跟随目标标题。
[SerializeField] private Transform target; // 保存要跟随的世界坐标目标。
[SerializeField] private Vector3 worldOffset = new Vector3(0f, 2f, 0f); // 保存血条相对目标的世界坐标偏移。
[Header("UI 引用")] // 在 Inspector 显示 UI 引用标题。
[SerializeField] private Canvas canvas; // 保存血条所在的 Canvas。
[SerializeField] private Image fillImage; // 保存血条填充图片。
[Header("相机引用")] // 在 Inspector 显示相机引用标题。
[SerializeField] private Camera worldCamera; // 保存用于世界坐标转屏幕坐标的相机。
private RectTransform rectTransform; // 保存当前血条自己的 RectTransform。
private RectTransform canvasRectTransform; // 保存 Canvas 的 RectTransform。
private float currentHp = 100f; // 保存当前血量。
private float maxHp = 100f; // 保存最大血量。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
rectTransform = GetComponent<RectTransform>(); // 获取当前血条的 RectTransform。
if (canvas == null) // 如果没有手动拖入 Canvas。
{ // if 代码块开始。
canvas = GetComponentInParent<Canvas>(); // 从父节点查找 Canvas。
} // if 代码块结束。
if (canvas != null) // 如果找到了 Canvas。
{ // if 代码块开始。
canvasRectTransform = canvas.GetComponent<RectTransform>(); // 获取 Canvas 的 RectTransform。
} // if 代码块结束。
if (worldCamera == null) // 如果没有手动拖入相机。
{ // if 代码块开始。
worldCamera = Camera.main; // 默认使用主相机。
} // if 代码块结束。
} // Awake 方法结束。
private void LateUpdate() // Unity 生命周期函数,在 Update 之后调用,适合跟随目标。
{ // LateUpdate 方法开始。
UpdatePosition(); // 更新血条位置。
UpdateFill(); // 更新血条填充比例。
} // LateUpdate 方法结束。
private void UpdatePosition() // 更新血条 UI 位置。
{ // UpdatePosition 方法开始。
if (target == null || canvas == null || canvasRectTransform == null || worldCamera == null) // 如果关键引用缺失。
{ // if 代码块开始。
gameObject.SetActive(false); // 隐藏血条,避免显示在错误位置。
return; // 直接退出。
} // if 代码块结束。
Vector3 worldPosition = target.position + worldOffset; // 计算血条对应的世界坐标位置。
Vector3 screenPosition = worldCamera.WorldToScreenPoint(worldPosition); // 把世界坐标转换成屏幕坐标。
if (screenPosition.z < 0f) // 如果目标在相机背后。
{ // if 代码块开始。
gameObject.SetActive(false); // 隐藏血条。
return; // 直接退出。
} // if 代码块结束。
if (!gameObject.activeSelf) // 如果血条当前是隐藏状态。
{ // if 代码块开始。
gameObject.SetActive(true); // 重新显示血条。
} // if 代码块结束。
Camera uiCamera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; // Overlay 模式传 null,Camera 模式传 Canvas 相机。
Vector2 localPoint; // 定义 Canvas 本地坐标变量。
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRectTransform, screenPosition, uiCamera, out localPoint); // 把屏幕坐标转换成 Canvas 本地坐标。
rectTransform.anchoredPosition = localPoint; // 把血条移动到对应的 UI 坐标。
} // UpdatePosition 方法结束。
private void UpdateFill() // 更新血条填充显示。
{ // UpdateFill 方法开始。
if (fillImage == null) // 如果没有填充图片引用。
{ // if 代码块开始。
return; // 直接退出。
} // if 代码块结束。
float percent = maxHp <= 0f ? 0f : currentHp / maxHp; // 计算当前血量百分比。
fillImage.fillAmount = Mathf.Clamp01(percent); // 设置血条填充比例并限制在 0 到 1。
} // UpdateFill 方法结束。
public void SetTarget(Transform newTarget) // 设置新的跟随目标。
{ // SetTarget 方法开始。
target = newTarget; // 保存新的目标 Transform。
} // SetTarget 方法结束。
public void SetHp(float hp, float max) // 设置血量数据。
{ // SetHp 方法开始。
maxHp = Mathf.Max(1f, max); // 保存最大血量,并保证至少为 1。
currentHp = Mathf.Clamp(hp, 0f, maxHp); // 保存当前血量,并限制在 0 到最大血量之间。
UpdateFill(); // 立即刷新血条显示。
} // SetHp 方法结束。
} // 类结束。怎么挂到 Unity 里
把血条做成 UGUI:Canvas -> HealthBar -> Fill Image。
把 WorldHealthBarFollow 挂到血条根节点上。
target 拖角色的 Transform。
canvas 拖血条所在的 Canvas。
fillImage 拖绿色血条填充图。
worldOffset 通常设置成 (0, 2, 0),表示血条显示在角色头顶。
面试高分回答
IMPORTANT
我会说:血条 UI 和角色不在同一个坐标系里。角色在 3D 世界坐标,UGUI 在 Canvas 坐标,所以不能直接把角色坐标赋给血条。正确做法是先取角色头顶世界坐标,用 Camera.WorldToScreenPoint 转成屏幕坐标,再用 RectTransformUtility.ScreenPointToLocalPointInRectangle 转成 Canvas 本地坐标,最后设置血条的 anchoredPosition。更新时一般放在 LateUpdate,因为角色移动完成后再更新 UI 可以减少抖动。
一句话记忆
世界血条跟随 = 世界坐标转屏幕坐标,再转 Canvas 坐标,最后更新 RectTransform。
写一个 ScrollView 虚拟列表思路
核心解释
ScrollView 虚拟列表就是:数据可以有几千条,但 UI 不创建几千个,只创建屏幕能看到的那几个 Item。滚动时复用这些 Item,重新设置它们的位置和显示数据。
核心思路
Content 的高度还是按照总数据数量撑开:总高度 = 数据数量 * Item高度。
但是实际 UI Item 数量只需要:屏幕可见数量 + 缓冲数量。
滚动时根据 content.anchoredPosition.y 算出当前第一个可见数据下标,然后让池里的 Item 显示对应数据。
C# 简化实现
c
using System.Collections.Generic; // 引入 List 集合类型。
using UnityEngine; // 引入 Unity 引擎命名空间。
using UnityEngine.UI; // 引入 ScrollRect 和 Text 等 UI 类型。
public class VirtualListItem : MonoBehaviour // 定义虚拟列表里的单个 Item。
{ // VirtualListItem 类开始。
[SerializeField] private Text titleText; // 保存 Item 上的文本组件。
public void Refresh(int index, string content) // 刷新当前 Item 显示的数据。
{ // Refresh 方法开始。
titleText.text = index + " : " + content; // 显示数据下标和数据内容。
} // Refresh 方法结束。
} // VirtualListItem 类结束。
public class SimpleVirtualList : MonoBehaviour // 定义简单纵向虚拟列表。
{ // SimpleVirtualList 类开始。
[SerializeField] private ScrollRect scrollRect; // 保存 ScrollRect 组件。
[SerializeField] private RectTransform viewport; // 保存 Viewport 区域。
[SerializeField] private RectTransform content; // 保存 Content 容器。
[SerializeField] private VirtualListItem itemPrefab; // 保存 Item 预制体。
[SerializeField] private float itemHeight = 80f; // 保存每个 Item 的高度。
[SerializeField] private int bufferCount = 2; // 保存额外缓冲 Item 数量。
private readonly List<string> dataList = new List<string>(); // 保存所有数据。
private readonly List<VirtualListItem> itemPool = new List<VirtualListItem>(); // 保存复用的 Item 池。
private void Start() // Unity 生命周期函数,开始时调用。
{ // Start 方法开始。
for (int i = 0; i < 1000; i++) // 模拟创建 1000 条数据。
{ // for 代码块开始。
dataList.Add("邮件数据 " + i); // 添加一条测试数据。
} // for 代码块结束。
InitList(); // 初始化虚拟列表。
} // Start 方法结束。
private void InitList() // 初始化虚拟列表。
{ // InitList 方法开始。
float contentHeight = dataList.Count * itemHeight; // 计算 Content 总高度。
content.sizeDelta = new Vector2(content.sizeDelta.x, contentHeight); // 设置 Content 高度。
int visibleCount = Mathf.CeilToInt(viewport.rect.height / itemHeight); // 计算屏幕可见 Item 数量。
int createCount = visibleCount + bufferCount; // 计算真正需要创建的 Item 数量。
for (int i = 0; i < createCount; i++) // 循环创建少量 Item。
{ // for 代码块开始。
VirtualListItem item = Instantiate(itemPrefab, content); // 创建一个 Item 并放到 Content 下。
itemPool.Add(item); // 把 Item 加入复用池。
} // for 代码块结束。
scrollRect.onValueChanged.AddListener(_ => RefreshVisibleItems()); // 监听滚动变化并刷新可见 Item。
RefreshVisibleItems(); // 初始化时先刷新一次显示。
} // InitList 方法结束。
private void RefreshVisibleItems() // 刷新当前屏幕附近的 Item。
{ // RefreshVisibleItems 方法开始。
float scrollY = content.anchoredPosition.y; // 获取 Content 当前向上滚动的距离。
int firstIndex = Mathf.FloorToInt(scrollY / itemHeight); // 计算第一个可见数据下标。
firstIndex = Mathf.Max(0, firstIndex); // 防止下标小于 0。
for (int i = 0; i < itemPool.Count; i++) // 遍历所有复用 Item。
{ // for 代码块开始。
int dataIndex = firstIndex + i; // 计算这个 Item 应该显示哪条数据。
VirtualListItem item = itemPool[i]; // 取出当前复用 Item。
if (dataIndex >= dataList.Count) // 如果数据下标越界。
{ // if 代码块开始。
item.gameObject.SetActive(false); // 隐藏这个 Item。
continue; // 跳过后续刷新逻辑。
} // if 代码块结束。
item.gameObject.SetActive(true); // 显示这个 Item。
RectTransform itemRect = item.GetComponent<RectTransform>(); // 获取 Item 的 RectTransform。
float y = -dataIndex * itemHeight; // 计算 Item 在 Content 里的纵向位置。
itemRect.anchoredPosition = new Vector2(0f, y); // 设置 Item 的 UI 坐标。
item.Refresh(dataIndex, dataList[dataIndex]); // 刷新 Item 显示的数据。
} // for 代码块结束。
} // RefreshVisibleItems 方法结束。
} // SimpleVirtualList 类结束。面试高分回答
TIP
我会说:普通 ScrollView 如果有 5000 条数据,就创建 5000 个 UI Item,会导致初始化慢、Canvas 重建重、内存高、滑动卡顿。虚拟列表的做法是让 Content 保持真实总高度,但只创建少量可见 Item。滚动时根据滚动距离计算起始数据下标,然后复用 Item,更新它们的位置和显示内容。
注意点
等高 Item 最简单,用 scrollY / itemHeight 就能算下标。
不等高 Item 要维护高度前缀和,再用二分查找当前首项。
大量列表尽量不要挂 VerticalLayoutGroup 和 ContentSizeFitter,否则频繁布局重建会很卡。
一句话记忆
虚拟列表 = Content 撑高度,Item 少量创建,滚动时换数据不换对象。
写一个简单状态机控制动画
核心解释
简单状态机控制动画,就是用代码维护一个“当前角色状态”,比如 Idle、Move、Attack、Dead。状态变化时,统一调用 ChangeState,再让 Animator 播放对应动画。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间。
public class SimpleAnimationStateMachine : MonoBehaviour // 定义一个简单动画状态机脚本。
{ // 类开始。
private enum AnimState // 定义角色动画逻辑状态枚举。
{ // 枚举开始。
Idle, // 待机状态。
Move, // 移动状态。
Attack, // 攻击状态。
Dead // 死亡状态。
} // 枚举结束。
[SerializeField] private Animator animator; // 保存 Animator 组件引用。
[SerializeField] private float attackDuration = 0.8f; // 保存攻击状态持续时间。
private AnimState currentState; // 保存当前状态。
private float attackTimer; // 保存攻击剩余时间。
private bool isDead; // 保存角色是否已经死亡。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
if (animator == null) // 如果没有手动拖入 Animator。
{ // if 代码块开始。
animator = GetComponent<Animator>(); // 自动获取当前物体上的 Animator。
} // if 代码块结束。
ChangeState(AnimState.Idle); // 初始状态切到待机。
} // Awake 方法结束。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
if (Input.GetKeyDown(KeyCode.K)) // 如果按下 K 键,模拟角色死亡。
{ // if 代码块开始。
Die(); // 进入死亡逻辑。
} // if 代码块结束。
if (isDead) // 如果角色已经死亡。
{ // if 代码块开始。
return; // 死亡后不再处理移动和攻击输入。
} // if 代码块结束。
if (currentState == AnimState.Attack) // 如果当前处于攻击状态。
{ // if 代码块开始。
UpdateAttackState(); // 更新攻击状态计时。
return; // 攻击中不处理移动状态切换。
} // if 代码块结束。
if (Input.GetMouseButtonDown(0)) // 如果按下鼠标左键。
{ // if 代码块开始。
ChangeState(AnimState.Attack); // 切换到攻击状态。
return; // 攻击优先级高于移动,直接退出。
} // if 代码块结束。
float horizontal = Input.GetAxisRaw("Horizontal"); // 读取左右移动输入。
float vertical = Input.GetAxisRaw("Vertical"); // 读取前后移动输入。
bool hasMoveInput = Mathf.Abs(horizontal) > 0.01f || Mathf.Abs(vertical) > 0.01f; // 判断是否有移动输入。
ChangeState(hasMoveInput ? AnimState.Move : AnimState.Idle); // 有输入就切移动,没有输入就切待机。
} // Update 方法结束。
private void UpdateAttackState() // 更新攻击状态。
{ // UpdateAttackState 方法开始。
attackTimer -= Time.deltaTime; // 扣除本帧经过的时间。
if (attackTimer <= 0f) // 如果攻击时间结束。
{ // if 代码块开始。
ChangeState(AnimState.Idle); // 攻击结束后回到待机。
} // if 代码块结束。
} // UpdateAttackState 方法结束。
private void Die() // 进入死亡状态。
{ // Die 方法开始。
isDead = true; // 标记角色已经死亡。
ChangeState(AnimState.Dead); // 切换到死亡状态。
} // Die 方法结束。
private void ChangeState(AnimState nextState) // 切换角色状态。
{ // ChangeState 方法开始。
if (currentState == nextState) // 如果目标状态和当前状态一样。
{ // if 代码块开始。
return; // 不重复播放同一个动画。
} // if 代码块结束。
if (currentState == AnimState.Dead) // 如果当前已经是死亡状态。
{ // if 代码块开始。
return; // 死亡状态不允许切换到其他状态。
} // if 代码块结束。
currentState = nextState; // 保存新的当前状态。
PlayAnimationByState(nextState); // 根据状态播放对应动画。
} // ChangeState 方法结束。
private void PlayAnimationByState(AnimState state) // 根据状态播放动画。
{ // PlayAnimationByState 方法开始。
if (state == AnimState.Idle) // 如果进入待机状态。
{ // if 代码块开始。
animator.CrossFade("Idle", 0.1f); // 平滑切换到 Idle 动画。
} // if 代码块结束。
else if (state == AnimState.Move) // 如果进入移动状态。
{ // else if 代码块开始。
animator.CrossFade("Run", 0.1f); // 平滑切换到 Run 动画。
} // else if 代码块结束。
else if (state == AnimState.Attack) // 如果进入攻击状态。
{ // else if 代码块开始。
attackTimer = attackDuration; // 重置攻击计时器。
animator.CrossFade("Attack", 0.05f); // 平滑切换到 Attack 动画。
} // else if 代码块结束。
else if (state == AnimState.Dead) // 如果进入死亡状态。
{ // else if 代码块开始。
animator.CrossFade("Die", 0.1f); // 平滑切换到 Die 动画。
} // else if 代码块结束。
} // PlayAnimationByState 方法结束。
} // 类结束。怎么用
Animator 里准备 4 个动画状态,名字分别叫:Idle、Run、Attack、Die。
把脚本挂到角色身上,把角色的 Animator 拖进去。
WASD 有输入时播放 Run,没输入时播放 Idle。
鼠标左键播放 Attack。
按 K 模拟死亡,播放 Die。
面试高分回答
NOTE
我会说:我会把“角色逻辑状态”和“Animator 动画表现”分开。代码里的状态机负责判断角色现在能不能移动、能不能攻击、死亡后能不能切状态;Animator 只负责播放对应动画。所有切状态都走 ChangeState 一个入口,这样不会到处散落 Play 或 SetBool,后续加受击、翻滚、眩晕也更容易维护。
一句话记忆
状态机控制动画 = 逻辑状态统一切换,Animator 只负责表现。
写一个点击地面移动的 Raycast 脚本
核心解释
点击地面移动的核心是:鼠标点的是屏幕坐标,不是世界坐标。所以要用相机从鼠标位置发射一条射线,射线打到地面后,hit.point 才是角色真正要移动到的世界坐标。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎命名空间。
[RequireComponent(typeof(CharacterController))] // 要求挂脚本的物体必须有 CharacterController 组件。
public class ClickGroundMove : MonoBehaviour // 定义点击地面移动脚本。
{ // 类开始。
[SerializeField] private Camera rayCamera; // 保存发射射线用的相机。
[SerializeField] private LayerMask groundMask = -1; // 保存地面检测层,默认检测所有层。
[SerializeField] private float rayDistance = 200f; // 保存射线最大检测距离。
[SerializeField] private float moveSpeed = 5f; // 保存角色移动速度。
[SerializeField] private float rotateSpeed = 12f; // 保存角色转向速度。
[SerializeField] private float stopDistance = 0.08f; // 保存距离目标多近时停止移动。
[SerializeField] private float gravity = -9.81f; // 保存重力加速度。
[SerializeField] private Transform targetMarker; // 保存目标点标记物,可选。
private CharacterController controller; // 保存 CharacterController 组件引用。
private Vector3 targetPosition; // 保存当前点击得到的目标点。
private bool hasTarget; // 保存当前是否有移动目标。
private float verticalVelocity; // 保存竖直方向速度。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
controller = GetComponent<CharacterController>(); // 获取角色身上的 CharacterController。
if (rayCamera == null) // 如果没有手动指定射线相机。
{ // if 代码块开始。
rayCamera = Camera.main; // 默认使用场景主相机。
} // if 代码块结束。
} // Awake 方法结束。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
HandleClick(); // 处理鼠标点击并更新目标点。
MoveToTarget(); // 让角色朝目标点移动。
} // Update 方法结束。
private void HandleClick() // 处理点击地面的逻辑。
{ // HandleClick 方法开始。
if (!Input.GetMouseButtonDown(0)) // 如果这一帧没有按下鼠标左键。
{ // if 代码块开始。
return; // 不需要发射射线,直接返回。
} // if 代码块结束。
if (rayCamera == null) // 如果相机引用为空。
{ // if 代码块开始。
return; // 没有相机就无法从屏幕点生成射线。
} // if 代码块结束。
Ray ray = rayCamera.ScreenPointToRay(Input.mousePosition); // 根据鼠标屏幕坐标生成一条世界射线。
if (Physics.Raycast(ray, out RaycastHit hit, rayDistance, groundMask)) // 如果射线命中了地面层。
{ // if 代码块开始。
targetPosition = hit.point; // 把命中点保存为角色目标位置。
targetPosition.y = transform.position.y; // 让目标点和角色保持同一水平高度。
hasTarget = true; // 标记当前有目标点可以移动。
UpdateTargetMarker(hit.point); // 更新目标点标记物的位置。
} // if 代码块结束。
} // HandleClick 方法结束。
private void MoveToTarget() // 处理角色朝目标点移动。
{ // MoveToTarget 方法开始。
ApplyGravity(); // 先更新竖直方向重力。
if (!hasTarget) // 如果当前没有移动目标。
{ // if 代码块开始。
Vector3 gravityMove = Vector3.up * verticalVelocity; // 只保留重力方向的移动。
controller.Move(gravityMove * Time.deltaTime); // 应用重力移动。
return; // 没有目标时直接结束。
} // if 代码块结束。
Vector3 toTarget = targetPosition - transform.position; // 计算角色到目标点的方向向量。
toTarget.y = 0f; // 去掉 y 轴,避免角色因为目标高度变化而上下移动。
if (toTarget.magnitude <= stopDistance) // 如果角色已经足够接近目标点。
{ // if 代码块开始。
hasTarget = false; // 清除移动目标。
return; // 停止移动。
} // if 代码块结束。
Vector3 moveDirection = toTarget.normalized; // 把方向向量归一化,得到纯方向。
RotateToDirection(moveDirection); // 让角色朝移动方向转身。
Vector3 horizontalMove = moveDirection * moveSpeed; // 计算水平移动速度。
Vector3 finalMove = horizontalMove + Vector3.up * verticalVelocity; // 合成水平移动和竖直重力。
controller.Move(finalMove * Time.deltaTime); // 使用 CharacterController 执行移动。
} // MoveToTarget 方法结束。
private void RotateToDirection(Vector3 direction) // 让角色朝某个方向旋转。
{ // RotateToDirection 方法开始。
if (direction.sqrMagnitude <= 0.0001f) // 如果方向几乎为零。
{ // if 代码块开始。
return; // 不需要旋转。
} // if 代码块结束。
Quaternion targetRotation = Quaternion.LookRotation(direction); // 根据移动方向计算目标朝向。
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime); // 平滑旋转到目标朝向。
} // RotateToDirection 方法结束。
private void ApplyGravity() // 处理 CharacterController 的重力。
{ // ApplyGravity 方法开始。
if (controller.isGrounded && verticalVelocity < 0f) // 如果角色在地面上,并且正在向下掉。
{ // if 代码块开始。
verticalVelocity = -2f; // 给一个轻微向下速度,让角色稳定贴地。
} // if 代码块结束。
else // 如果角色不在地面上。
{ // else 代码块开始。
verticalVelocity += gravity * Time.deltaTime; // 根据重力加速度累加竖直速度。
} // else 代码块结束。
} // ApplyGravity 方法结束。
private void UpdateTargetMarker(Vector3 worldPoint) // 更新点击目标标记。
{ // UpdateTargetMarker 方法开始。
if (targetMarker == null) // 如果没有设置目标标记物。
{ // if 代码块开始。
return; // 直接返回。
} // if 代码块结束。
targetMarker.position = worldPoint; // 把标记物移动到射线命中的世界点。
targetMarker.gameObject.SetActive(true); // 显示目标标记物。
} // UpdateTargetMarker 方法结束。
} // 类结束。怎么用
角色身上挂 CharacterController。
把这个脚本挂到角色身上。
把地面物体设置到一个 Layer,比如 Ground。
把脚本里的 groundMask 只勾选 Ground。
点击地面时,射线会命中地面,角色就会朝 hit.point 移动。
面试高分回答
WARNING
我会说:点击地面移动分两部分。第一部分是射线检测:用 Camera.ScreenPointToRay(Input.mousePosition) 从屏幕点击点生成射线,再用 Physics.Raycast 检测地面层,拿到 hit.point。第二部分是移动:把 hit.point 保存成目标点,角色每帧计算自己到目标点的方向,乘移动速度和 Time.deltaTime,最后用 CharacterController.Move 移动。Raycast 只负责找点,真正移动要单独写逻辑。
一句话记忆
点击移动 = 屏幕点发射线,射线打地面拿 hit.point,角色每帧朝目标点走。
写一个技能范围检测
核心解释
技能范围检测通常分两步:先用 Physics.OverlapSphere 或 OverlapSphereNonAlloc 找半径内的候选目标,再根据阵营、存活、角度、遮挡等规则做二次筛选。
核心思路
圆形技能:只要目标在半径内,就算命中候选。
扇形技能:先判断距离,再判断目标方向和角色正前方的夹角。
直线技能:一般可以用 BoxCast、CapsuleCast 或者矩形范围检测。
为了减少 GC,面试里可以说使用 Physics.OverlapSphereNonAlloc,避免每次检测都分配新数组。
C# 代码
c
using System.Collections.Generic; // 引入 List 集合类型。
using UnityEngine; // 引入 Unity 引擎命名空间。
public enum SkillRangeShape // 定义技能范围形状枚举。
{ // 枚举开始。
Circle, // 圆形范围。
Sector // 扇形范围。
} // 枚举结束。
public interface ISkillTarget // 定义技能目标接口。
{ // 接口开始。
bool IsAlive { get; } // 表示目标是否存活。
int CampId { get; } // 表示目标阵营 ID。
Vector3 Position { get; } // 表示目标世界坐标。
void TakeSkillDamage(int damage); // 定义受到技能伤害的方法。
} // 接口结束。
public class SkillRangeDetector : MonoBehaviour // 定义技能范围检测脚本。
{ // 类开始。
[SerializeField] private LayerMask targetMask; // 保存目标所在的 LayerMask。
[SerializeField] private int selfCampId = 1; // 保存自己的阵营 ID。
[SerializeField] private int maxColliderCount = 64; // 保存最多检测多少个 Collider。
private Collider[] colliderBuffer; // 保存 NonAlloc 检测使用的 Collider 缓冲数组。
private readonly List<ISkillTarget> resultTargets = new List<ISkillTarget>(); // 保存本次检测出来的目标列表。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
colliderBuffer = new Collider[maxColliderCount]; // 创建 Collider 缓冲数组,之后重复使用避免 GC。
} // Awake 方法结束。
public List<ISkillTarget> DetectTargets(SkillRangeShape shape, float radius, float angle) // 检测技能范围内的目标。
{ // DetectTargets 方法开始。
resultTargets.Clear(); // 清空上一次检测结果。
Vector3 center = transform.position; // 取施法者当前位置作为检测中心。
int hitCount = Physics.OverlapSphereNonAlloc(center, radius, colliderBuffer, targetMask); // 用 NonAlloc 方法找半径内的候选 Collider。
for (int i = 0; i < hitCount; i++) // 遍历所有候选 Collider。
{ // for 代码块开始。
Collider hitCollider = colliderBuffer[i]; // 取出当前候选 Collider。
if (hitCollider == null) // 如果当前 Collider 为空。
{ // if 代码块开始。
continue; // 跳过这个无效 Collider。
} // if 代码块结束。
ISkillTarget target = hitCollider.GetComponentInParent<ISkillTarget>(); // 从 Collider 或父物体上获取技能目标接口。
if (target == null) // 如果这个对象不是技能目标。
{ // if 代码块开始。
continue; // 跳过这个对象。
} // if 代码块结束。
if (!IsValidTarget(target)) // 如果目标阵营或存活状态不满足条件。
{ // if 代码块开始。
continue; // 跳过这个目标。
} // if 代码块结束。
if (shape == SkillRangeShape.Sector && !IsInSector(target.Position, radius, angle)) // 如果是扇形技能,并且目标不在扇形内。
{ // if 代码块开始。
continue; // 跳过这个目标。
} // if 代码块结束。
resultTargets.Add(target); // 把符合条件的目标加入结果列表。
} // for 代码块结束。
return resultTargets; // 返回最终命中的目标列表。
} // DetectTargets 方法结束。
private bool IsValidTarget(ISkillTarget target) // 判断目标是否可以被技能命中。
{ // IsValidTarget 方法开始。
if (!target.IsAlive) // 如果目标已经死亡。
{ // if 代码块开始。
return false; // 死亡目标不能命中。
} // if 代码块结束。
if (target.CampId == selfCampId) // 如果目标和自己是同阵营。
{ // if 代码块开始。
return false; // 同阵营目标不命中。
} // if 代码块结束。
return true; // 目标有效,可以命中。
} // IsValidTarget 方法结束。
private bool IsInSector(Vector3 targetPosition, float radius, float angle) // 判断目标是否在扇形范围内。
{ // IsInSector 方法开始。
Vector3 toTarget = targetPosition - transform.position; // 计算施法者到目标的方向向量。
toTarget.y = 0f; // 忽略高度差,只在水平面判断范围。
if (toTarget.sqrMagnitude > radius * radius) // 如果目标距离超过半径。
{ // if 代码块开始。
return false; // 目标不在技能半径内。
} // if 代码块结束。
Vector3 forward = transform.forward; // 获取施法者正前方方向。
forward.y = 0f; // 忽略前方向的高度分量。
float currentAngle = Vector3.Angle(forward, toTarget); // 计算目标方向和正前方之间的夹角。
float halfAngle = angle * 0.5f; // 计算扇形半角。
return currentAngle <= halfAngle; // 夹角小于等于半角,说明目标在扇形范围内。
} // IsInSector 方法结束。
public void CastDamageSkill(SkillRangeShape shape, float radius, float angle, int damage) // 释放一个造成伤害的范围技能。
{ // CastDamageSkill 方法开始。
List<ISkillTarget> targets = DetectTargets(shape, radius, angle); // 先检测范围内的目标。
for (int i = 0; i < targets.Count; i++) // 遍历所有命中的目标。
{ // for 代码块开始。
targets[i].TakeSkillDamage(damage); // 对每个目标造成技能伤害。
} // for 代码块结束。
} // CastDamageSkill 方法结束。
} // 类结束。怎么用
把敌人设置到一个 Layer,比如 Enemy。
把敌人的根节点或父节点实现 ISkillTarget。
把 SkillRangeDetector 挂到施法者身上。
圆形技能调用:
c
detector.CastDamageSkill(SkillRangeShape.Circle, 5f, 360f, 100); // 释放半径 5 米的圆形伤害技能。扇形技能调用:
c
detector.CastDamageSkill(SkillRangeShape.Sector, 6f, 90f, 120); // 释放半径 6 米、角度 90 度的扇形技能。面试高分回答
IMPORTANT
我会说:技能范围检测一般不要一上来遍历全场所有怪物,而是先做粗筛,比如用 Physics.OverlapSphereNonAlloc 获取一定半径内的候选 Collider,并用 LayerMask 过滤无关对象。然后做细筛,比如判断目标是否存活、是否敌对、是否在扇形角度内、是否有遮挡。最后把合法目标收集起来,交给伤害系统统一结算。
一句话记忆
技能范围检测 = 物理范围粗筛,业务规则细筛,最后统一结算命中目标。
写一个本地存档 JSON 管理器
核心解释
本地 JSON 存档管理器的核心就是:把游戏里的数据对象转成 JSON 字符串,写入 Application.persistentDataPath;读档时再把 JSON 字符串还原成数据对象。
C# 代码
c
using System; // 引入 Serializable 和 Exception 等基础类型。
using System.IO; // 引入 File、Path、Directory 等文件操作类型。
using UnityEngine; // 引入 UnityEngine,用于 JsonUtility、Application、Debug。
[Serializable] // 标记这个类可以被 JsonUtility 序列化。
public class PlayerSaveData // 定义玩家本地存档数据类。
{ // PlayerSaveData 类开始。
public int version = 1; // 保存存档版本号,方便以后做版本兼容。
public string playerName = "Player"; // 保存玩家名字。
public int level = 1; // 保存玩家等级。
public int gold = 0; // 保存玩家金币。
public int currentStage = 1; // 保存当前关卡进度。
} // PlayerSaveData 类结束。
public static class LocalJsonSaveManager // 定义本地 JSON 存档管理器。
{ // LocalJsonSaveManager 类开始。
private const string SaveExtension = ".json"; // 定义存档文件扩展名。
public static bool Save<T>(string fileName, T data) where T : class // 保存任意引用类型数据到 JSON 文件。
{ // Save 方法开始。
if (string.IsNullOrWhiteSpace(fileName)) // 判断文件名是否为空。
{ // if 代码块开始。
Debug.LogError("存档失败:文件名为空"); // 输出错误日志。
return false; // 返回保存失败。
} // if 代码块结束。
if (data == null) // 判断存档数据是否为空。
{ // if 代码块开始。
Debug.LogError("存档失败:数据为空"); // 输出错误日志。
return false; // 返回保存失败。
} // if 代码块结束。
try // 开始捕获文件写入异常。
{ // try 代码块开始。
string path = GetSavePath(fileName); // 获取完整存档路径。
string folder = Path.GetDirectoryName(path); // 获取存档所在文件夹路径。
if (!Directory.Exists(folder)) // 如果存档文件夹不存在。
{ // if 代码块开始。
Directory.CreateDirectory(folder); // 创建存档文件夹。
} // if 代码块结束。
string json = JsonUtility.ToJson(data, true); // 把数据对象转换成格式化 JSON 字符串。
File.WriteAllText(path, json); // 把 JSON 字符串写入本地文件。
Debug.Log("存档成功:" + path); // 输出保存成功日志。
return true; // 返回保存成功。
} // try 代码块结束。
catch (Exception exception) // 捕获保存过程中发生的异常。
{ // catch 代码块开始。
Debug.LogError("存档失败:" + exception.Message); // 输出异常信息。
return false; // 返回保存失败。
} // catch 代码块结束。
} // Save 方法结束。
public static T Load<T>(string fileName) where T : class, new() // 从 JSON 文件读取数据。
{ // Load 方法开始。
if (!Exists(fileName)) // 如果存档文件不存在。
{ // if 代码块开始。
Debug.LogWarning("读档失败:存档不存在,返回默认数据"); // 输出警告日志。
return new T(); // 返回一个默认数据对象。
} // if 代码块结束。
try // 开始捕获文件读取异常。
{ // try 代码块开始。
string path = GetSavePath(fileName); // 获取完整存档路径。
string json = File.ReadAllText(path); // 从本地文件读取 JSON 字符串。
T data = JsonUtility.FromJson<T>(json); // 把 JSON 字符串还原成数据对象。
return data ?? new T(); // 如果还原失败就返回默认对象。
} // try 代码块结束。
catch (Exception exception) // 捕获读取过程中发生的异常。
{ // catch 代码块开始。
Debug.LogError("读档失败:" + exception.Message); // 输出异常信息。
return new T(); // 返回默认数据对象。
} // catch 代码块结束。
} // Load 方法结束。
public static bool Exists(string fileName) // 判断指定存档是否存在。
{ // Exists 方法开始。
string path = GetSavePath(fileName); // 获取完整存档路径。
return File.Exists(path); // 返回文件是否存在。
} // Exists 方法结束。
public static bool Delete(string fileName) // 删除指定存档。
{ // Delete 方法开始。
if (!Exists(fileName)) // 如果存档文件不存在。
{ // if 代码块开始。
return false; // 返回删除失败。
} // if 代码块结束。
string path = GetSavePath(fileName); // 获取完整存档路径。
File.Delete(path); // 删除本地存档文件。
Debug.Log("删除存档:" + path); // 输出删除日志。
return true; // 返回删除成功。
} // Delete 方法结束。
public static string GetSavePath(string fileName) // 获取存档完整路径。
{ // GetSavePath 方法开始。
string finalName = fileName.EndsWith(SaveExtension) ? fileName : fileName + SaveExtension; // 自动补全 json 后缀。
return Path.Combine(Application.persistentDataPath, finalName); // 拼出跨平台持久化存档路径。
} // GetSavePath 方法结束。
} // LocalJsonSaveManager 类结束。使用示例
c
using UnityEngine; // 引入 Unity 引擎命名空间。
public class SaveExample : MonoBehaviour // 定义一个存档测试脚本。
{ // SaveExample 类开始。
private PlayerSaveData saveData; // 保存当前玩家存档数据。
private void Start() // Unity 生命周期函数,游戏开始时调用。
{ // Start 方法开始。
saveData = LocalJsonSaveManager.Load<PlayerSaveData>("player_save"); // 读取玩家存档,没有存档就创建默认数据。
} // Start 方法结束。
private void Update() // Unity 生命周期函数,每帧调用一次。
{ // Update 方法开始。
if (Input.GetKeyDown(KeyCode.S)) // 如果按下 S 键。
{ // if 代码块开始。
saveData.gold += 100; // 模拟玩家获得 100 金币。
LocalJsonSaveManager.Save("player_save", saveData); // 保存当前玩家数据。
} // if 代码块结束。
if (Input.GetKeyDown(KeyCode.L)) // 如果按下 L 键。
{ // if 代码块开始。
saveData = LocalJsonSaveManager.Load<PlayerSaveData>("player_save"); // 重新读取玩家存档。
} // if 代码块结束。
if (Input.GetKeyDown(KeyCode.D)) // 如果按下 D 键。
{ // if 代码块开始。
LocalJsonSaveManager.Delete("player_save"); // 删除玩家存档文件。
} // if 代码块结束。
} // Update 方法结束。
} // SaveExample 类结束。面试高分回答
WARNING
我会说:本地 JSON 存档一般分为数据类和存档管理器。数据类只保存玩家等级、金币、关卡等纯数据;管理器负责 Save、Load、Exists、Delete。保存时用 JsonUtility.ToJson 序列化,再用 File.WriteAllText 写到 Application.persistentDataPath。读档时用 File.ReadAllText 读取,再用 JsonUtility.FromJson 反序列化。重要项目还要考虑异常处理、版本兼容、备份、防篡改。
注意点
JsonUtility 主要序列化字段,不适合直接序列化属性和 Dictionary。
本地 JSON 容易被玩家改,重要数据要做校验、加密,或者交给服务端。
一句话记忆
JSON 存档 = 数据对象转 JSON 写文件,读档时 JSON 再还原成对象。
写一个资源异步加载封装
核心解释
资源异步加载封装的核心是:业务层只调用 LoadAsync,不用关心底层怎么加载。管理器内部负责查缓存、发起异步加载、合并重复请求、加载完成后回调。
C# 代码
c
using System; // 引入 Action 回调类型。
using System.Collections; // 引入 IEnumerator 协程接口。
using System.Collections.Generic; // 引入 Dictionary 和 List 集合。
using UnityEngine; // 引入 Unity 引擎命名空间。
public class AsyncResourceLoader : MonoBehaviour // 定义资源异步加载管理器。
{ // 类开始。
public static AsyncResourceLoader Instance { get; private set; } // 定义单例实例,方便业务层调用。
private readonly Dictionary<string, UnityEngine.Object> cache = new Dictionary<string, UnityEngine.Object>(); // 保存已经加载完成的资源缓存。
private readonly Dictionary<string, List<Action<UnityEngine.Object>>> loadingCallbacks = new Dictionary<string, List<Action<UnityEngine.Object>>>(); // 保存正在加载中的资源回调列表。
private void Awake() // Unity 生命周期函数,脚本加载时调用。
{ // Awake 方法开始。
if (Instance != null && Instance != this) // 如果已经存在另一个实例。
{ // if 代码块开始。
Destroy(gameObject); // 销毁当前重复实例。
return; // 直接退出。
} // if 代码块结束。
Instance = this; // 保存当前实例为单例。
DontDestroyOnLoad(gameObject); // 切场景时不销毁加载管理器。
} // Awake 方法结束。
public void LoadAsync<T>(string path, Action<T> onComplete) where T : UnityEngine.Object // 对外提供泛型异步加载接口。
{ // LoadAsync 方法开始。
if (string.IsNullOrEmpty(path)) // 如果资源路径为空。
{ // if 代码块开始。
Debug.LogError("资源路径为空"); // 输出错误日志。
onComplete?.Invoke(null); // 回调空资源给调用方。
return; // 直接结束。
} // if 代码块结束。
if (cache.TryGetValue(path, out UnityEngine.Object cachedAsset)) // 如果缓存里已经有这个资源。
{ // if 代码块开始。
onComplete?.Invoke(cachedAsset as T); // 直接把缓存资源回调给调用方。
return; // 不再重复加载。
} // if 代码块结束。
Action<UnityEngine.Object> wrappedCallback = asset => onComplete?.Invoke(asset as T); // 把泛型回调包装成 UnityEngine.Object 回调。
if (loadingCallbacks.TryGetValue(path, out List<Action<UnityEngine.Object>> callbacks)) // 如果这个资源正在加载中。
{ // if 代码块开始。
callbacks.Add(wrappedCallback); // 只追加回调,不重复启动加载。
return; // 等已有加载完成后一起回调。
} // if 代码块结束。
loadingCallbacks[path] = new List<Action<UnityEngine.Object>> { wrappedCallback }; // 创建这个路径对应的回调列表。
StartCoroutine(LoadCoroutine<T>(path)); // 启动协程异步加载资源。
} // LoadAsync 方法结束。
private IEnumerator LoadCoroutine<T>(string path) where T : UnityEngine.Object // 定义真正执行异步加载的协程。
{ // LoadCoroutine 方法开始。
ResourceRequest request = Resources.LoadAsync<T>(path); // 使用 Resources 异步加载指定路径资源。
yield return request; // 等待异步加载完成。
UnityEngine.Object asset = request.asset; // 获取加载完成的资源对象。
if (asset == null) // 如果加载结果为空。
{ // if 代码块开始。
Debug.LogError("资源加载失败:" + path); // 输出加载失败日志。
} // if 代码块结束。
else // 如果加载成功。
{ // else 代码块开始。
cache[path] = asset; // 把资源加入缓存。
} // else 代码块结束。
List<Action<UnityEngine.Object>> callbacks = loadingCallbacks[path]; // 取出等待这个资源的所有回调。
loadingCallbacks.Remove(path); // 从加载中字典移除这个资源路径。
for (int i = 0; i < callbacks.Count; i++) // 遍历所有等待回调。
{ // for 代码块开始。
callbacks[i]?.Invoke(asset); // 把加载结果通知给每个调用方。
} // for 代码块结束。
} // LoadCoroutine 方法结束。
public void Unload(string path) // 卸载指定缓存资源。
{ // Unload 方法开始。
if (!cache.TryGetValue(path, out UnityEngine.Object asset)) // 如果缓存中没有这个资源。
{ // if 代码块开始。
return; // 直接退出。
} // if 代码块结束。
cache.Remove(path); // 从缓存字典移除资源。
Resources.UnloadAsset(asset); // 卸载这个资源对象。
} // Unload 方法结束。
public void ClearCache() // 清理全部资源缓存。
{ // ClearCache 方法开始。
cache.Clear(); // 清空缓存字典。
Resources.UnloadUnusedAssets(); // 请求 Unity 卸载没有被引用的资源。
} // ClearCache 方法结束。
} // 类结束。使用示例
c
using UnityEngine; // 引入 Unity 引擎命名空间。
using UnityEngine.UI; // 引入 UI 组件命名空间。
public class LoadIconExample : MonoBehaviour // 定义一个加载图标示例脚本。
{ // 类开始。
[SerializeField] private Image iconImage; // 保存用于显示图标的 Image 组件。
private void Start() // Unity 生命周期函数,游戏开始时调用。
{ // Start 方法开始。
AsyncResourceLoader.Instance.LoadAsync<Sprite>("Icons/skill_fire", OnIconLoaded); // 异步加载 Resources/Icons/skill_fire 路径下的 Sprite。
} // Start 方法结束。
private void OnIconLoaded(Sprite sprite) // 定义图标加载完成回调。
{ // OnIconLoaded 方法开始。
if (sprite == null) // 如果加载结果为空。
{ // if 代码块开始。
return; // 直接退出。
} // if 代码块结束。
iconImage.sprite = sprite; // 把加载到的 Sprite 设置给 UI 图标。
} // OnIconLoaded 方法结束。
} // 类结束。面试高分回答
IMPORTANT
我会说:资源异步加载最好不要散落在业务代码里,而是封装成统一的加载管理器。业务层只传资源路径和回调,管理器内部先查缓存,缓存命中就立即返回;缓存未命中再启动异步加载。如果同一个资源正在加载中,后来的请求只追加回调,不重复发起加载。加载完成后写入缓存,并统一通知所有等待者。项目里可以把底层从 Resources.LoadAsync 替换成 Addressables 或 AssetBundle,上层接口保持不变。
一句话记忆
异步加载封装 = 业务只发请求,管理器负责缓存、去重、加载、回调和卸载。