Skip to content

角色与 AI

角色状态机怎么设计?

角色状态机怎么设计?

角色状态机就是:把角色当前行为拆成几个明确状态,比如 IdleMoveAttackHitStunDead,并且同一时刻只允许角色处于一个主状态。

character-state-machine-design-csharp

为什么要用状态机?

如果不用状态机,代码很容易变成:

角色正在攻击时还能移动。

角色死亡后还能放技能。

角色受击硬直时还能跳跃。

攻击碰撞盒忘记关闭。

动画、输入、移动、技能逻辑互相乱改。

状态机的作用就是把规则收拢:当前是什么状态,就只执行这个状态允许的逻辑。

常见角色状态

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-bt-goap-difference-csharp

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 攻击;玩家死亡、跑远或丢失视野后回到巡逻。

monster-patrol-chase-attack-csharp

核心逻辑

巡逻:怪物沿几个巡逻点移动,到点后等一下,再去下一个点。

追击:怪物发现玩家后,把玩家位置设置成寻路目标。

攻击:怪物进入攻击范围后停下来,面向玩家,按攻击冷却播放攻击动画。

丢失:玩家跑太远、死亡、被遮挡太久,怪物回到巡逻或出生点。

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.2s0.5s 检测一次玩家。

追击时也不要每帧 SetDestination,可以隔一小段时间更新路径。

大量怪物可以分帧更新 AI,避免同一帧所有怪物一起算。

面试高分回答

IMPORTANT

怪物巡逻、追击、攻击一般用 FSM 实现。巡逻状态下怪物沿巡逻点移动,同时定期检测玩家是否进入发现范围;发现玩家后切换到追击状态,用 NavMeshAgent 设置玩家位置为目标;进入攻击距离后切换到攻击状态,停止寻路、面向玩家,并按攻击冷却播放攻击动画和判定;如果玩家离开攻击范围就回到追击,如果玩家死亡、距离过远或丢失视野,就回到巡逻或出生点。实现时要注意状态切换条件清晰,发现距离和丢失距离要分开,寻路和检测要降频,大量怪物时可以分帧更新。

AI 感知系统怎么设计?

AI 感知系统怎么设计?

AI 感知系统就是让怪物“知道世界发生了什么”。比如它能不能看见玩家、有没有听到声音、有没有被攻击、是否记得玩家最后出现的位置。

感知系统不直接决定“打不打”,它负责收集信息;真正的行为选择交给 FSM、行为树或 GOAP。

ai-perception-system-csharp

核心模块

视觉:判断玩家是否在感知范围内、是否在视野角内、是否被墙遮挡。

听觉:接收声音事件,比如脚步声、枪声、爆炸声,然后去声音位置调查。

事件感知:被攻击、队友报警、陷阱触发,即使没看见玩家,也能进入警戒。

记忆:记录最后看到玩家的位置,玩家消失后继续搜索一段时间。

黑板:保存感知结果,比如当前目标、最后已知位置、警戒等级、听到的声音点。

决策系统:读取黑板,决定巡逻、调查、追击、攻击、逃跑。

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 == falseHasMemory() == true,怪物去最后已知位置搜索。

如果没有目标、没有声音、没有记忆,怪物回到巡逻。

性能优化

不要让所有 AI 每帧都做复杂感知。

视觉检测顺序要从便宜到贵:先距离,再角度,最后 Raycast 遮挡。

大量 AI 可以每 0.2s0.5s 检测一次。

声音适合用事件广播,不要每帧扫描。

场景很大时,可以用空间划分、触发器、网格分区减少候选目标数量。

面试高分回答

TIP

AI 感知系统负责把外部世界信息转换成 AI 决策可用的数据。常见感知包括视觉、听觉、受击事件、队友报警和记忆系统。视觉一般按距离、视野角、遮挡检测判断是否看见目标;听觉通常用事件广播,比如枪声或脚步声在一定半径内通知 AI;记忆系统会保存目标最后出现的位置,让 AI 在丢失目标后继续搜索一段时间。感知结果最好写入黑板数据,状态机或行为树只读取黑板做决策。这样感知和决策解耦,方便扩展,也方便优化。性能上要避免每个 AI 每帧做完整检测,应使用降频、分帧、事件驱动和空间划分。

群体怪物如何优化?

群体怪物如何优化?

群体怪物优化的核心不是“让每只怪物都少一点逻辑”,而是:不要让所有怪物每帧都做完整 AI、寻路、动画、物理、渲染和 GC 分配。

crowd-monster-optimization-csharp

主要瓶颈

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 动画。

animation-logic-state-sync-csharp

推荐同步方式

逻辑状态切换时,主动设置 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); // 不管动画播到哪里,逻辑都直接切到死亡
    } // 函数结束
} // 类结束

常见同步方式

参数同步:逻辑设置 SpeedIsGroundedAttackTriggerHitTrigger

事件同步:动画关键帧调用逻辑函数,比如打开攻击判定盒。

时间同步:代码读取 normalizedTime,根据动画播放进度控制判定窗口。

状态回调:用 StateMachineBehaviour 在动画状态进入、退出时通知逻辑。

常见坑

不要让逻辑状态和 Animator 状态各自独立判断,否则会出现“逻辑已经死亡,动画还在攻击”。

动画事件回调时一定要检查当前逻辑状态,因为动画事件可能在角色被打断后仍然触发。

退出攻击、受击、死亡时要清理碰撞盒、特效、移动锁。

网络游戏里,服务端状态优先,客户端动画必须服从服务端同步结果。

面试高分回答

TIP

动画状态和逻辑状态同步时,我会让逻辑状态机作为唯一权威,Animator 只负责表现。逻辑状态切换时主动设置 Animator 参数,比如移动速度、攻击 Trigger、受击 Trigger、死亡 Trigger;动画只在关键帧通过 Animation Event 或 StateMachineBehaviour 回调逻辑,比如打开攻击判定、关闭碰撞盒、通知攻击动画结束。这样可以避免逻辑和动画各自维护一套状态导致不同步。遇到打断、死亡、网络校正时,逻辑状态优先,并强制清理动画相关的碰撞盒、特效和锁定状态。

技能 CD 如何管理?

技能 CD 如何管理?

技能 CD 本质上是一个“时间限制”。推荐做法不是每个技能都开一个协程 WaitForSeconds,而是记录这个技能下一次可释放的时间:readyTime

判断能不能释放时,看:

当前时间 >= readyTime

如果成立,说明 CD 好了;否则说明还在冷却。

skill-cooldown-management-csharp

基本流程

玩家按技能键。

先检查技能是否存在、蓝量够不够、角色状态能不能放、目标是否合法。

再检查 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 显示,防止玩家修改客户端绕过冷却。

属性系统怎么设计?

属性系统怎么设计?

属性系统的核心是:基础值和修饰值分开,最终值统一计算。

不要到处直接改 attackdefensemaxHp。正确做法是:角色基础属性来自等级和配置,装备、Buff、天赋、光环提供修饰值,最后由属性系统按统一公式算出最终属性。

attribute-system-design-csharp

常见属性来源

基础属性:角色等级、职业、成长表。

装备属性:武器加攻击,防具加防御,饰品加暴击。

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,而是加入属性系统的修饰列表。

equipment-attribute-calculation-csharp

常见计算公式

比较常见的顺序是:

最终属性 = (基础值 + 固定加成) × (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。

death-respawn-flow-csharp

死亡流程

受到伤害后,先扣血。

如果 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。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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