Appearance
角色与 AI
角色状态机怎么设计?
角色状态机怎么设计?
角色状态机就是:把角色当前行为拆成几个明确状态,比如 Idle、Move、Attack、HitStun、Dead,并且同一时刻只允许角色处于一个主状态。
为什么要用状态机?
如果不用状态机,代码很容易变成:
角色正在攻击时还能移动。
角色死亡后还能放技能。
角色受击硬直时还能跳跃。
攻击碰撞盒忘记关闭。
动画、输入、移动、技能逻辑互相乱改。
状态机的作用就是把规则收拢:当前是什么状态,就只执行这个状态允许的逻辑。
常见角色状态
Idle:待机,没有移动输入。
Move:移动,有方向输入。
Attack:攻击中,通常限制移动或转向。
HitStun:受击硬直,不能移动、不能攻击。
Dead:死亡,最高优先级,不能再切回普通状态。
Unity C# 简化示例
c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Animator、Input 等类型
public enum CharacterState // 定义角色主状态枚举
{ // 枚举开始
Idle, // 待机状态
Move, // 移动状态
Attack, // 攻击状态
HitStun, // 受击硬直状态
Dead // 死亡状态
} // 枚举结束
public class CharacterStateMachineExample : MonoBehaviour // 定义角色状态机示例组件
{ // 类开始
public Animator animator; // 保存动画控制器引用
public float moveSpeed = 5f; // 保存角色移动速度
public int hp = 100; // 保存角色当前血量
private CharacterState currentState = CharacterState.Idle; // 保存当前角色状态
private float stateTimer = 0f; // 保存当前状态已经持续的时间
private void Update() // Unity 每帧调用 Update
{ // Update 函数开始
if (hp <= 0 && currentState != CharacterState.Dead) // 判断角色是否应该进入死亡状态
{ // if 语句开始
ChangeState(CharacterState.Dead); // 切换到死亡状态
return; // 死亡优先级最高,本帧后续逻辑不再执行
} // if 语句结束
stateTimer += Time.deltaTime; // 累加当前状态持续时间
UpdateCurrentState(); // 根据当前状态执行对应逻辑
} // Update 函数结束
private void UpdateCurrentState() // 定义当前状态更新函数
{ // 函数开始
switch (currentState) // 根据当前状态选择对应逻辑
{ // switch 开始
case CharacterState.Idle: // 当前是待机状态
UpdateIdle(); // 执行待机状态逻辑
break; // 结束待机分支
case CharacterState.Move: // 当前是移动状态
UpdateMove(); // 执行移动状态逻辑
break; // 结束移动分支
case CharacterState.Attack: // 当前是攻击状态
UpdateAttack(); // 执行攻击状态逻辑
break; // 结束攻击分支
case CharacterState.HitStun: // 当前是受击硬直状态
UpdateHitStun(); // 执行受击硬直逻辑
break; // 结束受击分支
case CharacterState.Dead: // 当前是死亡状态
UpdateDead(); // 执行死亡状态逻辑
break; // 结束死亡分支
} // switch 结束
} // 函数结束
private void UpdateIdle() // 定义待机状态逻辑
{ // 函数开始
Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 读取玩家移动输入
if (Input.GetMouseButtonDown(0)) // 判断玩家是否按下攻击键
{ // if 语句开始
ChangeState(CharacterState.Attack); // 从待机切换到攻击状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
if (input.sqrMagnitude > 0.01f) // 判断是否有移动输入
{ // if 语句开始
ChangeState(CharacterState.Move); // 从待机切换到移动状态
} // if 语句结束
} // 函数结束
private void UpdateMove() // 定义移动状态逻辑
{ // 函数开始
Vector3 input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); // 读取移动方向
if (Input.GetMouseButtonDown(0)) // 判断玩家是否按下攻击键
{ // if 语句开始
ChangeState(CharacterState.Attack); // 从移动切换到攻击状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
if (input.sqrMagnitude <= 0.01f) // 判断移动输入是否已经停止
{ // if 语句开始
ChangeState(CharacterState.Idle); // 从移动切回待机状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
transform.position += input.normalized * moveSpeed * Time.deltaTime; // 按输入方向移动角色
} // 函数结束
private void UpdateAttack() // 定义攻击状态逻辑
{ // 函数开始
if (stateTimer >= 0.6f) // 判断攻击动画或攻击流程是否结束
{ // if 语句开始
ChangeState(CharacterState.Idle); // 攻击结束后回到待机状态
} // if 语句结束
} // 函数结束
private void UpdateHitStun() // 定义受击硬直状态逻辑
{ // 函数开始
if (stateTimer >= 0.3f) // 判断硬直时间是否结束
{ // if 语句开始
ChangeState(CharacterState.Idle); // 硬直结束后回到待机状态
} // if 语句结束
} // 函数结束
private void UpdateDead() // 定义死亡状态逻辑
{ // 函数开始
} // 函数结束
public void ReceiveHit(int damage) // 定义受击函数
{ // 函数开始
if (currentState == CharacterState.Dead) // 判断角色是否已经死亡
{ // if 语句开始
return; // 死亡后不再处理普通受击
} // if 语句结束
hp -= damage; // 扣除血量
if (hp > 0) // 判断角色是否还活着
{ // if 语句开始
ChangeState(CharacterState.HitStun); // 没死就进入受击硬直状态
} // if 语句结束
} // 函数结束
private void ChangeState(CharacterState nextState) // 定义状态切换函数
{ // 函数开始
if (currentState == nextState) // 判断目标状态是否和当前状态一样
{ // if 语句开始
return; // 状态相同就不重复切换
} // if 语句结束
ExitState(currentState); // 先退出旧状态
currentState = nextState; // 设置当前状态为新状态
stateTimer = 0f; // 重置状态计时器
EnterState(currentState); // 再进入新状态
} // 函数结束
private void EnterState(CharacterState state) // 定义进入状态函数
{ // 函数开始
animator.SetInteger("State", (int)state); // 把当前状态同步给 Animator
} // 函数结束
private void ExitState(CharacterState state) // 定义退出状态函数
{ // 函数开始
} // 函数结束
} // 类结束面试里要讲的重点
Enter:进入状态时做一次,比如播放动画、打开受击锁、重置计时。
Update:状态持续时每帧执行,比如移动、攻击计时、硬直计时。
Exit:离开状态时清理,比如关闭攻击碰撞盒、解除移动锁、停止特效。
优先级怎么处理?
死亡最高:任何状态下血量为 0,都切 Dead。
受击较高:普通移动、攻击中如果被打断,可以切 HitStun。
攻击中通常不允许再次攻击,除非有连击窗口。
后摇中是否能翻滚取消,要看设计规则。
面试高分回答
NOTE
角色状态机是把角色行为拆成有限个互斥状态,比如待机、移动、攻击、受击、死亡。每个状态都有进入、更新、退出三个阶段,进入时播放动画或初始化数据,更新时处理该状态允许的输入和逻辑,退出时清理碰撞盒、特效和锁定标记。状态之间不能随便跳,必须通过明确条件切换,比如输入、动画结束、受击、死亡。简单项目可以用 enum + switch 实现,复杂项目可以把每个状态拆成独立类。设计时还要处理优先级,死亡最高,受击通常高于普通攻击,攻击和技能要限制移动或根据取消窗口切换。
FSM、行为树、GOAP 区别是什么?
FSM、行为树、GOAP 区别是什么?
一句话记忆:FSM 是“切状态”,行为树是“跑一棵决策树”,GOAP 是“根据目标自动做计划”。
FSM 是什么?
FSM,全名 Finite State Machine,有限状态机。
它的核心是:AI 当前处于某个状态,然后根据条件切换状态。
比如怪物有这些状态:
Patrol:巡逻。
Chase:追击玩家。
Attack:攻击玩家。
Dead:死亡。
FSM 很适合状态少、逻辑清楚的 AI。缺点是状态一多,状态之间的连线会变得很乱。
行为树是什么?
行为树 Behavior Tree 的核心是:每帧从根节点开始 Tick,根据节点返回成功、失败、运行中,决定执行哪个行为。
常见节点:
Selector:选择器,谁能成功就执行谁。
Sequence:顺序节点,必须一连串都成功。
Action:真正行为,比如移动、攻击、巡逻。
Condition:条件判断,比如是否看见玩家。
行为树比 FSM 更适合复杂 AI,因为行为可以组合复用,结构也更像策划能看懂的逻辑树。
GOAP 是什么?
GOAP,全名 Goal-Oriented Action Planning,目标导向行动规划。
它的核心是:AI 有目标,然后自己根据当前世界状态和可用动作,规划一串行动。
比如目标是“消灭玩家”。
当前状态:没有武器。
可用动作:
找武器:前置条件是知道武器位置,效果是有武器。
接近玩家:前置条件是知道玩家位置,效果是距离足够近。
攻击玩家:前置条件是有武器并且距离够近,效果是玩家受伤。
GOAP 会自动规划出:找武器 → 接近玩家 → 攻击玩家。
C# 小例子:三者思路对比
c
public enum EnemyState { Patrol, Chase, Attack } // 定义 FSM 的敌人状态
public class EnemyAiCompare // 定义一个 AI 对比示例类
{ // 类开始
private EnemyState state = EnemyState.Patrol; // 保存 FSM 当前状态
public void UpdateFsm(bool seePlayer, bool inAttackRange) // 定义 FSM 更新函数
{ // FSM 函数开始
if (state == EnemyState.Patrol && seePlayer) state = EnemyState.Chase; // 巡逻时看到玩家就切到追击
if (state == EnemyState.Chase && inAttackRange) state = EnemyState.Attack; // 追击时进入攻击范围就切到攻击
if (state == EnemyState.Attack && inAttackRange == false) state = EnemyState.Chase; // 攻击时玩家离开范围就切回追击
} // FSM 函数结束
public void UpdateBehaviorTree(bool inAttackRange, bool seePlayer) // 定义行为树思路的更新函数
{ // 行为树函数开始
if (inAttackRange) Attack(); // 选择器第一优先级:能攻击就攻击
else if (seePlayer) Chase(); // 选择器第二优先级:看见玩家就追击
else Patrol(); // 前面都不满足就巡逻
} // 行为树函数结束
public void UpdateGoap(bool hasWeapon, bool nearPlayer) // 定义 GOAP 思路的更新函数
{ // GOAP 函数开始
if (hasWeapon == false) FindWeapon(); // 为了达成攻击目标,没武器就先找武器
else if (nearPlayer == false) MoveToPlayer(); // 有武器但不够近,就先接近玩家
else Attack(); // 条件都满足后再攻击玩家
} // GOAP 函数结束
private void Patrol() { } // 定义巡逻行为
private void Chase() { } // 定义追击行为
private void Attack() { } // 定义攻击行为
private void FindWeapon() { } // 定义找武器行为
private void MoveToPlayer() { } // 定义接近玩家行为
} // 类结束怎么选择?
简单怪物、小机关、普通 Boss 阶段:用 FSM 就够。
复杂怪物、Boss 技能组合、NPC 日常行为:行为树更合适。
开放世界 NPC、模拟经营、目标很多且行动路径不固定:GOAP 更合适。
面试高分回答
NOTE
FSM、行为树和 GOAP 都是游戏 AI 决策方式。FSM 是有限状态机,用状态和状态切换表达 AI,优点是简单直观,缺点是状态多了容易爆炸。行为树把 AI 决策组织成树,每帧从根节点 Tick,通过 Selector、Sequence、Condition、Action 组合行为,扩展性和可读性比 FSM 更好,是游戏 AI 里非常常见的方案。GOAP 是目标导向规划,AI 不直接写死行为顺序,而是根据当前世界状态、目标和动作的前置条件与效果自动规划行动序列,灵活性最高,但实现和调试成本也最高。实际项目中,简单 AI 用 FSM,复杂行为用行为树,需要自主规划的 NPC 才考虑 GOAP。
怪物巡逻、追击、攻击怎么实现?
怪物巡逻、追击、攻击怎么实现?
最常见做法是用 FSM 状态机:怪物平时 Patrol 巡逻;发现玩家后切到 Chase 追击;进入攻击范围后切到 Attack 攻击;玩家死亡、跑远或丢失视野后回到巡逻。
核心逻辑
巡逻:怪物沿几个巡逻点移动,到点后等一下,再去下一个点。
追击:怪物发现玩家后,把玩家位置设置成寻路目标。
攻击:怪物进入攻击范围后停下来,面向玩家,按攻击冷却播放攻击动画。
丢失:玩家跑太远、死亡、被遮挡太久,怪物回到巡逻或出生点。
Unity C# 示例:NavMeshAgent 怪物 AI
c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour 和 Transform
using UnityEngine.AI; // 引入 UnityEngine.AI 命名空间,用来使用 NavMeshAgent
public enum MonsterState { Patrol, Chase, Attack } // 定义怪物状态,包含巡逻、追击、攻击
public class MonsterAI : MonoBehaviour // 定义怪物 AI 组件
{ // 类开始
public NavMeshAgent agent; // 保存寻路组件引用
public Animator animator; // 保存动画控制器引用
public Transform player; // 保存玩家 Transform 引用
public Transform[] patrolPoints; // 保存巡逻点数组
public float detectRange = 10f; // 保存发现玩家的距离
public float attackRange = 2f; // 保存攻击玩家的距离
public float loseRange = 15f; // 保存丢失玩家的距离
public float attackCooldown = 1.5f; // 保存攻击冷却时间
public float repathInterval = 0.2f; // 保存追击时重新寻路的间隔
private MonsterState state = MonsterState.Patrol; // 保存当前怪物状态
private int patrolIndex = 0; // 保存当前巡逻点下标
private float attackTimer = 0f; // 保存攻击冷却计时器
private float repathTimer = 0f; // 保存重新寻路计时器
private void Start() // Unity 在第一次 Update 前调用 Start
{ // Start 函数开始
ChangeState(MonsterState.Patrol); // 初始化进入巡逻状态
} // Start 函数结束
private void Update() // Unity 每帧调用 Update
{ // Update 函数开始
attackTimer -= Time.deltaTime; // 每帧减少攻击冷却计时
repathTimer -= Time.deltaTime; // 每帧减少重新寻路计时
if (state == MonsterState.Patrol) UpdatePatrol(); // 当前是巡逻状态就更新巡逻逻辑
if (state == MonsterState.Chase) UpdateChase(); // 当前是追击状态就更新追击逻辑
if (state == MonsterState.Attack) UpdateAttack(); // 当前是攻击状态就更新攻击逻辑
} // Update 函数结束
private void UpdatePatrol() // 定义巡逻状态逻辑
{ // 函数开始
if (CanSeePlayer()) // 判断怪物是否发现玩家
{ // if 语句开始
ChangeState(MonsterState.Chase); // 发现玩家就切换到追击状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
if (patrolPoints.Length == 0) // 判断是否没有配置巡逻点
{ // if 语句开始
return; // 没有巡逻点就不巡逻
} // if 语句结束
if (agent.remainingDistance <= agent.stoppingDistance && agent.pathPending == false) // 判断是否到达当前巡逻点
{ // if 语句开始
patrolIndex = (patrolIndex + 1) % patrolPoints.Length; // 切换到下一个巡逻点
agent.SetDestination(patrolPoints[patrolIndex].position); // 设置新的巡逻目标点
} // if 语句结束
} // 函数结束
private void UpdateChase() // 定义追击状态逻辑
{ // 函数开始
float distance = Vector3.Distance(transform.position, player.position); // 计算怪物和玩家之间的距离
if (distance <= attackRange) // 判断玩家是否进入攻击范围
{ // if 语句开始
ChangeState(MonsterState.Attack); // 进入攻击范围就切换到攻击状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
if (distance > loseRange) // 判断玩家是否跑出丢失范围
{ // if 语句开始
ChangeState(MonsterState.Patrol); // 玩家太远就回到巡逻状态
return; // 切换状态后结束当前逻辑
} // if 语句结束
if (repathTimer <= 0f) // 判断是否到了重新寻路时间
{ // if 语句开始
agent.SetDestination(player.position); // 设置玩家当前位置为追击目标
repathTimer = repathInterval; // 重置重新寻路计时器
} // if 语句结束
} // 函数结束
private void UpdateAttack() // 定义攻击状态逻辑
{ // 函数开始
float distance = Vector3.Distance(transform.position, player.position); // 计算怪物和玩家之间的距离
if (distance > attackRange) // 判断玩家是否离开攻击范围
{ // if 语句开始
ChangeState(MonsterState.Chase); // 离开攻击范围就继续追击
return; // 切换状态后结束当前逻辑
} // if 语句结束
FacePlayer(); // 攻击时让怪物面向玩家
if (attackTimer <= 0f) // 判断攻击冷却是否结束
{ // if 语句开始
animator.SetTrigger("Attack"); // 触发攻击动画
attackTimer = attackCooldown; // 重置攻击冷却
} // if 语句结束
} // 函数结束
private bool CanSeePlayer() // 定义发现玩家函数
{ // 函数开始
if (player == null) // 判断玩家引用是否为空
{ // if 语句开始
return false; // 没有玩家就不能发现
} // if 语句结束
float distance = Vector3.Distance(transform.position, player.position); // 计算怪物和玩家距离
return distance <= detectRange; // 距离小于发现范围就认为发现玩家
} // 函数结束
private void ChangeState(MonsterState nextState) // 定义切换状态函数
{ // 函数开始
state = nextState; // 设置当前状态为新状态
animator.SetInteger("State", (int)state); // 把状态同步给 Animator
if (state == MonsterState.Patrol && patrolPoints.Length > 0) agent.SetDestination(patrolPoints[patrolIndex].position); // 进入巡逻时设置巡逻点
if (state == MonsterState.Chase) repathTimer = 0f; // 进入追击时立即允许重新寻路
if (state == MonsterState.Attack) agent.ResetPath(); // 进入攻击时停止移动
} // 函数结束
private void FacePlayer() // 定义面向玩家函数
{ // 函数开始
Vector3 direction = player.position - transform.position; // 计算怪物指向玩家的方向
direction.y = 0f; // 去掉垂直方向,避免怪物上下抬头
if (direction.sqrMagnitude <= 0.001f) return; // 距离太近就不旋转
transform.rotation = Quaternion.LookRotation(direction); // 让怪物朝向玩家
} // 函数结束
} // 类结束为什么 loseRange 要比 detectRange 大?
如果发现距离是 10,丢失距离也是 10,玩家刚好在 10 米边缘来回移动,怪物会一会追、一会巡逻,状态疯狂抖动。
所以通常设计成:
c
detectRange = 10
loseRange = 15这样怪物发现玩家后,不会因为玩家稍微退一点就立刻放弃。
性能注意
不要让每只怪物每帧都做复杂视野检测和寻路。
可以每 0.2s 或 0.5s 检测一次玩家。
追击时也不要每帧 SetDestination,可以隔一小段时间更新路径。
大量怪物可以分帧更新 AI,避免同一帧所有怪物一起算。
面试高分回答
IMPORTANT
怪物巡逻、追击、攻击一般用 FSM 实现。巡逻状态下怪物沿巡逻点移动,同时定期检测玩家是否进入发现范围;发现玩家后切换到追击状态,用 NavMeshAgent 设置玩家位置为目标;进入攻击距离后切换到攻击状态,停止寻路、面向玩家,并按攻击冷却播放攻击动画和判定;如果玩家离开攻击范围就回到追击,如果玩家死亡、距离过远或丢失视野,就回到巡逻或出生点。实现时要注意状态切换条件清晰,发现距离和丢失距离要分开,寻路和检测要降频,大量怪物时可以分帧更新。
AI 感知系统怎么设计?
AI 感知系统怎么设计?
AI 感知系统就是让怪物“知道世界发生了什么”。比如它能不能看见玩家、有没有听到声音、有没有被攻击、是否记得玩家最后出现的位置。
感知系统不直接决定“打不打”,它负责收集信息;真正的行为选择交给 FSM、行为树或 GOAP。
核心模块
视觉:判断玩家是否在感知范围内、是否在视野角内、是否被墙遮挡。
听觉:接收声音事件,比如脚步声、枪声、爆炸声,然后去声音位置调查。
事件感知:被攻击、队友报警、陷阱触发,即使没看见玩家,也能进入警戒。
记忆:记录最后看到玩家的位置,玩家消失后继续搜索一段时间。
黑板:保存感知结果,比如当前目标、最后已知位置、警戒等级、听到的声音点。
决策系统:读取黑板,决定巡逻、调查、追击、攻击、逃跑。
Unity C# 简化示例:视觉 + 听觉 + 记忆
c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Transform、Physics 等类型
public class AiPerception : MonoBehaviour // 定义 AI 感知组件
{ // 类开始
public Transform eyePoint; // 保存 AI 眼睛位置,用来发射视线检测
public Transform target; // 保存玩家或目标 Transform
public float viewRange = 12f; // 保存视觉感知距离
public float viewAngle = 90f; // 保存视觉感知角度
public float memoryTime = 3f; // 保存目标丢失后的记忆时间
public LayerMask obstacleLayer; // 保存遮挡物所在 Layer
public bool hasTarget; // 保存当前是否看见目标
public bool heardNoise; // 保存当前是否听到声音
public Vector3 lastKnownPosition; // 保存最后看见或听到目标的位置
private float memoryTimer; // 保存记忆倒计时
private void Update() // Unity 每帧调用 Update
{ // Update 函数开始
UpdateVision(); // 更新视觉感知
UpdateMemory(); // 更新记忆倒计时
} // Update 函数结束
private void UpdateVision() // 定义视觉感知函数
{ // 函数开始
if (target == null) // 判断目标是否为空
{ // if 语句开始
hasTarget = false; // 没有目标就设置为不可见
return; // 直接结束视觉检测
} // if 语句结束
Vector3 toTarget = target.position - eyePoint.position; // 计算眼睛到目标的方向
float distance = toTarget.magnitude; // 计算目标距离
if (distance > viewRange) // 判断目标是否超出视觉范围
{ // if 语句开始
hasTarget = false; // 超出范围就看不见
return; // 直接结束视觉检测
} // if 语句结束
float angle = Vector3.Angle(transform.forward, toTarget); // 计算目标和 AI 正前方的夹角
if (angle > viewAngle * 0.5f) // 判断目标是否超出半视野角
{ // if 语句开始
hasTarget = false; // 超出视野角就看不见
return; // 直接结束视觉检测
} // if 语句结束
bool blocked = Physics.Raycast(eyePoint.position, toTarget.normalized, distance, obstacleLayer); // 检测目标和 AI 之间是否有墙体遮挡
if (blocked) // 判断是否被遮挡
{ // if 语句开始
hasTarget = false; // 被遮挡就看不见
return; // 直接结束视觉检测
} // if 语句结束
hasTarget = true; // 距离、角度、遮挡都通过后,认为看见目标
heardNoise = false; // 看见目标后,声音调查状态可以清掉
lastKnownPosition = target.position; // 记录目标最后已知位置
memoryTimer = memoryTime; // 重置记忆倒计时
} // 函数结束
private void UpdateMemory() // 定义记忆更新函数
{ // 函数开始
if (hasTarget) // 判断当前是否仍然看见目标
{ // if 语句开始
return; // 看见目标时不需要减少记忆时间
} // if 语句结束
if (memoryTimer > 0f) // 判断是否还有目标记忆
{ // if 语句开始
memoryTimer -= Time.deltaTime; // 随时间减少记忆倒计时
} // if 语句结束
} // 函数结束
public void HearNoise(Vector3 noisePosition, float noisePower) // 定义听到声音事件的函数
{ // 函数开始
float distance = Vector3.Distance(transform.position, noisePosition); // 计算 AI 到声音点的距离
if (distance > noisePower) // 判断声音是否传不到 AI
{ // if 语句开始
return; // 太远听不到就直接返回
} // if 语句结束
heardNoise = true; // 标记 AI 听到了声音
lastKnownPosition = noisePosition; // 把声音位置记录为最后已知位置
memoryTimer = memoryTime; // 听到声音后也启动记忆倒计时
} // 函数结束
public bool HasMemory() // 定义是否仍然记得目标位置的函数
{ // 函数开始
return memoryTimer > 0f; // 记忆时间大于 0 就表示还记得最后位置
} // 函数结束
} // 类结束怎么和状态机配合?
如果 hasTarget == true,怪物进入追击或攻击。
如果 heardNoise == true,怪物进入调查状态,走到声音点附近看看。
如果 hasTarget == false 但 HasMemory() == true,怪物去最后已知位置搜索。
如果没有目标、没有声音、没有记忆,怪物回到巡逻。
性能优化
不要让所有 AI 每帧都做复杂感知。
视觉检测顺序要从便宜到贵:先距离,再角度,最后 Raycast 遮挡。
大量 AI 可以每 0.2s 或 0.5s 检测一次。
声音适合用事件广播,不要每帧扫描。
场景很大时,可以用空间划分、触发器、网格分区减少候选目标数量。
面试高分回答
TIP
AI 感知系统负责把外部世界信息转换成 AI 决策可用的数据。常见感知包括视觉、听觉、受击事件、队友报警和记忆系统。视觉一般按距离、视野角、遮挡检测判断是否看见目标;听觉通常用事件广播,比如枪声或脚步声在一定半径内通知 AI;记忆系统会保存目标最后出现的位置,让 AI 在丢失目标后继续搜索一段时间。感知结果最好写入黑板数据,状态机或行为树只读取黑板做决策。这样感知和决策解耦,方便扩展,也方便优化。性能上要避免每个 AI 每帧做完整检测,应使用降频、分帧、事件驱动和空间划分。
群体怪物如何优化?
群体怪物如何优化?
群体怪物优化的核心不是“让每只怪物都少一点逻辑”,而是:不要让所有怪物每帧都做完整 AI、寻路、动画、物理、渲染和 GC 分配。
主要瓶颈
AI:每只怪物每帧感知、状态机、技能判断,会造成 CPU 压力。
寻路:大量 NavMeshAgent.SetDestination 会很贵,尤其每帧重算路径。
动画:大量 Animator、骨骼动画、蒙皮会吃 CPU/GPU。
渲染:同屏怪物太多会带来 DrawCall、阴影、Overdraw、蒙皮渲染压力。
物理:大量碰撞、Trigger、Raycast、OverlapSphere 会卡。
GC:频繁创建 List、LINQ、临时对象、Instantiate/Destroy 会产生 GC Alloc。
优化思路
近处怪物:完整 AI、完整动画、正常碰撞和寻路。
中距离怪物:低频 AI、低频寻路、降低动画更新频率。
远处怪物:休眠 AI,关闭 Animator,禁用物理,只保留简单位置或状态。
屏幕外怪物:能不渲染就不渲染,能不更新就不更新。
Unity C# 示例:怪物分层更新调度器
c
using System.Collections.Generic; // 引入集合命名空间,用来使用 List
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Transform 和 Vector3
public class MonsterAgent : MonoBehaviour // 定义单个怪物代理组件
{ // MonsterAgent 类开始
public Animator animator; // 保存怪物动画控制器
public float nearUpdateInterval = 0.05f; // 保存近距离更新间隔
public float middleUpdateInterval = 0.25f; // 保存中距离更新间隔
public float farUpdateInterval = 1f; // 保存远距离更新间隔
private float updateTimer = 0f; // 保存当前怪物自己的更新计时器
public void ManagedUpdate(Transform player) // 定义由调度器调用的更新函数
{ // ManagedUpdate 函数开始
float distance = Vector3.Distance(transform.position, player.position); // 计算怪物和玩家之间的距离
float interval = GetUpdateInterval(distance); // 根据距离决定更新频率
updateTimer -= Time.deltaTime; // 减少更新计时器
if (updateTimer > 0f) // 判断是否还没到下一次更新
{ // if 语句开始
return; // 没到时间就跳过本次 AI 更新
} // if 语句结束
updateTimer = interval; // 重置更新计时器
UpdateAi(distance); // 更新怪物 AI
UpdateAnimationLevel(distance); // 根据距离调整动画表现
} // ManagedUpdate 函数结束
private float GetUpdateInterval(float distance) // 定义根据距离获取更新间隔的函数
{ // 函数开始
if (distance < 10f) // 判断怪物是否在近距离
{ // if 语句开始
return nearUpdateInterval; // 近距离高频更新
} // if 语句结束
if (distance < 25f) // 判断怪物是否在中距离
{ // if 语句开始
return middleUpdateInterval; // 中距离低频更新
} // if 语句结束
return farUpdateInterval; // 远距离很低频更新
} // 函数结束
private void UpdateAi(float distance) // 定义 AI 更新函数
{ // 函数开始
Debug.Log("Update AI with distance " + distance); // 示例中用日志代替真实 AI 逻辑
} // 函数结束
private void UpdateAnimationLevel(float distance) // 定义动画 LOD 更新函数
{ // 函数开始
if (animator == null) // 判断动画控制器是否为空
{ // if 语句开始
return; // 没有动画控制器就直接返回
} // if 语句结束
animator.enabled = distance < 35f; // 超过 35 米就关闭 Animator 减少开销
} // 函数结束
} // MonsterAgent 类结束
public class MonsterUpdateScheduler : MonoBehaviour // 定义怪物更新调度器
{ // MonsterUpdateScheduler 类开始
public Transform player; // 保存玩家 Transform 引用
public int updatePerFrame = 20; // 保存每帧最多更新多少只怪物
private List<MonsterAgent> monsters = new List<MonsterAgent>(); // 保存所有怪物列表
private int updateIndex = 0; // 保存当前更新到哪一只怪物
private void Start() // Unity 在第一次 Update 前调用 Start
{ // Start 函数开始
monsters.AddRange(FindObjectsOfType<MonsterAgent>()); // 示例中查找场景里的所有怪物
} // Start 函数结束
private void Update() // Unity 每帧调用 Update
{ // Update 函数开始
if (player == null) // 判断玩家引用是否为空
{ // if 语句开始
return; // 没有玩家就无法做距离分层
} // if 语句结束
int count = Mathf.Min(updatePerFrame, monsters.Count); // 计算本帧实际要更新的怪物数量
for (int i = 0; i < count; i++) // 循环更新一部分怪物
{ // for 循环开始
if (monsters.Count == 0) // 判断怪物列表是否为空
{ // if 语句开始
return; // 没有怪物就直接返回
} // if 语句结束
updateIndex = updateIndex % monsters.Count; // 防止下标超过列表范围
MonsterAgent monster = monsters[updateIndex]; // 取出当前要更新的怪物
updateIndex++; // 更新下标指向下一只怪物
if (monster == null) // 判断怪物是否已经被销毁
{ // if 语句开始
continue; // 被销毁的怪物跳过
} // if 语句结束
monster.ManagedUpdate(player); // 让怪物执行受控更新
} // for 循环结束
} // Update 函数结束
} // MonsterUpdateScheduler 类结束常见优化手段
AI 分帧:不要所有怪物同一帧更新。
AI 降频:远处怪物 0.5 秒甚至 1 秒更新一次。
寻路降频:不要每帧 SetDestination,追击时每 0.2 秒更新一次路径。
动画 LOD:远处怪物关闭 Animator 或降低动画更新频率。
渲染 LOD:远处换低模,关闭阴影,减少材质数量。
GPU Instancing:同模型同材质的大量小怪可以考虑。
对象池:怪物、子弹、特效、飘字都尽量复用。
物理优化:使用 Layer 过滤,少用全场景检测,用 NonAlloc API,检测降频。
空间划分:用格子、四叉树、触发区,只更新玩家附近怪物。
面试高分回答
NOTE
群体怪物优化要先用 Profiler 判断瓶颈在 AI、寻路、动画、渲染、物理还是 GC。一般会按距离和可见性做 LOD:近处怪物完整更新,中距离怪物低频更新,远处怪物休眠或只保留简单状态。AI 和感知要分帧、降频,NavMeshAgent 不要每帧重算路径,动画可以使用 Animator Culling 或动画 LOD,渲染上使用 LOD、阴影裁剪、合批或 GPU Instancing,物理检测要用 Layer 过滤、NonAlloc 和低频检测。大量生成销毁的怪物、子弹、特效要用对象池,避免 GC 和实例化开销。
动画状态和逻辑状态如何同步?
动画状态和逻辑状态如何同步?
最重要的一句话:逻辑状态是权威,动画状态是表现。
也就是说,角色到底能不能移动、能不能攻击、能不能受击、是不是死亡,应该由逻辑状态机决定;Animator 只负责把这个状态表现出来,比如播放 Idle、Run、Attack、Hit、Dead 动画。
推荐同步方式
逻辑状态切换时,主动设置 Animator 参数。
比如逻辑进入 Attack,就 SetTrigger("Attack")。
比如逻辑进入 Move,就 SetFloat("Speed", speed)。
比如逻辑进入 Dead,就 SetTrigger("Dead")。
然后动画在关键帧用 Animation Event 回调逻辑,比如:
攻击判定帧到了,调用 OnAttackHitFrame()。
攻击动画结束了,调用 OnAttackAnimationEnd()。
受击动画结束了,调用 OnHitAnimationEnd()。
为什么不要让 Animator 当唯一状态机?
因为 Animator 主要负责表现,不适合决定复杂玩法规则。
比如角色正在播放攻击动画,但逻辑上可能已经被打断了。
比如动画还没播完,但角色死亡了,必须立刻切死亡状态。
比如网络同步时,服务端说角色已经死亡,客户端动画不能继续攻击。
所以逻辑状态要有最高控制权,Animator 只是跟随逻辑。
Unity C# 示例:逻辑状态驱动动画,动画事件回调逻辑
c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Animator 和 Input
public enum LogicState { Idle, Move, Attack, HitStun, Dead } // 定义逻辑状态枚举
public class AnimationLogicSyncExample : MonoBehaviour // 定义动画状态和逻辑状态同步示例
{ // 类开始
public Animator animator; // 保存 Animator 组件引用
public Collider weaponHitBox; // 保存武器攻击碰撞盒引用
private LogicState state = LogicState.Idle; // 保存当前逻辑状态
private bool attackHitFrameOpened = false; // 保存攻击判定帧是否已经打开
private void Update() // Unity 每帧调用 Update
{ // Update 函数开始
if (state == LogicState.Dead) // 判断当前是否已经死亡
{ // if 语句开始
return; // 死亡状态不再处理普通输入
} // if 语句结束
float move = Input.GetAxisRaw("Horizontal"); // 读取水平移动输入
animator.SetFloat("Speed", Mathf.Abs(move)); // 把移动速度同步给 Animator
if (Input.GetMouseButtonDown(0) && state != LogicState.Attack) // 判断是否按下攻击键且当前不在攻击中
{ // if 语句开始
ChangeState(LogicState.Attack); // 切换到攻击逻辑状态
} // if 语句结束
if (state == LogicState.Idle || state == LogicState.Move) // 判断当前是否允许普通移动状态切换
{ // if 语句开始
ChangeState(Mathf.Abs(move) > 0.01f ? LogicState.Move : LogicState.Idle); // 根据输入切换待机或移动
} // if 语句结束
} // Update 函数结束
private void ChangeState(LogicState nextState) // 定义逻辑状态切换函数
{ // 函数开始
if (state == nextState) // 判断新状态是否和当前状态一样
{ // if 语句开始
return; // 状态相同就不重复切换
} // if 语句结束
ExitState(state); // 先退出旧状态
state = nextState; // 设置当前逻辑状态
EnterState(state); // 再进入新状态
} // 函数结束
private void EnterState(LogicState enterState) // 定义进入状态函数
{ // 函数开始
if (enterState == LogicState.Attack) // 判断是否进入攻击状态
{ // if 语句开始
attackHitFrameOpened = false; // 重置攻击判定帧标记
animator.SetTrigger("Attack"); // 通知 Animator 播放攻击动画
} // if 语句结束
if (enterState == LogicState.HitStun) // 判断是否进入受击硬直状态
{ // if 语句开始
animator.SetTrigger("Hit"); // 通知 Animator 播放受击动画
} // if 语句结束
if (enterState == LogicState.Dead) // 判断是否进入死亡状态
{ // if 语句开始
animator.SetTrigger("Dead"); // 通知 Animator 播放死亡动画
} // if 语句结束
} // 函数结束
private void ExitState(LogicState exitState) // 定义退出状态函数
{ // 函数开始
if (exitState == LogicState.Attack) // 判断是否正在退出攻击状态
{ // if 语句开始
CloseWeaponHitBox(); // 退出攻击时强制关闭攻击碰撞盒
} // if 语句结束
} // 函数结束
public void OnAttackHitFrame() // 这个函数由攻击动画的 Animation Event 调用
{ // 函数开始
if (state != LogicState.Attack) // 判断当前逻辑状态是否不是攻击
{ // if 语句开始
return; // 如果攻击已经被打断,就忽略动画事件
} // if 语句结束
if (attackHitFrameOpened) // 判断判定帧是否已经开启过
{ // if 语句开始
return; // 已经开启过就避免重复开启
} // if 语句结束
attackHitFrameOpened = true; // 标记判定帧已经开启
weaponHitBox.enabled = true; // 打开武器碰撞盒
} // 函数结束
public void OnAttackAnimationEnd() // 这个函数由攻击动画结束处的 Animation Event 调用
{ // 函数开始
if (state != LogicState.Attack) // 判断当前是否已经不在攻击状态
{ // if 语句开始
return; // 如果状态已被打断,就忽略动画结束事件
} // if 语句结束
CloseWeaponHitBox(); // 攻击结束时关闭武器碰撞盒
ChangeState(LogicState.Idle); // 攻击动画结束后回到待机逻辑状态
} // 函数结束
private void CloseWeaponHitBox() // 定义关闭武器碰撞盒函数
{ // 函数开始
if (weaponHitBox != null) // 判断武器碰撞盒是否存在
{ // if 语句开始
weaponHitBox.enabled = false; // 关闭武器碰撞盒
} // if 语句结束
} // 函数结束
public void ForceDead() // 定义强制死亡函数
{ // 函数开始
ChangeState(LogicState.Dead); // 不管动画播到哪里,逻辑都直接切到死亡
} // 函数结束
} // 类结束常见同步方式
参数同步:逻辑设置 Speed、IsGrounded、AttackTrigger、HitTrigger。
事件同步:动画关键帧调用逻辑函数,比如打开攻击判定盒。
时间同步:代码读取 normalizedTime,根据动画播放进度控制判定窗口。
状态回调:用 StateMachineBehaviour 在动画状态进入、退出时通知逻辑。
常见坑
不要让逻辑状态和 Animator 状态各自独立判断,否则会出现“逻辑已经死亡,动画还在攻击”。
动画事件回调时一定要检查当前逻辑状态,因为动画事件可能在角色被打断后仍然触发。
退出攻击、受击、死亡时要清理碰撞盒、特效、移动锁。
网络游戏里,服务端状态优先,客户端动画必须服从服务端同步结果。
面试高分回答
TIP
动画状态和逻辑状态同步时,我会让逻辑状态机作为唯一权威,Animator 只负责表现。逻辑状态切换时主动设置 Animator 参数,比如移动速度、攻击 Trigger、受击 Trigger、死亡 Trigger;动画只在关键帧通过 Animation Event 或 StateMachineBehaviour 回调逻辑,比如打开攻击判定、关闭碰撞盒、通知攻击动画结束。这样可以避免逻辑和动画各自维护一套状态导致不同步。遇到打断、死亡、网络校正时,逻辑状态优先,并强制清理动画相关的碰撞盒、特效和锁定状态。
技能 CD 如何管理?
技能 CD 如何管理?
技能 CD 本质上是一个“时间限制”。推荐做法不是每个技能都开一个协程 WaitForSeconds,而是记录这个技能下一次可释放的时间:readyTime。
判断能不能释放时,看:
当前时间 >= readyTime如果成立,说明 CD 好了;否则说明还在冷却。
基本流程
玩家按技能键。
先检查技能是否存在、蓝量够不够、角色状态能不能放、目标是否合法。
再检查 CD 是否结束。
释放成功后,设置:
readyTime = 当前时间 + 技能冷却时间UI 每帧根据:
remaining = readyTime - 当前时间显示冷却遮罩和倒计时。
Unity C# 示例:技能 CD 管理器
c
using System.Collections.Generic; // 引入集合命名空间,用来使用 Dictionary
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Time 和 Mathf
[System.Serializable] // 标记这个类可以在 Inspector 中序列化显示
public class SkillCooldownConfig // 定义技能冷却配置类
{ // 类开始
public int skillId; // 保存技能 ID
public float cooldown; // 保存技能冷却时间
public bool useGlobalCooldown; // 保存这个技能是否触发公共冷却
} // 类结束
public class SkillCooldownManager : MonoBehaviour // 定义技能冷却管理器
{ // 类开始
public float globalCooldown = 0.5f; // 保存公共冷却时间
private float globalReadyTime = 0f; // 保存公共冷却结束时间
private Dictionary<int, float> skillReadyTimes = new Dictionary<int, float>(); // 保存每个技能的冷却结束时间
public bool CanCast(SkillCooldownConfig skill) // 定义判断技能是否可以释放的函数
{ // 函数开始
if (skill == null) // 判断技能配置是否为空
{ // if 语句开始
return false; // 没有技能配置就不能释放
} // if 语句结束
if (Time.time < globalReadyTime) // 判断公共冷却是否还没结束
{ // if 语句开始
return false; // 公共冷却中不能释放技能
} // if 语句结束
float readyTime = GetReadyTime(skill.skillId); // 获取这个技能自己的冷却结束时间
if (Time.time < readyTime) // 判断技能自己的冷却是否还没结束
{ // if 语句开始
return false; // 技能冷却中不能释放
} // if 语句结束
return true; // 公共冷却和技能冷却都结束,可以释放
} // 函数结束
public void StartCooldown(SkillCooldownConfig skill) // 定义开始技能冷却的函数
{ // 函数开始
if (skill == null) // 判断技能配置是否为空
{ // if 语句开始
return; // 没有技能配置就直接返回
} // if 语句结束
skillReadyTimes[skill.skillId] = Time.time + skill.cooldown; // 设置这个技能下一次可释放的时间
if (skill.useGlobalCooldown) // 判断这个技能是否触发公共冷却
{ // if 语句开始
globalReadyTime = Time.time + globalCooldown; // 设置公共冷却结束时间
} // if 语句结束
} // 函数结束
public float GetRemainingTime(int skillId) // 定义获取技能剩余冷却时间的函数
{ // 函数开始
float readyTime = GetReadyTime(skillId); // 获取技能冷却结束时间
float remaining = readyTime - Time.time; // 用结束时间减去当前时间得到剩余时间
return Mathf.Max(0f, remaining); // 返回不小于 0 的剩余时间
} // 函数结束
public float GetCooldownProgress(int skillId, float cooldown) // 定义获取冷却进度的函数
{ // 函数开始
if (cooldown <= 0f) // 判断冷却时间是否无效
{ // if 语句开始
return 1f; // 没有冷却就认为进度完成
} // if 语句结束
float remaining = GetRemainingTime(skillId); // 获取剩余冷却时间
float progress = 1f - remaining / cooldown; // 计算从 0 到 1 的冷却进度
return Mathf.Clamp01(progress); // 把进度限制在 0 到 1 之间
} // 函数结束
private float GetReadyTime(int skillId) // 定义获取技能可释放时间的函数
{ // 函数开始
if (skillReadyTimes.TryGetValue(skillId, out float readyTime)) // 尝试从字典中读取技能冷却结束时间
{ // if 语句开始
return readyTime; // 找到了就返回记录的结束时间
} // if 语句结束
return 0f; // 没有记录说明技能从未进入冷却,可以立即释放
} // 函数结束
} // 类结束为什么推荐 readyTime?
因为它稳定、简单、好同步。
如果用倒计时变量,每帧 remaining -= Time.deltaTime,暂停、掉帧、重连、服务器同步时都更麻烦。
如果用 readyTime,只要知道当前时间,就能随时算出剩余 CD。
UI 不需要管理 CD 逻辑,只需要读剩余时间显示。
公共 CD 是什么?
公共 CD 就是释放一个技能后,一组技能都短暂不可用。
比如释放普攻后,所有技能有 0.3s 公共冷却,防止同一帧连点多个技能。
所以技能 CD 管理通常有两层:
单技能 CD:每个技能自己的冷却。
公共 CD:一组技能共享的小冷却。
充能技能怎么处理?
有些技能不是普通 CD,而是充能制。
比如闪避最多存 3 次,每 5 秒恢复 1 次。
这种要记录:
当前层数 charges
最大层数 maxCharges
下一次恢复时间 nextChargeTime
每次释放消耗 1 层,层数没满时继续计时恢复。
联网游戏怎么处理?
联网游戏里,服务端必须权威管理 CD。
客户端可以提前灰显 UI、播放预测动画,但不能自己决定技能真的放成功。
正确流程是:
客户端请求释放技能。
服务端检查状态、蓝量、距离、目标、CD。
服务端通过后,记录权威 CD,并广播释放结果。
客户端收到结果后同步 UI 和表现。
面试高分回答
TIP
技能 CD 我会用时间戳管理,而不是给每个技能到处开协程。每个技能记录一个 readyTime,释放成功后设置为 当前时间 + 冷却时间,之后判断 当前时间 >= readyTime 就表示冷却完成。UI 通过 readyTime - 当前时间 计算剩余时间和遮罩进度。系统上还要支持公共 CD、技能分组 CD、充能技能、冷却缩减、技能取消后是否返还 CD 等规则。联网游戏中 CD 必须由服务端权威校验,客户端只做预测和 UI 显示,防止玩家修改客户端绕过冷却。
属性系统怎么设计?
属性系统怎么设计?
属性系统的核心是:基础值和修饰值分开,最终值统一计算。
不要到处直接改 attack、defense、maxHp。正确做法是:角色基础属性来自等级和配置,装备、Buff、天赋、光环提供修饰值,最后由属性系统按统一公式算出最终属性。
常见属性来源
基础属性:角色等级、职业、成长表。
装备属性:武器加攻击,防具加防御,饰品加暴击。
Buff 属性:临时加速、减速、加攻击、减防御。
天赋属性:永久加成或条件加成。
光环属性:队友、阵营、场景效果提供的范围加成。
推荐公式
常见公式可以这样设计:
最终值 = (基础值 + 固定加成) × (1 + 百分比加成)比如攻击力:
基础攻击 100。
装备固定加成 +20。
Buff 百分比加成 +30%。
最终攻击 = (100 + 20) × 1.3 = 156
重点是公式顺序必须统一,否则以后会很难查问题。
Unity C# 示例:可扩展属性系统
c
using System; // 引入 System 命名空间,用来使用 Action 事件
using System.Collections.Generic; // 引入集合命名空间,用来使用 Dictionary 和 List
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 Mathf
public enum AttributeType { MaxHp, Attack, Defense, MoveSpeed, CritRate } // 定义属性类型枚举
public enum ModifierType { Flat, Percent } // 定义修饰类型,Flat 是固定值,Percent 是百分比
public class AttributeModifier // 定义属性修饰器
{ // 类开始
public AttributeType type; // 保存这个修饰器影响哪种属性
public ModifierType modifierType; // 保存这个修饰器是固定值还是百分比
public float value; // 保存修饰数值
public object source; // 保存来源对象,比如装备、Buff、天赋
} // 类结束
public class AttributeValue // 定义单个属性的数据
{ // 类开始
public float baseValue; // 保存基础值
public float finalValue; // 保存最终值
public bool dirty = true; // 保存是否需要重新计算
public List<AttributeModifier> modifiers = new List<AttributeModifier>(); // 保存影响这个属性的所有修饰器
} // 类结束
public class AttributeSystem // 定义属性系统
{ // 类开始
public event Action<AttributeType, float> OnAttributeChanged; // 定义属性变化事件
private Dictionary<AttributeType, AttributeValue> attributes = new Dictionary<AttributeType, AttributeValue>(); // 保存所有属性
public void SetBaseValue(AttributeType type, float value) // 定义设置基础属性函数
{ // 函数开始
AttributeValue attribute = GetOrCreate(type); // 获取或创建对应属性
attribute.baseValue = value; // 设置基础值
attribute.dirty = true; // 标记这个属性需要重新计算
} // 函数结束
public float GetFinalValue(AttributeType type) // 定义获取最终属性函数
{ // 函数开始
AttributeValue attribute = GetOrCreate(type); // 获取或创建对应属性
if (attribute.dirty) // 判断属性是否需要重新计算
{ // if 语句开始
Recalculate(type, attribute); // 重新计算属性
} // if 语句结束
return attribute.finalValue; // 返回最终属性值
} // 函数结束
public void AddModifier(AttributeModifier modifier) // 定义添加属性修饰器函数
{ // 函数开始
AttributeValue attribute = GetOrCreate(modifier.type); // 获取这个修饰器影响的属性
attribute.modifiers.Add(modifier); // 把修饰器加入属性列表
attribute.dirty = true; // 标记属性需要重新计算
} // 函数结束
public void RemoveModifiersFromSource(object source) // 定义按来源移除修饰器函数
{ // 函数开始
foreach (AttributeValue attribute in attributes.Values) // 遍历所有属性
{ // foreach 循环开始
int removedCount = attribute.modifiers.RemoveAll(modifier => modifier.source == source); // 移除指定来源的所有修饰器
if (removedCount > 0) // 判断是否真的移除了修饰器
{ // if 语句开始
attribute.dirty = true; // 有移除就标记属性需要重新计算
} // if 语句结束
} // foreach 循环结束
} // 函数结束
private void Recalculate(AttributeType type, AttributeValue attribute) // 定义重新计算属性函数
{ // 函数开始
float flatAdd = 0f; // 保存固定值加成
float percentAdd = 0f; // 保存百分比加成
foreach (AttributeModifier modifier in attribute.modifiers) // 遍历所有修饰器
{ // foreach 循环开始
if (modifier.modifierType == ModifierType.Flat) flatAdd += modifier.value; // 固定值修饰器累加到 flatAdd
if (modifier.modifierType == ModifierType.Percent) percentAdd += modifier.value; // 百分比修饰器累加到 percentAdd
} // foreach 循环结束
float oldValue = attribute.finalValue; // 保存旧的最终值
attribute.finalValue = (attribute.baseValue + flatAdd) * (1f + percentAdd); // 按统一公式计算最终值
attribute.finalValue = Mathf.Max(0f, attribute.finalValue); // 保证最终值不小于 0
attribute.dirty = false; // 重算完成后清除脏标记
if (Mathf.Approximately(oldValue, attribute.finalValue) == false) // 判断属性值是否发生变化
{ // if 语句开始
OnAttributeChanged?.Invoke(type, attribute.finalValue); // 触发属性变化事件
} // if 语句结束
} // 函数结束
private AttributeValue GetOrCreate(AttributeType type) // 定义获取或创建属性函数
{ // 函数开始
if (attributes.TryGetValue(type, out AttributeValue attribute)) // 尝试从字典中获取属性
{ // if 语句开始
return attribute; // 找到了就返回
} // if 语句结束
attribute = new AttributeValue(); // 没找到就创建新的属性数据
attributes[type] = attribute; // 把新属性放入字典
return attribute; // 返回新创建的属性
} // 函数结束
} // 类结束当前值和最大值要分开
MaxHp 是属性。
CurrentHp 是资源值。
比如 Buff 把最大生命从 1000 提高到 1200,这时当前生命要不要跟着涨,要看设计。
通常至少要保证:
c
CurrentHp <= MaxHp否则最大生命降低后,当前生命可能超过最大值。
为什么要有 source?
因为装备脱下、Buff 移除时,要能把它带来的属性加成删掉。
比如中毒 Buff 降低 20% 移速,Buff 结束时必须移除这条修饰。
如果不记录来源,就很难知道该删哪一条。
面试高分回答
WARNING
属性系统我会拆成基础值、修饰器和最终值三层。基础值来自角色配置、等级和成长;修饰器来自装备、Buff、天赋、光环等来源;最终值由属性系统按统一公式计算,比如 (基础值 + 固定加成) × (1 + 百分比加成)。每个修饰器要记录属性类型、修饰类型、数值和来源,方便 Buff 结束或装备卸下时移除。属性变化后用脏标记延迟重算,并通过事件通知 UI、战斗公式和网络同步。当前血量这种资源值要和最大生命这种属性值分开管理。联网游戏中最终属性最好由服务端权威计算,客户端只做显示和预测。
装备影响属性怎么计算?
装备影响属性怎么计算?
装备不要直接改角色最终属性,而是把装备上的每条词条变成一个“属性修饰器”,交给属性系统统一计算。
比如武器写着“攻击 +30”,戒指写着“攻击 +10%”,它们都不是直接改 finalAttack,而是加入属性系统的修饰列表。
常见计算公式
比较常见的顺序是:
最终属性 = (基础值 + 固定加成) × (1 + 百分比加成) × 最终乘区例子:
基础攻击 = 100
武器固定攻击 = +30
戒指攻击百分比 = +10%
套装最终攻击 = +20%
最终攻击:
(100 + 30) × 1.10 × 1.20 = 171.6为什么要记录来源 source?
因为脱装备时要能删除这件装备带来的属性。
如果装备直接把攻击加到角色身上,脱装备时你很难知道要减多少,尤其还有强化、宝石、套装、Buff 叠在一起。
正确做法是:
装备 A 添加一组 modifier。
modifier 的 source 记录为装备 A。
脱装备时删除 source 为装备 A 的所有 modifier。
属性系统重新计算最终值。
C# 示例:装备把属性修饰器加到属性系统
c
using System.Collections.Generic; // 引入集合命名空间,用来使用 List 和 Dictionary
public enum AttributeType { Attack, Defense, MaxHp } // 定义属性类型,包括攻击、防御、最大生命
public enum ModifierType { Flat, Percent } // 定义修饰类型,包括固定值和百分比
public class AttributeModifier // 定义属性修饰器
{ // 类开始
public AttributeType type; // 保存修饰器影响哪种属性
public ModifierType modifierType; // 保存修饰器是固定值还是百分比
public float value; // 保存修饰器数值
public object source; // 保存修饰器来源,比如某件装备
} // 类结束
public class EquipmentItem // 定义装备数据
{ // 类开始
public string itemId; // 保存装备唯一 ID
public List<AttributeModifier> modifiers = new List<AttributeModifier>(); // 保存装备提供的所有属性修饰器
} // 类结束
public class AttributeSystem // 定义简化属性系统
{ // 类开始
private Dictionary<AttributeType, float> baseValues = new Dictionary<AttributeType, float>(); // 保存基础属性值
private List<AttributeModifier> modifiers = new List<AttributeModifier>(); // 保存所有属性修饰器
public void SetBaseValue(AttributeType type, float value) // 定义设置基础属性函数
{ // 函数开始
baseValues[type] = value; // 设置指定属性的基础值
} // 函数结束
public void AddModifier(AttributeModifier modifier) // 定义添加修饰器函数
{ // 函数开始
modifiers.Add(modifier); // 把修饰器加入列表
} // 函数结束
public void RemoveModifiersBySource(object source) // 定义按来源移除修饰器函数
{ // 函数开始
modifiers.RemoveAll(modifier => modifier.source == source); // 移除指定来源的所有修饰器
} // 函数结束
public float GetFinalValue(AttributeType type) // 定义获取最终属性函数
{ // 函数开始
float baseValue = baseValues.TryGetValue(type, out float value) ? value : 0f; // 获取基础值,如果没有就使用 0
float flatAdd = 0f; // 保存固定加成总和
float percentAdd = 0f; // 保存百分比加成总和
foreach (AttributeModifier modifier in modifiers) // 遍历所有修饰器
{ // foreach 开始
if (modifier.type != type) // 判断修饰器是否不是当前属性
{ // if 开始
continue; // 不是当前属性就跳过
} // if 结束
if (modifier.modifierType == ModifierType.Flat) // 判断是否是固定值修饰器
{ // if 开始
flatAdd += modifier.value; // 固定值累加到 flatAdd
} // if 结束
if (modifier.modifierType == ModifierType.Percent) // 判断是否是百分比修饰器
{ // if 开始
percentAdd += modifier.value; // 百分比累加到 percentAdd
} // if 结束
} // foreach 结束
return (baseValue + flatAdd) * (1f + percentAdd); // 按统一公式返回最终属性值
} // 函数结束
} // 类结束
public class EquipmentSystem // 定义装备系统
{ // 类开始
private AttributeSystem attributeSystem; // 保存属性系统引用
private Dictionary<string, EquipmentItem> equippedItems = new Dictionary<string, EquipmentItem>(); // 保存当前已穿戴装备
public EquipmentSystem(AttributeSystem attributeSystem) // 定义装备系统构造函数
{ // 构造函数开始
this.attributeSystem = attributeSystem; // 保存传入的属性系统
} // 构造函数结束
public void Equip(string slot, EquipmentItem item) // 定义穿装备函数
{ // 函数开始
Unequip(slot); // 穿新装备前先卸下这个槽位的旧装备
equippedItems[slot] = item; // 记录当前槽位的新装备
foreach (AttributeModifier modifier in item.modifiers) // 遍历装备上的所有修饰器
{ // foreach 开始
modifier.source = item; // 把修饰器来源设置为这件装备
attributeSystem.AddModifier(modifier); // 把装备修饰器添加到属性系统
} // foreach 结束
} // 函数结束
public void Unequip(string slot) // 定义卸装备函数
{ // 函数开始
if (equippedItems.TryGetValue(slot, out EquipmentItem oldItem) == false) // 判断这个槽位是否没有装备
{ // if 开始
return; // 没有装备就直接返回
} // if 结束
attributeSystem.RemoveModifiersBySource(oldItem); // 从属性系统移除这件装备带来的所有修饰器
equippedItems.Remove(slot); // 从已穿戴装备表中移除这个槽位
} // 函数结束
} // 类结束当前血量要特别注意
MaxHp 是属性,CurrentHp 是当前资源。
如果装备加了最大生命,脱下装备后最大生命下降,要保证:
c
CurrentHp <= MaxHp否则可能出现最大生命 1000,但当前生命 1200 的奇怪情况。
面试高分回答
NOTE
装备影响属性时,我不会直接修改角色最终属性,而是把装备词条转换成属性修饰器。角色有基础属性,装备提供固定值加成、百分比加成或特殊乘区,属性系统按统一公式计算最终值,比如 (基础值 + 固定加成) × (1 + 百分比加成)。每个修饰器要记录来源 source,这样脱装备时可以按来源移除该装备带来的所有加成,再重新计算最终属性。当前生命、当前蓝量这类资源值要和最大生命、最大蓝量这类属性分开管理。联网游戏里最终属性应由服务端权威计算,客户端只做显示。
死亡和复活流程怎么设计?
死亡和复活流程怎么设计?
死亡和复活流程的核心是:死亡要清理干净,复活要恢复完整。
死亡不是只把血量设为 0,还要停止输入、AI、移动、攻击、技能、碰撞盒、Buff 或特效等状态;复活也不是只把血量加回来,还要恢复位置、状态、碰撞、控制权、动画和 UI。
死亡流程
受到伤害后,先扣血。
如果 HP <= 0,进入死亡状态。
进入死亡时要做这些事:
停止移动。
停止攻击和技能。
关闭攻击碰撞盒。
停止 AI 或玩家输入。
播放死亡动画。
播放死亡音效和特效。
处理掉落、经验、任务、击杀者。
如果是玩家,显示复活 UI 或倒计时。
如果是怪物,延迟回收到对象池。
复活流程
复活时要做这些事:
移动到复活点。
恢复血量和蓝量。
清除死亡状态。
重新启用输入或 AI。
重新启用碰撞体。
播放复活动画或特效。
刷新 UI。
给短暂无敌,防止刚复活又被秒。
Unity C# 示例:死亡和复活管理
c
using UnityEngine; // 引入 UnityEngine 命名空间,用来使用 MonoBehaviour、Transform 和 Animator
using System.Collections; // 引入协程命名空间,用来使用 IEnumerator
public class DeathRespawnExample : MonoBehaviour // 定义死亡和复活流程示例组件
{ // 类开始
public int maxHp = 100; // 保存最大生命值
public int currentHp = 100; // 保存当前生命值
public Animator animator; // 保存动画控制器引用
public Collider bodyCollider; // 保存角色身体碰撞体
public Collider weaponHitBox; // 保存武器攻击碰撞盒
public MonoBehaviour inputOrAiController; // 保存输入控制器或 AI 控制器
public Transform respawnPoint; // 保存复活点
public float respawnDelay = 3f; // 保存死亡后等待复活的时间
public float invincibleTime = 2f; // 保存复活后的无敌时间
private bool isDead = false; // 保存角色是否已经死亡
private bool isInvincible = false; // 保存角色是否处于无敌状态
public void TakeDamage(int damage) // 定义受伤函数
{ // 函数开始
if (isDead) // 判断角色是否已经死亡
{ // if 开始
return; // 已经死亡就不再处理伤害
} // if 结束
if (isInvincible) // 判断角色是否处于无敌状态
{ // if 开始
return; // 无敌期间不处理伤害
} // if 结束
currentHp = Mathf.Max(0, currentHp - damage); // 扣除血量并保证不小于 0
if (currentHp <= 0) // 判断血量是否已经归零
{ // if 开始
StartCoroutine(DeathRoutine()); // 启动死亡流程协程
} // if 结束
} // 函数结束
private IEnumerator DeathRoutine() // 定义死亡流程协程
{ // 协程开始
isDead = true; // 标记角色已经死亡,防止重复进入死亡流程
CloseCombatState(); // 关闭攻击、技能和碰撞相关状态
EnableControl(false); // 禁用玩家输入或 AI 控制
animator.SetTrigger("Dead"); // 播放死亡动画
yield return new WaitForSeconds(respawnDelay); // 等待复活倒计时
Respawn(); // 执行复活逻辑
} // 协程结束
private void Respawn() // 定义复活函数
{ // 函数开始
if (respawnPoint != null) // 判断是否配置了复活点
{ // if 开始
transform.position = respawnPoint.position; // 把角色移动到复活点
transform.rotation = respawnPoint.rotation; // 把角色旋转到复活点朝向
} // if 结束
currentHp = maxHp; // 恢复生命值到最大值
isDead = false; // 清除死亡标记
EnableControl(true); // 重新启用玩家输入或 AI 控制
if (bodyCollider != null) // 判断身体碰撞体是否存在
{ // if 开始
bodyCollider.enabled = true; // 重新启用身体碰撞体
} // if 结束
animator.SetTrigger("Respawn"); // 播放复活动画
StartCoroutine(InvincibleRoutine()); // 开启复活后的短暂无敌
} // 函数结束
private void CloseCombatState() // 定义关闭战斗状态函数
{ // 函数开始
if (weaponHitBox != null) // 判断武器攻击碰撞盒是否存在
{ // if 开始
weaponHitBox.enabled = false; // 关闭攻击碰撞盒,避免死亡后继续误伤敌人
} // if 结束
if (bodyCollider != null) // 判断身体碰撞体是否存在
{ // if 开始
bodyCollider.enabled = false; // 关闭身体碰撞体,避免死亡期间继续被命中
} // if 结束
} // 函数结束
private void EnableControl(bool enabled) // 定义启用或禁用控制器函数
{ // 函数开始
if (inputOrAiController != null) // 判断控制器是否存在
{ // if 开始
inputOrAiController.enabled = enabled; // 设置输入控制器或 AI 控制器是否启用
} // if 结束
} // 函数结束
private IEnumerator InvincibleRoutine() // 定义复活无敌协程
{ // 协程开始
isInvincible = true; // 开启无敌状态
yield return new WaitForSeconds(invincibleTime); // 等待无敌时间结束
isInvincible = false; // 关闭无敌状态
} // 协程结束
} // 类结束玩家死亡和怪物死亡区别
玩家死亡通常要进入复活倒计时、回城、扣资源、显示 UI。
怪物死亡通常要停止 AI、播放死亡动画、掉落奖励、给经验、延迟回收到对象池。
Boss 死亡可能还要触发剧情、开门、结算副本。
常见坑
死亡流程重复执行:要用 isDead 防止多次触发死亡。
死亡后碰撞盒没关:角色死了还在打人或被打。
复活后状态没清:复活时仍然处于硬直、眩晕或攻击状态。
复活后立刻被秒:最好给短暂无敌或保护时间。
联网游戏里客户端自己复活:这是不安全的,服务端应决定死亡和复活。
面试高分回答
NOTE
死亡和复活流程我会按状态机设计。角色受伤后先扣血,如果血量小于等于 0,并且当前还没死亡,就切到 Dead 状态。进入死亡状态时要停止输入或 AI,停止移动、攻击和技能,关闭攻击碰撞盒和受击碰撞,播放死亡动画,处理掉落、经验、击杀者和 UI。复活时根据规则移动到复活点,恢复血量和资源,清理死亡、硬直、技能等状态,重新启用控制器和碰撞体,播放复活表现,并给短暂无敌保护。联网游戏中死亡和复活应由服务端权威决定,客户端只负责表现和 UI。