Skip to content

战斗系统

一次攻击流程怎么设计?

一次攻击流程怎么设计?

一次攻击不是“按下攻击键就立刻扣血”,而是一条完整链路:输入 → 条件检查 → 进入攻击状态 → 播放动画 → 前摇 → 判定帧 → 命中检测 → 伤害结算 → 反馈表现 → 后摇恢复。

attack-flow-design-csharp

核心流程

玩家按攻击键后,先判断能不能攻击,比如是否死亡、是否受击硬直、是否在冷却、是否已经攻击中。

可以攻击后,角色进入攻击状态,通常会限制移动、限制转向,播放攻击动画。

动画前半段叫前摇,表示角色正在起手,这时一般还不能打到敌人。

中间很短的一段叫判定帧,也叫命中窗口,只有这段时间才开启攻击检测。

检测到敌人后,进行伤害、击退、硬直、音效、特效、屏震、飘字等结算。

最后进入后摇,角色收招。后摇结束后,回到 Idle / Move,或者进入下一段连击。

Unity C# 简化示例:近战攻击流程

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Animator、Physics 等 Unity 类型
using System.Collections; // 引入协程命名空间,用来使用 IEnumerator
using System.Collections.Generic; // 引入集合命名空间,用来使用 HashSet
public interface IDamageable // 定义一个可受伤接口,让敌人实现它就能被攻击
{ // 接口开始
    void TakeDamage(int damage, Vector3 hitPoint, Vector3 hitDirection); // 定义受伤函数,参数包含伤害、命中点和击退方向
} // 接口结束
public class AttackFlowExample : MonoBehaviour // 定义一个角色攻击流程脚本
{ // 类开始
    public Animator animator; // 保存角色动画控制器
    public Transform hitPoint; // 保存攻击判定中心点,比如武器前方的位置
    public LayerMask enemyLayer; // 保存敌人所在 Layer,用来过滤攻击目标
    public int damage = 10; // 保存本次攻击造成的伤害
    public float hitRadius = 1.2f; // 保存攻击判定半径
    public float windupTime = 0.2f; // 保存前摇时间,表示攻击起手多久后才开始判定
    public float activeTime = 0.12f; // 保存判定帧持续时间,表示真正能打到人的窗口
    public float recoveryTime = 0.35f; // 保存后摇时间,表示攻击后多久恢复行动
    private bool isAttacking = false; // 记录角色当前是否正在攻击
    private HashSet<Transform> hitTargets = new HashSet<Transform>(); // 记录本次攻击已经命中过的目标,避免重复扣血
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        if (Input.GetMouseButtonDown(0)) // 判断玩家是否按下鼠标左键
        { // if 语句开始
            TryAttack(); // 尝试发起一次攻击
        } // if 语句结束
    } // Update 函数结束
    private void TryAttack() // 定义尝试攻击函数
    { // 函数开始
        if (isAttacking) // 判断当前是否已经在攻击中
        { // if 语句开始
            return; // 攻击中不允许重复起手
        } // if 语句结束
        StartCoroutine(AttackRoutine()); // 启动攻击协程,按时间推进攻击流程
    } // 函数结束
    private IEnumerator AttackRoutine() // 定义攻击流程协程
    { // 协程开始
        isAttacking = true; // 标记角色进入攻击状态
        hitTargets.Clear(); // 清空本次攻击已经命中过的目标列表
        animator.SetTrigger("Attack"); // 触发攻击动画
        yield return new WaitForSeconds(windupTime); // 等待前摇结束
        float timer = 0f; // 创建计时器,用来控制判定帧持续时间
        while (timer < activeTime) // 在判定帧时间内持续检测命中
        { // while 循环开始
            Collider[] hits = Physics.OverlapSphere(hitPoint.position, hitRadius, enemyLayer); // 检测攻击范围内的敌人碰撞体
            foreach (Collider hit in hits) // 遍历每一个被检测到的碰撞体
            { // foreach 循环开始
                Transform targetRoot = hit.transform.root; // 获取目标根节点,用来做本次攻击去重
                if (hitTargets.Contains(targetRoot)) // 判断这个目标是否已经被本次攻击打中过
                { // if 语句开始
                    continue; // 已经打中过就跳过,避免重复扣血
                } // if 语句结束
                IDamageable damageable = hit.GetComponentInParent<IDamageable>(); // 尝试从目标身上获取可受伤接口
                if (damageable == null) // 判断目标是否不能受伤
                { // if 语句开始
                    continue; // 不能受伤就跳过
                } // if 语句结束
                hitTargets.Add(targetRoot); // 记录这个目标已经被本次攻击命中过
                Vector3 direction = (hit.transform.position - transform.position).normalized; // 计算从攻击者指向目标的方向
                damageable.TakeDamage(damage, hitPoint.position, direction); // 对目标造成伤害并传入命中信息
            } // foreach 循环结束
            timer += Time.deltaTime; // 累加判定帧计时
            yield return null; // 等待下一帧继续检测
        } // while 循环结束
        yield return new WaitForSeconds(recoveryTime); // 等待后摇结束
        isAttacking = false; // 标记角色退出攻击状态
    } // 协程结束
} // 类结束

面试里要讲清楚的关键点

前摇:攻击起手时间,玩家已经按了攻击,但还没产生伤害。前摇越长,攻击越有风险。

判定帧:真正能打到敌人的时间窗口。近战手感好不好,很大程度看判定帧和动画是否对得上。

后摇:攻击收招时间。后摇期间通常不能立刻行动,除非设计了取消、闪避、连击。

去重:同一次攻击可能连续检测多帧,必须记录已经命中的敌人,避免一刀扣好几次血。

解耦:不要把所有伤害逻辑都硬塞进动画事件。动画事件可以通知“打开判定窗口”,但真正的攻击数据、伤害、范围、冷却,最好由配置或技能系统控制。

面试高分回答

TIP

一次攻击流程通常会拆成输入层、状态层、动画层、判定层和结算层。玩家输入攻击后,先检查角色状态和冷却,满足条件后进入攻击状态并播放动画。攻击动画会分为前摇、判定帧和后摇,只有判定帧期间才开启碰撞、射线或范围检测。命中目标后进行去重、伤害、击退、硬直、特效、音效等结算。后摇结束后角色回到移动或待机状态,也可能根据连击窗口进入下一段攻击。这样设计的好处是攻击手感可调、逻辑清晰,也方便扩展连击、取消、霸体、受击打断和技能配置。

技能释放流程怎么设计?

技能释放流程怎么设计?

技能释放不是“按键就扣血”,而是一套完整流程:输入 → 读配置 → 条件校验 → 进入施法状态 → 播放动画 / 特效 → 生效点 → 命中检测 → 效果结算 → 后摇 / 冷却 / 恢复。

skill-cast-flow-design-csharp

完整流程

玩家按技能键后,系统先找到对应技能配置,比如伤害、蓝耗、冷却、范围、施法时间、特效、动画名。

然后做条件校验:角色是否死亡、是否被控制、蓝量够不够、技能是否冷却、目标是否存在、距离是否够、方向是否正确。

校验通过后,进入技能释放状态。这个阶段可能锁定移动、限制转向、播放动画、显示读条、开始前摇。

到达生效点时,技能真正产生效果。比如近战范围检测、发射子弹、生成地面法阵、给自己加 Buff。

命中目标后,进入效果结算:伤害、治疗、击退、眩晕、减速、吸血、飘字、音效、特效。

最后进入后摇和冷却。后摇结束后角色恢复移动,冷却结束后技能才能再次释放。

核心设计思想

技能数据要配置化,不要把伤害、范围、冷却写死在代码里。

技能逻辑要分层,输入、状态、动画、判定、伤害、表现不要全部挤在一个函数里。

技能要有状态,比如准备中、施法中、生效中、后摇中、冷却中。

技能要考虑失败和打断,比如被眩晕、目标死亡、距离变化、蓝量不足、释放中被击退。

联网游戏里,客户端可以做表现和预测,但服务器必须校验冷却、距离、目标、命中和伤害。

Unity C# 示例:一个简化技能释放器

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Animator、Physics 等类型
using System.Collections; // 引入协程命名空间,用来使用 IEnumerator
using System.Collections.Generic; // 引入集合命名空间,用来使用 HashSet
[CreateAssetMenu(menuName = "Interview/Skill Data")] // 允许在 Unity 菜单中创建技能配置资源
public class SkillData : ScriptableObject // 定义技能配置类,用 ScriptableObject 保存技能数据
{ // 类开始
    public string skillName = "FireBall"; // 保存技能名字
    public int damage = 20; // 保存技能伤害
    public float manaCost = 10f; // 保存技能消耗蓝量
    public float cooldown = 2f; // 保存技能冷却时间
    public float castTime = 0.3f; // 保存技能前摇或施法时间
    public float activeRadius = 2f; // 保存技能生效范围
    public float recoveryTime = 0.4f; // 保存技能后摇时间
    public LayerMask targetLayer; // 保存技能可以命中的目标 Layer
} // 类结束
public interface IDamageable // 定义可受伤接口
{ // 接口开始
    void TakeDamage(int damage); // 定义受伤函数
} // 接口结束
public class SkillCaster : MonoBehaviour // 定义技能释放组件
{ // 类开始
    public SkillData skill; // 保存当前要释放的技能配置
    public Animator animator; // 保存角色动画控制器
    public Transform castPoint; // 保存技能生效中心点
    public float currentMana = 100f; // 保存角色当前蓝量
    private bool isCasting = false; // 记录角色当前是否正在施法
    private float nextCastTime = 0f; // 记录下一次允许释放技能的时间
    private HashSet<Transform> hitTargets = new HashSet<Transform>(); // 保存本次技能已经命中过的目标
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        if (Input.GetKeyDown(KeyCode.Q)) // 判断玩家是否按下 Q 键
        { // if 语句开始
            TryCastSkill(); // 尝试释放技能
        } // if 语句结束
    } // Update 函数结束
    private void TryCastSkill() // 定义尝试释放技能函数
    { // 函数开始
        if (skill == null) // 判断技能配置是否为空
        { // if 语句开始
            return; // 没有技能配置就直接返回
        } // if 语句结束
        if (isCasting) // 判断当前是否正在施法
        { // if 语句开始
            return; // 施法中不能重复释放
        } // if 语句结束
        if (Time.time < nextCastTime) // 判断技能是否还在冷却中
        { // if 语句开始
            return; // 冷却没好就直接返回
        } // if 语句结束
        if (currentMana < skill.manaCost) // 判断蓝量是否不足
        { // if 语句开始
            return; // 蓝量不足就直接返回
        } // if 语句结束
        StartCoroutine(CastSkillRoutine()); // 启动技能释放协程
    } // 函数结束
    private IEnumerator CastSkillRoutine() // 定义技能释放协程
    { // 协程开始
        isCasting = true; // 标记进入施法状态
        currentMana -= skill.manaCost; // 扣除技能消耗的蓝量
        nextCastTime = Time.time + skill.cooldown; // 设置下一次允许释放技能的时间
        hitTargets.Clear(); // 清空本次技能命中过的目标记录
        animator.SetTrigger(skill.skillName); // 根据技能名触发对应动画
        yield return new WaitForSeconds(skill.castTime); // 等待技能前摇或施法时间
        Collider[] hits = Physics.OverlapSphere(castPoint.position, skill.activeRadius, skill.targetLayer); // 检测技能范围内的目标
        foreach (Collider hit in hits) // 遍历所有命中的碰撞体
        { // foreach 循环开始
            Transform root = hit.transform.root; // 获取目标根节点用于去重
            if (hitTargets.Contains(root)) // 判断这个目标是否已经被本次技能命中过
            { // if 语句开始
                continue; // 已经命中过就跳过
            } // if 语句结束
            IDamageable damageable = hit.GetComponentInParent<IDamageable>(); // 尝试获取目标身上的受伤接口
            if (damageable == null) // 判断目标是否不能受伤
            { // if 语句开始
                continue; // 不能受伤就跳过
            } // if 语句结束
            hitTargets.Add(root); // 记录这个目标已经被本次技能命中
            damageable.TakeDamage(skill.damage); // 对目标造成技能伤害
        } // foreach 循环结束
        yield return new WaitForSeconds(skill.recoveryTime); // 等待技能后摇结束
        isCasting = false; // 标记退出施法状态
    } // 协程结束
} // 类结束

面试高分回答

NOTE

我会把技能释放拆成数据层、状态层、表现层和结算层。数据层用配置表或 ScriptableObject 保存伤害、冷却、范围、消耗、前摇、后摇和特效资源。玩家输入后,先进行状态、蓝量、冷却、目标、距离等校验,通过后进入施法状态并播放动画。到达生效点时,根据技能类型做范围检测、射线检测、弹道生成或 Buff 添加。命中目标后进行去重、伤害、控制、击退、治疗等结算,同时触发音效、特效、飘字和镜头反馈。最后进入后摇和冷却。联网游戏中,客户端负责表现和预测,服务器负责校验释放是否合法以及最终伤害结果。

伤害公式放客户端还是服务端?

伤害公式放客户端还是服务端?

联网游戏里,最终伤害公式必须放服务端。

客户端可以做攻击动画、特效、飘字预测、UI 预览,但不能决定最终伤害。因为客户端在玩家机器上,内存、资源、代码、网络包都有可能被改。如果服务端相信客户端传来的伤害值,外挂就可以把 100 点伤害改成 999999 点。

damage-formula-client-vs-server-csharp

正确分工

客户端负责:

播放攻击动画。

播放技能特效。

显示伤害飘字。

做本地预测,让手感更顺。

显示技能范围、预计伤害、目标提示。

服务端负责:

校验技能是否冷却。

校验蓝量是否足够。

校验攻击者是否能攻击。

校验目标是否存在、是否死亡、是否在范围内。

计算最终伤害。

处理暴击、护甲、减伤、Buff、Debuff。

扣血、死亡、掉落、经验、战斗日志。

把最终结果同步给客户端。

为什么不能只放客户端?

因为客户端不可信。

比如客户端告诉服务端:

“我打中了敌人。”

“我造成了 5000 伤害。”

“我暴击了。”

如果服务端直接相信,外挂就可以伪造这些结果。

所以客户端最多只能发:

“我想释放技能 A。”

“我的目标是怪物 B。”

“我的方向是这里。”

然后服务端自己判断是否合法,自己计算伤害。

服务端 C# 示例:权威伤害计算

c
using System; // 引入 System 命名空间,用来使用 Math 等基础类型
public class DamageRequest // 定义客户端发给服务端的攻击请求
{ // 类开始
    public int attackerId; // 保存攻击者 ID
    public int targetId; // 保存目标 ID
    public int skillId; // 保存技能 ID
} // 类结束
public class CombatUnit // 定义战斗单位数据
{ // 类开始
    public int id; // 保存单位 ID
    public int hp; // 保存当前血量
    public int attack; // 保存攻击力
    public int defense; // 保存防御力
    public bool isDead; // 保存是否已经死亡
} // 类结束
public class SkillConfig // 定义技能配置数据
{ // 类开始
    public int id; // 保存技能 ID
    public int baseDamage; // 保存技能基础伤害
    public float attackScale; // 保存攻击力倍率
    public float cooldown; // 保存技能冷却时间
} // 类结束
public class CombatServer // 定义服务端战斗系统
{ // 类开始
    public void HandleDamageRequest(DamageRequest request) // 处理客户端发来的伤害请求
    { // 函数开始
        CombatUnit attacker = FindUnit(request.attackerId); // 根据攻击者 ID 查找攻击者
        CombatUnit target = FindUnit(request.targetId); // 根据目标 ID 查找目标
        SkillConfig skill = FindSkill(request.skillId); // 根据技能 ID 查找技能配置
        if (attacker == null || target == null || skill == null) // 判断攻击者、目标或技能是否不存在
        { // if 语句开始
            return; // 数据不合法就直接拒绝
        } // if 语句结束
        if (attacker.isDead || target.isDead) // 判断攻击者或目标是否已经死亡
        { // if 语句开始
            return; // 死亡单位不能继续参与这次伤害
        } // if 语句结束
        if (CanCastSkill(attacker, target, skill) == false) // 服务端校验技能释放是否合法
        { // if 语句开始
            return; // 不合法就拒绝本次攻击
        } // if 语句结束
        int damage = CalculateDamage(attacker, target, skill); // 在服务端计算最终伤害
        target.hp = Math.Max(0, target.hp - damage); // 服务端扣除目标血量并保证不低于 0
        target.isDead = target.hp == 0; // 根据血量更新目标死亡状态
        BroadcastDamageResult(attacker.id, target.id, damage, target.hp); // 把最终结果广播给客户端
    } // 函数结束
    private int CalculateDamage(CombatUnit attacker, CombatUnit target, SkillConfig skill) // 定义服务端伤害公式
    { // 函数开始
        float rawDamage = skill.baseDamage + attacker.attack * skill.attackScale; // 计算技能基础伤害加攻击力倍率
        float reducedDamage = rawDamage - target.defense; // 用目标防御力抵消一部分伤害
        int finalDamage = Math.Max(1, (int)reducedDamage); // 最终伤害至少为 1
        return finalDamage; // 返回最终伤害
    } // 函数结束
    private bool CanCastSkill(CombatUnit attacker, CombatUnit target, SkillConfig skill) // 定义技能合法性校验函数
    { // 函数开始
        return true; // 示例中简化返回 true,真实项目要检查冷却、距离、蓝量和状态
    } // 函数结束
    private CombatUnit FindUnit(int unitId) // 定义查找战斗单位函数
    { // 函数开始
        return null; // 示例中省略查找逻辑,真实项目会从房间或战斗上下文中获取
    } // 函数结束
    private SkillConfig FindSkill(int skillId) // 定义查找技能配置函数
    { // 函数开始
        return null; // 示例中省略配置查找,真实项目会从配置表中获取
    } // 函数结束
    private void BroadcastDamageResult(int attackerId, int targetId, int damage, int targetHp) // 定义广播伤害结果函数
    { // 函数开始
    } // 函数结束
} // 类结束

那客户端完全不能有公式吗?

不是。

客户端可以有一份“非权威公式”,用于 UI 预览和本地预测。

比如玩家鼠标移到技能上,客户端显示“预计造成 120 到 150 点伤害”。

比如为了手感,客户端先播放命中特效和飘字。

但最终血量、死亡、掉落、胜负,必须以服务端结果为准。

如果服务端同步回来伤害是 98,客户端预测是 105,那客户端要校正到 98。

单机游戏呢?

单机游戏可以放客户端,因为没有服务端。

但如果是联网、排行榜、PVP、交易、掉落、战斗结算,关键公式就应该服务端权威。

面试高分回答

IMPORTANT

伤害公式在联网游戏中应该放在服务端,客户端不能决定最终伤害。客户端只负责输入、表现和预测,比如播放动画、特效、飘字、显示预计伤害。服务端负责校验技能是否合法,包括冷却、蓝量、状态、距离、目标有效性,然后读取配置计算最终伤害,处理暴击、护甲、Buff、减伤、扣血和死亡,并把结果同步给客户端。这样可以防止客户端篡改内存、修改资源或伪造网络包造成作弊。客户端可以保留一份预测公式提升手感,但最终战斗结果必须以服务端为准。

Buff 系统怎么设计?

Buff 系统怎么设计?

Buff 系统可以理解成:给角色临时挂上一些“状态效果”。比如中毒、燃烧、眩晕、加攻击、减速、护盾、回血、无敌,这些都可以看成 Buff。

重点不是写一个 bool isPoisoned,而是设计一套能添加、刷新、叠层、计时、结算、移除、同步 UI 的系统。

buff-system-design-csharp

核心结构

BuffData:配置数据。比如名字、持续时间、最大层数、每秒伤害、图标、是否可驱散。

BuffInstance:运行时实例。记录这个 Buff 当前剩余多久、现在几层、是谁施加的、挂在哪个角色身上。

BuffManager:挂在角色身上,负责添加 Buff、刷新 Buff、每帧更新时间、Tick 结算、移除过期 Buff。

属性系统:负责根据 Buff 重新计算攻击、防御、速度等属性。

表现系统:负责显示 Buff 图标、倒计时、层数、特效。

Buff 生命周期

添加时:执行 OnAdd,比如加攻击、播放特效、显示图标。

持续中:每帧减少剩余时间。

间隔触发:比如中毒每秒扣血,回血每秒治疗。

刷新或叠层:重复添加时,可能刷新时间,也可能增加层数。

移除时:执行 OnRemove,比如还原属性、移除特效、隐藏图标。

Unity C# 简化示例

c
using System.Collections.Generic; // 引入集合命名空间,用来使用 List
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour 和 ScriptableObject
public enum BuffType { Attribute, PeriodicDamage, Stun } // 定义 Buff 类型,分别表示属性类、持续伤害类、眩晕类
public enum BuffStackMode { RefreshTime, StackLayer, Replace } // 定义重复添加 Buff 时的处理方式
[CreateAssetMenu(menuName = "Interview/Buff Data")] // 允许在 Unity 菜单中创建 Buff 配置资源
public class BuffData : ScriptableObject // 定义 Buff 配置类,保存静态数据
{ // BuffData 类开始
    public int id; // 保存 Buff 唯一 ID
    public string buffName; // 保存 Buff 名字
    public BuffType type; // 保存 Buff 类型
    public BuffStackMode stackMode; // 保存 Buff 叠加规则
    public float duration = 5f; // 保存 Buff 持续时间
    public float tickInterval = 1f; // 保存 Tick 间隔,比如每秒扣血一次
    public int maxStack = 1; // 保存最大叠加层数
    public int value = 10; // 保存 Buff 数值,比如伤害、攻击加成或治疗量
    public bool canDispel = true; // 保存这个 Buff 是否可以被驱散
} // BuffData 类结束
public class CombatUnit : MonoBehaviour // 定义一个简化的战斗单位
{ // CombatUnit 类开始
    public int hp = 100; // 保存当前血量
    public int baseAttack = 10; // 保存基础攻击力
    public int extraAttack = 0; // 保存 Buff 额外增加的攻击力
    public bool isStunned = false; // 保存当前是否被眩晕
    public int Attack => baseAttack + extraAttack; // 返回最终攻击力
    public void TakeDamage(int damage) // 定义受伤函数
    { // TakeDamage 函数开始
        hp = Mathf.Max(0, hp - damage); // 扣除血量并保证血量不小于 0
    } // TakeDamage 函数结束
} // CombatUnit 类结束
public class BuffInstance // 定义 Buff 运行时实例
{ // BuffInstance 类开始
    public BuffData data; // 保存 Buff 配置引用
    public CombatUnit source; // 保存 Buff 来源者
    public CombatUnit target; // 保存 Buff 目标
    public float remainingTime; // 保存 Buff 剩余时间
    public float tickTimer; // 保存 Tick 计时器
    public int stack; // 保存当前叠加层数
    public BuffInstance(BuffData data, CombatUnit source, CombatUnit target) // 定义 Buff 实例构造函数
    { // 构造函数开始
        this.data = data; // 记录 Buff 配置
        this.source = source; // 记录 Buff 来源者
        this.target = target; // 记录 Buff 目标
        remainingTime = data.duration; // 初始化剩余时间
        tickTimer = data.tickInterval; // 初始化 Tick 计时器
        stack = 1; // 初始化层数为 1
    } // 构造函数结束
} // BuffInstance 类结束
public class BuffManager : MonoBehaviour // 定义 Buff 管理器,每个角色可以挂一个
{ // BuffManager 类开始
    public CombatUnit owner; // 保存 Buff 所属角色
    private List<BuffInstance> buffs = new List<BuffInstance>(); // 保存当前角色身上的所有 Buff
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        for (int i = buffs.Count - 1; i >= 0; i--) // 倒序遍历 Buff,方便移除过期 Buff
        { // for 循环开始
            BuffInstance buff = buffs[i]; // 取出当前 Buff
            buff.remainingTime -= Time.deltaTime; // 减少 Buff 剩余时间
            buff.tickTimer -= Time.deltaTime; // 减少 Buff Tick 计时器
            if (buff.tickTimer <= 0f) // 判断是否到了 Tick 时间
            { // if 语句开始
                ApplyTick(buff); // 执行 Buff 的周期效果
                buff.tickTimer = buff.data.tickInterval; // 重置 Tick 计时器
            } // if 语句结束
            if (buff.remainingTime <= 0f) // 判断 Buff 是否过期
            { // if 语句开始
                RemoveBuff(buff); // 移除过期 Buff
            } // if 语句结束
        } // for 循环结束
    } // Update 函数结束
    public void AddBuff(BuffData data, CombatUnit source) // 定义添加 Buff 函数
    { // AddBuff 函数开始
        BuffInstance existing = buffs.Find(buff => buff.data.id == data.id); // 查找身上是否已有同 ID 的 Buff
        if (existing != null) // 判断是否已经存在这个 Buff
        { // if 语句开始
            RefreshBuff(existing); // 根据规则刷新或叠加 Buff
            return; // 处理完重复 Buff 后直接返回
        } // if 语句结束
        BuffInstance instance = new BuffInstance(data, source, owner); // 创建新的 Buff 实例
        buffs.Add(instance); // 把新 Buff 加入列表
        ApplyOnAdd(instance); // 执行 Buff 添加时的效果
    } // AddBuff 函数结束
    private void RefreshBuff(BuffInstance buff) // 定义刷新 Buff 函数
    { // RefreshBuff 函数开始
        if (buff.data.stackMode == BuffStackMode.RefreshTime) // 判断叠加规则是否是刷新时间
        { // if 语句开始
            buff.remainingTime = buff.data.duration; // 重置 Buff 剩余时间
        } // if 语句结束
        if (buff.data.stackMode == BuffStackMode.StackLayer) // 判断叠加规则是否是增加层数
        { // if 语句开始
            buff.stack = Mathf.Min(buff.stack + 1, buff.data.maxStack); // 增加层数并限制最大层数
            buff.remainingTime = buff.data.duration; // 叠层后刷新持续时间
        } // if 语句结束
    } // RefreshBuff 函数结束
    private void ApplyOnAdd(BuffInstance buff) // 定义 Buff 添加时效果
    { // ApplyOnAdd 函数开始
        if (buff.data.type == BuffType.Attribute) // 判断是否是属性类 Buff
        { // if 语句开始
            owner.extraAttack += buff.data.value; // 给角色增加额外攻击力
        } // if 语句结束
        if (buff.data.type == BuffType.Stun) // 判断是否是眩晕 Buff
        { // if 语句开始
            owner.isStunned = true; // 设置角色进入眩晕状态
        } // if 语句结束
    } // ApplyOnAdd 函数结束
    private void ApplyTick(BuffInstance buff) // 定义 Buff 周期效果
    { // ApplyTick 函数开始
        if (buff.data.type == BuffType.PeriodicDamage) // 判断是否是持续伤害 Buff
        { // if 语句开始
            owner.TakeDamage(buff.data.value * buff.stack); // 按当前层数造成持续伤害
        } // if 语句结束
    } // ApplyTick 函数结束
    private void RemoveBuff(BuffInstance buff) // 定义移除 Buff 函数
    { // RemoveBuff 函数开始
        if (buff.data.type == BuffType.Attribute) // 判断是否是属性类 Buff
        { // if 语句开始
            owner.extraAttack -= buff.data.value; // 移除时还原攻击力加成
        } // if 语句结束
        if (buff.data.type == BuffType.Stun) // 判断是否是眩晕 Buff
        { // if 语句开始
            owner.isStunned = false; // 移除时取消眩晕状态
        } // if 语句结束
        buffs.Remove(buff); // 从 Buff 列表中移除这个 Buff
    } // RemoveBuff 函数结束
} // BuffManager 类结束

常见 Buff 类型

属性 Buff:加攻击、加防御、加移速、减攻速。

持续伤害 Buff:中毒、燃烧、流血。

持续治疗 Buff:回血、生命恢复。

控制 Buff:眩晕、沉默、冰冻、禁锢、击飞。

护盾 Buff:吸收伤害,护盾值被打完后移除。

免疫 Buff:免疫控制、免疫伤害、免疫减速。

触发 Buff:攻击时触发额外伤害,受击时反伤,击杀后回血。

叠加规则很重要

刷新时间:重复添加时不加层,只把持续时间重置,比如减速。

增加层数:重复添加时层数加一,比如中毒最多叠 5 层。

独立存在:每次添加都创建一个新实例,比如多个来源的燃烧。

替换规则:新的更强就替换旧的,新的更弱就忽略。

互斥规则:同类 Buff 只能存在一个,比如同一时间只能有一个主食物效果。

面试高分回答

CAUTION

Buff 系统我会拆成配置数据、运行时实例和 BuffManager 三层。配置数据保存 Buff 的静态信息,比如类型、持续时间、Tick 间隔、最大层数、数值和是否可驱散;运行时实例保存剩余时间、当前层数、来源者和目标;BuffManager 挂在角色身上,负责添加、刷新、叠层、Tick、移除和通知属性系统刷新。属性类 Buff 通常在添加和移除时修改属性,持续伤害或治疗类 Buff 按固定间隔 Tick 结算,控制类 Buff 会影响角色状态机。联网游戏中,Buff 的添加、移除和伤害结算应该由服务端权威处理,客户端只负责图标、倒计时、层数和特效表现。

技能打断和前后摇怎么处理?

技能打断和前后摇怎么处理?

技能要拆成三个核心阶段:前摇、生效段、后摇。打断和取消不能随便发生,要看当前处于哪个阶段,以及这个阶段有没有开放对应规则。

skill-interrupt-windup-recovery-csharp

前摇怎么处理?

前摇是技能起手阶段,比如角色抬手、蓄力、挥刀前半段。

这个阶段通常还没有造成伤害,所以如果被眩晕、击飞、死亡打断,技能一般不生效。

比如法师读条 1 秒,第 0.5 秒被打断,那火球不应该飞出去。

生效段怎么处理?

生效段是技能真正产生结果的阶段。

比如近战打开判定框,远程发射子弹,范围技能生成爆炸,Buff 技能给自己加状态。

如果技能已经生效,再被打断,就要看设计规则。比如火球已经发出去了,角色之后被打断,火球通常仍然继续飞;但如果是持续引导技能,被打断后后续伤害就停止。

后摇怎么处理?

后摇是技能收招阶段。

这时技能已经生效了,但角色还没完全恢复行动。后摇的作用是制造攻击风险,防止玩家无脑连续释放技能。

但为了手感,可以设计取消窗口。比如后摇后半段允许翻滚取消、连击取消、格挡取消。

打断和取消的区别

打断:外部强制中止,比如被敌人打晕、击飞、沉默、死亡。

取消:玩家主动中止,比如用翻滚取消后摇、用下一段连击取消收招。

简单说:打断是别人让你停,取消是设计允许你自己接别的动作。

Unity C# 简化示例

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Animator 和 Time
using System.Collections; // 引入协程命名空间,用来使用 IEnumerator
public enum SkillPhase { None, Windup, Active, Recovery } // 定义技能阶段,分别表示无技能、前摇、生效段、后摇
public class SkillInterruptExample : MonoBehaviour // 定义一个演示技能前后摇和打断的组件
{ // 类开始
    public Animator animator; // 保存角色动画控制器
    public float windupTime = 0.3f; // 保存技能前摇时间
    public float activeTime = 0.15f; // 保存技能生效段时间
    public float recoveryTime = 0.45f; // 保存技能后摇时间
    public float cancelStartInRecovery = 0.25f; // 保存后摇中允许取消的开始时间
    public bool hasSuperArmor = false; // 保存当前技能是否有霸体
    private SkillPhase phase = SkillPhase.None; // 保存当前技能阶段
    private Coroutine skillRoutine; // 保存当前技能协程
    private float recoveryTimer = 0f; // 保存后摇已经经过的时间
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        if (Input.GetKeyDown(KeyCode.Q)) // 判断玩家是否按下 Q 键
        { // if 语句开始
            TryCastSkill(); // 尝试释放技能
        } // if 语句结束
        if (Input.GetKeyDown(KeyCode.Space)) // 判断玩家是否按下空格键
        { // if 语句开始
            TryCancelToDodge(); // 尝试用翻滚取消后摇
        } // if 语句结束
    } // Update 函数结束
    private void TryCastSkill() // 定义尝试释放技能函数
    { // 函数开始
        if (phase != SkillPhase.None) // 判断角色是否正在释放技能
        { // if 语句开始
            return; // 正在释放技能就不能重新起手
        } // if 语句结束
        skillRoutine = StartCoroutine(CastRoutine()); // 启动技能流程协程
    } // 函数结束
    private IEnumerator CastRoutine() // 定义技能流程协程
    { // 协程开始
        phase = SkillPhase.Windup; // 设置当前阶段为前摇
        animator.SetTrigger("Skill"); // 播放技能动画
        yield return new WaitForSeconds(windupTime); // 等待前摇结束
        phase = SkillPhase.Active; // 设置当前阶段为生效段
        ApplySkillEffect(); // 在生效点真正产生技能效果
        yield return new WaitForSeconds(activeTime); // 等待生效段结束
        phase = SkillPhase.Recovery; // 设置当前阶段为后摇
        recoveryTimer = 0f; // 重置后摇计时器
        while (recoveryTimer < recoveryTime) // 在后摇时间内循环等待
        { // while 循环开始
            recoveryTimer += Time.deltaTime; // 累加后摇时间
            yield return null; // 等待下一帧继续
        } // while 循环结束
        phase = SkillPhase.None; // 技能流程结束,恢复到无技能状态
        skillRoutine = null; // 清空技能协程引用
    } // 协程结束
    public void TryInterruptByHit() // 定义被敌人攻击时尝试打断的函数
    { // 函数开始
        if (phase == SkillPhase.None) // 判断当前是否没有释放技能
        { // if 语句开始
            return; // 没有技能就不需要打断
        } // if 语句结束
        if (hasSuperArmor) // 判断当前技能是否有霸体
        { // if 语句开始
            return; // 有霸体就不被普通受击打断
        } // if 语句结束
        StopCurrentSkill(); // 没有霸体就停止当前技能
        animator.SetTrigger("Hit"); // 播放受击动画
    } // 函数结束
    private void TryCancelToDodge() // 定义尝试翻滚取消函数
    { // 函数开始
        if (phase != SkillPhase.Recovery) // 判断当前是否不在后摇阶段
        { // if 语句开始
            return; // 不在后摇就不能用这个规则取消
        } // if 语句结束
        if (recoveryTimer < cancelStartInRecovery) // 判断是否还没到取消窗口
        { // if 语句开始
            return; // 没到取消窗口就不能翻滚取消
        } // if 语句结束
        StopCurrentSkill(); // 停止当前技能后摇
        animator.SetTrigger("Dodge"); // 播放翻滚动画
    } // 函数结束
    private void StopCurrentSkill() // 定义停止当前技能函数
    { // 函数开始
        if (skillRoutine != null) // 判断技能协程是否存在
        { // if 语句开始
            StopCoroutine(skillRoutine); // 停止技能协程
        } // if 语句结束
        phase = SkillPhase.None; // 重置技能阶段
        skillRoutine = null; // 清空技能协程引用
    } // 函数结束
    private void ApplySkillEffect() // 定义技能真正生效的函数
    { // 函数开始
        Debug.Log("Skill effect applied"); // 输出日志,表示技能在这里真正生效
    } // 函数结束
} // 类结束

优先级怎么定?

一般优先级可以这样设计:

死亡最高,死亡后所有技能都停止。

强控其次,比如眩晕、击飞、冰冻,可以打断大部分技能。

霸体可以抵抗普通打断,但不一定抵抗死亡、抓取、特殊控制。

主动取消只在指定窗口生效,比如后摇后半段才能翻滚。

连击输入通常在连击窗口缓存,到了时间点再切到下一段攻击。

面试高分回答

NOTE

我会把技能按时间拆成前摇、生效段和后摇。前摇阶段技能还没产生效果,通常允许被受击、眩晕、击飞等强制打断,打断后技能不生效。生效段是判定帧、发射弹道或生成 Buff 的时间点,一旦生效就要明确结果是否保留,比如弹道技能发射后一般不会因为角色后续被打断而消失。后摇阶段技能已经生效,但角色还处于收招限制中,可以根据手感设计翻滚取消、连击取消或格挡取消窗口。实现上我会用状态机和技能配置控制每个阶段的时长、可打断性、可取消性和霸体规则,避免把逻辑全部硬绑在动画事件里。

受击、击退、硬直怎么设计?

受击、击退、硬直怎么设计?

这三个东西不要混在一起理解:

受击:角色收到了命中事件,比如扣血、播放受击动画、飘字、音效、特效。

击退:角色因为这次命中产生位移,比如被往后推、被击飞、被拉拽。

硬直:角色在一段时间内失去部分控制,比如不能移动、不能攻击、不能放技能。

hit-knockback-hitstun-design-csharp

核心设计思路

一次命中最好用一个 HitInfo 描述清楚。

里面包含:伤害、命中点、击退方向、击退力度、硬直时间、是否击飞、打断等级、攻击来源。

然后目标收到 HitInfo 后,统一处理扣血、状态切换、击退移动、动画反馈。

这样设计的好处是:普通攻击、技能、陷阱、子弹、怪物攻击都可以复用同一套受击逻辑。

受击怎么处理?

受击时先扣血,再判断是否死亡。

如果死亡,直接进入死亡状态,后面的击退和硬直可能不用继续处理,或者播放死亡击飞。

如果没死,再判断是否有霸体、无敌、护盾、免疫控制。

如果没有免疫,就进入受击状态,播放受击动画,禁止攻击和技能一段时间。

击退怎么处理?

击退方向通常来自攻击者到目标的方向:

目标位置 - 攻击者位置

然后把 Y 轴清掉,只保留水平击退方向。

击退可以有几种实现:

CharacterController:每帧 Move 一小段,适合角色控制器。

Rigidbody:使用 AddForce 或设置速度,适合物理角色。

Tween / 曲线:用曲线控制位移,适合动作游戏手感。

服务端坐标:联网游戏里,服务端计算位置,客户端做表现预测。

硬直怎么处理?

硬直本质是一个状态锁。

在硬直时间内,角色不能移动、不能攻击、不能放技能,或者只能做少量操作。

硬直结束后,角色回到 Idle / Move。

但是要注意,不要让玩家长时间失去控制。小怪被打可以硬直明显,玩家硬直要短,Boss 通常有霸体或韧性系统。

Unity C# 简化示例

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Vector3、CharacterController 等类型
using System.Collections; // 引入协程命名空间,用来使用 IEnumerator
public enum CombatState { Idle, Move, Attack, HitStun, Dead } // 定义战斗状态,包含待机、移动、攻击、硬直、死亡
public struct HitInfo // 定义一次命中的数据结构
{ // HitInfo 结构体开始
    public int damage; // 保存本次命中的伤害
    public Vector3 hitPoint; // 保存命中点位置
    public Vector3 knockbackDirection; // 保存击退方向
    public float knockbackForce; // 保存击退力度
    public float hitStunTime; // 保存硬直时间
    public bool canInterrupt; // 保存这次命中是否可以打断目标动作
} // HitInfo 结构体结束
public class HitReactionController : MonoBehaviour // 定义受击、击退、硬直控制器
{ // 类开始
    public CharacterController controller; // 保存角色控制器引用
    public Animator animator; // 保存动画控制器引用
    public int hp = 100; // 保存角色当前血量
    public bool hasSuperArmor = false; // 保存角色当前是否有霸体
    public CombatState state = CombatState.Idle; // 保存角色当前战斗状态
    private Coroutine hitRoutine; // 保存当前受击协程
    public void ReceiveHit(HitInfo hit) // 定义接收命中事件的函数
    { // 函数开始
        if (state == CombatState.Dead) // 判断角色是否已经死亡
        { // if 语句开始
            return; // 死亡后不再处理普通受击
        } // if 语句结束
        hp = Mathf.Max(0, hp - hit.damage); // 扣除血量并保证血量不小于 0
        SpawnHitFeedback(hit.hitPoint); // 播放受击特效、音效或飘字
        if (hp <= 0) // 判断角色是否已经被打死
        { // if 语句开始
            EnterDeadState(); // 进入死亡状态
            return; // 死亡后直接结束受击流程
        } // if 语句结束
        if (hasSuperArmor && hit.canInterrupt) // 判断角色是否有霸体且这次攻击属于可打断攻击
        { // if 语句开始
            animator.SetTrigger("HitLight"); // 只播放轻微受击表现
            return; // 有霸体时吃伤害但不进入硬直
        } // if 语句结束
        if (hitRoutine != null) // 判断是否已经有受击协程在运行
        { // if 语句开始
            StopCoroutine(hitRoutine); // 停止旧的受击协程,避免多个硬直流程叠在一起
        } // if 语句结束
        hitRoutine = StartCoroutine(HitStunRoutine(hit)); // 启动新的受击硬直协程
    } // 函数结束
    private IEnumerator HitStunRoutine(HitInfo hit) // 定义受击硬直协程
    { // 协程开始
        state = CombatState.HitStun; // 切换到硬直状态
        animator.SetTrigger("Hit"); // 播放受击动画
        float timer = 0f; // 创建计时器,用来记录硬直经过时间
        Vector3 direction = hit.knockbackDirection.normalized; // 标准化击退方向
        while (timer < hit.hitStunTime) // 在硬直时间内循环
        { // while 循环开始
            float remainRate = 1f - timer / hit.hitStunTime; // 计算剩余比例,让击退逐渐衰减
            Vector3 move = direction * hit.knockbackForce * remainRate * Time.deltaTime; // 计算本帧击退位移
            controller.Move(move); // 使用 CharacterController 移动角色
            timer += Time.deltaTime; // 累加硬直计时
            yield return null; // 等待下一帧继续
        } // while 循环结束
        state = CombatState.Idle; // 硬直结束后回到待机状态
        hitRoutine = null; // 清空受击协程引用
    } // 协程结束
    private void EnterDeadState() // 定义进入死亡状态函数
    { // 函数开始
        state = CombatState.Dead; // 设置状态为死亡
        animator.SetTrigger("Dead"); // 播放死亡动画
        if (hitRoutine != null) // 判断是否有受击协程正在运行
        { // if 语句开始
            StopCoroutine(hitRoutine); // 停止受击协程
        } // if 语句结束
        hitRoutine = null; // 清空协程引用
    } // 函数结束
    private void SpawnHitFeedback(Vector3 hitPoint) // 定义生成受击反馈函数
    { // 函数开始
        Debug.Log("Hit feedback at " + hitPoint); // 示例中用日志代替特效、音效和飘字
    } // 函数结束
} // 类结束

霸体和韧性怎么处理?

霸体:吃伤害,但不进入硬直,不被普通攻击打断。常用于 Boss、大招、重攻击。

韧性:有一条隐藏的抗打断值。普通攻击会削韧,削到 0 才进入大硬直或倒地。

无敌:既不吃伤害,也不硬直。比如翻滚无敌帧。

护盾:先扣护盾值,护盾没破可能不硬直,破盾后进入硬直。

连续受击要注意什么?

不要无限刷新硬直,否则玩家或怪物会一直不能动。

可以设置受击冷却,比如 0.2 秒内不重复进入硬直。

可以区分轻硬直、重硬直、击飞、倒地。

可以给 Boss 设置霸体或韧性,避免被小技能无限打断。

面试高分回答

TIP

我会把受击、击退、硬直拆开设计。攻击命中时生成一个 HitInfo,里面包含伤害、命中点、击退方向、击退力度、硬直时间和打断等级。目标收到后先扣血并判断死亡,再根据霸体、无敌、护盾、韧性等规则决定是否进入硬直。如果进入硬直,就切换到 HitStun 状态,锁住移动、攻击和技能,同时按击退方向做一段衰减位移。硬直结束后回到待机或移动状态。这样能让普通受击、击飞、倒地、霸体、破盾、Boss 韧性和联网同步都比较容易扩展。

碰撞盒如何跟动画同步?

碰撞盒如何跟动画同步?

核心就一句话:碰撞盒的位置跟骨骼走,碰撞盒的开关跟动画判定帧走。

比如角色挥剑,剑的 HitBox 可以挂在武器骨骼下面,这样动画挥剑时碰撞盒自然跟着剑移动。但它不能全程开启,应该只在真正能打到敌人的那几帧打开,前摇和后摇都关闭。

先区分两个盒子

HitBox:攻击判定盒,用来打别人。比如剑刃、拳头、技能范围。

HurtBox:受击判定盒,用来被别人打。比如头、身体、腿。

面试里说“碰撞盒跟动画同步”,多数时候指的是 HitBox 跟攻击动画同步。

常见做法一:HitBox 挂到骨骼下面

比如剑的碰撞盒挂在武器节点下面:

角色骨骼动。

武器跟着骨骼动。

HitBox 跟着武器动。

这样位置同步最自然,不用每帧手动算。

适合:刀、剑、拳头、脚踢、尾巴攻击。

常见做法二:Animation Event 开关碰撞盒

在攻击动画的关键帧上加事件:

前摇:HitBox 关闭。

挥到敌人的瞬间:HitBox 打开。

挥完:HitBox 关闭。

这样能让判定和动画对齐。

缺点是 Animation Event 和动画资源绑定比较紧,动画被换掉或裁剪后,事件也要维护。

常见做法三:配置表控制判定窗口

用数据配置:

hitStartTime = 0.25
hitEndTime = 0.42

播放技能时,代码根据时间判断什么时候开关 HitBox。

这种方式更适合技能系统,因为策划可以调数据,热更新也更方便。

Unity C# 示例:Animation Event 控制 HitBox

c
using System.Collections.Generic; // 引入集合命名空间,用来使用 HashSet
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Collider 和 GameObject
public interface IDamageable // 定义可受伤接口
{ // 接口开始
    void TakeDamage(int damage); // 定义受伤函数
} // 接口结束
public class AnimationHitBox : MonoBehaviour // 定义一个跟动画同步的攻击碰撞盒组件
{ // 类开始
    public Collider hitBoxCollider; // 保存攻击碰撞盒的 Collider
    public int damage = 10; // 保存本次攻击造成的伤害
    private bool isHitBoxActive = false; // 保存攻击碰撞盒是否处于开启状态
    private HashSet<IDamageable> hitTargets = new HashSet<IDamageable>(); // 保存本次攻击已经命中过的目标
    private void Awake() // Unity 在对象初始化时调用 Awake
    { // Awake 函数开始
        CloseHitBox(); // 初始化时关闭攻击碰撞盒
    } // Awake 函数结束
    public void OpenHitBox() // 动画事件调用这个函数来开启攻击判定
    { // OpenHitBox 函数开始
        hitTargets.Clear(); // 清空本次攻击已经命中过的目标
        isHitBoxActive = true; // 标记攻击碰撞盒进入开启状态
        hitBoxCollider.enabled = true; // 启用 Collider,让它开始参与触发检测
    } // OpenHitBox 函数结束
    public void CloseHitBox() // 动画事件调用这个函数来关闭攻击判定
    { // CloseHitBox 函数开始
        isHitBoxActive = false; // 标记攻击碰撞盒进入关闭状态
        if (hitBoxCollider != null) // 判断 Collider 是否存在
        { // if 语句开始
            hitBoxCollider.enabled = false; // 禁用 Collider,避免前摇或后摇误伤敌人
        } // if 语句结束
    } // CloseHitBox 函数结束
    private void OnTriggerEnter(Collider other) // 当攻击碰撞盒碰到其他 Collider 时调用
    { // OnTriggerEnter 函数开始
        if (isHitBoxActive == false) // 判断攻击碰撞盒是否没有开启
        { // if 语句开始
            return; // 没开启就不处理命中
        } // if 语句结束
        IDamageable damageable = other.GetComponentInParent<IDamageable>(); // 从目标身上查找可受伤接口
        if (damageable == null) // 判断目标是否不能受伤
        { // if 语句开始
            return; // 不能受伤就直接返回
        } // if 语句结束
        if (hitTargets.Contains(damageable)) // 判断这个目标是否已经被本次攻击命中过
        { // if 语句开始
            return; // 已经命中过就不重复扣血
        } // if 语句结束
        hitTargets.Add(damageable); // 记录这个目标已经被本次攻击命中
        damageable.TakeDamage(damage); // 对目标造成伤害
    } // OnTriggerEnter 函数结束
    private void OnDisable() // 当对象被禁用时调用
    { // OnDisable 函数开始
        CloseHitBox(); // 强制关闭碰撞盒,避免状态残留
    } // OnDisable 函数结束
} // 类结束

为什么要做命中去重?

因为 HitBox 通常会开启好几帧。

敌人如果一直在碰撞盒里,可能连续触发多次检测。

如果不去重,一刀可能扣三四次血。

所以同一次攻击里,要用 HashSet 记录已经命中过的目标。

打断时要注意什么?

如果攻击动画被打断,比如角色被击飞、死亡、切换状态,必须强制关闭 HitBox。

否则会出现角色已经不在攻击了,但碰撞盒还开着,敌人走过来还被扣血。

所以技能系统或状态机退出攻击状态时,也要调用 CloseHitBox()

高速武器怎么办?

如果剑挥得很快,只靠 OnTriggerEnter 可能漏判。

因为一帧之间剑从 A 点跳到 B 点,中间穿过敌人,但物理系统没采到。

这时可以用:

上一帧武器位置到当前帧武器位置做 SphereCast

用多个采样点模拟武器轨迹。

用动画曲线或配置表生成扫掠检测。

面试高分回答

IMPORTANT

碰撞盒和动画同步时,我会把“位置同步”和“时间同步”分开处理。位置上,攻击 HitBox 通常挂在武器、手、脚等骨骼节点下,让它随着动画骨骼自然移动;时间上,HitBox 不会全程开启,而是在攻击动画的判定帧开启,在前摇和后摇关闭。开启方式可以用 Animation Event,也可以用技能配置里的 hitStartTime 和 hitEndTime,通过 normalizedTime 控制。命中时要做本次攻击去重,避免同一个目标多次扣血。攻击被打断、死亡或状态切换时必须强制关闭 HitBox。对于高速武器,还需要用 SphereCast 或轨迹扫掠避免漏判。

子弹系统怎么设计?

子弹系统怎么设计?

子弹系统不要只理解成“生成一个物体,然后往前飞”。完整的子弹系统应该包括:子弹配置、发射器、对象池、飞行逻辑、碰撞检测、命中结算、生命周期管理、特效回收。

bullet-system-design-csharp

核心模块

BulletData:配置子弹速度、伤害、寿命、半径、是否穿透、是否爆炸、命中 Layer。

BulletLauncher:负责发射子弹,传入起点、方向、攻击者、技能数据。

Bullet:运行时子弹,负责移动、检测命中、生命周期结束。

BulletPool:对象池,避免频繁 InstantiateDestroy

DamageSystem:命中后统一走伤害结算,不要让子弹自己乱扣血。

EffectSystem:负责飞行特效、命中特效、音效、拖尾清理。

常见子弹类型

实体弹:子弹真的在场景里飞,比如箭、火球、导弹。

瞬发弹:开火瞬间用 Raycast 判断命中,比如枪械、激光。

追踪弹:飞行中持续朝目标转向,比如跟踪导弹。

范围弹:命中后在落点做范围检测,比如爆炸弹、火焰弹。

穿透弹:命中多个目标,但要做命中去重。

Unity C# 简化示例:实体子弹

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Vector3、Physics 等类型
using System.Collections.Generic; // 引入集合命名空间,用来使用 HashSet
public interface IDamageable // 定义可受伤接口
{ // 接口开始
    void TakeDamage(int damage); // 定义受伤函数
} // 接口结束
[CreateAssetMenu(menuName = "Interview/Bullet Data")] // 允许在 Unity 菜单里创建子弹配置资源
public class BulletData : ScriptableObject // 定义子弹配置类
{ // BulletData 类开始
    public int damage = 10; // 保存子弹伤害
    public float speed = 20f; // 保存子弹飞行速度
    public float lifeTime = 3f; // 保存子弹最大存活时间
    public float radius = 0.15f; // 保存子弹检测半径
    public bool canPierce = false; // 保存子弹是否可以穿透多个目标
    public LayerMask hitLayer; // 保存子弹可以命中的 Layer
} // BulletData 类结束
public class Bullet : MonoBehaviour // 定义运行时子弹组件
{ // Bullet 类开始
    public BulletData data; // 保存子弹配置
    private Vector3 direction; // 保存子弹飞行方向
    private Vector3 lastPosition; // 保存上一帧位置,用来做扫掠检测
    private float lifeTimer; // 保存子弹已经存活的时间
    private bool isActive; // 保存子弹是否处于激活状态
    private HashSet<IDamageable> hitTargets = new HashSet<IDamageable>(); // 保存已经命中过的目标,避免重复伤害
    public void Init(BulletData data, Vector3 position, Vector3 direction) // 定义初始化子弹函数
    { // Init 函数开始
        this.data = data; // 保存子弹配置
        transform.position = position; // 设置子弹初始位置
        this.direction = direction.normalized; // 保存标准化后的飞行方向
        lastPosition = position; // 初始化上一帧位置
        lifeTimer = 0f; // 重置存活计时器
        isActive = true; // 标记子弹进入激活状态
        hitTargets.Clear(); // 清空已经命中过的目标记录
        gameObject.SetActive(true); // 显示并启用子弹对象
    } // Init 函数结束
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        if (isActive == false) // 判断子弹是否没有激活
        { // if 语句开始
            return; // 没激活就不更新
        } // if 语句结束
        lifeTimer += Time.deltaTime; // 累加子弹存活时间
        if (lifeTimer >= data.lifeTime) // 判断子弹是否超过最大寿命
        { // if 语句开始
            Recycle(); // 超时后回收子弹
            return; // 回收后直接结束本帧逻辑
        } // if 语句结束
        Vector3 currentPosition = transform.position; // 记录当前帧开始时的位置
        Vector3 nextPosition = currentPosition + direction * data.speed * Time.deltaTime; // 计算下一帧子弹应该到达的位置
        CheckHit(currentPosition, nextPosition); // 用当前位置到下一位置做扫掠检测,避免高速穿透
        transform.position = nextPosition; // 更新子弹位置
        lastPosition = nextPosition; // 保存上一帧位置
    } // Update 函数结束
    private void CheckHit(Vector3 from, Vector3 to) // 定义子弹命中检测函数
    { // CheckHit 函数开始
        Vector3 move = to - from; // 计算本帧移动向量
        float distance = move.magnitude; // 计算本帧移动距离
        if (distance <= 0f) // 判断子弹是否几乎没有移动
        { // if 语句开始
            return; // 没移动就不检测
        } // if 语句结束
        Ray ray = new Ray(from, move.normalized); // 创建从旧位置指向新位置的射线
        RaycastHit[] hits = Physics.SphereCastAll(ray, data.radius, distance, data.hitLayer); // 使用球形扫掠检测本帧路径上的目标
        foreach (RaycastHit hit in hits) // 遍历所有命中的目标
        { // foreach 循环开始
            IDamageable damageable = hit.collider.GetComponentInParent<IDamageable>(); // 尝试获取目标身上的受伤接口
            if (damageable == null) // 判断目标是否不能受伤
            { // if 语句开始
                continue; // 不能受伤就跳过
            } // if 语句结束
            if (hitTargets.Contains(damageable)) // 判断这个目标是否已经被当前子弹命中过
            { // if 语句开始
                continue; // 已经命中过就避免重复伤害
            } // if 语句结束
            hitTargets.Add(damageable); // 记录这个目标已经被命中
            damageable.TakeDamage(data.damage); // 对目标造成伤害
            SpawnHitEffect(hit.point); // 在命中点播放命中特效
            if (data.canPierce == false) // 判断子弹是否不能穿透
            { // if 语句开始
                Recycle(); // 非穿透子弹命中后回收
                return; // 回收后结束检测
            } // if 语句结束
        } // foreach 循环结束
    } // CheckHit 函数结束
    private void SpawnHitEffect(Vector3 position) // 定义生成命中特效函数
    { // SpawnHitEffect 函数开始
        Debug.Log("Bullet hit at " + position); // 示例中用日志代替真实命中特效
    } // SpawnHitEffect 函数结束
    private void Recycle() // 定义回收子弹函数
    { // Recycle 函数开始
        isActive = false; // 标记子弹不再激活
        gameObject.SetActive(false); // 隐藏并禁用子弹对象,真实项目中会交还对象池
    } // Recycle 函数结束
} // Bullet 类结束

为什么高速子弹不要只靠 Trigger?

因为子弹速度很快时,一帧可能从敌人前面飞到敌人后面,中间穿过去了,但物理系统没有采到碰撞。

这叫穿透问题。

所以高速子弹常用:

Raycast:适合瞬发枪线。

SphereCast:适合有半径的子弹。

上一帧位置到当前帧位置的扫掠检测:适合快速实体弹。

为什么要用对象池?

如果每发子弹都 Instantiate,命中后 Destroy,大量子弹会产生性能开销和 GC 压力。

对象池做法是:

提前创建一批子弹。

发射时从池里取。

命中或超时后隐藏并放回池子。

下次继续复用。

面试高分回答

NOTE

我会把子弹系统拆成配置、发射、飞行、检测、结算和回收几个模块。配置里保存速度、伤害、寿命、半径、穿透、爆炸范围和目标 Layer。发射器从对象池取出子弹并初始化位置、方向和攻击者。子弹运行时根据类型做直线、抛物线、追踪或散射移动。碰撞检测上,慢速子弹可以用 Trigger,高速子弹要用上一帧位置到当前帧位置的 Raycast 或 SphereCast 防止穿透。命中后不直接乱扣血,而是生成命中信息交给伤害系统结算,并播放命中特效。子弹超时、命中或穿透次数耗尽后回收到对象池。联网游戏中,客户端可以做表现和预测,但最终命中和伤害最好由服务端权威判断。

目标锁定怎么做?

目标锁定怎么做?

目标锁定的核心不是“找最近的敌人”这么简单,而是:先找一批候选目标,再根据距离、视野角、屏幕中心、遮挡、目标状态打分,最后选出最合适的目标。

target-lock-on-system-csharp

基本流程

先用 Physics.OverlapSphere 找玩家周围一定范围内的敌人。

然后过滤掉不合法目标,比如死亡目标、友方目标、太远目标、在相机背后的目标。

再给每个候选目标算分。通常距离越近分数越高,越靠近屏幕中心分数越高,没有被墙挡住分数更高。

最后选择分数最高的目标作为锁定目标。

锁定后每帧维护目标,如果目标死亡、离开范围、被墙完全挡住、玩家主动取消,就解除锁定。

Unity C# 示例:第三人称目标锁定

c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Camera、Physics 等 Unity 类型
public class LockOnTarget : MonoBehaviour // 定义一个可以被锁定的目标组件
{ // LockOnTarget 类开始
    public bool isDead = false; // 保存目标是否死亡
    public Transform aimPoint; // 保存锁定瞄准点,比如胸口或头部
} // LockOnTarget 类结束
public class TargetLockSystem : MonoBehaviour // 定义目标锁定系统组件
{ // TargetLockSystem 类开始
    public Camera mainCamera; // 保存主相机引用
    public Transform player; // 保存玩家 Transform
    public float lockRadius = 12f; // 保存搜索目标的半径
    public float maxViewAngle = 70f; // 保存最大锁定视野角
    public LayerMask targetLayer; // 保存敌人所在 Layer
    public LayerMask obstacleLayer; // 保存遮挡物所在 Layer
    public LockOnTarget currentTarget; // 保存当前锁定目标
    private void Update() // Unity 每帧调用 Update
    { // Update 函数开始
        if (Input.GetKeyDown(KeyCode.Tab)) // 判断玩家是否按下 Tab 键
        { // if 语句开始
            ToggleLock(); // 切换锁定状态
        } // if 语句结束
        if (currentTarget != null) // 判断当前是否有锁定目标
        { // if 语句开始
            ValidateCurrentTarget(); // 每帧检查当前目标是否仍然有效
        } // if 语句结束
    } // Update 函数结束
    private void ToggleLock() // 定义切换锁定函数
    { // ToggleLock 函数开始
        if (currentTarget != null) // 判断当前是否已经锁定了目标
        { // if 语句开始
            currentTarget = null; // 取消当前锁定目标
            return; // 取消后直接返回
        } // if 语句结束
        currentTarget = FindBestTarget(); // 查找最合适的目标并锁定
    } // ToggleLock 函数结束
    private LockOnTarget FindBestTarget() // 定义查找最佳目标函数
    { // FindBestTarget 函数开始
        Collider[] colliders = Physics.OverlapSphere(player.position, lockRadius, targetLayer); // 搜索玩家周围半径内的目标碰撞体
        LockOnTarget bestTarget = null; // 保存当前分数最高的目标
        float bestScore = float.MinValue; // 保存当前最高分数
        foreach (Collider collider in colliders) // 遍历所有候选碰撞体
        { // foreach 循环开始
            LockOnTarget target = collider.GetComponentInParent<LockOnTarget>(); // 从候选物体身上获取可锁定目标组件
            if (target == null) // 判断目标组件是否不存在
            { // if 语句开始
                continue; // 没有目标组件就跳过
            } // if 语句结束
            if (target.isDead) // 判断目标是否已经死亡
            { // if 语句开始
                continue; // 死亡目标不能锁定
            } // if 语句结束
            if (IsInViewAngle(target) == false) // 判断目标是否不在锁定视野角内
            { // if 语句开始
                continue; // 不在视野角内就跳过
            } // if 语句结束
            if (HasLineOfSight(target) == false) // 判断目标是否被墙体遮挡
            { // if 语句开始
                continue; // 被遮挡就跳过
            } // if 语句结束
            float score = CalculateScore(target); // 计算这个目标的锁定分数
            if (score > bestScore) // 判断这个目标是否比当前最佳目标更合适
            { // if 语句开始
                bestScore = score; // 更新最高分数
                bestTarget = target; // 更新最佳目标
            } // if 语句结束
        } // foreach 循环结束
        return bestTarget; // 返回最终选中的最佳目标
    } // FindBestTarget 函数结束
    private bool IsInViewAngle(LockOnTarget target) // 定义视野角检测函数
    { // IsInViewAngle 函数开始
        Vector3 toTarget = GetAimPoint(target) - mainCamera.transform.position; // 计算相机到目标的方向向量
        float angle = Vector3.Angle(mainCamera.transform.forward, toTarget); // 计算目标和相机前方之间的夹角
        return angle <= maxViewAngle; // 如果夹角小于最大视野角,就认为目标可锁定
    } // IsInViewAngle 函数结束
    private bool HasLineOfSight(LockOnTarget target) // 定义遮挡检测函数
    { // HasLineOfSight 函数开始
        Vector3 start = mainCamera.transform.position; // 设置射线起点为相机位置
        Vector3 end = GetAimPoint(target); // 设置射线终点为目标瞄准点
        Vector3 direction = end - start; // 计算射线方向
        float distance = direction.magnitude; // 计算相机到目标的距离
        bool blocked = Physics.Raycast(start, direction.normalized, distance, obstacleLayer); // 检测相机和目标之间是否有遮挡
        return blocked == false; // 没有遮挡才返回 true
    } // HasLineOfSight 函数结束
    private float CalculateScore(LockOnTarget target) // 定义目标评分函数
    { // CalculateScore 函数开始
        Vector3 aimPoint = GetAimPoint(target); // 获取目标瞄准点
        float distance = Vector3.Distance(player.position, aimPoint); // 计算玩家到目标的距离
        Vector3 viewportPoint = mainCamera.WorldToViewportPoint(aimPoint); // 把目标世界坐标转换到屏幕视口坐标
        Vector2 screenOffset = new Vector2(viewportPoint.x - 0.5f, viewportPoint.y - 0.5f); // 计算目标距离屏幕中心的偏移
        float centerScore = 1f - Mathf.Clamp01(screenOffset.magnitude); // 计算屏幕中心分数,越靠中心分数越高
        float distanceScore = 1f - Mathf.Clamp01(distance / lockRadius); // 计算距离分数,越近分数越高
        return centerScore * 0.7f + distanceScore * 0.3f; // 综合屏幕中心和距离得到最终分数
    } // CalculateScore 函数结束
    private void ValidateCurrentTarget() // 定义当前目标有效性检查函数
    { // ValidateCurrentTarget 函数开始
        if (currentTarget.isDead) // 判断当前目标是否已经死亡
        { // if 语句开始
            currentTarget = null; // 死亡就解除锁定
            return; // 解除后直接返回
        } // if 语句结束
        float distance = Vector3.Distance(player.position, GetAimPoint(currentTarget)); // 计算玩家到当前目标的距离
        if (distance > lockRadius) // 判断目标是否超过锁定范围
        { // if 语句开始
            currentTarget = null; // 超出范围就解除锁定
            return; // 解除后直接返回
        } // if 语句结束
        if (HasLineOfSight(currentTarget) == false) // 判断目标是否被遮挡
        { // if 语句开始
            currentTarget = null; // 被遮挡就解除锁定
        } // if 语句结束
    } // ValidateCurrentTarget 函数结束
    private Vector3 GetAimPoint(LockOnTarget target) // 定义获取目标瞄准点函数
    { // GetAimPoint 函数开始
        if (target.aimPoint != null) // 判断目标是否配置了瞄准点
        { // if 语句开始
            return target.aimPoint.position; // 返回配置的瞄准点位置
        } // if 语句结束
        return target.transform.position; // 没有瞄准点就返回目标自身位置
    } // GetAimPoint 函数结束
} // TargetLockSystem 类结束

目标切换怎么做?

锁定后,玩家可能想切换到左边或右边的敌人。

常见做法是:

把所有候选目标转成屏幕坐标。

如果玩家向右切目标,就找屏幕中心右侧、距离当前目标最近的候选。

如果玩家向左切目标,就找屏幕中心左侧、距离当前目标最近的候选。

手柄上一般用右摇杆左右拨动切换目标。

相机和角色怎么配合?

锁定目标后,角色朝向可以逐渐转向目标。

相机可以让目标保持在画面中间附近。

移动方式可以变成围绕目标移动,比如左摇杆横向输入时角色绕目标走。

但相机不要太硬,否则玩家会晕。通常用平滑插值,让镜头慢慢跟过去。

常见坑

只按最近距离锁定,会锁到玩家没看的敌人。

不做遮挡检测,会锁到墙后面的敌人。

目标死亡后不清理,会一直锁着尸体。

多个敌人重叠时不做评分,会频繁跳目标。

锁定 UI 和实际目标不同步,会让玩家困惑。

联网游戏中,客户端选目标可以,但技能命中和伤害仍然要服务端校验。

面试高分回答

TIP

目标锁定系统一般分为候选搜索、目标筛选、目标评分和锁定维护。候选搜索可以用 OverlapSphere 找到玩家周围敌人;筛选阶段排除死亡、友方、超距离、相机背后或被遮挡的目标;评分阶段综合距离、屏幕中心偏移、视野角和优先级,选出分数最高的目标。锁定后需要持续检查目标是否死亡、离开范围、被遮挡或玩家主动取消,同时支持左右切换目标。表现上要有锁定圈、血条、高亮或相机辅助。联网游戏中,客户端可以做锁定选择和表现,但服务端仍然要校验目标是否合法。

战斗日志有什么用?

战斗日志有什么用?

战斗日志就是把战斗中发生的关键事件按时间记录下来。它不是只给玩家看的“你打了 100 点伤害”,更重要的是给开发、测试、服务器、安全、数据分析用。

battle-log-purpose-csharp

它能解决什么问题?

调试问题:为什么这次攻击没扣血?为什么暴击了?为什么 Buff 没生效?

复盘战斗:一场战斗发生了哪些技能、伤害、治疗、控制、死亡。

反作弊:检查客户端有没有伪造伤害、冷却、命中结果。

客服追查:玩家说“我明明赢了为什么输了”,可以查服务端权威日志。

数据分析:统计技能命中率、职业强度、Boss 死亡原因、玩家常死在哪个阶段。

战斗回放:如果日志记录得足够完整,可以按事件顺序还原战斗过程。

一条战斗日志通常记录什么?

时间:第几帧、时间戳、战斗开始后的第几秒。

来源:谁造成的事件,比如攻击者、施法者、Buff 来源。

目标:谁受到了影响,比如被攻击者、被治疗者。

事件类型:普通攻击、技能释放、伤害、治疗、Buff 添加、Buff 移除、死亡。

关键数值:伤害、治疗量、暴击、护甲减免、最终血量。

上下文:技能 ID、Buff ID、随机种子、位置、服务器帧号。

C# 示例:简单战斗日志系统

c
using System; // 引入 System 命名空间,用来使用 DateTime
using System.Collections.Generic; // 引入集合命名空间,用来使用 List
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour 和 Debug
public enum CombatLogType // 定义战斗日志类型
{ // 枚举开始
    SkillCast, // 表示技能释放事件
    Damage, // 表示伤害事件
    Heal, // 表示治疗事件
    BuffAdd, // 表示 Buff 添加事件
    BuffRemove, // 表示 Buff 移除事件
    Death // 表示死亡事件
} // 枚举结束
[Serializable] // 标记这个类可以被序列化
public class CombatLogEntry // 定义一条战斗日志
{ // 类开始
    public float battleTime; // 保存战斗开始后的时间
    public int frame; // 保存当前帧号
    public CombatLogType type; // 保存日志类型
    public string sourceId; // 保存事件来源者 ID
    public string targetId; // 保存事件目标 ID
    public string skillId; // 保存技能 ID
    public string buffId; // 保存 Buff ID
    public int value; // 保存伤害、治疗或其他数值
    public int targetHpAfter; // 保存事件发生后目标剩余血量
    public string message; // 保存方便人阅读的描述
} // 类结束
public class CombatLogger : MonoBehaviour // 定义战斗日志管理器
{ // 类开始
    private List<CombatLogEntry> logs = new List<CombatLogEntry>(); // 保存当前战斗的所有日志
    private float battleStartTime; // 保存战斗开始时间
    private void Awake() // Unity 在对象初始化时调用 Awake
    { // Awake 函数开始
        battleStartTime = Time.time; // 记录战斗开始时间
    } // Awake 函数结束
    public void LogDamage(string sourceId, string targetId, string skillId, int damage, int targetHpAfter) // 定义记录伤害日志函数
    { // 函数开始
        CombatLogEntry entry = new CombatLogEntry(); // 创建一条新的战斗日志
        entry.battleTime = Time.time - battleStartTime; // 记录战斗经过时间
        entry.frame = Time.frameCount; // 记录当前帧号
        entry.type = CombatLogType.Damage; // 设置日志类型为伤害
        entry.sourceId = sourceId; // 记录攻击者 ID
        entry.targetId = targetId; // 记录受击者 ID
        entry.skillId = skillId; // 记录技能 ID
        entry.buffId = string.Empty; // 伤害日志没有 Buff ID 就保存空字符串
        entry.value = damage; // 记录本次伤害值
        entry.targetHpAfter = targetHpAfter; // 记录目标受击后的血量
        entry.message = sourceId + " used " + skillId + " dealt " + damage + " to " + targetId; // 生成一条人能看懂的描述
        logs.Add(entry); // 把日志加入列表
        Debug.Log(entry.message); // 输出日志,方便开发阶段查看
    } // 函数结束
    public IReadOnlyList<CombatLogEntry> GetLogs() // 定义获取日志列表函数
    { // 函数开始
        return logs; // 返回只读日志列表,避免外部随意修改
    } // 函数结束
} // 类结束

客户端日志和服务端日志区别

客户端日志:适合查表现问题,比如动画有没有播、特效有没有出现、输入有没有触发。

服务端日志:适合查最终战斗结果,比如有没有命中、伤害怎么算、血量为什么变成这样。

联网游戏里,最终证据应该看服务端日志。客户端日志只能辅助,因为客户端不可信。

常见设计注意点

不要每帧无脑记录所有数据,日志会非常大。

关键事件必须记,比如释放技能、命中、伤害、Buff、死亡。

日志最好能关联战斗 ID、房间 ID、玩家 ID、服务器帧号。

正式服日志要分级,比如 Debug、Info、Warning、Error。

敏感日志要能上传服务器,方便线上排查。

战斗日志不应该影响战斗性能,最好异步写入或缓冲后批量处理。

面试高分回答

NOTE

战斗日志的作用是记录战斗中的关键事件,方便问题复现、战斗复盘、反作弊、数据分析和客服追查。它通常会记录时间、帧号、攻击者、目标、技能 ID、Buff ID、伤害值、目标剩余血量、暴击、减免和死亡等信息。客户端日志主要用于排查表现和输入问题,服务端日志用于记录权威战斗结算。设计时要注意日志粒度,关键事件必须记录,但不能每帧全量记录,否则会带来性能和存储压力。一个好的战斗日志应该能回答“这次伤害为什么发生、怎么算出来、最终造成了什么结果”。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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