Memuat Model 3D dan Membuat Game Pertarungan di Unity
Game pertarungan membutuhkan karakter yang bergerak dengan meyakinkan: pukulan, tendangan, blok, dan animasi mati. Karakter itu berasal dari model 3D yang sudah memiliki kerangka dan animasi. Artikel ini membahas dua hal sekaligus: memuat model ke dalam game saat runtime dari kode, dan membangun sistem pertarungan yang memakainya, termasuk serangan, blok, kesehatan, dan lawan AI.
Catatan: kode mempertahankan komentar bahasa Thailand dari versi asli. Kode belum dijalankan di Unity Editor. Pengujian dijalankan terhadap API Unity tiruan yang ditulis untuk artikel ini, yang hanya memastikan logika skrip. Periksa model dan animasi di editor. Berkas stub lengkapnya ada di versi Thailand.
Yang Anda butuhkan
- Unity Hub dan Unity Editor versi LTS terbaru
- Model karakter 3D dengan rig Humanoid dan animasi, dalam format FBX. Ada opsi gratis, tetapi periksa lisensi setiap sumber sebelum memakainya
- Dasar-dasar dari artikel FPS (skrip, Inspector, kompilasi, dan menjalankan tes)
Langkah 1: buat proyek dan impor model
1. Di Unity Hub, klik **New project**, pilih templat **3D (URP)** atau **3D**, lalu beri nama `MyFighter`.
2. Seret berkas FBX karakter ke `Assets/Models/`.
3. Pilih FBX. Di Inspector, pada bagian **Rig**, atur **Animation Type** menjadi **Humanoid** lalu klik **Apply**.
4. Pada bagian **Animation**, pastikan klip-kliponnya ada: Idle, Punch, Kick, Block, Hit, dan Die.
Pengaturan Humanoid penting karena memungkinkan animasi dari karakter lain bekerja pada karakter ini.
Langkah 2: buat Animator Controller
1. Klik kanan di `Assets` lalu pilih **Create → Animation → Animator Controller**. Beri nama `FighterController`.
2. Klik dua kali untuk membuka jendela Animator. Seret klip Idle ke dalam graf dan jadikan sebagai state default (oranye).
3. Buat parameter **Trigger** bernama `Attack`, `Hit`, dan `Die`, serta satu **Bool** bernama `Block`.
4. Buat state Punch yang dicapai dari Idle dengan trigger `Attack`, dan state Hit serta Die dengan cara yang sama.
5. Buat state Block yang dimasuki ketika `Block` bernilai true dan keluar ketika bernilai false.
Langkah 3: muat model saat runtime dari kode
Memuat dari kode berguna ketika game memungkinkan pemain memilih di antara beberapa karakter tanpa menaruh semuanya di scene sebelumnya. Cara paling sederhana adalah menyimpan prefab di `Assets/Resources/` dan memuatnya berdasarkan nama.
1. Seret prefab karakter, dengan Animator Controller sudah terpasang, ke `Assets/Resources/Characters/Fighter.prefab`.
2. Buat skrip bernama `ModelLoader` dan tempelkan kode ini.
using UnityEngine;
// โหลดโมเดลตัวละครตอนรันเกม จากโฟลเดอร์ Resources แล้วติดตั้งไว้ใต้จุดที่กำหนด
// ก่อนใช้งานให้วางไฟล์ Prefab ไว้ที่ Assets/Resources/Characters/<ชื่อ>.prefab
// Memuat model karakter saat game berjalan dari folder Resources, lalu memasangnya di bawah titik yang ditentukan.
// Sebelum dipakai, letakkan berkas Prefab di Assets/Resources/Characters/<nama>.prefab
public class ModelLoader : MonoBehaviour
{
[SerializeField] private string characterPath = "Characters/Fighter"; // ไม่ต้องใส่นามสกุลไฟล์ / tanpa ekstensi berkas
[SerializeField] private Transform modelParent; // จุดที่จะวางโมเดล / tempat model diletakkan
private GameObject model;
private Animator animator;
public GameObject Model => model;
public Animator Animator => animator;
private void Awake()
{
Load();
}
// คืนค่า true ถ้าโหลดสำเร็จและมี Animator ให้ควบคุม / true jika berhasil dimuat dan ada Animator untuk dikendalikan
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;
}
}
Poin-poin penting:
- `Resources.Load<GameObject>` menemukan berkas di folder `Resources` tanpa memerlukan ekstensi.
- `Object.Instantiate` membuat salinan prefab di scene, sehingga Anda bisa memunculkannya beberapa kali.
- Skrip memeriksa `null` baik saat mencari berkas maupun saat mencari Animator, sehingga error langsung muncul di Console dan tidak gagal belakangan.
Catatan: `Resources` cocok untuk proyek kecil dan belajar. Proyek yang lebih besar dan memuat banyak berkas sebaiknya memakai Addressables.
Langkah 4: si pejuang
`Fighter` menyimpan kesehatan, menerima damage, memblokir, menyerang, dan menggerakkan Animator agar animasi yang tepat dimainkan.
using UnityEngine;
// ตัวละครนักสู้: มีเลือด โจมตี ป้องกัน และตาย ผ่าน Animator เพื่อให้เล่นท่าทางตามเหตุการณ์
// Karakter pejuang: punya darah, menyerang, bertahan, dan mati melalui Animator agar gerakan sesuai kejadian
public class Fighter : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
[SerializeField] private int attackDamage = 10;
[SerializeField] private float attackRange = 1.5f; // ระยะที่โจมตีถึง (เมตร) / jarak jangkau serangan (meter)
[SerializeField] private float attackCooldown = 0.6f; // เวลาพักหลังโจมตีแต่ละครั้ง (วินาที) / jeda setelah tiap serangan (detik)
[SerializeField] private float blockMultiplier = 0.5f; // ป้องกันแล้วรับดาเมจเหลือกี่เปอร์เซ็นต์ / saat bertahan, damage yang diterima tinggal berapa persen
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 ถ้าการโจมตีเกิดขึ้น (ไม่ได้อยู่ในช่วงพัก ไม่ได้ตาย ไม่ได้กำลังป้องกัน)
// Menyerang lawan. true jika serangan terjadi (tidak sedang jeda, tidak mati, tidak sedang bertahan)
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);
}
}
Poin-poin penting:
- Cooldown (`attackCooldown`) mencegah pemain menekan serangan berulang-ulang.
- Saat bertahan, pejuang tidak bisa menyerang dan menerima damage yang berkurang melalui `blockMultiplier`.
- Setiap perubahan state melewati trigger Animator, sehingga logika game tidak perlu tahu bagaimana animasi dimainkan.
- Hit ditentukan dengan jarak sederhana. Itu cukup untuk proyek belajar, tetapi game sungguhan biasanya memakai hitbox seperti `Physics.OverlapBox` yang sesuai dengan animasi.
Langkah 5: lawan AI
`FighterAI` berjalan menuju pemain dan menyerang ketika berada dalam jangkauan.
using UnityEngine;
// สมองของคู่ต่อสู้: เดินเข้าหาผู้เล่น และโจมตีเมื่ออยู่ในระยะ
// Otak lawan: berjalan mendekati pemain dan menyerang ketika sudah dalam jangkauan
[RequireComponent(typeof(Fighter))]
public class FighterAI : MonoBehaviour
{
[SerializeField] private Fighter target; // ผู้เล่น / pemain
[SerializeField] private float walkSpeed = 2f; // ความเร็วเดินเข้าหา (เมตรต่อวินาที) / kecepatan mendekat (meter per detik)
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)
{
// ยังไกลเกินไป ให้เดินเข้าหาเป้าหมาย / masih terlalu jauh, berjalan mendekati target
Vector3 direction = (target.transform.position - transform.position).normalized;
transform.position = transform.position + direction * walkSpeed * Time.deltaTime;
}
else
{
// อยู่ในระยะแล้ว ลองโจมตี (ถ้าพักอยู่จะไม่โจมตี) / sudah dalam jangkauan, coba menyerang (jika sedang jeda, tidak menyerang)
self.Attack(target);
}
}
}
Poin-poin penting:
- AI ini hanya punya dua state, mendekat dan menyerang. Ini awal yang baik. Game sungguhan biasanya memakai state machine atau behaviour tree yang juga mencakup bertahan, menghindar, dan memilih gerakan.
- Jarak berhenti (1.5) sebaiknya sama dengan `attackRange` milik pejuang, agar AI bisa menyerang begitu sampai.
Langkah 6: tentukan pemenang
`RoundManager` memeriksa siapa yang mati dan mengumumkan pemenangnya.
using UnityEngine;
// ตัดสินผลแมตช์: ฝ่ายที่เหลือเลือดหรือยืนอยู่หลังอีกฝ่ายตายคือผู้ชนะ
// Memutuskan hasil pertandingan: pihak yang masih punya darah atau masih berdiri setelah lawannya mati adalah pemenang
public class RoundManager : MonoBehaviour
{
[SerializeField] private Fighter player;
[SerializeField] private Fighter enemy;
// คืน "Player" หรือ "Enemy" เมื่อมีคนตาย, คืน null ถ้าการต่อสู้ยังไม่จบ
// Mengembalikan "Player" atau "Enemy" ketika ada yang mati, null jika pertarungan belum selesai
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;
}
}
Langkah 7: rakit scene dan kendalikan pemain
1. Tambahkan tanah dan pencahayaan ke scene.
2. Buat GameObject bernama `Player`. Tambahkan `ModelLoader` dengan `characterPath` diisi `Characters/Fighter`, lalu tambahkan `Fighter`.
3. Panggil `Init` pada `Fighter` dengan Animator dari `ModelLoader`, sehingga kesehatan mulai dari 100. Skrip kecil pengendali scene adalah tempat biasanya untuk ini.
4. Buat GameObject `Enemy` dengan cara yang sama, lalu tambahkan `FighterAI` dengan `Target` diisi `Player`.
5. Buat GameObject `RoundManager` dan hubungkan kedua pejuang.
Pengendali sederhana ini memakai klik kiri untuk menyerang dan menahan klik kanan untuk bertahan.
using UnityEngine;
// ผู้เล่นควบคุมด้วยเมาส์: คลิกซ้ายโจมตี คลิกขวาค้างเพื่อป้องกัน
// Pemain dikendalikan dengan mouse: klik kiri untuk menyerang, tahan klik kanan untuk bertahan
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));
}
}
Langkah 8: pengujian otomatis
Pengujian memakai pendekatan yang sama dengan artikel FPS. Berkas stub (`FightStubs.cs`) dan berkas tes 24 kasus (`FightTests.cs`) ada di versi Thailand. Pengujian mencakup serangan dalam dan luar jangkauan, cooldown, bertahan, kematian, pejuang mati yang tidak bisa menyerang, AI yang mendekat dan menyerang, pemuatan model dengan berkas yang hilang dan tanpa Animator, serta logika pemenang.
Cara mengompilasi dan menjalankan: salin stub, berkas tes, dan keempat skrip (`ModelLoader.cs`, `Fighter.cs`, `FighterAI.cs`, `RoundManager.cs`) ke folder `build`, hapus `using UnityEngine;` hanya pada salinan, kompilasi dengan kompiler C# terbaru (misalnya `csc -langversion:latest -out:FightTests.exe *.cs`), lalu jalankan `FightTests.exe`.
Hasil pengujian
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
Hasil ini berasal dari stub, bukan dari Unity. Tampilan model, waktu animasi, dan deteksi hit yang sebenarnya tetap perlu diperiksa di editor.
Satu bug yang dibantu dicegah oleh tes: versi awal `Fighter` memakai `Mathf.Max(0, health - finalDamage)`. Itu tidak bisa dikompilasi, karena `Mathf.Max` menerima float sedangkan `health` bertipe int. Perbaikannya adalah pengurangan langsung dengan pembatasan di nol.
Langkah 9: uji di editor
Tekan **Play** dan periksa:
- Model pemain termuat dengan Animator-nya dan memainkan Idle.
- Klik kiri memainkan Punch, dan darah musuh berkurang.
- Tahan klik kanan memainkan Block, dan damage berkurang.
- Musuh berjalan mendekat dari kejauhan dan menyerang ketika sudah dekat.
- Ketika kesehatan mencapai nol, animasi Die dimainkan.
Masalah umum
- **Model tidak muncul:** periksa prefab berada di `Assets/Resources/Characters/` dan nama di `characterPath` sama persis.
- **Console menyebut tidak ada Animator:** pastikan prefab memiliki komponen Animator dengan controller terpasang.
- **Animasi tidak berubah:** pastikan nama trigger di Animator Controller sama persis dengan kode (`Attack`, `Hit`, `Die`).
- **Model melayang atau berputar salah:** pastikan rig adalah Humanoid dan pengaturan Root Motion pada klip sudah benar.
- **AI tidak bergerak:** pastikan kolom Target pada AI berisi pemain dan `Fighter` milik AI memiliki Animator.
Langkah berikutnya
Setelah pertarungan dasar berjalan, Anda bisa menambahkan kombo (gerakan berantai), sistem lompat dan menghindar, beberapa karakter yang bisa dipilih, serta efek suara dan partikel saat terkena pukulan.
Ringkasan
Memuat model dari kode memungkinkan game memilih karakter secara fleksibel, dan Animator memisahkan animasi dari logika pertarungan. Sistem pertarungan dibagi menjadi empat bagian yang jelas: pejuang, AI, hakim ronde, dan pemuat model. Setiap bagian bisa diuji sendiri, dan tes menangkap error kompilasi sebelum Anda membuka editor.