U
章 02角色与战斗9 课 — 点击打开
08 / 09 · 13 分钟
角色与战斗

会追击并反击的敌人

到目前为止的项目

Assets/

Scenes/

Arena.unity

KnightDummy ×3→Enemy ×3 (chases and hits back)

Scripts/

SkillCaster.csHealthBar.csEnemyBrain.cs

Prefabs/

Enemy.prefab

项目设置

Enemy layerObstacle layerNavMesh bake

你已经有的这一课新增这一课替换

上一课的 Dummy 只会挨打和死去,仅此而已。它不移动、不还手、也永远不会让你输。现在你根本输不了 —— 所以还算不上游戏。

这一课让它站起来。还是那个 capsule、那套 CharacterStats,新增三种行为:发现你、走向你、打你。

动态图解

距离决定一切:离得远就巡逻,进了圈就追击,到了跟前就抡你。

第 1 步 —— 三个状态,一个数字

竞技场就这么大,还轮不上寻路系统出场。一次距离检测选出状态,状态再选出行为。它的脑子就这么多内容。

EnemyBrain.cs
using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public class EnemyBrain : MonoBehaviour
{
    public Transform target;          // the Knight
    public float aggroRange = 9f;
    public float attackRange = 1.8f;  // stop and swing
    public float speed = 2.6f;
    public float damage = 12f;
    public float attackEvery = 1.2f;

    CharacterController cc;
    float nextAttack;

    void Awake() => cc = GetComponent<CharacterController>();

    void Update()
    {
        if (target == null) return;

        // Flatten Y: a target standing on a block is still "close" horizontally.
        Vector3 flat = target.position - transform.position;
        flat.y = 0f;
        float distance = flat.magnitude;

        if (distance > aggroRange) return;              // Patrol: do nothing yet

        transform.rotation = Quaternion.LookRotation(flat);

        if (distance > attackRange)                     // Chase
        {
            cc.Move(flat.normalized * speed * Time.deltaTime);
        }
        else if (Time.time >= nextAttack)               // Attack
        {
            nextAttack = Time.time + attackEvery;
            target.GetComponent<CharacterStats>().TakeDamage(damage);
        }

        cc.Move(Vector3.down * 9.8f * Time.deltaTime);  // stay on the floor
    }
}
  • flat.y = 0f 才是关键的一行。缺了它,站在方块上的 Knight 会被当成距离很远,你一跳敌人就没了兴致。

  • nextAttack 用的就是玩家技能那套冷却模式 —— 敌人也照你的规矩来。

  • CharacterStats 就是你已经写好的脚本。敌人对你调用 TakeDamage;你的技能对它调用同一个。一个脚本,两个方向。

第 2 步 —— 在场景里接好

  1. 1

    选中 Dummy。它从前一课起就有 CharacterStats 和 Enemy 层 —— 两个都保留。

  2. 2

    添加 Character Controller,再添加 EnemyBrain。把 Knight 拖进 Target 字段。

  3. 3

    把它重命名为 Enemy 并拖进 Assets/Prefabs。从此你放的是敌人,不是假人。

  4. 4

    在方块阵周围放下三个,距离各不相同。按 Play,朝其中一个走过去。

第 3 步 —— 让敌人死得掉

你的技能本来就能对 Enemy 层上的任何东西造成伤害,所以这一半几乎白送。唯一的新内容是血量归零那一刻 —— 而 CharacterStats 早就在播报了:你做血条的时候加过 OnDied。敌人只要听着,然后自我删除。

EnemyBrain.cs — replace Awake with this
CharacterStats stats;

void Awake()
{
    cc = GetComponent<CharacterController>();
    stats = GetComponent<CharacterStats>();

    // The Knight has CharacterStats too, so that script must never destroy
    // anything itself. The enemy listens for its own death instead.
    stats.OnDied += () => Destroy(gameObject);
}

第 4 步 —— 方块理应挡下攻击

现在的竞技场里有能跳上去的方块,可打斗这边没有任何东西知道它们存在。flat.y = 0f 把高度丢了,所以地板上的敌人会隔着实心的方块,朝头顶一米上的 Knight 抡拳。

两个问题就能修好,而且是故意分开的:它打得到那么高吗?中间有没有东西挡着?哪怕你俩都站在地板上,一堵墙卡的也是第二个问题。

  1. 1

    Obstacle 层早就存在:你当初阻止技能隔着方块打人时建的,P_Cube 也已经在里面。敌人读的还是同一个层。

  2. 2

    在 Enemy prefab 上右键它的名字 → Create Empty,命名为 Eye,Position 设成 0, 1.4, 0。这就是敌人看东西的起点。

  3. 3

    把下面的代码加进 EnemyBrain,然后在 Inspector 里把 Eye 拖进 Eye 字段,Obstacles 遮罩里只勾 Obstacle。

add to EnemyBrain.cs — what stops an attack
[Header("Cover & height")]
public LayerMask obstacles;          // the Obstacle layer: blocks, walls
public float reachHeight = 1.2f;     // how far above itself it can still swing
public Transform eye;                // an empty at head height

// A straight line from its eye to your chest. Anything on the Obstacle
// layer in the way means it has no shot.
bool CanSee()
{
    Vector3 chest = target.position + Vector3.up;
    return !Physics.Linecast(eye.position, chest, obstacles);
}

// You standing on a block are out of swinging range, even if the
// flattened distance says you are right next to it.
bool CanReach()
{
    return target.position.y - transform.position.y <= reachHeight;
}

然后攻击那行在出手前把两个问题都问一遍。Update 里其余的一律不动。

the attack line, now with two conditions
else if (Time.time >= nextAttack && CanReach() && CanSee())
{
    nextAttack = Time.time + attackEvery;
    target.GetComponent<CharacterStats>().TakeDamage(damage);
}

第 5 步 —— 会自己找路的脑子

追击仍然是一条直线。在敌人和 Knight 之间放个方块,它就会顶在方块侧面不动了,对着永远穿不过的墙一个劲往前挤。它不绕路,因为没人告诉过它有路可绕。

Unity 能替它把路算好。把可行走的地板 Bake 一次,NavMeshAgent 就照着那张地图走:绕过方块、穿过缺口、来到你面前。

  1. 1

    打开 Window▸AI▸Navigation。找不到这个 tab 的话,就到 Window▸Package Manager 里安装 AI Navigation 包。

  2. 2

    选中 Floor 和父物体 Blocks,在 Inspector 右上角勾上 Navigation Static。它就是在声明“这块属于地图”。

  3. 3

    在 Navigation 窗口里,Bake 标签页 → Bake。地板上覆起一层蓝色、到方块前停住 —— 蓝色之处就是敌人能走的地方。

  4. 4

    在 Enemy prefab 上移除 Character Controller,添加 Nav Mesh Agent。移动从此归 agent 管,旧的那几行 cc.Move 也就一起退役了。

  5. 5

    把 agent 的 Radius 设成 0.4 左右、Height 设成 2,这样它挤得进两个方块之间,而不是嫌缝太窄不肯走。

EnemyBrain.cs — chasing with a NavMeshAgent
using UnityEngine;
using UnityEngine.AI;                       // NavMeshAgent lives here

[RequireComponent(typeof(NavMeshAgent))]
public class EnemyBrain : MonoBehaviour
{
    public Transform target;
    public float aggroRange = 9f;
    public float attackRange = 1.8f;
    public float speed = 2.6f;

    NavMeshAgent agent;

    void Awake()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.speed = speed;
        agent.stoppingDistance = attackRange * 0.9f;   // stop before it hugs you
    }

    void Update()
    {
        if (target == null) return;

        Vector3 flat = target.position - transform.position;
        flat.y = 0f;
        float distance = flat.magnitude;

        if (distance > aggroRange)
        {
            agent.ResetPath();                         // lost interest
            return;
        }

        if (distance > attackRange)
            agent.SetDestination(target.position);     // it works out the route
        else
            agent.ResetPath();                         // close enough: stand and swing
    }
}
  • SetDestination 就是全部的要求:你给它一个点,它把路找出来。你再也不用自己写寻路。

  • stoppingDistance 让 agent 不会一直贴到你身上才出手,它也替代了原先用来停下追击的距离检测。

  • 重力从脚本里消失了,因为 agent 自己会待在 Bake 好的表面上。agent 掉不出地图之外 —— 这也是它不会跟你跳上方块的原因。

  • CanSee 和 CanReach 原样保留。寻路只决定它怎么走到你跟前;能不能打到你,仍旧由这两个说了算。

✖ Chasing with transform.position +=

敌人会卡进你跳上去的那些方块里,然后就直挺挺地站在方块内部。

✓ Chasing with CharacterController.Move

它会被方块挡住而不是穿过去 —— 等它成了 NavMeshAgent,还会绕路走,这正是你跳上方块的意义所在。