Skip to content

题海扩展:游戏系统设计

设计一个技能编辑器

unity-skill-editor-design

标准答案

技能编辑器的核心是:把技能从硬编码变成数据驱动。策划在编辑器里配置技能 ID、CD、消耗、目标类型、动画时间轴、命中盒、特效、音效、Buff、镜头震动;程序在运行时只读取导出的稳定数据执行。

我会把它拆成 5 个模块:技能列表、属性面板、时间轴编辑器、预览窗口、校验导出器。

核心流程

创建技能 -> 配基础属性 -> 配时间轴事件 -> 预览表现 -> 自动校验 -> 导出运行时数据 -> 战斗系统读取播放

时间轴里常见轨道有:

Animation:播放哪段攻击动画。 HitBox:第几帧开启攻击判定。 VFX:第几帧生成特效。 SFX:第几帧播放音效。 Buff:命中后加减 Buff。 CameraShake:命中瞬间震屏。 Move:冲刺、位移、击退。

底层设计

编辑器本身只负责编辑和校验,运行时不要依赖 UnityEditor。所以我会把数据分成两份:

编辑期数据:方便可视化编辑,可以用 ScriptableObject运行时数据:导出成 JSON、二进制或配置表,用于热更新和快速读取。

这样做的好处是:策划能自己调技能,程序只维护统一的技能播放器,后续新增技能不需要频繁改代码。

代码示例

c
using System; // 引入 Serializable 特性。 
using System.Collections.Generic; // 引入 List 容器。 
using UnityEngine; // 引入 Unity 基础类型。 
#if UNITY_EDITOR // 判断当前是否在 Unity 编辑器环境。 
using UnityEditor; // 引入 Unity 编辑器扩展 API。 
#endif // 结束编辑器环境判断。 
public enum SkillEventType { Animation, HitBox, VFX, SFX, Buff } // 定义技能时间轴事件类型。 
[Serializable] // 允许 SkillEvent 被 Unity 序列化。 
public sealed class SkillEvent // 定义单个技能事件。 
{ // SkillEvent 类开始。 
    public float time; // 事件触发时间,例如第 0.35 秒。 
    public SkillEventType type; // 事件类型,例如命中盒或特效。 
    public string assetKey; // 资源 key,例如特效 Addressables 地址。 
    public float radius; // 命中范围半径。 
} // SkillEvent 类结束。 
[CreateAssetMenu(menuName = "Game/Skill Config")] // 在 Unity 菜单中创建技能配置资产。 
public sealed class SkillConfig : ScriptableObject // 定义技能配置资产。 
{ // SkillConfig 类开始。 
    public int id; // 技能唯一 ID。 
    public string skillName; // 技能名字。 
    public float cooldown; // 技能冷却时间。 
    public List<SkillEvent> events = new List<SkillEvent>(); // 保存技能时间轴事件。 
} // SkillConfig 类结束。 
#if UNITY_EDITOR // 以下代码只在编辑器中编译。 
public sealed class SkillEditorWindow : EditorWindow // 定义技能编辑器窗口。 
{ // 窗口类开始。 
    private SkillConfig current; // 当前正在编辑的技能配置。 
    [MenuItem("Tools/Skill Editor")] // 给 Unity 添加菜单入口。 
    private static void Open() // 定义打开窗口的方法。 
    { // Open 方法开始。 
        GetWindow<SkillEditorWindow>("Skill Editor"); // 打开技能编辑器窗口。 
    } // Open 方法结束。 
    private void OnGUI() // 绘制编辑器窗口界面。 
    { // OnGUI 方法开始。 
        current = (SkillConfig)EditorGUILayout.ObjectField("Skill", current, typeof(SkillConfig), false); // 选择技能配置。 
        if (current == null) return; // 如果没有选择配置就直接返回。 
        current.id = EditorGUILayout.IntField("Id", current.id); // 绘制技能 ID 输入框。 
        current.skillName = EditorGUILayout.TextField("Name", current.skillName); // 绘制技能名输入框。 
        current.cooldown = EditorGUILayout.FloatField("Cooldown", current.cooldown); // 绘制冷却时间输入框。 
        if (GUILayout.Button("Validate")) ValidateSkill(); // 点击按钮时执行校验。 
        EditorUtility.SetDirty(current); // 标记配置已修改,方便 Unity 保存。 
    } // OnGUI 方法结束。 
    private void ValidateSkill() // 定义技能配置校验方法。 
    { // ValidateSkill 方法开始。 
        if (current.id <= 0) Debug.LogError("技能 ID 必须大于 0"); // 校验技能 ID 是否合法。 
        if (current.cooldown < 0) Debug.LogError("技能 CD 不能小于 0"); // 校验冷却时间是否非法。 
        if (current.events == null) Debug.LogError("技能事件列表不能为空"); // 校验事件列表是否为空。 
    } // ValidateSkill 方法结束。 
} // 窗口类结束。 
#endif // 结束编辑器专属代码。

面试加分点

我会强调:技能编辑器必须有自动校验,比如 ID 重复、资源 key 为空、命中帧早于前摇、特效资源丢失、Buff ID 不存在。否则工具只是“能填表”,不是生产级工具。

如果项目支持热更新,导出格式还要考虑版本兼容,比如旧客户端不认识新字段时要有默认值。

设计一个子弹弹道系统

unity-projectile-trajectory-system

标准答案

子弹弹道系统的核心是:配置驱动 + 轨迹求解 + 碰撞检测 + 命中结算 + 对象池回收

我会把系统拆成这些模块:

BulletConfig:配置速度、寿命、半径、轨迹类型、碰撞层、命中特效。 BulletFactory:负责从对象池取子弹并初始化。 Trajectory:负责计算子弹每帧位置。 Collision:负责 Raycast、SphereCast、Overlap 检测。 HitResolver:负责伤害、Buff、击退、表现事件。 BulletManager:统一 Tick 和回收大量子弹。 ObjectPool:减少频繁 Instantiate / Destroy 和 GC。

底层原理

子弹本质上是一个随时间变化的位置函数:

c
position = f(time)

直线弹道就是:

c
position = start + direction * speed * time

抛物线弹道会加重力:

c
position = start + velocity * time + 0.5 * gravity * time * time

追踪弹道则是每帧朝目标方向旋转或修正速度。

碰撞上要注意:高速子弹不能只靠 OnTriggerEnter,因为一帧飞太远会穿透。更稳的是用“上一帧位置到当前帧位置”的 RaycastSphereCast

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
public enum BulletMoveType { Straight, Homing } // 定义子弹轨迹类型。
[System.Serializable] // 允许配置类被 Unity 序列化。
public sealed class BulletConfig // 定义子弹配置数据。
{ // 配置类开始。
    public float speed = 10f; // 子弹移动速度。
    public float lifeTime = 3f; // 子弹最大存活时间。
    public float radius = 0.2f; // 子弹碰撞半径。
    public int damage = 10; // 子弹命中造成的伤害。
    public LayerMask hitMask; // 子弹可以命中的 Layer。
    public BulletMoveType moveType = BulletMoveType.Straight; // 子弹轨迹类型。
} // 配置类结束。
public sealed class Bullet : MonoBehaviour // 定义子弹运行时对象。
{ // 子弹类开始。
    private BulletConfig config; // 保存当前子弹使用的配置。
    private Vector3 direction; // 保存当前飞行方向。
    private Transform target; // 保存追踪目标。
    private float aliveTime; // 保存已经存活的时间。
    private Vector3 lastPosition; // 保存上一帧位置,用来防穿透检测。
    private System.Action<Bullet> recycle; // 保存回收回调。
    public void Init(BulletConfig cfg, Vector3 pos, Vector3 dir, Transform hitTarget, System.Action<Bullet> onRecycle) // 初始化子弹。
    { // Init 方法开始。
        config = cfg; // 保存配置。
        transform.position = pos; // 设置初始位置。
        direction = dir.normalized; // 保存归一化方向。
        target = hitTarget; // 保存追踪目标。
        aliveTime = 0f; // 重置存活时间。
        lastPosition = pos; // 重置上一帧位置。
        recycle = onRecycle; // 保存回收回调。
        gameObject.SetActive(true); // 激活子弹对象。
    } // Init 方法结束。
    private void Update() // Unity 每帧调用。
    { // Update 方法开始。
        float dt = Time.deltaTime; // 获取当前帧耗时。
        aliveTime += dt; // 累加存活时间。
        if (aliveTime >= config.lifeTime) // 判断是否超过寿命。
        { // 超时分支开始。
            Recycle(); // 回收子弹。
            return; // 结束本帧逻辑。
        } // 超时分支结束。
        lastPosition = transform.position; // 记录移动前的位置。
        UpdateDirection(dt); // 根据弹道类型更新方向。
        Vector3 nextPosition = transform.position + direction * config.speed * dt; // 计算下一帧位置。
        if (Physics.SphereCast(lastPosition, config.radius, direction, out RaycastHit hit, Vector3.Distance(lastPosition, nextPosition), config.hitMask)) // 从上一帧到下一帧做球形射线检测。
        { // 命中分支开始。
            OnHit(hit.collider); // 处理命中逻辑。
            Recycle(); // 命中后回收子弹。
            return; // 结束本帧逻辑。
        } // 命中分支结束。
        transform.position = nextPosition; // 没命中则更新位置。
    } // Update 方法结束。
    private void UpdateDirection(float dt) // 根据轨迹类型更新方向。
    { // UpdateDirection 方法开始。
        if (config.moveType != BulletMoveType.Homing) // 如果不是追踪弹。
        { // 非追踪分支开始。
            return; // 保持原方向。
        } // 非追踪分支结束。
        if (target == null) // 如果目标已经不存在。
        { // 目标丢失分支开始。
            return; // 保持当前方向继续飞。
        } // 目标丢失分支结束。
        Vector3 toTarget = (target.position - transform.position).normalized; // 计算指向目标的方向。
        direction = Vector3.Slerp(direction, toTarget, dt * 5f).normalized; // 平滑转向目标方向。
    } // UpdateDirection 方法结束。
    private void OnHit(Collider other) // 处理命中对象。
    { // OnHit 方法开始。
        Debug.Log($"Hit {other.name}, damage = {config.damage}"); // 输出命中信息,真实项目这里走伤害系统。
    } // OnHit 方法结束。
    private void Recycle() // 回收子弹对象。
    { // Recycle 方法开始。
        gameObject.SetActive(false); // 隐藏子弹对象。
        recycle?.Invoke(this); // 通知对象池回收。
    } // Recycle 方法结束。
} // 子弹类结束。

Unity 工程实践

少量子弹可以每个子弹自己 Update;大量弹幕时我会改成 BulletManager 统一 Tick,减少 MonoBehaviour 调用开销。碰撞检测尽量用 RaycastNonAllocOverlapSphereNonAlloc,大量目标时先用格子、四叉树、BVH 做 broad phase 粗筛。

联机项目里,客户端可以做子弹表现和预测,但最终命中最好以服务端为准,避免外挂改伤害、改速度、改命中。

常见坑

高速子弹穿透:用上一帧到当前帧的 Raycast / SphereCast。 回收后状态没清理:方向、目标、命中特效、回调都要重置。 每颗子弹 Instantiate:改对象池。 所有子弹都物理刚体:大量弹幕会让 Physics.Simulate 压力很大。 客户端直接判伤害:容易被外挂篡改。

设计一个连招系统

unity-combo-system-design

标准答案

连招系统的核心是:输入缓冲 + 连招窗口 + 动画同步 + 状态机 + 打断规则

玩家按键后,不是立刻无脑切下一段,而是先进入 InputBuffer。当前攻击动画播放到允许接招的时间段,也就是 Combo Window,系统才会消费缓存输入,并根据配置切到下一段攻击。

核心模块

ComboConfig:配置每段攻击能接哪一段、接招窗口、伤害、动画名。 InputBuffer:玩家提前按键时缓存一小段时间,提升手感。 ComboWindow:只有窗口打开时才能连下一段,避免无限取消。 StateMachine:管理 Idle / Attack / Recovery / Hit / DeadCancelRule:处理受击、闪避、死亡、霸体、技能打断。 AnimatorSync:用 normalizedTime 或动画事件同步命中帧和接招帧。

底层原理

连招本质是一个“带时间窗口的状态切换”:

Attack_01 播放中 -> 进入接招窗口 -> 检查缓存输入 -> 匹配下一段 -> 切 Attack_02

如果按太早:进入输入缓冲。 如果按得刚好:立刻接下一段。 如果按太晚:回到 Idle 或 Recovery。 如果被打断:清空输入缓冲,进入受击状态。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
public enum ComboInput { Light, Heavy } // 定义连招输入类型。
[System.Serializable] // 允许 Unity 在 Inspector 中序列化该类。
public sealed class ComboStep // 定义一段连招配置。
{ // ComboStep 类开始。
    public string animatorState; // 当前段攻击对应的 Animator 状态名。
    public float duration = 0.8f; // 当前段攻击总持续时间。
    public float comboStart = 0.35f; // 允许接下一段的开始时间。
    public float comboEnd = 0.65f; // 允许接下一段的结束时间。
    public int nextLight = -1; // 轻攻击输入对应的下一段索引。
    public int nextHeavy = -1; // 重攻击输入对应的下一段索引。
} // ComboStep 类结束。
public sealed class ComboController : MonoBehaviour // 定义连招控制器。
{ // ComboController 类开始。
    [SerializeField] private Animator animator; // 保存 Animator 引用。
    [SerializeField] private ComboStep[] steps; // 保存所有连招段配置。
    [SerializeField] private float bufferTime = 0.2f; // 保存输入缓冲时间。
    private int currentIndex = -1; // 保存当前连招段索引。
    private float attackTimer = 0f; // 保存当前攻击已播放时间。
    private float bufferTimer = 0f; // 保存输入缓冲剩余时间。
    private bool isAttacking = false; // 记录当前是否正在攻击。
    private bool hasBufferedInput = false; // 记录当前是否有缓存输入。
    private ComboInput bufferedInput = ComboInput.Light; // 保存最近一次缓存输入。
    private void Update() // Unity 每帧调用。
    { // Update 方法开始。
        ReadInput(); // 读取玩家输入。
        TickBuffer(); // 更新输入缓冲生命周期。
        TickCombo(); // 更新连招状态。
    } // Update 方法结束。
    private void ReadInput() // 读取输入。
    { // ReadInput 方法开始。
        if (Input.GetKeyDown(KeyCode.J)) // 判断是否按下轻攻击键。
        { // 轻攻击分支开始。
            BufferInput(ComboInput.Light); // 缓存轻攻击输入。
        } // 轻攻击分支结束。
        if (Input.GetKeyDown(KeyCode.K)) // 判断是否按下重攻击键。
        { // 重攻击分支开始。
            BufferInput(ComboInput.Heavy); // 缓存重攻击输入。
        } // 重攻击分支结束。
    } // ReadInput 方法结束。
    private void BufferInput(ComboInput input) // 缓存一次输入。
    { // BufferInput 方法开始。
        bufferedInput = input; // 记录输入类型。
        hasBufferedInput = true; // 标记当前存在缓存输入。
        bufferTimer = bufferTime; // 重置输入缓存时间。
        if (!isAttacking) // 如果当前没有攻击。
        { // 空闲分支开始。
            StartStep(0); // 从第一段攻击开始。
        } // 空闲分支结束。
    } // BufferInput 方法结束。
    private void TickBuffer() // 更新输入缓冲。
    { // TickBuffer 方法开始。
        if (!hasBufferedInput) // 如果没有缓存输入。
        { // 无缓存分支开始。
            return; // 直接返回。
        } // 无缓存分支结束。
        bufferTimer -= Time.deltaTime; // 扣除本帧时间。
        if (bufferTimer <= 0f) // 判断缓存是否过期。
        { // 过期分支开始。
            hasBufferedInput = false; // 清除缓存输入标记。
        } // 过期分支结束。
    } // TickBuffer 方法结束。
    private void TickCombo() // 更新连招。
    { // TickCombo 方法开始。
        if (!isAttacking) // 如果当前不在攻击。
        { // 非攻击分支开始。
            return; // 直接返回。
        } // 非攻击分支结束。
        attackTimer += Time.deltaTime; // 累加当前攻击时间。
        ComboStep step = steps[currentIndex]; // 取得当前段配置。
        if (IsInComboWindow(step) && hasBufferedInput) // 如果进入接招窗口并且有缓存输入。
        { // 接招分支开始。
            int next = GetNextIndex(step, bufferedInput); // 根据输入查询下一段索引。
            if (next >= 0) // 如果存在下一段。
            { // 有下一段分支开始。
                hasBufferedInput = false; // 消费缓存输入。
                StartStep(next); // 切到下一段攻击。
                return; // 结束本帧逻辑。
            } // 有下一段分支结束。
        } // 接招分支结束。
        if (attackTimer >= step.duration) // 如果当前攻击播放结束。
        { // 结束分支开始。
            EndCombo(); // 结束连招。
        } // 结束分支结束。
    } // TickCombo 方法结束。
    private bool IsInComboWindow(ComboStep step) // 判断是否处于接招窗口。
    { // IsInComboWindow 方法开始。
        return attackTimer >= step.comboStart && attackTimer <= step.comboEnd; // 返回当前时间是否落在窗口内。
    } // IsInComboWindow 方法结束。
    private int GetNextIndex(ComboStep step, ComboInput input) // 根据输入获取下一段。
    { // GetNextIndex 方法开始。
        return input == ComboInput.Light ? step.nextLight : step.nextHeavy; // 轻攻击走轻分支,重攻击走重分支。
    } // GetNextIndex 方法结束。
    private void StartStep(int index) // 开始某一段连招。
    { // StartStep 方法开始。
        currentIndex = index; // 保存当前段索引。
        attackTimer = 0f; // 重置当前攻击时间。
        isAttacking = true; // 标记进入攻击状态。
        animator.Play(steps[index].animatorState); // 播放对应攻击动画。
    } // StartStep 方法结束。
    private void EndCombo() // 结束连招。
    { // EndCombo 方法开始。
        currentIndex = -1; // 清空当前连招段。
        attackTimer = 0f; // 清空攻击时间。
        isAttacking = false; // 标记退出攻击状态。
        hasBufferedInput = false; // 清空缓存输入。
    } // EndCombo 方法结束。
    public void Interrupt() // 外部调用的打断接口。
    { // Interrupt 方法开始。
        EndCombo(); // 清空连招状态。
        animator.Play("Hit"); // 播放受击动画。
    } // Interrupt 方法结束。
} // ComboController 类结束。

Unity 工程实践

我不会把连招逻辑完全写在动画事件里。动画事件适合通知“命中帧到了”“特效该播了”,但能不能接下一段、接哪一段、是否能被打断,最好由配置和状态机控制。

如果是联机战斗,客户端可以预测播放连招表现,但服务端要校验当前状态、输入时间、CD、硬直、距离和是否被打断,防止玩家篡改连招速度或跳段攻击。

常见坑

只靠动画事件驱动:改动画就可能改坏逻辑。 没有输入缓冲:玩家稍微按早就接不上,手感很硬。 没有连招窗口:攻击可以随时取消,动作没有重量。 切状态不清缓存:受击后旧输入又触发攻击。 配置写死代码:新增武器和分支连招时非常难维护。

设计一个锁定系统

unity-target-lock-system-design

标准答案

锁定系统的核心是:候选收集 + 合法过滤 + 评分排序 + 当前目标状态 + 切换和丢失规则

不是简单找最近敌人。动作游戏里更常用“屏幕中心优先”,因为玩家通常想锁定自己正在看的目标,而不是最近但在侧后方的目标。

核心流程

按下锁定键 -> 收集范围内敌人 -> 过滤死亡/超距/背后/遮挡 -> 计算评分 -> 选最高分 -> 相机、UI、技能共用该目标

评分可以考虑:

屏幕中心距离:越靠准星或屏幕中心,优先级越高。 世界距离:越近越容易锁。 朝向角度:越在角色正前方越容易锁。 特殊权重:Boss、攻击玩家的敌人可以加权。 遮挡检测:墙后目标可以降低分数或直接过滤。

代码示例

c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class TargetLockSystem : MonoBehaviour // 定义目标锁定系统。
{ // 类开始。
    [SerializeField] private Camera mainCamera; // 保存主相机引用。
    [SerializeField] private Transform owner; // 保存玩家或施法者 Transform。
    [SerializeField] private LayerMask targetMask; // 保存可锁定目标 Layer。
    [SerializeField] private LayerMask obstacleMask; // 保存遮挡物 Layer。
    [SerializeField] private float lockRadius = 12f; // 保存最大锁定半径。
    [SerializeField] private float maxAngle = 80f; // 保存最大锁定角度。
    [SerializeField] private Transform currentTarget; // 保存当前锁定目标。
    private readonly Collider[] hits = new Collider[32]; // 复用碰撞数组,避免 GC。
    private void Update() // Unity 每帧调用。
    { // Update 方法开始。
        if (Input.GetKeyDown(KeyCode.Tab)) // 判断是否按下锁定键。
        { // if 开始。
            TryLockBestTarget(); // 尝试锁定评分最高的目标。
        } // if 结束。
        if (Input.GetKeyDown(KeyCode.Escape)) // 判断是否按下取消键。
        { // if 开始。
            Unlock(); // 取消当前锁定目标。
        } // if 结束。
        if (currentTarget != null && !IsValidTarget(currentTarget)) // 判断当前目标是否已经失效。
        { // if 开始。
            Unlock(); // 目标死亡、超距或遮挡时丢锁。
        } // if 结束。
    } // Update 方法结束。
    private void TryLockBestTarget() // 尝试锁定最佳目标。
    { // 方法开始。
        int count = Physics.OverlapSphereNonAlloc(owner.position, lockRadius, hits, targetMask); // 收集范围内候选目标。
        Transform best = null; // 保存当前最佳目标。
        float bestScore = float.MinValue; // 保存当前最高评分。
        for (int i = 0; i < count; i++) // 遍历所有候选碰撞体。
        { // 循环开始。
            Transform target = hits[i].transform; // 获取候选目标 Transform。
            if (!IsValidTarget(target)) // 判断目标是否合法。
            { // if 开始。
                continue; // 不合法则跳过。
            } // if 结束。
            float score = CalculateScore(target); // 计算目标评分。
            if (score > bestScore) // 判断该目标是否更优。
            { // if 开始。
                bestScore = score; // 更新最高评分。
                best = target; // 更新最佳目标。
            } // if 结束。
        } // 循环结束。
        currentTarget = best; // 保存最终锁定目标。
    } // 方法结束。
    private bool IsValidTarget(Transform target) // 判断目标是否合法。
    { // 方法开始。
        if (target == null) // 判断目标是否为空。
        { // if 开始。
            return false; // 空目标不可锁定。
        } // if 结束。
        Vector3 toTarget = target.position - owner.position; // 计算玩家到目标的方向。
        if (toTarget.sqrMagnitude > lockRadius * lockRadius) // 判断目标是否超出锁定范围。
        { // if 开始。
            return false; // 超出范围不可锁定。
        } // if 结束。
        if (Vector3.Angle(owner.forward, toTarget) > maxAngle) // 判断目标是否在角色视角范围内。
        { // if 开始。
            return false; // 角度太大不可锁定。
        } // if 结束。
        if (Physics.Linecast(owner.position + Vector3.up, target.position + Vector3.up, obstacleMask)) // 判断目标是否被墙体遮挡。
        { // if 开始。
            return false; // 被遮挡时不可锁定。
        } // if 结束。
        return true; // 所有条件通过则目标合法。
    } // 方法结束。
    private float CalculateScore(Transform target) // 计算目标锁定评分。
    { // 方法开始。
        Vector3 viewport = mainCamera.WorldToViewportPoint(target.position); // 把目标世界坐标转成屏幕视口坐标。
        float centerDistance = Vector2.Distance(new Vector2(viewport.x, viewport.y), new Vector2(0.5f, 0.5f)); // 计算目标离屏幕中心的距离。
        float worldDistance = Vector3.Distance(owner.position, target.position); // 计算玩家到目标的世界距离。
        float centerScore = 1f - centerDistance; // 屏幕越靠中心分数越高。
        float distanceScore = 1f - Mathf.Clamp01(worldDistance / lockRadius); // 距离越近分数越高。
        return centerScore * 0.7f + distanceScore * 0.3f; // 返回综合评分。
    } // 方法结束。
    private void Unlock() // 取消锁定。
    { // 方法开始。
        currentTarget = null; // 清空当前锁定目标。
    } // 方法结束。
    public Transform GetCurrentTarget() // 给技能、相机和 UI 读取当前目标。
    { // 方法开始。
        return currentTarget; // 返回当前锁定目标。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会让相机、技能释放、UI 标记都读同一个 currentTarget。相机负责看向目标,角色移动负责朝向目标,技能系统负责默认选中目标,UI 负责显示锁定框。

大量怪物时,不建议每帧全量 OverlapSphere。可以在按锁定键时搜索,或者低频刷新候选列表,也可以让战斗管理器维护附近敌人列表。

常见坑

只按距离选目标:玩家看着正前方敌人,系统却锁到侧后方近目标。 目标死亡不清理:技能继续打空对象。 遮挡不处理:隔墙锁怪,体验很怪。 UI 和技能各自找目标:相机锁 A,技能打 B,表现不一致。 候选每帧全量搜索:怪物多时性能浪费。

设计一个仇恨系统

unity-threat-aggro-system-design

标准答案

仇恨系统的核心是:怪物维护一张仇恨表,谁的仇恨值最高,怪物就优先攻击谁

但真实项目里不能只写“最后谁打我就追谁”,而是要综合:

伤害仇恨:玩家造成伤害越高,仇恨越高。 治疗仇恨:治疗队友也可能让怪物注意到治疗者。 嘲讽仇恨:短时间强制怪物攻击某个目标。 距离权重:太远的目标降低仇恨或脱战清理。 时间衰减:长时间没交互,仇恨逐渐下降。 切换阈值:新目标仇恨必须明显更高,避免怪物频繁左右摇摆。

底层原理

每个怪物可以有一张 ThreatTable

玩家 A -> 120 仇恨玩家 B -> 80 仇恨治疗 C -> 150 仇恨

AI 每隔一段时间从仇恨表里选择最高且合法的目标,然后交给行为树或状态机执行追击、攻击、释放技能。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 容器。
using UnityEngine; // 引入 Unity 基础类型。
public sealed class ThreatSystem : MonoBehaviour // 定义怪物仇恨系统。
{ // 类开始。
    [SerializeField] private float switchThreshold = 1.2f; // 新目标仇恨超过当前目标 1.2 倍才切换。
    [SerializeField] private float decayPerSecond = 2f; // 每秒仇恨衰减值。
    [SerializeField] private float maxTargetDistance = 20f; // 最大追击距离。
    private readonly Dictionary<Transform, float> threatTable = new Dictionary<Transform, float>(); // 保存目标和仇恨值。
    private Transform currentTarget; // 保存当前攻击目标。
    private void Update() // Unity 每帧调用。
    { // Update 方法开始。
        DecayThreat(Time.deltaTime); // 每帧衰减仇恨。
        CleanInvalidTargets(); // 清理死亡、为空或超距目标。
        SelectTarget(); // 根据仇恨表选择目标。
    } // Update 方法结束。
    public void AddDamageThreat(Transform attacker, float damage) // 添加伤害仇恨。
    { // 方法开始。
        AddThreat(attacker, damage * 1f); // 伤害仇恨通常等于伤害值乘倍率。
    } // 方法结束。
    public void AddHealThreat(Transform healer, float healValue) // 添加治疗仇恨。
    { // 方法开始。
        AddThreat(healer, healValue * 0.5f); // 治疗仇恨通常低于伤害仇恨。
    } // 方法结束。
    public void AddTauntThreat(Transform taunter, float value) // 添加嘲讽仇恨。
    { // 方法开始。
        AddThreat(taunter, value); // 先给嘲讽者增加大量仇恨。
        currentTarget = taunter; // 再强制当前目标指向嘲讽者。
    } // 方法结束。
    private void AddThreat(Transform target, float value) // 通用增加仇恨方法。
    { // 方法开始。
        if (target == null) // 判断目标是否为空。
        { // if 开始。
            return; // 空目标不处理。
        } // if 结束。
        if (!threatTable.ContainsKey(target)) // 判断表里是否还没有该目标。
        { // if 开始。
            threatTable.Add(target, 0f); // 初始化该目标仇恨值。
        } // if 结束。
        threatTable[target] += value; // 累加仇恨值。
    } // 方法结束。
    private void DecayThreat(float deltaTime) // 衰减仇恨。
    { // 方法开始。
        List<Transform> keys = new List<Transform>(threatTable.Keys); // 复制 key 列表,避免遍历中修改字典。
        for (int i = 0; i < keys.Count; i++) // 遍历所有目标。
        { // 循环开始。
            Transform target = keys[i]; // 取出当前目标。
            threatTable[target] -= decayPerSecond * deltaTime; // 按时间减少仇恨。
            if (threatTable[target] <= 0f) // 判断仇恨是否已经归零。
            { // if 开始。
                threatTable.Remove(target); // 移除无仇恨目标。
            } // if 结束。
        } // 循环结束。
    } // 方法结束。
    private void CleanInvalidTargets() // 清理无效目标。
    { // 方法开始。
        List<Transform> keys = new List<Transform>(threatTable.Keys); // 复制 key 列表,避免遍历中删除。
        for (int i = 0; i < keys.Count; i++) // 遍历所有目标。
        { // 循环开始。
            Transform target = keys[i]; // 获取当前目标。
            if (target == null) // 判断目标是否被销毁。
            { // if 开始。
                threatTable.Remove(target); // 移除空目标。
                continue; // 继续处理下一个目标。
            } // if 结束。
            float distance = Vector3.Distance(transform.position, target.position); // 计算怪物到目标的距离。
            if (distance > maxTargetDistance) // 判断是否超出追击距离。
            { // if 开始。
                threatTable.Remove(target); // 超距目标从仇恨表移除。
            } // if 结束。
        } // 循环结束。
        if (currentTarget != null && !threatTable.ContainsKey(currentTarget)) // 判断当前目标是否已被清理。
        { // if 开始。
            currentTarget = null; // 清空当前目标。
        } // if 结束。
    } // 方法结束。
    private void SelectTarget() // 选择当前攻击目标。
    { // 方法开始。
        Transform bestTarget = null; // 保存最高仇恨目标。
        float bestThreat = float.MinValue; // 保存最高仇恨值。
        foreach (KeyValuePair<Transform, float> pair in threatTable) // 遍历仇恨表。
        { // 循环开始。
            if (pair.Value > bestThreat) // 判断该目标仇恨是否更高。
            { // if 开始。
                bestThreat = pair.Value; // 更新最高仇恨。
                bestTarget = pair.Key; // 更新最高仇恨目标。
            } // if 结束。
        } // 循环结束。
        if (bestTarget == null) // 判断是否没有可用目标。
        { // if 开始。
            currentTarget = null; // 没目标时清空当前目标。
            return; // 结束选择。
        } // if 结束。
        if (currentTarget == null) // 判断当前是否没有目标。
        { // if 开始。
            currentTarget = bestTarget; // 直接锁定最高仇恨目标。
            return; // 结束选择。
        } // if 结束。
        float currentThreat = threatTable.TryGetValue(currentTarget, out float value) ? value : 0f; // 读取当前目标仇恨值。
        if (bestTarget != currentTarget && bestThreat > currentThreat * switchThreshold) // 判断新目标是否明显更高。
        { // if 开始。
            currentTarget = bestTarget; // 切换到新的高仇恨目标。
        } // if 结束。
    } // 方法结束。
    public Transform GetCurrentTarget() // 对外提供当前目标。
    { // 方法开始。
        return currentTarget; // 返回当前攻击目标。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会让伤害系统、治疗系统、Buff 系统都通过事件通知仇恨系统,比如 OnDamageOnHealOnTaunt。AI 状态机或行为树只读取 GetCurrentTarget(),不直接计算仇恨。

普通小怪可以简化成“攻击者 + 距离”规则;Boss、多人副本、坦克/治疗/输出职业明确的项目,就需要完整仇恨表、嘲讽覆盖、脱战重置和调试面板。

常见坑

只追最后攻击者:治疗、嘲讽、持续伤害都不好处理。 没有切换阈值:怪物会在多个玩家之间频繁摇摆。 目标死亡不清表:AI 继续追空对象。 仇恨不衰减:脱战后仍然追很远。 仇恨逻辑写在 AI 行为里:后续很难扩展和调试。

设计一个怪物刷新系统

unity-monster-spawn-system-design

标准答案

怪物刷新系统的核心是:配置表 + 刷新点 + 调度器 + 对象池 + 数量上限 + 死亡回收

它不是简单 Instantiate 怪物,而是由配置决定:在哪里刷、刷什么、刷多少、什么时候刷、是否波次刷新、死亡后多久再刷、最多同时存在多少只。

核心模块

SpawnConfig:怪物 ID、刷新数量、间隔、最大存活数、波次配置。 SpawnPoint:出生点位置、随机半径、朝向。 SpawnManager:统一调度刷新逻辑。 ObjectPool:复用怪物对象,减少 Instantiate / Destroy。 MonsterLifecycle:怪物死亡、脱战、切场景时通知回收。 TriggerCondition:玩家靠近、任务触发、副本阶段、时间触发。

底层原理

运行时流程一般是:

加载场景 -> 读取刷怪配置 -> 注册刷新点 -> 检查刷新条件 -> 从对象池取怪 -> 初始化属性和 AI -> 怪物死亡 -> 回收 -> 等待下一轮刷新

重点是要控制 maxAlive,否则玩家不打怪、系统一直刷,就会导致怪物数量爆炸。

代码示例

c
using System.Collections.Generic; // 引入 Queue 和 List 容器。
using UnityEngine; // 引入 Unity 基础类型。
[System.Serializable] // 让刷新规则可以在 Inspector 中显示。
public sealed class SpawnRule // 定义单条刷怪规则。
{ // SpawnRule 类开始。
    public GameObject prefab; // 怪物预制体。
    public int maxAlive = 5; // 当前刷新点最多同时存在的怪物数量。
    public float interval = 3f; // 每次尝试刷新之间的间隔。
} // SpawnRule 类结束。
public sealed class MonsterSpawnPoint : MonoBehaviour // 定义怪物刷新点。
{ // MonsterSpawnPoint 类开始。
    public float radius = 3f; // 出生点随机半径。
    public Vector3 GetRandomPosition() // 获取一个随机出生位置。
    { // 方法开始。
        Vector2 circle = Random.insideUnitCircle * radius; // 在圆形范围内取随机点。
        return transform.position + new Vector3(circle.x, 0f, circle.y); // 返回世界坐标位置。
    } // 方法结束。
} // MonsterSpawnPoint 类结束。
public sealed class MonsterSpawnManager : MonoBehaviour // 定义刷怪管理器。
{ // MonsterSpawnManager 类开始。
    [SerializeField] private SpawnRule rule; // 保存刷怪规则。
    [SerializeField] private MonsterSpawnPoint point; // 保存刷怪点。
    private readonly Queue<GameObject> pool = new Queue<GameObject>(); // 保存怪物对象池。
    private readonly List<GameObject> alive = new List<GameObject>(); // 保存当前存活怪物。
    private float timer; // 保存刷新计时器。
    private void Update() // Unity 每帧调用。
    { // Update 方法开始。
        timer += Time.deltaTime; // 累加刷新计时。
        if (timer < rule.interval) return; // 间隔未到就不刷新。
        timer = 0f; // 重置计时器。
        TrySpawn(); // 尝试刷新怪物。
    } // Update 方法结束。
    private void TrySpawn() // 尝试生成怪物。
    { // TrySpawn 方法开始。
        alive.RemoveAll(monster => monster == null || !monster.activeSelf); // 清理已经死亡或回收的怪物记录。
        if (alive.Count >= rule.maxAlive) return; // 达到最大存活数量就不再刷新。
        GameObject monster = GetFromPool(); // 从对象池取一个怪物。
        monster.transform.position = point.GetRandomPosition(); // 设置怪物出生位置。
        monster.transform.rotation = point.transform.rotation; // 设置怪物出生朝向。
        monster.SetActive(true); // 激活怪物对象。
        alive.Add(monster); // 记录为存活怪物。
    } // TrySpawn 方法结束。
    private GameObject GetFromPool() // 从对象池获取怪物。
    { // GetFromPool 方法开始。
        if (pool.Count > 0) return pool.Dequeue(); // 池里有对象就复用。
        GameObject monster = Instantiate(rule.prefab); // 池里没有就创建新对象。
        return monster; // 返回怪物对象。
    } // GetFromPool 方法结束。
    public void Despawn(GameObject monster) // 回收怪物。
    { // Despawn 方法开始。
        monster.SetActive(false); // 隐藏怪物对象。
        pool.Enqueue(monster); // 放回对象池等待复用。
    } // Despawn 方法结束。
} // MonsterSpawnManager 类结束。

Unity 工程实践

副本里可以用波次刷新:上一波死亡后再刷下一波。开放世界里可以用区域触发:玩家靠近才激活刷新点,离远后怪物休眠或回收。联机项目里刷怪最好由服务端决定,客户端只表现,避免玩家本地改刷怪数量或掉落。

常见坑

无限刷新导致怪物爆量。 怪物回收时没有重置血量、AI、Buff、仇恨。 所有刷新点每帧检查,怪物多时浪费 CPU。 切场景时没有统一 Despawn,导致对象和计时器残留。 精英怪、宝箱怪死亡后没有存档,玩家反复刷奖励。

设计一个掉落系统

unity-loot-drop-system-design

标准答案

掉落系统的核心是:掉落表 + 掉落池 + 权重随机 + 归属规则 + 发奖入账 + 日志校验

它不是简单随机一个物品,而是要支持:普通掉落、稀有掉落、首通奖励、保底、活动倍率、队伍分配、掉地表现、背包入账、服务端校验和日志追踪。

核心流程

怪物死亡 -> 查 DropTable -> 抽 DropPool -> 根据权重随机物品 -> 计算数量 -> 校验归属 -> 发奖入背包 -> 生成掉落表现 -> 记录日志

单机项目可以本地随机;联网游戏尤其是稀有掉落,必须服务端计算,客户端只显示结果,否则玩家可能篡改掉落。

代码示例

c
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Unity 基础类型。
[System.Serializable] // 允许 Unity 序列化掉落条目。
public sealed class DropItem // 定义一个掉落条目。
{ // DropItem 类开始。
    public int itemId; // 掉落物品 ID。
    public int minCount = 1; // 最小掉落数量。
    public int maxCount = 1; // 最大掉落数量。
    public int weight = 1; // 权重值,越大越容易被抽中。
} // DropItem 类结束。
[System.Serializable] // 允许 Unity 序列化掉落池。
public sealed class DropPool // 定义一个掉落池。
{ // DropPool 类开始。
    public List<DropItem> items = new List<DropItem>(); // 保存该池里的所有掉落项。
} // DropPool 类结束。
public sealed class DropSystem // 定义掉落系统。
{ // DropSystem 类开始。
    public DropItem RollOne(DropPool pool) // 从掉落池中随机一个条目。
    { // RollOne 方法开始。
        int totalWeight = 0; // 保存总权重。
        for (int i = 0; i < pool.items.Count; i++) // 遍历所有掉落条目。
        { // 循环开始。
            totalWeight += pool.items[i].weight; // 累加每个条目的权重。
        } // 循环结束。
        int random = Random.Range(1, totalWeight + 1); // 在 1 到总权重之间随机一个值。
        int current = 0; // 保存当前累计权重。
        for (int i = 0; i < pool.items.Count; i++) // 再次遍历掉落条目。
        { // 循环开始。
            current += pool.items[i].weight; // 累加到当前条目。
            if (random <= current) // 判断随机值是否落在当前权重区间。
            { // if 开始。
                return pool.items[i]; // 命中当前条目并返回。
            } // if 结束。
        } // 循环结束。
        return null; // 理论上不会走到这里,防止配置异常。
    } // RollOne 方法结束。
    public int RollCount(DropItem item) // 随机掉落数量。
    { // RollCount 方法开始。
        return Random.Range(item.minCount, item.maxCount + 1); // 返回最小到最大之间的随机数量。
    } // RollCount 方法结束。
} // DropSystem 类结束。

Unity 工程实践

我会把掉落配置拆成三层:

DropTable:怪物、副本、宝箱绑定哪张掉落表。 DropPool:普通池、稀有池、保底池、活动池。 DropItem:物品 ID、数量范围、权重、是否绑定、是否公告。

如果是多人副本,还要处理归属:击杀者、队伍共享、伤害排名、个人独立掉落。稀有物品最好记录日志:来源、玩家、怪物 ID、随机种子、掉落结果,方便补单、查异常和验证概率。

常见坑

客户端直接发稀有奖励。 权重配置为 0 或总权重为 0 没校验。 掉落表现生成了,但背包入账失败。 重复点击拾取导致重复发奖。 稀有掉落没有日志,线上争议无法追查。

设计一个宝箱系统

unity-chest-system-design

标准答案

宝箱系统的核心是:配置 + 状态 + 条件校验 + 幂等开箱 + 奖励发放 + 持久化

它不是玩家点一下就 AddItem,而是要考虑:宝箱类型、开启条件、奖励来源、防重复领取、动画表现、背包入账、存档恢复和服务端校验。

核心流程

玩家靠近 -> 显示交互提示 -> 点击开箱 -> 检查条件 -> 设置 Opening 状态 -> 发放奖励 -> 播放动画 -> 保存 Opened 状态 -> 刷新 UI

宝箱常见类型有:一次性宝箱、每日宝箱、房间宝箱、钥匙宝箱、付费宝箱、任务宝箱。

底层原理

宝箱系统最重要的是状态机:

Locked:条件不满足,比如没有钥匙。 Closed:可以开启,但还没开。 Opening:正在请求发奖,防止重复点击。 Opened:已经领取,不能重复发奖。

如果是单机普通宝箱,可以本地存档 chestId -> opened。如果是联网游戏、稀有宝箱、付费宝箱,就必须服务端权威,客户端只展示开箱结果。

代码示例

c
using System.Collections.Generic; // 引入 HashSet 容器。
using UnityEngine; // 引入 Unity 基础类型。
public enum ChestState { Locked, Closed, Opening, Opened } // 定义宝箱状态。
[System.Serializable] // 允许 Unity 序列化宝箱配置。
public sealed class ChestConfig // 定义宝箱配置。
{ // ChestConfig 类开始。
    public string chestId; // 宝箱唯一 ID。
    public int needKeyItemId; // 开启需要的钥匙道具 ID。
    public int rewardItemId; // 奖励道具 ID。
    public int rewardCount; // 奖励数量。
} // ChestConfig 类结束。
public sealed class ChestSystem // 定义宝箱系统。
{ // ChestSystem 类开始。
    private readonly HashSet<string> openedChests = new HashSet<string>(); // 保存已经开启过的宝箱 ID。
    private readonly HashSet<string> openingChests = new HashSet<string>(); // 保存正在开启中的宝箱 ID。
    public ChestState GetState(ChestConfig config) // 获取宝箱当前状态。
    { // GetState 方法开始。
        if (openedChests.Contains(config.chestId)) // 判断宝箱是否已经开启。
        { // if 开始。
            return ChestState.Opened; // 返回已开启状态。
        } // if 结束。
        if (openingChests.Contains(config.chestId)) // 判断宝箱是否正在开启。
        { // if 开始。
            return ChestState.Opening; // 返回开启中状态。
        } // if 结束。
        return ChestState.Closed; // 默认返回可开启但未开启状态。
    } // GetState 方法结束。
    public bool TryOpen(ChestConfig config) // 尝试开启宝箱。
    { // TryOpen 方法开始。
        if (openedChests.Contains(config.chestId)) // 如果宝箱已经开过。
        { // if 开始。
            return false; // 不能重复开启。
        } // if 结束。
        if (openingChests.Contains(config.chestId)) // 如果宝箱正在开。
        { // if 开始。
            return false; // 防止连点重复请求。
        } // if 结束。
        if (!HasKey(config.needKeyItemId)) // 判断玩家是否有钥匙。
        { // if 开始。
            return false; // 没钥匙则不能开启。
        } // if 结束。
        openingChests.Add(config.chestId); // 标记宝箱进入开启中。
        bool success = GrantReward(config.rewardItemId, config.rewardCount); // 发放奖励。
        openingChests.Remove(config.chestId); // 移除开启中状态。
        if (!success) // 判断发奖是否失败。
        { // if 开始。
            return false; // 发奖失败时不写入已开状态。
        } // if 结束。
        openedChests.Add(config.chestId); // 发奖成功后标记为已开启。
        SaveChestState(config.chestId); // 保存宝箱状态。
        return true; // 返回开启成功。
    } // TryOpen 方法结束。
    private bool HasKey(int keyItemId) // 判断玩家是否有钥匙。
    { // HasKey 方法开始。
        return keyItemId <= 0 || true; // 示例:无钥匙要求或背包检查通过。
    } // HasKey 方法结束。
    private bool GrantReward(int itemId, int count) // 发放奖励。
    { // GrantReward 方法开始。
        Debug.Log($"获得奖励 itemId={itemId}, count={count}"); // 示例:真实项目接背包系统。
        return true; // 示例默认发奖成功。
    } // GrantReward 方法结束。
    private void SaveChestState(string chestId) // 保存宝箱状态。
    { // SaveChestState 方法开始。
        Debug.Log($"保存宝箱已开启:{chestId}"); // 示例:真实项目写存档或通知服务端。
    } // SaveChestState 方法结束。
} // ChestSystem 类结束。

Unity 工程实践

我会让场景里的 ChestView 只负责表现:显示提示、播放开启动画、显示奖励弹窗。真正的校验和发奖放在 ChestService 或服务端接口里。这样 UI 连点、断线重连、切场景回来,都不会重复发奖。

最关键的面试点是:发奖成功后才能写 Opened 状态;如果发奖失败但状态已经保存成 Opened,玩家会丢奖励。

常见坑

连点导致重复发奖。 发奖失败但宝箱状态已开启。 切场景后宝箱状态丢失。 付费宝箱在客户端直接发奖励。 本地存档没校验,玩家改文件重复开箱。

设计一个装备强化系统

unity-equipment-enhancement-system

标准答案

装备强化系统就是:玩家消耗金币、材料、保护符等资源,根据强化配置提升装备等级,并同步刷新属性、战力、存档和表现。

核心不是“点一下 level + 1”,而是要把它设计成一条稳定流程:

选择装备 -> 读取配置 -> 校验材料 -> 防重复请求 -> 扣除消耗 -> 成功率判定 -> 更新装备 -> 重算属性 -> 保存数据 -> 刷新 UI

底层原理

装备强化一般分三层:

数据层:EquipmentState 保存装备唯一 ID、强化等级、失败次数、绑定状态。

配置层:EnhanceConfig 保存每一级消耗、成功率、保底次数、失败惩罚。

逻辑层:EnhanceSystem 负责校验、扣资源、判定结果、保存日志、通知 UI。

联网游戏里,强化结果必须由服务端决定。客户端不能自己随机成功,否则玩家可以改内存、改协议、改本地存档来作弊。

C# 简化实现

c
using UnityEngine; // 引入 Unity 基础 API。 
public sealed class EquipmentState // 定义装备运行时状态类。 
{ // EquipmentState 类开始。 
    public string guid; // 装备唯一实例 ID,不能只用配置 ID。 
    public int enhanceLevel; // 当前强化等级。 
    public int failCount; // 连续失败次数,用于保底。 
} // EquipmentState 类结束。 
public sealed class EnhanceConfig // 定义强化配置类。 
{ // EnhanceConfig 类开始。 
    public int targetLevel; // 本次强化后的目标等级。 
    public int goldCost; // 本次强化需要消耗的金币。 
    public int materialCost; // 本次强化需要消耗的材料数量。 
    public float successRate; // 本次强化的基础成功率。 
    public int pityFailCount; // 连续失败多少次后触发保底。 
} // EnhanceConfig 类结束。 
public sealed class EnhanceSystem // 定义装备强化系统。 
{ // EnhanceSystem 类开始。 
    public bool TryEnhance(EquipmentState equip, EnhanceConfig config) // 尝试强化装备。 
    { // TryEnhance 方法开始。 
        if (equip == null) return false; // 装备为空,直接失败。 
        if (config == null) return false; // 配置为空,直接失败。 
        if (equip.enhanceLevel + 1 != config.targetLevel) return false; // 防止配置等级和装备等级不匹配。 
        if (!HasEnoughCost(config)) return false; // 检查金币和材料是否足够。 
        ConsumeCost(config); // 扣除金币和材料。 
        bool isPity = equip.failCount >= config.pityFailCount; // 判断是否已经达到保底次数。 
        bool isSuccess = isPity || Random.value <= config.successRate; // 根据保底或随机成功率得到结果。 
        if (isSuccess) // 如果强化成功。 
        { // 成功分支开始。 
            equip.enhanceLevel = config.targetLevel; // 提升装备强化等级。 
            equip.failCount = 0; // 成功后清空失败次数。 
            RecalculateAttribute(equip); // 重新计算装备属性和战力。 
            SaveEquipment(equip); // 保存装备状态。 
            return true; // 返回强化成功。 
        } // 成功分支结束。 
        equip.failCount++; // 强化失败时增加失败计数。 
        SaveEquipment(equip); // 保存失败次数,避免保底丢失。 
        return false; // 返回强化失败。 
    } // TryEnhance 方法结束。 
    private bool HasEnoughCost(EnhanceConfig config) { return true; } // 示例方法:真实项目检查背包和货币。 
    private void ConsumeCost(EnhanceConfig config) { } // 示例方法:真实项目扣除金币和材料。 
    private void RecalculateAttribute(EquipmentState equip) { } // 示例方法:真实项目刷新属性和战力。 
    private void SaveEquipment(EquipmentState equip) { } // 示例方法:真实项目写存档或请求服务端。 
} // EnhanceSystem 类结束。

常见坑

  1. 客户端自己决定成功率,容易被作弊。
  2. 扣了材料但装备等级没保存,需要事务或服务端原子操作。
  3. 没做按钮防连点,可能重复强化。
  4. 保底次数没有保存,玩家重登后数据错乱。
  5. UI 只刷新数字,忘了刷新属性、战力、红点、日志。

设计一个装备词条系统

unity-equipment-affix-system

标准答案

装备词条系统就是:给同一件装备生成不同的随机属性,让装备有差异化。比如同样是“史诗长剑”,一把可能是 攻击 +50、暴击率 +3%,另一把可能是 攻速 +5%、火伤 +8%

它的核心设计是:

词条配置 -> 词条池 -> 权重随机 -> 冲突检查 -> 数值随机 -> 写入装备实例 -> 属性系统重算

核心模块

AffixConfig:词条配置,比如攻击、暴击、生命、吸血、火伤。

AffixPool:词条池,根据装备部位、品质、职业筛选可出现词条。

AffixInstance:词条实例,保存到具体装备上,比如“暴击率 +3.2%”。

AffixGenerator:词条生成器,负责随机数量、随机类型、随机数值。

AttributeSystem:属性系统,把词条转换成角色最终属性。

底层原理

配置表里不保存“某件装备的随机结果”,只保存规则。真正掉落或洗练时,系统根据规则生成具体实例,并把实例保存到装备数据里。

重点是:同一个 configId 可以随机出不同 value。比如 暴击率词条 配置范围是 1% - 5%,某件装备可能滚出 3.2%,这个 3.2% 必须保存下来。

C# 简化实现

c
using System; // 引入 Random 随机数类。 
using System.Collections.Generic; // 引入 List 和 HashSet 集合。 
public enum AffixStatType // 定义词条影响的属性类型。 
{ // 枚举开始。 
    Attack, // 攻击力词条。 
    CritRate, // 暴击率词条。 
    HpPercent, // 生命百分比词条。 
} // 枚举结束。 
public sealed class AffixConfig // 定义词条配置表数据。 
{ // AffixConfig 类开始。 
    public int id; // 词条配置 ID。 
    public AffixStatType statType; // 词条影响的属性类型。 
    public int minValue; // 词条随机最小值。 
    public int maxValue; // 词条随机最大值。 
    public int weight; // 词条被抽中的权重。 
    public string conflictGroup; // 词条冲突组,同组不能重复出现。 
} // AffixConfig 类结束。 
public sealed class AffixInstance // 定义装备身上的词条实例。 
{ // AffixInstance 类开始。 
    public int configId; // 记录来自哪个词条配置。 
    public AffixStatType statType; // 保存属性类型,方便属性系统读取。 
    public int value; // 保存本次实际随机出来的数值。 
} // AffixInstance 类结束。 
public sealed class EquipmentState // 定义装备运行时数据。 
{ // EquipmentState 类开始。 
    public string guid; // 装备唯一实例 ID。 
    public List<AffixInstance> affixes = new List<AffixInstance>(); // 当前装备拥有的词条列表。 
} // EquipmentState 类结束。 
public sealed class AffixGenerator // 定义词条生成器。 
{ // AffixGenerator 类开始。 
    private readonly Random random = new Random(); // 创建随机数对象,真实项目可改成服务端随机。 
    public List<AffixInstance> Roll(List<AffixConfig> pool, int count) // 从词条池里随机生成指定数量的词条。 
    { // Roll 方法开始。 
        List<AffixInstance> result = new List<AffixInstance>(); // 创建结果列表。 
        HashSet<string> usedGroups = new HashSet<string>(); // 记录已经出现过的冲突组。 
        for (int i = 0; i < count; i++) // 循环生成多个词条。 
        { // for 循环开始。 
            AffixConfig config = RollOne(pool, usedGroups); // 按权重抽一个可用词条。 
            if (config == null) break; // 如果没有可用词条,就提前结束。 
            usedGroups.Add(config.conflictGroup); // 记录该词条的冲突组。 
            int value = random.Next(config.minValue, config.maxValue + 1); // 在配置范围内随机数值。 
            result.Add(new AffixInstance { configId = config.id, statType = config.statType, value = value }); // 保存词条实例。 
        } // for 循环结束。 
        return result; // 返回生成好的词条列表。 
    } // Roll 方法结束。 
    private AffixConfig RollOne(List<AffixConfig> pool, HashSet<string> usedGroups) // 按权重抽取一个词条配置。 
    { // RollOne 方法开始。 
        int totalWeight = 0; // 统计可用词条总权重。 
        foreach (AffixConfig config in pool) // 遍历词条池。 
        { // foreach 循环开始。 
            if (usedGroups.Contains(config.conflictGroup)) continue; // 冲突组已经出现过就跳过。 
            totalWeight += config.weight; // 累加当前词条权重。 
        } // foreach 循环结束。 
        if (totalWeight <= 0) return null; // 没有可抽词条就返回空。 
        int roll = random.Next(1, totalWeight + 1); // 在总权重范围内随机一个点。 
        foreach (AffixConfig config in pool) // 再次遍历词条池。 
        { // foreach 循环开始。 
            if (usedGroups.Contains(config.conflictGroup)) continue; // 跳过已经冲突的词条。 
            roll -= config.weight; // 扣掉当前词条权重区间。 
            if (roll <= 0) return config; // 随机点落在当前区间就返回该词条。 
        } // foreach 循环结束。 
        return null; // 理论兜底,防止异常情况。 
    } // RollOne 方法结束。 
} // AffixGenerator 类结束。

项目里怎么落地

掉落装备时:根据装备品质决定词条数量,比如蓝装 1 条、紫装 2 条、橙装 3 条。

洗练装备时:可以允许玩家锁定某些词条,只重抽未锁定词条。

属性刷新时:不要让 UI 自己算,应该统一进入属性系统,比如 BaseAttr + EquipAffix + Buff + Talent

联网项目里:词条随机结果必须服务端生成,客户端只发“我要洗练”的请求,然后展示服务端返回结果。

常见坑

随机结果不保存,只保存配置 ID,导致每次打开装备词条都变。

没有冲突组,导致同一件装备同时出现多个重复核心词条。

客户端自己随机词条,容易被改包和改内存。

配置改动没有兼容老装备,导致旧装备词条找不到配置。

洗练没有预览确认,玩家体验会很差。

设计一个随机属性系统

unity-random-attribute-system

标准答案

随机属性系统就是:根据配置表和上下文,随机生成一组属性类型和属性数值,比如 攻击 +37暴击率 +3.5%生命 +6%

它通常是装备词条、宠物成长、肉鸽奖励、关卡词缀、掉落品质的底层系统。

核心流程是:

输入上下文 -> 过滤属性池 -> 随机条数 -> 权重抽类型 -> 范围抽数值 -> 规则校验 -> 保存实例 -> 属性重算

底层原理

配置表只描述“可能随机出什么”,比如攻击范围 10~50,权重 100

运行时实例保存“这次实际随机到了什么”,比如攻击 +37

所以不能每次打开 UI 重新随机,否则同一件装备属性会变。真正项目里要保存 configId、value、seed、来源、版本号

联网游戏里,随机结果最好由服务端生成。客户端只请求和展示,避免玩家改本地随机结果。

C# 简化实现

c
using System; // 引入 Random 随机数。 
using System.Collections.Generic; // 引入 List 和 HashSet 集合。 
public enum StatType // 定义属性类型枚举。 
{ // StatType 枚举开始。 
    Attack, // 攻击力。 
    Defense, // 防御力。 
    CritRate, // 暴击率。 
} // StatType 枚举结束。 
public sealed class RandomAttrConfig // 定义随机属性配置。 
{ // RandomAttrConfig 类开始。 
    public int id; // 属性配置 ID。 
    public StatType statType; // 属性类型。 
    public int minValue; // 随机最小值。 
    public int maxValue; // 随机最大值。 
    public int weight; // 被抽中的权重。 
    public string group; // 冲突组,同组只能出现一个。 
} // RandomAttrConfig 类结束。 
public sealed class RandomAttrInstance // 定义随机出来的属性实例。 
{ // RandomAttrInstance 类开始。 
    public int configId; // 记录来自哪个配置。 
    public StatType statType; // 保存属性类型。 
    public int value; // 保存实际随机数值。 
} // RandomAttrInstance 类结束。 
public sealed class RandomAttrGenerator // 定义随机属性生成器。 
{ // RandomAttrGenerator 类开始。 
    private readonly Random random; // 保存随机数对象。 
    public RandomAttrGenerator(int seed) // 构造函数传入随机种子。 
    { // 构造函数开始。 
        random = new Random(seed); // 用种子创建随机数,方便复现问题。 
    } // 构造函数结束。 
    public List<RandomAttrInstance> Roll(List<RandomAttrConfig> pool, int count) // 随机生成属性列表。 
    { // Roll 方法开始。 
        List<RandomAttrInstance> result = new List<RandomAttrInstance>(); // 创建结果列表。 
        HashSet<string> usedGroups = new HashSet<string>(); // 记录已经出现的冲突组。 
        for (int i = 0; i < count; i++) // 按数量循环随机属性。 
        { // for 循环开始。 
            RandomAttrConfig config = PickOne(pool, usedGroups); // 从属性池按权重抽一个配置。 
            if (config == null) break; // 没有可用配置就提前结束。 
            usedGroups.Add(config.group); // 记录当前属性的冲突组。 
            int value = random.Next(config.minValue, config.maxValue + 1); // 在范围内随机属性值。 
            RandomAttrInstance attr = new RandomAttrInstance(); // 创建属性实例。 
            attr.configId = config.id; // 保存配置 ID。 
            attr.statType = config.statType; // 保存属性类型。 
            attr.value = value; // 保存实际随机值。 
            result.Add(attr); // 把属性实例加入结果。 
        } // for 循环结束。 
        return result; // 返回最终随机属性列表。 
    } // Roll 方法结束。 
    private RandomAttrConfig PickOne(List<RandomAttrConfig> pool, HashSet<string> usedGroups) // 按权重抽取一个配置。 
    { // PickOne 方法开始。 
        int totalWeight = 0; // 统计总权重。 
        foreach (RandomAttrConfig config in pool) // 遍历属性池。 
        { // foreach 循环开始。 
            if (usedGroups.Contains(config.group)) continue; // 冲突组已出现就跳过。 
            totalWeight += config.weight; // 累加可用配置权重。 
        } // foreach 循环结束。 
        if (totalWeight <= 0) return null; // 没有可抽属性就返回空。 
        int roll = random.Next(1, totalWeight + 1); // 在总权重范围内随机一个点。 
        foreach (RandomAttrConfig config in pool) // 再遍历一次属性池。 
        { // foreach 循环开始。 
            if (usedGroups.Contains(config.group)) continue; // 跳过冲突属性。 
            roll -= config.weight; // 扣掉当前属性的权重区间。 
            if (roll <= 0) return config; // 随机点落入当前区间就返回。 
        } // foreach 循环结束。 
        return null; // 理论兜底返回空。 
    } // PickOne 方法结束。 
} // RandomAttrGenerator 类结束。

项目注意点

随机属性系统最好配置驱动,不要在代码里硬编码“攻击、暴击、生命”。

随机结果要保存实例,不要只保存配置。

正式项目随机结果要服务端权威,客户端只展示。

配置改动要考虑老数据兼容,否则老装备可能找不到属性配置。

如果要排查线上问题,可以记录 seed、来源、配置版本、随机结果日志

设计一个关卡波次系统

unity-level-wave-system

标准答案

关卡波次系统就是:用配置表控制一关里第几波刷什么怪、刷多少、从哪里刷、间隔多久刷,以及什么时候进入下一波和什么时候结算胜负。

核心流程是:

加载关卡配置 -> 预加载资源 -> 开始第 1 波 -> 分批刷怪 -> 监听怪物死亡 -> 判断清场 -> 进入下一波 -> 全部完成后结算

底层原理

波次系统本质是一个“关卡节奏状态机”。它不是一次性把所有怪 Instantiate 出来,而是按配置逐步生成,让战斗节奏可控。

配置表决定规则:怪物 ID、数量、出生点、间隔、延迟、胜负条件。

运行时系统负责调度:当前第几波、当前存活怪数量、是否清场、是否失败、是否暂停、是否切场景。

真正项目里刷怪一般接对象池,不建议每只怪都 Instantiate/Destroy,否则容易产生卡顿和 GC。

C# 简化实现

c
using System; // 引入 Action 回调类型。 
using System.Collections; // 引入 IEnumerator,用于协程。 
using System.Collections.Generic; // 引入 List 集合。 
using UnityEngine; // 引入 Unity 基础 API。 
[Serializable] // 允许该配置显示在 Inspector。 
public sealed class WaveEnemyConfig // 定义每波里的怪物配置。 
{ // WaveEnemyConfig 类开始。 
    public GameObject prefab; // 怪物预制体,真实项目可换成 enemyId。 
    public int count; // 该怪物要刷出的数量。 
    public float interval; // 每只怪之间的生成间隔。 
    public Transform spawnPoint; // 怪物出生点。 
} // WaveEnemyConfig 类结束。 
[Serializable] // 允许波次配置显示在 Inspector。 
public sealed class WaveConfig // 定义单个波次配置。 
{ // WaveConfig 类开始。 
    public float delayBeforeStart; // 当前波次开始前的延迟。 
    public bool waitUntilClear = true; // 是否等待本波怪物清完再进入下一波。 
    public List<WaveEnemyConfig> enemies = new List<WaveEnemyConfig>(); // 本波要生成的怪物组。 
} // WaveConfig 类结束。 
public sealed class LevelWaveSystem : MonoBehaviour // 定义关卡波次系统。 
{ // LevelWaveSystem 类开始。 
    public List<WaveConfig> waves = new List<WaveConfig>(); // 关卡所有波次配置。 
    private readonly List<GameObject> aliveEnemies = new List<GameObject>(); // 当前还活着的怪物列表。 
    private Coroutine runningCoroutine; // 保存当前波次协程,方便停止。 
    public void StartLevel() // 开始关卡。 
    { // StartLevel 方法开始。 
        if (runningCoroutine != null) StopCoroutine(runningCoroutine); // 如果已有流程在跑,先停止旧流程。 
        aliveEnemies.Clear(); // 清空上一局残留的存活怪记录。 
        runningCoroutine = StartCoroutine(RunWaves()); // 启动波次主流程。 
    } // StartLevel 方法结束。 
    private IEnumerator RunWaves() // 波次主流程协程。 
    { // RunWaves 方法开始。 
        for (int i = 0; i < waves.Count; i++) // 按顺序遍历所有波次。 
        { // for 循环开始。 
            WaveConfig wave = waves[i]; // 取出当前波次配置。 
            yield return new WaitForSeconds(wave.delayBeforeStart); // 等待当前波次的开始延迟。 
            yield return StartCoroutine(SpawnWave(wave)); // 生成当前波次的所有怪物。 
            if (wave.waitUntilClear) // 如果配置要求清场后再继续。 
            { // 清场等待分支开始。 
                yield return new WaitUntil(() => aliveEnemies.Count == 0); // 等到当前存活怪数量为 0。 
            } // 清场等待分支结束。 
        } // for 循环结束。 
        OnLevelWin(); // 所有波次完成后触发胜利结算。 
    } // RunWaves 方法结束。 
    private IEnumerator SpawnWave(WaveConfig wave) // 生成一波怪物。 
    { // SpawnWave 方法开始。 
        foreach (WaveEnemyConfig enemyConfig in wave.enemies) // 遍历本波所有怪物组。 
        { // foreach 循环开始。 
            for (int i = 0; i < enemyConfig.count; i++) // 按数量逐只生成怪物。 
            { // for 循环开始。 
                GameObject enemy = Instantiate(enemyConfig.prefab, enemyConfig.spawnPoint.position, enemyConfig.spawnPoint.rotation); // 创建怪物,真实项目建议换成对象池取对象。 
                aliveEnemies.Add(enemy); // 把怪物加入存活列表。 
                EnemyDeathHook hook = enemy.GetComponent<EnemyDeathHook>(); // 获取怪物死亡回调组件。 
                if (hook != null) hook.Init(() => aliveEnemies.Remove(enemy)); // 怪物死亡时从存活列表移除。 
                yield return new WaitForSeconds(enemyConfig.interval); // 等待下一只怪物生成间隔。 
            } // for 循环结束。 
        } // foreach 循环结束。 
    } // SpawnWave 方法结束。 
    private void OnLevelWin() // 关卡胜利处理。 
    { // OnLevelWin 方法开始。 
        Debug.Log("关卡胜利,发放奖励并进入结算界面。"); // 示例:真实项目会通知奖励、任务、UI 系统。 
    } // OnLevelWin 方法结束。 
} // LevelWaveSystem 类结束。 
public sealed class EnemyDeathHook : MonoBehaviour // 定义怪物死亡回调组件。 
{ // EnemyDeathHook 类开始。 
    private Action onDead; // 保存死亡时要调用的回调。 
    public void Init(Action callback) // 初始化死亡回调。 
    { // Init 方法开始。 
        onDead = callback; // 保存外部传入的死亡回调。 
    } // Init 方法结束。 
    public void Die() // 怪物死亡方法。 
    { // Die 方法开始。 
        onDead?.Invoke(); // 通知波次系统该怪物已经死亡。 
        gameObject.SetActive(false); // 关闭怪物对象,真实项目一般归还对象池。 
    } // Die 方法结束。 
} // EnemyDeathHook 类结束。

项目注意点

关卡开始前要预加载怪物、特效、音效,避免第一只怪刷出来时卡顿。

大量怪物要用对象池,并且分批刷,不要一帧生成几十上百个。

进入下一波不能只看“刷完了”,还要看“怪物是否清完”或“目标是否完成”。

切场景、暂停、玩家死亡时,要停止协程并回收已经刷出的怪。

联机项目里,刷怪时间、怪物类型和出生位置最好由服务端决定,客户端只播放表现。

设计一个副本结算系统

unity-dungeon-settlement-system

标准答案

副本结算系统就是:副本结束时收集玩家战斗数据,判断胜负,计算星级、经验、金币、掉落奖励,然后把副本记录、奖励变化、任务进度一起保存,并展示结算界面。

核心流程是:

副本结束 -> 冻结战斗 -> 收集统计 -> 判定胜负 -> 计算星级 -> 计算奖励 -> 防重复结算 -> 保存数据 -> 展示 UI

底层原理

结算系统不是单纯打开一个 UI 面板,它本质是一次“状态提交”。 比如玩家通关后,背包增加了奖励,任务进度变化了,首通状态变化了,这些数据必须一起成功,不能出现“UI 显示发奖了,但背包没到账”。

所以正式项目里要重点考虑:

结算 ID:每次副本生成唯一 settlementId,防止重复提交。

幂等:同一个 settlementId 重复请求,只能发一次奖励。

事务:副本记录、背包奖励、任务进度要一起保存。

服务端权威:联网游戏里胜负、奖励最好由服务端判定。

断线补偿:结算过程中断线,重连后可以查询并补展示或补发。

C# 简化实现

c
using System.Collections.Generic; // 引入 HashSet 集合。 
public enum DungeonResultType { Win, Lose } // 定义副本结算结果类型。 
public sealed class DungeonStats // 定义副本运行统计数据。 
{ // DungeonStats 类开始。 
    public int killCount; // 击杀怪物数量。 
    public int deathCount; // 玩家死亡次数。 
    public float usedTime; // 通关或失败所用时间。 
    public bool mainTargetCompleted; // 主目标是否完成。 
} // DungeonStats 类结束。 
public sealed class DungeonSettlementConfig // 定义副本结算配置。 
{ // DungeonSettlementConfig 类开始。 
    public float threeStarTime; // 三星要求的通关时间。 
    public int threeStarMaxDeath; // 三星允许的最大死亡次数。 
    public int baseGold; // 基础金币奖励。 
    public int baseExp; // 基础经验奖励。 
} // DungeonSettlementConfig 类结束。 
public sealed class DungeonSettlementResult // 定义副本结算结果。 
{ // DungeonSettlementResult 类开始。 
    public DungeonResultType resultType; // 保存胜利或失败。 
    public int star; // 保存星级。 
    public int gold; // 保存金币奖励。 
    public int exp; // 保存经验奖励。 
    public bool alreadySettled; // 是否是重复结算请求。 
} // DungeonSettlementResult 类结束。 
public sealed class DungeonSettlementSystem // 定义副本结算系统。 
{ // DungeonSettlementSystem 类开始。 
    private readonly HashSet<string> settledIds = new HashSet<string>(); // 保存已经结算过的 ID。 
    public DungeonSettlementResult Settle(string settlementId, DungeonStats stats, DungeonSettlementConfig config) // 执行副本结算。 
    { // Settle 方法开始。 
        if (settledIds.Contains(settlementId)) return BuildDuplicateResult(); // 如果重复结算,直接返回幂等结果。 
        DungeonSettlementResult result = new DungeonSettlementResult(); // 创建结算结果对象。 
        result.resultType = stats.mainTargetCompleted ? DungeonResultType.Win : DungeonResultType.Lose; // 根据主目标判断胜负。 
        result.star = CalcStar(stats, config, result.resultType); // 根据统计数据计算星级。 
        result.gold = CalcGold(config, result.star, result.resultType); // 根据星级计算金币。 
        result.exp = CalcExp(config, result.star, result.resultType); // 根据星级计算经验。 
        SaveSettlement(settlementId, result); // 保存副本记录和奖励变化。 
        settledIds.Add(settlementId); // 标记该结算 ID 已经处理。 
        return result; // 返回结算结果给 UI。 
    } // Settle 方法结束。 
    private int CalcStar(DungeonStats stats, DungeonSettlementConfig config, DungeonResultType type) // 计算星级。 
    { // CalcStar 方法开始。 
        if (type == DungeonResultType.Lose) return 0; // 失败时没有星级。 
        if (stats.usedTime <= config.threeStarTime && stats.deathCount <= config.threeStarMaxDeath) return 3; // 满足时间和死亡要求给三星。 
        if (stats.deathCount <= config.threeStarMaxDeath + 1) return 2; // 条件略差时给二星。 
        return 1; // 只要通关就至少一星。 
    } // CalcStar 方法结束。 
    private int CalcGold(DungeonSettlementConfig config, int star, DungeonResultType type) // 计算金币奖励。 
    { // CalcGold 方法开始。 
        if (type == DungeonResultType.Lose) return 0; // 失败不给金币。 
        return config.baseGold * star; // 按星级放大奖励。 
    } // CalcGold 方法结束。 
    private int CalcExp(DungeonSettlementConfig config, int star, DungeonResultType type) // 计算经验奖励。 
    { // CalcExp 方法开始。 
        if (type == DungeonResultType.Lose) return config.baseExp / 10; // 失败给少量参与经验。 
        return config.baseExp * star; // 胜利按星级给经验。 
    } // CalcExp 方法结束。 
    private DungeonSettlementResult BuildDuplicateResult() // 构造重复结算结果。 
    { // BuildDuplicateResult 方法开始。 
        return new DungeonSettlementResult { alreadySettled = true }; // 返回已结算标记,避免重复发奖。 
    } // BuildDuplicateResult 方法结束。 
    private void SaveSettlement(string settlementId, DungeonSettlementResult result) { } // 示例:真实项目要事务保存副本记录、背包、任务和日志。 
} // DungeonSettlementSystem 类结束。

项目注意点

客户端单机 Demo 可以本地算结算,但联网项目应该服务端算,客户端只展示。

结算按钮防连点不够,真正防重复要靠 settlementId 幂等。

副本奖励、背包、任务、成就最好统一走奖励服务,不要 UI 自己加道具。

断线重连后要查询“是否有未完成结算”,避免玩家通关但没领奖。

结算日志要记录副本 ID、耗时、击杀、奖励、来源,方便查补发和反作弊。

设计一个排行榜系统

unity-leaderboard-system

标准答案

排行榜系统就是:玩家完成某个玩法后提交成绩,服务端校验成绩并更新排名,客户端分页拉取榜单并展示自己的名次。

核心流程是:

玩法结束 -> 提交分数 -> 服务端校验 -> 更新排行榜 -> 查询 TopN / 我的名次 -> 客户端展示

正式项目里排行榜一定要强调:客户端不可信,权威排名应该由服务端计算

底层原理

排行榜本质是一个“按分数排序的有序数据集合”。

小型 Demo 可以用 List 排序;正式项目常用类似 Redis Sorted Set 的结构,因为它适合做:

TopN 查询。

玩家名次查询。

分数更新。

分页查询。

赛季榜隔离。

如果分数相同,要提前定义规则,比如“分数高优先,提交时间早优先”,否则并列名次会有争议。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 和 Dictionary 集合。 
using System.Linq; // 引入 OrderByDescending 排序方法。 
public sealed class RankEntry // 定义排行榜单条记录。 
{ // RankEntry 类开始。 
    public string playerId; // 玩家 ID。 
    public string playerName; // 玩家昵称。 
    public int score; // 玩家分数。 
    public long submitTime; // 提交时间,用于同分排序。 
} // RankEntry 类结束。 
public sealed class RankPage // 定义排行榜分页结果。 
{ // RankPage 类开始。 
    public int pageIndex; // 当前页下标。 
    public int pageSize; // 每页数量。 
    public List<RankEntry> entries; // 当前页排行榜数据。 
} // RankPage 类结束。 
public sealed class LeaderboardSystem // 定义排行榜系统。 
{ // LeaderboardSystem 类开始。 
    private readonly Dictionary<string, RankEntry> rankMap = new Dictionary<string, RankEntry>(); // 用玩家 ID 保存排行榜记录。 
    public void SubmitScore(string playerId, string playerName, int score, long submitTime) // 提交玩家分数。 
    { // SubmitScore 方法开始。 
        if (score < 0) return; // 分数非法时直接拒绝。 
        if (rankMap.TryGetValue(playerId, out RankEntry oldEntry)) // 判断玩家是否已有成绩。 
        { // 已有成绩分支开始。 
            if (score < oldEntry.score) return; // 新分数更低时不更新最高分。 
            if (score == oldEntry.score && submitTime >= oldEntry.submitTime) return; // 同分时保留更早提交的成绩。 
            oldEntry.score = score; // 更新玩家最高分。 
            oldEntry.submitTime = submitTime; // 更新提交时间。 
            oldEntry.playerName = playerName; // 更新玩家昵称。 
            return; // 更新完成后返回。 
        } // 已有成绩分支结束。 
        RankEntry entry = new RankEntry(); // 创建新的排行榜记录。 
        entry.playerId = playerId; // 保存玩家 ID。 
        entry.playerName = playerName; // 保存玩家昵称。 
        entry.score = score; // 保存玩家分数。 
        entry.submitTime = submitTime; // 保存提交时间。 
        rankMap[playerId] = entry; // 写入排行榜字典。 
    } // SubmitScore 方法结束。 
    public RankPage GetPage(int pageIndex, int pageSize) // 获取排行榜分页数据。 
    { // GetPage 方法开始。 
        List<RankEntry> sorted = rankMap.Values.OrderByDescending(e => e.score).ThenBy(e => e.submitTime).ToList(); // 按分数降序、时间升序排序。 
        List<RankEntry> page = sorted.Skip(pageIndex * pageSize).Take(pageSize).ToList(); // 按页码截取数据。 
        return new RankPage { pageIndex = pageIndex, pageSize = pageSize, entries = page }; // 返回分页结果。 
    } // GetPage 方法结束。 
    public int GetMyRank(string playerId) // 查询玩家自己的名次。 
    { // GetMyRank 方法开始。 
        List<RankEntry> sorted = rankMap.Values.OrderByDescending(e => e.score).ThenBy(e => e.submitTime).ToList(); // 重新得到有序排名。 
        for (int i = 0; i < sorted.Count; i++) // 遍历有序列表。 
        { // for 循环开始。 
            if (sorted[i].playerId == playerId) return i + 1; // 找到玩家后返回从 1 开始的名次。 
        } // for 循环结束。 
        return -1; // 玩家不在榜上时返回 -1。 
    } // GetMyRank 方法结束。 
} // LeaderboardSystem 类结束。

项目注意点

单机 Demo 可以本地排序;联网项目必须服务端校验成绩。

客户端 UI 不要每帧刷新榜单,应该打开时请求、下拉刷新或定时刷新。

排行榜列表要用虚拟列表,避免几百上千条 Item 一次性创建。

Top100 可以缓存;全量排行要分页,不要一次拉完整榜。

赛季排行榜要按 seasonId 隔离数据,结算奖励也要绑定同一个赛季。

要防刷分:校验战斗日志、时间上限、伤害上限、关卡合法性。

设计一个成就系统

unity-achievement-system

标准答案

成就系统就是:玩家在游戏中完成某些长期目标后,系统自动记录进度、判断完成状态,并允许玩家领取奖励。

核心流程是:

玩法事件 -> 成就系统监听 -> 匹配成就配置 -> 更新进度 -> 判断完成 -> 刷新红点 -> 玩家领取奖励 -> 保存领取状态

它最好是事件驱动,比如击杀怪物、通关副本、获得装备、累计登录时抛事件,成就系统只处理相关事件。

底层原理

成就系统一般分三类数据:

配置数据:成就 ID、事件类型、目标数量、奖励、分类、前置条件。

运行时数据:当前进度、是否完成、是否已领奖。

事件数据:这次发生了什么,比如 KillMonsterClearStageCollectItem

不要每帧扫描所有成就,也不要每次 UI 打开才计算。正确做法是:玩法系统发生事件时通知成就系统,成就系统只更新相关成就,然后保存进度。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 和 Dictionary 集合。 
public enum AchievementEventType // 定义成就事件类型。 
{ // AchievementEventType 枚举开始。 
    KillMonster, // 击杀怪物事件。 
    ClearStage, // 通关关卡事件。 
    CollectItem, // 收集道具事件。 
} // AchievementEventType 枚举结束。 
public enum AchievementState // 定义成就状态。 
{ // AchievementState 枚举开始。 
    Doing, // 进行中。 
    Done, // 已完成但未领取。 
    Claimed, // 已领取奖励。 
} // AchievementState 枚举结束。 
public sealed class AchievementConfig // 定义成就配置。 
{ // AchievementConfig 类开始。 
    public int id; // 成就 ID。 
    public AchievementEventType eventType; // 该成就监听的事件类型。 
    public int targetValue; // 成就目标进度。 
    public int rewardGold; // 成就奖励金币。 
} // AchievementConfig 类结束。 
public sealed class AchievementProgress // 定义成就运行时进度。 
{ // AchievementProgress 类开始。 
    public int id; // 成就 ID。 
    public int value; // 当前进度。 
    public AchievementState state; // 当前成就状态。 
} // AchievementProgress 类结束。 
public sealed class AchievementEvent // 定义玩法系统抛出的事件。 
{ // AchievementEvent 类开始。 
    public AchievementEventType eventType; // 事件类型。 
    public int value; // 本次事件增加的进度。 
} // AchievementEvent 类结束。 
public sealed class AchievementSystem // 定义成就系统。 
{ // AchievementSystem 类开始。 
    private readonly Dictionary<int, AchievementConfig> configs = new Dictionary<int, AchievementConfig>(); // 按成就 ID 保存配置。 
    private readonly Dictionary<int, AchievementProgress> progresses = new Dictionary<int, AchievementProgress>(); // 按成就 ID 保存进度。 
    private readonly Dictionary<AchievementEventType, List<int>> eventIndex = new Dictionary<AchievementEventType, List<int>>(); // 按事件类型建立成就索引。 
    public void AddConfig(AchievementConfig config) // 添加成就配置。 
    { // AddConfig 方法开始。 
        configs[config.id] = config; // 保存配置到字典。 
        progresses[config.id] = new AchievementProgress { id = config.id, value = 0, state = AchievementState.Doing }; // 初始化运行时进度。 
        if (!eventIndex.ContainsKey(config.eventType)) eventIndex[config.eventType] = new List<int>(); // 如果索引不存在就创建列表。 
        eventIndex[config.eventType].Add(config.id); // 把成就 ID 挂到对应事件类型下。 
    } // AddConfig 方法结束。 
    public void OnGameEvent(AchievementEvent gameEvent) // 接收玩法事件并更新成就。 
    { // OnGameEvent 方法开始。 
        if (!eventIndex.TryGetValue(gameEvent.eventType, out List<int> ids)) return; // 没有成就监听该事件就直接返回。 
        foreach (int id in ids) // 遍历该事件关联的成就。 
        { // foreach 循环开始。 
            AchievementConfig config = configs[id]; // 取出成就配置。 
            AchievementProgress progress = progresses[id]; // 取出成就进度。 
            if (progress.state != AchievementState.Doing) continue; // 已完成或已领取的成就不再累加。 
            progress.value += gameEvent.value; // 累加本次事件带来的进度。 
            if (progress.value >= config.targetValue) // 判断是否达到目标值。 
            { // 完成分支开始。 
                progress.value = config.targetValue; // 把进度限制到目标值。 
                progress.state = AchievementState.Done; // 标记为可领取。 
                RefreshRedPoint(); // 刷新红点。 
            } // 完成分支结束。 
            SaveProgress(progress); // 保存成就进度。 
        } // foreach 循环结束。 
    } // OnGameEvent 方法结束。 
    public bool ClaimReward(int achievementId) // 领取成就奖励。 
    { // ClaimReward 方法开始。 
        AchievementProgress progress = progresses[achievementId]; // 取出成就进度。 
        if (progress.state != AchievementState.Done) return false; // 只有完成未领取的成就才能领奖。 
        AchievementConfig config = configs[achievementId]; // 取出成就配置。 
        AddGold(config.rewardGold); // 发放金币奖励。 
        progress.state = AchievementState.Claimed; // 标记为已领取。 
        SaveProgress(progress); // 保存领取状态。 
        RefreshRedPoint(); // 更新红点状态。 
        return true; // 返回领取成功。 
    } // ClaimReward 方法结束。 
    private void AddGold(int gold) { } // 示例:真实项目接背包或货币系统。 
    private void SaveProgress(AchievementProgress progress) { } // 示例:真实项目写存档或请求服务端。 
    private void RefreshRedPoint() { } // 示例:真实项目通知红点系统刷新。 
} // AchievementSystem 类结束。

项目注意点

成就完成和奖励领取要分开,完成只是 Done,领奖后才是 Claimed

重要成就最好服务端校验,避免客户端伪造击杀数、通关数。

成就很多时要建立事件索引,不要每次事件都遍历所有成就。

红点只在状态变化时刷新,不要每帧全量检查。

登录时可以根据累计统计补算离线成就,比如累计登录、累计击杀。

设计一个每日任务系统

unity-daily-task-system

标准答案

每日任务系统就是:每天按服务器日期刷新一组任务,玩家完成玩法行为后更新任务进度,完成后领取奖励,并累计活跃度。

核心流程是:

登录游戏 -> 获取服务器时间 -> 检查是否跨天 -> 初始化今日任务 -> 监听玩法事件 -> 更新任务进度 -> 完成后领奖 -> 刷新红点

重点是:每日任务不能依赖本地时间,应该以服务器时间为准

底层原理

每日任务一般分三类数据:

配置数据:任务 ID、事件类型、目标值、奖励、活跃度、开放条件。

运行时数据:今日日期 dateKey、当前进度、任务状态、是否已领奖。

重置数据:服务器日期、刷新时间点、旧日任务是否过期。

比如每天凌晨 5 点刷新,就不能简单用 DateTime.Today,而是要根据服务器时间算业务日期。否则玩家改手机时间,就可能重复刷新任务或重复领奖。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 和 Dictionary 集合。 
public enum DailyTaskEventType // 定义每日任务事件类型。 
{ // DailyTaskEventType 枚举开始。 
    Login, // 登录事件。 
    KillMonster, // 击杀怪物事件。 
    ClearStage, // 通关关卡事件。 
} // DailyTaskEventType 枚举结束。 
public enum DailyTaskState // 定义每日任务状态。 
{ // DailyTaskState 枚举开始。 
    Doing, // 进行中。 
    Done, // 已完成但未领取。 
    Claimed, // 已领取奖励。 
} // DailyTaskState 枚举结束。 
public sealed class DailyTaskConfig // 定义每日任务配置。 
{ // DailyTaskConfig 类开始。 
    public int id; // 任务 ID。 
    public DailyTaskEventType eventType; // 任务监听的事件类型。 
    public int targetValue; // 任务目标进度。 
    public int rewardGold; // 任务奖励金币。 
    public int activityPoint; // 完成任务后增加的活跃度。 
} // DailyTaskConfig 类结束。 
public sealed class DailyTaskProgress // 定义每日任务进度。 
{ // DailyTaskProgress 类开始。 
    public int taskId; // 任务 ID。 
    public int dateKey; // 今日日期标记,例如 20260803。 
    public int value; // 当前任务进度。 
    public DailyTaskState state; // 当前任务状态。 
} // DailyTaskProgress 类结束。 
public sealed class DailyTaskEvent // 定义玩法事件。 
{ // DailyTaskEvent 类开始。 
    public DailyTaskEventType eventType; // 事件类型。 
    public int value; // 本次事件增加的进度。 
} // DailyTaskEvent 类结束。 
public sealed class DailyTaskSystem // 定义每日任务系统。 
{ // DailyTaskSystem 类开始。 
    private int currentDateKey; // 当前服务器日期标记。 
    private int activityPoint; // 今日活跃度积分。 
    private readonly Dictionary<int, DailyTaskConfig> configs = new Dictionary<int, DailyTaskConfig>(); // 保存任务配置。 
    private readonly Dictionary<int, DailyTaskProgress> progresses = new Dictionary<int, DailyTaskProgress>(); // 保存任务进度。 
    public void InitToday(int serverDateKey, List<DailyTaskConfig> todayConfigs) // 初始化今日任务。 
    { // InitToday 方法开始。 
        if (currentDateKey == serverDateKey) return; // 如果已经是今天的数据,就不重复初始化。 
        currentDateKey = serverDateKey; // 更新当前日期标记。 
        activityPoint = 0; // 跨天后清空今日活跃度。 
        configs.Clear(); // 清空旧任务配置。 
        progresses.Clear(); // 清空旧任务进度。 
        foreach (DailyTaskConfig config in todayConfigs) // 遍历今日任务配置。 
        { // foreach 循环开始。 
            configs[config.id] = config; // 保存任务配置。 
            DailyTaskProgress progress = new DailyTaskProgress(); // 创建任务进度。 
            progress.taskId = config.id; // 设置任务 ID。 
            progress.dateKey = serverDateKey; // 设置今日日期标记。 
            progress.value = 0; // 初始化任务进度为 0。 
            progress.state = DailyTaskState.Doing; // 初始化任务状态为进行中。 
            progresses[config.id] = progress; // 保存任务进度。 
        } // foreach 循环结束。 
        SaveAll(); // 保存今日任务数据。 
        RefreshRedPoint(); // 刷新红点。 
    } // InitToday 方法结束。 
    public void OnGameEvent(DailyTaskEvent gameEvent) // 处理玩法事件。 
    { // OnGameEvent 方法开始。 
        foreach (DailyTaskConfig config in configs.Values) // 遍历所有今日任务配置。 
        { // foreach 循环开始。 
            if (config.eventType != gameEvent.eventType) continue; // 只处理事件类型匹配的任务。 
            DailyTaskProgress progress = progresses[config.id]; // 取出对应任务进度。 
            if (progress.state != DailyTaskState.Doing) continue; // 已完成或已领取的任务不再累加。 
            progress.value += gameEvent.value; // 累加任务进度。 
            if (progress.value >= config.targetValue) // 判断任务是否完成。 
            { // 完成分支开始。 
                progress.value = config.targetValue; // 限制进度不超过目标值。 
                progress.state = DailyTaskState.Done; // 标记为可领取。 
                RefreshRedPoint(); // 刷新红点提示。 
            } // 完成分支结束。 
            SaveProgress(progress); // 保存任务进度。 
        } // foreach 循环结束。 
    } // OnGameEvent 方法结束。 
    public bool ClaimReward(int taskId) // 领取每日任务奖励。 
    { // ClaimReward 方法开始。 
        DailyTaskProgress progress = progresses[taskId]; // 取出任务进度。 
        if (progress.state != DailyTaskState.Done) return false; // 只有完成未领取的任务才能领奖。 
        DailyTaskConfig config = configs[taskId]; // 取出任务配置。 
        AddGold(config.rewardGold); // 发放金币奖励。 
        activityPoint += config.activityPoint; // 累加今日活跃度。 
        progress.state = DailyTaskState.Claimed; // 标记任务已领取。 
        SaveProgress(progress); // 保存任务领取状态。 
        SaveActivityPoint(); // 保存今日活跃度。 
        RefreshRedPoint(); // 刷新红点。 
        return true; // 返回领取成功。 
    } // ClaimReward 方法结束。 
    private void AddGold(int gold) { } // 示例:真实项目接货币或背包系统。 
    private void SaveProgress(DailyTaskProgress progress) { } // 示例:真实项目保存单个任务进度。 
    private void SaveActivityPoint() { } // 示例:真实项目保存活跃度数据。 
    private void SaveAll() { } // 示例:真实项目保存全部今日任务数据。 
    private void RefreshRedPoint() { } // 示例:真实项目通知红点系统刷新。 
} // DailyTaskSystem 类结束。

项目注意点

每日任务刷新要用服务器时间,常见是凌晨 5 点刷新,而不是自然 0 点。

任务完成和奖励领取要分开,完成只是 Done,领取后才是 Claimed

活跃度宝箱和任务奖励要分开保存,宝箱也要防重复领取。

跨天时要处理旧任务:旧日未领取奖励是否过期,要产品规则先定清楚。

红点只在任务状态变化或领奖状态变化时刷新,不要每帧扫描任务列表。

联网项目领奖要服务端做幂等,防止重复请求导致奖励翻倍。

设计一个签到系统

unity-sign-in-system

标准答案

签到系统就是:玩家每天进入游戏后,根据服务器日期判断今天是否可签到,签到成功后发放奖励,并保存已签到日期、连续签到天数和领取状态。

核心流程是:

登录游戏 -> 获取服务器时间 -> 判断今日是否已签 -> 点击签到 -> 发放奖励 -> 保存签到记录 -> 刷新红点

重点是:签到不能依赖本地时间,正式项目要以服务器时间为准

底层原理

签到系统一般分三类数据:

配置数据:第几天奖励、累计奖励、补签消耗、活动时间。

状态数据:当前月份、已签到日期、连续签到天数、补签次数。

时间数据:服务器日期 dateKey,比如 20260803

如果是月签到,通常保存“本月已签过哪些日期”;如果是七日签到,通常保存“累计签到到了第几天”。这两个不要混在一起,否则跨月和活动重开时很容易出错。

C# 简化实现

c
using System.Collections.Generic; // 引入 HashSet 集合。 
public sealed class SignInConfig // 定义签到配置。 
{ // SignInConfig 类开始。 
    public int dayIndex; // 第几天签到。 
    public int rewardGold; // 当天签到奖励金币。 
    public int makeupCost; // 补签需要消耗的道具或货币。 
} // SignInConfig 类结束。 
public sealed class SignInState // 定义玩家签到状态。 
{ // SignInState 类开始。 
    public int monthKey; // 当前月份标记,例如 202608。 
    public int lastSignDateKey; // 上一次签到日期,例如 20260803。 
    public int streakCount; // 连续签到天数。 
    public int makeupCount; // 本月已经补签次数。 
    public HashSet<int> signedDateKeys = new HashSet<int>(); // 保存本月已经签到过的日期。 
} // SignInState 类结束。 
public sealed class SignInSystem // 定义签到系统。 
{ // SignInSystem 类开始。 
    private readonly SignInState state = new SignInState(); // 保存当前玩家签到状态。 
    public bool CanSignIn(int serverDateKey) // 判断今天是否可以签到。 
    { // CanSignIn 方法开始。 
        return !state.signedDateKeys.Contains(serverDateKey); // 今日没签过就可以签到。 
    } // CanSignIn 方法结束。 
    public bool SignIn(int serverDateKey, int serverMonthKey, SignInConfig config) // 执行今日签到。 
    { // SignIn 方法开始。 
        ResetIfNewMonth(serverMonthKey); // 如果跨月就重置月签到数据。 
        if (!CanSignIn(serverDateKey)) return false; // 如果今天已经签过就拒绝。 
        state.signedDateKeys.Add(serverDateKey); // 记录今天已经签到。 
        UpdateStreak(serverDateKey); // 更新连续签到天数。 
        AddGold(config.rewardGold); // 发放当天签到奖励。 
        state.lastSignDateKey = serverDateKey; // 记录最后签到日期。 
        SaveState(); // 保存签到状态。 
        RefreshRedPoint(); // 刷新签到红点。 
        return true; // 返回签到成功。 
    } // SignIn 方法结束。 
    public bool MakeupSignIn(int missedDateKey, SignInConfig config) // 执行补签。 
    { // MakeupSignIn 方法开始。 
        if (state.signedDateKeys.Contains(missedDateKey)) return false; // 已经签过的日期不能补签。 
        if (!HasMakeupCost(config.makeupCost)) return false; // 补签材料不足就拒绝。 
        ConsumeMakeupCost(config.makeupCost); // 扣除补签消耗。 
        state.signedDateKeys.Add(missedDateKey); // 把漏签日期标记为已签。 
        state.makeupCount++; // 增加补签次数。 
        AddGold(config.rewardGold); // 发放补签日期对应奖励。 
        SaveState(); // 保存补签状态。 
        RefreshRedPoint(); // 刷新红点。 
        return true; // 返回补签成功。 
    } // MakeupSignIn 方法结束。 
    private void ResetIfNewMonth(int serverMonthKey) // 检查是否跨月。 
    { // ResetIfNewMonth 方法开始。 
        if (state.monthKey == serverMonthKey) return; // 还是同一个月就不重置。 
        state.monthKey = serverMonthKey; // 更新当前月份。 
        state.signedDateKeys.Clear(); // 清空本月已签到日期。 
        state.makeupCount = 0; // 清空本月补签次数。 
        state.streakCount = 0; // 根据规则决定是否清连续签到,这里示例清空。 
    } // ResetIfNewMonth 方法结束。 
    private void UpdateStreak(int serverDateKey) // 更新连续签到天数。 
    { // UpdateStreak 方法开始。 
        state.streakCount++; // 示例简化:真实项目应判断昨天是否已签。 
    } // UpdateStreak 方法结束。 
    private bool HasMakeupCost(int cost) { return true; } // 示例:真实项目检查补签道具或货币。 
    private void ConsumeMakeupCost(int cost) { } // 示例:真实项目扣除补签消耗。 
    private void AddGold(int gold) { } // 示例:真实项目接货币或背包系统。 
    private void SaveState() { } // 示例:真实项目保存到本地或服务端。 
    private void RefreshRedPoint() { } // 示例:真实项目通知红点系统刷新。 
} // SignInSystem 类结束。

项目注意点

签到时间必须用服务器时间,不能用本地时间。

同一天重复签到要直接拒绝,服务端最好用签到流水号做幂等。

月签到和七日签到要分开设计,月签到按自然月,七日签到按累计天数。

补签只能补过去漏签的日期,不能补未来日期。

跨月后要清理月签到状态,避免旧数据影响新月份。

红点只在“今日未签、可补签、状态变化”时刷新,不要每帧扫描。

设计一个活动系统

unity-activity-system

标准答案

活动系统就是:用一套通用框架承载各种运营活动,比如登录活动、累计消费、限时副本、兑换商店、排行榜活动。它通过配置控制活动时间、入口、规则、进度、奖励和结束清理。

核心流程是:

拉取活动配置 -> 校验服务器时间 -> 判断活动是否开放 -> 显示活动入口 -> 监听玩法事件 -> 更新活动进度 -> 领取奖励 -> 保存状态 -> 活动结束清理

重点是:活动框架和具体玩法要拆开。框架负责时间、入口、奖励、红点;具体玩法负责自己的规则。

底层原理

活动系统一般分三层:

配置层:活动 ID、活动类型、开始时间、结束时间、入口图标、奖励配置、规则参数。

状态层:活动是否开放、当前进度、是否完成、奖励是否已领取、活动版本号。

服务层:判断开放时间、更新进度、处理奖励、刷新红点、活动结束清理。

正式项目里,活动开放时间、奖励领取、进度校验最好由服务端控制。客户端只负责展示入口、倒计时、红点和活动界面。

C# 简化实现

c
using System.Collections.Generic; // 引入 Dictionary 集合。 
public enum ActivityType // 定义活动类型。 
{ // ActivityType 枚举开始。 
    Login, // 登录活动。 
    Consume, // 累计消费活动。 
    Dungeon, // 限时副本活动。 
} // ActivityType 枚举结束。 
public enum ActivityPhase // 定义活动阶段。 
{ // ActivityPhase 枚举开始。 
    NotStart, // 未开始。 
    Open, // 进行中。 
    Reward, // 领奖期。 
    Closed, // 已结束。 
} // ActivityPhase 枚举结束。 
public sealed class ActivityConfig // 定义活动配置。 
{ // ActivityConfig 类开始。 
    public int id; // 活动 ID。 
    public ActivityType type; // 活动类型。 
    public long startTime; // 活动开始时间戳。 
    public long endTime; // 活动结束时间戳。 
    public int targetValue; // 活动目标进度。 
    public int rewardGold; // 活动奖励金币。 
} // ActivityConfig 类结束。 
public sealed class ActivityState // 定义玩家活动状态。 
{ // ActivityState 类开始。 
    public int activityId; // 活动 ID。 
    public int progress; // 当前活动进度。 
    public bool claimed; // 是否已经领取奖励。 
    public ActivityPhase phase; // 当前活动阶段。 
} // ActivityState 类结束。 
public sealed class ActivityEvent // 定义活动事件。 
{ // ActivityEvent 类开始。 
    public ActivityType type; // 事件对应的活动类型。 
    public int value; // 本次事件增加的进度。 
} // ActivityEvent 类结束。 
public sealed class ActivitySystem // 定义活动系统。 
{ // ActivitySystem 类开始。 
    private readonly Dictionary<int, ActivityConfig> configs = new Dictionary<int, ActivityConfig>(); // 保存活动配置。 
    private readonly Dictionary<int, ActivityState> states = new Dictionary<int, ActivityState>(); // 保存活动状态。 
    public void AddActivity(ActivityConfig config, long serverNow) // 添加或更新活动配置。 
    { // AddActivity 方法开始。 
        configs[config.id] = config; // 保存活动配置。 
        if (!states.ContainsKey(config.id)) // 如果没有该活动状态。 
        { // 初始化状态分支开始。 
            states[config.id] = new ActivityState { activityId = config.id, progress = 0, claimed = false }; // 创建活动状态。 
        } // 初始化状态分支结束。 
        states[config.id].phase = CalcPhase(config, serverNow); // 根据服务器时间刷新活动阶段。 
        RefreshEntrance(config.id); // 刷新活动入口。 
    } // AddActivity 方法结束。 
    public void OnActivityEvent(ActivityEvent activityEvent, long serverNow) // 处理玩法事件。 
    { // OnActivityEvent 方法开始。 
        foreach (ActivityConfig config in configs.Values) // 遍历所有活动配置。 
        { // foreach 循环开始。 
            if (config.type != activityEvent.type) continue; // 只处理类型匹配的活动。 
            ActivityState state = states[config.id]; // 取出活动状态。 
            state.phase = CalcPhase(config, serverNow); // 根据服务器时间刷新阶段。 
            if (state.phase != ActivityPhase.Open) continue; // 非进行中活动不更新进度。 
            if (state.claimed) continue; // 已领奖活动不再更新。 
            state.progress += activityEvent.value; // 累加活动进度。 
            if (state.progress > config.targetValue) state.progress = config.targetValue; // 限制进度不超过目标。 
            SaveState(state); // 保存活动进度。 
            RefreshRedPoint(config.id); // 刷新红点。 
        } // foreach 循环结束。 
    } // OnActivityEvent 方法结束。 
    public bool ClaimReward(int activityId, long serverNow) // 领取活动奖励。 
    { // ClaimReward 方法开始。 
        ActivityConfig config = configs[activityId]; // 取出活动配置。 
        ActivityState state = states[activityId]; // 取出活动状态。 
        state.phase = CalcPhase(config, serverNow); // 刷新活动阶段。 
        if (state.claimed) return false; // 已经领取过就拒绝。 
        if (state.phase != ActivityPhase.Open && state.phase != ActivityPhase.Reward) return false; // 只有开放期或领奖期可领奖。 
        if (state.progress < config.targetValue) return false; // 进度不足不能领奖。 
        AddGold(config.rewardGold); // 发放活动奖励。 
        state.claimed = true; // 标记奖励已领取。 
        SaveState(state); // 保存领取状态。 
        RefreshRedPoint(activityId); // 刷新红点。 
        return true; // 返回领取成功。 
    } // ClaimReward 方法结束。 
    private ActivityPhase CalcPhase(ActivityConfig config, long serverNow) // 根据服务器时间计算活动阶段。 
    { // CalcPhase 方法开始。 
        if (serverNow < config.startTime) return ActivityPhase.NotStart; // 当前时间早于开始时间,活动未开始。 
        if (serverNow <= config.endTime) return ActivityPhase.Open; // 当前时间在活动时间内,活动进行中。 
        return ActivityPhase.Closed; // 超过结束时间,活动已结束。 
    } // CalcPhase 方法结束。 
    private void AddGold(int gold) { } // 示例:真实项目接奖励或背包系统。 
    private void SaveState(ActivityState state) { } // 示例:真实项目保存活动状态。 
    private void RefreshEntrance(int activityId) { } // 示例:真实项目刷新活动入口显示。 
    private void RefreshRedPoint(int activityId) { } // 示例:真实项目通知红点系统刷新。 
} // ActivitySystem 类结束。

项目注意点

活动时间要用服务器时间,不要用本地时间。

活动奖励要做幂等,claimed 状态必须保存,重复请求不能重复发奖。

活动入口、红点、倒计时要根据状态刷新:未开始、进行中、领奖期、已结束。

活动配置最好支持远端下发或热更新,但要有版本号、校验和回滚。

活动结束时要处理未领奖奖励:是过期、邮件补发,还是进入领奖期,规则要提前定。

不要每个活动都写一套硬编码逻辑,应该抽象出通用活动框架。

设计一个抽卡系统

unity-gacha-system

标准答案

抽卡系统就是:玩家消耗钻石、抽卡券等资源,从指定卡池里按概率获得角色、装备、碎片或道具,并处理 UP、保底、重复转换和抽卡记录。

核心流程是:

选择卡池 -> 校验资源 -> 扣除消耗 -> 判断保底 -> 按权重随机 -> 发放奖励 -> 更新保底 -> 保存日志 -> 播放抽卡表现

正式项目里重点是:抽卡结果必须服务端生成,客户端只展示动画和结果

底层原理

抽卡系统一般分四层:

配置层:卡池 ID、开放时间、消耗、奖励列表、权重、稀有度、UP、保底次数。

状态层:玩家在该卡池的累计抽数、保底计数、历史记录、是否拥有角色。

服务层:校验资源、扣资源、随机结果、处理保底、发奖、写日志。

表现层:抽卡动画、跳过动画、结果展示、重复角色转碎片提示。

最关键的是事务:扣资源、生成结果、发奖励、更新保底、写日志要一起成功,不能扣了钻石但奖励没到账。

C# 简化实现

c
using System; // 引入 Random 随机数。 
using System.Collections.Generic; // 引入 List 集合。 
public enum GachaRarity // 定义抽卡稀有度。 
{ // GachaRarity 枚举开始。 
    R, // 普通奖励。 
    SR, // 较稀有奖励。 
    SSR // 最高稀有奖励。 
} // GachaRarity 枚举结束。 
public sealed class GachaItemConfig // 定义卡池里的单个奖励配置。 
{ // GachaItemConfig 类开始。 
    public int rewardId; // 奖励 ID,例如角色 ID 或道具 ID。 
    public int weight; // 抽中的权重。 
    public GachaRarity rarity; // 奖励稀有度。 
} // GachaItemConfig 类结束。 
public sealed class GachaPoolConfig // 定义卡池配置。 
{ // GachaPoolConfig 类开始。 
    public int poolId; // 卡池 ID。 
    public int costItemId; // 抽卡消耗的道具 ID。 
    public int costCount; // 单抽消耗数量。 
    public int ssrPityLimit; // SSR 保底次数。 
    public List<GachaItemConfig> items = new List<GachaItemConfig>(); // 卡池奖励列表。 
} // GachaPoolConfig 类结束。 
public sealed class PlayerGachaState // 定义玩家抽卡状态。 
{ // PlayerGachaState 类开始。 
    public int poolId; // 对应的卡池 ID。 
    public int totalDrawCount; // 总抽卡次数。 
    public int pityCount; // 距离上次 SSR 的未出次数。 
} // PlayerGachaState 类结束。 
public sealed class GachaResult // 定义单次抽卡结果。 
{ // GachaResult 类开始。 
    public int rewardId; // 抽到的奖励 ID。 
    public GachaRarity rarity; // 抽到的奖励稀有度。 
    public bool isDuplicate; // 是否是重复角色或重复奖励。 
} // GachaResult 类结束。 
public sealed class GachaSystem // 定义抽卡系统。 
{ // GachaSystem 类开始。 
    private readonly Random random = new Random(); // 创建随机数对象,正式项目应放服务端。 
    public List<GachaResult> Draw(GachaPoolConfig pool, PlayerGachaState state, int count) // 执行抽卡。 
    { // Draw 方法开始。 
        List<GachaResult> results = new List<GachaResult>(); // 创建抽卡结果列表。 
        if (count <= 0) return results; // 抽卡次数非法时直接返回空结果。 
        if (!HasCost(pool.costItemId, pool.costCount * count)) return results; // 资源不足时拒绝抽卡。 
        ConsumeCost(pool.costItemId, pool.costCount * count); // 扣除抽卡消耗。 
        for (int i = 0; i < count; i++) // 按抽卡次数循环。 
        { // for 循环开始。 
            GachaItemConfig item = RollOne(pool, state); // 抽出一个奖励。 
            state.totalDrawCount++; // 增加总抽卡次数。 
            bool isSSR = item.rarity == GachaRarity.SSR; // 判断是否抽到 SSR。 
            state.pityCount = isSSR ? 0 : state.pityCount + 1; // SSR 重置保底,否则累加保底。 
            bool duplicate = HasOwned(item.rewardId); // 判断是否已经拥有该奖励。 
            AddRewardOrShard(item, duplicate); // 发放奖励或转换碎片。 
            GachaResult result = new GachaResult(); // 创建单次抽卡结果。 
            result.rewardId = item.rewardId; // 保存奖励 ID。 
            result.rarity = item.rarity; // 保存奖励稀有度。 
            result.isDuplicate = duplicate; // 保存是否重复。 
            results.Add(result); // 把结果加入列表。 
        } // for 循环结束。 
        SaveGachaState(state); // 保存玩家抽卡状态。 
        SaveGachaLog(pool.poolId, results); // 保存抽卡日志。 
        return results; // 返回抽卡结果。 
    } // Draw 方法结束。 
    private GachaItemConfig RollOne(GachaPoolConfig pool, PlayerGachaState state) // 抽取单个奖励。 
    { // RollOne 方法开始。 
        if (state.pityCount + 1 >= pool.ssrPityLimit) return PickFirstSSR(pool); // 达到保底时强制抽 SSR。 
        return PickByWeight(pool.items); // 未触发保底时按权重随机。 
    } // RollOne 方法结束。 
    private GachaItemConfig PickByWeight(List<GachaItemConfig> items) // 按权重随机一个奖励。 
    { // PickByWeight 方法开始。 
        int totalWeight = 0; // 统计总权重。 
        foreach (GachaItemConfig item in items) totalWeight += item.weight; // 累加所有奖励权重。 
        int roll = random.Next(1, totalWeight + 1); // 在总权重范围内随机一个点。 
        foreach (GachaItemConfig item in items) // 遍历奖励列表。 
        { // foreach 循环开始。 
            roll -= item.weight; // 扣除当前奖励的权重区间。 
            if (roll <= 0) return item; // 随机点落入当前区间就返回该奖励。 
        } // foreach 循环结束。 
        return items[0]; // 理论兜底,防止异常。 
    } // PickByWeight 方法结束。 
    private GachaItemConfig PickFirstSSR(GachaPoolConfig pool) // 从卡池中取一个 SSR 奖励。 
    { // PickFirstSSR 方法开始。 
        foreach (GachaItemConfig item in pool.items) // 遍历卡池奖励。 
        { // foreach 循环开始。 
            if (item.rarity == GachaRarity.SSR) return item; // 找到 SSR 就返回。 
        } // foreach 循环结束。 
        return PickByWeight(pool.items); // 没有 SSR 配置时退回普通权重随机。 
    } // PickFirstSSR 方法结束。 
    private bool HasCost(int itemId, int count) { return true; } // 示例:真实项目检查钻石或抽卡券。 
    private void ConsumeCost(int itemId, int count) { } // 示例:真实项目扣除抽卡资源。 
    private bool HasOwned(int rewardId) { return false; } // 示例:真实项目检查是否已拥有角色。 
    private void AddRewardOrShard(GachaItemConfig item, bool duplicate) { } // 示例:真实项目发角色或转碎片。 
    private void SaveGachaState(PlayerGachaState state) { } // 示例:真实项目保存抽卡状态。 
    private void SaveGachaLog(int poolId, List<GachaResult> results) { } // 示例:真实项目保存抽卡日志。 
} // GachaSystem 类结束。

项目注意点

客户端不要自己随机抽卡结果,否则很容易被改内存或改包。

十连规则要写清楚:是否保底 SR,是否影响 SSR 大保底。

不同卡池的保底是否继承,要看产品规则,代码里不能写死。

重复角色一般转碎片、命座、升星材料或通用货币。

概率公示要和配置一致,否则运营和合规风险很大。

抽卡请求要有流水号,失败后可以查询结果,避免扣费成功但奖励没到账。

设计一个概率保底系统

unity-probability-pity-system

标准答案

概率保底系统就是:在基础随机概率之外,记录玩家连续未命中的次数;如果一直没中目标奖励,就逐步提高概率,或者到指定次数后强制命中。

核心流程是:

读取保底状态 -> 计算当前概率 -> 判断是否触发硬保底 -> 执行随机 -> 命中则重置计数 -> 未命中则累加计数 -> 保存状态和日志

常见场景有:抽卡 SSR 保底、装备强化成功保底、稀有材料掉落保底。

底层原理

保底系统一般有三种规则:

基础概率:普通情况下按固定概率随机,比如 0.6% 出 SSR。

软保底:达到某个次数后,概率逐步提高,比如 70 抽后每抽增加概率。

硬保底:达到最大次数后强制命中,比如 90 抽必出 SSR。

还有一种常见规则叫“大保底”:如果这次出了高稀有但不是 UP,下一次高稀有必定是 UP。

重点是:保底计数必须保存到玩家数据里,而且通常要按卡池或玩法分开保存。

C# 简化实现

c
using System; // 引入 Random 随机数。 
public sealed class PityConfig // 定义保底配置。 
{ // PityConfig 类开始。 
    public double baseRate; // 基础命中概率,例如 0.006 表示 0.6%。 
    public int softPityStart; // 软保底开始次数,例如 70 抽后开始加概率。 
    public double softPityStep; // 每超过一次软保底增加的概率。 
    public int hardPityLimit; // 硬保底次数,例如 90 抽必出。 
} // PityConfig 类结束。 
public sealed class PityState // 定义玩家保底状态。 
{ // PityState 类开始。 
    public int missCount; // 连续未命中目标奖励的次数。 
    public bool guaranteeUp; // 是否触发大保底,下一次高稀有必定 UP。 
} // PityState 类结束。 
public sealed class PityResult // 定义保底随机结果。 
{ // PityResult 类开始。 
    public bool hitTarget; // 是否命中目标奖励。 
    public bool triggeredHardPity; // 是否触发硬保底。 
    public double finalRate; // 本次实际使用的概率。 
    public int missCountAfter; // 本次随机后的未命中次数。 
} // PityResult 类结束。 
public sealed class ProbabilityPitySystem // 定义概率保底系统。 
{ // ProbabilityPitySystem 类开始。 
    private readonly Random random = new Random(); // 创建随机数对象,正式项目应放服务端。 
    public PityResult Roll(PityConfig config, PityState state) // 执行一次带保底的随机。 
    { // Roll 方法开始。 
        double finalRate = CalcFinalRate(config, state); // 计算本次实际概率。 
        bool hardPity = state.missCount + 1 >= config.hardPityLimit; // 判断是否达到硬保底。 
        bool hit = hardPity || random.NextDouble() < finalRate; // 硬保底直接命中,否则按概率随机。 
        if (hit) // 如果命中目标奖励。 
        { // 命中分支开始。 
            state.missCount = 0; // 命中后重置未命中计数。 
        } // 命中分支结束。 
        else // 如果没有命中目标奖励。 
        { // 未命中分支开始。 
            state.missCount++; // 未命中后累加保底计数。 
        } // 未命中分支结束。 
        SavePityState(state); // 保存新的保底状态。 
        PityResult result = new PityResult(); // 创建结果对象。 
        result.hitTarget = hit; // 保存是否命中。 
        result.triggeredHardPity = hardPity; // 保存是否触发硬保底。 
        result.finalRate = finalRate; // 保存本次实际概率。 
        result.missCountAfter = state.missCount; // 保存随机后的计数。 
        SavePityLog(result); // 保存随机日志,方便排查和概率审计。 
        return result; // 返回随机结果。 
    } // Roll 方法结束。 
    private double CalcFinalRate(PityConfig config, PityState state) // 计算当前实际概率。 
    { // CalcFinalRate 方法开始。 
        double rate = config.baseRate; // 先使用基础概率。 
        if (state.missCount + 1 > config.softPityStart) // 判断是否进入软保底区间。 
        { // 软保底分支开始。 
            int over = state.missCount + 1 - config.softPityStart; // 计算超过软保底起点的次数。 
            rate += over * config.softPityStep; // 根据超过次数提高概率。 
        } // 软保底分支结束。 
        if (rate > 1.0) rate = 1.0; // 概率最大不能超过 100%。 
        return rate; // 返回最终概率。 
    } // CalcFinalRate 方法结束。 
    private void SavePityState(PityState state) { } // 示例:真实项目保存到玩家数据或服务端。 
    private void SavePityLog(PityResult result) { } // 示例:真实项目记录随机日志。 
} // ProbabilityPitySystem 类结束。

项目注意点

保底状态要按玩家、卡池、活动版本保存,不能所有卡池共用一个计数。

命中目标奖励后要重置计数;如果是大保底,还要处理 UP 标记。

正式项目随机应由服务端执行,客户端不能自己算结果。

概率公示要和真实配置一致,否则容易出运营和合规问题。

要写概率模拟工具,比如模拟一百万次抽样,检查平均抽数、保底触发率、极端情况是否符合预期。

设计一个角色养成系统

unity-character-progression-system

标准答案

角色养成系统就是:玩家通过经验、材料、碎片等资源提升角色的等级、突破、星级、技能等级,系统再统一重算角色属性、战力、红点和展示。

核心流程是:

选择角色 -> 读取配置 -> 校验等级上限和材料 -> 扣除资源 -> 更新角色状态 -> 重算属性 -> 保存数据 -> 刷新 UI

重点是:角色养成不要让 UI 自己改属性,应该统一走养成服务和属性系统

底层原理

角色养成一般分四层:

配置层:角色基础属性、成长曲线、升级经验、突破消耗、升星消耗、技能消耗。

状态层:角色当前等级、经验、突破阶、星级、技能等级。

服务层:负责校验资源、扣除材料、更新状态、保存日志。

属性层:根据等级、星级、突破、装备、Buff 等统一重算最终属性。

比如角色升级只改变 levelexp,不要直接到处改攻击力。攻击力应该由属性系统统一计算:

最终攻击 = 基础攻击 + 等级成长 + 升星加成 + 装备加成 + Buff 加成

C# 简化实现

c
using System.Collections.Generic; // 引入 Dictionary 集合。 
public sealed class CharacterState // 定义角色运行时状态。 
{ // CharacterState 类开始。 
    public int characterId; // 角色 ID。 
    public int level; // 当前等级。 
    public int exp; // 当前经验。 
    public int star; // 当前星级。 
    public int breakLevel; // 当前突破阶数。 
} // CharacterState 类结束。 
public sealed class CharacterLevelConfig // 定义角色等级配置。 
{ // CharacterLevelConfig 类开始。 
    public int level; // 等级。 
    public int needExp; // 升到下一级需要的经验。 
    public int maxLevelByBreak; // 当前突破阶允许的等级上限。 
} // CharacterLevelConfig 类结束。 
public sealed class CharacterDevelopSystem // 定义角色养成系统。 
{ // CharacterDevelopSystem 类开始。 
    private readonly Dictionary<int, CharacterLevelConfig> levelConfigs = new Dictionary<int, CharacterLevelConfig>(); // 保存等级配置。 
    public void AddExp(CharacterState character, int addExp) // 给角色增加经验。 
    { // AddExp 方法开始。 
        if (character == null) return; // 角色为空就直接返回。 
        if (addExp <= 0) return; // 增加经验非法就直接返回。 
        character.exp += addExp; // 累加角色经验。 
        TryLevelUp(character); // 尝试根据经验自动升级。 
        RecalculateAttribute(character); // 重新计算角色属性。 
        SaveCharacter(character); // 保存角色状态。 
        RefreshCharacterUI(character); // 刷新角色 UI。 
    } // AddExp 方法结束。 
    private void TryLevelUp(CharacterState character) // 尝试升级角色。 
    { // TryLevelUp 方法开始。 
        while (CanLevelUp(character)) // 只要满足条件就持续升级。 
        { // while 循环开始。 
            CharacterLevelConfig config = levelConfigs[character.level]; // 读取当前等级配置。 
            character.exp -= config.needExp; // 扣除升级需要的经验。 
            character.level++; // 提升角色等级。 
        } // while 循环结束。 
    } // TryLevelUp 方法结束。 
    private bool CanLevelUp(CharacterState character) // 判断角色是否可以升级。 
    { // CanLevelUp 方法开始。 
        if (!levelConfigs.ContainsKey(character.level)) return false; // 没有等级配置就不能升级。 
        CharacterLevelConfig config = levelConfigs[character.level]; // 读取当前等级配置。 
        if (character.level >= config.maxLevelByBreak) return false; // 达到当前突破等级上限就不能升级。 
        return character.exp >= config.needExp; // 经验足够才可以升级。 
    } // CanLevelUp 方法结束。 
    public bool TryBreakthrough(CharacterState character, int materialCount) // 尝试突破角色。 
    { // TryBreakthrough 方法开始。 
        if (character == null) return false; // 角色为空就失败。 
        if (materialCount <= 0) return false; // 材料数量非法就失败。 
        if (!HasBreakMaterial(character, materialCount)) return false; // 材料不足就失败。 
        ConsumeBreakMaterial(character, materialCount); // 扣除突破材料。 
        character.breakLevel++; // 提升突破阶数。 
        RecalculateAttribute(character); // 突破后重新计算属性。 
        SaveCharacter(character); // 保存角色状态。 
        RefreshCharacterUI(character); // 刷新角色 UI。 
        return true; // 返回突破成功。 
    } // TryBreakthrough 方法结束。 
    private bool HasBreakMaterial(CharacterState character, int count) { return true; } // 示例:真实项目检查背包材料。 
    private void ConsumeBreakMaterial(CharacterState character, int count) { } // 示例:真实项目扣除突破材料。 
    private void RecalculateAttribute(CharacterState character) { } // 示例:真实项目接属性系统重算战力。 
    private void SaveCharacter(CharacterState character) { } // 示例:真实项目保存到本地或服务端。 
    private void RefreshCharacterUI(CharacterState character) { } // 示例:真实项目刷新角色面板和红点。 
} // CharacterDevelopSystem 类结束。

项目注意点

升级、突破、升星、技能升级都应该统一走资源校验和保存流程。

属性不要散落在 UI、角色脚本、战斗脚本里乱算,最好统一由属性系统重算。

正式项目里资源扣除和状态更新要服务端校验,避免客户端伪造材料。

红点只在资源变化或角色状态变化时刷新,不要每帧扫描所有角色。

养成动画要和数据解耦:先确认数据成功,再播放升级表现。

设计一个技能树系统

unity-skill-tree-system

一句话定义 技能树系统本质是一张“有向依赖图”:每个技能是一个节点,节点之间用前置条件连接,玩家消耗技能点或材料来解锁、升级,并把效果同步到属性系统、技能系统或表现层。

核心设计 技能树不要只当成 UI 来做,UI 只是展示。真正的数据应该拆成:

SkillNodeConfig:配置层,保存技能 ID、最大等级、消耗、前置节点、效果 ID。 SkillNodeState:运行时状态,保存玩家当前解锁等级。 SkillTreeSystem:逻辑层,负责检查前置、检查消耗、升级、重置、保存。 EffectSystem:效果层,负责把技能树节点转成属性加成、技能解锁、被动效果。 UI:表现层,只根据状态画节点、连线、红点和按钮状态。

常见流程 玩家点击升级时,不是直接改 UI,而是:

点击节点 -> 查配置 -> 检查前置节点 -> 检查技能点 -> 修改节点等级 -> 应用效果 -> 保存状态 -> 刷新 UI

这样做的好处是 UI、数据、逻辑分离,后面加职业技能树、天赋页、洗点、服务器校验都不会乱。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 和 Dictionary 集合类型。 

public sealed class SkillNodeConfig // 定义技能树节点配置类。 
{ // SkillNodeConfig 类开始。 
    public int id; // 技能节点 ID。 
    public int maxLevel; // 技能节点最大等级。 
    public int costPoint; // 每次升级需要消耗的技能点。 
    public string effectId; // 技能节点对应的效果 ID。 
    public List<int> preNodeIds = new List<int>(); // 当前节点依赖的前置节点列表。 
} // SkillNodeConfig 类结束。 

public sealed class SkillNodeState // 定义玩家身上的技能节点状态。 
{ // SkillNodeState 类开始。 
    public int nodeId; // 技能节点 ID。 
    public int level; // 当前等级,0 表示未解锁。 
} // SkillNodeState 类结束。 

public sealed class SkillTreeState // 定义玩家整棵技能树的状态。 
{ // SkillTreeState 类开始。 
    public int remainPoint; // 玩家剩余技能点。 
    public Dictionary<int, SkillNodeState> nodes = new Dictionary<int, SkillNodeState>(); // 保存每个技能节点的状态。 
} // SkillTreeState 类结束。 

public sealed class SkillTreeSystem // 定义技能树系统。 
{ // SkillTreeSystem 类开始。 
    private readonly Dictionary<int, SkillNodeConfig> configs = new Dictionary<int, SkillNodeConfig>(); // 保存所有技能节点配置。 

    public bool TryUpgrade(SkillTreeState tree, int nodeId) // 尝试升级某个技能节点。 
    { // TryUpgrade 方法开始。 
        if (!configs.ContainsKey(nodeId)) return false; // 如果配置不存在,直接升级失败。 
        SkillNodeConfig config = configs[nodeId]; // 取出当前节点配置。 
        if (!tree.nodes.ContainsKey(nodeId)) tree.nodes[nodeId] = new SkillNodeState { nodeId = nodeId, level = 0 }; // 没有状态时创建默认状态。 
        SkillNodeState state = tree.nodes[nodeId]; // 取出当前节点状态。 
        if (state.level >= config.maxLevel) return false; // 如果已经满级,不能继续升级。 
        if (tree.remainPoint < config.costPoint) return false; // 如果技能点不足,不能升级。 
        if (!CheckPreNodes(tree, config)) return false; // 如果前置节点不满足,不能升级。 
        tree.remainPoint -= config.costPoint; // 扣除技能点。 
        state.level++; // 提升当前技能等级。 
        ApplySkillEffect(config.effectId, state.level); // 应用技能效果。 
        SaveTreeState(tree); // 保存技能树状态。 
        RefreshSkillTreeUI(); // 刷新技能树 UI 和红点。 
        return true; // 返回升级成功。 
    } // TryUpgrade 方法结束。 

    private bool CheckPreNodes(SkillTreeState tree, SkillNodeConfig config) // 检查前置节点是否满足。 
    { // CheckPreNodes 方法开始。 
        foreach (int preId in config.preNodeIds) // 遍历所有前置节点 ID。 
        { // foreach 循环开始。 
            if (!tree.nodes.ContainsKey(preId)) return false; // 如果前置节点没有状态,说明未解锁。 
            if (tree.nodes[preId].level <= 0) return false; // 如果前置节点等级为 0,说明未解锁。 
        } // foreach 循环结束。 
        return true; // 所有前置条件都满足。 
    } // CheckPreNodes 方法结束。 

    private void ApplySkillEffect(string effectId, int level) { } // 示例:真实项目里接属性系统、技能系统或 Buff 系统。 
    private void SaveTreeState(SkillTreeState tree) { } // 示例:真实项目里保存到本地或服务端。 
    private void RefreshSkillTreeUI() { } // 示例:真实项目里刷新节点状态、连线和红点。 
} // SkillTreeSystem 类结束。

面试加分点 配置导入时要检查循环依赖、缺失节点、孤立节点。洗点时要移除旧效果并按规则返还资源。联机游戏里客户端只能请求升级,真正的技能点扣除、前置校验、结果保存应该由服务端确认。

设计一个天赋系统

unity-talent-system-design

标准答案 天赋系统可以理解成“技能树的成长版本”:它不是单纯解锁技能,而是让玩家在不同分支里做 Build 选择,比如输出、生存、控制、辅助等方向。

核心拆分TalentConfig:天赋配置,记录节点 ID、前置节点、最大等级、消耗、效果 ID。 TalentState:玩家状态,记录某个节点当前点了几级。 TalentSystem:核心规则,负责检查前置、扣点、升级、重置、保存。 EffectSystem:效果生效,把天赋转换成属性加成、技能强化、Buff 或表现变化。 UI:只负责显示节点、连线、红点、预览收益,不直接改数据。

底层原理 天赋树本质是一张有向图。节点是天赋,边是依赖关系。项目里通常要求它是 DAG,也就是不能有循环依赖,否则会出现“ A 依赖 B,B 又依赖 A ”这种永远解不开的配置错误。

C# 简化实现

c
using System.Collections.Generic; // 引入泛型集合。 

public sealed class TalentNodeConfig // 定义天赋节点配置。 
{ // TalentNodeConfig 类开始。 
    public int Id; // 天赋节点 ID。 
    public int MaxLevel; // 天赋最大等级。 
    public int CostPoint; // 每升一级消耗的天赋点。 
    public string EffectId; // 天赋对应的效果 ID。 
    public List<int> PreNodeIds = new List<int>(); // 前置天赋节点 ID 列表。 
} // TalentNodeConfig 类结束。 

public sealed class TalentNodeState // 定义玩家的天赋节点状态。 
{ // TalentNodeState 类开始。 
    public int NodeId; // 天赋节点 ID。 
    public int Level; // 当前等级,0 表示未点亮。 
} // TalentNodeState 类结束。 

public sealed class TalentTreeState // 定义玩家整棵天赋树状态。 
{ // TalentTreeState 类开始。 
    public int RemainPoint; // 玩家剩余天赋点。 
    public Dictionary<int, TalentNodeState> Nodes = new Dictionary<int, TalentNodeState>(); // 保存所有天赋节点状态。 
} // TalentTreeState 类结束。 

public sealed class TalentSystem // 定义天赋系统核心逻辑。 
{ // TalentSystem 类开始。 
    private readonly Dictionary<int, TalentNodeConfig> configs = new Dictionary<int, TalentNodeConfig>(); // 保存所有天赋配置。 

    public bool TryUpgrade(TalentTreeState tree, int nodeId) // 尝试升级某个天赋节点。 
    { // TryUpgrade 方法开始。 
        if (!configs.TryGetValue(nodeId, out TalentNodeConfig config)) return false; // 找不到配置就失败。 
        if (!tree.Nodes.ContainsKey(nodeId)) tree.Nodes[nodeId] = new TalentNodeState { NodeId = nodeId, Level = 0 }; // 没有状态时初始化。 
        TalentNodeState state = tree.Nodes[nodeId]; // 取出当前节点状态。 
        if (state.Level >= config.MaxLevel) return false; // 满级不能继续升级。 
        if (tree.RemainPoint < config.CostPoint) return false; // 天赋点不足不能升级。 
        if (!CheckPreNodes(tree, config)) return false; // 前置节点不满足不能升级。 
        tree.RemainPoint -= config.CostPoint; // 扣除天赋点。 
        state.Level++; // 提升天赋等级。 
        ApplyEffect(config.EffectId, state.Level); // 应用天赋效果。 
        Save(tree); // 保存玩家天赋状态。 
        return true; // 返回升级成功。 
    } // TryUpgrade 方法结束。 

    private bool CheckPreNodes(TalentTreeState tree, TalentNodeConfig config) // 检查前置天赋是否满足。 
    { // CheckPreNodes 方法开始。 
        foreach (int preId in config.PreNodeIds) // 遍历所有前置节点。 
        { // foreach 循环开始。 
            if (!tree.Nodes.TryGetValue(preId, out TalentNodeState preState)) return false; // 前置节点没有状态就失败。 
            if (preState.Level <= 0) return false; // 前置节点未点亮就失败。 
        } // foreach 循环结束。 
        return true; // 所有前置条件都满足。 
    } // CheckPreNodes 方法结束。 

    private void ApplyEffect(string effectId, int level) { } // 示例:真实项目里接属性、技能或 Buff 系统。 
    private void Save(TalentTreeState tree) { } // 示例:真实项目里保存到本地或服务端。 
} // TalentSystem 类结束。

面试加分点 天赋系统一定要配置驱动,不能把每个天赋写死在代码里。上线前要用工具检查循环依赖、缺失节点、非法消耗。洗点时要移除旧效果并返还资源。联机项目里客户端只发升级请求,真正的消耗校验和状态保存应该放服务端。

设计一个对话分支系统

unity-dialogue-branch-system

标准答案 对话分支系统本质是一张“有向图”:每个对话节点是一段台词,每个选项是一条边,玩家选择不同选项后跳到不同节点,同时可以触发任务、奖励、好感度、镜头、动画等事件。

核心模块DialogueGraph:整段对话配置,包含入口节点和所有节点。 DialogueNode:一个对话节点,保存角色名、文本 key、选项列表。 DialogueOption:一个分支选项,保存显示文本、条件、事件、下一个节点。 DialogueRuntime:运行时控制器,负责显示当前节点、过滤选项、处理选择、推进流程。 DialogueContext:上下文,提供任务状态、背包物品、好感度等条件查询。

C# 简化实现

c
using System.Collections.Generic; // 引入泛型集合。 
public sealed class DialogueOptionConfig // 定义对话选项配置。 
{ // DialogueOptionConfig 类开始。 
    public string TextKey; // 选项文本的多语言 key。 
    public string ConditionKey; // 显示该选项需要满足的条件 key。 
    public string EventKey; // 选择该选项后触发的事件 key。 
    public int NextNodeId; // 选择后跳转到的下一个节点 ID,0 表示结束。 
} // DialogueOptionConfig 类结束。 
public sealed class DialogueNodeConfig // 定义对话节点配置。 
{ // DialogueNodeConfig 类开始。 
    public int Id; // 当前节点 ID。 
    public string SpeakerKey; // 说话人名字的多语言 key。 
    public string ContentKey; // 台词内容的多语言 key。 
    public List<DialogueOptionConfig> Options = new List<DialogueOptionConfig>(); // 当前节点的选项列表。 
} // DialogueNodeConfig 类结束。 
public sealed class DialogueGraphConfig // 定义整段对话配置。 
{ // DialogueGraphConfig 类开始。 
    public int StartNodeId; // 对话入口节点 ID。 
    public Dictionary<int, DialogueNodeConfig> Nodes = new Dictionary<int, DialogueNodeConfig>(); // 所有对话节点。 
} // DialogueGraphConfig 类结束。 
public sealed class DialogueContext // 定义对话运行上下文。 
{ // DialogueContext 类开始。 
    public HashSet<string> Flags = new HashSet<string>(); // 保存任务状态、剧情标记、条件标记。 
} // DialogueContext 类结束。 
public sealed class DialogueRuntime // 定义对话运行时。 
{ // DialogueRuntime 类开始。 
    private DialogueGraphConfig graph; // 当前正在运行的对话图。 
    private DialogueContext context; // 当前玩家上下文。 
    private int currentNodeId; // 当前所在节点 ID。 
    public void Enter(DialogueGraphConfig config, DialogueContext ctx) // 进入一段对话。 
    { // Enter 方法开始。 
        graph = config; // 保存对话图配置。 
        context = ctx; // 保存运行上下文。 
        currentNodeId = graph.StartNodeId; // 从入口节点开始。 
    } // Enter 方法结束。 
    public DialogueNodeConfig GetCurrentNode() // 获取当前对话节点。 
    { // GetCurrentNode 方法开始。 
        return graph.Nodes[currentNodeId]; // 返回当前节点配置。 
    } // GetCurrentNode 方法结束。 
    public List<DialogueOptionConfig> GetVisibleOptions() // 获取当前可显示选项。 
    { // GetVisibleOptions 方法开始。 
        List<DialogueOptionConfig> result = new List<DialogueOptionConfig>(); // 创建可见选项列表。 
        foreach (DialogueOptionConfig option in GetCurrentNode().Options) // 遍历当前节点全部选项。 
        { // foreach 循环开始。 
            if (CheckCondition(option.ConditionKey)) result.Add(option); // 条件满足时才加入可见列表。 
        } // foreach 循环结束。 
        return result; // 返回可见选项。 
    } // GetVisibleOptions 方法结束。 
    public bool Choose(DialogueOptionConfig option) // 玩家选择一个选项。 
    { // Choose 方法开始。 
        if (!CheckCondition(option.ConditionKey)) return false; // 条件不满足时禁止选择。 
        TriggerEvent(option.EventKey); // 触发选项绑定的事件。 
        currentNodeId = option.NextNodeId; // 跳转到下一个节点。 
        return currentNodeId != 0; // 返回是否还有后续对话。 
    } // Choose 方法结束。 
    private bool CheckCondition(string conditionKey) // 检查选项条件。 
    { // CheckCondition 方法开始。 
        return string.IsNullOrEmpty(conditionKey) || context.Flags.Contains(conditionKey); // 空条件直接通过,否则查上下文标记。 
    } // CheckCondition 方法结束。 
    private void TriggerEvent(string eventKey) { } // 示例:真实项目里通知任务、奖励、动画或镜头系统。 
} // DialogueRuntime 类结束。

面试加分点 对话系统最好配置驱动,剧情策划改表,程序维护解释器、校验器和编辑器工具。UI 不应该自己判断任务条件,而是问 DialogueRuntime 当前有哪些可见选项。导入配置时要检查缺失节点、非法跳转、死路和循环;如果允许循环,也要保证有退出条件。

设计一个剧情回放系统

unity-story-replay-system

标准答案 剧情回放系统的核心是:玩家第一次经历剧情时会推进任务、发奖励、写存档;但回放时只复现文本、镜头、Timeline、语音和角色动作,不能再次改变真实游戏状态。

核心模块StoryConfig:剧情配置,保存剧情 ID、步骤、资源、标题、多语言 key。 UnlockRecord:解锁记录,保存玩家看过哪些剧情。 ReplayService:回放入口,检查是否解锁、预加载资源、创建沙盒上下文。 StoryPlayer:真正播放剧情步骤,可复用首次播放逻辑。 ReplayContext:沙盒上下文,屏蔽奖励、任务推进、真实存档写入。

底层原理 剧情回放最重要的是“上下文隔离”。同一个 StoryPlayer 可以播放剧情,但它调用外部系统时要经过 IStoryContext。真实播放用 RealStoryContext,回放用 ReplayStoryContext,这样回放不会重复发奖励,也不会把任务状态推进错。

C# 简化实现

c
using System.Collections.Generic; // 引入泛型集合。 
public sealed class StoryConfig // 定义剧情配置。 
{ // StoryConfig 类开始。 
    public int StoryId; // 剧情 ID。 
    public string TitleKey; // 剧情标题的多语言 key。 
    public List<string> AssetKeys = new List<string>(); // 剧情需要预加载的资源 key。 
    public List<string> StepKeys = new List<string>(); // 剧情步骤 key,例如对话、镜头、Timeline。 
} // StoryConfig 类结束。 
public sealed class StoryUnlockRecord // 定义剧情解锁记录。 
{ // StoryUnlockRecord 类开始。 
    public HashSet<int> UnlockedStoryIds = new HashSet<int>(); // 保存已经解锁的剧情 ID。 
} // StoryUnlockRecord 类结束。 
public interface IStoryContext // 定义剧情播放上下文接口。 
{ // IStoryContext 接口开始。 
    bool IsReplay { get; } // 标记当前是否是回放模式。 
    void GiveReward(string rewardId); // 发放奖励接口。 
    void SetQuestFlag(string flag); // 修改任务或剧情标记接口。 
} // IStoryContext 接口结束。 
public sealed class ReplayStoryContext : IStoryContext // 定义回放用沙盒上下文。 
{ // ReplayStoryContext 类开始。 
    public bool IsReplay => true; // 回放模式固定返回 true。 
    public void GiveReward(string rewardId) { } // 回放时忽略奖励发放。 
    public void SetQuestFlag(string flag) { } // 回放时忽略任务推进。 
} // ReplayStoryContext 类结束。 
public sealed class StoryReplaySystem // 定义剧情回放系统。 
{ // StoryReplaySystem 类开始。 
    private readonly Dictionary<int, StoryConfig> configs = new Dictionary<int, StoryConfig>(); // 保存全部剧情配置。 
    private readonly StoryPlayer player = new StoryPlayer(); // 保存剧情播放器。 
    public bool TryReplay(StoryUnlockRecord record, int storyId) // 尝试回放某段剧情。 
    { // TryReplay 方法开始。 
        if (!record.UnlockedStoryIds.Contains(storyId)) return false; // 没解锁就不能回放。 
        if (!configs.TryGetValue(storyId, out StoryConfig config)) return false; // 找不到配置就不能回放。 
        PreloadAssets(config); // 预加载剧情需要的资源。 
        IStoryContext context = new ReplayStoryContext(); // 创建沙盒上下文。 
        player.Play(config, context); // 使用沙盒上下文播放剧情。 
        return true; // 返回回放启动成功。 
    } // TryReplay 方法结束。 
    private void PreloadAssets(StoryConfig config) { } // 示例:真实项目里接 Addressables 或资源管理器。 
} // StoryReplaySystem 类结束。 
public sealed class StoryPlayer // 定义剧情播放器。 
{ // StoryPlayer 类开始。 
    public void Play(StoryConfig config, IStoryContext context) { } // 示例:逐步播放对话、Timeline、镜头和语音。 
} // StoryPlayer 类结束。

面试加分点 回放系统要强调“只读表现,不写真实状态”。退出回放时要停止协程、Timeline、语音,并释放资源句柄。跳过剧情不能直接 Stop,要进入统一收尾逻辑,否则可能残留黑边、镜头、UI 锁定或音频。

设计一个教程引导系统

unity-tutorial-guide-system

标准答案 教程引导系统本质是一个“配置驱动的流程解释器”:策划配置每一步做什么,程序运行时按条件触发,引导玩家点击目标 UI、完成操作、保存进度,并且要能恢复、跳过和防卡死。

核心模块GuideConfig:配置引导步骤,比如目标 UI 路径、提示文案、完成条件、下一步。 GuideRuntime:运行时控制器,负责启动、推进、等待、完成、跳过。 GuideView:表现层,负责遮罩、挖洞、高亮、箭头、提示文字。 GuideSave:保存玩家已经完成到哪一步,防止重复引导。 EventSystem:通过升级、任务完成、打开 UI 等事件触发引导。

底层原理 引导系统不要写成一堆 if/else,否则后期会非常难维护。更好的方式是把每一步抽象成数据节点,运行时只负责解释节点:进入步骤、显示引导、等待条件、完成后跳到下一步。

C# 简化实现

c
using System.Collections.Generic; // 引入泛型集合。 

public enum GuideStepType // 定义引导步骤类型。 
{ // GuideStepType 枚举开始。 
    ClickTarget, // 点击目标 UI。 
    WaitEvent, // 等待某个事件完成。 
    ShowTip // 只显示提示。 
} // GuideStepType 枚举结束。 

public sealed class GuideStepConfig // 定义单个引导步骤配置。 
{ // GuideStepConfig 类开始。 
    public int StepId; // 当前步骤 ID。 
    public int NextStepId; // 下一步 ID,0 表示结束。 
    public GuideStepType StepType; // 当前步骤类型。 
    public string TargetPath; // 目标 UI 路径。 
    public string TipTextKey; // 提示文本多语言 key。 
    public string ConditionKey; // 启动该步骤需要满足的条件。 
} // GuideStepConfig 类结束。 

public sealed class GuideSaveData // 定义引导存档数据。 
{ // GuideSaveData 类开始。 
    public HashSet<int> FinishedSteps = new HashSet<int>(); // 保存已经完成的步骤 ID。 
} // GuideSaveData 类结束。 

public interface IGuideView // 定义引导表现层接口。 
{ // IGuideView 接口开始。 
    void Show(GuideStepConfig step); // 显示遮罩、高亮和提示。 
    void Hide(); // 隐藏当前引导表现。 
} // IGuideView 接口结束。 

public sealed class GuideSystem // 定义教程引导系统。 
{ // GuideSystem 类开始。 
    private readonly Dictionary<int, GuideStepConfig> steps = new Dictionary<int, GuideStepConfig>(); // 保存所有步骤配置。 
    private readonly IGuideView view; // 保存引导表现层对象。 
    private readonly GuideSaveData saveData; // 保存玩家引导进度。 
    private GuideStepConfig currentStep; // 保存当前正在执行的步骤。 

    public GuideSystem(IGuideView guideView, GuideSaveData data) // 构造引导系统。 
    { // 构造函数开始。 
        view = guideView; // 注入表现层。 
        saveData = data; // 注入存档数据。 
    } // 构造函数结束。 

    public void TryStart(int stepId) // 尝试启动某个引导步骤。 
    { // TryStart 方法开始。 
        if (!steps.TryGetValue(stepId, out GuideStepConfig step)) return; // 找不到配置就不启动。 
        if (saveData.FinishedSteps.Contains(stepId)) return; // 已经完成过就不重复启动。 
        if (!CheckCondition(step.ConditionKey)) return; // 条件不满足就不启动。 
        EnterStep(step); // 进入该引导步骤。 
    } // TryStart 方法结束。 

    public void CompleteCurrentStep() // 完成当前引导步骤。 
    { // CompleteCurrentStep 方法开始。 
        if (currentStep == null) return; // 当前没有步骤就直接返回。 
        saveData.FinishedSteps.Add(currentStep.StepId); // 记录当前步骤已完成。 
        int nextStepId = currentStep.NextStepId; // 取出下一步 ID。 
        view.Hide(); // 隐藏当前引导表现。 
        currentStep = null; // 清空当前步骤。 
        if (nextStepId != 0) TryStart(nextStepId); // 如果有下一步,就继续启动下一步。 
        SaveProgress(); // 保存引导进度。 
    } // CompleteCurrentStep 方法结束。 

    private void EnterStep(GuideStepConfig step) // 进入指定引导步骤。 
    { // EnterStep 方法开始。 
        currentStep = step; // 设置当前步骤。 
        view.Show(step); // 显示遮罩、高亮、箭头和提示。 
    } // EnterStep 方法结束。 

    private bool CheckCondition(string conditionKey) // 检查引导条件。 
    { // CheckCondition 方法开始。 
        return string.IsNullOrEmpty(conditionKey); // 示例:真实项目里接任务、等级、功能开放条件。 
    } // CheckCondition 方法结束。 

    private void SaveProgress() { } // 示例:真实项目里保存到本地或服务端。 
} // GuideSystem 类结束。

面试加分点 引导系统一定要考虑防卡死:目标 UI 没加载出来、按钮路径变了、资源加载失败、玩家断线重进,都不能让遮罩永远挡住界面。强引导可以屏蔽非目标点击,弱引导允许玩家自由操作。步骤完成要做幂等,避免重复点击导致进度跳两次。

设计一个关卡编辑器

unity-level-editor-system

标准答案 关卡编辑器本质是一个“给策划用的可视化数据生产工具”:策划在编辑器里摆放出生点、敌人点、触发器、场景物件、路径点等内容,工具负责校验并导出关卡数据,运行时只读取导出的数据来加载关卡。

核心模块EditorWindow:编辑器入口,提供创建、选择、保存、导出按钮。 SceneViewTool:在 Scene 里可视化摆放点位、触发区域、路径。 LevelData:关卡数据,保存对象 ID、位置、旋转、参数。 Validator:导出前检查缺失引用、越界、重叠、非法参数。 Exporter:把编辑期数据导出成 JSON、二进制或 ScriptableObject。 RuntimeLoader:运行时读取数据,按资源 ID 实例化对象。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 集合。 
using UnityEngine; // 引入 Unity 常用类型。 

[System.Serializable] // 允许该类被 Unity 序列化。 
public sealed class LevelObjectData // 定义关卡中的一个对象数据。 
{ // LevelObjectData 类开始。 
    public string prefabId; // 资源 ID,运行时用它查 Prefab。 
    public Vector3 position; // 对象在关卡中的位置。 
    public Vector3 eulerAngles; // 对象的欧拉角旋转。 
    public string argsJson; // 自定义参数,例如刷怪数量或触发条件。 
} // LevelObjectData 类结束。 

[CreateAssetMenu(menuName = "Game/Level Data")] // 在 Unity 菜单里创建关卡数据资源。 
public sealed class LevelData : ScriptableObject // 定义关卡数据资源。 
{ // LevelData 类开始。 
    public string levelId; // 关卡 ID。 
    public int version = 1; // 数据版本号,用于以后兼容旧关卡。 
    public List<LevelObjectData> objects = new List<LevelObjectData>(); // 当前关卡内的对象列表。 
} // LevelData 类结束。 

public sealed class LevelRuntimeLoader : MonoBehaviour // 定义运行时关卡加载器。 
{ // LevelRuntimeLoader 类开始。 
    public LevelData levelData; // 要加载的关卡数据。 
    public Transform root; // 实例化对象的父节点。 

    public void Load() // 加载关卡。 
    { // Load 方法开始。 
        if (levelData == null) return; // 如果没有关卡数据,直接返回。 
        foreach (LevelObjectData data in levelData.objects) // 遍历关卡里的所有对象数据。 
        { // foreach 循环开始。 
            GameObject prefab = LoadPrefab(data.prefabId); // 根据资源 ID 加载 Prefab。 
            if (prefab == null) continue; // 如果资源不存在,跳过该对象。 
            Quaternion rotation = Quaternion.Euler(data.eulerAngles); // 把欧拉角转换成旋转。 
            GameObject instance = Instantiate(prefab, data.position, rotation, root); // 按数据实例化对象。 
            InitObject(instance, data.argsJson); // 用自定义参数初始化对象逻辑。 
        } // foreach 循环结束。 
    } // Load 方法结束。 

    private GameObject LoadPrefab(string prefabId) // 根据 ID 加载 Prefab。 
    { // LoadPrefab 方法开始。 
        return null; // 示例:真实项目里接 Addressables 或资源管理器。 
    } // LoadPrefab 方法结束。 

    private void InitObject(GameObject instance, string argsJson) // 初始化实例对象。 
    { // InitObject 方法开始。 
    } // InitObject 方法结束。 
} // LevelRuntimeLoader 类结束。

面试加分点 关卡编辑器一定要做到“编辑器和运行时分离”:UnityEditor 相关代码放 Editor 目录,不能进包体。运行时不读编辑器对象,只读导出的纯数据。导出前必须做校验,并且错误提示要能定位到具体对象,否则策划很难自己修。

设计一个地图传送系统

unity-map-teleport-system

标准答案 地图传送系统不是简单改 transform.position,而是一个完整流程:触发传送点、校验条件、锁定输入、加载目标地图、找到出生点、设置角色位置和相机、恢复操作,并处理失败回退。

核心模块TeleportConfig:传送点配置,保存目标场景、出生点 ID、等级/任务/门票条件。 TeleportService:传送流程控制器,防止重复触发,统一处理传送请求。 SceneLoader:异步加载地图,显示 Loading,控制进度。 SpawnResolver:根据 spawnId 找到目标出生点。 SaveSystem:保存当前地图和位置,方便断线重进恢复。

C# 简化实现

c
using System.Collections; // 引入 IEnumerator,用于协程。 
using UnityEngine; // 引入 Unity 常用类型。 
using UnityEngine.SceneManagement; // 引入场景加载 API。 

public enum TeleportState // 定义传送状态。 
{ // TeleportState 枚举开始。 
    Idle, // 空闲状态,可以发起传送。 
    Loading // 加载状态,正在传送中。 
} // TeleportState 枚举结束。 

[System.Serializable] // 允许该配置被 Unity 序列化。 
public sealed class TeleportConfig // 定义传送点配置。 
{ // TeleportConfig 类开始。 
    public string portalId; // 当前传送点 ID。 
    public string targetScene; // 目标场景名。 
    public string spawnPointId; // 目标出生点 ID。 
    public int requiredLevel; // 进入目标地图需要的最低等级。 
} // TeleportConfig 类结束。 

public sealed class TeleportService : MonoBehaviour // 定义地图传送服务。 
{ // TeleportService 类开始。 
    public Transform player; // 玩家角色 Transform。 
    private TeleportState state = TeleportState.Idle; // 当前传送状态。 

    public void RequestTeleport(TeleportConfig config, int playerLevel) // 请求传送。 
    { // RequestTeleport 方法开始。 
        if (state != TeleportState.Idle) return; // 正在传送时忽略重复请求。 
        if (config == null) return; // 配置为空时直接返回。 
        if (playerLevel < config.requiredLevel) return; // 等级不足时禁止传送。 
        StartCoroutine(TeleportRoutine(config)); // 启动传送协程。 
    } // RequestTeleport 方法结束。 

    private IEnumerator TeleportRoutine(TeleportConfig config) // 执行传送流程。 
    { // TeleportRoutine 方法开始。 
        state = TeleportState.Loading; // 标记为加载中。 
        LockInput(true); // 锁定玩家输入。 
        ShowLoading(true); // 显示 Loading 界面。 
        AsyncOperation operation = SceneManager.LoadSceneAsync(config.targetScene); // 异步加载目标场景。 
        while (!operation.isDone) // 等待场景加载完成。 
        { // while 循环开始。 
            UpdateLoading(operation.progress); // 更新加载进度。 
            yield return null; // 等待下一帧继续检查。 
        } // while 循环结束。 
        Transform spawn = FindSpawnPoint(config.spawnPointId); // 查找目标出生点。 
        if (spawn != null) // 如果出生点存在。 
        { // if 语句开始。 
            player.position = spawn.position; // 设置玩家位置。 
            player.rotation = spawn.rotation; // 设置玩家朝向。 
        } // if 语句结束。 
        SaveCurrentLocation(config.targetScene, config.spawnPointId); // 保存当前位置。 
        ShowLoading(false); // 隐藏 Loading 界面。 
        LockInput(false); // 解锁玩家输入。 
        state = TeleportState.Idle; // 恢复空闲状态。 
    } // TeleportRoutine 方法结束。 

    private Transform FindSpawnPoint(string spawnPointId) { return null; } // 示例:真实项目里从场景出生点表查找。 
    private void LockInput(bool locked) { } // 示例:真实项目里禁用移动、攻击和 UI 重复点击。 
    private void ShowLoading(bool visible) { } // 示例:真实项目里显示或隐藏 Loading。 
    private void UpdateLoading(float progress) { } // 示例:真实项目里刷新进度条。 
    private void SaveCurrentLocation(string scene, string spawnId) { } // 示例:真实项目里保存当前地图和出生点。 
} // TeleportService 类结束。

面试加分点 同场景传送可以直接改坐标,但跨场景传送必须处理异步加载、资源释放、输入锁定和失败回退。出生点要做安全检查,比如是否在 NavMesh 上、是否卡墙、是否掉出地图。联机游戏里传送条件和目标位置最好由服务端确认,客户端只负责表现和请求。

设计一个迷雾探索系统

unity-fog-exploration-system

标准答案 迷雾探索系统本质是:把地图切成网格或低分辨率纹理,每个格子维护一种状态:未知已探索当前可见。玩家或单位移动时,根据视野半径揭开周围格子,再用 Shader、贴图或 UI 遮罩把迷雾画出来。

核心设计FogConfig:地图范围、格子大小、视野半径。 FogState:每个格子的状态。 FogUpdater:根据玩家位置更新可见区域。 FogRenderer:把状态写进雾纹理并渲染到地图上。 FogSave:只保存“已探索区域”,当前可见区域进地图后重新计算。

C# 简化实现

c
using UnityEngine; // 引入 Unity 常用类型。 

public enum FogCellState // 定义迷雾格子状态。 
{ // FogCellState 枚举开始。 
    Unknown, // 未知区域,完全黑。 
    Explored, // 已探索区域,半透明。 
    Visible // 当前可见区域,完全显示。 
} // FogCellState 枚举结束。 

public sealed class FogOfWarSystem : MonoBehaviour // 定义迷雾探索系统。 
{ // FogOfWarSystem 类开始。 
    public int width = 128; // 迷雾网格宽度。 
    public int height = 128; // 迷雾网格高度。 
    public float cellSize = 1f; // 每个格子对应的世界大小。 
    public float revealRadius = 6f; // 玩家视野半径。 
    public Vector3 origin; // 地图左下角世界坐标。 
    public Transform player; // 玩家 Transform。 
    private FogCellState[] cells; // 保存所有格子的迷雾状态。 
    private Texture2D fogTexture; // 用于渲染迷雾的贴图。 

    private void Awake() // 初始化迷雾系统。 
    { // Awake 方法开始。 
        cells = new FogCellState[width * height]; // 创建状态数组。 
        fogTexture = new Texture2D(width, height, TextureFormat.RGBA32, false); // 创建迷雾纹理。 
        RefreshAllTexture(); // 初始化整张迷雾图。 
    } // Awake 方法结束。 

    private void Update() // 每帧更新示例逻辑。 
    { // Update 方法开始。 
        ClearVisibleToExplored(); // 把上一帧可见区域变成已探索。 
        Reveal(player.position, revealRadius); // 根据玩家位置揭开迷雾。 
        RefreshAllTexture(); // 刷新迷雾纹理。 
    } // Update 方法结束。 

    private void Reveal(Vector3 worldPos, float radius) // 揭开某个世界坐标周围的迷雾。 
    { // Reveal 方法开始。 
        int centerX = Mathf.FloorToInt((worldPos.x - origin.x) / cellSize); // 世界 X 转格子 X。 
        int centerY = Mathf.FloorToInt((worldPos.z - origin.z) / cellSize); // 世界 Z 转格子 Y。 
        int cellRadius = Mathf.CeilToInt(radius / cellSize); // 视野半径转格子半径。 
        for (int y = centerY - cellRadius; y <= centerY + cellRadius; y++) // 遍历视野范围内的 Y。 
        { // 外层 for 开始。 
            for (int x = centerX - cellRadius; x <= centerX + cellRadius; x++) // 遍历视野范围内的 X。 
            { // 内层 for 开始。 
                if (x < 0 || x >= width || y < 0 || y >= height) continue; // 越界格子跳过。 
                float dx = x - centerX; // 计算 X 方向距离。 
                float dy = y - centerY; // 计算 Y 方向距离。 
                if (dx * dx + dy * dy > cellRadius * cellRadius) continue; // 超出圆形视野就跳过。 
                cells[y * width + x] = FogCellState.Visible; // 标记当前格子可见。 
            } // 内层 for 结束。 
        } // 外层 for 结束。 
    } // Reveal 方法结束。 

    private void ClearVisibleToExplored() // 清理上一轮可见状态。 
    { // ClearVisibleToExplored 方法开始。 
        for (int i = 0; i < cells.Length; i++) // 遍历所有格子。 
        { // for 循环开始。 
            if (cells[i] == FogCellState.Visible) cells[i] = FogCellState.Explored; // 可见区域变成已探索区域。 
        } // for 循环结束。 
    } // ClearVisibleToExplored 方法结束。 

    private void RefreshAllTexture() // 刷新整张迷雾纹理。 
    { // RefreshAllTexture 方法开始。 
        for (int y = 0; y < height; y++) // 遍历纹理 Y。 
        { // 外层 for 开始。 
            for (int x = 0; x < width; x++) // 遍历纹理 X。 
            { // 内层 for 开始。 
                FogCellState state = cells[y * width + x]; // 读取当前格子状态。 
                Color color = GetFogColor(state); // 根据状态得到迷雾颜色。 
                fogTexture.SetPixel(x, y, color); // 写入纹理像素。 
            } // 内层 for 结束。 
        } // 外层 for 结束。 
        fogTexture.Apply(); // 应用纹理修改。 
    } // RefreshAllTexture 方法结束。 

    private Color GetFogColor(FogCellState state) // 根据状态返回颜色。 
    { // GetFogColor 方法开始。 
        if (state == FogCellState.Visible) return new Color(0f, 0f, 0f, 0f); // 当前可见区域不遮挡。 
        if (state == FogCellState.Explored) return new Color(0f, 0f, 0f, 0.45f); // 已探索区域半透明遮挡。 
        return new Color(0f, 0f, 0f, 0.9f); // 未知区域强遮挡。 
    } // GetFogColor 方法结束。 
} // FogOfWarSystem 类结束。

面试加分点 真实项目里不要每帧全图 SetPixel + Apply,大地图会很贵。更好的做法是按 Chunk 分块,只更新脏区域,或者降低更新频率,比如角色跨格子后再更新。存档时只保存 Explored 位集,Visible 每次进地图重新计算。

设计一个小地图系统

unity-minimap-system

标准答案 小地图系统本质是“世界坐标到 UI 坐标的映射系统”。它负责把玩家、NPC、任务点、怪物、传送门等世界对象显示到小地图上,同时处理缩放、旋转、迷雾、点击寻路和图标性能优化。

核心设计MapConfig:保存地图世界范围、底图、UI 尺寸。 CoordinateMapper:负责 WorldToMapMapToWorldIconManager:管理玩家、NPC、任务点、怪物图标,最好用对象池。 MinimapView:负责底图显示、旋转、缩放、裁剪和点击导航。 FogSystem:如果有迷雾,小地图和大地图共用探索状态。

C# 简化实现

c
using System.Collections.Generic; // 引入 Dictionary。 
using UnityEngine; // 引入 Unity 常用类型。 

public sealed class MinimapSystem : MonoBehaviour // 定义小地图系统。 
{ // MinimapSystem 类开始。 
    public Vector2 worldOrigin; // 世界地图左下角坐标,使用 XZ 平面。 
    public Vector2 worldSize; // 世界地图宽高,X 对应宽,Y 对应 Z 方向长度。 
    public RectTransform mapRect; // 小地图 UI 根节点。 
    public RectTransform playerIcon; // 玩家图标。 
    public Transform player; // 玩家世界对象。 
    private readonly Dictionary<Transform, RectTransform> icons = new Dictionary<Transform, RectTransform>(); // 世界对象到 UI 图标的映射。 

    private void LateUpdate() // 在 LateUpdate 中刷新小地图表现。 
    { // LateUpdate 方法开始。 
        if (player != null && playerIcon != null) UpdateIcon(player, playerIcon); // 刷新玩家图标。 
        foreach (KeyValuePair<Transform, RectTransform> pair in icons) UpdateIcon(pair.Key, pair.Value); // 刷新普通对象图标。 
    } // LateUpdate 方法结束。 

    public Vector2 WorldToMap(Vector3 worldPos) // 世界坐标转换成小地图 UI 坐标。 
    { // WorldToMap 方法开始。 
        float u = (worldPos.x - worldOrigin.x) / worldSize.x; // 把世界 X 归一化到 0 到 1。 
        float v = (worldPos.z - worldOrigin.y) / worldSize.y; // 把世界 Z 归一化到 0 到 1。 
        float x = (u - 0.5f) * mapRect.rect.width; // 转成以 UI 中心为原点的 X 坐标。 
        float y = (v - 0.5f) * mapRect.rect.height; // 转成以 UI 中心为原点的 Y 坐标。 
        return new Vector2(x, y); // 返回地图 UI 坐标。 
    } // WorldToMap 方法结束。 

    public Vector3 MapToWorld(Vector2 mapPos) // 小地图 UI 坐标反算世界坐标。 
    { // MapToWorld 方法开始。 
        float u = mapPos.x / mapRect.rect.width + 0.5f; // 把 UI X 反归一化。 
        float v = mapPos.y / mapRect.rect.height + 0.5f; // 把 UI Y 反归一化。 
        float x = worldOrigin.x + u * worldSize.x; // 计算世界 X。 
        float z = worldOrigin.y + v * worldSize.y; // 计算世界 Z。 
        return new Vector3(x, 0f, z); // 返回世界坐标,Y 可交给地形采样修正。 
    } // MapToWorld 方法结束。 

    private void UpdateIcon(Transform target, RectTransform icon) // 更新单个图标位置。 
    { // UpdateIcon 方法开始。 
        Vector2 mapPos = WorldToMap(target.position); // 计算目标在小地图上的位置。 
        icon.anchoredPosition = mapPos; // 设置图标 UI 坐标。 
        icon.localEulerAngles = new Vector3(0f, 0f, -target.eulerAngles.y); // 用目标朝向刷新图标旋转。 
    } // UpdateIcon 方法结束。 
} // MinimapSystem 类结束。

面试加分点 小地图可以用 RenderTexture 摄像机 做,也可以用 静态底图 + UI 图标 做。前者真实但多一次渲染,移动端要谨慎;后者性能更稳定,但要自己维护坐标映射。大量图标不要每帧创建销毁,要用对象池、范围裁剪和事件驱动刷新。

设计一个任务追踪 UI

unity-quest-tracker-ui

标准答案 任务追踪 UI 是任务系统的“轻量展示层”:它显示当前追踪任务的标题、目标进度、可提交状态,并提供点击导航、打开地图、展开收起等交互。真正的任务进度由任务系统维护,UI 只接收事件刷新。

核心设计QuestService:维护任务状态和目标进度。 TrackService:决定当前追踪哪个任务,支持手动追踪和自动追踪。 TrackerView:显示任务标题、目标列表、提交状态、导航按钮。 ObjectiveItemPool:复用目标条目,避免频繁创建 UI。 NavigationBridge:点击任务后打开地图、自动寻路或提示传送。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 集合。 
using UnityEngine; // 引入 Unity 基础类型。 
using UnityEngine.UI; // 引入 UGUI 组件。 

public sealed class QuestObjectiveModel // 定义任务目标显示数据。 
{ // QuestObjectiveModel 类开始。 
    public string text; // 目标描述文本。 
    public int current; // 当前完成数量。 
    public int required; // 目标需要数量。 
} // QuestObjectiveModel 类结束。 

public sealed class QuestTrackModel // 定义任务追踪显示模型。 
{ // QuestTrackModel 类开始。 
    public int questId; // 当前任务 ID。 
    public string title; // 当前任务标题。 
    public bool canSubmit; // 当前任务是否可提交。 
    public List<QuestObjectiveModel> objectives = new List<QuestObjectiveModel>(); // 当前任务目标列表。 
} // QuestTrackModel 类结束。 

public sealed class QuestTrackerUI : MonoBehaviour // 定义任务追踪 UI。 
{ // QuestTrackerUI 类开始。 
    public Text titleText; // 任务标题文本。 
    public Text stateText; // 任务状态文本。 
    public Button navigateButton; // 导航按钮。 
    public Transform objectiveRoot; // 目标条目父节点。 
    public QuestObjectiveItem itemPrefab; // 目标条目预制体。 
    private QuestTrackModel currentModel; // 当前显示的任务数据。 
    private readonly List<QuestObjectiveItem> itemPool = new List<QuestObjectiveItem>(); // 目标条目对象池。 

    public void Refresh(QuestTrackModel model) // 刷新任务追踪 UI。 
    { // Refresh 方法开始。 
        currentModel = model; // 保存当前任务数据。 
        titleText.text = model.title; // 刷新任务标题。 
        stateText.text = model.canSubmit ? "可提交" : "进行中"; // 刷新任务状态。 
        EnsureItemCount(model.objectives.Count); // 确保目标条目数量足够。 
        for (int i = 0; i < itemPool.Count; i++) // 遍历目标条目池。 
        { // for 循环开始。 
            bool visible = i < model.objectives.Count; // 判断该条目是否需要显示。 
            itemPool[i].gameObject.SetActive(visible); // 设置条目显隐。 
            if (visible) itemPool[i].SetData(model.objectives[i]); // 显示时刷新目标数据。 
        } // for 循环结束。 
        navigateButton.onClick.RemoveAllListeners(); // 清理旧点击事件。 
        navigateButton.onClick.AddListener(OnClickNavigate); // 绑定新的导航点击事件。 
    } // Refresh 方法结束。 

    private void EnsureItemCount(int count) // 确保对象池里有足够目标条目。 
    { // EnsureItemCount 方法开始。 
        while (itemPool.Count < count) // 当池子数量不足时循环创建。 
        { // while 循环开始。 
            QuestObjectiveItem item = Instantiate(itemPrefab, objectiveRoot); // 创建新的目标条目。 
            itemPool.Add(item); // 把新条目加入对象池。 
        } // while 循环结束。 
    } // EnsureItemCount 方法结束。 

    private void OnClickNavigate() // 点击导航按钮。 
    { // OnClickNavigate 方法开始。 
        if (currentModel == null) return; // 没有任务数据时直接返回。 
        Debug.Log("Navigate Quest: " + currentModel.questId); // 示例:真实项目里调用地图或寻路系统。 
    } // OnClickNavigate 方法结束。 
} // QuestTrackerUI 类结束。 

public sealed class QuestObjectiveItem : MonoBehaviour // 定义单个任务目标条目。 
{ // QuestObjectiveItem 类开始。 
    public Text contentText; // 目标内容文本。 
    public void SetData(QuestObjectiveModel model) // 设置目标显示数据。 
    { // SetData 方法开始。 
        contentText.text = model.text + " " + model.current + " / " + model.required; // 拼出目标进度文本。 
    } // SetData 方法结束。 
} // QuestObjectiveItem 类结束。

面试加分点 任务追踪 UI 不要每帧轮询任务状态,也不要每次刷新都重新创建目标 Item。更好的做法是:任务状态变化时发 QuestChanged 事件,UI 收到后刷新显示模型;OnEnable 订阅事件,OnDisable 取消订阅,避免 UI 关闭后还被回调。

设计一个战斗飘字系统

unity-combat-floating-text-system

标准答案 战斗飘字系统本质是“战斗结果的表现层”:伤害结算系统只产生 DamageEvent,飘字系统监听事件后,用对象池取出 UI 文本,把怪物头顶世界坐标转成 Canvas 坐标,播放上浮、缩放、淡出动画,结束后回收。

核心设计DamageEvent:伤害值、暴击、治疗、Miss、目标位置。 FloatingTextService:飘字入口,负责限流、合并、派发。 FloatingTextPool:对象池,避免高频创建销毁 UI。 FloatingTextItem:单个飘字,负责动画和生命周期。 Canvas:建议单独放飘字层,减少影响主 UI 重建。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 集合。 
using TMPro; // 引入 TextMeshPro 文本组件。 
using UnityEngine; // 引入 Unity 常用类型。 

public enum FloatingTextType { Damage, Critical, Heal, Miss } // 定义飘字类型。 

public struct FloatingTextData // 定义飘字数据。 
{ // FloatingTextData 结构体开始。 
    public int value; // 飘字数值。 
    public FloatingTextType type; // 飘字类型。 
    public Vector3 worldPos; // 飘字出现的世界坐标。 
} // FloatingTextData 结构体结束。 

public sealed class FloatingTextSystem : MonoBehaviour // 定义飘字系统。 
{ // FloatingTextSystem 类开始。 
    public Camera worldCamera; // 用于世界坐标转屏幕坐标的相机。 
    public RectTransform canvasRoot; // 飘字所在的 Canvas 根节点。 
    public FloatingTextItem prefab; // 飘字预制体。 
    private readonly List<FloatingTextItem> pool = new List<FloatingTextItem>(); // 飘字对象池。 

    public void Show(FloatingTextData data) // 显示一个飘字。 
    { // Show 方法开始。 
        FloatingTextItem item = GetItem(); // 从对象池取出飘字。 
        Vector2 uiPos = WorldToUI(data.worldPos); // 把世界坐标转成 UI 坐标。 
        string text = BuildText(data); // 根据数据生成显示文本。 
        Color color = GetColor(data.type); // 根据飘字类型获取颜色。 
        item.Play(text, color, uiPos, ReleaseItem); // 播放飘字动画。 
    } // Show 方法结束。 

    private FloatingTextItem GetItem() // 从池子里获取飘字对象。 
    { // GetItem 方法开始。 
        foreach (FloatingTextItem item in pool) if (!item.gameObject.activeSelf) return item; // 找到空闲对象就复用。 
        FloatingTextItem created = Instantiate(prefab, canvasRoot); // 池子不够时创建新对象。 
        pool.Add(created); // 把新对象加入池子。 
        return created; // 返回新对象。 
    } // GetItem 方法结束。 

    private void ReleaseItem(FloatingTextItem item) // 回收飘字对象。 
    { // ReleaseItem 方法开始。 
        item.gameObject.SetActive(false); // 隐藏对象,等待下次复用。 
    } // ReleaseItem 方法结束。 

    private Vector2 WorldToUI(Vector3 worldPos) // 世界坐标转 UI 坐标。 
    { // WorldToUI 方法开始。 
        Vector3 screenPos = worldCamera.WorldToScreenPoint(worldPos); // 世界坐标转屏幕坐标。 
        RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRoot, screenPos, null, out Vector2 uiPos); // 屏幕坐标转 Canvas 坐标。 
        return uiPos; // 返回 UI 坐标。 
    } // WorldToUI 方法结束。 

    private string BuildText(FloatingTextData data) // 生成飘字文本。 
    { // BuildText 方法开始。 
        if (data.type == FloatingTextType.Miss) return "Miss"; // Miss 类型显示 Miss。 
        if (data.type == FloatingTextType.Heal) return "+" + data.value; // 治疗显示正数。 
        return "-" + data.value; // 伤害显示负数。 
    } // BuildText 方法结束。 

    private Color GetColor(FloatingTextType type) // 根据类型返回颜色。 
    { // GetColor 方法开始。 
        if (type == FloatingTextType.Critical) return Color.yellow; // 暴击用黄色。 
        if (type == FloatingTextType.Heal) return Color.green; // 治疗用绿色。 
        if (type == FloatingTextType.Miss) return Color.gray; // Miss 用灰色。 
        return Color.red; // 普通伤害用红色。 
    } // GetColor 方法结束。 
} // FloatingTextSystem 类结束。

面试加分点 高频战斗里飘字最怕 Instantiate/Destroy、字符串分配、Canvas 重建和大量重叠。优化方向是对象池、限流合并、同目标随机偏移、重要飘字优先显示、飘字单独 Canvas、UI 关闭或切场景时统一回收。

设计一个伤害统计系统

unity-damage-statistics-system

设计一个战斗回放系统

unity-battle-replay-system

设计一个本地录像系统

unity-local-video-recording-system

设计一个多人房间系统

unity-multiplayer-room-system

设计一个匹配系统

unity-matchmaking-system

设计一个聊天系统

unity-chat-system

设计一个好友系统

unity-friend-system

设计一个邮件系统

unity-mail-system

一句话定义: 邮件系统本质是“服务端异步投递消息和奖励”的系统,不只是一个 UI 列表。

核心设计: 客户端负责展示、分页、红点、已读请求、领取请求;服务端负责发信、保存状态、校验领取、发放附件、过期清理。附件领取一定要做幂等,避免玩家断线重试、按钮连点、重复请求导致重复发奖。

标准流程: 系统或运营生成邮件 -> 服务端写入玩家收件箱 -> 客户端登录或推送刷新红点 -> 玩家打开邮件 -> 请求领取附件 -> 服务端校验未领取 -> 发奖励 -> 标记已领取 -> 过期后清理。

常见坑点: 邮件模板和玩家状态要分开;删除通常用软删除;一键领取也要逐封校验;附件奖励不能相信客户端;过期邮件能不能领取要规则明确;红点不要每帧轮询,应该事件驱动刷新。

C# 简化代码:

c
using System.Collections.Generic; // 引入泛型集合。  
public enum MailState // 定义邮件状态枚举。  
{ // 枚举开始。  
    Unread, // 未读状态。  
    Read, // 已读状态。  
    Claimed, // 附件已领取状态。  
    Deleted // 已删除状态。  
} // 枚举结束。  

public sealed class MailData // 定义玩家邮件数据。  
{ // 类开始。  
    public long MailId; // 邮件唯一 ID。  
    public long OwnerId; // 收件玩家 ID。  
    public MailState State; // 当前邮件状态。  
    public List<int> Rewards = new List<int>(); // 简化表示附件奖励 ID 列表。  
} // 类结束。  

public sealed class MailService // 定义邮件服务。  
{ // 类开始。  
    private readonly Dictionary<long, MailData> mails = new Dictionary<long, MailData>(); // 保存所有邮件。  
    private long nextMailId = 1; // 自增邮件 ID。  

    public long SendMail(long ownerId, List<int> rewards) // 发送邮件。  
    { // 方法开始。  
        MailData mail = new MailData(); // 创建邮件对象。  
        mail.MailId = nextMailId++; // 分配唯一邮件 ID。  
        mail.OwnerId = ownerId; // 设置收件人。  
        mail.State = MailState.Unread; // 初始状态设置为未读。  
        mail.Rewards.AddRange(rewards); // 写入附件奖励。  
        mails[mail.MailId] = mail; // 保存邮件数据。  
        return mail.MailId; // 返回邮件 ID。  
    } // 方法结束。  

    public bool Claim(long ownerId, long mailId) // 领取邮件附件。  
    { // 方法开始。  
        if (!mails.TryGetValue(mailId, out MailData mail)) return false; // 邮件不存在则失败。  
        if (mail.OwnerId != ownerId) return false; // 不能领取别人的邮件。  
        if (mail.State == MailState.Claimed) return true; // 已领取直接成功,保证幂等。  
        if (mail.State == MailState.Deleted) return false; // 已删除邮件不能领取。  
        foreach (int rewardId in mail.Rewards) GiveReward(ownerId, rewardId); // 发放每个附件奖励。  
        mail.State = MailState.Claimed; // 发奖成功后标记为已领取。  
        return true; // 返回领取成功。  
    } // 方法结束。  

    private void GiveReward(long ownerId, int rewardId) // 发放奖励。  
    { // 方法开始。  
        // 实际项目中这里会调用背包或货币系统,并放进服务端事务。 // 说明真实项目做法。  
    } // 方法结束。  
} // 类结束。

设计一个公告系统

unity-announcement-system

一句话定义:公告系统就是运营内容投放系统,负责把维护公告、活动公告、补偿公告、紧急通知按时间、区服、渠道、人群推送或展示给玩家。

核心设计:公告不要写死在客户端,也不要只做一个弹窗。完整系统一般分成后台配置、服务端公告服务、投放规则、客户端公告管理器、公告 UI、已读记录。服务端决定“谁能看到什么公告”,客户端负责拉取、缓存、排序、展示和标记已读。

底层流程:运营后台编辑公告 -> 审核校验 -> 保存版本 -> 到时间生效 -> 根据区服、渠道、版本、等级、人群筛选 -> 客户端登录拉取或在线推送 -> 按优先级进入展示队列 -> 玩家查看后记录已读。

工程重点:公告配置是公共数据,已读状态是玩家个人数据,不能混在一起。紧急维护适合强弹窗,活动公告适合公告列表,轻提示适合跑马灯。公告要支持撤回、过期、多语言、富文本限制、图片异步加载和弱网缓存。

常见坑点:公告发错要能立即下线;同一公告不要反复弹;旧版本缓存不能覆盖新公告;富文本不能无限制开放;公告列表多时要用虚拟列表;服务端筛选规则必须可配置,不能每次都改代码。

c
using System; // 引入 Action 事件类型。
using System.Collections.Generic; // 引入 List 和 HashSet 集合类型。
public enum NoticeDisplayType // 定义公告展示类型。
{ // NoticeDisplayType 枚举开始。
    Popup, // 强弹窗公告。
    Banner, // 跑马灯公告。
    List // 普通列表公告。
} // NoticeDisplayType 枚举结束。
public sealed class NoticeData // 定义公告数据。
{ // NoticeData 类开始。
    public int Id; // 公告唯一 ID。
    public string Title = ""; // 公告标题。
    public string Content = ""; // 公告正文。
    public int Priority; // 公告优先级。
    public long StartTime; // 公告开始生效时间。
    public long EndTime; // 公告结束时间。
    public NoticeDisplayType DisplayType; // 公告展示方式。
} // NoticeData 类结束。
public sealed class NoticeManager // 定义客户端公告管理器。
{ // NoticeManager 类开始。
    private readonly List<NoticeData> notices = new List<NoticeData>(); // 保存当前有效公告列表。
    private readonly HashSet<int> readIds = new HashSet<int>(); // 保存玩家已读公告 ID。
    public event Action<NoticeData> OnShowPopup; // 通知 UI 显示弹窗公告。
    public event Action<NoticeData> OnShowBanner; // 通知 UI 显示跑马灯公告。
    public void Refresh(List<NoticeData> serverList) // 用服务端数据刷新公告列表。
    { // Refresh 方法开始。
        notices.Clear(); // 清空旧公告缓存。
        notices.AddRange(serverList); // 添加服务端下发的新公告。
        notices.Sort((a, b) => b.Priority.CompareTo(a.Priority)); // 按优先级从高到低排序。
    } // Refresh 方法结束。
    public void Tick(long now) // 定时检查需要展示的公告。
    { // Tick 方法开始。
        for (int i = 0; i < notices.Count; i++) // 遍历所有公告。
        { // for 循环开始。
            NoticeData notice = notices[i]; // 取出当前公告。
            if (!IsActive(notice, now)) continue; // 未生效或已过期就跳过。
            if (readIds.Contains(notice.Id)) continue; // 已读公告不重复弹出。
            if (notice.DisplayType == NoticeDisplayType.Popup) OnShowPopup?.Invoke(notice); // 弹窗公告交给弹窗 UI。
            if (notice.DisplayType == NoticeDisplayType.Banner) OnShowBanner?.Invoke(notice); // 跑马灯公告交给跑马灯 UI。
        } // for 循环结束。
    } // Tick 方法结束。
    public void MarkRead(int noticeId) // 标记公告已读。
    { // MarkRead 方法开始。
        readIds.Add(noticeId); // 记录公告 ID,避免重复展示。
        SaveReadState(); // 保存已读状态到本地或上报服务端。
    } // MarkRead 方法结束。
    private bool IsActive(NoticeData notice, long now) // 判断公告当前是否生效。
    { // IsActive 方法开始。
        return now >= notice.StartTime && now <= notice.EndTime; // 当前时间在生效区间内才展示。
    } // IsActive 方法结束。
    private void SaveReadState() // 保存已读状态。
    { // SaveReadState 方法开始。
        // 实际项目里可写入本地缓存,也可以上报服务端做跨设备同步。 // 说明真实项目保存位置。
    } // SaveReadState 方法结束。
} // NoticeManager 类结束。

设计一个配置表校验系统

unity-config-validation-system

一句话定义:配置表校验系统就是在配置进入客户端前,自动检查表结构、字段类型、数值范围、唯一性、跨表引用、资源路径和多语言 key,提前阻断错误。

核心设计:我会把它做成“规则驱动”的工具链,而不是在业务代码里到处写 if。规则分两类:通用字段规则,比如必填、类型、范围、唯一 ID;业务规则,比如技能表引用 Buff 表、任务表引用掉落表、图标路径必须存在。

落地流程:策划改表 -> 导表工具解析 -> 校验结构 -> 校验类型范围 -> 校验唯一性 -> 校验跨表引用 -> 校验资源存在 -> 生成报告 -> Error 阻断构建,Warning 进入确认列表。

Unity 项目重点:资源路径、Prefab、图标、音效、Addressables key 的检查应放在 Editor/CI 阶段,不能污染运行时代码。运行时只加载已经通过校验的强类型配置数据。

c
using System.Collections.Generic; // 引入 List、Dictionary、HashSet 集合类型。
public enum IssueLevel // 定义校验问题级别。
{ // IssueLevel 枚举开始。
    Error, // Error 表示必须修复,否则阻断导表或构建。
    Warning // Warning 表示需要确认,但不一定阻断构建。
} // IssueLevel 枚举结束。
public sealed class ValidationIssue // 定义一条校验问题。
{ // ValidationIssue 类开始。
    public IssueLevel Level; // 保存问题级别。
    public string Table = ""; // 保存出错表名。
    public int Row; // 保存出错行号。
    public string Field = ""; // 保存出错字段名。
    public string Message = ""; // 保存具体错误原因。
} // ValidationIssue 类结束。
public sealed class FieldRule // 定义字段校验规则。
{ // FieldRule 类开始。
    public string Name = ""; // 字段名。
    public bool Required; // 是否必填。
    public bool Unique; // 是否要求全表唯一。
    public bool IsInt; // 是否要求整数类型。
    public int Min; // 整数最小值。
    public int Max; // 整数最大值。
} // FieldRule 类结束。
public sealed class ConfigValidator // 定义配置表校验器。
{ // ConfigValidator 类开始。
    private readonly List<FieldRule> rules = new List<FieldRule>(); // 保存本表所有字段规则。
    public void AddRule(FieldRule rule) // 添加一条字段规则。
    { // AddRule 方法开始。
        rules.Add(rule); // 把规则加入规则列表。
    } // AddRule 方法结束。
    public List<ValidationIssue> Validate(string table, List<Dictionary<string, string>> rows) // 校验一张表。
    { // Validate 方法开始。
        List<ValidationIssue> issues = new List<ValidationIssue>(); // 创建问题列表。
        Dictionary<string, HashSet<string>> uniqueSets = new Dictionary<string, HashSet<string>>(); // 创建唯一性检查缓存。
        foreach (FieldRule rule in rules) if (rule.Unique) uniqueSets[rule.Name] = new HashSet<string>(); // 为唯一字段创建集合。
        for (int i = 0; i < rows.Count; i++) // 遍历每一行配置。
        { // for 循环开始。
            Dictionary<string, string> row = rows[i]; // 取出当前行数据。
            foreach (FieldRule rule in rules) // 遍历每条字段规则。
            { // foreach 循环开始。
                row.TryGetValue(rule.Name, out string value); // 尝试读取字段值。
                if (rule.Required && string.IsNullOrWhiteSpace(value)) // 检查必填字段是否为空。
                    issues.Add(Make(table, i + 1, rule.Name, IssueLevel.Error, "必填字段为空")); // 记录必填错误。
                if (string.IsNullOrWhiteSpace(value)) continue; // 空值后续不再检查。
                if (rule.IsInt && !int.TryParse(value, out int number)) // 检查整数格式。
                    issues.Add(Make(table, i + 1, rule.Name, IssueLevel.Error, "字段不是整数")); // 记录类型错误。
                else if (rule.IsInt && (number < rule.Min || number > rule.Max)) // 检查整数范围。
                    issues.Add(Make(table, i + 1, rule.Name, IssueLevel.Error, "数值超出范围")); // 记录范围错误。
                if (rule.Unique && !uniqueSets[rule.Name].Add(value)) // 检查唯一字段是否重复。
                    issues.Add(Make(table, i + 1, rule.Name, IssueLevel.Error, "字段值重复")); // 记录重复错误。
            } // foreach 循环结束。
        } // for 循环结束。
        return issues; // 返回所有校验问题。
    } // Validate 方法结束。
    private ValidationIssue Make(string table, int row, string field, IssueLevel level, string message) // 创建校验问题对象。
    { // Make 方法开始。
        return new ValidationIssue { Table = table, Row = row, Field = field, Level = level, Message = message }; // 返回带定位信息的问题。
    } // Make 方法结束。
} // ConfigValidator 类结束。

设计一个 GM 指令系统

unity-gm-command-system

一句话定义:GM 指令系统就是给客服、运营、测试、管理员使用的高权限命令工具,用来查玩家数据、发奖励、封号、改配置、热重载、处理线上问题。

核心设计:GM 系统首先是安全系统,其次才是调试工具。完整链路应该是:输入命令 -> 解析参数 -> 查找命令处理器 -> 权限校验 -> 参数校验 -> 执行业务 -> 写审计日志 -> 返回结果。线上任何会改玩家数据的命令,都必须可授权、可追踪、可回滚。

项目里我会这样拆:GMConsole 负责输入和展示;CommandRouter 负责解析和分发;IGmCommand 表示每条具体指令;PermissionGuard 做权限和环境校验;AuditLog 记录操作者、目标玩家、命令、参数、执行结果和前后值。

常见坑点:不能把 Debug 面板带进正式包;不能只在客户端判断 GM 权限;不能先改数据再补日志;高危指令要二次确认;发奖励、封号、删数据这类操作要有审批、审计和回滚方案。

c
using System; // 引入 Array 和 StringSplitOptions 等基础类型。
using System.Collections.Generic; // 引入 Dictionary 用来保存命令注册表。
public enum GmRole // 定义 GM 权限等级。
{ // GmRole 枚举开始。
    Support = 1, // 客服权限,只能查数据或做低风险操作。
    Operator = 2, // 运营权限,可以发补偿或处理活动相关问题。
    Admin = 3 // 管理员权限,可以执行高危命令。
} // GmRole 枚举结束。
public sealed class GmContext // 定义一次 GM 执行上下文。
{ // GmContext 类开始。
    public long OperatorId; // 操作者账号 ID。
    public GmRole Role; // 操作者权限等级。
    public string LastReply = ""; // 保存执行结果,方便 UI 展示。
    public void Reply(string text) => LastReply = text; // 设置返回给操作者的提示文本。
} // GmContext 类结束。
public interface IGmCommand // 定义 GM 指令接口。
{ // IGmCommand 接口开始。
    string Name { get; } // 指令名,例如 addgold。
    GmRole RequiredRole { get; } // 执行该指令需要的最低权限。
    bool Execute(GmContext ctx, string[] args); // 执行指令并返回是否成功。
} // IGmCommand 接口结束。
public sealed class AddGoldCommand : IGmCommand // 定义一个加金币指令。
{ // AddGoldCommand 类开始。
    public string Name => "addgold"; // 指令名是 addgold。
    public GmRole RequiredRole => GmRole.Operator; // 加金币至少需要运营权限。
    public bool Execute(GmContext ctx, string[] args) // 执行加金币逻辑。
    { // Execute 方法开始。
        if (args.Length < 2) { ctx.Reply("参数不足"); return false; } // 参数不足时直接失败。
        if (!long.TryParse(args[0], out long targetUserId)) { ctx.Reply("玩家 ID 非法"); return false; } // 校验玩家 ID。
        if (!int.TryParse(args[1], out int amount)) { ctx.Reply("金币数量非法"); return false; } // 校验金币数量。
        if (amount <= 0 || amount > 100000) { ctx.Reply("金币数量超出范围"); return false; } // 限制数量,避免误操作。
        ctx.Reply($"给玩家 {targetUserId} 增加 {amount} 金币"); // 返回执行成功提示。
        return true; // 返回成功。
    } // Execute 方法结束。
} // AddGoldCommand 类结束。
public sealed class GmCommandService // 定义 GM 指令服务。
{ // GmCommandService 类开始。
    private readonly Dictionary<string, IGmCommand> commands = new Dictionary<string, IGmCommand>(); // 保存命令名到处理器的映射。
    public void Register(IGmCommand command) // 注册一条 GM 指令。
    { // Register 方法开始。
        commands[command.Name] = command; // 把命令处理器放入字典。
    } // Register 方法结束。
    public bool ExecuteRaw(GmContext ctx, string raw) // 执行原始 GM 输入字符串。
    { // ExecuteRaw 方法开始。
        if (string.IsNullOrWhiteSpace(raw)) { ctx.Reply("空指令"); return false; } // 空字符串直接拒绝。
        string[] parts = raw.Trim().TrimStart('/').Split(' ', StringSplitOptions.RemoveEmptyEntries); // 拆分命令名和参数。
        if (parts.Length == 0) { ctx.Reply("指令格式错误"); return false; } // 没有解析出命令时失败。
        string name = parts[0].ToLowerInvariant(); // 命令名统一转小写。
        if (!commands.TryGetValue(name, out IGmCommand command)) { ctx.Reply("未知 GM 指令"); return false; } // 查不到处理器则失败。
        if (ctx.Role < command.RequiredRole) { ctx.Reply("权限不足"); return false; } // 权限不足则拒绝执行。
        string[] args = new string[parts.Length - 1]; // 创建参数数组。
        Array.Copy(parts, 1, args, 0, args.Length); // 复制命令参数。
        bool ok = command.Execute(ctx, args); // 执行具体命令逻辑。
        WriteAudit(ctx, raw, ok); // 无论成功失败都写审计日志。
        return ok; // 返回执行结果。
    } // ExecuteRaw 方法结束。
    private void WriteAudit(GmContext ctx, string raw, bool ok) // 写入审计日志。
    { // WriteAudit 方法开始。
        string result = ok ? "成功" : "失败"; // 把执行结果转成可读文本。
        Console.WriteLine($"GM={ctx.OperatorId}, CMD={raw}, RESULT={result}"); // 实际项目里应写入服务端审计库。
    } // WriteAudit 方法结束。
} // GmCommandService 类结束。

设计一个埋点系统

unity-analytics-tracking-system

一句话定义:埋点系统就是把玩家行为、性能、付费、关卡、异常等事件按统一规范采集起来,经过缓存、批量上传、清洗入库,最后用于数据分析和线上问题定位。

核心设计:客户端负责“规范采集和可靠上报”,服务端负责“接收、清洗、去重、入库、分析”。不要把埋点写成到处 Debug.Log,而是统一走 Analytics.Track(eventName, params),由 SDK 自动补公共字段,比如玩家 ID、设备、渠道、版本、场景、时间戳、sessionId、eventSeq。

底层重点:埋点不能阻塞主线程,不能每次事件都立刻发网络请求。通常先进入内存队列,再落一份磁盘缓存,按数量或时间批量上传。弱网失败要延迟重试,队列要有上限,高频事件要采样,重复上报要用 uid + sessionId + eventSeq 去重。

Unity 项目里常见埋点:新手引导步骤、关卡开始/失败/通关、技能释放、付费按钮点击、资源加载耗时、FPS、卡顿、崩溃前关键日志。注意埋点只能用于分析,不能作为安全依据,比如付费结果必须以服务端订单为准。

c
using System; // 引入 DateTimeOffset 用来生成时间戳。
using System.Collections.Generic; // 引入 Queue、List、Dictionary 集合类型。
public sealed class AnalyticsEvent // 定义一条埋点事件。
{ // AnalyticsEvent 类开始。
    public string Name = ""; // 事件名,例如 level_start。
    public long Time; // 事件发生时间戳。
    public int Seq; // 当前 session 内递增序号,用于去重和排序。
    public Dictionary<string, string> Fields = new Dictionary<string, string>(); // 保存公共字段和业务字段。
} // AnalyticsEvent 类结束。
public sealed class AnalyticsManager // 定义埋点管理器。
{ // AnalyticsManager 类开始。
    private readonly Queue<AnalyticsEvent> queue = new Queue<AnalyticsEvent>(); // 保存等待上传的事件队列。
    private readonly Dictionary<string, string> common = new Dictionary<string, string>(); // 保存公共字段。
    private int seq; // 保存当前会话内事件序号。
    private const int BatchSize = 20; // 每批最多上传 20 条事件。
    private const int MaxQueueSize = 1000; // 队列最大长度,避免弱网时无限涨内存。
    public AnalyticsManager(string uid, string version, string channel) // 初始化埋点管理器。
    { // 构造函数开始。
        common["uid"] = uid; // 记录玩家 ID。
        common["version"] = version; // 记录客户端版本。
        common["channel"] = channel; // 记录渠道号。
        common["sessionId"] = Guid.NewGuid().ToString("N"); // 生成本次启动的会话 ID。
    } // 构造函数结束。
    public void Track(string name, Dictionary<string, string> fields) // 记录一条埋点。
    { // Track 方法开始。
        if (string.IsNullOrWhiteSpace(name)) return; // 事件名为空时直接忽略。
        AnalyticsEvent e = new AnalyticsEvent(); // 创建事件对象。
        e.Name = name; // 设置事件名。
        e.Time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // 设置毫秒级时间戳。
        e.Seq = ++seq; // 生成递增事件序号。
        foreach (var kv in common) e.Fields[kv.Key] = kv.Value; // 写入公共字段。
        foreach (var kv in fields) e.Fields[kv.Key] = kv.Value; // 写入业务字段。
        e.Fields["eventName"] = name; // 把事件名也写入字段,方便服务端统一解析。
        e.Fields["eventSeq"] = e.Seq.ToString(); // 写入序号,方便服务端去重。
        if (queue.Count >= MaxQueueSize) queue.Dequeue(); // 队列过长时丢弃最旧普通事件。
        queue.Enqueue(e); // 把事件加入待上传队列。
        if (queue.Count >= BatchSize) Flush(); // 达到批量大小后触发上传。
    } // Track 方法结束。
    public void Flush() // 批量上传队列中的事件。
    { // Flush 方法开始。
        if (queue.Count == 0) return; // 没有事件时不上传。
        List<AnalyticsEvent> batch = new List<AnalyticsEvent>(); // 创建本次上传批次。
        while (queue.Count > 0 && batch.Count < BatchSize) // 从队列中取出一批事件。
        { // while 循环开始。
            batch.Add(queue.Dequeue()); // 取出一条事件加入批次。
        } // while 循环结束。
        bool ok = SendToServer(batch); // 把事件批量发送给服务端。
        if (ok) return; // 上传成功时直接结束。
        for (int i = 0; i < batch.Count; i++) // 上传失败时把事件放回队列。
        { // for 循环开始。
            queue.Enqueue(batch[i]); // 重新入队,等待下次重试。
        } // for 循环结束。
    } // Flush 方法结束。
    private bool SendToServer(List<AnalyticsEvent> batch) // 模拟发送到服务端。
    { // SendToServer 方法开始。
        return true; // 实际项目里这里会序列化 JSON 或 Protobuf 后异步上传。
    } // SendToServer 方法结束。
} // AnalyticsManager 类结束。

设计一个日志上传系统

unity-log-upload-system

一句话定义:日志上传系统就是把客户端运行日志、错误堆栈、设备信息、资源版本和最近操作轨迹收集起来,经过本地滚动存储、压缩脱敏、可靠上传后,用于线上问题定位。

核心设计:它和埋点不一样。埋点偏统计分析,日志偏还原现场。只上传一行 Error 往往不够,必须带上下文,比如玩家 ID、客户端版本、渠道、机型、场景名、网络状态、资源版本、traceId、最近关键操作。

Unity 项目里怎么做:可以监听 Application.logMessageReceivedThreaded 采集日志,但这个回调可能不在主线程,所以里面不要调用 Unity API。日志先进入线程安全队列,再由后台或主线程安全地写入本地滚动文件。上传时机可以是崩溃后下次启动补传、玩家反馈主动上传、Error 触发上传、支付异常上传。

常见坑点:正式包不能开大量 Debug 日志;不要每帧拼字符串;日志文件要按大小或日期滚动;上传前要压缩;手机号、Token、密码要脱敏;弱网失败要重试;日志平台要能按玩家、版本、机型、渠道、traceId 检索。

c
using System; // 引入 DateTime 和 Exception 等基础类型。
using System.Collections.Concurrent; // 引入线程安全队列。
using System.IO; // 引入文件读写相关类型。
using UnityEngine; // 引入 UnityEngine 和 Application 日志回调。
public sealed class LogUploadManager : MonoBehaviour // 定义日志上传管理器。
{ // LogUploadManager 类开始。
    private readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>(); // 保存待写入文件的日志队列。
    private string logPath; // 保存当前日志文件路径。
    private const long MaxLogSize = 1024 * 1024; // 限制单个日志文件最大 1MB。
    private void Awake() // Unity 初始化回调。
    { // Awake 方法开始。
        logPath = Path.Combine(Application.persistentDataPath, "client.log"); // 生成日志文件路径。
        Application.logMessageReceivedThreaded += OnUnityLog; // 注册 Unity 线程安全日志回调。
    } // Awake 方法结束。
    private void OnDestroy() // Unity 销毁回调。
    { // OnDestroy 方法开始。
        Application.logMessageReceivedThreaded -= OnUnityLog; // 取消日志回调,避免对象销毁后继续收到事件。
        FlushToFile(); // 销毁前尽量把队列中的日志写入文件。
    } // OnDestroy 方法结束。
    private void OnUnityLog(string condition, string stackTrace, LogType type) // 接收 Unity 日志。
    { // OnUnityLog 方法开始。
        string time = DateTime.UtcNow.ToString("O"); // 使用 UTC 时间记录日志发生时间。
        string line = $"{time}|{type}|{condition}|{stackTrace}"; // 拼出一行日志内容。
        queue.Enqueue(Desensitize(line)); // 脱敏后放入线程安全队列。
    } // OnUnityLog 方法结束。
    private void Update() // Unity 每帧回调。
    { // Update 方法开始。
        FlushToFile(); // 把队列中的日志批量写入本地文件。
    } // Update 方法结束。
    private void FlushToFile() // 将队列日志写入文件。
    { // FlushToFile 方法开始。
        if (queue.IsEmpty) return; // 没有日志时直接返回。
        RollIfNeeded(); // 写入前检查是否需要滚动日志文件。
        using StreamWriter writer = new StreamWriter(logPath, true); // 以追加模式打开日志文件。
        while (queue.TryDequeue(out string line)) // 从队列中取出所有待写日志。
        { // while 循环开始。
            writer.WriteLine(line); // 写入一行日志。
        } // while 循环结束。
    } // FlushToFile 方法结束。
    private void RollIfNeeded() // 检查日志文件是否需要滚动。
    { // RollIfNeeded 方法开始。
        if (!File.Exists(logPath)) return; // 文件不存在时不需要滚动。
        FileInfo info = new FileInfo(logPath); // 获取日志文件信息。
        if (info.Length < MaxLogSize) return; // 文件未超过大小限制时不滚动。
        string backup = logPath + ".bak"; // 生成备份文件路径。
        if (File.Exists(backup)) File.Delete(backup); // 已有旧备份则删除。
        File.Move(logPath, backup); // 把当前日志文件移动为备份文件。
    } // RollIfNeeded 方法结束。
    public void UploadNow() // 主动触发日志上传。
    { // UploadNow 方法开始。
        FlushToFile(); // 上传前先把内存队列刷到文件。
        if (!File.Exists(logPath)) return; // 没有日志文件时不上传。
        byte[] bytes = File.ReadAllBytes(logPath); // 读取日志文件字节。
        SendToServer(bytes); // 发送到日志服务器。
    } // UploadNow 方法结束。
    private string Desensitize(string text) // 对日志做简单脱敏。
    { // Desensitize 方法开始。
        return text.Replace("token=", "token=***"); // 示例:隐藏 token 字段。
    } // Desensitize 方法结束。
    private void SendToServer(byte[] bytes) // 上传日志到服务器。
    { // SendToServer 方法开始。
        // 实际项目里这里会先压缩,再用 HTTP 异步上传,并在失败时做重试。 // 说明真实项目实现。
    } // SendToServer 方法结束。
} // LogUploadManager 类结束。

设计一个错误码系统

unity-error-code-system

一句话定义:错误码系统就是客户端和服务端之间统一描述失败原因的协议契约,客户端根据错误码决定提示、重试、重登、上报和监控。

核心设计:错误码不能只是一个数字。它应该包含:所属模块、错误含义、用户文案 key、严重级别、是否可重试、是否需要弹窗、是否需要上报。服务端返回 code + args + traceId,客户端根据本地错误码表映射成多语言提示和处理策略。

编码规则:可以按模块分段,比如 10000 通用错误,20000 网络错误,30000 登录错误,50000 背包错误,60000 战斗错误。旧错误码不能复用新含义,删除的错误码也不要重新分配,否则老客户端会误处理。

Unity 项目里怎么用:网络层收到响应后,如果 code != 0,先查错误码表。普通错误显示 Toast;网络超时触发重试;登录态失效跳登录;支付、战斗、资源加载相关错误要带 traceId 上报日志和埋点,方便线上定位。

c
using System; // 引入 Console,示例中用来模拟 UI 输出。
using System.Collections.Generic; // 引入 Dictionary,用来保存错误码配置。
public enum ErrorPolicy // 定义错误处理策略。
{ // ErrorPolicy 枚举开始。
    Toast, // 只显示轻提示。
    Dialog, // 显示确认弹窗。
    Retry, // 尝试重试请求。
    Relogin, // 跳转登录流程。
    SilentReport // 不打扰玩家,只做日志上报。
} // ErrorPolicy 枚举结束。
public sealed class ErrorInfo // 定义错误码配置数据。
{ // ErrorInfo 类开始。
    public int Code; // 错误码数字。
    public string TextKey = ""; // 多语言文本 key。
    public ErrorPolicy Policy; // 客户端处理策略。
    public bool Report; // 是否需要上报日志或埋点。
} // ErrorInfo 类结束。
public sealed class ErrorCodeManager // 定义错误码管理器。
{ // ErrorCodeManager 类开始。
    private readonly Dictionary<int, ErrorInfo> table = new Dictionary<int, ErrorInfo>(); // 保存 code 到配置的映射。
    public void Register(ErrorInfo info) // 注册一条错误码配置。
    { // Register 方法开始。
        table[info.Code] = info; // 放入字典,方便快速查询。
    } // Register 方法结束。
    public void Handle(int code, string traceId) // 处理服务端返回的错误码。
    { // Handle 方法开始。
        if (code == 0) return; // code 为 0 通常表示成功。
        if (!table.TryGetValue(code, out ErrorInfo info)) // 查询错误码配置。
        { // if 代码块开始。
            ShowToast("操作失败,请稍后再试"); // 未知错误码显示通用提示。
            Report(code, traceId, "unknown"); // 未知错误码必须上报,方便补表。
            return; // 兜底处理后结束。
        } // if 代码块结束。
        string text = Localize(info.TextKey); // 根据 textKey 查多语言文案。
        if (info.Policy == ErrorPolicy.Toast) ShowToast(text); // Toast 策略显示轻提示。
        if (info.Policy == ErrorPolicy.Dialog) ShowDialog(text); // Dialog 策略显示弹窗。
        if (info.Policy == ErrorPolicy.Retry) RetryLastRequest(); // Retry 策略重试上一次请求。
        if (info.Policy == ErrorPolicy.Relogin) GoToLogin(); // Relogin 策略跳登录。
        if (info.Report) Report(code, traceId, info.Policy.ToString()); // 配置需要上报时记录日志。
    } // Handle 方法结束。
    private string Localize(string key) // 根据多语言 key 获取文本。
    { // Localize 方法开始。
        return key; // 示例中直接返回 key,真实项目会查语言表。
    } // Localize 方法结束。
    private void ShowToast(string text) // 显示轻提示。
    { // ShowToast 方法开始。
        Console.WriteLine("Toast: " + text); // 示例用控制台模拟 UI Toast。
    } // ShowToast 方法结束。
    private void ShowDialog(string text) // 显示弹窗。
    { // ShowDialog 方法开始。
        Console.WriteLine("Dialog: " + text); // 示例用控制台模拟 UI Dialog。
    } // ShowDialog 方法结束。
    private void RetryLastRequest() // 重试上一次请求。
    { // RetryLastRequest 方法开始。
        Console.WriteLine("Retry request"); // 示例中输出重试动作。
    } // RetryLastRequest 方法结束。
    private void GoToLogin() // 跳转登录。
    { // GoToLogin 方法开始。
        Console.WriteLine("Go to login"); // 示例中输出登录跳转动作。
    } // GoToLogin 方法结束。
    private void Report(int code, string traceId, string policy) // 上报错误信息。
    { // Report 方法开始。
        Console.WriteLine($"ErrorCode={code}, TraceId={traceId}, Policy={policy}"); // 上报 code、traceId 和处理策略。
    } // Report 方法结束。
} // ErrorCodeManager 类结束。

设计一个客户端版本兼容系统

unity-client-version-compatibility-system

一句话定义:客户端版本兼容系统就是启动阶段的“兼容决策中心”,根据客户端 App 版本、资源版本、协议版本、配置版本和渠道信息,决定允许进入、热更、软更新、强更新或阻断。

核心设计:不要只判断 Application.version。真实项目里至少要看:AppVersion、AssetVersion、ProtocolVersion、ConfigSchemaVersion、HotfixVersion、Channel。客户端启动先做版本握手,服务端返回策略:AllowHotfixSoftUpdateForceUpdateBlock

底层重点:协议要向后兼容,新增字段尽量用 optional,旧字段不能复用新含义;资源 Manifest 要带版本和 hash,热更失败要能回滚旧资源;本地存档要带 schemaVersion,升级时做迁移;灰度发布要按渠道、区服、白名单放量,出问题能快速关开关。

Unity 项目流程:启动游戏 -> 收集版本矩阵 -> 请求版本服务 -> 服务端判断兼容策略 -> 客户端下载热更资源或提示更新 -> 必要时迁移本地数据 -> 进入登录和场景流程。兼容检查要早于登录和加载主场景,否则旧客户端可能拿新协议或新资源直接崩。

c
using System; // 引入 Version 类型,用来比较版本号。
public enum VersionPolicy // 定义版本兼容策略。
{ // VersionPolicy 枚举开始。
    Allow, // 允许直接进入游戏。
    Hotfix, // 需要先下载资源或热更包。
    SoftUpdate, // 提示更新,但不强制阻塞。
    ForceUpdate, // 强制更新,不能进入游戏。
    Block // 版本被封禁或不支持。
} // VersionPolicy 枚举结束。
public sealed class ClientVersionInfo // 定义客户端上报的版本信息。
{ // ClientVersionInfo 类开始。
    public string AppVersion = ""; // 客户端 App 包版本。
    public string AssetVersion = ""; // 客户端资源版本。
    public int ProtocolVersion; // 客户端协议版本。
    public int ConfigSchemaVersion; // 客户端配置结构版本。
    public string Channel = ""; // 客户端渠道号。
} // ClientVersionInfo 类结束。
public sealed class ServerVersionRule // 定义服务端版本规则。
{ // ServerVersionRule 类开始。
    public string MinAppVersion = "1.0.0"; // 最低允许进入的 App 版本。
    public string LatestAppVersion = "1.0.0"; // 当前推荐的最新 App 版本。
    public string RequiredAssetVersion = ""; // 当前要求的资源版本。
    public int MinProtocolVersion; // 最低兼容协议版本。
    public int RequiredConfigSchemaVersion; // 当前要求的配置结构版本。
} // ServerVersionRule 类结束。
public sealed class VersionCompatibilityChecker // 定义版本兼容检查器。
{ // VersionCompatibilityChecker 类开始。
    public VersionPolicy Check(ClientVersionInfo client, ServerVersionRule rule) // 检查客户端版本并返回策略。
    { // Check 方法开始。
        if (Compare(client.AppVersion, rule.MinAppVersion) < 0) return VersionPolicy.ForceUpdate; // App 低于最低版本则强更。
        if (client.ProtocolVersion < rule.MinProtocolVersion) return VersionPolicy.ForceUpdate; // 协议过旧无法兼容则强更。
        if (client.ConfigSchemaVersion < rule.RequiredConfigSchemaVersion) return VersionPolicy.Hotfix; // 配置结构旧则先尝试热更资源。
        if (client.AssetVersion != rule.RequiredAssetVersion) return VersionPolicy.Hotfix; // 资源版本不一致则下载热更资源。
        if (Compare(client.AppVersion, rule.LatestAppVersion) < 0) return VersionPolicy.SoftUpdate; // 不是最新版本则软提示更新。
        return VersionPolicy.Allow; // 所有条件满足则允许进入。
    } // Check 方法结束。
    private int Compare(string a, string b) // 比较两个版本字符串。
    { // Compare 方法开始。
        Version va = Version.TryParse(a, out Version parsedA) ? parsedA : new Version(0, 0, 0); // 解析左侧版本,失败时按最低版本处理。
        Version vb = Version.TryParse(b, out Version parsedB) ? parsedB : new Version(0, 0, 0); // 解析右侧版本,失败时按最低版本处理。
        return va.CompareTo(vb); // 返回版本比较结果。
    } // Compare 方法结束。
} // VersionCompatibilityChecker 类结束。

设计一个账号切换流程

unity-account-switch-flow

一句话定义:账号切换流程就是安全结束旧账号会话,清理旧账号相关状态,再重新登录并拉取新账号数据的完整流程。

核心设计:不能只把 token 清空就完事。旧账号可能还有网络回包、异步加载、事件监听、UI 红点、缓存数据、本地存档、对象池对象、聊天好友状态。如果这些没清干净,新账号登录后可能看到旧账号背包、头像、邮件、红点,甚至旧回包写进新账号数据。

标准流程:点击切换 -> 二次确认 -> 锁住输入 -> 保存旧账号数据 -> 取消异步任务 -> 断开网络连接 -> 清理 token/session/cache/event/UI -> SDK 登出 -> 重新登录 -> 服务端鉴权 -> 版本握手 -> 拉取角色和业务数据 -> 进入游戏。

关键坑点:每次登录生成新的 sessionId,网络回包和异步回调都要检查当前 sessionId,旧 session 的结果直接丢弃。本地存档要按账号 ID 分目录保存,不能多个账号共用一个文件。切换失败时要回到登录页,不要半清理后还停在游戏内。

c
using System; // 引入 Guid,用来生成新的 sessionId。
using System.Threading; // 引入 CancellationTokenSource,用来取消旧账号异步任务。
using System.Threading.Tasks; // 引入 Task,用来表示异步流程。
public interface IAuthService // 定义认证服务接口。
{ // IAuthService 接口开始。
    Task LogoutAsync(); // 登出当前平台或游戏账号。
    Task<string> LoginAsync(); // 登录新账号并返回 token。
} // IAuthService 接口结束。
public interface IGameSession // 定义游戏会话接口。
{ // IGameSession 接口开始。
    Task FlushSaveAsync(); // 保存或上传旧账号数据。
    void Disconnect(); // 断开旧账号网络连接。
    void ClearAccountCache(); // 清理账号相关缓存。
    Task LoadNewAccountAsync(string token, string sessionId); // 根据新 token 拉取新账号数据。
} // IGameSession 接口结束。
public sealed class AccountSwitchManager // 定义账号切换管理器。
{ // AccountSwitchManager 类开始。
    private readonly IAuthService auth; // 保存认证服务。
    private readonly IGameSession session; // 保存游戏会话服务。
    private CancellationTokenSource accountCts = new CancellationTokenSource(); // 保存当前账号异步取消源。
    private bool switching; // 标记当前是否正在切换账号。
    public string CurrentSessionId { get; private set; } = ""; // 保存当前会话 ID。
    public AccountSwitchManager(IAuthService auth, IGameSession session) // 构造账号切换管理器。
    { // 构造函数开始。
        this.auth = auth; // 注入认证服务。
        this.session = session; // 注入游戏会话服务。
    } // 构造函数结束。
    public async Task SwitchAccountAsync() // 执行账号切换流程。
    { // SwitchAccountAsync 方法开始。
        if (switching) return; // 防止重复点击导致流程并发。
        switching = true; // 标记切换流程开始。
        try // 捕获切换过程中的异常。
        { // try 代码块开始。
            accountCts.Cancel(); // 取消旧账号的异步请求和资源加载。
            accountCts.Dispose(); // 释放旧取消源。
            accountCts = new CancellationTokenSource(); // 创建新账号使用的新取消源。
            await session.FlushSaveAsync(); // 保存旧账号进度,避免数据丢失。
            session.Disconnect(); // 断开旧账号网络连接。
            session.ClearAccountCache(); // 清理旧账号缓存、事件和 UI 状态。
            await auth.LogoutAsync(); // 调用 SDK 或账号系统登出旧账号。
            string token = await auth.LoginAsync(); // 登录新账号并拿到新 token。
            CurrentSessionId = Guid.NewGuid().ToString("N"); // 生成新 sessionId,用来隔离旧回包。
            await session.LoadNewAccountAsync(token, CurrentSessionId); // 拉取新账号角色、背包、邮件等数据。
        } // try 代码块结束。
        finally // 无论成功失败都执行收尾。
        { // finally 代码块开始。
            switching = false; // 解除切换中状态。
        } // finally 代码块结束。
    } // SwitchAccountAsync 方法结束。
    public bool IsCurrentSession(string sessionId) // 判断回调是否属于当前账号会话。
    { // IsCurrentSession 方法开始。
        return sessionId == CurrentSessionId; // 只有 sessionId 匹配才允许写入数据。
    } // IsCurrentSession 方法结束。
} // AccountSwitchManager 类结束。

设计一个防重复点击系统

unity-anti-double-click-system

一句话定义:防重复点击系统就是防止玩家连续点击导致同一操作被重复执行,比如重复购买、重复领奖、重复进副本、重复发送请求。

核心设计:不要只靠 button.interactable = false。完整方案要分三层:UI 层防连点,业务层防并发,服务端做幂等。客户端负责避免误触和重复发请求,服务端负责保证“真正改变数据的操作只能生效一次”。

标准流程:玩家点击 -> 按钮进入冷却 -> 业务 actionKey 上锁 -> 生成 requestId -> 发送请求 -> 服务端按 userId + actionKey + requestId 去重 -> 返回结果 -> 客户端刷新 UI -> finally 解锁。

常见坑点:请求失败也必须解锁;Loading 遮罩要有超时;弱网重试要复用同一个 requestId;购买、发奖、扣体力不能只信客户端禁按钮;重复点击系统最好封装成 SafeButtonClickGuard,不要每个界面手写一套。

c
using System; // 引入 Guid 和 Func。
using System.Collections.Generic; // 引入 HashSet 和 Dictionary。
using System.Threading.Tasks; // 引入 Task,用来支持异步请求。
using UnityEngine; // 引入 Time 和 Debug。
using UnityEngine.UI; // 引入 Button 组件。

public sealed class AntiRepeatClickService // 定义防重复点击服务。
{ // AntiRepeatClickService 类开始。
    private readonly HashSet<string> running = new HashSet<string>(); // 保存正在执行中的业务 key。
    private readonly Dictionary<string, float> nextClickTime = new Dictionary<string, float>(); // 保存每个业务 key 下一次允许点击时间。
    public async Task<bool> RunAsync(string actionKey, Button button, Func<string, Task> operation, float cooldown = 0.5f) // 执行受保护的点击逻辑。
    { // RunAsync 方法开始。
        float now = Time.unscaledTime; // 使用不受暂停影响的时间。
        if (nextClickTime.TryGetValue(actionKey, out float allowTime) && now < allowTime) return false; // 冷却中直接拒绝点击。
        if (running.Contains(actionKey)) return false; // 同一个业务正在执行时拒绝重复进入。
        string requestId = Guid.NewGuid().ToString("N"); // 生成本次请求唯一 ID。
        running.Add(actionKey); // 给当前业务上锁。
        nextClickTime[actionKey] = now + cooldown; // 设置按钮级点击冷却。
        if (button != null) button.interactable = false; // 禁用按钮,给玩家明确反馈。
        try // 开始执行受保护逻辑。
        { // try 代码块开始。
            await operation(requestId); // 执行业务请求,并把 requestId 传给网络层。
            return true; // 请求成功时返回 true。
        } // try 代码块结束。
        catch (Exception e) // 捕获业务异常。
        { // catch 代码块开始。
            Debug.LogError(e); // 打印错误,方便定位失败原因。
            return false; // 请求失败时返回 false。
        } // catch 代码块结束。
        finally // 无论成功失败都要解锁。
        { // finally 代码块开始。
            running.Remove(actionKey); // 移除业务锁,避免按钮永久不可点。
            if (button != null) button.interactable = true; // 恢复按钮可点击状态。
        } // finally 代码块结束。
    } // RunAsync 方法结束。
} // AntiRepeatClickService 类结束。

设计一个弱网提示系统

unity-weak-network-hint-system

一句话定义:弱网提示系统就是根据心跳 RTT、超时次数、请求失败率、重连状态判断网络质量,再用分级 UI 提醒玩家,并配合重试、重连和状态同步恢复游戏。

核心设计:不能只靠 Application.internetReachability,它只能说明设备有没有网络类型,不能证明游戏服务器可达。真实项目里要看心跳延迟、连续超时、接口失败率、断线重连次数,并且做防抖:连续多次差才提示弱网,连续恢复后再隐藏。

提示分级:轻微弱网只显示角标或延迟颜色;明显卡顿显示横幅“网络不稳定”;断线重连时显示遮罩或重连弹窗;离线或重连失败时给“重试/返回登录”按钮。大厅可以轻提示,战斗中要更明确地显示延迟和重连状态。

常见坑点:不要一次请求失败就弹弱网;不要恢复一帧就立刻隐藏;不要把服务器错误误判为弱网;购买、领奖这类请求重试要配合 requestId 幂等;后台切前台后要重新心跳,必要时重新同步状态。

c
using System; // 引入 Action 事件类型。
using UnityEngine; // 引入 MonoBehaviour 和 Time。

public enum NetworkQuality // 定义网络质量状态。
{ // NetworkQuality 枚举开始。
    Good, // 网络正常。
    Weak, // 网络较差。
    Reconnecting, // 正在重连。
    Offline // 已离线或连续重连失败。
} // NetworkQuality 枚举结束。

public sealed class WeakNetworkMonitor : MonoBehaviour // 定义弱网监控组件。
{ // WeakNetworkMonitor 类开始。
    public event Action<NetworkQuality> OnQualityChanged; // 网络状态变化事件。
    private NetworkQuality current = NetworkQuality.Good; // 当前网络状态。
    private int badCount; // 连续差网络次数。
    private int goodCount; // 连续恢复次数。
    private float lastHeartbeatTime; // 上次收到心跳的时间。
    private const float WeakRtt = 300f; // RTT 超过 300ms 认为有弱网倾向。
    private const float TimeoutSeconds = 5f; // 超过 5 秒没心跳认为超时。
    private const int EnterWeakCount = 3; // 连续 3 次异常才进入弱网。
    private const int LeaveWeakCount = 3; // 连续 3 次正常才退出弱网。

    private void Awake() // Unity 初始化回调。
    { // Awake 方法开始。
        lastHeartbeatTime = Time.unscaledTime; // 初始化心跳时间。
    } // Awake 方法结束。

    private void Update() // Unity 每帧回调。
    { // Update 方法开始。
        if (Time.unscaledTime - lastHeartbeatTime > TimeoutSeconds) // 判断心跳是否超时。
        { // if 代码块开始。
            ReportSample(false, 9999f); // 用一个失败样本推动状态进入重连或离线。
        } // if 代码块结束。
    } // Update 方法结束。

    public void OnHeartbeatReply(float rttMs) // 收到心跳返回时调用。
    { // OnHeartbeatReply 方法开始。
        lastHeartbeatTime = Time.unscaledTime; // 更新最后心跳时间。
        bool ok = rttMs <= WeakRtt; // RTT 小于阈值认为本次正常。
        ReportSample(ok, rttMs); // 上报本次网络样本。
    } // OnHeartbeatReply 方法结束。

    private void ReportSample(bool ok, float rttMs) // 根据一次样本更新网络状态。
    { // ReportSample 方法开始。
        if (ok) goodCount++; // 正常样本增加恢复计数。
        if (ok) badCount = 0; // 正常样本清空异常计数。
        if (!ok) badCount++; // 异常样本增加异常计数。
        if (!ok) goodCount = 0; // 异常样本清空恢复计数。
        if (badCount >= EnterWeakCount && current == NetworkQuality.Good) SetQuality(NetworkQuality.Weak); // 连续异常时进入弱网。
        if (badCount >= EnterWeakCount * 2 && current == NetworkQuality.Weak) SetQuality(NetworkQuality.Reconnecting); // 弱网继续恶化时进入重连。
        if (badCount >= EnterWeakCount * 4 && current == NetworkQuality.Reconnecting) SetQuality(NetworkQuality.Offline); // 重连长期失败时进入离线。
        if (goodCount >= LeaveWeakCount && current != NetworkQuality.Good) SetQuality(NetworkQuality.Good); // 连续正常时恢复正常。
    } // ReportSample 方法结束。

    private void SetQuality(NetworkQuality next) // 切换网络质量状态。
    { // SetQuality 方法开始。
        if (current == next) return; // 状态没变化时不重复通知。
        current = next; // 保存新状态。
        OnQualityChanged?.Invoke(current); // 通知 UI 显示或隐藏弱网提示。
    } // SetQuality 方法结束。
} // WeakNetworkMonitor 类结束。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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