Skip to content

题海扩展:岗位开放题

如果让你做一个低配版王者荣耀战斗客户端,你怎么拆模块?

moba-lite-battle-client-modules

标准答案 如果让我做一个低配版 MOBA 战斗客户端,我会先做一个最小可用战斗闭环:一张地图、两队英雄、移动、普攻、技能、血量、死亡、复活、结算。 模块上我会按这条主线拆:

输入层 -> 命令层 -> 战斗逻辑层 -> 事件层 -> 表现层

核心原则是:逻辑层只算结果,表现层只播效果,配置和资源做支撑,网络同步做边界。

模块怎么拆 输入模块:负责摇杆、技能按钮、锁定目标,把玩家操作转成命令。 命令模块:把输入封装成 MoveCommandCastSkillCommand,方便本地执行、网络发送、回放记录。 战斗核心:负责实体、属性、技能、Buff、CD、命中、伤害、死亡复活。 表现模块:负责 Animator、特效、音效、飘字、受击反馈、相机震动。 UI 模块:负责血条、技能按钮 CD、小地图、结算界面。 配置模块:英雄、技能、Buff、弹道、特效路径都走配置表。 资源模块:预加载、异步加载、对象池、卸载。 网络模块:输入上行、状态快照下行、插值、弱网处理。 调试模块:战斗日志、技能范围 Gizmos、伤害流水、状态机可视化。

关键取舍 低配版不一开始追求完整商业级联机,而是先保证战斗闭环和模块边界。 技能先做配置驱动,但不要一上来做复杂编辑器。 表现和逻辑分离,避免“动画事件直接改伤害”。 对象池先覆盖子弹、特效、飘字、血条,减少运行时 Instantiate 和 GC。 网络先抽接口,本地可以跑单机模拟,后面再接服务器权威同步。

c
using System.Collections.Generic; // 引入 List 集合,用来保存模块列表

public interface IBattleModule // 定义战斗模块接口
{ // 接口开始
    void Init(BattleContext context); // 初始化模块,并注入战斗上下文
    void Tick(float deltaTime); // 每帧更新模块逻辑
} // 接口结束

public sealed class BattleContext // 定义战斗上下文
{ // 类开始
    public readonly Queue<BattleCommand> Commands = new Queue<BattleCommand>(); // 保存本帧等待处理的战斗命令
    public readonly Queue<BattleEvent> Events = new Queue<BattleEvent>(); // 保存逻辑层产生的表现事件
} // 类结束

public abstract class BattleCommand // 定义战斗命令基类
{ // 类开始
    public int EntityId; // 记录发出命令的角色 ID
} // 类结束

public sealed class CastSkillCommand : BattleCommand // 定义释放技能命令
{ // 类开始
    public int SkillId; // 记录要释放的技能 ID
    public int TargetId; // 记录目标角色 ID
} // 类结束

public sealed class BattleEvent // 定义战斗事件
{ // 类开始
    public string EventType; // 记录事件类型,比如 Damage、Dead、CastSkill
    public int SourceId; // 记录事件来源角色
    public int TargetId; // 记录事件目标角色
} // 类结束

public sealed class BattleClient // 定义战斗客户端总控
{ // 类开始
    private readonly BattleContext context = new BattleContext(); // 创建战斗上下文
    private readonly List<IBattleModule> modules = new List<IBattleModule>(); // 创建模块列表

    public BattleClient() // 构造战斗客户端
    { // 构造函数开始
        modules.Add(new InputModule()); // 添加输入模块,把玩家操作转成命令
        modules.Add(new BattleLogicModule()); // 添加战斗逻辑模块,计算技能、伤害和状态
        modules.Add(new BattleViewModule()); // 添加表现模块,播放动画、特效和 UI
    } // 构造函数结束

    public void Init() // 初始化战斗客户端
    { // 方法开始
        foreach (IBattleModule module in modules) // 遍历所有模块
        { // 循环开始
            module.Init(context); // 把同一个上下文注入每个模块
        } // 循环结束
    } // 方法结束

    public void Tick(float deltaTime) // 每帧驱动战斗
    { // 方法开始
        foreach (IBattleModule module in modules) // 按固定顺序遍历模块
        { // 循环开始
            module.Tick(deltaTime); // 更新当前模块
        } // 循环结束
    } // 方法结束
} // 类结束

面试加分说法 我会把战斗系统拆成“数据层、逻辑层、表现层”。数据层配置英雄和技能;逻辑层计算命中、伤害、CD、Buff;表现层只消费事件播放动画和特效。这样后续加 100 个技能,不是复制 100 份代码,而是扩展配置、技能行为节点和少量通用执行器。

如果让你做一个开放世界资源加载系统,你怎么设计?

open-world-resource-streaming-system

标准答案 如果让我设计开放世界资源加载系统,我会按这条主线拆:

地图分块 -> 玩家位置计算可见范围 -> 生成加载/卸载计划 -> 异步加载资源 -> 分帧激活对象 -> 引用计数卸载 -> 内存监控回收

核心目标是:玩家移动时看不到明显加载痕迹,同时内存、IO 和主线程卡顿都受控。

模块怎么拆 世界分块模块:把大地图切成 Chunk/Cell,每块记录坐标、场景、资源包、依赖、LOD、邻接关系。 可见性模块:根据玩家位置、相机方向、移动速度,计算需要激活、预加载、卸载的 Chunk。 加载调度模块:把加载任务按优先级排队,近处优先、移动方向前方优先、玩法关键资源优先。 资源管理模块:统一封装 Addressables 或 AssetBundle,管理依赖、句柄、引用计数。 场景流式模块:大块地形或建筑可以用 Additive Scene 异步加载。 对象激活模块:资源加载完后不要一帧全 Instantiate,要分帧实例化、分帧激活。 内存管理模块:维护缓存、LRU、内存预算,超过预算先卸载远处低优先级资源。 兜底模块:没加载完时用低模、雾、遮挡、占位模型,避免玩家看到空洞。 调试监控模块:显示 Chunk 状态、加载耗时、内存占用、失败资源、引用计数。

底层原理 开放世界的瓶颈不是“能不能加载”,而是什么时候加载、加载多少、什么时候卸载、主线程激活多少。 异步 IO 可以放到后台,但 Unity 对象实例化、场景激活、材质上传、Shader Variant 预热经常会回到主线程,所以必须做预算控制和分帧处理。

Unity 实践 Unity 里可以用 Addressables 管资源,用 LoadSceneAsync 做 Additive 场景流式加载。 地形、建筑、怪物刷新点、采集物、音频、特效可以按 Chunk 分组。 玩家附近 1 圈激活,2 圈预加载,3 圈保留低模或卸载。移动速度越快,预加载半径越大。

c
using System.Collections.Generic; // 引入 HashSet 和 Queue 集合类型
using UnityEngine; // 引入 Vector3 和 Mathf 等 Unity 类型

public enum ChunkState // 定义 Chunk 的加载状态
{ // 枚举开始
    Unloaded, // 未加载状态
    Loading, // 正在加载状态
    Loaded, // 已加载但未激活状态
    Active // 已激活状态
} // 枚举结束

public sealed class WorldChunk // 定义开放世界分块数据
{ // 类开始
    public Vector2Int Coord; // Chunk 的二维网格坐标
    public string AddressKey; // Addressables 或 AssetBundle 资源地址
    public ChunkState State; // 当前 Chunk 状态
    public int RefCount; // 当前 Chunk 的引用计数
} // 类结束

public sealed class OpenWorldStreamingSystem // 定义开放世界流式加载系统
{ // 类开始
    private readonly Dictionary<Vector2Int, WorldChunk> chunks = new Dictionary<Vector2Int, WorldChunk>(); // 保存所有 Chunk 数据
    private readonly Queue<WorldChunk> loadQueue = new Queue<WorldChunk>(); // 保存等待加载的 Chunk 队列
    private readonly HashSet<Vector2Int> visibleSet = new HashSet<Vector2Int>(); // 保存当前需要可见的 Chunk 集合
    private readonly int activeRadius = 1; // 定义激活半径
    private readonly int preloadRadius = 2; // 定义预加载半径

    public void Tick(Vector3 playerPosition) // 每帧或定时更新流式加载系统
    { // 方法开始
        Vector2Int center = WorldToChunkCoord(playerPosition); // 把玩家世界坐标转换成 Chunk 坐标
        UpdateVisibleSet(center); // 根据玩家所在 Chunk 更新可见集合
        ScheduleLoads(); // 根据可见集合生成加载任务
        ProcessLoadBudget(); // 按每帧预算处理加载队列
        UnloadFarChunks(center); // 卸载离玩家太远的 Chunk
    } // 方法结束

    private Vector2Int WorldToChunkCoord(Vector3 position) // 把世界坐标转换为 Chunk 坐标
    { // 方法开始
        int x = Mathf.FloorToInt(position.x / 100f); // 根据 Chunk 尺寸计算 X 坐标
        int y = Mathf.FloorToInt(position.z / 100f); // 根据 Chunk 尺寸计算 Z 坐标
        return new Vector2Int(x, y); // 返回二维 Chunk 坐标
    } // 方法结束

    private void UpdateVisibleSet(Vector2Int center) // 更新需要加载或预加载的 Chunk 集合
    { // 方法开始
        visibleSet.Clear(); // 清空上一帧可见集合
        for (int x = -preloadRadius; x <= preloadRadius; x++) // 遍历预加载范围内的 X 偏移
        { // X 循环开始
            for (int y = -preloadRadius; y <= preloadRadius; y++) // 遍历预加载范围内的 Y 偏移
            { // Y 循环开始
                visibleSet.Add(new Vector2Int(center.x + x, center.y + y)); // 把需要预加载的 Chunk 加入集合
            } // Y 循环结束
        } // X 循环结束
    } // 方法结束

    private void ScheduleLoads() // 生成加载任务
    { // 方法开始
        foreach (Vector2Int coord in visibleSet) // 遍历所有需要可见的 Chunk 坐标
        { // 循环开始
            if (!chunks.TryGetValue(coord, out WorldChunk chunk)) continue; // 如果没有该 Chunk 配置则跳过
            if (chunk.State != ChunkState.Unloaded) continue; // 如果已经加载或正在加载则跳过
            chunk.State = ChunkState.Loading; // 把 Chunk 标记为正在加载
            loadQueue.Enqueue(chunk); // 把 Chunk 放入加载队列
        } // 循环结束
    } // 方法结束

    private void ProcessLoadBudget() // 按预算处理加载任务
    { // 方法开始
        int maxLoadPerFrame = 1; // 限制每帧最多启动一个加载任务
        while (maxLoadPerFrame > 0 && loadQueue.Count > 0) // 如果还有预算并且队列不为空
        { // 循环开始
            WorldChunk chunk = loadQueue.Dequeue(); // 取出一个待加载 Chunk
            StartAsyncLoad(chunk); // 启动异步加载
            maxLoadPerFrame--; // 消耗本帧加载预算
        } // 循环结束
    } // 方法结束

    private void StartAsyncLoad(WorldChunk chunk) // 启动异步加载
    { // 方法开始
        chunk.RefCount++; // 增加引用计数,防止加载中被误释放
        chunk.State = ChunkState.Loaded; // 示例中直接标记为已加载,实际项目这里会等待异步回调
    } // 方法结束

    private void UnloadFarChunks(Vector2Int center) // 卸载远处 Chunk
    { // 方法开始
        foreach (WorldChunk chunk in chunks.Values) // 遍历所有 Chunk
        { // 循环开始
            int dx = Mathf.Abs(chunk.Coord.x - center.x); // 计算 X 方向距离
            int dy = Mathf.Abs(chunk.Coord.y - center.y); // 计算 Y 方向距离
            if (dx <= preloadRadius || dy <= preloadRadius) continue; // 如果还在预加载范围内则不卸载
            if (chunk.RefCount > 0) chunk.RefCount--; // 降低引用计数
            if (chunk.RefCount == 0) chunk.State = ChunkState.Unloaded; // 引用计数为 0 时标记为可卸载
        } // 循环结束
    } // 方法结束
} // 类结束

面试加分说法 我不会把开放世界加载理解成“玩家走到哪就 Load 哪”。真正要做的是:Chunk 分区、优先级调度、异步加载、分帧激活、引用计数卸载、内存预算和监控闭环。 这样才能解释为什么加载不卡、为什么内存不会一直涨、为什么资源依赖不会被误卸载。

如果让你做一个 1000 人同屏展示,你怎么优化?

unity-thousand-players-onscreen-optimization

标准答案 如果让我做 1000 人同屏展示,我不会把 1000 个单位都当“完整角色”跑。核心思路是:先裁剪,再分 LOD,近处完整,远处降级,最后用 Profiler/Frame Debugger 验证 CPU、GPU、内存和 GC。

模块拆法 渲染层:共享材质、图集、SRP Batcher、GPU Instancing,远处角色用 Billboard、Impostor 或 VAT。 动画层:近处用完整 Animator,远处降低动画更新频率,甚至改成烘焙动画或 GPU Skinning。 逻辑层:不要 1000 个 Update,用 Update Manager 分帧调度,远处只插值位置。 UI 层:血条、名字、称号也要 LOD,远处隐藏,不要 1000 个 World Space Canvas。 特效层:远处不播高成本粒子,技能特效分高、中、低档。 网络层:服务端做兴趣管理,客户端只接收必要范围内的快照,远处低频同步。 资源层:角色、骨骼、材质、贴图尽量复用,对象池管理角色、飘字、特效。

底层原理 1000 人同屏主要卡在几个点: CPU:Animator、Update、AI、物理、UI 刷新。 GPU:骨骼蒙皮、顶点数、Overdraw、SetPass、阴影。 内存:贴图、Mesh、Animator Controller、特效实例。 GC:频繁创建对象、字符串、LINQ、临时集合。

所以优化不是只说“减少 DrawCall”,而是把每个人按距离和可见性分成不同成本:

近处 20 到 50 个:完整模型、完整动画、完整技能反馈。 中距离 100 到 300 个:低模、低骨骼、低频动画、隐藏部分 UI。 远处几百个:假人、Billboard、Impostor、GPU Instancing,不跑物理,不显示血条。

c
using UnityEngine; // 引入 Unity 基础类型

public enum CrowdLodLevel // 定义人群 LOD 等级
{ // 枚举开始
    High, // 近处角色,完整表现
    Mid, // 中距离角色,简化表现
    Low, // 远处角色,极简表现
    Hidden // 不可见角色,直接隐藏
} // 枚举结束

public sealed class CrowdActor : MonoBehaviour // 定义一个人群角色
{ // 类开始
    public Animator Animator; // 保存 Animator 引用
    public Renderer[] Renderers; // 保存角色所有 Renderer
    public GameObject Nameplate; // 保存名字和血条 UI
    public void SetLod(CrowdLodLevel level) // 根据 LOD 设置角色表现
    { // 方法开始
        bool visible = level != CrowdLodLevel.Hidden; // 判断角色是否需要显示
        foreach (Renderer renderer in Renderers) renderer.enabled = visible; // 根据可见性开关渲染器
        if (Animator != null) Animator.enabled = level == CrowdLodLevel.High; // 只有近处角色启用完整 Animator
        if (Nameplate != null) Nameplate.SetActive(level == CrowdLodLevel.High); // 只有近处角色显示血条和名字
    } // 方法结束
} // 类结束

public sealed class CrowdLodManager : MonoBehaviour // 定义人群 LOD 管理器
{ // 类开始
    public Camera MainCamera; // 保存主相机引用
    public CrowdActor[] Actors; // 保存场景中的所有人群角色
    public int CheckPerFrame = 80; // 每帧只检查一部分角色,避免单帧尖峰
    private int cursor; // 保存本帧检查起点
    private const float HighDistance = 20f; // 定义近距离阈值
    private const float MidDistance = 45f; // 定义中距离阈值
    private const float LowDistance = 80f; // 定义远距离阈值

    private void LateUpdate() // 在 LateUpdate 中更新 LOD
    { // 方法开始
        if (MainCamera == null || Actors == null) return; // 如果相机或角色数组为空,直接返回
        Vector3 cameraPosition = MainCamera.transform.position; // 读取相机当前位置
        Plane[] planes = GeometryUtility.CalculateFrustumPlanes(MainCamera); // 计算相机视锥体平面
        for (int i = 0; i < CheckPerFrame && Actors.Length > 0; i++) // 每帧限制检查数量
        { // 循环开始
            CrowdActor actor = Actors[cursor]; // 取出当前要检查的角色
            cursor = (cursor + 1) % Actors.Length; // 游标前进,并在末尾回到开头
            if (actor == null) continue; // 如果角色为空,跳过
            Bounds bounds = new Bounds(actor.transform.position, Vector3.one * 2f); // 构造简化包围盒
            if (!GeometryUtility.TestPlanesAABB(planes, bounds)) // 如果角色不在视锥内
            { // 判断开始
                actor.SetLod(CrowdLodLevel.Hidden); // 不可见角色直接隐藏
                continue; // 继续处理下一个角色
            } // 判断结束
            float distance = Vector3.Distance(cameraPosition, actor.transform.position); // 计算角色到相机距离
            if (distance <= HighDistance) actor.SetLod(CrowdLodLevel.High); // 近处使用完整表现
            else if (distance <= MidDistance) actor.SetLod(CrowdLodLevel.Mid); // 中距离使用简化表现
            else if (distance <= LowDistance) actor.SetLod(CrowdLodLevel.Low); // 远距离使用极简表现
            else actor.SetLod(CrowdLodLevel.Hidden); // 超远距离隐藏
        } // 循环结束
    } // 方法结束
} // 类结束

面试加分说法 我会先用 Profiler 定位是 CPU 还是 GPU:如果 Animator.UpdateBehaviourUpdate 高,就优先做动画 LOD 和 Update Manager;如果 Camera.Render、SetPass、Overdraw 高,就优先做合批、Instancing、LOD 和特效裁剪。优化后要拿数据说话,比如帧率、Main Thread、DrawCall、SetPass、显存、GC Alloc 的前后对比。

如果玩家反馈手机发热,你怎么排查?

unity-mobile-overheat-diagnosis

标准答案

玩家反馈手机发热,我不会一上来就降画质,而是先复现和采集数据。手机发热本质是“持续功耗过高”:CPU、GPU、内存带宽、IO、网络重试都可能让 SoC 长时间高负载,最后触发降频,表现为发热、掉帧、卡顿。

排查流程

  1. 先确认现象:机型、系统版本、电量、是否充电、环境温度、画质档、帧率、具体场景、玩多久开始热、是否弱网。
  2. 用工具定位:Unity Profiler 看 Main ThreadRender ThreadGC Alloc、内存;Frame Debugger / RenderDoc 看 DrawCall、Overdraw、阴影、后处理;Android Studio / Xcode Instruments 看 CPU、GPU、功耗和降频。
  3. 判断瓶颈:CPU 高看大量 Update、AI、物理、Animator、日志、GC;GPU 高看分辨率、透明特效、阴影、后处理、Overdraw;内存高看纹理、音频、Bundle 常驻和资源泄漏。
  4. 再做优化:限帧、动态分辨率、LOD、降低阴影距离、减少实时光和后处理、对象池、分帧执行、资源及时卸载、低端机画质档。
  5. 最后验证:同一台设备、同一场景跑 10 到 20 分钟,对比优化前后的 FPS、CPU ms、GPU ms、GC Alloc、温升、电流、降频时间。

Unity 工程里怎么说

面试里可以这样答:我会先用真机复现,不用编辑器数据下结论。比如团战场景发热,我会先看是不是 Camera.Render 和透明粒子导致 GPU 长时间满载;如果是背包界面发热,我会看 UGUI 重建、Layout、ScrollView Item 和图集;如果是战斗一段时间后越来越热,我会重点查对象池、GC、资源没释放和 AI 分帧。

简单降级代码

c
using UnityEngine; // 引入 Unity 的基础 API

public sealed class MobileThermalGuard : MonoBehaviour // 定义移动端发热保护组件
{ // 类开始
    public int NormalFrameRate = 60; // 正常情况下的目标帧率
    public int HotFrameRate = 30; // 发热或持续低帧时的降级帧率
    public float CheckInterval = 5f; // 每隔几秒统计一次平均帧率
    public float LowFpsThreshold = 45f; // 低于这个平均帧率时认为设备压力较大
    private float elapsed; // 记录当前统计窗口经过的时间
    private int frameCount; // 记录当前统计窗口内的帧数
    private bool downgraded; // 记录是否已经进入降级状态

    private void Awake() // 对象初始化时调用
    { // 方法开始
        QualitySettings.vSyncCount = 0; // 关闭 VSync,让 targetFrameRate 能生效
        Application.targetFrameRate = NormalFrameRate; // 设置正常目标帧率
    } // 方法结束

    private void Update() // 每帧调用一次
    { // 方法开始
        elapsed += Time.unscaledDeltaTime; // 累加不受暂停影响的真实时间
        frameCount++; // 累加当前统计窗口的帧数
        if (elapsed < CheckInterval) return; // 还没到检测间隔就直接返回
        float fps = frameCount / elapsed; // 计算这一段时间的平均帧率
        if (fps < LowFpsThreshold && !downgraded) // 如果持续低帧并且还没有降级
        { // 判断开始
            ApplyLowQuality(); // 执行低画质降级逻辑
        } // 判断结束
        elapsed = 0f; // 重置统计时间
        frameCount = 0; // 重置统计帧数
    } // 方法结束

    private void ApplyLowQuality() // 发热保护降级方法
    { // 方法开始
        downgraded = true; // 标记已经降级,避免重复执行
        Application.targetFrameRate = HotFrameRate; // 降低帧率,减少 CPU 和 GPU 持续功耗
        QualitySettings.shadowDistance *= 0.5f; // 缩短阴影距离,降低 GPU 阴影开销
        QualitySettings.particleRaycastBudget = Mathf.Min(QualitySettings.particleRaycastBudget, 64); // 限制粒子碰撞预算
    } // 方法结束
} // 类结束

真正项目里还可以接 Unity Adaptive Performance 或平台热状态回调,做到“温度升高自动降画质,温度恢复再谨慎恢复”。

如果战斗偶现不同步,你怎么定位?

unity-battle-desync-diagnosis

标准答案

战斗偶现不同步,我会先明确同步模式,然后把问题转成一个可定位的问题:哪一个 Tick 开始,客户端和服务端状态第一次不一致。

我不会只看最后“血量不一样”“位置不一样”,因为最终结果可能已经被放大很多次了。正确做法是记录战斗日志、输入序号、随机种子、网络包序号、配置版本、状态快照或状态 Hash,然后通过回放和逐 Tick 比对,找到第一帧分叉点。

底层原理

战斗同步本质上是:

同样的初始状态 + 同样的输入 + 同样的随机数 + 同样的配置 + 同样的结算顺序 = 同样的结果。

只要其中一个条件被破坏,就可能不同步。

常见原因有这些:

  1. 输入不同:某个技能输入丢包、重复、乱序、晚到。
  2. 随机不同:客户端和服务端随机种子不一致,或者某一边多调用了一次随机。
  3. 时间不同:用 Time.deltaTime 参与战斗结算,导致不同机器结果不同。
  4. 配置不同:客户端热更表和服务端表版本不一致。
  5. 结算顺序不同:比如多个 Buff、多个伤害事件顺序不同。
  6. 浮点误差:不同平台、不同编译方式下浮点计算细节可能不同。
  7. 预测回滚问题:客户端预测后,服务端快照回来,回滚和重放逻辑有 bug。
  8. 动画和逻辑混用:用动画事件直接决定命中,帧率波动时触发时机不同。

Unity 项目里我会怎么查

第一步,先补日志字段:

battleIdplayerIdtickinputSeqserverTickrandomSeedconfigVersionentityStateHashskillIdbuffListhppositioncd

第二步,做状态 Hash:

每个 Tick 后,把关键战斗状态算一个 Hash。比如实体 ID、位置、血量、Buff、技能 CD、随机状态都参与计算。

第三步,对比客户端和服务端:

如果 tick 100 一样,tick 101 一样,tick 102 不一样,那就重点查 tick 102 前后发生了什么:输入包、技能释放、伤害结算、随机调用、Buff 触发、快照合并。

第四步,用回放复现:

线上问题最怕偶现,所以要把输入流、随机种子和配置版本保存下来,离线跑一遍回放。能复现,就能稳定修。

一段可用于定位的状态 Hash 示例

c
using System; // 引入基础类型和异常相关 API
using System.Collections.Generic; // 引入 List 集合类型

public struct BattleUnitState // 定义一个用于参与同步校验的单位状态
{ // 结构体开始
    public int EntityId; // 单位唯一 ID,保证不同单位可以稳定排序
    public int Hp; // 当前血量,参与战斗一致性校验
    public int PosX; // X 坐标,建议用定点数或放大后的整数
    public int PosZ; // Z 坐标,建议用定点数或放大后的整数
    public int SkillCd; // 技能 CD,防止客户端和服务端 CD 不一致
    public int BuffHash; // Buff 列表算出的 Hash,避免逐个字段打印太大
} // 结构体结束

public static class BattleStateHashUtil // 定义战斗状态 Hash 工具类
{ // 类开始
    public static uint CalcHash(int tick, List<BattleUnitState> units) // 计算某一 Tick 的战斗状态 Hash
    { // 方法开始
        unchecked // 允许整数溢出,因为 Hash 混合本来就依赖溢出
        { // unchecked 代码块开始
            uint hash = 2166136261u; // 使用 FNV-1a 的初始值
            hash = Mix(hash, tick); // 把当前 Tick 混入 Hash
            units.Sort((a, b) => a.EntityId.CompareTo(b.EntityId)); // 按 EntityId 排序,避免遍历顺序不稳定
            for (int i = 0; i < units.Count; i++) // 遍历所有单位状态
            { // 循环开始
                BattleUnitState unit = units[i]; // 取出当前单位状态
                hash = Mix(hash, unit.EntityId); // 混入单位 ID
                hash = Mix(hash, unit.Hp); // 混入血量
                hash = Mix(hash, unit.PosX); // 混入 X 坐标
                hash = Mix(hash, unit.PosZ); // 混入 Z 坐标
                hash = Mix(hash, unit.SkillCd); // 混入技能 CD
                hash = Mix(hash, unit.BuffHash); // 混入 Buff 状态
            } // 循环结束
            return hash; // 返回本 Tick 的状态 Hash
        } // unchecked 代码块结束
    } // 方法结束

    private static uint Mix(uint hash, int value) // 把一个整数混入 Hash
    { // 方法开始
        unchecked // 允许无符号整数自然溢出
        { // unchecked 代码块开始
            hash ^= (uint)value; // 先把当前值异或进 Hash
            hash *= 16777619u; // 再乘 FNV-1a 质数打散
            return hash; // 返回混合后的 Hash
        } // unchecked 代码块结束
    } // 方法结束
} // 类结束

注意:这段代码适合做定位工具。正式战斗逻辑里不要随便 Sort 原始战斗列表,最好用稳定顺序的数据结构,或者把状态复制到调试缓冲区再算 Hash。

面试可以这样总结

我会先确定同步模型,再用日志和状态 Hash 找第一分叉 Tick。不同步问题不能只靠猜,要把输入、随机、配置、网络包、状态快照全部按 Tick 对齐。找到第一帧不一致之后,再查是输入乱序、随机调用不同、配置版本不同、结算顺序不同,还是预测回滚合并有问题。最后用回放系统复现和回归,避免同类问题再次出现。

如果热更新后部分机型闪退,你怎么回滚?

unity-hotfix-rollback-crash

标准答案

热更新后部分机型闪退,我会先“止血”,再“定向回滚”,最后“验证恢复”。

具体步骤是:先看崩溃平台确认是不是某个热更版本导致的;立刻暂停灰度和继续下发;把服务端入口 Manifest 切回上一个稳定版本;对问题机型加黑名单或降级规则;客户端启动时如果发现上一次加载补丁后闪退,就进入安全模式,跳过坏补丁,重新拉稳定 Manifest。

底层原理

热更新真正控制入口的一般不是单个资源文件,而是 Manifest / Catalog / VersionConfig

所以回滚不是“删掉一个文件”这么简单,而是:

  1. 服务端把当前版本指针切回旧稳定 Manifest。
  2. 客户端重新拉版本清单。
  3. 本地把坏补丁加入黑名单。
  4. 清理或忽略坏版本缓存。
  5. 重新加载旧资源、旧 Lua、旧 DLL 或旧热更代码。
  6. 继续监控崩溃率和启动成功率。

最重要的一点:启动器不能依赖热更代码。否则坏补丁一加载就闪退,客户端连回滚逻辑都跑不到。

Unity 项目里怎么做

如果是资源热更,比如 AssetBundle、Addressables Catalog 出问题,就回滚 Manifest,让客户端重新指向旧 Bundle。

如果是代码热更,比如 Lua、ILRuntime、HybridCLR 热更 DLL 出问题,就回退代码包,同时要考虑补充元数据、AOT 泛型、配置表兼容和重启加载。

如果只影响部分机型,就不要全量回滚,可以按 deviceModelGPUOSABI、渠道做定向规则,让问题机型走旧版本,其他用户继续用新版本。

启动安全模式伪代码

c
using UnityEngine; // 引入 Unity 基础 API

public sealed class HotUpdateRollbackGuard : MonoBehaviour // 定义热更新回滚守卫组件
{ // 类开始
    private const string CurrentPatchKey = "hotfix.current"; // 保存当前正在使用的补丁版本
    private const string LaunchingPatchKey = "hotfix.launching"; // 保存上一次启动时尝试加载的补丁版本
    private const string LaunchOkKey = "hotfix.launch.ok"; // 保存上一次启动是否成功进入游戏
    private const string BadPatchKey = "hotfix.bad"; // 保存已经判定为坏补丁的版本号
    public string StablePatchVersion = "1.0.0"; // 兜底稳定补丁版本,真实项目中通常由服务端下发

    private void Awake() // 游戏启动早期执行
    { // 方法开始
        DontDestroyOnLoad(gameObject); // 让回滚守卫跨场景存在
        CheckLastLaunch(); // 检查上一次启动是否因为补丁导致失败
        MarkLaunchStart(); // 标记本次启动开始加载当前补丁
    } // 方法结束

    private void CheckLastLaunch() // 检查上一次启动结果
    { // 方法开始
        int launchOk = PlayerPrefs.GetInt(LaunchOkKey, 1); // 读取上一次启动是否成功
        string launchingPatch = PlayerPrefs.GetString(LaunchingPatchKey, ""); // 读取上一次尝试加载的补丁版本
        if (launchOk == 0 && string.IsNullOrEmpty(launchingPatch) == false) // 如果上次启动没成功并且记录了补丁版本
        { // 判断开始
            RollbackBadPatch(launchingPatch); // 把上次补丁当作疑似坏补丁并回滚
        } // 判断结束
    } // 方法结束

    private void MarkLaunchStart() // 标记本次启动开始
    { // 方法开始
        string currentPatch = PlayerPrefs.GetString(CurrentPatchKey, StablePatchVersion); // 获取当前补丁版本
        PlayerPrefs.SetString(LaunchingPatchKey, currentPatch); // 记录本次正在尝试加载的补丁版本
        PlayerPrefs.SetInt(LaunchOkKey, 0); // 先标记为未成功,等真正进游戏后再改成成功
        PlayerPrefs.Save(); // 立即写盘,防止闪退前状态丢失
    } // 方法结束

    public void MarkLaunchSuccess() // 游戏成功进入主界面或战斗后调用
    { // 方法开始
        PlayerPrefs.SetInt(LaunchOkKey, 1); // 标记本次启动成功
        PlayerPrefs.Save(); // 保存成功状态
    } // 方法结束

    private void RollbackBadPatch(string badPatch) // 回滚坏补丁
    { // 方法开始
        PlayerPrefs.SetString(BadPatchKey, badPatch); // 记录坏补丁版本,避免下次继续加载
        PlayerPrefs.SetString(CurrentPatchKey, StablePatchVersion); // 把当前补丁切回稳定版本
        ClearBadPatchCache(badPatch); // 清理坏补丁缓存文件
        PlayerPrefs.SetInt(LaunchOkKey, 1); // 防止安全模式自身被误判为继续闪退
        PlayerPrefs.Save(); // 保存回滚状态
    } // 方法结束

    private void ClearBadPatchCache(string badPatch) // 清理坏补丁缓存
    { // 方法开始
        Debug.Log("Ignore bad hotfix patch: " + badPatch); // 真实项目中这里删除或屏蔽坏 Bundle、Catalog、Lua、DLL
    } // 方法结束
} // 类结束

面试可以这样总结

我会先暂停灰度,切回稳定 Manifest,并对问题机型做定向隔离。客户端侧要有启动安全模式:如果上次加载某个补丁后没有成功进入游戏,下次启动就跳过这个补丁,清理坏缓存,重新拉稳定版本。回滚后继续看崩溃率、启动成功率、下载失败率和问题机型日志,确认恢复后再分析根因。

如果 UI 打开越来越慢,你怎么分析?

unity-ui-open-slower-diagnosis

标准答案

UI 打开越来越慢,我会先判断它是不是“累计型问题”。也就是第 1 次打开正常,第 10 次、第 50 次越来越慢,这通常不是单纯资源大,而是事件重复监听、对象没清理、资源句柄没释放、缓存无限增长、GC 变多、Canvas Rebuild 变重。

排查顺序

  1. 连续打开关闭同一个 UI,比如 100 次,记录每次打开耗时、GC Alloc、对象数量、内存变化。
  2. 把 UI 打开流程拆开计时:资源加载、实例化、Awake/OnEnable、绑定数据、创建 Item、Layout 重建、动画播放。
  3. 用 Profiler 看 Canvas.BuildBatchCanvas.SendWillRenderCanvasesLayoutRebuilderGC.AllocInstantiateDestroy
  4. 用 Memory Profiler 看 UI 关闭后对象有没有下降,贴图、字体、Prefab、Addressables Handle 是否残留。
  5. 如果每次打开回调次数越来越多,优先查 OnEnable 订阅事件但 OnDisable 没取消。

常见原因

UI 列表每次打开都重新创建大量 Item,没有虚拟列表或对象池。 LayoutGroupContentSizeFitter 混用,导致父子层级反复 Layout Rebuild。 打开 UI 时重复注册事件,关闭时没注销,导致一次刷新触发多次回调。 Addressables 加载了 UI 资源但没释放句柄,资源和依赖越堆越多。 关闭 UI 只隐藏,没有清理临时数据、Tween、Timer、协程、红点监听。 动态字体、图集、TMP 字体资源临时加载,也可能导致首次或多次打开卡顿。

分段打点代码

c
using System; // 引入 IDisposable 接口和 GC API
using System.Diagnostics; // 引入 Stopwatch 计时器
using UnityEngine; // 引入 Unity 基础 API
using Debug = UnityEngine.Debug; // 指定 Debug 使用 Unity 的日志类

public struct UIPerfScope : IDisposable // 定义一个 UI 性能打点作用域
{ // 结构体开始
    private readonly string name; // 保存当前打点名称
    private readonly Stopwatch watch; // 保存当前打点计时器
    private readonly long memoryBefore; // 保存打点开始前的托管内存

    public UIPerfScope(string name) // 构造函数,进入作用域时调用
    { // 构造函数开始
        this.name = name; // 记录这段 UI 流程的名称
        memoryBefore = GC.GetTotalMemory(false); // 记录开始前托管内存,不主动触发 GC
        watch = Stopwatch.StartNew(); // 启动计时器
    } // 构造函数结束

    public void Dispose() // 离开 using 作用域时自动调用
    { // 方法开始
        watch.Stop(); // 停止计时器
        long memoryAfter = GC.GetTotalMemory(false); // 记录结束后的托管内存
        long memoryDelta = memoryAfter - memoryBefore; // 计算这段流程产生的内存变化
        Debug.Log($"[UI PERF] {name}, cost={watch.ElapsedMilliseconds}ms, gcDelta={memoryDelta}B"); // 输出耗时和内存变化
    } // 方法结束
} // 结构体结束

public sealed class UIPanelOpener : MonoBehaviour // 定义一个示例 UI 打开器
{ // 类开始
    public GameObject PanelPrefab; // 面板预制体引用
    private GameObject cachedPanel; // 缓存已经创建过的面板对象

    public void OpenPanel() // 打开 UI 面板
    { // 方法开始
        using (new UIPerfScope("UI.Open.Total")) // 统计整个 UI 打开流程
        { // using 开始
            using (new UIPerfScope("UI.Instantiate")) // 统计实例化耗时
            { // using 开始
                if (cachedPanel == null) // 如果面板还没有创建
                { // 判断开始
                    cachedPanel = Instantiate(PanelPrefab, transform); // 创建面板并挂到当前 UI 根节点下
                } // 判断结束
            } // using 结束
            using (new UIPerfScope("UI.BindData")) // 统计数据绑定耗时
            { // using 开始
                BindData(); // 执行数据刷新逻辑
            } // using 结束
            cachedPanel.SetActive(true); // 显示面板
        } // using 结束
    } // 方法结束

    public void ClosePanel() // 关闭 UI 面板
    { // 方法开始
        if (cachedPanel == null) return; // 如果面板不存在就直接返回
        cachedPanel.SetActive(false); // 隐藏面板,避免下次重复实例化
    } // 方法结束

    private void BindData() // 绑定 UI 数据
    { // 方法开始
        Debug.Log("Refresh UI data here."); // 示例:真实项目里这里刷新文本、图片、列表和红点
    } // 方法结束
} // 类结束

面试总结

我会说:UI 越开越慢,我先不会凭感觉改,而是连续开关压测,把打开流程拆成加载、创建、绑定、布局、动画几段分别打点。然后用 Profiler 和 Memory Profiler 看是不是 Canvas RebuildGC Alloc、事件重复监听、资源句柄没释放或列表对象越来越多。修完之后连续开关 100 次,看耗时曲线是否稳定。

如果资源包体突然增加 500MB,你怎么查?

unity-package-size-increase-diagnosis

标准答案

包体突然增加 500MB,我不会先猜“是不是贴图太大”,而是先做新旧构建产物 diff。先确认变大的是 APK/AAB/IPA、首包资源、热更包,还是某个 AssetBundle / Addressables Group。然后用 Build Report、Addressables Build Layout、APK Analyzer、Editor.log 找到增量最大的文件、Bundle、目录,再按资源类型排查。

排查流程

  1. 确认范围:是安装包变大,还是热更下载包变大;是 Android 还是 iOS;是全渠道还是某个渠道。
  2. 新旧对比:拿上一个正常版本和当前版本拆包,对比 Top 增量文件。
  3. 看资源类型:纹理、音频、视频、Shader Variant、场景、Resources、StreamingAssets。
  4. 看打包规则:公共依赖是否重复打进多个 Bundle,远程资源是否误进首包。
  5. 修复验证:改导入设置、拆公共依赖、迁出首包、裁剪 Shader Variant,然后重新构建对比。
  6. 防止复发:CI 每次构建生成包体 diff,超过阈值直接报警或阻断。

常见原因

纹理:Max Size 被调大,压缩格式从 ASTC 8x8 变成 ASTC 4x4,或者误用了 RGBA32。 音频:多语言语音误进首包,压缩质量过高,或者没有按语言分包。 视频:CG、教程视频、宣传片被放进 StreamingAssets。 Bundle:公共贴图、材质、Shader 没拆共享包,多个 Bundle 重复打。 Resources:临时资源放进 Resources,导致被强制打进包。 Shader:Shader Variant 没裁剪,变体数量暴涨。 平台:Android 同时打了多个 ABI,或者 Debug 符号、测试资源误入。

简易资源大小扫描脚本

这个脚本适合初筛“项目里哪些源文件很大”,正式判断包体仍然要看 Build Report / Addressables Layout。

c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段工具代码
using System.Collections.Generic; // 引入 List 集合类型
using System.IO; // 引入 FileInfo 和 Path 文件 API
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Debug 日志 API

public static class AssetSizeReporter // 定义资源大小报告工具类
{ // 类开始
    [MenuItem("Tools/Build/Print Top Asset Sizes")] // 在 Unity 菜单栏添加工具入口
    private static void PrintTopAssetSizes() // 打印项目中最大的资源文件
    { // 方法开始
        List<string> rows = new List<string>(); // 创建列表保存资源路径和大小
        string[] paths = AssetDatabase.GetAllAssetPaths(); // 获取项目中所有资源路径
        foreach (string path in paths) // 遍历每一个资源路径
        { // 循环开始
            if (path.StartsWith("Assets/") == false) continue; // 只统计 Assets 目录下的项目资源
            string fullPath = Path.GetFullPath(path); // 把 Unity 相对路径转换成系统完整路径
            if (File.Exists(fullPath) == false) continue; // 如果不是普通文件就跳过
            long size = new FileInfo(fullPath).Length; // 读取源文件字节大小
            rows.Add(size.ToString("D20") + "|" + path); // 用固定长度数字拼接,方便排序
        } // 循环结束
        rows.Sort(); // 按字符串升序排序
        rows.Reverse(); // 反转后变成从大到小
        int count = Mathf.Min(30, rows.Count); // 最多打印前 30 个大文件
        for (int i = 0; i < count; i++) // 遍历前 N 个文件
        { // 循环开始
            string[] parts = rows[i].Split('|'); // 拆出大小和路径
            long bytes = long.Parse(parts[0]); // 把大小字符串转成数字
            float mb = bytes / 1024f / 1024f; // 把字节转换成 MB
            Debug.Log($"[AssetSize] {mb:F2} MB  {parts[1]}"); // 打印资源大小和路径
        } // 循环结束
    } // 方法结束
} // 类结束
#endif // 结束编辑器专用代码

面试总结

我会这样说:包体突增 500MB,本质是构建产物回归。我会先拆新旧包做 diff,定位增量来自哪个目录、哪个 Bundle、哪个 Group,再分别查纹理压缩、音频视频、Bundle 重复依赖、Resources、StreamingAssets、Shader Variant。修完后重新构建,用报告证明首包和热更包都恢复到目标范围。

如果角色动画和判定不同步,你怎么处理?

unity-animation-hitbox-sync

标准答案

角色动画和判定不同步,我会先把它分成两个层:动画是表现层,判定是逻辑层。 不能完全靠动画事件来决定伤害,因为动画可能被 CrossFade、过渡、变速、低帧率、打断、Root Motion 影响。更稳的做法是用技能时间轴控制前摇、命中窗口、后摇、可取消帧;动画只负责对齐表现。

处理思路

  1. 先确认现象:是“刀还没挥到就掉血”,还是“刀挥到了没伤害”。
  2. 记录 animTimelogicTimeskillTick、攻击盒位置、目标位置。
  3. 把攻击拆成:前摇、判定窗口、后摇、取消窗口。
  4. 判定窗口内才开启攻击盒,比如 0.25s ~ 0.38s
  5. 动画事件可以播特效、音效、震屏,但关键伤害判定最好由技能时间轴控制。
  6. 如果是联机游戏,服务端判定权威,客户端只做预测表现。

底层原因

动画系统是按渲染帧更新的,战斗逻辑可能按逻辑 Tick 或服务端 Tick 更新。 如果直接用动画事件做命中,帧率低、动画过渡、动画被打断时,事件可能延迟、跳过或者重复触发。 所以真正稳定的方案是:逻辑时间轴决定判定,动画播放速度跟逻辑时间轴对齐

示例代码

c
using System.Collections.Generic; // 引入 HashSet,用来避免同一次攻击重复命中同一个目标
using UnityEngine; // 引入 Unity 的基础 API

public sealed class AttackTimelineHitbox : MonoBehaviour // 定义一个基于技能时间轴的攻击判定组件
{ // 类开始
    public Animator Animator; // 角色 Animator,用来播放攻击动画
    public Transform HitCenter; // 攻击盒中心点,通常挂在角色前方或武器挂点上
    public LayerMask TargetMask; // 目标 Layer,用来过滤可以被攻击的对象
    public Vector3 HalfExtents = new Vector3(0.6f, 0.8f, 1.0f); // 攻击盒半尺寸,实际大小是它的两倍
    public float StartupTime = 0.25f; // 前摇时间,这段时间只播放动作,不产生伤害
    public float ActiveTime = 0.12f; // 判定窗口时间,只有这段时间会检测命中
    public float RecoveryTime = 0.45f; // 后摇时间,这段时间通常不能立刻放下一个技能
    private readonly Collider[] hitBuffer = new Collider[16]; // 复用检测数组,避免每次检测产生 GC
    private readonly HashSet<int> damagedTargets = new HashSet<int>(); // 记录本次攻击已经命中的目标
    private float timer; // 当前攻击已经经过的时间
    private bool attacking; // 当前是否正在攻击
    private bool hitboxActive; // 当前攻击盒是否处于判定窗口

    public void PlayAttack() // 外部调用这个方法开始攻击
    { // 方法开始
        if (attacking) return; // 如果已经在攻击中,就避免重复触发
        attacking = true; // 标记进入攻击状态
        hitboxActive = false; // 初始时攻击盒还没有开启
        timer = 0f; // 重置攻击时间轴
        damagedTargets.Clear(); // 清空本次攻击命中过的目标
        Animator.CrossFade("Attack", 0.05f); // 播放攻击动画,短时间过渡到攻击状态
    } // 方法结束

    private void Update() // 每帧推进示例逻辑
    { // 方法开始
        if (attacking == false) return; // 如果不在攻击中就不处理
        timer += Time.deltaTime; // 推进攻击时间,联机确定性战斗里应改成固定 Tick 推进
        float activeStart = StartupTime; // 判定窗口开始时间
        float activeEnd = StartupTime + ActiveTime; // 判定窗口结束时间
        float attackEnd = StartupTime + ActiveTime + RecoveryTime; // 整个攻击动作结束时间
        hitboxActive = timer >= activeStart && timer <= activeEnd; // 根据时间轴判断攻击盒是否开启
        if (hitboxActive) // 如果当前处于命中窗口
        { // 判断开始
            CheckHit(); // 执行攻击盒检测
        } // 判断结束
        if (timer >= attackEnd) // 如果攻击时间已经结束
        { // 判断开始
            attacking = false; // 退出攻击状态
            hitboxActive = false; // 关闭攻击盒
        } // 判断结束
    } // 方法结束

    private void CheckHit() // 执行攻击盒命中检测
    { // 方法开始
        int count = Physics.OverlapBoxNonAlloc(HitCenter.position, HalfExtents, hitBuffer, HitCenter.rotation, TargetMask); // 检测攻击盒内的目标
        for (int i = 0; i < count; i++) // 遍历本次检测到的所有目标
        { // 循环开始
            Collider target = hitBuffer[i]; // 取出当前目标碰撞体
            int targetId = target.GetInstanceID(); // 获取目标实例 ID,用来做去重
            if (damagedTargets.Add(targetId) == false) continue; // 如果已经命中过这个目标,就跳过
            Debug.Log("Hit target: " + target.name); // 示例:真实项目里这里会发送伤害事件或请求服务端确认
        } // 循环结束
    } // 方法结束

    private void OnDrawGizmosSelected() // 在 Scene 视图中绘制攻击盒
    { // 方法开始
        if (HitCenter == null) return; // 如果没有设置攻击盒中心点就不绘制
        Matrix4x4 oldMatrix = Gizmos.matrix; // 保存原来的 Gizmos 矩阵
        Gizmos.color = hitboxActive ? Color.red : Color.yellow; // 判定窗口内画红色,否则画黄色
        Gizmos.matrix = Matrix4x4.TRS(HitCenter.position, HitCenter.rotation, Vector3.one); // 设置攻击盒的位置和旋转
        Gizmos.DrawWireCube(Vector3.zero, HalfExtents * 2f); // 绘制攻击盒线框
        Gizmos.matrix = oldMatrix; // 恢复原来的 Gizmos 矩阵
    } // 方法结束
} // 类结束

面试总结

我会说:我不会让伤害判定完全挂在动画事件上,而是用技能时间轴配置命中帧和攻击盒。动画事件可以做音效、特效、震屏这种表现触发;真正的伤害窗口由逻辑层控制。调试时我会把攻击盒、animTimelogicTime、命中目标画出来,这样能快速看出是动画偏了、攻击盒偏了,还是逻辑时间轴偏了。

如果技能系统后续要支持 1000 个技能,你怎么设计?

unity-skill-system-1000-skills-design

标准答案

如果技能系统以后要支持 1000 个技能,我不会给每个技能写一个独立脚本。核心设计应该是:配置驱动 + 组件化效果 + 技能时间轴 + 运行时上下文 + 工具校验

也就是:技能本身是一份配置,里面描述 CD、消耗、前摇、命中帧、目标筛选规则、效果列表、动画、特效、音效。代码只提供通用能力,比如伤害、治疗、Buff、子弹、召唤、位移、护盾。新技能尽量通过组合配置完成,只有出现全新机制时才扩展代码组件。

系统怎么拆

数据层:SkillConfig,保存技能静态配置。 运行层:SkillInstance / SkillContext,保存本次释放的施法者、目标、位置、随机种子、时间进度。 目标层:TargetSelector,负责单体、圆形、扇形、矩形、链式目标筛选。 效果层:EffectExecutor,负责伤害、Buff、治疗、子弹、击退等效果。 时间轴层:控制前摇、命中帧、后摇、取消窗口、多段伤害。 表现层:监听逻辑事件,播放动画、特效、音效、震屏、飘字。 工具层:技能编辑器、配置校验、范围预览、时间轴预览、自动检查。

简化代码结构

c
using System.Collections.Generic; // 引入 Dictionary 和 List 集合
using UnityEngine; // 引入 Unity 基础 API

public enum SkillEffectType { Damage, Heal, Buff } // 定义技能效果类型

[System.Serializable] // 允许 Unity 序列化这个配置类
public sealed class SkillEffectConfig // 定义单个技能效果配置
{ // 类开始
    public SkillEffectType Type; // 效果类型,比如伤害、治疗、Buff
    public int Value; // 效果数值,比如伤害值或治疗值
} // 类结束

[CreateAssetMenu(menuName = "Game/Skill Config")] // 允许在 Unity 里创建技能配置资源
public sealed class SkillConfig : ScriptableObject // 定义技能静态配置
{ // 类开始
    public int Id; // 技能唯一 ID
    public float Cooldown; // 技能冷却时间
    public float CastTime; // 技能释放总时长
    public List<SkillEffectConfig> Effects; // 技能包含的效果列表
} // 类结束

public sealed class SkillContext // 定义一次技能释放的运行时上下文
{ // 类开始
    public GameObject Caster; // 施法者对象
    public GameObject Target; // 当前目标对象
    public Vector3 CastPosition; // 技能释放位置
} // 类结束

public interface ISkillEffectExecutor // 定义效果执行器接口
{ // 接口开始
    void Execute(SkillContext context, SkillEffectConfig config); // 执行某个技能效果
} // 接口结束

public sealed class DamageEffectExecutor : ISkillEffectExecutor // 定义伤害效果执行器
{ // 类开始
    public void Execute(SkillContext context, SkillEffectConfig config) // 执行伤害效果
    { // 方法开始
        Debug.Log($"Deal damage: {config.Value}"); // 示例:真实项目里这里调用伤害系统
    } // 方法结束
} // 类结束

public sealed class SkillService // 定义技能服务,统一管理技能释放
{ // 类开始
    private readonly Dictionary<int, SkillConfig> configs = new Dictionary<int, SkillConfig>(); // 缓存技能 ID 到配置的映射
    private readonly Dictionary<SkillEffectType, ISkillEffectExecutor> executors = new Dictionary<SkillEffectType, ISkillEffectExecutor>(); // 缓存效果类型到执行器的映射

    public SkillService() // 构造技能服务
    { // 构造函数开始
        executors[SkillEffectType.Damage] = new DamageEffectExecutor(); // 注册伤害执行器
    } // 构造函数结束

    public void RegisterConfig(SkillConfig config) // 注册技能配置
    { // 方法开始
        configs[config.Id] = config; // 把技能配置放入字典缓存
    } // 方法结束

    public void Cast(int skillId, SkillContext context) // 释放一个技能
    { // 方法开始
        if (configs.TryGetValue(skillId, out SkillConfig config) == false) return; // 找不到技能配置就直接返回
        for (int i = 0; i < config.Effects.Count; i++) // 遍历技能配置里的所有效果
        { // 循环开始
            SkillEffectConfig effect = config.Effects[i]; // 取出当前效果配置
            if (executors.TryGetValue(effect.Type, out ISkillEffectExecutor executor) == false) continue; // 找不到执行器就跳过
            executor.Execute(context, effect); // 执行当前效果
        } // 循环结束
    } // 方法结束
} // 类结束

面试重点

1000 个技能的难点不只是“能放出来”,而是后期能维护、能调试、能热更、能让策划配置。 所以我会强调:技能系统要把“稳定的代码能力”和“经常变化的技能数据”分开。代码提供原子能力,配置负责组合。

性能上,战斗中不能频繁反射、不能大量 LINQ、不能每次释放都解析配置。配置要启动时预解析,执行器要缓存,子弹和特效要走对象池,大量目标筛选要结合空间划分。

联机时,客户端请求里只带 skillId、目标、方向、释放 Tick;服务端校验 CD、蓝量、距离、状态和命中,客户端只做预测表现。

如果策划频繁改表导致 Bug,你怎么建设工具?

unity-config-table-tooling

标准答案

如果策划频繁改表导致 Bug,我不会简单说“让策划小心点”,而是建设一套配置表工具链,把错误前置到导表、提交、构建、发布前

核心目标是:策划可以频繁改,但错误要能自动发现、能定位到表格行列、能看到影响范围、能快速回滚。

我会怎么做

  1. 表结构规范化:每张表有 Schema,定义字段名、类型、默认值、范围、枚举、是否必填。
  2. 自动校验:检查重复 ID、空字段、非法范围、枚举错误、跨表引用、资源路径是否存在。
  3. 代码生成:根据配置表生成强类型 C# 类,避免到处用字符串字段和硬编码。
  4. 差异报告:每次改表生成 diff,告诉程序和测试这次改了哪些技能、怪物、掉落、关卡。
  5. 编辑器预览:技能范围、掉落概率、关卡波次、怪物刷新可以在 Unity 编辑器里直接预览。
  6. CI 阻断:提交或打包前跑全量校验,有错误直接失败,不让坏配置进包。
  7. 运行时兜底:热加载失败时保留旧配置,线上配置带版本号,异常时可以回滚。

底层原理

配置表 Bug 本质上是“数据破坏了代码假设”。

比如代码假设 skillId 一定能找到技能,但策划填了不存在的 ID;代码假设 buffId 可选,但表里填了非法字符串;代码假设掉落概率总和是 100%,但实际填成 135%。 工具链要做的事情就是把这些假设显式写成规则,然后自动检查。

Unity 编辑器校验示例

c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段工具代码
using System.Collections.Generic; // 引入 HashSet 用来检查重复 ID
using System.IO; // 引入 File 读取表格文件
using UnityEditor; // 引入 Unity 编辑器菜单 API
using UnityEngine; // 引入 Unity 日志 API

public static class ConfigTableValidator // 定义配置表校验工具类
{ // 类开始
    private const string SkillCsvPath = "Assets/GameConfigs/Skill.csv"; // 定义技能表路径

    [MenuItem("Tools/Config/Validate Skill Table")] // 在 Unity 菜单栏添加校验入口
    private static void ValidateSkillTable() // 校验技能表
    { // 方法开始
        if (File.Exists(SkillCsvPath) == false) // 判断技能表文件是否存在
        { // 判断开始
            Debug.LogError("技能表不存在: " + SkillCsvPath); // 输出文件不存在错误
            return; // 直接结束校验
        } // 判断结束

        string[] lines = File.ReadAllLines(SkillCsvPath); // 读取 CSV 的所有行
        HashSet<int> ids = new HashSet<int>(); // 创建集合用于检查重复技能 ID
        int errorCount = 0; // 记录错误数量

        for (int row = 1; row < lines.Length; row++) // 从第 2 行开始遍历,默认第 1 行是表头
        { // 循环开始
            string line = lines[row]; // 获取当前行文本
            if (string.IsNullOrWhiteSpace(line)) continue; // 空行直接跳过
            string[] cols = line.Split(','); // 简单按逗号切分字段,正式项目建议用可靠 CSV 库
            if (cols.Length < 4) // 判断字段数量是否足够
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行字段数量不足"); // 报告字段数量错误
                errorCount++; // 错误数量加一
                continue; // 跳过当前行后续检查
            } // 判断结束

            if (int.TryParse(cols[0], out int id) == false) // 尝试解析技能 ID
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 skillId 不是整数"); // 报告 ID 类型错误
                errorCount++; // 错误数量加一
                continue; // 没有合法 ID 就跳过当前行
            } // 判断结束

            if (ids.Add(id) == false) // 检查技能 ID 是否重复
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 skillId 重复: {id}"); // 报告重复 ID
                errorCount++; // 错误数量加一
            } // 判断结束

            if (float.TryParse(cols[2], out float cooldown) == false) // 尝试解析技能 CD
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 cooldown 不是数字"); // 报告 CD 类型错误
                errorCount++; // 错误数量加一
            } // 判断结束
            else if (cooldown < 0f) // 判断 CD 是否小于 0
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 cooldown 不能小于 0"); // 报告 CD 范围错误
                errorCount++; // 错误数量加一
            } // 判断结束

            if (int.TryParse(cols[3], out int damage) == false) // 尝试解析伤害值
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 damage 不是整数"); // 报告伤害类型错误
                errorCount++; // 错误数量加一
            } // 判断结束
            else if (damage < 0) // 判断伤害是否小于 0
            { // 判断开始
                Debug.LogError($"Skill.csv 第 {row + 1} 行 damage 不能小于 0"); // 报告伤害范围错误
                errorCount++; // 错误数量加一
            } // 判断结束
        } // 循环结束

        if (errorCount > 0) // 判断是否存在错误
        { // 判断开始
            Debug.LogError($"技能表校验失败,错误数量: {errorCount}"); // 输出校验失败结果
        } // 判断结束
        else // 如果没有错误
        { // else 开始
            Debug.Log("技能表校验通过"); // 输出校验成功结果
        } // else 结束
    } // 方法结束
} // 类结束
#endif // 结束编辑器专用代码

面试总结

我会说:策划频繁改表不可怕,可怕的是没有工具把错误拦住。我会建设 Schema、导表校验、跨表引用检查、资源存在性检查、diff 报告、编辑器预览和 CI 阻断。这样配置错误能在提交前暴露,错误信息能定位到具体表格、行、列,线上再配合版本号和回滚机制,避免坏配置直接影响玩家。

如果美术资源格式混乱,你怎么做导入规范?

unity-art-asset-import-standards

标准答案

如果美术资源格式混乱,我不会只写一份规范文档让大家手动遵守,而是做一套资源导入规范工具链:命名和目录规范、自动 Importer 设置、资源检查报告、CI 阻断、例外白名单。

目标是:资源一进 Unity 工程,就自动按规则设置;不符合规范的资源能定位到路径和原因;特殊资源可以走白名单,但要有负责人和过期时间。

具体怎么做

  1. 先定规范:角色、场景、UI、特效、音频、视频分别放目录,命名带用途、平台、尺寸或类型。
  2. 贴图规范:限制最大尺寸,设置压缩格式,关闭不必要的 Read/Write,区分 UI 贴图、法线贴图、Lightmap。
  3. 模型规范:统一单位、缩放、Rig、法线切线、动画压缩,关闭不必要的 Read/Write
  4. 音频规范:BGM、语音、音效分别设置加载方式、压缩质量、是否预加载。
  5. 自动导入:用 AssetPostprocessor 按目录和命名自动设置 TextureImporterModelImporterAudioImporter
  6. 检查工具:扫描超大贴图、错误压缩、Missing 引用、材质 Shader 不规范、Prefab 引用丢失。
  7. CI 阻断:构建前跑检查,严重问题直接失败;普通问题输出报告。
  8. 白名单机制:特殊资源允许例外,但必须写原因、负责人和过期时间。

底层原理

Unity 的资源导入不是直接使用源文件,而是通过 Importer 生成导入后的资源数据,并把设置写在 .meta 文件里。 如果每个人手动设置,就很容易出现:贴图压缩格式不一致、模型 Read/Write 误开、音频加载方式不对、包体和内存突然上涨。 所以规范要落到工具上,让 Importer 自动设置,CI 自动检查。

AssetPostprocessor 示例

c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段导入工具
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API

public sealed class ArtAssetImportProcessor : AssetPostprocessor // 定义美术资源自动导入处理器
{ // 类开始
    private void OnPreprocessTexture() // Unity 导入贴图前自动调用
    { // 方法开始
        TextureImporter importer = (TextureImporter)assetImporter; // 把当前导入器转换成贴图导入器
        importer.isReadable = false; // 默认关闭 Read/Write,减少内存占用
        importer.mipmapEnabled = assetPath.Contains("/UI/") == false; // UI 贴图一般关闭 Mipmap,场景贴图一般开启
        importer.textureCompression = TextureImporterCompression.Compressed; // 默认开启压缩
        importer.maxTextureSize = assetPath.Contains("/UI/") ? 1024 : 2048; // UI 和场景贴图设置不同尺寸上限

        TextureImporterPlatformSettings android = importer.GetPlatformTextureSettings("Android"); // 获取 Android 平台贴图设置
        android.overridden = true; // 开启 Android 平台覆盖设置
        android.format = TextureImporterFormat.ASTC_6x6; // Android 默认使用 ASTC 6x6 平衡质量和体积
        android.maxTextureSize = importer.maxTextureSize; // Android 使用统一尺寸上限
        importer.SetPlatformTextureSettings(android); // 写回 Android 平台设置

        TextureImporterPlatformSettings ios = importer.GetPlatformTextureSettings("iPhone"); // 获取 iOS 平台贴图设置
        ios.overridden = true; // 开启 iOS 平台覆盖设置
        ios.format = TextureImporterFormat.ASTC_6x6; // iOS 默认使用 ASTC 6x6
        ios.maxTextureSize = importer.maxTextureSize; // iOS 使用统一尺寸上限
        importer.SetPlatformTextureSettings(ios); // 写回 iOS 平台设置
    } // 方法结束

    private void OnPreprocessModel() // Unity 导入模型前自动调用
    { // 方法开始
        ModelImporter importer = (ModelImporter)assetImporter; // 把当前导入器转换成模型导入器
        importer.globalScale = 1f; // 统一模型缩放比例
        importer.isReadable = false; // 默认关闭 Mesh Read/Write,减少内存
        importer.importBlendShapes = true; // 默认允许导入 BlendShape,角色表情可能需要
        importer.importCameras = false; // 不导入美术文件里的摄像机
        importer.importLights = false; // 不导入美术文件里的灯光
        importer.animationCompression = ModelImporterAnimationCompression.Optimal; // 使用较优动画压缩
    } // 方法结束

    private void OnPreprocessAudio() // Unity 导入音频前自动调用
    { // 方法开始
        AudioImporter importer = (AudioImporter)assetImporter; // 把当前导入器转换成音频导入器
        AudioImporterSampleSettings settings = importer.defaultSampleSettings; // 读取默认音频导入设置
        settings.compressionFormat = AudioCompressionFormat.Vorbis; // 使用 Vorbis 压缩格式
        settings.quality = assetPath.Contains("/BGM/") ? 0.7f : 0.5f; // BGM 保留更高质量,音效压缩更多
        settings.loadType = assetPath.Contains("/BGM/") ? AudioClipLoadType.Streaming : AudioClipLoadType.DecompressOnLoad; // BGM 流式加载,短音效解压加载
        importer.defaultSampleSettings = settings; // 写回音频导入设置
        importer.preloadAudioData = assetPath.Contains("/BGM/") == false; // BGM 不预加载,短音效可以预加载
    } // 方法结束
} // 类结束
#endif // 结束编辑器专用代码

面试总结

我会说:美术资源混乱,本质是协作流程没有工具兜底。我会先和 TA、美术确定目录、命名、尺寸、压缩、模型、音频规范,然后用 AssetPostprocessor 自动设置 Importer,用检查工具输出报告,用 CI 阻断严重违规资源。特殊资源可以白名单,但必须有原因、负责人和过期时间。这样能减少包体暴涨、内存浪费、Missing 引用、材质不合批等问题。

如果低端机帧率只有 20,你怎么做画质分档?

unity-low-end-quality-tiers

标准答案

低端机只有 20 FPS,我不会直接说“把画质调低”,而是先用 Profiler 判断瓶颈:是 CPU、GPU、内存、发热降频,还是某个场景资源太重。然后做低、中、高档画质配置,低端机优先保证稳定帧率和操作手感,高端机再追求画面效果。

怎么分档

低档:30 FPS、降低渲染比例、关或降低实时阴影、减少后处理、降低粒子数量、降低 LOD 距离、减少透明 Overdraw。 中档:30 或 45 FPS,中等渲染比例,保留基础阴影和少量后处理。 高档:60 FPS,正常渲染比例,更高阴影质量、Bloom、抗锯齿、更多特效。 动态档:如果连续几秒低帧或发热,就自动降档;恢复时要慢一点,避免画质来回抖动。

底层原理

画质分档本质是在控制不同硬件上的资源预算:

GPU 主要受分辨率、阴影、后处理、透明物体、粒子、MSAA 影响。 CPU 主要受 AI、Animator、Physics、大量 Update、UI Rebuild、特效逻辑影响。 内存主要受贴图质量、音频加载、Mesh、粒子、预加载资源影响。 低端机还容易发热降频,所以不能只看瞬时 FPS,要跑 10 到 20 分钟看稳定性。

示例代码

c
using UnityEngine; // 引入 Unity 基础 API

public enum QualityTier // 定义画质档位枚举
{ // 枚举开始
    Low, // 低档,优先保证低端机稳定帧率
    Medium, // 中档,兼顾画面和性能
    High // 高档,优先保证画面效果
} // 枚举结束

public sealed class MobileQualityManager : MonoBehaviour // 定义移动端画质管理器
{ // 类开始
    public QualityTier CurrentTier = QualityTier.Medium; // 当前画质档位
    public float CheckInterval = 5f; // 每隔几秒检测一次平均帧率
    public float LowFpsThreshold = 25f; // 平均帧率低于该值时考虑降档
    private float elapsed; // 当前检测窗口累计时间
    private int frameCount; // 当前检测窗口累计帧数

    private void Start() // 游戏开始时调用
    { // 方法开始
        ApplyQuality(CurrentTier); // 应用初始画质档位
    } // 方法结束

    private void Update() // 每帧检测运行状态
    { // 方法开始
        elapsed += Time.unscaledDeltaTime; // 累加真实时间,不受暂停影响
        frameCount++; // 累加帧数
        if (elapsed < CheckInterval) return; // 没到检测间隔就直接返回
        float fps = frameCount / elapsed; // 计算检测窗口内的平均帧率
        if (fps < LowFpsThreshold && CurrentTier != QualityTier.Low) // 如果持续低帧并且还不是低档
        { // 判断开始
            CurrentTier--; // 降低一个画质档位
            ApplyQuality(CurrentTier); // 应用新的画质档位
        } // 判断结束
        elapsed = 0f; // 重置累计时间
        frameCount = 0; // 重置累计帧数
    } // 方法结束

    public void ApplyQuality(QualityTier tier) // 应用指定画质档位
    { // 方法开始
        CurrentTier = tier; // 保存当前画质档位
        QualitySettings.vSyncCount = 0; // 移动端通常关闭 VSync,让 targetFrameRate 生效
        if (tier == QualityTier.Low) // 如果是低档
        { // 判断开始
            Application.targetFrameRate = 30; // 低端机优先锁 30 帧,减少发热和波动
            QualitySettings.shadowDistance = 0f; // 关闭实时阴影距离,降低 GPU 压力
            QualitySettings.antiAliasing = 0; // 关闭 MSAA,减少带宽和填充压力
            QualitySettings.masterTextureLimit = 1; // 使用半分辨率贴图,降低内存和带宽
            QualitySettings.lodBias = 0.6f; // 更早切换到低 LOD 模型
            ScalableBufferManager.ResizeBuffers(0.7f, 0.7f); // 降低渲染比例,减少像素填充成本
        } // 判断结束
        else if (tier == QualityTier.Medium) // 如果是中档
        { // 判断开始
            Application.targetFrameRate = 45; // 中档可以设置 45 帧作为折中目标
            QualitySettings.shadowDistance = 25f; // 保留较短距离的阴影
            QualitySettings.antiAliasing = 2; // 使用较低 MSAA
            QualitySettings.masterTextureLimit = 0; // 使用原始贴图质量
            QualitySettings.lodBias = 0.85f; // 使用中等 LOD 距离
            ScalableBufferManager.ResizeBuffers(0.85f, 0.85f); // 使用中等渲染比例
        } // 判断结束
        else // 否则就是高档
        { // else 开始
            Application.targetFrameRate = 60; // 高档目标 60 帧
            QualitySettings.shadowDistance = 50f; // 使用更远的阴影距离
            QualitySettings.antiAliasing = 4; // 使用更高 MSAA
            QualitySettings.masterTextureLimit = 0; // 使用原始贴图质量
            QualitySettings.lodBias = 1.0f; // 使用正常 LOD 距离
            ScalableBufferManager.ResizeBuffers(1.0f, 1.0f); // 使用完整渲染比例
        } // else 结束
    } // 方法结束
} // 类结束

面试总结

我会说:低端机 20 FPS 时,第一步不是盲目降画质,而是先定位瓶颈。画质分档要覆盖 GPU、CPU、内存和发热,比如渲染比例、阴影、后处理、粒子、LOD、贴图质量、帧率、AI 和物理更新频率。最后要用真机跑固定场景,对比平均 FPS、P1 FPS、温度、内存和功耗,证明低档确实稳定。

如果网络延迟 200ms,动作游戏怎么保证手感?

unity-action-game-200ms-latency-feel

标准答案

200ms 延迟下,动作游戏不能等服务端回包再让角色动,否则玩家按下方向键后 0.2 秒才响应,手感会很差。

我的方案是:客户端预测保证手感,服务端权威保证公平,远端角色插值显示,本地角色收到权威快照后平滑校正,命中类操作可以做延迟补偿。

具体怎么做

移动:玩家输入后,本地立刻移动角色,同时把输入序号、Tick、方向发给服务端。服务端验证后返回权威位置,客户端对比预测位置和权威位置,小误差平滑修正,大误差回滚并重放未确认输入。

攻击:动画、特效、音效、镜头震动可以立即播放,让玩家觉得技能放出去了;但真正的伤害、命中、Buff、击杀奖励必须等服务端确认。

其他玩家:不要直接显示最新网络位置,而是用插值缓冲,比如延迟显示 100ms 左右,在两个服务端快照之间插值,减少瞬移和抖动。

命中补偿:对于射击、快速技能,可以让服务端根据客户端开火时间回看历史位置,判断当时是否命中,但要限制最大补偿时间,避免被高延迟玩家利用。

弱网策略:输入包要有 seqtick、重发和 ACK;网络很差时给提示,限制高风险操作,比如交易、竞技匹配、关键结算。

底层原理

网络延迟不可消除,只能隐藏。 动作游戏的手感来自“输入到反馈”的时间,如果这个时间超过几十毫秒就会明显迟钝。 所以本地必须先预测执行;但客户端不可信,不能让它决定最终伤害和资源变化。 因此系统要做两件事:本地先演,服务端后判;判错了再修正。

简化客户端预测代码

c
using System.Collections.Generic; // 引入 List 集合,用来保存未确认输入
using UnityEngine; // 引入 Unity 基础 API

public struct MoveCommand // 定义一个移动输入指令
{ // 结构体开始
    public int Sequence; // 输入序号,用来和服务端确认结果对应
    public Vector2 Direction; // 输入方向,比如摇杆或 WASD 方向
    public float DeltaTime; // 这条输入对应的模拟时间
} // 结构体结束

public struct ServerSnapshot // 定义服务端权威快照
{ // 结构体开始
    public int LastProcessedSequence; // 服务端已经处理到的输入序号
    public Vector3 Position; // 服务端认为玩家当前的权威位置
} // 结构体结束

public sealed class ClientPredictionMotor : MonoBehaviour // 定义客户端预测移动组件
{ // 类开始
    public float MoveSpeed = 5f; // 角色移动速度
    public float ReconcileThreshold = 0.35f; // 误差超过这个值才进行明显校正
    private int nextSequence = 1; // 下一条输入的序号
    private readonly List<MoveCommand> pendingCommands = new List<MoveCommand>(); // 保存还没有被服务端确认的输入

    private void Update() // 每帧读取输入并预测移动
    { // 方法开始
        Vector2 dir = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 读取玩家输入方向
        if (dir.sqrMagnitude > 1f) dir.Normalize(); // 如果斜向输入长度超过 1,就归一化
        MoveCommand command = new MoveCommand(); // 创建一条移动指令
        command.Sequence = nextSequence++; // 分配输入序号并递增
        command.Direction = dir; // 保存输入方向
        command.DeltaTime = Time.deltaTime; // 保存本帧模拟时间
        Simulate(command); // 本地立即模拟移动,保证手感
        pendingCommands.Add(command); // 保存未确认输入,等服务端快照回来后可能要重放
        SendMoveCommand(command); // 把输入指令发送给服务端
    } // 方法结束

    private void Simulate(MoveCommand command) // 根据输入指令模拟移动
    { // 方法开始
        Vector3 move = new Vector3(command.Direction.x, 0f, command.Direction.y); // 把二维输入转换成三维移动方向
        transform.position += move * MoveSpeed * command.DeltaTime; // 立刻更新本地位置
    } // 方法结束

    public void OnServerSnapshot(ServerSnapshot snapshot) // 收到服务端权威快照时调用
    { // 方法开始
        float error = Vector3.Distance(transform.position, snapshot.Position); // 计算本地预测位置和服务端位置的误差
        if (error > ReconcileThreshold) // 如果误差超过阈值
        { // 判断开始
            transform.position = snapshot.Position; // 先把本地位置拉回服务端权威位置
            RemoveConfirmedCommands(snapshot.LastProcessedSequence); // 删除服务端已经确认处理过的输入
            for (int i = 0; i < pendingCommands.Count; i++) // 遍历剩余未确认输入
            { // 循环开始
                Simulate(pendingCommands[i]); // 重新模拟未确认输入,恢复预测状态
            } // 循环结束
        } // 判断结束
        else // 如果误差很小
        { // else 开始
            transform.position = Vector3.Lerp(transform.position, snapshot.Position, 0.2f); // 小误差用插值平滑修正
            RemoveConfirmedCommands(snapshot.LastProcessedSequence); // 删除已经被服务端确认的输入
        } // else 结束
    } // 方法结束

    private void RemoveConfirmedCommands(int lastProcessedSequence) // 删除已确认输入
    { // 方法开始
        pendingCommands.RemoveAll(command => command.Sequence <= lastProcessedSequence); // 移除服务端已经处理过的指令
    } // 方法结束

    private void SendMoveCommand(MoveCommand command) // 发送移动指令给服务端
    { // 方法开始
        Debug.Log("Send move command seq: " + command.Sequence); // 示例:真实项目里这里走 UDP 或可靠 UDP 发送
    } // 方法结束
} // 类结束

面试总结

我会说:200ms 延迟下,动作游戏要把“手感”和“权威”拆开。本地预测保证玩家输入立刻有反馈;服务端权威保证公平和反作弊;远端角色用插值缓冲显示;本地角色收到服务端快照后做平滑校正或回滚重放;射击和高速技能可以做有限的延迟补偿。这样既不会按键迟钝,也不会把关键结算交给客户端。

如果 Boss 技能非常复杂,你如何配置化?

unity-boss-complex-skill-configurable

标准答案

Boss 技能非常复杂时,我不会把每个技能都写成一大段 if/else 或协程,而是做成阶段配置 + 技能池 + 条件选择 + 技能时间轴 + 效果节点组合

简单说:Boss AI 只负责“什么时候选哪个技能”,技能系统负责“这个技能在第几秒做什么”。比如第 0 秒播放预警,第 0.8 秒锁定目标,第 1.2 秒造成伤害,第 1.5 秒召唤小怪,第 2 秒进入后摇,这些都走配置。

怎么拆

Boss 配置:血量阶段、技能池、CD 组、权重、狂暴条件。 技能配置:前摇、命中帧、后摇、可打断窗口、时间轴事件。 目标配置:单体、扇形、圆形、矩形、随机点名、仇恨最高。 效果配置:伤害、Buff、召唤、位移、弹幕、地板预警、镜头震动。 表现配置:动画、特效、音效、字幕、镜头、屏幕震动。

代码示例

c
using System.Collections.Generic; // 引入 List 集合,用来保存技能事件
using UnityEngine; // 引入 Unity 基础 API

public enum BossEventType // 定义 Boss 技能时间轴事件类型
{ // 枚举开始
    Warning, // 预警事件,比如显示红圈或扇形范围
    LockTarget, // 锁定目标事件,比如记录点名玩家位置
    Damage, // 伤害事件,比如在命中帧结算伤害
    Summon // 召唤事件,比如召唤小怪或生成机关
} // 枚举结束

[System.Serializable] // 允许 Unity 序列化该配置类
public sealed class BossSkillEvent // 定义 Boss 技能时间轴上的单个事件
{ // 类开始
    public float Time; // 事件触发时间,单位是秒
    public BossEventType Type; // 事件类型,决定要执行哪种逻辑
    public string Param; // 事件参数,比如特效名、怪物 ID、伤害倍率
} // 类结束

[CreateAssetMenu(menuName = "Game/Boss Skill Config")] // 允许在 Unity 里创建 Boss 技能配置资源
public sealed class BossSkillConfig : ScriptableObject // 定义 Boss 技能配置
{ // 类开始
    public int Id; // 技能唯一 ID
    public float Cooldown; // 技能冷却时间
    public List<BossSkillEvent> Events = new List<BossSkillEvent>(); // 技能时间轴事件列表
} // 类结束

public sealed class BossSkillRunner : MonoBehaviour // 定义 Boss 技能运行器
{ // 类开始
    private BossSkillConfig currentSkill; // 当前正在执行的技能配置
    private float timer; // 当前技能已经运行的时间
    private int nextEventIndex; // 下一个要触发的时间轴事件下标
    private bool running; // 当前是否正在执行技能

    public void Play(BossSkillConfig skill) // 开始执行一个 Boss 技能
    { // 方法开始
        currentSkill = skill; // 保存当前技能配置
        timer = 0f; // 重置技能时间
        nextEventIndex = 0; // 从第一个事件开始触发
        running = true; // 标记技能开始运行
    } // 方法结束

    private void Update() // 每帧推进技能时间轴
    { // 方法开始
        if (running == false || currentSkill == null) return; // 如果没有技能在运行就直接返回
        timer += Time.deltaTime; // 推进技能时间,联机项目可改为固定 Tick
        while (nextEventIndex < currentSkill.Events.Count && currentSkill.Events[nextEventIndex].Time <= timer) // 触发所有到时间的事件
        { // 循环开始
            ExecuteEvent(currentSkill.Events[nextEventIndex]); // 执行当前时间轴事件
            nextEventIndex++; // 移动到下一个事件
        } // 循环结束
    } // 方法结束

    private void ExecuteEvent(BossSkillEvent skillEvent) // 执行单个时间轴事件
    { // 方法开始
        if (skillEvent.Type == BossEventType.Warning) Debug.Log("显示预警: " + skillEvent.Param); // 处理预警事件
        if (skillEvent.Type == BossEventType.LockTarget) Debug.Log("锁定目标: " + skillEvent.Param); // 处理锁定目标事件
        if (skillEvent.Type == BossEventType.Damage) Debug.Log("结算伤害: " + skillEvent.Param); // 处理伤害事件
        if (skillEvent.Type == BossEventType.Summon) Debug.Log("召唤单位: " + skillEvent.Param); // 处理召唤事件
    } // 方法结束
} // 类结束

面试总结

我会说:复杂 Boss 的关键不是把逻辑写死,而是把变化点配置化。BossBrain 根据阶段、距离、仇恨、CD、人数选择技能;SkillRunner 按时间轴执行事件;目标筛选和效果节点复用。编辑器要能预览范围、弹道、阶段切换,并在构建前校验空资源、非法参数、死循环和缺失引用。这样 Boss 再复杂,也是在组合能力,而不是堆代码。

如果要支持回放系统,你怎么记录数据?

unity-replay-system-record-data

标准答案

回放系统不是简单录屏,而是记录一份“能重建战斗过程的数据”。我会优先记录:版本信息、初始状态、随机种子、输入流、事件流、周期快照、状态 Hash

如果是战斗验证或反作弊,核心记录输入流:体积小,但要求逻辑确定性。 如果是观战、精彩回放或拖进度条,就要加周期快照:体积更大,但可以快速 Seek。

要记录哪些数据

Header:游戏版本、配置表版本、地图 ID、玩法模式、玩家列表、随机种子。 初始状态:角色属性、出生点、装备、Buff、场景关键对象状态。 输入流:每个 Tick 的玩家输入,比如移动方向、技能 ID、目标 ID、瞄准方向。 事件流:伤害、治疗、Buff、死亡、击杀、掉落、技能提示、表现事件。 周期快照:每隔 N 秒保存一次关键状态,方便拖进度和断点回放。 状态 Hash:每个 Tick 或关键 Tick 记录校验和,用来定位不同步第一分叉点。

简化代码示例

c
using System.Collections.Generic; // 引入 List 集合
using UnityEngine; // 引入 Unity 基础 API

public enum ReplayCommandType // 定义回放命令类型
{ // 枚举开始
    Move, // 移动命令
    CastSkill // 释放技能命令
} // 枚举结束

[System.Serializable] // 允许 Unity 序列化命令数据
public struct ReplayCommand // 定义一条回放命令
{ // 结构体开始
    public int Tick; // 命令发生在哪个逻辑 Tick
    public int PlayerId; // 哪个玩家发出的命令
    public ReplayCommandType Type; // 命令类型
    public int SkillId; // 技能 ID,移动命令可以为 0
    public int TargetId; // 目标 ID,没有目标可以为 0
    public Vector2 Direction; // 移动方向或瞄准方向
} // 结构体结束

[System.Serializable] // 允许 Unity 序列化整场回放数据
public sealed class ReplayData // 定义回放文件数据
{ // 类开始
    public string AppVersion; // 游戏版本号
    public string ConfigVersion; // 配置表版本号
    public int MapId; // 地图 ID
    public int RandomSeed; // 战斗随机种子
    public List<ReplayCommand> Commands = new List<ReplayCommand>(); // 按 Tick 保存输入命令
    public List<uint> StateHashes = new List<uint>(); // 保存关键 Tick 的状态 Hash
} // 类结束

public sealed class ReplayRecorder // 定义回放录制器
{ // 类开始
    private readonly ReplayData data = new ReplayData(); // 创建本次回放数据对象

    public ReplayRecorder(string appVersion, string configVersion, int mapId, int seed) // 创建录制器并写入 Header
    { // 构造函数开始
        data.AppVersion = appVersion; // 保存游戏版本
        data.ConfigVersion = configVersion; // 保存配置表版本
        data.MapId = mapId; // 保存地图 ID
        data.RandomSeed = seed; // 保存随机种子
    } // 构造函数结束

    public void RecordCommand(ReplayCommand command) // 记录一条输入命令
    { // 方法开始
        data.Commands.Add(command); // 把命令追加到输入流
    } // 方法结束

    public void RecordStateHash(uint hash) // 记录一个状态 Hash
    { // 方法开始
        data.StateHashes.Add(hash); // 把状态校验和追加到列表
    } // 方法结束

    public string ToJson() // 把回放数据转成 JSON
    { // 方法开始
        return JsonUtility.ToJson(data); // 返回 JSON 字符串,正式项目可换成二进制和压缩格式
    } // 方法结束
} // 类结束

面试总结

我会说:如果要支持回放,我不会只录屏,因为录屏不能复盘逻辑问题。我的方案是记录最小可重建数据:Header、初始状态、随机种子、输入流、事件流、周期快照和状态 Hash。输入回放适合战斗验证和不同步定位,快照回放适合观战和拖进度。线上还要注意压缩、脱敏、版本兼容和自动回放测试。

如果要做战斗录像验证,你怎么保证确定性?

unity-battle-replay-determinism

标准答案

战斗录像验证要保证确定性,核心是:同一份录像数据,在同一套配置和逻辑下重跑,必须得到同样的状态 Hash

我会控制六件事:同输入、同初始状态、同随机、同 Tick、同结算顺序、同数值规则。只要其中一个不固定,录像就可能在某个 Tick 开始分叉。

具体怎么保证

输入固定:录像记录每个 Tick 的玩家输入,回放时只喂这份输入流。 随机固定:保存随机种子,用自定义 RNG,不混用 UnityEngine.Random。 时间固定:战斗逻辑按固定 Tick 推进,不用 Time.deltaTime 参与权威结算。 顺序固定:实体、Buff、伤害事件按稳定 ID 排序,不依赖 Dictionary 遍历顺序。 数值固定:核心战斗尽量用整数或定点数,避免不同平台浮点误差。 配置固定:记录配置版本、资源版本、技能表版本,回放时必须加载同版本数据。 校验固定:每个 Tick 计算状态 Hash,发现不同就定位第一分叉 Tick。

底层原理

录像验证本质是一次“确定性重放”。 如果初始状态一样、输入一样、随机一样、结算顺序一样,那么结果就应该一样。 如果结果不一样,就说明某个地方用了不确定因素,比如随机调用次数不同、浮点误差、遍历顺序变化、配置版本变化、动画事件影响逻辑、物理模拟参与权威结算。

简化确定性 RNG 和 Hash 示例

c
using System.Collections.Generic; // 引入 List 集合
using UnityEngine; // 引入 Unity 基础 API

public struct BattleUnitSnapshot // 定义参与 Hash 的单位快照
{ // 结构体开始
    public int EntityId; // 实体稳定 ID
    public int Hp; // 当前血量
    public int PosX; // 定点数 X 坐标
    public int PosZ; // 定点数 Z 坐标
    public int BuffHash; // Buff 列表 Hash
} // 结构体结束

public sealed class DeterministicRandom // 定义一个简单确定性随机数生成器
{ // 类开始
    private uint state; // 保存随机数内部状态

    public DeterministicRandom(uint seed) // 构造函数传入固定种子
    { // 构造函数开始
        state = seed == 0 ? 1u : seed; // 避免种子为 0 导致序列退化
    } // 构造函数结束

    public int NextInt(int minInclusive, int maxExclusive) // 生成指定范围内的整数
    { // 方法开始
        state ^= state << 13; // 使用 xorshift 第一步扰动状态
        state ^= state >> 17; // 使用 xorshift 第二步扰动状态
        state ^= state << 5; // 使用 xorshift 第三步扰动状态
        uint range = (uint)(maxExclusive - minInclusive); // 计算随机范围大小
        return minInclusive + (int)(state % range); // 把随机值映射到目标范围
    } // 方法结束
} // 类结束

public static class BattleDeterminismHash // 定义战斗确定性 Hash 工具
{ // 类开始
    public static uint CalcStateHash(int tick, List<BattleUnitSnapshot> units) // 计算某个 Tick 的状态 Hash
    { // 方法开始
        unchecked // 允许整数溢出,因为 Hash 混合依赖自然溢出
        { // unchecked 开始
            units.Sort((a, b) => a.EntityId.CompareTo(b.EntityId)); // 按稳定 ID 排序,避免遍历顺序不确定
            uint hash = 2166136261u; // 使用 FNV-1a 初始值
            hash = Mix(hash, tick); // 混入当前 Tick
            for (int i = 0; i < units.Count; i++) // 遍历所有单位快照
            { // 循环开始
                BattleUnitSnapshot unit = units[i]; // 取出当前单位快照
                hash = Mix(hash, unit.EntityId); // 混入实体 ID
                hash = Mix(hash, unit.Hp); // 混入血量
                hash = Mix(hash, unit.PosX); // 混入 X 坐标
                hash = Mix(hash, unit.PosZ); // 混入 Z 坐标
                hash = Mix(hash, unit.BuffHash); // 混入 Buff 状态
            } // 循环结束
            return hash; // 返回该 Tick 的状态 Hash
        } // unchecked 结束
    } // 方法结束

    private static uint Mix(uint hash, int value) // 把一个整数混入 Hash
    { // 方法开始
        unchecked // 允许无符号整数溢出
        { // unchecked 开始
            hash ^= (uint)value; // 把值异或进 Hash
            hash *= 16777619u; // 乘以 FNV 质数打散
            return hash; // 返回混合后的 Hash
        } // unchecked 结束
    } // 方法结束
} // 类结束

面试总结

我会说:战斗录像验证不是看画面像不像,而是重跑同一份输入后检查每个 Tick 的状态 Hash。为了保证确定性,我会固定输入流、初始状态、随机种子、配置版本、逻辑 Tick、结算顺序和数值规则。Unity 的动画、特效、物理和帧率只做表现,不参与权威战斗结果。如果某些逻辑无法保证确定性,就改成记录服务端事件流或周期快照兜底。

如果要做跨平台输入,你怎么抽象?

unity-cross-platform-input-abstraction

标准答案

跨平台输入的核心是:设备层和业务层解耦。 键鼠、手柄、触屏、陀螺仪只是不同输入来源;角色移动、技能释放、UI 点击这些业务系统不应该直接关心“按的是 W 还是摇杆”。

我会把输入分成四层:设备输入层、适配层、动作层、业务层。 设备层读键鼠、手柄、触屏;适配层把它们转成统一的 InputFrame;业务层只读 MoveAttackSkillInteract 这种游戏动作。

怎么设计

设备层:KeyboardMouseInputSourceGamepadInputSourceTouchInputSource。 动作层:统一输出 MoveAimAttackDownSkillDown。 上下文层:区分 GameplayUIDialogueCutscene,避免打开 UI 时角色还在移动。 配置层:支持改键、手柄映射、触屏布局、平台默认配置。 业务层:角色、战斗、UI 只依赖输入接口,不直接读 Input.GetKey

代码示例

c
using UnityEngine; // 引入 Unity 基础 API

public enum InputContext // 定义输入上下文
{ // 枚举开始
    Gameplay, // 玩法输入上下文
    UI // UI 输入上下文
} // 枚举结束

public struct InputFrame // 定义统一输入帧
{ // 结构体开始
    public Vector2 Move; // 移动方向
    public Vector2 Aim; // 瞄准方向
    public bool AttackDown; // 本帧是否按下攻击
    public bool SkillDown; // 本帧是否按下技能
} // 结构体结束

public interface IInputSource // 定义输入源接口
{ // 接口开始
    InputFrame Read(); // 读取当前设备输入并转换成统一输入帧
} // 接口结束

public sealed class KeyboardMouseInputSource : IInputSource // 定义键鼠输入源
{ // 类开始
    public InputFrame Read() // 读取键鼠输入
    { // 方法开始
        InputFrame frame = default; // 创建默认输入帧
        frame.Move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 读取 WASD 或方向键
        frame.Aim = new Vector2(Input.mousePosition.x, Input.mousePosition.y); // 读取鼠标屏幕位置作为瞄准输入
        frame.AttackDown = Input.GetMouseButtonDown(0); // 鼠标左键作为攻击输入
        frame.SkillDown = Input.GetKeyDown(KeyCode.Space); // 空格键作为技能输入
        if (frame.Move.sqrMagnitude > 1f) frame.Move.Normalize(); // 防止斜向移动速度更快
        return frame; // 返回统一输入帧
    } // 方法结束
} // 类结束

public sealed class InputService : MonoBehaviour // 定义输入服务
{ // 类开始
    public InputContext Context = InputContext.Gameplay; // 当前输入上下文
    public InputFrame Current { get; private set; } // 当前帧统一输入数据
    private IInputSource source; // 当前正在使用的输入源

    private void Awake() // 初始化时调用
    { // 方法开始
        source = new KeyboardMouseInputSource(); // 示例默认使用键鼠输入源
    } // 方法结束

    private void Update() // 每帧采样输入
    { // 方法开始
        if (Context == InputContext.Gameplay) // 如果当前是玩法上下文
        { // 判断开始
            Current = source.Read(); // 读取设备输入并输出统一动作
        } // 判断结束
        else // 如果当前不是玩法上下文
        { // else 开始
            Current = default; // 清空玩法输入,避免 UI 打开时角色还在响应
        } // else 结束
    } // 方法结束

    public void SetContext(InputContext context) // 切换输入上下文
    { // 方法开始
        Context = context; // 保存新的输入上下文
    } // 方法结束

    public void SetSource(IInputSource newSource) // 切换输入设备来源
    { // 方法开始
        source = newSource; // 替换当前输入源
    } // 方法结束
} // 类结束

面试总结

我会说:跨平台输入不要让业务里到处写平台判断,而是把输入抽象成统一动作。Unity 新输入系统的 InputActionActionMapBinding 很适合做这件事,但项目里最好再包一层自己的 InputService,这样以后换输入系统、支持手柄、触屏、改键、回放、联机输入同步都会更稳。

如果要支持手柄和触屏,你怎么设计输入层?

unity-gamepad-touch-input-layer

标准答案

如果要同时支持手柄和触屏,我会把输入层拆成:设备适配层、动作帧层、上下文层、业务消费层

手柄和触屏的输入方式完全不同:手柄有摇杆、扳机、按键、震动、断连重连;触屏有虚拟摇杆、技能按钮、多点触控、安全区、UI 穿透。但角色移动、技能释放、UI 操作不应该关心这些差异,它们只应该读取统一的 InputFrame

设计思路

手柄层:处理摇杆死区、灵敏度、按键映射、震动、断连重连。 触屏层:处理虚拟摇杆、技能按钮、滑屏瞄准、多点触控、手指 ID、安全区。 动作层:统一输出 MoveAimAttackDownSkillDownDodgeDown。 上下文层:区分 GameplayUIDialogue,避免打开 UI 时角色还响应移动。 配置层:支持手柄改键、触屏按钮布局、左手模式、不同平台默认配置。 业务层:角色、技能、网络、回放都只读统一动作帧。

核心原则

不要在角色代码里写:

c
Input.GetKey(...)
Input.GetTouch(...)
Gamepad.current...

而是统一成:

c
inputFrame.Move
inputFrame.AttackDown
inputFrame.SkillDown

这样以后加手柄、触屏、键鼠、回放、AI 接管、自动化测试都会容易很多。

示例代码

c
using UnityEngine; // 引入 Unity 基础 API
public enum InputScheme // 定义当前输入方案类型
{ // 枚举开始
    Gamepad, // 表示当前使用手柄输入
    Touch // 表示当前使用触屏输入
} // 枚举结束
public enum InputContext // 定义输入上下文
{ // 枚举开始
    Gameplay, // 表示玩法输入上下文
    UI // 表示 UI 输入上下文
} // 枚举结束
public struct PlayerInputFrame // 定义统一的玩家输入帧
{ // 结构体开始
    public Vector2 Move; // 保存移动方向
    public Vector2 Aim; // 保存瞄准方向
    public bool AttackDown; // 保存本帧是否按下攻击
    public bool SkillDown; // 保存本帧是否按下技能
    public bool DodgeDown; // 保存本帧是否按下闪避
} // 结构体结束
public interface IInputAdapter // 定义输入适配器接口
{ // 接口开始
    PlayerInputFrame Read(); // 读取当前设备输入并转换成统一动作帧
} // 接口结束
public sealed class TouchHudState : MonoBehaviour // 定义触屏 UI 输入状态
{ // 类开始
    public Vector2 Move; // 虚拟摇杆输出的移动方向
    public Vector2 Aim; // 滑屏或右侧摇杆输出的瞄准方向
    public bool AttackDown; // 攻击按钮本帧是否按下
    public bool SkillDown; // 技能按钮本帧是否按下
    public bool DodgeDown; // 闪避按钮本帧是否按下
} // 类结束
public sealed class GamepadInputAdapter : IInputAdapter // 定义手柄输入适配器
{ // 类开始
    private readonly float deadZone; // 保存摇杆死区阈值
    public GamepadInputAdapter(float deadZone) // 构造手柄输入适配器
    { // 构造函数开始
        this.deadZone = deadZone; // 保存外部传入的死区阈值
    } // 构造函数结束
    public PlayerInputFrame Read() // 读取手柄输入
    { // 方法开始
        PlayerInputFrame frame = default; // 创建默认输入帧
        frame.Move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 读取左摇杆移动方向
        if (frame.Move.magnitude < deadZone) frame.Move = Vector2.zero; // 小于死区就当作没有输入
        if (frame.Move.sqrMagnitude > 1f) frame.Move.Normalize(); // 防止斜向移动速度变快
        frame.Aim = new Vector2(Input.GetAxisRaw("RightStickX"), Input.GetAxisRaw("RightStickY")); // 读取右摇杆瞄准方向
        frame.AttackDown = Input.GetButtonDown("Fire1"); // 读取攻击按钮
        frame.SkillDown = Input.GetButtonDown("Fire2"); // 读取技能按钮
        frame.DodgeDown = Input.GetButtonDown("Jump"); // 读取闪避按钮
        return frame; // 返回统一输入帧
    } // 方法结束
} // 类结束
public sealed class TouchInputAdapter : IInputAdapter // 定义触屏输入适配器
{ // 类开始
    private readonly TouchHudState hud; // 保存触屏 HUD 状态引用
    public TouchInputAdapter(TouchHudState hud) // 构造触屏输入适配器
    { // 构造函数开始
        this.hud = hud; // 保存外部传入的触屏 HUD
    } // 构造函数结束
    public PlayerInputFrame Read() // 读取触屏输入
    { // 方法开始
        PlayerInputFrame frame = default; // 创建默认输入帧
        frame.Move = hud.Move; // 读取虚拟摇杆移动方向
        frame.Aim = hud.Aim; // 读取触屏瞄准方向
        frame.AttackDown = hud.AttackDown; // 读取触屏攻击按钮
        frame.SkillDown = hud.SkillDown; // 读取触屏技能按钮
        frame.DodgeDown = hud.DodgeDown; // 读取触屏闪避按钮
        return frame; // 返回统一输入帧
    } // 方法结束
} // 类结束
public sealed class PlayerInputService : MonoBehaviour // 定义玩家输入服务
{ // 类开始
    public TouchHudState TouchHud; // 保存触屏 HUD 引用
    public InputScheme Scheme = InputScheme.Touch; // 保存当前输入方案
    public InputContext Context = InputContext.Gameplay; // 保存当前输入上下文
    public PlayerInputFrame Current { get; private set; } // 暴露当前帧输入给业务层读取
    private IInputAdapter gamepadAdapter; // 保存手柄输入适配器
    private IInputAdapter touchAdapter; // 保存触屏输入适配器
    private void Awake() // 初始化输入服务
    { // 方法开始
        gamepadAdapter = new GamepadInputAdapter(0.2f); // 创建手柄适配器并设置摇杆死区
        touchAdapter = new TouchInputAdapter(TouchHud); // 创建触屏适配器并绑定 HUD
    } // 方法结束
    private void Update() // 每帧采样输入
    { // 方法开始
        if (Context != InputContext.Gameplay) // 如果当前不是玩法上下文
        { // 判断开始
            Current = default; // 清空输入,避免 UI 状态下角色误操作
            return; // 直接结束本帧输入采样
        } // 判断结束
        IInputAdapter adapter = Scheme == InputScheme.Gamepad ? gamepadAdapter : touchAdapter; // 根据当前方案选择适配器
        Current = adapter.Read(); // 读取统一输入帧
    } // 方法结束
    public void SwitchScheme(InputScheme scheme) // 切换输入方案
    { // 方法开始
        Scheme = scheme; // 保存新的输入方案
    } // 方法结束
    public void SwitchContext(InputContext context) // 切换输入上下文
    { // 方法开始
        Context = context; // 保存新的输入上下文
    } // 方法结束
} // 类结束

面试总结

我会说:手柄和触屏不能直接散落在角色、技能、UI 代码里,而是分别做 Adapter,最终统一成 InputFrame。手柄重点处理死区、按键映射、断连重连和震动;触屏重点处理虚拟摇杆、多点触控、UI 穿透、安全区和布局配置。业务层只消费动作数据,这样后续支持改键、回放、联机预测和自动化测试都会更清晰。

如果要做多人匹配,你怎么处理取消和超时?

unity-multiplayer-match-cancel-timeout

标准答案

多人匹配里的“取消”和“超时”不能只在客户端本地改 UI,核心应该交给服务端用一个 ticketId 管理。

一次匹配流程可以理解成:

c
Idle -> Requesting -> Matching -> Found -> Confirming -> EnteringRoom -> InRoom

取消和超时,本质都是对这个 ticketId 发起一次状态变更请求:

  • 客户端点取消:发送 Cancel(ticketId),服务端决定是否真的取消成功。
  • 客户端等待超时:先提示玩家,再向服务端查询或取消 ticket。
  • 服务端队列超时:按 deadline / TTL 清理匹配票据,防止玩家掉线后还留在队列里。
  • 如果“取消”和“匹配成功”同时发生,以服务端状态版本为准。

底层原理

最容易出问题的是竞态:

比如玩家刚点取消,同时服务器已经匹配成功并准备进房。 这时客户端不能直接认为“我取消了”,否则会出现:

  • UI 显示已取消,但服务端已经创建房间。
  • 玩家进房失败,但队友已经进房。
  • 旧消息晚到,把新状态覆盖掉。
  • 重复点击取消导致重复请求、重复回调。

所以工程上通常会加:

  • ticketId:标识这一次匹配。
  • state:当前匹配状态。
  • stateVersion:状态版本号,丢弃旧消息。
  • deadline:服务端超时清理时间。
  • 幂等取消:重复取消同一个 ticketId,返回同一个最终结果。

Unity / 游戏项目里怎么做

客户端负责表现和交互,服务端负责最终结果。

客户端可以立刻把按钮置灰、显示“正在取消”,但不能直接删除状态;要等服务端返回:

c
using System; // 引入 Guid,用来生成唯一 ticketId。 

public enum MatchState // 定义匹配状态枚举。 
{ // 枚举开始。 
    Idle, // 没有匹配。 
    Requesting, // 正在发送匹配请求。 
    Matching, // 已进入匹配队列。 
    Found, // 已经找到对局。 
    Confirming, // 正在等待玩家确认。 
    EnteringRoom, // 正在进入房间。 
    InRoom, // 已经进入房间。 
    Canceled, // 已取消。 
    Timeout, // 已超时。 
    Failed // 匹配失败。 
} // 枚举结束。 

public sealed class MatchClientController // 客户端匹配控制器。 
{ // 类开始。 
    private MatchState _state = MatchState.Idle; // 当前客户端显示的匹配状态。 
    private string _ticketId = ""; // 当前这次匹配的唯一票据。 
    private int _stateVersion = 0; // 服务端状态版本号,用来防止旧消息覆盖新消息。 
    private float _deadlineTime = 0f; // 客户端等待超时时间点。 

    public void StartMatch(string mode, float now) // 发起匹配。 
    { // 方法开始。 
        if (_state != MatchState.Idle) return; // 如果已经在匹配中,就不要重复发起。 
        _ticketId = Guid.NewGuid().ToString("N"); // 生成本次匹配的唯一 ticketId。 
        _stateVersion = 0; // 新 ticket 的版本号从 0 开始。 
        _deadlineTime = now + 30f; // 客户端最多等 30 秒再提示超时。 
        _state = MatchState.Requesting; // 本地先进入请求中状态。 
        SendStartToServer(_ticketId, mode); // 把 ticketId 和模式发送给服务端。 
    } // 方法结束。 

    public void CancelMatch() // 玩家点击取消匹配。 
    { // 方法开始。 
        if (_state != MatchState.Matching && _state != MatchState.Confirming) return; // 只有排队或确认中才允许取消。 
        SendCancelToServer(_ticketId); // 发送取消请求,不直接认定取消成功。 
    } // 方法结束。 

    public void Tick(float now) // 每帧或定时调用,用来检查客户端等待超时。 
    { // 方法开始。 
        if (_state != MatchState.Matching) return; // 只有匹配中才需要检查超时。 
        if (now < _deadlineTime) return; // 还没到超时时间就继续等待。 
        _state = MatchState.Timeout; // 本地先显示超时状态。 
        SendCancelToServer(_ticketId); // 同时请求服务端取消这个 ticket。 
    } // 方法结束。 

    public void OnServerState(string ticketId, MatchState serverState, int version) // 收到服务端状态同步。 
    { // 方法开始。 
        if (ticketId != _ticketId) return; // 不是当前 ticket 的消息就丢弃。 
        if (version < _stateVersion) return; // 旧版本消息不能覆盖新状态。 
        _stateVersion = version; // 记录最新版本号。 
        _state = serverState; // 以服务端状态为最终状态。 
    } // 方法结束。 

    private void SendStartToServer(string ticketId, string mode) { } // 这里封装网络发送匹配请求。 
    private void SendCancelToServer(string ticketId) { } // 这里封装网络发送取消请求。 
} // 类结束。

面试总结

我会回答:取消和超时都不能只靠客户端按钮状态处理,而是要把匹配抽象成服务端 ticket。客户端取消只是请求,服务端根据 ticket 当前状态决定是取消成功、已经匹配成功,还是已经进房。为了防止乱序和重复请求,要做幂等、状态机、版本号和超时清理。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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