加载 3D 模型并用 Unity 制作格斗游戏

格斗游戏需要能自然动作的角色:拳击、踢腿、格挡和死亡动画。这些角色来自带骨骼和动画的 3D 模型。本文同时讲两件事:用代码在游戏运行时加载模型,以及制作使用该模型的战斗系统,包括攻击、格挡、血量和 AI 对手。

注:代码保留了原始泰语版本中的注释。代码未在 Unity 编辑器中运行。测试是针对为本文编写的 Unity API 模拟库进行的,只验证脚本逻辑。模型和动画请在编辑器中检查。完整的模拟库文件见泰语版本。

需要准备的东西

FBX 的 Inspector 示意图(不是实际截图):1 Assets/Models 中的文件 2 Rig 标签 3 Animation Type 下拉框 4 Apply 按钮
FBX 的 Inspector 示意图(不是实际截图):1 Assets/Models 中的文件 2 Rig 标签 3 Animation Type 下拉框 4 Apply 按钮

第 1 步:创建项目并导入模型

1. 在 Unity Hub 中点击 **New project**,选择 **3D (URP)** 或 **3D** 模板,命名为 `MyFighter`。

2. 把角色的 FBX 文件拖到 `Assets/Models/`。

3. 选中 FBX,在 Inspector 的 **Rig** 中把 **Animation Type** 设为 **Humanoid**,然后点击 **Apply**。

4. 在 **Animation** 中确认有 Idle、Punch、Kick、Block、Hit、Die 等动画片段。

设置 Humanoid 很重要,它让其他角色的动画也能用在这个角色上。

Animator 窗口示意图(不是实际截图):1 添加参数按钮 2 参数名称与类型 3 默认的 Idle 状态(橙色) 4 通往各动作状态的过渡
Animator 窗口示意图(不是实际截图):1 添加参数按钮 2 参数名称与类型 3 默认的 Idle 状态(橙色) 4 通往各动作状态的过渡

第 2 步:创建 Animator Controller

1. 在 `Assets` 中右键,选择 **Create → Animation → Animator Controller**,命名为 `FighterController`。

2. 双击打开 Animator 窗口,把 Idle 片段拖入图中,并设为默认状态(橙色)。

3. 创建 **Trigger** 参数 `Attack`、`Hit`、`Die`,以及 **Bool** 参数 `Block`。

4. 创建从 Idle 通过 `Attack` 触发进入的 Punch 状态,Hit 和 Die 也用同样方法创建。

5. 创建 Block 状态,当 `Block` 为 true 时进入,为 false 时退出。

第 3 步:用代码在运行时加载模型

用代码加载的好处是,玩家可以在多个角色之间选择,而无需提前把所有角色都放进场景。最简单的做法是把预制体放在 `Assets/Resources/` 中,然后按名称加载。

1. 把已设置好 Animator Controller 的角色预制体放到 `Assets/Resources/Characters/Fighter.prefab`。

2. 创建名为 `ModelLoader` 的脚本,粘贴以下代码。

using UnityEngine;

// โหลดโมเดลตัวละครตอนรันเกม จากโฟลเดอร์ Resources แล้วติดตั้งไว้ใต้จุดที่กำหนด
// ก่อนใช้งานให้วางไฟล์ Prefab ไว้ที่ Assets/Resources/Characters/<ชื่อ>.prefab
public class ModelLoader : MonoBehaviour
{
    [SerializeField] private string characterPath = "Characters/Fighter";  // ไม่ต้องใส่นามสกุลไฟล์
    [SerializeField] private Transform modelParent;                       // จุดที่จะวางโมเดล

    private GameObject model;
    private Animator animator;

    public GameObject Model => model;
    public Animator Animator => animator;

    private void Awake()
    {
        Load();
    }

    // คืนค่า true ถ้าโหลดสำเร็จและมี Animator ให้ควบคุม
    public bool Load()
    {
        GameObject prefab = Resources.Load<GameObject>(characterPath);
        if (prefab == null)
        {
            Debug.LogError("ไม่พบโมเดลที่ Resources/" + characterPath);
            return false;
        }

        model = Object.Instantiate(prefab, modelParent);
        animator = model.GetComponent<Animator>();
        if (animator == null)
        {
            Debug.LogError("โมเดลนี้ไม่มี Animator");
            return false;
        }
        return true;
    }
}

要点:

注:`Resources` 适合小型项目和学习。加载大量文件的大型项目应使用 Addressables。

第 4 步:战斗角色

`Fighter` 负责保存血量、承受伤害、格挡、攻击,并驱动 Animator 播放相应动画。

using UnityEngine;

// ตัวละครนักสู้: มีเลือด โจมตี ป้องกัน และตาย ผ่าน Animator เพื่อให้เล่นท่าทางตามเหตุการณ์
public class Fighter : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;
    [SerializeField] private int attackDamage = 10;
    [SerializeField] private float attackRange = 1.5f;     // ระยะที่โจมตีถึง (เมตร)
    [SerializeField] private float attackCooldown = 0.6f;  // เวลาพักหลังโจมตีแต่ละครั้ง (วินาที)
    [SerializeField] private float blockMultiplier = 0.5f; // ป้องกันแล้วรับดาเมจเหลือกี่เปอร์เซ็นต์

    private int health;
    private bool isBlocking;
    private float nextAttackTime;
    private Animator animator;

    public int Health => health;
    public bool IsDead => health <= 0;
    public bool IsBlocking => isBlocking;

    public void Init(Animator animatorSource)
    {
        animator = animatorSource;
        health = maxHealth;
    }

    // โจมตีคู่ต่อสู้ คืน true ถ้าการโจมตีเกิดขึ้น (ไม่ได้อยู่ในช่วงพัก ไม่ได้ตาย ไม่ได้กำลังป้องกัน)
    public bool Attack(Fighter target)
    {
        if (IsDead || isBlocking || Time.time < nextAttackTime) return false;

        nextAttackTime = Time.time + attackCooldown;
        SetTrigger("Attack");

        if (DistanceTo(target) <= attackRange)
        {
            target.TakeHit(attackDamage);
        }
        return true;
    }

    public void SetBlocking(bool value)
    {
        if (IsDead) return;
        isBlocking = value;
        animator?.SetBool("Block", value);
    }

    public void TakeHit(int damage)
    {
        if (IsDead) return;

        int finalDamage = isBlocking ? (int)(damage * blockMultiplier) : damage;
        health -= finalDamage;
        if (health < 0) health = 0;

        if (IsDead)
        {
            SetTrigger("Die");
        }
        else
        {
            SetTrigger("Hit");
        }
    }

    public float DistanceTo(Fighter other)
    {
        return Vector3.Distance(transform.position, other.transform.position);
    }

    private void SetTrigger(string name)
    {
        animator?.SetTrigger(name);
    }
}

要点:

第 5 步:AI 对手

`FighterAI` 会朝玩家走去,进入范围后攻击。

using UnityEngine;

// สมองของคู่ต่อสู้: เดินเข้าหาผู้เล่น และโจมตีเมื่ออยู่ในระยะ
[RequireComponent(typeof(Fighter))]
public class FighterAI : MonoBehaviour
{
    [SerializeField] private Fighter target;         // ผู้เล่น
    [SerializeField] private float walkSpeed = 2f;   // ความเร็วเดินเข้าหา (เมตรต่อวินาที)

    private Fighter self;

    private void Awake()
    {
        self = GetComponent<Fighter>();
    }

    private void Update()
    {
        if (self.IsDead || target == null || target.IsDead) return;

        if (self.DistanceTo(target) > 1.5f)
        {
            // ยังไกลเกินไป ให้เดินเข้าหาเป้าหมาย
            Vector3 direction = (target.transform.position - transform.position).normalized;
            transform.position = transform.position + direction * walkSpeed * Time.deltaTime;
        }
        else
        {
            // อยู่ในระยะแล้ว ลองโจมตี (ถ้าพักอยู่จะไม่โจมตี)
            self.Attack(target);
        }
    }
}

要点:

第 6 步:判定胜负

`RoundManager` 检查谁已倒下,并宣布胜者。

using UnityEngine;

// ตัดสินผลแมตช์: ฝ่ายที่เหลือเลือดหรือยืนอยู่หลังอีกฝ่ายตายคือผู้ชนะ
public class RoundManager : MonoBehaviour
{
    [SerializeField] private Fighter player;
    [SerializeField] private Fighter enemy;

    // คืน "Player" หรือ "Enemy" เมื่อมีคนตาย, คืน null ถ้าการต่อสู้ยังไม่จบ
    public string GetWinner()
    {
        if (enemy.IsDead && !player.IsDead) return "Player";
        if (player.IsDead && !enemy.IsDead) return "Enemy";
        if (player.IsDead && enemy.IsDead) return "Draw";
        return null;
    }
}
Player 的 Inspector 示意图(不是实际截图):1 Character Path 2 Max Health 3 Self 4 Opponent
Player 的 Inspector 示意图(不是实际截图):1 Character Path 2 Max Health 3 Self 4 Opponent

第 7 步:搭建场景并控制玩家

1. 在场景中添加地面和灯光。

2. 创建名为 `Player` 的 GameObject,添加 `ModelLoader`(`characterPath` 设为 `Characters/Fighter`)和 `Fighter`。

3. 用 `ModelLoader` 中的 Animator 调用 `Fighter` 的 `Init`,使血量从 100 开始。通常在一个场景控制脚本中完成。

4. 用同样的方法创建 `Enemy`,再添加 `FighterAI`,并把 `Target` 设为 `Player`。

5. 创建 `RoundManager` 的 GameObject,并关联两个战斗者。

这个简单的控制器用左键攻击,按住右键格挡。

using UnityEngine;

// ผู้เล่นควบคุมด้วยเมาส์: คลิกซ้ายโจมตี คลิกขวาค้างเพื่อป้องกัน
public class PlayerInput : MonoBehaviour
{
    [SerializeField] private Fighter self;
    [SerializeField] private Fighter opponent;

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            self.Attack(opponent);
        }
        self.SetBlocking(Input.GetMouseButton(1));
    }
}

第 8 步:自动化测试

测试方法与 FPS 文章相同。模拟库文件(`FightStubs.cs`)和 24 项测试文件(`FightTests.cs`)见泰语版本。测试涵盖范围内与范围外的攻击、冷却、格挡、死亡、死亡角色无法攻击、AI 接近与攻击、预制体缺失和没有 Animator 时的模型加载,以及胜负判定。

编译与运行:把模拟库、测试文件和四个脚本(`ModelLoader.cs`、`Fighter.cs`、`FighterAI.cs`、`RoundManager.cs`)复制到 `build` 文件夹,只在副本中删除 `using UnityEngine;`,用较新的 C# 编译器编译(例如 `csc -langversion:latest -out:FightTests.exe *.cs`),然后运行 `FightTests.exe`。

测试结果

PASS  attack in range: attack starts
PASS  attack in range: defender loses 10 health (100 to 90)
PASS  attack in range: defender plays Hit animation
PASS  attack: attacker plays Attack animation
PASS  attack out of range: no damage (still 100)
PASS  cooldown: first attack succeeds
PASS  cooldown: second attack at 0.3 s is refused
PASS  cooldown: only one hit landed (90)
PASS  cooldown: attack after 0.7 s is allowed
PASS  block: damage halved (100 to 95)
PASS  death: fighter is dead at 0 health
PASS  death: Die animation is played
PASS  dead fighter cannot attack
PASS  damage to a dead fighter is ignored
PASS  AI approaches the player when far
PASS  AI attacks when in range
PASS  model loader: loads the prefab from Resources
PASS  model loader: finds the Animator on the model
PASS  model loader: missing file returns false
PASS  model loader: missing file logs an error
PASS  model loader: prefab without Animator returns false
PASS  round: no winner while both are alive
PASS  round: player wins when enemy is dead
PASS  round: enemy wins when player is dead

Passed 24 / Failed 0

这些结果来自模拟库,而不是 Unity。模型外观、动画时机和实际命中判定仍需在编辑器中检查。

测试帮助避免的一个错误:早期版本的 `Fighter` 使用了 `Mathf.Max(0, health - finalDamage)`。这段代码无法编译,因为 `Mathf.Max` 接受 float,而 `health` 是 int。修复方法是直接相减并在 0 处截断。

第 9 步:在编辑器中测试

按 **Play** 后检查:

常见问题

后续方向

基本战斗可用后,可以加入连招(连续技)、跳跃与闪避、多个可选角色,以及命中时的音效和粒子特效。

总结

用代码加载模型使游戏可以灵活选择角色,而 Animator 让动画与战斗逻辑分离。战斗系统分为四个清晰的部分:战斗者、AI、胜负裁判和模型加载。每部分都可以单独测试,测试会在打开编辑器之前发现编译错误。