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

Skema FBX Inspector (bukan tangkapan layar asli): 1 berkas di Assets/Models 2 tab Rig 3 dropdown Animation Type 4 tombol Apply
Skema FBX Inspector (bukan tangkapan layar asli): 1 berkas di Assets/Models 2 tab Rig 3 dropdown Animation Type 4 tombol Apply

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.

Skema jendela Animator (bukan tangkapan layar asli): 1 tombol tambah parameter 2 nama dan tipe parameter 3 state Idle, berwarna oranye sebagai default 4 transisi ke state untuk setiap gerakan
Skema jendela Animator (bukan tangkapan layar asli): 1 tombol tambah parameter 2 nama dan tipe parameter 3 state Idle, berwarna oranye sebagai default 4 transisi ke state untuk setiap gerakan

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:

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:

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:

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;
    }
}
Skema Player Inspector (bukan tangkapan layar asli): 1 Character Path 2 Max Health 3 Self 4 Opponent
Skema Player Inspector (bukan tangkapan layar asli): 1 Character Path 2 Max Health 3 Self 4 Opponent

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:

Masalah umum

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.