3Dモデルの読み込みとUnityで格闘ゲームを作る

格闘ゲームには、パンチ、キック、ガード、やられ演出などを自然に動かせるキャラクターが必要です。それらは骨格とアニメーションを持つ3Dモデルから作られます。この記事では2つのことを一緒に扱います。コードでゲーム実行中にモデルを読み込むこと、そしてそのモデルを使う戦闘システムを作ることです。戦闘システムには、攻撃、ガード、体力、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` が true のときに入り、false で戻る Block 状態を作ります。

ステップ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 を作り、`characterPath` を `Characters/Fighter` にした `ModelLoader` と `Fighter` を追加します。

3. `ModelLoader` の Animator を使って `Fighter` の `Init` を呼び、体力を100から始めます。通常はシーン用の小さなスクリプトで行います。

4. 同じ方法で `Enemy` を作り、`Target` を `Player` にした `FighterAI` を追加します。

5. `RoundManager` を作り、両方の戦闘員を設定します。

この簡単な操作では、左クリックで攻撃、右クリック長押しでガードします。

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 がない場合のモデル読み込み、勝敗判定を確認します。

コンパイルと実行の手順:スタブ、テストファイル、4つのスクリプト(`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、勝敗判定、モデル読み込みの4つに分かれ、それぞれ単独でテストできます。テストは、エディタを開く前にコンパイルエラーを見つけてくれます。