Appearance
战斗系统
一次攻击流程怎么设计?
一次攻击流程怎么设计?
一次攻击不是“按下攻击键就立刻扣血”,而是一条完整链路:输入 → 条件检查 → 进入攻击状态 → 播放动画 → 前摇 → 判定帧 → 命中检测 → 伤害结算 → 反馈表现 → 后摇恢复。
核心流程
玩家按攻击键后,先判断能不能攻击,比如是否死亡、是否受击硬直、是否在冷却、是否已经攻击中。
可以攻击后,角色进入攻击状态,通常会限制移动、限制转向,播放攻击动画。
动画前半段叫前摇,表示角色正在起手,这时一般还不能打到敌人。
中间很短的一段叫判定帧,也叫命中窗口,只有这段时间才开启攻击检测。
检测到敌人后,进行伤害、击退、硬直、音效、特效、屏震、飘字等结算。
最后进入后摇,角色收招。后摇结束后,回到 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
一次攻击流程通常会拆成输入层、状态层、动画层、判定层和结算层。玩家输入攻击后,先检查角色状态和冷却,满足条件后进入攻击状态并播放动画。攻击动画会分为前摇、判定帧和后摇,只有判定帧期间才开启碰撞、射线或范围检测。命中目标后进行去重、伤害、击退、硬直、特效、音效等结算。后摇结束后角色回到移动或待机状态,也可能根据连击窗口进入下一段攻击。这样设计的好处是攻击手感可调、逻辑清晰,也方便扩展连击、取消、霸体、受击打断和技能配置。
技能释放流程怎么设计?
技能释放流程怎么设计?
技能释放不是“按键就扣血”,而是一套完整流程:输入 → 读配置 → 条件校验 → 进入施法状态 → 播放动画 / 特效 → 生效点 → 命中检测 → 效果结算 → 后摇 / 冷却 / 恢复。
完整流程
玩家按技能键后,系统先找到对应技能配置,比如伤害、蓝耗、冷却、范围、施法时间、特效、动画名。
然后做条件校验:角色是否死亡、是否被控制、蓝量够不够、技能是否冷却、目标是否存在、距离是否够、方向是否正确。
校验通过后,进入技能释放状态。这个阶段可能锁定移动、限制转向、播放动画、显示读条、开始前摇。
到达生效点时,技能真正产生效果。比如近战范围检测、发射子弹、生成地面法阵、给自己加 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 点。
正确分工
客户端负责:
播放攻击动画。
播放技能特效。
显示伤害飘字。
做本地预测,让手感更顺。
显示技能范围、预计伤害、目标提示。
服务端负责:
校验技能是否冷却。
校验蓝量是否足够。
校验攻击者是否能攻击。
校验目标是否存在、是否死亡、是否在范围内。
计算最终伤害。
处理暴击、护甲、减伤、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 的系统。
核心结构
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 的添加、移除和伤害结算应该由服务端权威处理,客户端只负责图标、倒计时、层数和特效表现。
技能打断和前后摇怎么处理?
技能打断和前后摇怎么处理?
技能要拆成三个核心阶段:前摇、生效段、后摇。打断和取消不能随便发生,要看当前处于哪个阶段,以及这个阶段有没有开放对应规则。
前摇怎么处理?
前摇是技能起手阶段,比如角色抬手、蓄力、挥刀前半段。
这个阶段通常还没有造成伤害,所以如果被眩晕、击飞、死亡打断,技能一般不生效。
比如法师读条 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 的时间点,一旦生效就要明确结果是否保留,比如弹道技能发射后一般不会因为角色后续被打断而消失。后摇阶段技能已经生效,但角色还处于收招限制中,可以根据手感设计翻滚取消、连击取消或格挡取消窗口。实现上我会用状态机和技能配置控制每个阶段的时长、可打断性、可取消性和霸体规则,避免把逻辑全部硬绑在动画事件里。
受击、击退、硬直怎么设计?
受击、击退、硬直怎么设计?
这三个东西不要混在一起理解:
受击:角色收到了命中事件,比如扣血、播放受击动画、飘字、音效、特效。
击退:角色因为这次命中产生位移,比如被往后推、被击飞、被拉拽。
硬直:角色在一段时间内失去部分控制,比如不能移动、不能攻击、不能放技能。
核心设计思路
一次命中最好用一个 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 或轨迹扫掠避免漏判。
子弹系统怎么设计?
子弹系统怎么设计?
子弹系统不要只理解成“生成一个物体,然后往前飞”。完整的子弹系统应该包括:子弹配置、发射器、对象池、飞行逻辑、碰撞检测、命中结算、生命周期管理、特效回收。
核心模块
BulletData:配置子弹速度、伤害、寿命、半径、是否穿透、是否爆炸、命中 Layer。
BulletLauncher:负责发射子弹,传入起点、方向、攻击者、技能数据。
Bullet:运行时子弹,负责移动、检测命中、生命周期结束。
BulletPool:对象池,避免频繁 Instantiate 和 Destroy。
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 防止穿透。命中后不直接乱扣血,而是生成命中信息交给伤害系统结算,并播放命中特效。子弹超时、命中或穿透次数耗尽后回收到对象池。联网游戏中,客户端可以做表现和预测,但最终命中和伤害最好由服务端权威判断。
目标锁定怎么做?
目标锁定怎么做?
目标锁定的核心不是“找最近的敌人”这么简单,而是:先找一批候选目标,再根据距离、视野角、屏幕中心、遮挡、目标状态打分,最后选出最合适的目标。
基本流程
先用 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 点伤害”,更重要的是给开发、测试、服务器、安全、数据分析用。
它能解决什么问题?
调试问题:为什么这次攻击没扣血?为什么暴击了?为什么 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、伤害值、目标剩余血量、暴击、减免和死亡等信息。客户端日志主要用于排查表现和输入问题,服务端日志用于记录权威战斗结算。设计时要注意日志粒度,关键事件必须记录,但不能每帧全量记录,否则会带来性能和存储压力。一个好的战斗日志应该能回答“这次伤害为什么发生、怎么算出来、最终造成了什么结果”。