加载 3D 模型并用 Unity 制作格斗游戏
格斗游戏需要能自然动作的角色:拳击、踢腿、格挡和死亡动画。这些角色来自带骨骼和动画的 3D 模型。本文同时讲两件事:用代码在游戏运行时加载模型,以及制作使用该模型的战斗系统,包括攻击、格挡、血量和 AI 对手。
注:代码保留了原始泰语版本中的注释。代码未在 Unity 编辑器中运行。测试是针对为本文编写的 Unity API 模拟库进行的,只验证脚本逻辑。模型和动画请在编辑器中检查。完整的模拟库文件见泰语版本。
需要准备的东西
- Unity Hub 和最新的 LTS 版 Unity 编辑器
- 带 Humanoid 骨骼和动画的 3D 角色模型,FBX 格式。有免费资源,但使用前请确认各来源的授权
- FPS 文章中的基础知识(脚本、Inspector、编译与运行测试)
第 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 很重要,它让其他角色的动画也能用在这个角色上。
第 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.Load<GameObject>` 无需扩展名,即可在 `Resources` 文件夹中查找文件。
- `Object.Instantiate` 会在场景中生成预制体的副本,因此可以多次生成。
- 脚本在查找文件和查找 Animator 时都检查 `null`,这样错误会立即显示在控制台中,而不是之后才出问题。
注:`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);
}
}
要点:
- 冷却时间(`attackCooldown`)防止玩家连按无限攻击。
- 格挡时无法攻击,并通过 `blockMultiplier` 降低受到的伤害。
- 所有状态变化都通过 Animator 的触发器进行,游戏逻辑无需知道动画如何播放。
- 命中判断使用简单距离,对学习项目足够。真实游戏通常会用 `Physics.OverlapBox` 等与动画匹配的受击盒。
第 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);
}
}
}
要点:
- 这个 AI 只有两个状态:接近和攻击。作为起点已经足够。真实游戏通常使用状态机或行为树,并加入防守、闪避和技能选择。
- 停止距离(1.5)应与 `Fighter` 的 `attackRange` 一致,这样 AI 到达后就能立即攻击。
第 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;
}
}
第 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 加载,并播放 Idle。
- 左键播放 Punch,对手血量减少。
- 按住右键播放 Block,受到的伤害降低。
- 对手在远处朝你走来,靠近后攻击。
- 血量归零时播放 Die 动画。
常见问题
- **模型不显示:** 检查预制体在 `Assets/Resources/Characters/` 中,且 `characterPath` 的名称完全一致。
- **控制台提示没有 Animator:** 检查预制体有 Animator 组件,并已分配控制器。
- **动画不切换:** 检查 Animator Controller 中的触发器名称与代码完全一致(`Attack`、`Hit`、`Die`)。
- **模型悬空或朝向错误:** 检查骨骼是否为 Humanoid,以及动画片段的 Root Motion 设置。
- **AI 不移动:** 检查 AI 的 Target 是否为玩家,以及 AI 的 `Fighter` 是否有 Animator。
后续方向
基本战斗可用后,可以加入连招(连续技)、跳跃与闪避、多个可选角色,以及命中时的音效和粒子特效。
总结
用代码加载模型使游戏可以灵活选择角色,而 Animator 让动画与战斗逻辑分离。战斗系统分为四个清晰的部分:战斗者、AI、胜负裁判和模型加载。每部分都可以单独测试,测试会在打开编辑器之前发现编译错误。