Skip to content

题海扩展:战斗与动画

攻击前摇、命中帧、后摇如何设计?

unity-attack-windup-hit-recovery

标准答案: 攻击最好设计成“配置驱动的时间轴状态机”:前摇是准备阶段,命中帧是打开判定窗口,后摇是收招硬直,取消窗口决定能不能接闪避、技能或下一段连招。

三个阶段怎么理解:

前摇:玩家按下攻击后,到真正能打到人之前。这里通常播放起手动画、扣体力、锁方向,可以设置“可打断”或“霸体”。

命中帧:真正产生伤害的窗口。只在这段时间打开 Hitbox 或做范围检测,同一段攻击里要记录已命中的目标,避免一帧一结算导致重复伤害。

后摇:攻击结束后的收招阶段。后摇越长,攻击越有风险;后摇里可以设计取消窗口,让玩家接连招、闪避、技能。

项目里我会这样做:AttackConfig 配置 windupTimeactiveStartactiveEndrecoveryTimecancelStart,运行时用 elapsedTime 推进阶段。动画事件可以作为表现标记,但伤害判定、取消、打断最好由战斗状态机控制。

C# 简化代码:

c
using System.Collections.Generic; // 引入 HashSet 用来记录已命中的目标。  

public enum AttackPhase // 定义攻击阶段。  
{ // 枚举开始。  
    Windup, // 前摇阶段。  
    Active, // 命中帧阶段。  
    Recovery, // 后摇阶段。  
    Finished // 攻击结束。  
} // 枚举结束。  

public sealed class AttackConfig // 定义攻击配置。  
{ // 类开始。  
    public float windupTime = 0.25f; // 前摇持续时间。  
    public float activeTime = 0.15f; // 命中窗口持续时间。  
    public float recoveryTime = 0.35f; // 后摇持续时间。  
    public float cancelStartTime = 0.55f; // 允许取消的起始时间。  
    public int damage = 100; // 本次攻击伤害。  
} // 类结束。  

public sealed class AttackRuntime // 定义一次攻击的运行时数据。  
{ // 类开始。  
    public AttackPhase Phase = AttackPhase.Windup; // 当前攻击阶段。  
    public float ElapsedTime = 0f; // 已经经过的攻击时间。  
    public readonly HashSet<int> HitTargets = new HashSet<int>(); // 已命中过的目标 ID。  
} // 类结束。

常见坑点: 不要把伤害完全绑死在动画事件上;动画变速、转场、丢帧、复用 Clip 都可能导致判定漂移。联机游戏里,客户端可以先播放表现,但命中结果最好由服务端验证;帧同步项目则更适合用逻辑帧数代替浮点时间。

技能释放过程中被打断怎么办?

unity-skill-interrupt-design

标准答案: 技能释放中被打断时,不能只 StopAnimation,而要走一套完整流程:判断是否允许打断 -> 比较打断优先级 -> 清理技能现场 -> 结算资源和 CD -> 切换到受击、硬直、击退或死亡状态。

设计重点:

前摇被打断:一般还没造成伤害,可以选择返还部分蓝量、体力,或者只进入短 CD。

命中帧后被打断:已经结算的伤害通常不能回滚,否则会引发战斗一致性问题。

读条技能被打断:通常读条失败,不产生效果,可以按配置决定是否进入 CD。

霸体或无敌:如果角色处于霸体、无敌、免控状态,就要根据打断等级判断能不能打断。

被打断后必须清理: 关闭 Hitbox、停止计时器或协程、停止或淡出特效、停止音效、清理输入缓存、清理临时 Buff、处理已经生成的投射物,最后切换角色状态并广播事件给 UI 和表现层。

C# 简化代码:

c
public enum SkillPhase { Windup, Channel, Active, Recovery, Finished } // 定义技能阶段。  
public enum InterruptResult { Failed, Ignored, Success } // 定义打断结果。  

public sealed class SkillInstance // 定义技能运行时实例。  
{ // 类开始。  
    public SkillPhase Phase; // 当前技能阶段。  
    public int ArmorLevel; // 当前抗打断等级。  
    public bool Interrupted; // 是否已经被打断。  
} // 类结束。  

public sealed class InterruptInfo // 定义打断信息。  
{ // 类开始。  
    public int Priority; // 打断优先级。  
    public string NextState; // 打断后要进入的状态。  
} // 类结束。  

public sealed class SkillController // 定义技能控制器。  
{ // 类开始。  
    private SkillInstance currentSkill; // 当前正在释放的技能。  

    public InterruptResult TryInterrupt(InterruptInfo info) // 尝试打断当前技能。  
    { // 方法开始。  
        if (currentSkill == null) return InterruptResult.Failed; // 没有技能可打断。  
        if (currentSkill.Interrupted) return InterruptResult.Ignored; // 已经打断过则忽略,保证幂等。  
        if (info.Priority < currentSkill.ArmorLevel) return InterruptResult.Failed; // 打断优先级不够则失败。  
        if (currentSkill.Phase == SkillPhase.Active) return InterruptResult.Failed; // 示例规则:命中帧不允许普通打断。  
        currentSkill.Interrupted = true; // 标记当前技能已被打断。  
        CloseHitBox(); // 关闭技能判定盒。  
        StopSkillTimer(); // 停止技能计时器或协程。  
        StopSkillVfx(); // 停止或淡出技能特效。  
        ApplyCooldownPolicy(); // 按配置处理技能 CD。  
        ChangeState(info.NextState); // 切换到受击、硬直或击退状态。  
        return InterruptResult.Success; // 返回打断成功。  
    } // 方法结束。  

    private void CloseHitBox() { } // 关闭判定盒。  
    private void StopSkillTimer() { } // 停止技能计时。  
    private void StopSkillVfx() { } // 停止技能表现。  
    private void ApplyCooldownPolicy() { } // 处理冷却规则。  
    private void ChangeState(string state) { } // 切换角色状态。  
} // 类结束。

面试加分说法: 我会把打断设计成“配置驱动 + 状态机驱动”。配置决定哪些阶段能被打断、资源是否返还、CD 怎么算;状态机保证打断后进入明确状态。联机项目里,客户端可以先播被打断表现,但最终是否打断、伤害是否生效,应该以服务端或同步逻辑为准。

技能释放时角色移动如何处理?

unity-skill-movement-policy

标准答案: 技能释放时角色移动不要让移动脚本、动画、技能脚本各自改位置,而是让“技能状态”输出移动策略,再由统一的移动系统执行。常见策略有:锁移动、降速移动、只允许转向、强制位移、Root Motion 位移、输入缓存。

常见处理方式:

前摇阶段:通常锁方向,或者允许很小幅度移动,让角色有起手动作的重量感。

命中阶段:突进、冲刺、挥砍位移通常由技能配置驱动,普通输入被屏蔽。

后摇阶段:可以允许低速移动,也可以开放取消窗口,比如接闪避或下一段连招。

读条技能:一般不能移动,或者移动会打断读条。

移动施法:允许移动,但速度乘一个倍率,比如 30% 或 50%。

底层设计: 移动输入先进入角色状态机,状态机判断当前是否在释放技能,然后读取 MovePolicy。最后所有位移都走统一出口,比如 CharacterController.MoveRigidbody.MovePosition 或服务器校验后的同步位置。

C# 简化代码:

c
using UnityEngine; // 引入 Unity API。  

public enum SkillMovePolicy // 定义技能移动策略。  
{ // 枚举开始。  
    LockMove, // 锁定移动。  
    SlowMove, // 降速移动。  
    TurnOnly, // 只允许转向。  
    ForcedMove // 强制位移。  
} // 枚举结束。  

public sealed class SkillMoveConfig // 定义技能移动配置。  
{ // 类开始。  
    public SkillMovePolicy Policy; // 当前技能使用的移动策略。  
    public float SpeedScale = 1f; // 移动速度倍率。  
    public Vector3 ForcedVelocity; // 技能强制位移速度。  
} // 类结束。  

public sealed class SkillMoveController // 定义技能移动控制器。  
{ // 类开始。  
    private SkillMoveConfig currentConfig; // 当前技能移动配置。  

    public Vector3 GetFinalMove(Vector3 inputMove, float deltaTime) // 计算最终位移。  
    { // 方法开始。  
        if (currentConfig == null) return inputMove * deltaTime; // 没有技能时使用普通移动。  
        if (currentConfig.Policy == SkillMovePolicy.LockMove) return Vector3.zero; // 锁移动时不产生位移。  
        if (currentConfig.Policy == SkillMovePolicy.TurnOnly) return Vector3.zero; // 只转向时不产生位移。  
        if (currentConfig.Policy == SkillMovePolicy.SlowMove) return inputMove * currentConfig.SpeedScale * deltaTime; // 降速移动。  
        if (currentConfig.Policy == SkillMovePolicy.ForcedMove) return currentConfig.ForcedVelocity * deltaTime; // 技能强制位移。  
        return inputMove * deltaTime; // 默认回退到普通移动。  
    } // 方法结束。  

    public void EnterSkill(SkillMoveConfig config) // 进入技能状态。  
    { // 方法开始。  
        currentConfig = config; // 保存当前技能移动配置。  
    } // 方法结束。  

    public void ExitSkill() // 退出技能状态。  
    { // 方法开始。  
        currentConfig = null; // 清空技能移动配置。  
    } // 方法结束。  
} // 类结束。

面试加分说法: 我会把移动权限做成配置,而不是到处写 if (isCasting)。这样普攻、突进、读条、移动施法都能复用同一套移动系统。联机项目里客户端可以预测位移,但服务器要校验技能位移的起点、方向、距离和落点,防止移速外挂或穿墙。

技能目标丢失怎么办?

unity-skill-target-lost

标准答案: 技能目标丢失时,不能只写 target == null 就结束,要按技能类型处理:锁定单体技能可以取消或重新寻敌,范围技能可以打在释放时的快照位置,投射物可以追踪目标或飞向最后位置,读条技能要在读条结束时重新校验目标。

项目里我会这样设计: 技能释放时不长期持有 Transform,而是保存 targetIdsnapshotPosition。在释放前、前摇中、命中前、结算前都重新校验目标是否存在、是否存活、是否可攻击、是否在范围、是否隐身或无敌。

C# 简化代码:

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

public enum TargetLostPolicy // 定义目标丢失策略。  
{ // 枚举开始。  
    CancelSkill, // 取消技能。  
    UseSnapshot, // 使用释放时的快照位置。  
    Retarget // 重新选择目标。  
} // 枚举结束。  

public sealed class SkillTargetContext // 定义技能目标上下文。  
{ // 类开始。  
    public int TargetId; // 目标逻辑 ID。  
    public Vector3 SnapshotPosition; // 释放时记录的目标位置。  
    public TargetLostPolicy LostPolicy; // 目标丢失后的处理策略。  
} // 类结束。  

public sealed class SkillTargetResolver // 定义目标解析器。  
{ // 类开始。  
    public bool TryResolveTarget(SkillTargetContext context, out Transform target, out Vector3 castPosition) // 尝试解析目标。  
    { // 方法开始。  
        target = FindTargetTransform(context.TargetId); // 通过目标 ID 查询当前对象。  
        castPosition = context.SnapshotPosition; // 默认使用快照位置。  
        if (target != null && IsTargetValid(target)) return true; // 目标存在且有效就继续释放。  
        if (context.LostPolicy == TargetLostPolicy.UseSnapshot) return true; // 允许打快照点就继续释放。  
        if (context.LostPolicy == TargetLostPolicy.Retarget) return TryFindNewTarget(out target, out castPosition); // 允许重新寻敌就尝试找新目标。  
        target = null; // 取消技能时清空目标。  
        return false; // 返回目标解析失败。  
    } // 方法结束。  

    private Transform FindTargetTransform(int targetId) { return null; } // 示例:实际项目从实体管理器查询。  
    private bool IsTargetValid(Transform target) { return target.gameObject.activeInHierarchy; } // 示例:实际项目还要检查存活、阵营、距离和隐身。  
    private bool TryFindNewTarget(out Transform target, out Vector3 castPosition) { target = null; castPosition = Vector3.zero; return false; } // 示例:实际项目按仇恨、距离、角度重新选目标。  
} // 类结束。

面试加分说法: 目标丢失本质是战斗规则问题,不只是空引用问题。客户端可以先播放表现,但联机游戏里最终是否命中、是否造成伤害,应该由服务端或同步逻辑确认,避免玩家攻击已经死亡、隐身、超距或不存在的目标。

范围技能如何选目标?

unity-area-skill-target-selection

标准答案: 范围技能选目标一般分四步:几何粗筛、规则精筛、排序限量、最终校验。也就是先按圆形、扇形、矩形等范围拿到候选目标,再过滤阵营、存活、无敌、隐身、障碍,最后按距离、血量、仇恨等规则排序,取最多 N 个目标。

常见范围: 圆形 AOE:爆炸、治疗圈,用 OverlapSphereNonAlloc。 扇形 AOE:挥砍、喷火,先圆形粗筛,再用点乘判断角度。 矩形 AOE:冲击波、地刺,用 OverlapBoxNonAlloc。 直线/胶囊:激光、冲锋路径,用 Capsule 或线段距离判断。

C# 简化代码:

c
using System.Collections.Generic; // 引入 List 和 HashSet。  
using UnityEngine; // 引入 Unity 类型。  
public enum Team { Player, Enemy } // 定义阵营枚举。  
public sealed class Targetable : MonoBehaviour // 定义可被选中的目标组件。  
{ // 类开始。  
    public int EntityId; // 目标逻辑 ID。  
    public Team Team; // 目标所属阵营。  
    public bool IsAlive = true; // 目标是否存活。  
    public bool IsInvincible = false; // 目标是否无敌。  
} // 类结束。  
public sealed class AreaTargetSelector : MonoBehaviour // 定义范围选目标器。  
{ // 类开始。  
    private readonly Collider[] buffer = new Collider[64]; // 复用碰撞体数组,避免 GC。  
    private readonly HashSet<int> usedIds = new HashSet<int>(); // 记录已加入的目标 ID,避免重复命中。  
    public int SelectCircleTargets(Vector3 center, float radius, Team casterTeam, LayerMask targetMask, int maxCount, List<Targetable> result) // 选择圆形范围内的目标。  
    { // 方法开始。  
        result.Clear(); // 清空上一次结果。  
        usedIds.Clear(); // 清空去重表。  
        int count = Physics.OverlapSphereNonAlloc(center, radius, buffer, targetMask); // 用无 GC 的方式做圆形粗筛。  
        for (int i = 0; i < count; i++) // 遍历候选碰撞体。  
        { // 循环开始。  
            Collider hit = buffer[i]; // 取出当前碰撞体。  
            if (hit == null) continue; // 碰撞体为空就跳过。  
            Targetable target = hit.GetComponentInParent<Targetable>(); // 找到目标组件。  
            if (target == null) continue; // 没有目标组件就跳过。  
            if (usedIds.Contains(target.EntityId)) continue; // 同一个目标多个 Collider 只算一次。  
            if (target.Team == casterTeam) continue; // 同阵营目标跳过。  
            if (!target.IsAlive) continue; // 死亡目标跳过。  
            if (target.IsInvincible) continue; // 无敌目标跳过。  
            usedIds.Add(target.EntityId); // 记录目标已经加入。  
            result.Add(target); // 把合法目标加入结果。  
        } // 循环结束。  
        result.Sort((a, b) => (a.transform.position - center).sqrMagnitude.CompareTo((b.transform.position - center).sqrMagnitude)); // 按距离从近到远排序。  
        if (result.Count > maxCount) result.RemoveRange(maxCount, result.Count - maxCount); // 超过数量上限就裁掉。  
        return result.Count; // 返回最终目标数量。  
    } // 方法结束。  
} // 类结束。

面试加分说法: 我不会直接遍历场上所有怪物,而是先用物理查询或空间分区做粗筛;大量单位时用 NonAlloc、对象缓存、平方距离比较,减少 GC 和 CPU。客户端可以显示范围和预判命中,但联机游戏里最终命中、伤害和目标合法性应该由服务端复核。

扇形技能如何判断命中?

unity-sector-skill-hit-test

标准答案: 扇形技能命中一般分两步:先按半径做圆形粗筛,再用点乘判断目标是否在扇形角度内。

核心公式:dot = Vector3.Dot(forward.normalized, dir.normalized) 如果 dot >= Mathf.Cos(halfAngle),说明目标在扇形半角范围内。

这里的 forward 是角色朝向,dir 是“施法者指向目标”的方向。点乘越接近 1,说明目标越靠近正前方;小于阈值就说明目标偏到扇形外了。

C# 核心代码:

c
using UnityEngine; // 引入 Unity 的 Vector3 和 Mathf。  

public static class SectorHitUtil // 定义扇形命中工具类。  
{ // 类开始。  
    public static bool IsInSector(Vector3 casterPos, Vector3 casterForward, Vector3 targetPos, float radius, float halfAngle) // 判断目标是否在扇形内。  
    { // 方法开始。  
        Vector3 toTarget = targetPos - casterPos; // 计算施法者到目标的方向向量。  
        toTarget.y = 0f; // 忽略 Y 轴高度,只判断 XZ 平面。  
        casterForward.y = 0f; // 施法者朝向也压到 XZ 平面。  
        float sqrDistance = toTarget.sqrMagnitude; // 使用平方距离,避免开方。  
        if (sqrDistance > radius * radius) return false; // 超出半径,直接不命中。  
        if (sqrDistance <= 0.0001f) return true; // 目标几乎在脚下,认为命中。  
        Vector3 dir = toTarget.normalized; // 得到目标方向的单位向量。  
        Vector3 forward = casterForward.normalized; // 得到角色朝向的单位向量。  
        float dot = Vector3.Dot(forward, dir); // 点乘得到两个方向的接近程度。  
        float cosHalfAngle = Mathf.Cos(halfAngle * Mathf.Deg2Rad); // 把半角转成余弦阈值。  
        return dot >= cosHalfAngle; // 大于阈值说明目标在扇形内。  
    } // 方法结束。  
} // 类结束。

项目里完整流程: 先用 Physics.OverlapSphereNonAlloc 拿半径内候选,减少遍历数量;再对候选目标做点乘角度判断;最后过滤阵营、死亡、无敌、隐身、障碍物,并用 HashSet<EntityId> 去重,避免一个角色多个 Collider 被重复命中。

常见坑点: 配置里的角度通常是“总角度”,代码里要用“半角”;地面技能一般忽略 Y 轴;边界目标可以加一点容差;客户端可以先显示命中预判,但联机项目最终命中最好由服务端校验。

矩形技能如何判断命中?

unity-rectangle-skill-hit-test

标准答案: 矩形技能命中可以理解成:把目标转到施法者的局部坐标里判断。目标在前方距离 front 落在矩形长度内,左右偏移 side 落在半宽内,就算命中。

核心公式:front = Dot(target - origin, forward)side = Dot(target - origin, right) 满足:0 <= front <= lengthAbs(side) <= halfWidth

C# 核心代码:

c
using UnityEngine; // 引入 Unity 的 Vector3 和 Mathf。  
public static class RectangleHitUtil // 定义矩形命中工具类。  
{ // 类开始。  
    public static bool IsInForwardRectangle(Vector3 origin, Vector3 forward, Vector3 target, float length, float halfWidth) // 判断目标是否在角色前方矩形内。  
    { // 方法开始。  
        Vector3 toTarget = target - origin; // 计算目标相对施法者的位置。  
        toTarget.y = 0f; // 忽略 Y 轴,只在 XZ 平面判断。  
        forward.y = 0f; // 角色朝向也压到 XZ 平面。  
        if (forward.sqrMagnitude <= 0.0001f) return false; // 朝向无效时直接返回失败。  
        Vector3 f = forward.normalized; // 得到前方单位向量。  
        Vector3 r = new Vector3(f.z, 0f, -f.x); // 根据前方计算右方向单位向量。  
        float front = Vector3.Dot(toTarget, f); // 投影到前方轴,得到前后距离。  
        float side = Vector3.Dot(toTarget, r); // 投影到右方轴,得到左右偏移。  
        if (front < 0f) return false; // 目标在角色身后,不命中。  
        if (front > length) return false; // 目标超过技能长度,不命中。  
        if (Mathf.Abs(side) > halfWidth) return false; // 目标左右偏移超过半宽,不命中。  
        return true; // 同时满足长度和宽度条件,判定命中。  
    } // 方法结束。  
} // 类结束。

Unity 项目里怎么做: 客户端可以用 Physics.OverlapBoxNonAlloc 先粗筛候选,再用上面的数学方式精确判断;最后还要过滤阵营、死亡、无敌、遮挡,并用 EntityId 去重,避免一个角色多个 Collider 被重复命中。

常见坑点: 矩形要跟随角色朝向旋转,不能只用世界坐标的 X/Z 判断;技能配置里要说清楚是“从角色前方开始的方向矩形”,还是“以落点为中心的中心矩形”。联机项目里客户端显示范围,最终命中最好服务端复核。

子弹飞行过程中目标死亡怎么办?

unity-projectile-target-death

标准答案: 子弹飞行过程中目标死亡时,不要继续追 Transform,而是按子弹配置处理:直线弹继续飞,追踪弹可以飞到最后位置、重新寻敌或消失,AOE 弹可以在最后落点爆炸,锁定必中弹通常在结算前发现目标死亡就不造成伤害。

核心设计: 子弹发射时保存 targetIdlastValidPosition。飞行过程中每帧或每个逻辑 Tick 重新查询目标,如果目标还活着就更新快照;如果目标死亡、销毁或无效,就进入 TargetLost 分支,按策略收尾,并确保只结算一次、只回收一次。

C# 简化代码:

c
using UnityEngine; // 引入 Unity 类型。  
public enum ProjectileLostPolicy // 定义子弹目标丢失策略。  
{ // 枚举开始。  
    DestroyProjectile, // 目标死亡后直接销毁子弹。  
    FlyToLastPosition, // 目标死亡后飞向最后合法位置。  
    Retarget, // 目标死亡后尝试重新寻找目标。  
    ExplodeAtLastPosition // 目标死亡后在最后合法位置爆炸。  
} // 枚举结束。  
public sealed class ProjectileRuntime // 定义子弹运行时数据。  
{ // 类开始。  
    public int TargetId; // 子弹追踪的目标逻辑 ID。  
    public Vector3 LastValidPosition; // 目标最后一次有效的位置。  
    public ProjectileLostPolicy LostPolicy; // 目标丢失后的处理策略。  
    public bool Finished; // 子弹是否已经结束。  
    public bool HasHit; // 子弹是否已经造成过命中。  
} // 类结束。  
public sealed class ProjectileController // 定义子弹控制器。  
{ // 类开始。  
    private ProjectileRuntime runtime; // 当前子弹运行时数据。  
    public void Tick(float deltaTime) // 每帧或每个逻辑 Tick 更新子弹。  
    { // 方法开始。  
        if (runtime.Finished) return; // 已结束的子弹不再更新。  
        Transform target = FindTarget(runtime.TargetId); // 通过目标 ID 查询目标对象。  
        if (target != null && IsAlive(target)) // 如果目标存在并且还活着。  
        { // 判断开始。  
            runtime.LastValidPosition = target.position; // 更新最后合法位置。  
            MoveTo(target.position, deltaTime); // 继续朝当前目标飞行。  
            return; // 正常追踪时结束本次更新。  
        } // 判断结束。  
        HandleTargetLost(deltaTime); // 目标死亡或无效时进入丢失处理。  
    } // 方法结束。  
    private void HandleTargetLost(float deltaTime) // 处理目标丢失。  
    { // 方法开始。  
        if (runtime.LostPolicy == ProjectileLostPolicy.DestroyProjectile) Finish(); // 配置为销毁时直接结束。  
        else if (runtime.LostPolicy == ProjectileLostPolicy.FlyToLastPosition) MoveTo(runtime.LastValidPosition, deltaTime); // 配置为飞最后位置时继续飞。  
        else if (runtime.LostPolicy == ProjectileLostPolicy.Retarget) TryRetargetOrFinish(); // 配置为重新寻敌时尝试换目标。  
        else if (runtime.LostPolicy == ProjectileLostPolicy.ExplodeAtLastPosition) Explode(runtime.LastValidPosition); // 配置为爆炸时在最后位置爆炸。  
    } // 方法结束。  
    private void Finish() { runtime.Finished = true; } // 标记子弹结束,实际项目还要关闭碰撞和回对象池。  
    private void Explode(Vector3 pos) { if (!runtime.HasHit) runtime.HasHit = true; Finish(); } // 爆炸时保证只结算一次。  
    private void MoveTo(Vector3 pos, float dt) { } // 示例:实际项目在这里移动子弹。  
    private void TryRetargetOrFinish() { Finish(); } // 示例:实际项目在这里重新寻找目标。  
    private Transform FindTarget(int id) { return null; } // 示例:实际项目从实体管理器查询目标。  
    private bool IsAlive(Transform target) { return target.gameObject.activeInHierarchy; } // 示例:实际项目检查生命状态。  
} // 类结束。

面试加分说法: 我会把子弹做成状态机:Flying -> TargetLost -> Explode/Finished。目标死亡后一定要清理 Collider、计时器、命中表、特效和对象池状态,避免“幽灵子弹”继续造成伤害。联机游戏里客户端只负责飞行表现,最终是否命中、是否造成伤害,应该由服务端或同步逻辑确认。

追踪子弹如何实现?

unity-homing-projectile-implementation

标准答案: 追踪子弹的核心是:每帧根据目标位置算出“期望方向”,再用最大转向速度把当前方向慢慢转过去,最后按 direction * speed * deltaTime 推进位置。

基本流程: 生成子弹 -> 保存目标 ID 和初始方向 -> 每帧查询目标 -> 计算目标方向 -> 限制转向角 -> 移动子弹 -> 做碰撞检测 -> 命中、超时或目标丢失后回收。

C# 简化代码:

c
using UnityEngine; // 引入 Unity 类型。  
public sealed class HomingProjectile : MonoBehaviour // 定义追踪子弹组件。  
{ // 类开始。  
    public float Speed = 12f; // 子弹移动速度。  
    public float TurnSpeed = 360f; // 每秒最大转向角度。  
    public float LifeTime = 5f; // 子弹最大生命周期。  
    private Transform target; // 当前追踪目标。  
    private Vector3 direction; // 当前飞行方向。  
    private float timer; // 已飞行时间。  
    private bool finished; // 是否已经结束。  
    public void Init(Transform newTarget, Vector3 startDirection) // 初始化子弹。  
    { // 方法开始。  
        target = newTarget; // 保存追踪目标。  
        direction = startDirection.normalized; // 保存初始方向。  
        timer = 0f; // 重置生命周期计时。  
        finished = false; // 重置结束标记。  
        gameObject.SetActive(true); // 激活子弹对象。  
    } // 方法结束。  
    private void Update() // 每帧更新子弹。  
    { // 方法开始。  
        if (finished) return; // 已结束则不再更新。  
        timer += Time.deltaTime; // 累加飞行时间。  
        if (timer >= LifeTime) { Finish(); return; } // 超过生命周期就回收。  
        if (target != null) // 如果目标还存在。  
        { // 判断开始。  
            Vector3 desiredDirection = target.position - transform.position; // 计算子弹指向目标的方向。  
            if (desiredDirection.sqrMagnitude > 0.0001f) // 方向有效才转向。  
            { // 判断开始。  
                float maxRadians = TurnSpeed * Mathf.Deg2Rad * Time.deltaTime; // 计算本帧最大转向弧度。  
                direction = Vector3.RotateTowards(direction, desiredDirection.normalized, maxRadians, 0f); // 平滑转向目标方向。  
            } // 判断结束。  
        } // 判断结束。  
        Vector3 oldPosition = transform.position; // 记录移动前位置。  
        Vector3 newPosition = oldPosition + direction * Speed * Time.deltaTime; // 计算移动后位置。  
        if (Physics.Linecast(oldPosition, newPosition, out RaycastHit hit)) { OnHit(hit.collider); return; } // 用线段检测防止高速穿透。  
        transform.position = newPosition; // 应用新位置。  
        transform.forward = direction; // 让子弹朝向飞行方向。  
    } // 方法结束。  
    private void OnHit(Collider collider) // 处理命中。  
    { // 方法开始。  
        if (finished) return; // 防止重复命中。  
        finished = true; // 标记已经结束。  
        Finish(); // 进入统一收尾。  
    } // 方法结束。  
    private void Finish() // 统一结束子弹。  
    { // 方法开始。  
        target = null; // 清空目标引用。  
        gameObject.SetActive(false); // 示例:实际项目这里归还对象池。  
    } // 方法结束。  
} // 类结束。

项目里的关键点: 目标不要只存 Transform,最好存 targetId,每帧从实体管理器查目标;目标死亡时可以飞最后位置、重新寻敌或销毁;高速子弹要用上一帧位置到当前位置的 Raycast 防穿透;回对象池前要重置目标、方向、计时器、命中标记。

面试加分说法: 追踪子弹不要每帧直接 LookAt,否则会像瞬间锁头。用 RotateTowards 或方向插值限制转向速度,手感会自然很多。联机项目中客户端负责飞行表现,最终命中、伤害和目标合法性最好由服务端确认。

抛物线子弹如何实现?

unity-parabolic-projectile-implementation

标准答案: 抛物线子弹可以用两种方式实现:一种是 Rigidbody 设置初速度交给物理重力,另一种是用逻辑公式直接计算轨迹。技能子弹通常更推荐逻辑公式,因为轨迹可控、好调参数、好做同步和回放。

核心公式: 先用 t 表示飞行进度:t = elapsed / duration。 水平位置:basePos = Lerp(startPos, endPos, t)。 额外高度:height = 4 * arcHeight * t * (1 - t)。 最终位置:pos = basePos + Vector3.up * height

C# 简化代码:

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

public sealed class ParabolicProjectile : MonoBehaviour // 定义抛物线子弹组件。  
{ // 类开始。  
    private Vector3 startPos; // 子弹起点。  
    private Vector3 endPos; // 子弹落点。  
    private float duration; // 飞行总时长。  
    private float arcHeight; // 抛物线最高额外高度。  
    private float elapsed; // 已飞行时间。  
    private bool finished; // 是否已经结束。  

    public void Init(Vector3 start, Vector3 end, float flyDuration, float height) // 初始化抛物线子弹。  
    { // 方法开始。  
        startPos = start; // 保存起点。  
        endPos = end; // 保存落点。  
        duration = Mathf.Max(0.01f, flyDuration); // 保存飞行时间,并避免除零。  
        arcHeight = height; // 保存抛物线高度。  
        elapsed = 0f; // 重置已飞行时间。  
        finished = false; // 重置结束标记。  
        transform.position = startPos; // 把子弹放到起点。  
        gameObject.SetActive(true); // 激活子弹对象。  
    } // 方法结束。  

    private void Update() // 每帧更新子弹。  
    { // 方法开始。  
        if (finished) return; // 已结束则不再更新。  
        Vector3 oldPos = transform.position; // 记录上一帧位置。  
        elapsed += Time.deltaTime; // 累加飞行时间。  
        float t = Mathf.Clamp01(elapsed / duration); // 计算 0 到 1 的飞行进度。  
        Vector3 basePos = Vector3.Lerp(startPos, endPos, t); // 计算水平插值位置。  
        float height = 4f * arcHeight * t * (1f - t); // 计算先升后降的抛物线高度。  
        Vector3 newPos = basePos + Vector3.up * height; // 合成最终位置。  
        if (Physics.Linecast(oldPos, newPos, out RaycastHit hit)) { OnHit(hit.point); return; } // 用线段检测防止高速穿透。  
        transform.position = newPos; // 应用新位置。  
        Vector3 tangent = newPos - oldPos; // 计算当前飞行切线方向。  
        if (tangent.sqrMagnitude > 0.0001f) transform.forward = tangent.normalized; // 让子弹朝向飞行方向。  
        if (t >= 1f) OnHit(endPos); // 到达终点后触发落点结算。  
    } // 方法结束。  

    private void OnHit(Vector3 hitPos) // 处理命中或落点爆炸。  
    { // 方法开始。  
        if (finished) return; // 防止重复结算。  
        finished = true; // 标记已经结束。  
        transform.position = hitPos; // 把子弹放到命中点。  
        gameObject.SetActive(false); // 示例:实际项目这里播放爆炸并回对象池。  
    } // 方法结束。  
} // 类结束。

项目里要注意: 发射时最好锁定落点快照,不要目标移动后轨迹突然变形;高速子弹要用上一帧到当前帧的 Linecast 防穿透;朝向可以用当前位置到下一帧位置的切线;落地后一般触发 AOE 范围选目标;联机项目同步起点、落点、飞行时间、高度,双方按同一公式算轨迹。

多段伤害如何实现?

unity-multi-hit-damage

标准答案: 多段伤害不是简单循环扣血,而是把一个技能拆成多个“命中段”或“Tick 点”。每一段都有自己的触发时间、范围、伤害倍率、受击表现和命中去重表,然后每段生成一个 DamageEvent 交给伤害结算系统。

核心设计: 比如三连斩:第 1 段倍率 0.6,第 2 段倍率 0.8,第 3 段倍率 1.2。运行时用技能时间轴推进,到达每段触发点时做范围检测、过滤目标、去重、生成伤害事件。

C# 简化代码:

c
using System.Collections.Generic; // 引入 List 和 HashSet。  
using UnityEngine; // 引入 Unity 类型。  
[System.Serializable] // 允许在 Inspector 中配置。  
public sealed class HitSegment // 定义一个命中段配置。  
{ // 类开始。  
    public float TriggerTime; // 这一段触发伤害的时间点。  
    public float DamageRate = 1f; // 这一段的伤害倍率。  
    public float Radius = 2f; // 这一段的检测半径。  
} // 类结束。  
public sealed class MultiHitSkill : MonoBehaviour // 定义多段伤害技能。  
{ // 类开始。  
    public HitSegment[] Segments; // 多段伤害配置数组。  
    public LayerMask TargetMask; // 目标 Layer。  
    private readonly Collider[] buffer = new Collider[32]; // 复用碰撞体数组,避免 GC。  
    private readonly HashSet<int> hitIds = new HashSet<int>(); // 当前段已经命中的目标 ID。  
    private float elapsed; // 技能已经运行的时间。  
    private int nextSegmentIndex; // 下一段要触发的段索引。  
    private bool running; // 技能是否正在运行。  
    public void Begin() // 开始释放技能。  
    { // 方法开始。  
        elapsed = 0f; // 重置技能时间。  
        nextSegmentIndex = 0; // 从第一段开始。  
        running = true; // 标记技能运行中。  
    } // 方法结束。  
    private void Update() // 每帧推进技能。  
    { // 方法开始。  
        if (!running) return; // 技能没有运行就返回。  
        elapsed += Time.deltaTime; // 累加技能时间。  
        while (nextSegmentIndex < Segments.Length && elapsed >= Segments[nextSegmentIndex].TriggerTime) // 到达某段触发时间就执行。  
        { // 循环开始。  
            RunSegment(Segments[nextSegmentIndex]); // 执行当前命中段。  
            nextSegmentIndex++; // 移动到下一段。  
        } // 循环结束。  
        if (nextSegmentIndex >= Segments.Length) running = false; // 所有段都执行完就结束技能。  
    } // 方法结束。  
    private void RunSegment(HitSegment segment) // 执行某一段伤害。  
    { // 方法开始。  
        hitIds.Clear(); // 每一段单独清空命中表,允许下一段再次命中。  
        int count = Physics.OverlapSphereNonAlloc(transform.position, segment.Radius, buffer, TargetMask); // 检测范围内目标。  
        for (int i = 0; i < count; i++) // 遍历候选目标。  
        { // 循环开始。  
            Collider target = buffer[i]; // 取出当前碰撞体。  
            if (target == null) continue; // 空对象跳过。  
            int id = target.GetInstanceID(); // 示例:实际项目应使用 EntityId。  
            if (hitIds.Contains(id)) continue; // 当前段已经命中过就跳过。  
            hitIds.Add(id); // 记录当前段已命中。  
            ApplyDamage(id, segment.DamageRate); // 按当前段倍率生成伤害。  
        } // 循环结束。  
    } // 方法结束。  
    private void ApplyDamage(int targetId, float damageRate) // 应用伤害。  
    { // 方法开始。  
        // 实际项目这里应创建 DamageEvent,交给伤害系统处理属性、Buff、暴击和减伤。 // 说明真实项目做法。  
    } // 方法结束。  
} // 类结束。

面试加分说法: 我会把多段伤害做成配置驱动,而不是写死在动画事件里。动画事件可以作为标记,但最终是否触发伤害、是否重复命中、倍率是多少,应该由技能时间轴和服务端规则决定。

常见坑点: 不要用 OnTriggerStay 每帧扣血,否则帧率不同会导致伤害次数不同。多段伤害一定要有命中去重表,并明确是“每段可命中一次”,还是“整个技能只命中一次”。被打断时还要关闭后续命中段,避免技能停了伤害还在触发。

DOT 持续伤害如何实现?

unity-dot-damage-over-time

标准答案: DOT 持续伤害一般做成一种 Buff:施加后保存持续时间和 Tick 间隔,运行时每隔固定时间生成一次 DamageEvent,交给伤害系统结算,而不是每帧直接扣血。

核心设计:DotConfig 配置持续时间、Tick 间隔、基础伤害、叠层规则;DotInstance 保存剩余时间、Tick 计时器、层数、施法者;角色身上的 BuffComponent 统一管理所有 DOT。

C# 简化代码:

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

public sealed class DotConfig // 定义 DOT 配置。  
{ // 类开始。  
    public int BuffId; // Buff 配置 ID。  
    public float Duration; // 持续时间。  
    public float TickInterval; // 每次跳伤害的间隔。  
    public int DamagePerTick; // 每跳基础伤害。  
    public int MaxStack; // 最大叠层数。  
} // 类结束。  

public sealed class DotInstance // 定义 DOT 运行时实例。  
{ // 类开始。  
    public DotConfig Config; // 当前 DOT 使用的配置。  
    public int CasterId; // 施法者 ID。  
    public int TargetId; // 目标 ID。  
    public int Stack; // 当前层数。  
    public float RemainingTime; // 剩余持续时间。  
    public float TickTimer; // Tick 计时器。  
} // 类结束。  

public sealed class DotComponent // 定义角色身上的 DOT 管理组件。  
{ // 类开始。  
    private readonly List<DotInstance> dots = new List<DotInstance>(); // 保存当前身上的 DOT。  

    public void Update(float deltaTime) // 每帧或每个逻辑 Tick 更新 DOT。  
    { // 方法开始。  
        for (int i = dots.Count - 1; i >= 0; i--) // 倒序遍历,方便移除结束的 DOT。  
        { // 循环开始。  
            DotInstance dot = dots[i]; // 取出当前 DOT。  
            dot.RemainingTime -= deltaTime; // 扣减剩余时间。  
            dot.TickTimer += deltaTime; // 累加 Tick 计时。  
            while (dot.TickTimer >= dot.Config.TickInterval) // 到达 Tick 间隔就结算。  
            { // 循环开始。  
                dot.TickTimer -= dot.Config.TickInterval; // 减去一个 Tick 间隔。  
                ApplyDotDamage(dot); // 生成一次 DOT 伤害。  
            } // 循环结束。  
            if (dot.RemainingTime <= 0f) dots.RemoveAt(i); // 持续时间结束后移除 DOT。  
        } // 循环结束。  
    } // 方法结束。  

    public void AddDot(DotConfig config, int casterId, int targetId) // 添加或刷新 DOT。  
    { // 方法开始。  
        DotInstance dot = FindDot(config.BuffId, casterId); // 查找同来源同 Buff。  
        if (dot == null) // 如果不存在已有 DOT。  
        { // 判断开始。  
            dot = new DotInstance(); // 创建新的 DOT 实例。  
            dot.Config = config; // 保存 DOT 配置。  
            dot.CasterId = casterId; // 保存施法者。  
            dot.TargetId = targetId; // 保存目标。  
            dot.Stack = 1; // 初始层数为 1。  
            dots.Add(dot); // 添加到 DOT 列表。  
        } // 判断结束。  
        else // 如果已经存在同类 DOT。  
        { // 分支开始。  
            dot.Stack = System.Math.Min(dot.Stack + 1, config.MaxStack); // 增加层数但不超过上限。  
        } // 分支结束。  
        dot.RemainingTime = config.Duration; // 刷新持续时间。  
    } // 方法结束。  

    private DotInstance FindDot(int buffId, int casterId) // 查找已有 DOT。  
    { // 方法开始。  
        foreach (DotInstance dot in dots) // 遍历当前 DOT。  
        { // 循环开始。  
            if (dot.Config.BuffId == buffId && dot.CasterId == casterId) return dot; // 找到同 Buff 同来源就返回。  
        } // 循环结束。  
        return null; // 没找到则返回空。  
    } // 方法结束。  

    private void ApplyDotDamage(DotInstance dot) // 应用 DOT 伤害。  
    { // 方法开始。  
        int damage = dot.Config.DamagePerTick * dot.Stack; // 根据层数计算本次伤害。  
        // 实际项目中这里创建 DamageEvent,交给伤害系统处理护盾、减伤、免疫和战斗日志。 // 说明真实项目做法。  
    } // 方法结束。  
} // 类结束。

常见坑点: 不要用 OnTriggerStay 或每帧扣血,否则帧率不同会导致伤害不同;大量 DOT 不建议每个都开协程,统一由 Buff 管理器更新更好;目标死亡、切场景、驱散 Buff 时要清理 DOT;联机项目里 DOT 的 Tick 和伤害最好由服务端确认。

护盾 Buff 如何结算?

unity-shield-buff-settlement

标准答案

护盾 Buff 本质是“伤害进入血量前的一层吸收池”。结算时不要把它当成 UI 血条效果,而是要放进统一伤害流程里:先算伤害是否命中,再处理免疫、减伤,然后由护盾吸收,护盾不够时剩余伤害才扣 HP。

推荐结算顺序

一般可以设计成:

伤害命中 -> 免疫/无敌判断 -> 防御和减伤 -> 护盾吸收 -> 溢出伤害扣血 -> 触发破盾/受击/飘字事件

多个护盾同时存在时,一定要有稳定规则,比如按 priority 排序;同优先级按创建序号或剩余时间排序。联机、回放、帧同步项目尤其要注意确定性,否则不同客户端可能算出不同血量。

c
using System; // 引入基础命名空间

public sealed class ShieldBuff // 定义护盾 Buff
{ // 类开始
    public int Priority; // 护盾优先级,数值越大越先扣
    public int CreateOrder; // 护盾创建顺序,用于同优先级时保证稳定排序
    public int Value; // 当前护盾剩余值

    public int Absorb(int damage) // 吸收传入伤害并返回剩余伤害
    { // 方法开始
        int absorbed = Math.Min(Value, damage); // 计算本次护盾实际吸收量
        Value -= absorbed; // 扣掉护盾值
        return damage - absorbed; // 返回护盾吸收后剩余的溢出伤害
    } // 方法结束

    public bool IsBroken() // 判断护盾是否已经破掉
    { // 方法开始
        return Value <= 0; // 护盾值小于等于 0 就表示破盾
    } // 方法结束
} // 类结束

面试加分点

护盾结算要产出事件,比如 OnShieldAbsorbOnShieldBreakOnHpChanged,表现层只根据这些事件播放护盾闪烁、破盾特效、飘字和音效。不要让 UI 反过来修改护盾值。

DOT 持续伤害、反伤、多段伤害也应该走同一个伤害入口,否则很容易出现“普通攻击会扣盾,但持续伤害绕过护盾”的 Bug。

常见坑

多个护盾扣除顺序不固定。 护盾破了以后重复触发破盾事件。 护盾吸收发生在减伤前还是减伤后没有写清楚。 表现层和逻辑层混在一起,导致血条显示和真实数据不一致。

一句话收尾

护盾 Buff 的核心是:统一伤害入口、固定结算顺序、稳定护盾优先级、处理溢出伤害,并把吸收和破盾结果同步给表现层。

伤害免疫和无敌帧如何设计?

unity-damage-immunity-iframe-design

标准答案

伤害免疫和无敌帧都不是“简单不扣血”,而是伤害结算链里的过滤规则。

免疫更像“规则判断”:比如火免、毒免、控制免疫、击退免疫。 无敌帧更像“时间窗口”:比如翻滚第 3 到第 18 帧不受伤害,复活后 2 秒无敌。

底层设计

一次攻击命中后,先生成 DamageEvent,不要马上扣血。统一交给战斗结算器判断:

命中事件 -> 重复命中校验 -> 无敌帧判断 -> 免疫规则判断 -> 减伤 -> 护盾 -> 扣血 -> 返回结果

这样 UI、飘字、音效、日志、联网同步都可以读同一个 DamageResult

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

public enum DamageType // 定义伤害类型。
{ // 枚举开始。
    Physical, // 物理伤害。
    Fire, // 火焰伤害。
    Poison // 毒伤害。
} // 枚举结束。

public enum DamageResultType // 定义结算结果类型。
{ // 枚举开始。
    Hit, // 正常命中。
    Immune, // 被免疫规则挡掉。
    Invincible, // 被无敌帧挡掉。
    Rejected // 被重复命中等规则拒绝。
} // 枚举结束。

public sealed class DamageEvent // 定义一次伤害事件。
{ // 类开始。
    public int Amount; // 伤害数值。
    public DamageType Type; // 伤害类型。
    public int SourceId; // 攻击者 ID。
    public int SkillId; // 技能 ID。
    public int HitId; // 本次命中 ID,用来防止重复扣血。
} // 类结束。

public sealed class InvincibleWindow // 定义无敌帧窗口。
{ // 类开始。
    public int StartTick; // 无敌开始逻辑帧。
    public int EndTick; // 无敌结束逻辑帧。
    public bool Contains(int tick) => tick >= StartTick && tick <= EndTick; // 判断当前帧是否处于无敌窗口。
} // 类结束。

public sealed class ImmunityRule // 定义免疫规则。
{ // 类开始。
    public DamageType Type; // 免疫的伤害类型。
    public bool Match(DamageEvent e) => e.Type == Type; // 判断这次伤害是否被免疫。
} // 类结束。

public sealed class DamageResult // 定义伤害结算结果。
{ // 类开始。
    public DamageResultType Type; // 本次结算结果类型。
    public int FinalDamage; // 最终扣血数值。
    public string Reason; // 结果原因,方便日志和表现层使用。
} // 类结束。

public sealed class DamageResolver // 定义伤害结算器。
{ // 类开始。
    private readonly HashSet<string> handledHits = new HashSet<string>(); // 记录已处理命中,避免重复扣血。

    public DamageResult Resolve(DamageEvent e, int tick, List<InvincibleWindow> iframes, List<ImmunityRule> immunities) // 结算一次伤害。
    { // 方法开始。
        string key = e.SourceId + "_" + e.SkillId + "_" + e.HitId; // 生成唯一命中键。
        if (!handledHits.Add(key)) return new DamageResult { Type = DamageResultType.Rejected, FinalDamage = 0, Reason = "重复命中" }; // 重复命中直接拒绝。

        for (int i = 0; i < iframes.Count; i++) // 遍历无敌帧窗口。
            if (iframes[i].Contains(tick)) return new DamageResult { Type = DamageResultType.Invincible, FinalDamage = 0, Reason = "无敌帧" }; // 当前处于无敌帧就不受伤害。

        for (int i = 0; i < immunities.Count; i++) // 遍历免疫规则。
            if (immunities[i].Match(e)) return new DamageResult { Type = DamageResultType.Immune, FinalDamage = 0, Reason = "伤害免疫" }; // 类型匹配就免疫伤害。

        return new DamageResult { Type = DamageResultType.Hit, FinalDamage = e.Amount, Reason = "正常命中" }; // 没有被过滤就正常扣血。
    } // 方法结束。
} // 类结束。

Unity 项目里怎么用

翻滚、闪避、复活保护适合用无敌帧;火免、毒免、霸体、控制免疫适合用免疫规则。 不要只靠 Collider.enabled = false,因为碰撞检测、受击表现、联网同步、战斗日志会变得很难统一。更好的做法是:碰撞照常检测,真正是否扣血交给伤害结算器。

常见坑点

无敌帧最好用逻辑帧或 tick,不要完全依赖动画播放时间;动画事件可以触发开始和结束,但最终状态要由逻辑层兜底。联机游戏里客户端可以预测播放闪避表现,但最终是否无敌应该由服务端判定。

暴击、闪避、格挡顺序怎么设计?

unity-crit-dodge-block-order

标准答案

我会把顺序设计成:

命中合法性 -> 闪避 -> 格挡 -> 暴击 -> 减伤/护盾 -> 扣血 -> 输出 DamageResult

原因是:闪避代表根本没打中,所以它应该最先挡掉伤害;格挡代表打中了但被防御动作处理暴击是对有效伤害做倍率放大,所以通常放在闪避和格挡之后。

重点不是“唯一正确顺序”,而是项目必须统一。 如果项目想突出防御价值,可以 闪避 -> 格挡 -> 暴击;如果想突出攻击收益,也可以 闪避 -> 暴击 -> 格挡。但不能 A 模块先暴击,B 模块先格挡,否则日志、飘字、PVP 同步都会乱。

c
using System; // 引入 Random 和 Math。

public enum HitResultType // 定义命中结果类型。
{ // 枚举开始。
    Normal, // 普通命中。
    Dodge, // 闪避成功。
    Block, // 格挡成功。
    Critical, // 暴击成功。
    BlockCritical // 格挡和暴击同时发生。
} // 枚举结束。

public sealed class CombatStats // 定义战斗属性。
{ // 类开始。
    public int Attack; // 攻击力。
    public int Defense; // 防御力。
    public float DodgeRate; // 闪避率。
    public float BlockRate; // 格挡率。
    public float BlockReduceRate; // 格挡减伤比例。
    public float CritRate; // 暴击率。
    public float CritMultiplier; // 暴击倍率。
} // 类结束。

public sealed class DamageResult // 定义伤害结果。
{ // 类开始。
    public HitResultType Type; // 本次命中结果。
    public int FinalDamage; // 最终伤害。
    public bool Dodged; // 是否闪避。
    public bool Blocked; // 是否格挡。
    public bool Critical; // 是否暴击。
} // 类结束。

public static class DamageCalculator // 定义伤害计算器。
{ // 类开始。
    public static DamageResult Calculate(CombatStats attacker, CombatStats defender, Random random) // 计算一次伤害。
    { // 方法开始。
        DamageResult result = new DamageResult(); // 创建伤害结果。
        int damage = Math.Max(1, attacker.Attack - defender.Defense); // 先算基础伤害,至少为 1。
        if (random.NextDouble() < defender.DodgeRate) // 先判闪避。
        { // 闪避分支开始。
            result.Type = HitResultType.Dodge; // 设置结果为闪避。
            result.Dodged = true; // 标记已经闪避。
            result.FinalDamage = 0; // 闪避后最终伤害为 0。
            return result; // 闪避成功直接结束。
        } // 闪避分支结束。
        if (random.NextDouble() < defender.BlockRate) // 再判格挡。
        { // 格挡分支开始。
            result.Blocked = true; // 标记已经格挡。
            damage = (int)(damage * (1f - defender.BlockReduceRate)); // 按格挡比例降低伤害。
        } // 格挡分支结束。
        if (random.NextDouble() < attacker.CritRate) // 再判暴击。
        { // 暴击分支开始。
            result.Critical = true; // 标记已经暴击。
            damage = (int)(damage * attacker.CritMultiplier); // 按暴击倍率放大伤害。
        } // 暴击分支结束。
        result.Type = result.Blocked && result.Critical ? HitResultType.BlockCritical : result.Blocked ? HitResultType.Block : result.Critical ? HitResultType.Critical : HitResultType.Normal; // 根据标记合成最终结果类型。
        result.FinalDamage = Math.Max(0, damage); // 最终伤害不能小于 0。
        return result; // 返回结算结果。
    } // 方法结束。
} // 类结束。

面试加分说法

闪避成功后不应该触发暴击、吸血、反伤、破盾,因为攻击没有真正落到目标身上。格挡是否允许暴击,要看项目配置:有的游戏允许“暴击被格挡”,有的游戏让格挡压制暴击。联机项目里这些随机判定最好由服务端或确定性随机流统一生成,客户端只根据 DamageResult 播放飘字和表现。

伤害公式如何避免数值溢出?

unity-damage-formula-overflow

标准答案

伤害公式避免溢出的核心是:不要用 int 直接连乘,先把第一个参与计算的值转成 long,倍率用统一缩放单位,中间阶段就 Clamp,最后再安全转回 int

最危险的写法是:

c
int damage = attack * skillRate * critRate;

因为 attack * skillRate 这一步可能已经超过 int.MaxValue,即使你最后再 Clamp,也已经晚了。

推荐思路

配置校验 -> long 中间值 -> 定点倍率 -> 分段 Clamp -> 最终转 int -> 写日志

比如倍率统一用万分比:

10000 = 100%15000 = 150%20000 = 200%

这样比到处写 1.5f2.0f 更容易做确定性,也更适合战斗日志和服务端校验。

c
using System; // 引入 Math 工具类。

public static class SafeDamageFormula // 定义安全伤害公式工具类。
{ // 类开始。
    private const long RateScale = 10000L; // 倍率缩放单位,10000 表示 100%。
    private const long MaxFinalDamage = 999999999L; // 项目允许的最大最终伤害。
    private const int MaxRate = 100000; // 项目允许的最大倍率,100000 表示 1000%。

    public static int Calculate(int attack, int defense, int skillRate, int critMultiplier, int buffMultiplier) // 计算最终伤害。
    { // 方法开始。
        int safeAttack = ClampInt(attack, 0, int.MaxValue); // 攻击力不能小于 0。
        int safeDefense = ClampInt(defense, 0, int.MaxValue); // 防御力不能小于 0。
        int safeSkillRate = ClampInt(skillRate, 0, MaxRate); // 技能倍率限制在安全范围内。
        int safeCritMultiplier = ClampInt(critMultiplier, 0, MaxRate); // 暴击倍率限制在安全范围内。
        int safeBuffMultiplier = ClampInt(buffMultiplier, 0, MaxRate); // Buff 倍率限制在安全范围内。
        long damage = Math.Max(1L, (long)safeAttack - safeDefense); // 先转 long 再做基础伤害计算。
        damage = MulRateAndClamp(damage, safeSkillRate); // 乘技能倍率并立刻截断。
        damage = MulRateAndClamp(damage, safeCritMultiplier); // 乘暴击倍率并立刻截断。
        damage = MulRateAndClamp(damage, safeBuffMultiplier); // 乘 Buff 倍率并立刻截断。
        damage = ClampLong(damage, 0L, MaxFinalDamage); // 最终伤害再做一次总上限保护。
        return (int)damage; // 确认在 int 范围内后再转回 int。
    } // 方法结束。

    private static long MulRateAndClamp(long value, int rate) // 按万分比倍率计算。
    { // 方法开始。
        long safeValue = ClampLong(value, 0L, MaxFinalDamage); // 乘法前先限制输入值。
        int safeRate = ClampInt(rate, 0, MaxRate); // 乘法前先限制倍率值。
        long result = checked(safeValue * safeRate); // 使用 checked 暴露异常乘法风险。
        result = result / RateScale; // 除以缩放单位得到真实伤害。
        return ClampLong(result, 0L, MaxFinalDamage); // 每个倍率阶段都做 Clamp。
    } // 方法结束。

    private static int ClampInt(int value, int min, int max) // 限制 int 范围。
    { // 方法开始。
        if (value < min) return min; // 小于最小值就返回最小值。
        if (value > max) return max; // 大于最大值就返回最大值。
        return value; // 否则返回原值。
    } // 方法结束。

    private static long ClampLong(long value, long min, long max) // 限制 long 范围。
    { // 方法开始。
        if (value < min) return min; // 小于最小值就返回最小值。
        if (value > max) return max; // 大于最大值就返回最大值。
        return value; // 否则返回原值。
    } // 方法结束。
} // 类结束。

面试加分点

(long)(a * b) 是错的,因为 a * b 已经先按 int 算完了;正确写法是 (long)a * b。另外,策划表导入时就要校验攻击力、倍率、Buff 层数、最大伤害上限。Debug 或开发包可以用 checked 尽早暴露溢出,线上则要 Clamp 并记录异常日志。

服务端战斗和客户端表现如何同步?

unity-server-combat-client-presentation-sync

标准答案

服务端战斗和客户端表现同步的核心是:客户端只发操作意图,服务端做权威结算,客户端根据服务端结果包播放表现。

客户端可以先播技能前摇、挥刀动作、镜头震动这些“手感表现”,但真正的 命中、伤害、Buff、死亡、掉落、CD 是否成功 应该以服务端为准。

推荐流程

c
客户端输入 -> 本地预测表现 -> 发送 SkillCommand -> 服务端校验 -> 服务端结算 -> 广播 CombatResult -> 客户端表现队列播放 -> 不一致时校正
using System.Collections.Generic; // 引入 List 和 HashSet。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。

public struct CombatResultMsg // 定义服务端下发的战斗结果包。
{ // 结构体开始。
    public int EventId; // 唯一事件 ID,用来避免重复播放。
    public int ServerTick; // 服务端结算时的逻辑帧。
    public int CasterId; // 释放技能的角色 ID。
    public int TargetId; // 被命中的目标 ID。
    public int SkillId; // 技能 ID。
    public int Damage; // 服务端计算出来的伤害。
    public int TargetHp; // 服务端结算后的目标血量。
    public bool IsCritical; // 是否暴击。
    public bool IsDead; // 目标是否死亡。
} // 结构体结束。

public sealed class CombatPresentationQueue : MonoBehaviour // 定义客户端战斗表现队列。
{ // 类开始。
    private readonly List<CombatResultMsg> queue = new List<CombatResultMsg>(); // 保存还没播放的结果包。
    private readonly HashSet<int> playedEvents = new HashSet<int>(); // 保存已经播放过的事件 ID。
    private int clientDisplayTick; // 客户端当前表现时间轴。
    private const int DelayTick = 3; // 表现延迟几个 tick,用来缓冲网络抖动。

    public void OnReceiveCombatResult(CombatResultMsg msg) // 收到服务端战斗结果。
    { // 方法开始。
        if (playedEvents.Contains(msg.EventId)) return; // 如果已经播放过,就忽略重复包。
        queue.Add(msg); // 把结果包加入表现队列。
        queue.Sort((a, b) => a.ServerTick.CompareTo(b.ServerTick)); // 按服务端 tick 排序。
    } // 方法结束。

    public void Tick() // 每个逻辑帧调用一次。
    { // 方法开始。
        clientDisplayTick++; // 推进客户端表现 tick。
        int playableTick = clientDisplayTick - DelayTick; // 计算当前允许播放到哪个服务端 tick。
        for (int i = queue.Count - 1; i >= 0; i--) // 从后往前遍历,方便删除。
        { // 循环开始。
            CombatResultMsg msg = queue[i]; // 取出一个结果包。
            if (msg.ServerTick > playableTick) continue; // 还没到播放时间就继续等。
            queue.RemoveAt(i); // 从队列移除准备播放的结果。
            if (!playedEvents.Add(msg.EventId)) continue; // 幂等保护,防止重复播放。
            PlayResult(msg); // 根据服务端结果播放表现。
        } // 循环结束。
    } // 方法结束。

    private void PlayResult(CombatResultMsg msg) // 播放战斗表现。
    { // 方法开始。
        Debug.Log($"Skill={msg.SkillId}, Damage={msg.Damage}, Hp={msg.TargetHp}"); // 示例:实际项目里播放飘字、命中特效和血条变化。
    } // 方法结束。
} // 类结束。

面试加分点

客户端表现层不要自己再算一次伤害,否则会出现“客户端显示暴击,服务端没暴击”的问题。结果包里要带 serverTick、sequence、entityId、skillId、hitId、DamageResult,客户端按 tick 排序播放,并做幂等处理。弱网下用表现缓冲平滑抖动;预测错了,小误差插值修正,大误差强制拉回。

客户端预测伤害有什么风险?

unity-client-predict-damage-risks

标准答案

客户端预测伤害最大的风险是:客户端以为自己打中了、扣血了,但服务端可能判定没打中、伤害不同、目标已经无敌或已经死亡。

所以客户端可以预测“表现”,比如命中特效、震屏、临时飘字;但不能预测“真实结果”,比如真实 HP、死亡、掉落、任务进度、Buff 状态。真实战斗状态必须以服务端 CombatResult 为准。

主要风险

  1. 作弊风险:客户端如果能上报伤害,玩家可以改内存、改封包,把伤害改大。
  2. 状态不同步:客户端扣了 100 血,服务端只扣 30 血,血条会回弹。
  3. 死亡回滚:客户端预测击杀,服务端判定没死,就会出现“死了又活”的尴尬表现。
  4. 重复结算:预测先扣一次,服务端结果包回来又扣一次。
  5. 隐藏信息泄露:客户端本地算伤害,可能需要知道敌人真实防御、抗性、Buff,容易泄露。
  6. 日志对账困难:客户端日志和服务端日志不一致,线上问题很难复盘。

正确做法

预测层和真实数据层分开:

c
using System; // 引入 Math 工具类。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。

public sealed class ClientHpPresenter : MonoBehaviour // 定义客户端血条表现类。
{ // 类开始。
    private int realHp; // 服务端确认的真实血量。
    private int displayHp; // 客户端当前显示用的血量。
    public void Init(int hp) // 初始化血量。
    { // 方法开始。
        realHp = hp; // 设置真实血量。
        displayHp = hp; // 设置显示血量。
    } // 方法结束。
    public void PredictVisualDamage(int damage) // 预测伤害表现。
    { // 方法开始。
        displayHp = Math.Max(0, displayHp - damage); // 只改显示血量,不改真实血量。
        Debug.Log($"预测飘字:-{damage}"); // 播放临时飘字。
    } // 方法结束。
    public void ApplyServerHp(int serverHp) // 应用服务端确认结果。
    { // 方法开始。
        realHp = Math.Max(0, serverHp); // 真实血量只接受服务端结果。
        displayHp = realHp; // 显示血量对齐真实血量。
        Debug.Log($"服务端确认 HP:{realHp}"); // 输出服务端确认日志。
    } // 方法结束。
} // 类结束。

面试加分说法

我会说:客户端预测伤害只用于手感,不进入权威数据。正式扣血、死亡、掉落、任务进度都等服务端结果包。结果包要带 eventId 做幂等,防止重复扣血;带 serverTick 做排序,防止乱序表现;预测错了,小误差平滑修正,大误差强制校正。

动画根运动和逻辑位移冲突怎么办?

unity-root-motion-logic-conflict

标准答案

动画 Root Motion 和逻辑位移冲突,本质是:Animator、脚本、物理、网络同步同时在改角色位置。解决思路不是简单开关 Root Motion,而是规定:最终坐标只能有一个写入者

我一般会这样设计:

Animator.deltaPosition / 逻辑速度 / 击退位移 / 网络校正 -> MovementMotor -> CharacterController 或 Rigidbody -> 最终 Transform

也就是说,Root Motion 可以作为“位移来源”,但不能绕过角色移动模块直接改坐标。

常见方案

普通移动、寻路、联机角色:推荐逻辑驱动位置,动画只播 in-place 动画,用 Speed 参数做 Blend Tree。

翻滚、处决、攻击突进:可以用 Root Motion 提供位移,但在 OnAnimatorMove() 里拿到 animator.deltaPosition,再交给 CharacterController.Move() 或自己的 Motor.Move(),让它统一处理碰撞、地面和阻挡。

c
using UnityEngine; // 引入 Unity 引擎 API。

public enum MovementSource // 定义位移来源。
{ // 枚举开始。
    Logic, // 逻辑位移驱动角色。
    RootMotion, // 动画根运动驱动角色。
    None // 当前状态不允许主动位移。
} // 枚举结束。

[RequireComponent(typeof(CharacterController))] // 要求角色身上必须有 CharacterController。
public sealed class RootMotionMovementBridge : MonoBehaviour // 定义 Root Motion 和逻辑位移桥接脚本。
{ // 类开始。
    [SerializeField] private Animator animator; // 保存 Animator 引用。
    [SerializeField] private float logicSpeed = 5f; // 逻辑移动速度。
    [SerializeField] private float maxRootDelta = 1f; // 限制单帧 Root Motion 位移,避免异常动画把角色拉飞。
    private CharacterController controller; // 保存 CharacterController 引用。
    private MovementSource source = MovementSource.Logic; // 当前位移来源默认是逻辑驱动。

    private void Awake() // 初始化回调。
    { // 方法开始。
        controller = GetComponent<CharacterController>(); // 获取 CharacterController 组件。
        animator.applyRootMotion = true; // 开启 Root Motion,让 OnAnimatorMove 可以拿到 deltaPosition。
    } // 方法结束。

    public void SetMovementSource(MovementSource nextSource) // 切换当前位移来源。
    { // 方法开始。
        source = nextSource; // 保存新的位移来源。
    } // 方法结束。

    private void Update() // 每帧处理逻辑位移。
    { // 方法开始。
        if (source != MovementSource.Logic) return; // 不是逻辑驱动状态就不处理输入位移。
        Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); // 读取输入方向。
        Vector3 delta = input.normalized * logicSpeed * Time.deltaTime; // 根据速度和 deltaTime 计算本帧位移。
        controller.Move(delta); // 统一通过 CharacterController 移动角色。
        animator.SetFloat("Speed", input.magnitude); // 用逻辑速度驱动动画混合。
    } // 方法结束。

    private void OnAnimatorMove() // Animator 计算完 Root Motion 后调用。
    { // 方法开始。
        if (source != MovementSource.RootMotion) return; // 不是 Root Motion 状态就不吃动画位移。
        Vector3 rootDelta = Vector3.ClampMagnitude(animator.deltaPosition, maxRootDelta); // 获取并限制动画根位移。
        controller.Move(rootDelta); // 仍然通过 CharacterController 执行移动。
        transform.rotation *= animator.deltaRotation; // 使用动画根旋转更新角色朝向。
    } // 方法结束。
} // 类结束。

面试加分点

我会强调:Root Motion 不是不能用,而是要看状态。普通跑动用逻辑位移更容易响应输入和网络同步;翻滚、突进、处决用 Root Motion 更贴合动画表现。项目里最好给每个状态配置 Logic / RootMotion / None,并且禁止动画、物理、脚本、网络校正同时写 Transform.position

动画混合导致脚滑怎么处理?

unity-animation-blend-foot-sliding

标准答案

动画混合导致脚滑,本质是:角色真实移动速度和动画脚步速度不一致。比如角色逻辑上跑 5m/s,但当前混出来的动画脚步只有 3m/s,脚就会在地上滑。

我会按这个顺序处理:

先查真实速度 -> 查动画 Clip 根速度 -> 调 Blend Tree 阈值 -> 调动画播放速度 -> 缩短不合理过渡 -> 最后用 Foot IK 修细节

常见解决方案

逻辑驱动移动时:角色位置由 CharacterController / Rigidbody / NavMeshAgent 决定,Animator 只负责表现。Animator 参数要用真实速度,不要用输入值。比如摇杆推满不代表角色真的跑起来了,角色可能被墙挡住、减速、击退。

Root Motion 驱动时:用 animator.deltaPosition 作为位移来源,但仍然交给统一 Motor 处理碰撞,不要再额外用逻辑速度移动一次。

c
using UnityEngine; // 引入 Unity 引擎 API。
[RequireComponent(typeof(CharacterController))] // 要求当前对象必须有 CharacterController。
public sealed class LocomotionFootSlideFix : MonoBehaviour // 定义脚滑修正示例脚本。
{ // 类开始。
    [SerializeField] private Animator animator; // 保存 Animator 引用。
    [SerializeField] private CharacterController controller; // 保存 CharacterController 引用。
    [SerializeField] private float runClipSpeed = 5f; // 跑步动画本身对应的速度,单位通常是 m/s。
    [SerializeField] private float dampTime = 0.08f; // Animator 参数平滑时间。
    private static readonly int SpeedHash = Animator.StringToHash("Speed"); // 缓存 Speed 参数哈希。
    private static readonly int MoveXHash = Animator.StringToHash("MoveX"); // 缓存 MoveX 参数哈希。
    private static readonly int MoveYHash = Animator.StringToHash("MoveY"); // 缓存 MoveY 参数哈希。
    private static readonly int AnimSpeedHash = Animator.StringToHash("AnimSpeed"); // 缓存动画播放速度倍率参数哈希。
    private void Reset() // 编辑器中挂脚本时自动补引用。
    { // 方法开始。
        controller = GetComponent<CharacterController>(); // 获取 CharacterController。
        animator = GetComponentInChildren<Animator>(); // 获取子节点上的 Animator。
    } // 方法结束。
    private void Update() // 每帧同步动画参数。
    { // 方法开始。
        Vector3 worldVelocity = controller.velocity; // 读取角色真实移动速度。
        Vector3 localVelocity = transform.InverseTransformDirection(worldVelocity); // 把世界速度转成角色本地速度。
        localVelocity.y = 0f; // 忽略竖直方向速度,避免跳跃影响跑步混合。
        float speed = localVelocity.magnitude; // 计算水平真实速度大小。
        float animSpeed = runClipSpeed > 0.01f ? Mathf.Clamp(speed / runClipSpeed, 0.75f, 1.25f) : 1f; // 根据真实速度修正动画步频。
        animator.SetFloat(SpeedHash, speed, dampTime, Time.deltaTime); // 用真实速度驱动一维 Blend Tree。
        animator.SetFloat(MoveXHash, localVelocity.x, dampTime, Time.deltaTime); // 用本地横向速度驱动二维 Blend Tree。
        animator.SetFloat(MoveYHash, localVelocity.z, dampTime, Time.deltaTime); // 用本地前后速度驱动二维 Blend Tree。
        animator.SetFloat(AnimSpeedHash, animSpeed, dampTime, Time.deltaTime); // 把播放速度倍率传给 Animator State。
    } // 方法结束。
} // 类结束。

面试加分点

AnimSpeed 最好绑定到 Animator State 的 Speed Multiplier,不要随便改全局 animator.speed,否则攻击、受击、死亡动画都会一起变快。Foot IK 只能修“脚掌接触地面时的小滑动”,如果真实速度和动画速度差很多,IK 会被迫拉腿,效果反而更怪。

攻击判定用动画事件还是代码时间轴?

unity-attack-hit-animation-event-vs-code-timeline

标准答案

我更推荐:攻击判定用代码时间轴,动画事件只做表现提示。

也就是说,真正的 命中窗口、目标筛选、伤害结算、Buff、硬直、击退 由战斗系统按时间轴推进;动画事件可以用来播 音效、特效、震屏、脚步声、刀光,但不要直接在动画事件里扣血。

为什么不建议动画事件直接做攻击判定

动画事件依附在 Animation Clip 上,如果动画被打断、过渡、变速、替换、重定向,事件触发时机可能变化。服务端战斗、回放系统、自动测试通常也不应该依赖 Animator 播放状态。所以核心战斗逻辑放在代码时间轴里更稳定。

推荐流程

c
进入攻击状态 -> 时间轴推进 -> 到命中窗口 -> 做范围检测 -> 生成 DamageEvent -> 统一结算 -> 表现层播放结果
using UnityEngine; // 引入 Unity 引擎 API。
public sealed class AttackTimelineDetector : MonoBehaviour // 定义攻击时间轴判定脚本。
{ // 类开始。
    [SerializeField] private Transform hitPoint; // 攻击检测中心点。
    [SerializeField] private float hitRadius = 1.5f; // 攻击检测半径。
    [SerializeField] private float hitStartTime = 0.25f; // 命中窗口开始时间。
    [SerializeField] private float hitEndTime = 0.35f; // 命中窗口结束时间。
    [SerializeField] private float attackTotalTime = 0.8f; // 整个攻击动作持续时间。
    [SerializeField] private LayerMask targetLayer; // 可以被攻击的目标层。
    private readonly Collider[] hitResults = new Collider[16]; // 非分配检测缓存,减少 GC。
    private float elapsedTime; // 当前攻击已经经过的时间。
    private bool attacking; // 当前是否正在攻击。
    private bool hitChecked; // 当前攻击是否已经做过命中检测。
    public void BeginAttack() // 开始一次攻击。
    { // 方法开始。
        elapsedTime = 0f; // 重置攻击计时。
        attacking = true; // 标记进入攻击状态。
        hitChecked = false; // 重置命中检测标记。
    } // 方法结束。
    private void Update() // 每帧推进攻击时间轴。
    { // 方法开始。
        if (!attacking) return; // 不在攻击状态就不处理。
        elapsedTime += Time.deltaTime; // 推进攻击经过时间。
        if (!hitChecked && elapsedTime >= hitStartTime && elapsedTime <= hitEndTime) // 到达命中窗口并且还没检测过。
        { // 命中窗口分支开始。
            DetectHit(); // 执行攻击检测。
            hitChecked = true; // 标记本次攻击已经检测过。
        } // 命中窗口分支结束。
        if (elapsedTime >= attackTotalTime) // 如果攻击总时间结束。
        { // 攻击结束分支开始。
            attacking = false; // 退出攻击状态。
        } // 攻击结束分支结束。
    } // 方法结束。
    private void DetectHit() // 执行命中检测。
    { // 方法开始。
        int count = Physics.OverlapSphereNonAlloc(hitPoint.position, hitRadius, hitResults, targetLayer); // 查询范围内目标。
        for (int i = 0; i < count; i++) // 遍历检测到的目标。
        { // 循环开始。
            Collider target = hitResults[i]; // 取出一个目标碰撞体。
            if (target == null) continue; // 目标为空就跳过。
            Debug.Log($"生成 DamageEvent:{target.name}"); // 示例:真实项目里这里生成伤害事件。
        } // 循环结束。
    } // 方法结束。
    public void OnAnimationVfxEvent() // 动画事件可以调用这个表现函数。
    { // 方法开始。
        Debug.Log("播放刀光、音效、震屏"); // 动画事件只触发表现,不直接扣血。
    } // 方法结束。
} // 类结束。

面试加分点

小型单机 Demo 里,动画事件直接开关攻击盒也能跑;但正式项目,尤其是联机、动作、可回放、可热更的项目,我会把攻击判定做成配置化时间轴。这样策划能调 前摇、命中帧、后摇、取消帧,程序能做日志、回放、服务端同步,动画资源改了也不会悄悄影响战斗规则。

技能时间轴如何配置?

unity-skill-timeline-config

标准答案

技能时间轴就是把一个技能拆成“按时间触发的事件轨道”。比如:

0 帧开始前摇 -> 8 帧开命中窗口 -> 12 帧关闭命中 -> 18 帧允许取消 -> 30 帧技能结束

我会把它分成两类事件:

逻辑事件:命中检测、伤害结算、Buff、霸体、无敌、打断、取消。 表现事件:动画、特效、音效、震屏、残影、飘字。

核心原则是:逻辑轨道决定战斗结果,表现轨道只负责演出来。

c
using System.Collections.Generic; // 引入 List 和 HashSet。
using UnityEngine; // 引入 Unity 的 ScriptableObject 和 Debug。

public enum SkillEventType // 定义技能时间轴事件类型。
{ // 枚举开始。
    Hit, // 命中检测事件。
    Vfx, // 特效事件。
    Sfx, // 音效事件。
    SuperArmorStart, // 霸体开始事件。
    SuperArmorEnd, // 霸体结束事件。
    CancelStart // 允许取消事件。
} // 枚举结束。

[System.Serializable] // 让事件数据可以显示在 Inspector。
public sealed class SkillTimelineEvent // 定义一条时间轴事件。
{ // 类开始。
    public int EventId; // 事件唯一 ID,用来防止重复触发。
    public int Frame; // 事件触发的逻辑帧。
    public SkillEventType Type; // 事件类型。
    public string Arg; // 事件参数,例如特效名、音效名、命中盒 ID。
} // 类结束。

[CreateAssetMenu(menuName = "Combat/Skill Timeline")] // 在 Unity 菜单里创建技能时间轴配置。
public sealed class SkillTimelineConfig : ScriptableObject // 定义技能时间轴配置。
{ // 类开始。
    public int SkillId; // 技能 ID。
    public int TotalFrame = 30; // 技能总帧数。
    public List<SkillTimelineEvent> Events = new List<SkillTimelineEvent>(); // 技能事件列表。
} // 类结束。

public sealed class SkillTimelineRunner // 定义技能时间轴运行器。
{ // 类开始。
    private SkillTimelineConfig config; // 当前运行的技能配置。
    private int currentFrame; // 当前推进到第几帧。
    private readonly HashSet<int> firedEvents = new HashSet<int>(); // 已触发事件集合。

    public void Start(SkillTimelineConfig skillConfig) // 开始播放一个技能时间轴。
    { // 方法开始。
        config = skillConfig; // 保存技能配置。
        currentFrame = 0; // 从第 0 帧开始。
        firedEvents.Clear(); // 清空已触发事件。
    } // 方法结束。

    public void Tick() // 每个逻辑帧推进一次。
    { // 方法开始。
        if (config == null) return; // 没有配置就直接返回。
        for (int i = 0; i < config.Events.Count; i++) // 遍历所有事件。
        { // 循环开始。
            SkillTimelineEvent e = config.Events[i]; // 取出当前事件。
            if (e.Frame != currentFrame) continue; // 不是当前帧的事件就跳过。
            if (!firedEvents.Add(e.EventId)) continue; // 已触发过的事件不再重复触发。
            ExecuteEvent(e); // 执行时间轴事件。
        } // 循环结束。
        currentFrame++; // 推进到下一帧。
        if (currentFrame > config.TotalFrame) config = null; // 超过总帧数后结束技能。
    } // 方法结束。

    private void ExecuteEvent(SkillTimelineEvent e) // 执行具体事件。
    { // 方法开始。
        Debug.Log($"触发技能事件:{e.Type}, 参数:{e.Arg}"); // 示例:真实项目里这里分发到战斗层或表现层。
    } // 方法结束。
} // 类结束。

面试加分点

技能时间轴最好配置化,策划可以在编辑器里调 前摇、命中帧、后摇、取消帧、霸体帧、无敌帧。服务端只跑逻辑轨道,不跑特效和音效;客户端可以跑表现轨道。每个事件要有 eventId,避免帧率波动或网络重发导致重复触发。

受击硬直如何和动画同步?

unity-hitstun-animation-sync

标准答案

受击硬直要和动画同步,核心原则是:硬直是逻辑状态,动画是表现状态。

也就是说,角色能不能动、能不能攻击、能不能取消技能,应该由逻辑层的 HitStunState 控制;受击动画只是根据硬直结果播放对应表现。不要让“动画播完”决定“逻辑恢复”,否则动画过渡、变速、打断、网络校正都会让角色状态混乱。

推荐流程

c
DamageResult -> HitStunState -> 锁输入/锁技能 -> 播受击动画 -> 处理击退 -> 逻辑计时结束 -> 恢复控制
using UnityEngine; // 引入 Unity 引擎 API。
public struct HitReactionData // 定义受击反应数据。
{ // 结构体开始。
    public int StunFrames; // 受击硬直帧数。
    public int AnimationHash; // 受击动画状态哈希。
    public float CrossFadeTime; // 动画切换过渡时间。
    public Vector3 Knockback; // 击退位移或击退速度。
} // 结构体结束。
public sealed class HitStunController : MonoBehaviour // 定义受击硬直控制器。
{ // 类开始。
    [SerializeField] private Animator animator; // 保存 Animator 引用。
    private int remainStunFrames; // 剩余硬直帧数。
    private Vector3 pendingKnockback; // 待处理击退位移。
    private bool inHitStun; // 当前是否处于硬直状态。
    public bool CanMove => !inHitStun; // 是否允许移动。
    public bool CanCastSkill => !inHitStun; // 是否允许释放技能。
    public void ApplyHit(HitReactionData data) // 应用一次受击结果。
    { // 方法开始。
        remainStunFrames = Mathf.Max(remainStunFrames, data.StunFrames); // 新硬直和剩余硬直取较大值。
        pendingKnockback = data.Knockback; // 记录击退数据。
        inHitStun = remainStunFrames > 0; // 只要硬直帧大于 0 就进入硬直状态。
        animator.CrossFade(data.AnimationHash, data.CrossFadeTime); // 播放受击动画。
    } // 方法结束。
    public void Tick() // 每个逻辑帧推进一次。
    { // 方法开始。
        if (!inHitStun) return; // 不在硬直状态就直接返回。
        ApplyKnockback(); // 处理本帧击退表现。
        remainStunFrames--; // 扣减一帧硬直。
        if (remainStunFrames <= 0) ExitHitStun(); // 硬直结束后恢复控制。
    } // 方法结束。
    private void ApplyKnockback() // 处理击退。
    { // 方法开始。
        transform.position += pendingKnockback * Time.fixedDeltaTime; // 示例:真实项目建议交给 Motor 或物理层处理。
        pendingKnockback = Vector3.Lerp(pendingKnockback, Vector3.zero, 0.2f); // 逐步衰减击退速度。
    } // 方法结束。
    private void ExitHitStun() // 退出硬直状态。
    { // 方法开始。
        remainStunFrames = 0; // 清空剩余硬直帧。
        pendingKnockback = Vector3.zero; // 清空击退数据。
        inHitStun = false; // 标记不再硬直。
    } // 方法结束。
} // 类结束。

面试加分点

如果受击动画比硬直短,可以让动画 Blend 回 Idle,但逻辑仍然锁到硬直结束;如果动画比硬直长,可以调动画 SpeedScale 或提前过渡。动画事件可以播受击音效、闪白、停顿感,但不要由动画事件来解锁角色。联机项目里,服务端下发 hitTick、stunFrames、reactType、knockback,客户端按结果播放动画,最终状态以服务端为准。

击退碰墙如何处理?

unity-knockback-wall-collision

标准答案

击退碰墙的核心是:击退不能直接改 Transform,它应该作为一种位移请求,交给统一的 MovementMotor / CharacterController / Rigidbody 去处理碰撞。

推荐流程:

DamageResult 产生击退 -> 计算本帧击退 delta -> 碰撞检测 -> 碰墙 -> 根据法线处理剩余速度 -> 输出状态

碰墙后一般有四种处理:

停止:撞墙后清掉朝墙方向速度,角色停住。 沿墙滑动:把击退速度投影到墙面切线方向,角色贴墙滑过去。 反弹:用 Vector3.Reflect 根据墙面法线反弹,适合弹球、特殊技能。 撞墙硬直:如果撞墙速度很大,触发额外硬直、屏震、音效、尘土特效。

c
using UnityEngine; // 引入 Unity 引擎 API。

[RequireComponent(typeof(CharacterController))] // 要求当前角色必须有 CharacterController。
public sealed class KnockbackMotor : MonoBehaviour // 定义击退移动控制器。
{ // 类开始。
    [SerializeField] private float decay = 18f; // 击退速度每秒衰减值。
    [SerializeField] private float minSpeed = 0.05f; // 小于这个速度就结束击退。
    [SerializeField] private float wallSlideFactor = 0.35f; // 撞墙后沿墙滑动保留的速度比例。
    [SerializeField] private float wallStunSpeed = 7f; // 超过这个速度撞墙会触发撞墙硬直。
    [SerializeField] private bool slideOnWall = true; // 是否允许角色沿墙滑动。
    private CharacterController controller; // 保存 CharacterController 引用。
    private Vector3 knockVelocity; // 当前击退速度。
    private Vector3 lastWallNormal; // 最近一次撞到墙面的法线。
    private bool knocking; // 当前是否正在击退。

    private void Awake() // 初始化组件。
    { // 方法开始。
        controller = GetComponent<CharacterController>(); // 获取 CharacterController。
    } // 方法结束。

    public void ApplyKnockback(Vector3 direction, float speed) // 外部调用:施加击退。
    { // 方法开始。
        direction.y = 0f; // 水平击退时忽略竖直方向。
        if (direction.sqrMagnitude <= 0.0001f) return; // 方向太小就不处理。
        knockVelocity = direction.normalized * speed; // 根据方向和速度生成击退速度。
        knocking = true; // 标记进入击退状态。
    } // 方法结束。

    private void FixedUpdate() // 固定帧推进击退。
    { // 方法开始。
        if (!knocking) return; // 不在击退状态就直接返回。
        float speedBeforeMove = knockVelocity.magnitude; // 记录撞墙前速度,用来判断是否触发撞墙硬直。
        lastWallNormal = Vector3.zero; // 每帧先清空墙面法线。
        Vector3 delta = knockVelocity * Time.fixedDeltaTime; // 根据速度计算本帧位移。
        CollisionFlags flags = controller.Move(delta); // 通过 CharacterController 移动,让碰撞系统处理阻挡。
        bool hitWall = (flags & CollisionFlags.Sides) != 0; // 判断本帧是否撞到侧面墙体。
        if (hitWall && lastWallNormal != Vector3.zero) // 如果撞墙并且拿到了墙面法线。
        { // 撞墙分支开始。
            if (speedBeforeMove >= wallStunSpeed) OnWallStun(); // 撞墙速度足够大时触发撞墙硬直表现。
            knockVelocity = slideOnWall ? Vector3.ProjectOnPlane(knockVelocity, lastWallNormal) * wallSlideFactor : Vector3.zero; // 选择沿墙滑动或直接停住。
        } // 撞墙分支结束。
        knockVelocity = Vector3.MoveTowards(knockVelocity, Vector3.zero, decay * Time.fixedDeltaTime); // 击退速度逐渐衰减。
        if (knockVelocity.sqrMagnitude <= minSpeed * minSpeed) knocking = false; // 速度足够小时结束击退。
    } // 方法结束。

    private void OnControllerColliderHit(ControllerColliderHit hit) // CharacterController 撞到碰撞体时回调。
    { // 方法开始。
        if (hit.normal.y > 0.5f) return; // 地面法线不当成墙面处理。
        lastWallNormal = hit.normal; // 记录墙面法线,后续用于滑墙或停止。
    } // 方法结束。

    private void OnWallStun() // 处理撞墙硬直表现。
    { // 方法开始。
        Debug.Log("触发撞墙硬直、尘土、音效、屏震"); // 示例:真实项目里通知状态机和表现层。
    } // 方法结束。
} // 类结束。

面试加分点

高速击退容易穿墙,所以要用 CharacterController.MoveRigidbody 碰撞,或者移动前做 CapsuleCast / Sweep。如果是联机游戏,最终位置和撞墙结果最好由服务端计算,客户端只做预测表现。碰墙后的反应也要配置化:小怪可以直接停住,Boss 可以抗击退,硬核动作游戏可以做撞墙二次硬直。

空中连击如何设计?

unity-air-combo-design

一句话定义 空中连击就是:把敌人打进“浮空状态”,在限定的连击窗口内继续命中,同时用高度、重力、硬直、落地保护来防止无限连。

核心设计

  1. 起手浮空:普通攻击或技能命中后,给目标一个向上的速度,让它进入 AirborneState
  2. 空中硬直:目标在空中不能自由移动,防止被打飞后马上恢复控制。
  3. 连击窗口:攻击者只有在某个时间段内输入下一段,才允许继续空连。
  4. 高度控制:不能一直往上打,要限制最大高度、下落速度、重力倍率。
  5. 连击衰减:连得越多,浮空力、硬直、伤害可以逐渐降低,避免无限连。
  6. 落地规则:落地后进入恢复、倒地、受身或保护状态。

面试里可以这样说

空中连击不是单纯“空中按攻击”,它本质是战斗状态机加技能时间轴。目标侧维护浮空状态、速度、硬直和落地规则;攻击者侧维护输入缓存和连击窗口。每次命中后重新计算浮空高度、重力倍率和连击次数,同时做最大连段限制,防止无限浮空。

C# 示例

c
using UnityEngine; // 引入 Unity 引擎 API。
public sealed class AirComboController : MonoBehaviour // 定义空中连击控制器。
{ // 类开始。
    [SerializeField] private int maxAirHits = 5; // 最大空中连击命中次数。
    [SerializeField] private float launchSpeed = 8f; // 起手浮空向上速度。
    [SerializeField] private float gravity = -20f; // 基础重力加速度。
    [SerializeField] private float airGravityScale = 0.45f; // 空中连击期间的重力倍率。
    [SerializeField] private float maxHeight = 6f; // 最大浮空高度。
    private Vector3 velocity; // 当前角色速度。
    private int airHitCount; // 当前空中命中次数。
    private bool airborne; // 是否处于浮空状态。
    public void Launch() // 起手浮空方法。
    { // 方法开始。
        airborne = true; // 标记目标进入浮空状态。
        airHitCount = 0; // 重置空中命中次数。
        velocity = new Vector3(0f, launchSpeed, 0f); // 给目标一个向上的初速度。
    } // 方法结束。
    public bool TryAirHit(float extraUpSpeed) // 尝试接一段空中连击。
    { // 方法开始。
        if (!airborne) return false; // 如果目标不在空中,就不能接空中连击。
        if (airHitCount >= maxAirHits) return false; // 如果超过最大连击数,就禁止继续浮空。
        airHitCount++; // 空中命中次数加一。
        float decay = 1f - airHitCount * 0.12f; // 根据连击次数计算浮空衰减。
        decay = Mathf.Clamp(decay, 0.35f, 1f); // 限制衰减最低值,避免完全没有反馈。
        velocity.y = Mathf.Max(velocity.y, extraUpSpeed * decay); // 刷新向上速度,但不允许无限增高。
        return true; // 返回接招成功。
    } // 方法结束。
    private void FixedUpdate() // 固定帧更新空中运动。
    { // 方法开始。
        if (!airborne) return; // 如果不在浮空状态,就不处理。
        velocity.y += gravity * airGravityScale * Time.fixedDeltaTime; // 按空中重力倍率计算下落。
        Vector3 nextPos = transform.position + velocity * Time.fixedDeltaTime; // 计算下一帧位置。
        nextPos.y = Mathf.Min(nextPos.y, maxHeight); // 限制最大浮空高度。
        transform.position = nextPos; // 示例中直接移动角色,正式项目建议交给 Motor 或 CharacterController。
        if (transform.position.y <= 0f) Land(); // 如果到达地面,就执行落地逻辑。
    } // 方法结束。
    private void Land() // 落地处理方法。
    { // 方法开始。
        airborne = false; // 退出浮空状态。
        airHitCount = 0; // 清空空中连击次数。
        velocity = Vector3.zero; // 清空速度。
    } // 方法结束。
} // 类结束。

常见坑

不要只做“命中后加向上速度”,否则很容易无限浮空。真正项目里还要有最大连段、硬直衰减、落地保护、受身规则、技能打断、网络同步和动画时间轴。

连招输入缓冲如何实现?

unity-combo-input-buffer

一句话定义 连招输入缓冲就是:玩家提前按下攻击键时,先把这次输入保存一小段时间,等角色进入“可接下一段”的窗口,再消费这次输入。

底层思路 核心不是“按键就立刻切动作”,而是把输入拆成三步:

  1. 采集输入Update 里检测玩家按了攻击、闪避、技能。
  2. 写入缓冲:记录输入类型、时间戳、优先级。
  3. 窗口消费:当前动作进入可取消窗口时,检查缓冲里有没有合法输入,有就切到下一段。

为什么需要它 如果没有输入缓冲,玩家必须刚好在几帧窗口内按键,手感会很硬。 有了缓冲,玩家稍微早按一点也能接上,连招会更顺。

C# 示例

c
using UnityEngine; // 引入 Unity 引擎 API。
public enum ComboInputType // 定义连招输入类型。
{ // 枚举开始。
    None, // 没有输入。
    Attack, // 普通攻击输入。
    Dodge, // 闪避输入。
    Skill // 技能输入。
} // 枚举结束。
public sealed class ComboInputBuffer : MonoBehaviour // 定义连招输入缓冲组件。
{ // 类开始。
    [SerializeField] private float bufferTime = 0.25f; // 输入最多保留 0.25 秒。
    private ComboInputType bufferedType = ComboInputType.None; // 当前缓存的输入类型。
    private float bufferedTime; // 当前缓存输入的发生时间。
    private bool hasBufferedInput; // 当前是否有缓存输入。
    private bool comboWindowOpen; // 当前是否处于可接招窗口。
    private int comboIndex; // 当前连招段数。
    private void Update() // 每帧采集输入和尝试消费。
    { // 方法开始。
        if (Input.GetKeyDown(KeyCode.J)) BufferInput(ComboInputType.Attack); // 按下 J 时缓存攻击输入。
        if (Input.GetKeyDown(KeyCode.Space)) BufferInput(ComboInputType.Dodge); // 按下空格时缓存闪避输入。
        ExpireOldInput(); // 每帧检查输入是否已经过期。
        if (comboWindowOpen) TryConsumeInput(); // 只有窗口打开时才尝试消费输入。
    } // 方法结束。
    private void BufferInput(ComboInputType type) // 写入输入缓冲。
    { // 方法开始。
        bufferedType = type; // 保存本次输入类型。
        bufferedTime = Time.time; // 保存本次输入时间。
        hasBufferedInput = true; // 标记当前存在缓存输入。
    } // 方法结束。
    public void SetComboWindow(bool open) // 由技能时间轴或动画状态机打开/关闭窗口。
    { // 方法开始。
        comboWindowOpen = open; // 设置当前是否允许接下一段。
    } // 方法结束。
    private void TryConsumeInput() // 尝试消费缓存输入。
    { // 方法开始。
        if (!hasBufferedInput) return; // 没有缓存输入就不处理。
        if (bufferedType != ComboInputType.Attack) return; // 示例里只有攻击能接连招。
        hasBufferedInput = false; // 先清空,保证一次输入只消费一次。
        comboIndex++; // 连招段数加一。
        PlayCombo(comboIndex); // 播放下一段连招。
    } // 方法结束。
    private void ExpireOldInput() // 清理过期输入。
    { // 方法开始。
        if (!hasBufferedInput) return; // 没有缓存输入就不用检查。
        if (Time.time - bufferedTime <= bufferTime) return; // 输入还没过期就保留。
        hasBufferedInput = false; // 输入超时后清空。
        bufferedType = ComboInputType.None; // 输入类型恢复为空。
    } // 方法结束。
    private void PlayCombo(int index) // 播放连招动作。
    { // 方法开始。
        Debug.Log("Play combo " + index); // 示例输出,正式项目里会驱动 Animator 或技能状态机。
    } // 方法结束。
} // 类结束。

面试补充点

缓冲时间不能太长,否则会像系统替玩家操作;消费后必须立刻清空,否则一次按键可能触发多段连招;如果有攻击、闪避、技能同时输入,要设计优先级,比如闪避通常高于普通攻击。

技能取消如何设计?

一句话定义 技能取消就是:当前技能还没完全播完时,在允许的取消窗口内,根据规则切到另一个动作,比如接下一段、闪避、受击、死亡。

unity-skill-cancel-design

核心设计

技能取消不是简单 Animator.CrossFade()。真正要做的是:

  1. 技能时间轴:技能分成前摇、命中帧、后摇、恢复阶段。
  2. 取消窗口:只有某些帧允许取消,比如命中后能接下一段,后摇能闪避。
  3. 取消类型:普通取消、命中取消、闪避取消、强制取消。
  4. 状态门禁:检查当前是否眩晕、霸体、死亡、蓝量够不够、CD 好没好。
  5. 优先级:受击/死亡通常高于闪避,闪避通常高于普通连招。
  6. 退出清理:取消旧技能时要关闭旧判定盒、旧位移、旧特效、旧协程。

C# 示例

c
using System.Collections.Generic; // 引入 List 集合。
using UnityEngine; // 引入 Unity API。
public enum CancelTarget { Attack2, Dodge, Hurt } // 定义可以取消到的目标动作。
public sealed class SkillCancelDemo : MonoBehaviour // 定义技能取消示例组件。
{ // 类开始。
    private struct CancelRule // 定义一条取消规则。
    { // 结构体开始。
        public CancelTarget target; // 允许取消到哪个动作。
        public float startTime; // 取消窗口开始时间。
        public float endTime; // 取消窗口结束时间。
        public bool requireHit; // 是否要求当前技能已经命中。
    } // 结构体结束。
    [SerializeField] private Animator animator; // 引用 Animator 播放动画。
    private readonly List<CancelRule> rules = new List<CancelRule>(); // 保存当前技能的取消规则。
    private float skillStartTime; // 当前技能开始时间。
    private bool skillActive; // 当前是否正在释放技能。
    private bool hitConfirmed; // 当前技能是否已经命中。
    private void Awake() // 初始化取消规则。
    { // 方法开始。
        rules.Add(new CancelRule { target = CancelTarget.Attack2, startTime = 0.35f, endTime = 0.55f, requireHit = true }); // 命中后才能接第二段。
        rules.Add(new CancelRule { target = CancelTarget.Dodge, startTime = 0.55f, endTime = 0.90f, requireHit = false }); // 后摇阶段允许闪避取消。
        rules.Add(new CancelRule { target = CancelTarget.Hurt, startTime = 0.00f, endTime = 1.00f, requireHit = false }); // 受击可以强制打断。
    } // 方法结束。
    private void Update() // 每帧检测输入。
    { // 方法开始。
        if (!skillActive) return; // 没有释放技能时不处理取消。
        if (Input.GetKeyDown(KeyCode.J)) TryCancel(CancelTarget.Attack2); // 玩家按攻击时尝试接下一段。
        if (Input.GetKeyDown(KeyCode.Space)) TryCancel(CancelTarget.Dodge); // 玩家按空格时尝试闪避取消。
    } // 方法结束。
    public void BeginSkill() // 开始释放当前技能。
    { // 方法开始。
        skillActive = true; // 标记技能开始。
        hitConfirmed = false; // 重置命中状态。
        skillStartTime = Time.time; // 记录技能开始时间。
        animator.Play("Attack1"); // 播放第一段攻击动画。
    } // 方法结束。
    public void OnHitConfirmed() // 命中帧检测到命中时调用。
    { // 方法开始。
        hitConfirmed = true; // 记录当前技能已经命中。
    } // 方法结束。
    public void OnHurt() // 角色受击时调用。
    { // 方法开始。
        TryCancel(CancelTarget.Hurt); // 尝试强制取消到受击状态。
    } // 方法结束。
    private void TryCancel(CancelTarget target) // 尝试取消到目标动作。
    { // 方法开始。
        if (!FindRule(target, out CancelRule rule)) return; // 找不到规则就不能取消。
        float elapsed = Time.time - skillStartTime; // 计算当前技能已经播放多久。
        if (elapsed < rule.startTime || elapsed > rule.endTime) return; // 不在取消窗口内就不能取消。
        if (rule.requireHit && !hitConfirmed) return; // 需要命中但没命中就不能取消。
        ExitCurrentSkill(); // 退出当前技能并清理残留。
        EnterTarget(target); // 进入目标动作。
    } // 方法结束。
    private bool FindRule(CancelTarget target, out CancelRule result) // 查找目标取消规则。
    { // 方法开始。
        foreach (CancelRule rule in rules) // 遍历所有规则。
        { // 循环开始。
            if (rule.target != target) continue; // 目标不匹配就跳过。
            result = rule; // 保存命中的规则。
            return true; // 返回查找成功。
        } // 循环结束。
        result = default; // 没找到时返回默认值。
        return false; // 返回查找失败。
    } // 方法结束。
    private void ExitCurrentSkill() // 退出当前技能。
    { // 方法开始。
        skillActive = false; // 标记当前技能结束。
        hitConfirmed = false; // 清空命中状态。
    } // 方法结束。
    private void EnterTarget(CancelTarget target) // 进入目标动作。
    { // 方法开始。
        if (target == CancelTarget.Attack2) animator.CrossFade("Attack2", 0.05f); // 取消到第二段攻击。
        if (target == CancelTarget.Dodge) animator.CrossFade("Dodge", 0.05f); // 取消到闪避。
        if (target == CancelTarget.Hurt) animator.CrossFade("Hurt", 0.02f); // 强制切到受击。
    } // 方法结束。
} // 类结束。

面试里加分说法 我会把技能取消做成配置驱动,而不是把帧数写死在代码里。比如每个技能配置 CancelWindow,里面写能取消到哪些动作、窗口时间、是否需要命中、优先级。这样策划调手感时只改表,程序只负责统一校验和执行。

常见坑 只切动画不清理旧技能,是最容易出 bug 的地方。旧技能的判定盒、位移曲线、特效、音效、协程如果没退出,会出现“动画变了但伤害还按旧技能算”的问题。

锁定目标后相机如何处理?

一句话定义 锁定目标后,相机不要“死盯敌人”,而是跟随玩家、偏向目标、保持玩家和目标都在画面内,并处理平滑、遮挡、距离和解锁。

unity-lock-target-camera

核心做法

锁定后相机通常围绕玩家移动,但视线看向玩家和目标之间的一个焦点。这样玩家不会出画面,目标也能保持在视野里。角色移动也会变成战斗移动:左右输入是绕目标横移,前后输入是靠近或远离目标。

工程里要处理这些点

  1. LookAt 焦点:不要只看敌人,可以用 Lerp(player, target, bias) 算中间点。
  2. 相机平滑:位置用 SmoothDamp,旋转用 Slerp,避免突然甩镜头。
  3. 遮挡处理:相机到焦点之间做 SphereCast,被墙挡住就拉近。
  4. 解锁条件:目标死亡、距离太远、不可见、切场景时要自动解锁。
  5. 目标切换:摇杆或按键切换时,选屏幕方向上最合适的目标。

C# 示例

c
using UnityEngine; // 引入 Unity 引擎 API。
public sealed class LockOnCamera : MonoBehaviour // 定义锁定目标相机组件。
{ // 类开始。
    [SerializeField] private Transform player; // 玩家 Transform。
    [SerializeField] private Transform cameraTransform; // 相机 Transform。
    [SerializeField] private float distance = 5f; // 相机和玩家的基础距离。
    [SerializeField] private float height = 2.2f; // 相机相对玩家的高度。
    [SerializeField] private float focusHeight = 1.3f; // LookAt 焦点的高度。
    [SerializeField] private float targetBias = 0.45f; // 焦点偏向目标的比例。
    [SerializeField] private float positionSmooth = 0.08f; // 位置平滑时间。
    [SerializeField] private float rotationSpeed = 12f; // 旋转跟随速度。
    [SerializeField] private float collisionRadius = 0.25f; // 遮挡检测的球半径。
    [SerializeField] private LayerMask wallMask; // 墙体和障碍物 Layer。
    private Transform target; // 当前锁定目标。
    private Vector3 cameraVelocity; // SmoothDamp 使用的速度缓存。
    public void SetTarget(Transform newTarget) // 设置锁定目标。
    { // 方法开始。
        target = newTarget; // 保存新的锁定目标。
    } // 方法结束。
    public void ClearTarget() // 清除锁定目标。
    { // 方法开始。
        target = null; // 取消当前锁定目标。
    } // 方法结束。
    private void LateUpdate() // 相机通常放在 LateUpdate,等角色移动后再跟随。
    { // 方法开始。
        if (player == null || cameraTransform == null) return; // 玩家或相机为空就不处理。
        Vector3 playerFocus = player.position + Vector3.up * focusHeight; // 计算玩家焦点。
        Vector3 focus = playerFocus; // 默认相机看向玩家焦点。
        if (target != null) focus = Vector3.Lerp(playerFocus, target.position + Vector3.up * focusHeight, targetBias); // 锁定时看向玩家和目标之间。
        Vector3 faceDir = target != null ? target.position - player.position : player.forward; // 有目标就用玩家到目标方向,否则用玩家前方。
        faceDir.y = 0f; // 去掉垂直方向,避免相机上下乱翻。
        if (faceDir.sqrMagnitude < 0.001f) faceDir = player.forward; // 方向太小时使用玩家朝向兜底。
        faceDir.Normalize(); // 标准化方向向量。
        Vector3 desiredPos = player.position - faceDir * distance + Vector3.up * height; // 计算相机期望位置。
        Vector3 toCamera = desiredPos - focus; // 计算焦点到相机的方向。
        float maxDistance = toCamera.magnitude; // 记录焦点到期望相机位置的距离。
        if (Physics.SphereCast(focus, collisionRadius, toCamera.normalized, out RaycastHit hit, maxDistance, wallMask)) // 检测焦点到相机之间是否有墙。
        { // if 开始。
            desiredPos = focus + toCamera.normalized * Mathf.Max(hit.distance - 0.15f, 0.5f); // 有遮挡时把相机拉到墙前。
        } // if 结束。
        cameraTransform.position = Vector3.SmoothDamp(cameraTransform.position, desiredPos, ref cameraVelocity, positionSmooth); // 平滑移动相机。
        Quaternion lookRot = Quaternion.LookRotation(focus - cameraTransform.position, Vector3.up); // 计算看向焦点的旋转。
        cameraTransform.rotation = Quaternion.Slerp(cameraTransform.rotation, lookRot, rotationSpeed * Time.deltaTime); // 平滑旋转相机。
    } // 方法结束。
} // 类结束。

面试加分说法 如果项目用 Cinemachine,我会优先用 TargetGroup + Composer 做玩家和目标同屏,再用 Collider 模块处理遮挡;如果战斗手感要求很细,比如魂类锁定、横向绕圈、特殊 Boss 镜头,我会自研 Camera Rig,把跟随、LookAt、碰撞、切目标和解锁规则拆开。

Boss 多阶段技能如何组织?

一句话定义 Boss 多阶段技能就是:把 Boss 战按血量或事件拆成多个 Phase,每个阶段有自己的技能池、权重、CD、入场演出和退出清理。

unity-boss-multi-phase-skill

核心设计

  1. 阶段配置:例如 100%-70%70%-40%40%-0%,每个阶段配置不同技能池。
  2. 阶段切换:血量低于阈值时触发切阶段,必须防止重复触发。
  3. 切阶段清理:停止当前技能、关闭旧判定盒、停止旧协程、清理临时 Buff。
  4. 入场演出:播放吼叫、变身、无敌、镜头震动、召唤等过渡表现。
  5. 技能调度:从当前阶段技能池里按距离、CD、权重、目标状态选择技能。
  6. 难度递增:后期可以缩短 CD、增加连招、增加 AOE、提高移动欲望。

C# 示例

c
using System.Collections.Generic; // 引入泛型集合。
using UnityEngine; // 引入 Unity API。
[System.Serializable] // 让配置类可以在 Inspector 显示。
public sealed class BossPhaseConfig // 定义 Boss 阶段配置。
{ // 类开始。
    public string phaseName; // 阶段名字。
    [Range(0f, 1f)] public float enterHpPercent; // 进入该阶段的血量百分比。
    public string enterSkillId; // 进入阶段时播放的入场技能。
    public List<string> skillIds = new List<string>(); // 当前阶段可用技能列表。
} // 类结束。
public sealed class BossPhaseController : MonoBehaviour // 定义 Boss 阶段控制器。
{ // 类开始。
    [SerializeField] private List<BossPhaseConfig> phases = new List<BossPhaseConfig>(); // 所有阶段配置。
    [SerializeField] private float decisionInterval = 1.5f; // AI 每隔多久决策一次技能。
    private int currentPhaseIndex; // 当前阶段下标。
    private float nextDecisionTime; // 下一次允许决策的时间。
    private bool phaseChanging; // 是否正在切阶段。
    private readonly HashSet<int> enteredPhases = new HashSet<int>(); // 已经进入过的阶段集合。
    public void Tick(float hpPercent) // 外部每帧传入 Boss 当前血量百分比。
    { // 方法开始。
        TrySwitchPhase(hpPercent); // 先检查是否需要切阶段。
        if (phaseChanging) return; // 切阶段演出期间不释放普通技能。
        if (Time.time < nextDecisionTime) return; // 决策间隔没到就不选技能。
        nextDecisionTime = Time.time + decisionInterval; // 设置下一次决策时间。
        CastSkillFromCurrentPhase(); // 从当前阶段技能池释放技能。
    } // 方法结束。
    private void TrySwitchPhase(float hpPercent) // 尝试切换阶段。
    { // 方法开始。
        for (int i = currentPhaseIndex + 1; i < phases.Count; i++) // 从下一个阶段开始检查。
        { // 循环开始。
            if (hpPercent > phases[i].enterHpPercent) continue; // 血量还没低于阈值就跳过。
            if (enteredPhases.Contains(i)) continue; // 这个阶段已经触发过就跳过。
            EnterPhase(i); // 进入新阶段。
            break; // 一次只切一个阶段。
        } // 循环结束。
    } // 方法结束。
    private void EnterPhase(int phaseIndex) // 进入指定阶段。
    { // 方法开始。
        phaseChanging = true; // 标记正在切阶段。
        currentPhaseIndex = phaseIndex; // 更新当前阶段下标。
        enteredPhases.Add(phaseIndex); // 记录该阶段已经进入过。
        StopCurrentSkill(); // 停止旧技能。
        ClearOldHitBoxes(); // 清理旧攻击判定盒。
        PlaySkill(phases[phaseIndex].enterSkillId); // 播放阶段入场技能。
        phaseChanging = false; // 示例里立刻结束,真实项目通常等入场时间轴回调。
    } // 方法结束。
    private void CastSkillFromCurrentPhase() // 从当前阶段释放技能。
    { // 方法开始。
        BossPhaseConfig phase = phases[currentPhaseIndex]; // 取出当前阶段配置。
        if (phase.skillIds.Count == 0) return; // 当前阶段没有技能就不释放。
        int index = Random.Range(0, phase.skillIds.Count); // 示例中随机选择一个技能。
        string skillId = phase.skillIds[index]; // 取出技能 ID。
        PlaySkill(skillId); // 播放选择出来的技能。
    } // 方法结束。
    private void StopCurrentSkill() // 停止当前正在释放的技能。
    { // 方法开始。
        Debug.Log("Stop current skill"); // 示例日志,真实项目会退出技能状态。
    } // 方法结束。
    private void ClearOldHitBoxes() // 清理旧判定盒。
    { // 方法开始。
        Debug.Log("Clear old hit boxes"); // 示例日志,真实项目会关闭碰撞盒和伤害触发器。
    } // 方法结束。
    private void PlaySkill(string skillId) // 播放技能。
    { // 方法开始。
        Debug.Log("Play skill: " + skillId); // 示例日志,真实项目会交给技能系统或时间轴。
    } // 方法结束。
} // 类结束。

面试加分说法 我不会把 Boss 写成一堆 if hp < 0.7 then...,而是做成配置驱动:BossPhaseConfig 管阶段,SkillScheduler 管技能选择,SkillTimeline 管表现和判定。这样 Boss 多了以后也能复用同一套框架。

常见坑 切阶段时只换技能池,不清理旧技能状态,会导致旧判定盒、旧特效、旧协程继续跑。另一个坑是血量在阈值附近反复触发,所以阶段切换必须做幂等。

战斗 Debug 工具需要显示什么?

一句话定义 战斗 Debug 工具就是把战斗黑盒拆开显示:谁在什么状态、放了什么技能、哪一帧命中、伤害怎么算、事件顺序是否正确。

unity-combat-debug-tool

必须显示什么

  1. 实体状态:HP、MP、状态机、目标、位置、速度、阵营。
  2. 技能信息:当前技能 ID、当前帧、前摇、命中帧、后摇、取消窗口、CD。
  3. 判定可视化:HitBox、HurtBox、攻击范围、受击点、射线、扇形范围。
  4. 伤害详情:基础伤害、攻击力、防御、暴击、减伤、Buff 修正、最终伤害。
  5. Buff 状态:Buff ID、层数、剩余时间、来源、是否可驱散。
  6. AI 决策:当前目标、仇恨、距离、当前行为、为什么选这个技能。
  7. 事件日志:输入、技能开始、命中、伤害、Buff 添加、死亡、结算。
  8. 性能指标:一帧检测次数、活跃技能数、GC Alloc、耗时、对象池数量。
  9. 调试控制:暂停、单帧步进、慢动作、重放、导出日志、生成怪物、清 CD。

C# 简单示例

c
#if UNITY_EDITOR || DEVELOPMENT_BUILD // 只在编辑器或开发包中启用。
using UnityEngine; // 引入 Unity API。
public sealed class CombatDebugOverlay : MonoBehaviour // 定义战斗 Debug 面板。
{ // 类开始。
    [SerializeField] private bool visible = true; // 是否显示 Debug 面板。
    [SerializeField] private string unitName = "Player"; // 当前观察的单位名字。
    [SerializeField] private int hp = 820; // 当前血量。
    [SerializeField] private int maxHp = 1000; // 最大血量。
    [SerializeField] private string state = "Attack_02"; // 当前战斗状态。
    [SerializeField] private string skillId = "BossSlash_02"; // 当前技能 ID。
    [SerializeField] private int frame = 18; // 当前技能帧。
    [SerializeField] private bool hitBoxActive = true; // 当前判定盒是否开启。
    [SerializeField] private string lastLog = "Hit Enemy_03 Damage 135"; // 最近一条战斗日志。
    private void Update() // 每帧处理调试快捷键。
    { // 方法开始。
        if (Input.GetKeyDown(KeyCode.F3)) visible = !visible; // 按 F3 开关 Debug 面板。
    } // 方法结束。
    private void OnGUI() // 使用 IMGUI 绘制开发期面板。
    { // 方法开始。
        if (!visible) return; // 不显示时直接返回。
        GUILayout.BeginArea(new Rect(20f, 20f, 360f, 260f), GUI.skin.box); // 开始绘制面板区域。
        GUILayout.Label("Combat Debug"); // 显示面板标题。
        GUILayout.Label("Unit: " + unitName); // 显示当前单位。
        GUILayout.Label("HP: " + hp + " / " + maxHp); // 显示血量。
        GUILayout.Label("State: " + state); // 显示状态机状态。
        GUILayout.Label("Skill: " + skillId); // 显示当前技能。
        GUILayout.Label("Frame: " + frame); // 显示当前技能帧。
        GUILayout.Label("HitBox: " + hitBoxActive); // 显示判定盒状态。
        GUILayout.Label("Log: " + lastLog); // 显示最近战斗日志。
        GUILayout.EndArea(); // 结束绘制面板区域。
    } // 方法结束。
} // 类结束。
#endif // 结束开发包编译开关。

面试加分说法 我会把 Debug 工具做成“状态快照 + 时间轴 + Gizmo 可视化 + 事件日志 + 回放”。这样遇到“偶现没伤害”“Boss 不放技能”“角色卡死”时,不靠猜,而是能按帧看到发生了什么。

常见坑 只打日志不够,因为判定盒、攻击范围、角色朝向、目标选择这些必须可视化。另一个坑是 Debug 工具进正式包,所以要用 UNITY_EDITORDEVELOPMENT_BUILD 控制。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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