ティミーの伝説

初めての3Dゲームを作ってみましょう

プロジェクト設定

まず、新規プロジェクトを作成しましょう。2Dゲームであるため「Universal 3D」を選んでください。プロジェクト名は「legend」にしましょう。

image.png

素材を追加

すべての素材をプロジェクトに追加しましょう。

プロトタイプ用のシーンを構築

本番のシーンを作る前、各部品を個別で開発しなければならない。キャラクターの移動やカメラの操作などを作業用のシーンで開発し、出来上がったら本番シーンに入れると快適に仕事ができる。

それでは、「Scenes」フォルダーを作成し「Prototype」の新しいシーンを追加してください。その後、床面になる「キューブ」を追加し、サイズは [50 x 0.1 x 50] で広い遊ぶ場所を準備しましょう

image.png

ただし、このままだと、真っ白な床面で動きなどが把握できないので、単純なプロトタイプ用のテクスチャ(greybox_grey_grid)をドラッグドロップで追加しましょう。ただし、床面のサイズは50倍大きくなったので、テクスチャも50回をタイル化しましょう。床の「Material」(マテリアル)を選択し、「Tiling」を 50 x 50 にしてください。

image.png

また、坂を上るとジャンプのテストもやりたいので、ランプと箱を追加しましょう。ランプはキューブから作成し、斜めに倒せば良いでしょう:

image.png

最後、ただのキューブを作成し、ランプとつながるようにする:

image.png

素材を作成する前に、ゲームの操作、面白さ、楽しさを確かめらためのプロトタイプシーンは「Grayboxing」(グレーボクシング)という。ある程度コストを削減し、まずゲーム性を確かめるためのシンプルなレベルである。


Timmyの移動を実装

主人公のゲームオブジェクトを作成

主人公のキャラクターの移動を実装しましょう。まず、空のゲームオブジェクトを作成し、「Player」という名前を付けてください。このゲームオブジェクトはプレーヤーの役割にする。

3Dモデルを追加

Characters/Timmy/Models から Timmy のモデルを追加してください。Player の子オブジェクトとして追加しましょう:

image.png

よく見ると、色が多少おかしい。これはノーマルマップ(※)の問題であるので、解決しましょう。Characters/Timmy/Textures の中にあるノーマルテクスチャ(青い画像)を選択し、ノーマルマップであることを指定し、最後に「Apply」を押してください:

image.png

 

(※)ノーマルマップについては、また「ゲームエンジンII」で勉強する予定

image.png

物理処理の準備

キャラクターを物理的に移動するべきなので、コライダーとリジッドボディが必要。Player に Rigidbody と CapsuleCollider を追加してください。なお、コライダーの形がある程度 3Dモデルの体に合わせましょう:

image.png

スクリプト

Inputクラスを使用し、キャラクターを動かしましょう。後で改善するべきですが、とりあえず、WASD のキーで前後左右を移動しましょう:

image.png

AとDでX軸(赤い軸)で移動し、W とSでZ軸(青い軸)で移動する。なお、各入力は:

また、移動速度をUnityで変えられるようにしましょう。この知識を活用し、移動のスクリプト「PlayerMove」を実装:

// プレーヤーの移動を処理する
public class PlayerMove : MonoBehaviour
{
    // 速度をInspectorで設定
    [SerializeField]
    private float speed = 5.0f;
    
    // 物理的に移動するので、リジッドボディが必要
    private Rigidbody rbody;

    // 3D空間の速度ベクトル
    private Vector3 velocity;

    private void Start()
    {
        // 参照を取っておく
        rbody = GetComponent<Rigidbody>();
    }

    private void Update()
    {
        // 何も入力がない前提で、速度をゼロにする
        velocity = Vector3.zero;
        
        // A/S(水平入力)を取得し、3D空間のX軸に割り当てる
        velocity.x = Input.GetAxis("Horizontal");
        
        // W/D (垂直入力)を取得し、3D空間のZ軸に割り当てる
        velocity.z = Input.GetAxis("Vertical");
        
        // 単位ベクトルにする
        velocity.Normalize();
        
        // 速度を乗算する
        velocity *= speed;
    }

    private void FixedUpdate()
    {
        // 速度を設定
        rbody.linearVelocity = velocity;
    }
}
Normalize()とは

Normalize()はあるベクトルを長さ1(単位ベクトル)にする。今回の処理は以下の図で解析してある:

image.png

上下、または左右 のみ で移動する場合は、GetAxis が -1~1 の数値を返すので、移動速度「speed」を掛けると、問題なく、長さ5の速度ベクトルができる。一方、同時に入力すると(斜め移動)

image.png

掛け算の前に単位ベクトルにすると:

image.png

実行確認

このスクリプトを「Player」にアタッチし、実行してみてください。移動し、ランプを上り、ジャンプしてみてください。なお、カメラの処理まだ行っていないので、Game ビューと Scene ビューを同時に表示するのはおすすめ。

問題:倒れたり、回転したりする

すべての動きをリジッドボディに任せているため、移動だけではなく、回転も自由になっている。今後、回転はスクリプトで実現するので、リジッドボディが回転ができないようにすれば良いでしょう。制約設定で回転を固定しましょう:

image.png

問題:ゆっくり落ちる

回転の問題を解決できたが、ランプから飛び出すと、ゆっくりに落ちる。なぜなら、Updateごとに速度を上書きしているからです!重力による落下をリジッドボディに任せても、縦軸(Y軸)の速度をゼロにしているので、なかなか落ちない!

解決としては、Y軸の速度を保守し、XとZだけを上書きすれば良い。FixedUpdate を以下のように直しましょう:

private void FixedUpdate()
{
    // 現在の速度を取得
    Vector3 v = rbody.linearVelocity;
    
    // XとZ を上書き
    v.x = velocity.x;
    v.z = velocity.z;
    
    // 速度を設定
    rbody.linearVelocity = v;
}

これで確認しましょう。

主人公を追いかけるカメラ

Timmyの移動できたとしても、操作が確認しにくいので、プレーヤーを追いかけるカメラ(三人称視点)を作りましょう

単純なアプローチ

一番簡単なのは「カメラの位置を主人公の位置にしろ!」の仕組みである。これをすぐ試せるので、「CameraController」スクリプトを作成してみましょう:

スクリプト

// 主人公を追いかけるカメラ
public class CameraController : MonoBehaviour
{
    // 追いかけるオブジェクトのTransform
    [SerializeField] 
    private Transform target;

    private void Update()
    {
        // 自分の位置をターゲットの位置にするだけ
        transform.position = target.position;
    }
}

このスクリプトをカメラにアタッチし、確認しましょう。なお、Unity でカメラの「target」を「Player」にしてください。

image.png

改善:セルフィスティック

今の処理はとても単純で、カメラはプレーヤーの足元に行ってしまいました。もう少し賢い作戦を考えてみましょう。「三人称」といえば:

image.png

セルフィスティックは良いでしょう!シーンにあるカメラの親子関係を変えて、同じ仕組みになるようにしましょう。具体的に:

image.png

これはヒエラルキー(とその位置)はこうなる:

image.png


image.png

image.png

image.png

image.png

なお、「CameraController」のスクリプトは、カメラではなく、実際に動く基礎(Base)アタッチすべき。Main Camera からスクリプトを削除するのは、右側にある「」を押し、「Remove Component」(コンポーネントを削除)でできる。

image.png

では、「CameraController」をBaseにアタッチし、もう一度確認しましょう。

カメラをマウスで回転

カメラは主人公に追いかけても、周りを自由に見たいので、マウスでカメラを回転させたい。今のカメラの作りだと簡単に責任を分けることができる!

IMAGE: Height rotating and Pole pivoting

そして、WASDの入力は Input.GetAxis("Horizontal");Input.GetAxis("Vertical"); で取得できたが、同じようにカメラの回転 Input.GetAxis("CamRotY"); とカメラの上下角度 Input.GetAxis("CamRotX"); を取れれば良いけど、この入力軸が定義されていない。

新しい入力を定義する

入力マネージャーの紹介

※以下は「入力マネージャー(旧)」に対する話しである。2年生になったら「入力システム(新)」に切り替える予定。

"Horizontal" (水平入力)と "Vertical" (垂直入力)が魔法の言葉ではなく、プロジェクト設定として定義されている入力である。これをトップメニューの「Edit / Project Settings」(編集 / プロジェクト設定)の中にある「Input Manager」(入力マネージャー)から確認ができる。

image.png

ここで「Horizontal」という入力名は「Positive」(正の数)と「Negative」(負の数)があるので、‐1~1の間の「軸」であることを確認できる。つまり、Negative Buttonの「a」を押すと、Input.GetAxis("Horizontal") が -1 を返す、Positive Button の「d」を押すとInput.GetAxis("Horizontal") が1を返す。この行動がすでに使用し、Timmyを動かしました。

その他には、Fire1~Fire3 や Jump の入力名がある。これはデフォルトとして定義されている入力であるが、変えたり、消したり、増やしたりすることが可能である。

「Jump」を確認してみましょう:

image.png

この入力は「Positive」のみで、「Negative」がない。つまり、この入力は「軸」ではなく「ボタン」である。ボタンは「Input.GetButtonXXX("入力名")」で取得でき、「true」または「false」を返す。GetMouseButtonXXX と同様に3パターンがある:

デフォルト設定としては「Jump」という入力は「space」に設置されているので、スペースキーを押したら GetButtonDown("Jump") は true になる!

もちろん、これらあくまで初期設定なので、キーを変えて良いし、名前も変えても大丈夫(例えば、「Kick」「Punch」「Attack」など)

もうちょっと見ると、多くの入力は2回が現れる!もう一つの "Horizontal" を確認すると:

image.png

あれ?「Positive」も「Negative」もない?なにこれ?

これは、"Horizontal" に対するもう一つの入力処理である。Typeを詳しくみると、「Joystick」というキーワードが書かれている。

そう!これはコントローラーに対応する入力。A/Dを使えば、"Horizontal" が発動するし、コントローラーの「X Axis」を使うと、同じ "Horizontal" が発動する。

つまり、入力マネージャーが入力ハードウェアを抽象化してくれる機能である!

キーワード&マウス、コントローラーを関係なく、C#で「Horizontal」を処理すれば十分。Unity が裏側で入力を通訳してくれる。

image.png

カメラ操作の入力を作成

すでに「MouseX」と「MouseY」の入力を定義されているので、これを再利用しましょう。MouseX はマウスの横の動きなので、これを「CamRotY」にしましょう。同様に MouseY を「CamRotX」に書き換えましょう。

image.png

これで、後ほどコントローラーの対応を追加したいなら、同じ名前の新しい入力を追加し、適切なボタンやスティックを割り当てることが可能。

スクリプト

360度回転

CameraController を更新しましょう。まず、Y軸まわり(360度回転)を実装するには先ほど定義した「CamRotY」の入力を活用し、Height を回転する。

// 主人公を追いかけるカメラ
public class CameraController : MonoBehaviour
{
    // 追いかけるオブジェクトのTransform
    [SerializeField] 
    private Transform target;

    // 360度回転に使うTransform
    [SerializeField] 
    private Transform height;
    
    private void Update()
    {
        // 自分の位置をターゲットの位置
        transform.position = target.position;
        
        // 回転処理
        Rotate360();
    }

    // 360度の回転する
    private void Rotate360()
    {
        // トランスフォームの回転を取得
        // Quaternion(クオーターニオン・四元数)は回転を表すクラスである
        // Quaternionについては、「ゲーム数学」でまた勉強する
        Quaternion rot = height.localRotation;
        
        // このまま使えないので、XYZの回転角度(オイラー角度)にする
        Vector3 angles = rot.eulerAngles;
        
        // Y軸だけを更新
        angles.y += Input.GetAxis("CamRotY");
        
        // 回転を更新(オイラー角度 → クオーターニオンに戻す)
        height.localRotation = Quaternion.Euler(angles);
    }
}

一応、回転するが、速度が多少遅く感じるので、回転速度を「度 / 秒」単位で調整できるようにしましょう:

using UnityEngine;

// 主人公を追いかけるカメラ
public class CameraController : MonoBehaviour
{
    // 回転速度(度/秒)
    [SerializeField] 
    private float rotSpeed = 10;

    // (省略)
  
    // 360度の回転する
    private void Rotate360()
    {
        // トランスフォームの回転を取得
        // Quaternion(クオーターニオン・四元数)は回転を表すクラスである
        // Quaternionについては、「ゲーム数学」でまた勉強する
        Quaternion rot = height.localRotation;
        
        // このまま使えないので、XYZの回転角度(オイラー角度)にする
        Vector3 angles = rot.eulerAngles;
        
        // Y軸だけを更新
        angles.y += Input.GetAxis("CamRotY") * rotSpeed * Time.deltaTime;
        
        // 回転を更新(オイラー角度 → クオーターニオンに戻す)
        height.localRotation = Quaternion.Euler(angles);
    }
}

上下回転

これで主人公まわり360度の回転が実現できたが、今度、カメラの上下角度を変えたい。このため、「Pole」をX 軸まわりに回転すれば良いでしょう。Input.GetAxis("CamRotX"); を使用し、実装しましょう:

using UnityEngine;

// 主人公を追いかけるカメラ
public class CameraController : MonoBehaviour
{
    // 上下角度を変えるために使うTransform
    [SerializeField] 
    private Transform pole;

    // (省略)
  
    private void Update()
    {
        // 自分の位置をターゲットの位置
        transform.position = target.position;
        
        // 回転処理
        Rotate360();
        RotateUpDown();
    }
    
    // 360度の回転する
    private void Rotate360() // (省略)
    
    // 上下の回転
    private void RotateUpDown()
    {
        // ポールの回転を取得し、X軸まわりだけ回転する
        Vector3 angles = pole.localRotation.eulerAngles;
        angles.x -= Input.GetAxis("CamRotX") * rotSpeed * Time.deltaTime;
        pole.localRotation = Quaternion.Euler(angles);
    }
}

これで上下の回転できたが、カメラが逆さまになるのが望ましくないので、「最低角度」と「最大角度」を設定しましょう。これは自由に変わらないので、定数(const)として宣言しても良い。

// 上下の回転
private void RotateUpDown()
{
    // 最低と最大角度
    const float MinAngle = -30f;
    const float MaxAngle = 60f;
    
    // ポールの回転を取得し、X軸まわりだけ回転する
    Vector3 angles = pole.localRotation.eulerAngles;
    angles.x -= Input.GetAxis("CamRotX") * rotSpeed * Time.deltaTime;
    
    // 制限
    angles.x = Mathf.Clamp(angles.x, MinAngle, MaxAngle);
    pole.localRotation = Quaternion.Euler(angles);
}

問題発生

あれ?上はちゃんと制限されているが、下の方に行くと、また上に飛んでしまう?

一旦制限を取り消し、Debug.Log で角度を確認してみると「 - 角度」ではなく、回転一周まわって、また360度から始まってしまう!これが、Quaternion の内部的な処理であり、角度が0以下になると、360度に変わり、360は MaxAngle を超えるので、Mathf.Clamp で 最大角度に飛んでしまう!

image.png

解決には、localRotation の角度を使用せず、内部の変数を使えば良いでしょう:

// 上下回転の角度
private float angleX = 0;

// (省略) 

// 上下の回転
private void RotateUpDown()
{
    // 最低と最大角度
    const float MinAngle = -30f;
    const float MaxAngle = 60f;
    
    // 角度を更新し、制限
    angleX -= Input.GetAxis("CamRotX") * rotSpeed * Time.deltaTime;
    angleX = Mathf.Clamp(angleX, MinAngle, MaxAngle);
    
    // ポールの回転を取得し、X軸まわりだけ回転する
    pole.localRotation = Quaternion.Euler(angleX, 0, 0);
}

Timmyがカメラに相対的に移動する

これでカメラが自由に回転できるようになったが、今度何かおかしい現象が発生。

カメラが主人公の後ろにある場合は、何とか正しく動きますが、真横、または正面から見ると入力する、主人公の動きが変に感じる。なぜなら、キャラクターがカメラの位置を関係なく、世界のXZ軸に沿って動くので、カメラの向きが変わったら、おかしく感じる。

この問題を解決するには、世界に絶対的に動くのではなく、カメラの向きに合わせて相対的に移動すること。

image.png

image.png

この場合は、カメラ前方(青い矢印)が世界のZ軸と並行になっているので、「W」キーを押すと、正しく移動する

一方、Heightを回すと、カメラ前方の向きが変わり、「W」を押すと、世界に対して斜め(青い矢印の向き)に移動してほしい

スクリプト

PlayerMove を編集し、世界のXZではなく、Heightの回転を従って移動させましょう。これには、Transform コンポネントの「forward」と「right」を使いましょう!

using UnityEngine;

// プレーヤーの移動を処理する
public class PlayerMove : MonoBehaviour
{
    // カメラのY軸回転状況を知りたい
    [SerializeField] 
    private Transform cameraYRot;

    // (省略)
    
    private void Update()
    {
        // 何も入力がない前提で、速度をゼロにする
        velocity = Vector3.zero;
        
        // A/S(水平入力)を取得し、3D空間のX軸に割り当てる
        // velocity.x = Input.GetAxis("Horizontal");

        // W/D (垂直入力)を取得し、3D空間のZ軸に割り当てる
        // velocity.z = Input.GetAxis("Vertical");
        
        // Heightオブジェクトの向きを従って移動
        velocity += cameraYRot.right * Input.GetAxis("Horizontal");
        velocity += cameraYRot.forward * Input.GetAxis("Vertical");
        
        // 単位ベクトルにする
        velocity.Normalize();
        
        // 速度を乗算する
        velocity *= speed;
    }

    // (省略)
}

cameraRotY はカメラが Y 軸まわるオブジェクトなので、「Height」にして、実行してみてください。

Timmyの向きを進行方向に合わせる

せっかくなので、Timmyが移動中に走っている向きに合わせて、回転しましょう。すでに「forward」を学んだので利用できる。Timmyの前方(transform.forward)は速度ベクトルと同じ。ただし、向きを変えるのは、入力がある時だけ。

上記の Update() は以下のように変わる:

private void Update()
{
    // 何も入力がない前提で、速度をゼロにする
    velocity = Vector3.zero;
    
    // Heightオブジェクトの向きを従って移動
    velocity += cameraYRot.right * Input.GetAxis("Horizontal");
    velocity += cameraYRot.forward * Input.GetAxis("Vertical");
    
    // 入力あれば、速度ベクトルはゼロではないはず
    if (velocity != Vector3.zero)
    {
        // 単位ベクトルにする
        velocity.Normalize();
        
        // 前方の向きを変える
        transform.forward = velocity;
        
        // 速度を乗算する
        velocity *= speed;
    }
}

 

ジャンプを実装

ジャンプは前後左右の移動とは関係ないため、別のスクリプトとして実装ができる。同じPlayerMoveでも実装できるが、「一つのスクリプト=1つの責任」の考えを従い、今回はジャンプ専用の「PlayerJump」スクリプトを作成しましょう。

このスクリプトは「Jump」という入力(プロジェクト設定→入力マネージャーを確認)が発動したら、一旦Y軸の速度を変えて、上がるようにしましょう。また、ジャンプ力をUnityで変えられるようにしましょう。

スクリプト

using UnityEngine;

// ジャンプ機能を追加するスクリプト
public class PlayerJump : MonoBehaviour
{
    // ジャンプ力を設定する
    [SerializeField] 
    private float jumpPower = 5.0f;

    // 速度を変えるためのリジッドボディ
    private Rigidbody rbody;

    // ジャンプしろ!
    private bool doJump;
    
    void Start()
    {
        // コンポネント取得
        rbody = GetComponent<Rigidbody>();
    }

    void Update()
    {
        // ジャンプしたか?
        if (Input.GetButtonDown("Jump"))
            doJump = true;
    }
    
    private void FixedUpdate()
    {
        // ジャンプしていなければ、なにもしない
        if (!doJump)
            return; // メソッド終了
        
        // 現在の速度を取得
        Vector3 v = rbody.linearVelocity;
        
        // Yだけを上書き
        v.y = jumpPower;
        
        // 速度を設定
        rbody.linearVelocity = v;
        
        // ジャンプしたので、doJump を false に戻す
        doJump = false;
    }
}

これでジャンプを確認しましょう。

問題:多重ジャンプ

 ジャンプできるようになったが、空中であってもジャンプができるので、ジャンプし続けば空に飛んでしまう!これを防ぐには、現状にジャンプできるかどうかの確認が必要。

 

 今回は「足元の下に何もなければ(つまり空中であること)、ジャンプができない」ことがジャンプの条件にしましょう。

 

 では、どうやって足元に地面の存在確認できるのか?これを学んだ「Raycast」で解決ができる。足元からビーム(レイ)を発射し、何も当たらなければ、空中であることを確認ができる。

image.png

では、PlayerJump を更新し、地面の時だけジャンプできるようにしましょう。まず、地面の確認メソッド:

// 現在、地面を触れている?
private bool IsOnFloor()
{
    // 確認する距離。ここは適切に調整
    const float CheckDistance = 0.2f;
    
    // レイが始まる位置。現在位置からもう少し上から始まる
    Vector3 origin = transform.position + Vector3.up * 0.01f;
    
    // origin から下に向かい、決まった距離のビームを発射
    // 何か当たったら「true」を返す
    return Physics.Raycast(origin, Vector3.down, CheckDistance);
}

そして、Update で:

void Update()
{
    // ジャンプしたか?
    if (IsOnFloor() && Input.GetButtonDown("Jump"))
        doJump = true;
}

問題:壁にくっつける

次の試してみてください。台に向かって、ジャンプし、壁を当たってください。引き続きに壁に力を与えると、主人公が落下せず、壁にくっつけてしまう。魔雑のため、滑らずにそこでとまってしまう。

この問題を「Physics Material」で解決ができる。主人公のコライダーが魔雑ないようにし、滑りやすくしましょう。プロジェクトフォルダーの中に新しい「Physics Material」を作成し、名前は「PlayerSlide」にする

image.png

魔雑「0」弾み「0」で、どちらにも「最低数値」を使用。 Player のコライダーに「PlayerSlide」をアサインすれば完成

image.png

アニメーションを追加

Timmy がずっと「T-ポース」でなかなか面白くないので、走るときとジャンプの時もアニメーションを再生しましょう。このため「アニメーションコントローラー」(Animation Controller)および「アニメーター」(Animator) コンポーネントを使用し、条件でアニメーションを切り替えて、主人公を適切に動かしましょう。

シーン設定

動くのは Timmy なので、Timmy (Playerではなく) に Animator コンポーネントを追加してください。

image.png

ここで、「Controller」と「Avatar」を設定しないといけない。

Controller とは

Controller(アニメーションコントローラー)はアニメーションの遷移を管理するところである。以下のような画面でアニメーションを挿入し、「Transition」(遷移)で、アニメーションの切り替えが管理できるものである。

image.png

Timmy のコントローラーがまだできていないので、新しいのを作りましょう。Characters / Timmy / Animations の中で新しい「アニメーション ➡ アニメーションコントローラー」を作成し、「TimmyController」の名前にしてください:

image.png

これを「Animator」コントローラーの「Controller」に設定ください。

Avatar とは

Avatar は、3Dモデルの「骸骨」(スケルトン)である。アバターの「骨」(ボーン)を動かすことにより、モデルが正しく変形する。この情報3Dモデルのデザイナーさんが作るべき。開発者はモデルからデータを抽出するだけ。プロジェクトフォルダでTimmyのモデルを選択しましょう(Characters / Timmy / Models / Timmy)

image.png

ここで「Rig」を確認しましょう。このモデルからアバターを作るので、いくつかの設定を変えないといけない。

image.png

その後は「Apply」ボタンを押してください。大きく変わらないが、ボーンを確認ができる。「Configure」ボタンを押すと

image.png

アバターが抽出されたことが確認ができる。アバターができたので、Timmy の Animator コンポーネントの Avatar を設定しましょう 

image.png

アニメーションファイルのアバターも!

これで、キャラクターの骸骨(アバター)を設定できたが、次に、このモデルが使うアニメーションにもアバターの設定が必要。Characters / Timmy / Animation の中にいろんなファイルが入っているが、TimmyController 以外はすべてアニメーションである:

image.png

このアニメーションは「Timmyのアニメーションだよ!」を指定するために、このファイルに作ったばかりのアバターを指定しなければならない。すべてのアニメーションを選択し、また「Rig」を確認しましょう。ここでは

image.png

最後に、「Apply」を押してください。これで、アニメーションファイルと Timmy の3Dモデルの紐づけができる。

アニメーション

待機/アイドル(Idle)

まず、キャラクターが何もしてないときのアイドルアニメーションを作成しましょう。コントローラーの中に「idle」のアニメーションをドラッグドロップで追加

image.png

今回のアニメーションが Mixamo からダウンロードしたので、アニメーションの名前は「mixamo_com」になってしまう。これでわかりにくいので、Inspector で「Idle」という名前にしましょう。これがデフォルトアニメーションになるので、このままで実行してみましょう:

image.png

「T-ポーズ」ではなく、待機アニメーションになった!ただし、少し長めに待つと、アニメーションが止まってしまいます。なぜなら、デフォルトとしてアニメーションがループしないから。プロジェクトで「idle」を選択し「Animation」の設定を Inspector で更新しましょう:

image.png

ここで「Loop Time」のチェックが外しているため、アニメーションがループはしない。チェックを入れ、もう一度確認しましょう。

走る(Running)

つぎに、走るアニメーションを追加しましょう。プロジェクトから「running」をアニメーションコントローラーに追加し、アニメーション名を「Running」にしましょう。このアニメーションもループできるように設定を変えてくださいね

image.png

次、遷移(Transition)を作らないといけない。Idle → Running と Running → Idle それぞれの遷移を「右クリック」→「Make Transition」で作成しましょう:

image.png

これで実行すると、待機→走る→待機 の繰り返しが続ける。走り始めると、Idle → Running の遷移し、止まったら Running → Idle に戻るようにしましょう。そのため、「今走っているのか?」のパラメータを追加しましょう。アニメーターコントローラーで Bool 型の IsRunning のパラメータを追加してください:

image.png

これが遷移するかしないかのパラメータにしましょう。Idle → Running で IsRunning は true だったら、遷移する。そして、Running → Idle には、 IsRunning が false の時遷移しましょう:

image.png

image.png

これで実行し、アニメーションコントローラーのパラメータ IsRunning を変えながら、挙動を確認してみてください。

問題:すぐ遷移しない

これで問題が明らかになった。 IsRunning を変えても、すぐ遷移しない。アニメーションが終わってから遷移するので、反応が遅い。これを直すには、「アニメーション終了まで待たず、すぐ遷移しろ!」の設定すれば良いでしょう。遷移での「Has Exit Time」(終了時間あり)のチェックを外してください。

スクリプト

最後は、C#からアニメーションコントローラーのパラメターを設定し、走っている間には IsRunning を true にしましょう。PlayerMove スクリプト移動の処理しているので、ここで簡単にパラメターを設定ができる。まず、Animator の参照が必要で:

// プレーヤーの移動を処理する
public class PlayerMove : MonoBehaviour
{
    // Unity で Animator の参照を設定
    [SerializeField]
    private Animator animator;

    // (省略)
}

そして、Update を少し更新するだけ:

private void Update()
{
    // 何も入力がない前提で、速度をゼロにする
    velocity = Vector3.zero;

    // Heightオブジェクトの向きを従って移動
    velocity += cameraYRot.right * Input.GetAxis("Horizontal");
    velocity += cameraYRot.forward * Input.GetAxis("Vertical");
    
    // 入力あれば、速度ベクトルはゼロではないはず
    bool isRunning = velocity != Vector3.zero;
    if (isRunning)
    {
        // 単位ベクトルにする
        velocity.Normalize();
        
        // 前方の向きを変える
        transform.forward = velocity;
        
        // 速度を乗算する
        velocity *= speed;
    }

    // アニメーターのパラメターを設定
    animator.SetBool("IsRunning", isRunning);
}

Unity で PlayerMove の Animator 参照を設定し、走ってみましょう!

ジャンプ(Jump)

ジャンプは走るよりも多少複雑である。ジャンプの高さにより、アニメションの長さが変わるので、3つのパーツに分割されている

これで遷移が難しくなるが、少し考えて遷移を整理しましょう。赤は遷移のパラメター条件(Condition)

まず、遷移図を作りましょう。アニメションを追加し、わかりやすい名前にしてください:

※falling idle は ループするので、アニメションの設定を忘れずに

image.png

スクリプト

遷移とその条件ができたら、次、スクリプトでパラメターの設定が必要。ジャンプに関する処理が「PlayerJump」の中にあるので、そのスクリプトを編集しましょう。まず、アニメーターの参照から:

using UnityEngine;

// ジャンプ機能を追加するスクリプト
public class PlayerJump : MonoBehaviour
{
    // Unity で Animator の参照を設定
    [SerializeField]
    private Animator animator;

    // (省略)
}

そして、Update で:

void Update()
{
    // 着地なのか?
    bool onFloor = IsOnFloor();
    
    // ジャンプしたか?
    if (onFloor && Input.GetButtonDown("Jump"))
    {
        animator.SetTrigger("JumpTrigger");
        doJump = true;
    }
    
    // アニメーターのパラメター設定
    animator.SetBool("IsAir", !onFloor);
}

遷移の改善、微調整

これで各アニメションの再選、遷移の実現ができたが、違和感のときもある。転がるのが長いし、立つアニメションも長いので、着地のすぐ後にまた走り始めると、変に見える場合もある。以下の微調整で少し見かけが改善できる。

転がるアニメーションを速くする

転がるのが長いので、スピードアップしましょう。アニメーターコントローラーで「Roll」のアニメーションを選択し、Inspector で Speed を調整してみましょう。1.5倍~2.5倍の間にお好みの数値で調整し、確かめてください。

image.png

着陸したと、また移動中かどうかを確認

以下のように遷移を調整し、Land か Roll の再生中に、走行の状況が変わったら、すぐ適切なアニメーションへ遷移する。

image.png

ジャンプポーズでフリーズする

image.png

 ジャンプ中に、連続にジャンプすれば、JumpTrigger また発動し、結果として、JumpUp のアニメーションへ遷移し、そこで止まってしまいます。

 

 ジャンプができない場合は、JumpTrigger のパラメータをリセットすれば、この問題を解決ができる。

 

 以下のように、PlayerJump のスクリプトを修正すれば、直る:

void Update()
{
    // 着地なのか?
    bool onFloor = IsOnFloor();

    // ジャンプしたか?
    if (onFloor && Input.GetButtonDown("Jump"))
    {
        // アニメータの"JumpTrigger"パラメータを設定
        animator.SetTrigger("JumpTrigger");
        doJump = true;
    }
    // そうじゃない場合は、リセットする
    else
        animator.ResetTrigger("JumpTrigger");

    
    // アニメーターのパラメター設定
    animator.SetBool("IsAir", !onFloor);
}


アイテムを拾う

3D シーンの配置できるコインと鍵を拾って、Timmy が持っているアイテムを追跡しましょう。

コイン

シーンにコインを一つ作ろう(後ほどプレハブにして、何個も配置できるようにする)。まず、「Coin」の名前で空のオブジェクトを作成する。拾う処理を確認しやしくするため、Timmyの近くに配置してください。

次、「Coin」の子オブジェクトとして、3Dモデルを追加しましょう。Meshes の中にある「coin-gold」を「Coin」の中に挿入し、位置は 0, 0, 0 にしてください。

最後、3Dモデルとの接触を確認できるため、SphereCollider 型の トリガーを追加してください

image.png

※注目:3Dモデル coin-gold の位置は「親に対して 0, 0, 0」であることを確認してください。3D空間の中で動かすのは、親の Coin である!

アニメーション

コインが浮くアニメーションを作成しましょう。coin-gold を選択し、アニメーションパネルを表示してください。ここで2つの値をアニメーションで更新しましょう:位置と回転

フレーム 0 60 120
位置 0, 1, 0 0, 0.5, 0 0, 1, 0
回転 0, 0, 0 0, 180, 0 0, 360, 0

タグ

何を拾ったのかを確認したいので、SphereCollider が付いている coin-gold には「Coin」というタグを付けてください

プレハブ化

最後、親の「Coin」を新しく作った「Prefabs」フォルダーの中に入れ、プレハブ化にしてください

image.png

インベントリ

つぎ、主人公のインベントリ(アイテム管理クラス)を作成しましょう。このクラスは OnTriggerEnter を活用し、タグを確認する。「Coin」だったら、持っているコインの数を増やす。Debug.Log で確認しましょう:

スクリプト

using UnityEngine;

// アイテムを管理するインベントリ
public class Inventory : MonoBehaviour
{
    // コインの数
    private int coinCount;

    // トリガーが発動した
    private void OnTriggerEnter(Collider other)
    {
        // コインなの?
        if (other.CompareTag("Coin"))
        {
            coinCount++;
            Debug.Log($"コインの数:{coinCount}");
        }
    }
}

このスクリプトをPlayerにアタッチし、実行してみてください。

アイテム削除

一応、これで正しく数えられることが確認できるが、コインが消えないので、何度も拾うことができてしまう。拾った後に、アイテムをシーンから廃止しましょう。ここの注目ポイントは、削除するのはコインの「親」であること(コライダーは子オブジェクトにあるから)

// トリガーが発動した
private void OnTriggerEnter(Collider other)
{
    // コインなの?
    if (other.CompareTag("Coin"))
    {
        // コライダー「の」トランスフォーム「の」親「の」ゲームオブジェクト
        Destroy(other.transform.parent.gameObject);

        coinCount++;        
        Debug.Log($"コインの数:{coinCount}");
    }
}

いくつかのコインを配置し、確認してみてください。

コインと同じ手順を従い、鍵のプレハブを作成してください。ただし:

にしてください。そして、Inventory クラスは:

using UnityEngine;

// アイテムを管理するインベントリ
public class Inventory : MonoBehaviour
{
    // コインの数
    private int coinCount;

    // 鍵の数
    private int keyCount;

    // トリガーが発動した
    private void OnTriggerEnter(Collider other)
    {
        // コインなの?
        if (other.CompareTag("Coin"))
        {
            // コライダー「の」トランスフォーム「の」親「の」ゲームオブジェクト
            Destroy(other.transform.parent.gameObject);
            coinCount++;
            Debug.Log($"コインの数:{coinCount}");
        }
        
        // 鍵なの
        else if (other.CompareTag("Key"))
        {
            Destroy(other.transform.parent.gameObject);
            keyCount++;
            Debug.Log($"鍵の数:{keyCount}");
        }
    }
}

インベントリの改善:統一処理を一体化

この処理で、すぐ眼立つのは、似たような処理2回現れる

この作り方は大きな問題2つがある:

できる限りに、統一している処理を一体にすると、ゲームの整備が楽になる!このため、新しい C# のクラス「Dictionary」を紹介

なんでも指数として使える"配列"「Dictionary」

今まで、2つのコレクションを学んできた:

いずれにしても、それぞれの要素を「指数」でアクセスする形になる。先頭の要素を「0番目」の要素であり、そこから連続に次々の要素をアクセスができる。

// 配列、またはリストは「指数」でアクセス
int [] numbers = new int[5];
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
// など…

連続にデータを処理する場合は、配列、またはリストは最適であるが、今回のインベントリのように、連続で整理するよりも、何かの「キーワード」でアクセスしたい。具体的に、それぞれのアイテムの数を、そのアイテムの名前でアクセスしたい。

// これをやりたい…
items["Coin"] = 10; // コイン10個ある
items["Key"] = 0;   // 鍵がない

これがあれば、アイテムごとの変数(coinCount, keyCount)を別々で作成する必要がない。この特別なコレクションに新しい "アイテム"を追加すれば、処理が統一できるようになる。

このコレクションは「ディクショナリー(Dictionary)」といい、リストと同様に、変数を作るのは1つだけで、中には要素を自由に追加ができるが、違いとしては、0から連続にアクセスするではなく、何かの「キー」で要素を探す。

ここで「キー」にできるのは、ほとんどなんでもOK!

書式

ディクショナリーの宣言は、以下のようになる:

Dictionary<キーの型、要素の型> ディクショナリー名;

そして、新しいディクショナリーを作るには:

ディクショナリー名 = new Dictionary<キーの型、要素の型>();

例えば、文字列→整数のディクショナリーは

// 学生の点数
// キー:文字列
// 要素:整数
Dictionary<string, int> studentScore = new Dictionary<string, int>();

// 代入
studentScore["太郎"] = 75;
studentScore["大原"] = 91;

// 読み込む
int score = studentScore["太郎"];
Debug.Log($"太郎さんの点数は{score}");

または、列挙→文字列でもOKですし:

// 武器の列挙
public enum Weapon
{
    Sword,
    Pistol,
    Axe,
    Rock
}

// 各武器の日本語表示 
// キー:武器(列挙)
// 要素:文字列
Dictionary<Weapon, string> weaponName = Dictionary<Weapon, string>();

// 代入
weaponName[Weapon.Sword] = "剣";
weaponName[Weapon.Axe] = "斧";

// 読み込む
int name = weaponName[Weapon.Pistol];

Inventory クラスの改善

では、Dictionary を使用し、処理を一体化しましょう:

using System.Collections.Generic;
using UnityEngine;

// アイテムを管理するインベントリ
public class Inventory : MonoBehaviour
{
    // アイテムの数(タグ → 数)
    private Dictionary<string, int> itemCount;

    private void Start()
    {
        // ディクショナリーを作成
        itemCount = new Dictionary<string, int>();
        
        // 有効なアイテムとその初期の数
        itemCount["Coin"] = 0;
        itemCount["Key"] = 0;
    }

    // トリガーが発動した
    private void OnTriggerEnter(Collider other)
    {
        // 相手のタグを求める
        string itemTag = other.tag;

        // そのタグあるかないかを確認
        // "ContainsKey" は、ディクショナリーの中に
        // 定めた「キー」があると、「true」を返す
        bool isItem = itemCount.ContainsKey(itemTag);

        // 有効なアイテじゃなければ、何もしない
        if (!isItem)
            return; // 終了

        // コライダー「の」トランスフォーム「の」親「の」ゲームオブジェクト
        Destroy(other.transform.parent.gameObject);
        itemCount[itemTag]++;
        Debug.Log($"{itemTag}の数:{itemCount[itemTag]}");
    }
}

宝箱を作成

このステップで、宝箱のアニメーションを作成し、開く。ただし、主人公が事前に鍵を持っていることを確認しなければならない。また、遷移のないアニメーションの切り替えも紹介する。

宝箱の準備

シーンに宝箱のモデルをシーンに追加しましょう。Coin と Key と同様に、空のゲームオブジェクトを作成し、3Dモデルを子オブジェクトとして追加した方がおすすめ。

サイズが多少小さいので3Dモデル(子オブジェクト)のサイズは 2倍ぐらい大きくしましょう。

image.png

なお、ちゃんとぶつかるように、宝箱はコライダーが必要。3Dモデルの子オブジェクトに BoxCollider を追加し、サイズを調整してください:

image.png

アニメーション

もし、主人公が会議を持っていれば、宝箱が開くので、ふたが開くアニメーションが必要。アニメーションパネルを使用し、「OpenChest」作ってください:

フレーム 0 30
lid の 回転 0, 0, 0 -120, 0, 0

なお、ループしないように、出来上がったアニメーションを Project フォルダーで探し、Loop Time のチェックを外してください

image.png

このまま実行すると、宝箱が勝手に開いてしまうので、アニメーションコントローラーの調整が必要。現状を見ると:

image.png

ゲーム開始のときに「OpenChest」が実行するので、勝手に開いていしまうことが確認できる。そうしないように、空のアニメーション「Empty」を作成しましょう。アニメーターパネルの中に右クリックし、Create State → Empty を選択してください。

image.png

この新しいアニメーションの名前を「Empty」にし、デフォルトアニメーションにしましょう。アニメーションの上に右クリックし、「Set as Layer Default State」を選択しましょう、これで、ゲーム開始の時、何もしない。

image.png

今回のアニメーションは、ただの切り替えなので、遷移を使用しない。このため、パラメータを作成する必要なく、Transition も使わない。

スクリプト

今回のスクリプトは:

宝箱を開くスクリプト

では「OpenWithKey」のスクリプトを作成しましょう

// 鍵を持っていれば開く
public class OpenWithKey : MonoBehaviour
{
    // すでに開いている?
    private bool isOpen = false;
    
    // 衝突の時
    private void OnCollisionEnter(Collision other)
    {
        // 開いていれば、何も処理しない
        if (isOpen)
            return;
        
        // 衝突してきたゲームオブジェクトのInventoryを取得
        Inventory inventory = other.gameObject.GetComponent<Inventory>();

        // Inventoryのないオブジェクト(ゾンビなど)を衝突する可能性があるので
        // インベントリが存在することを確認
        if (inventory == null) // ない!
            return; // なにもしない  

        // 鍵の数を求める
        int count = inventory.GetCount("Key");
        
        // 1つもない? なにもしない
        if (count < 1)
            return;
        
        // 鍵を消費
        inventory.UseItem("Key", 1);
        
        // 遷移せず、直接にアニメーションを再生
        Animator anime = GetComponent<Animator>();
        anime.Play("OpenChest"); 
        
        // 2度も実行しないように、開いていることにする
        isOpen = true;
    }
}

主なポイント①:コンポーネント存在の確認

// 衝突してきたゲームオブジェクトのInventoryを取得
Inventory inventory = other.gameObject.GetComponent<Inventory>();

// Inventoryのないオブジェクト(ゾンビなど)を衝突する可能性があるので
// インベントリが存在することを確認
if (inventory == null) // ない!
    return; // なにもしない  

宝箱と何が衝突するかわからない。もちろん、Inventoryコンポーネントを持っているプレーヤーの衝突を期待しているが、もしかしたら他のゲームオブジェクト(落下してきた岩など)も衝突する可能性がある。

そのために、「衝突してきたオブジェクトが Inventory のコンポーネントがあるか」のを確認している。そのコンポーネントがないと、inventorynull になるので、必ずプレーヤーであることを保証ができる。

主なポイント②:遷移なしのアニメーション再生

// 遷移せず、直接にアニメーションを再生
Animator anime = GetComponent<Animator>();
anime.Play("OpenChest"); 

今まで、パラメータを使用し、遷移でアニメーションを切り替えが行ってきたが、直接に「このアニメーションを再生しろ」のもできる。今回の単純な切り替えで、これで十分。

主なポイント③:在庫確認と消費

// 鍵の数を求める
int count = inventory.GetCount("Key");

// 鍵を消費
inventory.UseItem("Key", 1);

Inventoryの編集

上記の2つのメソッドまだ作成していないので、実装しましょう。Inventory クラスを編集し、

// アイテムの数を返す
public int GetCount(string item)
{
    // アイテム存在しなければ、ゼロを返す
    if (!itemCount.ContainsKey(item))
        return 0;
    return itemCount[item];
}

// アイテムを消費する
public void UseItem(string item, int count)
{
    // 現在の数を確認
    int nowCount = GetCount(item);
    
    // 足りたら、消費する
    if (nowCount >= count)
        itemCount[item] -= count;
}

「OpenWithKey」を宝箱のコライダーにアタッチし、確認してみましょう。

UIを作成

インベントリの中にあるアイテム(コイン、鍵)を画面上で表示し、主人公の体力も可視化しましょう。

Canvas作成

ヒエラルキーに新しい Canvas を追加してください。

Canvas Scaler を紹介

Canvas を作成するときに、同時に「Canvas Scaler」のコンポーネントを作成される。このコンポーネントは想定する画面のサイズに合わせて、自動的にCanvasを拡大縮小する。

例えば、UIを開発したときに、想定していた画面サイズは FullHD (1920 x 1080) だった。ただ、プレーヤーは 4K(3840 x 2160)で遊ぶとしたら、画面がすごく小さくなってしまう。

この場合は Canvas Scaler を使用すれば、画面が大きくなっても、小さくなっても、いつも正しく表示される

image.png

Canvas Scaler 無し:FullHD から 4K に切り替えると、UIが小さくなってしまう

image.png

Canvas Scaler で想定サイズを入力すると、4K に切り替えても、UIがのサイズを保持する

今回、上記の設定で進めましょう。

コインの数

画面の右上にコインの数を表示しましょう。

image.png

Canvas の中で、以下のようにヒエラルキーを作ってください:

image.png

 

こうなるように:

 

image.png

  • Coin:Image(画像)にし、背景は半透明の黒にする。この「部品」が画面の右上に表示されるので、アンカーポイントが右上にしてください。

image.png

  • Icon:Image(画像)にし、コインのアイコンにする

image.png

 

  • Text:TextMeshPro にし、000 ので始まるようにする

image.png

Coinの画像が付かない?

3Dゲームを作るときに、すべての画像が 3Dモデルで使う「Texture (テクスチャ)」と思われる。テクスチャが UI で使えないので、スプライトになるように設定しなければならない。

UIで使う画像を選択し、Inspector で設定を変えてください:

image.png

これで、「Sprite」(2Dゲームと UI)にし、スプライトモードは「Single」(単体)にする。

※:同じ画像にたくさんのスプライトがある場合はここは「Multiple」(多数)になる

鍵の数

同じ手順を従って、画面の右下に鍵の数が表示できるUIを作成してください

image.png

スクリプト

UI管理するスクリプト「UIManager」を作成し、コインの数と鍵の数を更新するメソッドを実装しましょう。まず、必要なのは、テキストの参照なので:

// UIを管理する
public class UIManager : MonoBehaviour
{
    // コインのテキスト
    [SerializeField] 
    private TextMeshProUGUI coinText;
    
    // 鍵のテキスト
    [SerializeField] 
    private TextMeshProUGUI keyText;
}

そして、アイテムのタグで、数を設定できるメソッド:

// アイテム(コイン、鍵)の数を設定
public void SetCount(string item, int count)
{
    // タグで確認
    switch (item)
    {
        // コイン
        case "Coin":
            coinText.text = $"{count:000}";
            break;
        // 鍵
        case "Key":
            keyText.text = $"{count:000}";
            break;
    }
}

Inventoryの編集

そして、Inventory スクリプトを更新し、鍵、またはコインを拾ったら、UIを更新しましょう。まず、Unityで紐づけできるように:

// アイテムを管理するインベントリ
public class Inventory : MonoBehaviour
{
    // UIを更新するため
    [SerializeField] 
    private UIManager uiManager;
    
    //(省略)
}

そして、拾うときに:

// トリガーが発動した
private void OnTriggerEnter(Collider other)
{
    // 相手のタグを求める
    string itemTag = other.tag;

    // そのタグあるかないかを確認
    // "ContainsKey" は、ディクショナリーの中に
    // 定めた「キー」があると、「true」を返す
    bool isItem = itemCount.ContainsKey(itemTag);

    // 有効なアイテじゃなければ、何もしない
    if (!isItem)
        return; // 終了

    // コライダー「の」トランスフォーム「の」親「の」ゲームオブジェクト
    Destroy(other.transform.parent.gameObject);
    itemCount[itemTag]++;
    
    // UIを更新
    uiManager.SetCount(itemTag, itemCount[itemTag]);
}

もちろん、消費のときも…

// アイテムを消費する
public void UseItem(string item, int count)
{
    // 現在の数を確認
    int nowCount = GetCount(item);
    
    // 足りたら、消費する
    if (nowCount >= count)
        itemCount[item] -= count;
    
    // UIを更新
    uiManager.SetCount(item, itemCount[item]);
}

アタッチ、紐づけ

次、Canvas に UIManager をアタッチし、各種のテキストを設定:

image.png

そして、Inventory と UIManager の紐づけも

image.png

これでもう一度確認しましょう。

ゾンビを実装

主人公を追いかける「ゾンビ」を実現しましょう!

シーンの設定

ゲームオブジェクトと3Dモデル

Timmyと同様に、ゾンビのゲームオブジェクトを作成しましょう。空のゲームオブジェクトを作成し、その下に子オブジェクトとしてZombieのモデルを追加する:

image.png

ただし、ゾンビは物理的に動かないので、コライダーとリジッドボディが要らない。ゾンビの動かした方については、下記で説明する。

アニメーション

今回は、アニメーションは1つしかなくて、ずっと歩くようにしましょう。ただし、Timmy と同様に「アバター」の設定が必要。

image.png

image.png

そして、ゾンビのアニメーターコントローラーを作成し、「ZombieWalk」のアニメションをデフォルトにする

image.png

これで実行してみてください(ループを忘れずに~)

自動的に道を探す「ナビゲーション」

人間を制御するキャラクターが、当たり判定を確認するだけで、動きの実装ができる。人間が勝手に障害物などを避けて動く。一方、CPUキャラクターがとても馬鹿であり、障害物を避けたらすることができない。

この問題を解決するには、Unityが「Navigation」(ナビゲーション)を提供する。ナビゲーションとは、自動車のナビと同じように、目的地を設定し、最適なルートを探す処理である。

ナビゲーションは以下の主なコンポーネントを組み合わせて、作成する

その他にも、ナビゲーションの影響にあるコンポーネント(NavMesh Link、NavMesh Obstacle、など)もあるが、最低限でこの2つの組み合わせで十分。

ナビゲーションメッシュ(NavMesh Surface)

ナビの地図である。地図を作るのは、シーンを固定してから!シーンが変わったら(壁を増やした、障害物の位置を変えた)、地図も更新しないといけないので、ご注意ください。

まず、地図に使うオブジェクトをグループ化にしないといけないので、シーンを整理し、ゾンビと主人公の間にいくつかの壁を作ってみましょう。空ゲームオブジェクト「Stage」を作成し、中には床面、ランプそして新しい壁を追加してください。

image.png

ここで「ナビゲーションメッシュ」を作りましょう。「Stage」を選択し、「NavMesh Surface」を追加してください

image.png

ここで地図を作る時に必要なパラメータを設定できるが、最も重要なのは、何を基づいて地図を作るのか。これは「Object Collection」から設定ができる。今回は「Stageとその子オブジェクトを使用」にしたいので「Current Object Hierarchy」(現在オブジェクトのヒエラルキー)を選択しましょう。

そして、ナビゲーションメッシュを作成するには「Bake」(焼く)ボタンを押すだけ。このボタンを押すと、Stageとその子オブジェクトのすべてを処理し、歩ける「地図」を水色で表示される:

image.png

壁にあまり近づかないよう、周囲に「歩けない」領域が現れる。この地図が焼いているので、ステージが変わっても地図が変わらない! 1つの壁を削除しても、「歩けない」領域が残る:

image.png

ナビゲーションメッシュを焼いた後にシーンが変わったら、もう一度「Bake」してください。

エージェント(NavMesh Agent)

エージェントは、水色の「地図」(ナビゲーションメッシュ)上で歩くものである。目的地を設定すれば、現在地から最適な道を探し、自動的に動く。

今回のゲームで、ゾンビが動いてほしいので、エージェントにしましょう。ゾンビ(親オブジェクト)に「NavMesh Agent」を追加してください。

image.png

ここでエージェントのパラメータを調整ができる。主なパラメータは:

Base Offset(基礎オフセット) 足元からの「ずれ」
Speed(速度) 移動速度
Angular Speed(回転速度) 向きを変える速度
Acceleration(加速度) 加速と減速の速さ
Stopping Distance(停止距離) 目的地までこの距離になったら止まる
Radius(半径) エージェントの「太さ」
Height(高さ) エージェントの「身長」

とりあえず、ゾンビの速度を「1」にし、残りのパラメータをそのままにしましょう。

スクリプト

プレーヤーを追いかける

スクリプトは、プレーヤーの位置を求め、エージェントの目的地を設定するだけ。「PlayerChase」のスクリプトは以下の通りである:

using UnityEngine;
using UnityEngine.AI;

// プレーヤーを追いかける
public class PlayerChase : MonoBehaviour
{
    // 追いかけるプレーヤー
    private GameObject player;
    
    // エージェントの目的地を設定するため
    private NavMeshAgent agent;
    
    private void Start()
    {
        // 後ほどプレハブにするので、Unityで紐づけせず、
        // 実行中にプレーヤーを探す
        player = GameObject.Find("Player");

        // NavMeshAgentのコンポネント取得
        agent = GetComponent<NavMeshAgent>();
    }

    private void Update()
    {
        // 追いかける
        // SetDestination はエージェントの目的地を設定するメソッド。
        // 引数はその位置である(プレーヤーの位置)
        agent.SetDestination(player.transform.position);
    }
}

このスクリプトを Zombie にアッタッチし、実行してみてください。

Timmyの体力

これで、追いかけるようになったが、ゾンビが主人公を捕まっても、なにもならない。Timmyの体力を実装しましょう。「HitPoints」のスクリプトは以下の通りである:

// 体力を管理するクラス
public class HitPoints : MonoBehaviour
{
    // 体力
    private int hp = 3;
}

当たり判定とダメージ

ここでトリガーを活用ができる。もし、HitPoints がダメージの起源(ゾンビ、罠、炎など)と重なったら、ダメージを受ける。どれぐらい受けるのかをその「ダメージの起源」(DamageSource)で指定ができる

image.png

まず、ダメージの起源を作成しましょう:

// ダメージの起源
public class DamageSource : MonoBehaviour
{
    // ダメージの量
    [SerializeField] 
    private int damage;
    
    // ダメージの量(読み込む専用)
    public int GetDamage()
    {
        return damage;
    }
}

そして、HitPoints スクリプトは:

// 体力を管理するクラス
public class HitPoints : MonoBehaviour
{
    // 体力
    private int hp = 3;

    // トリガーに入ったら
    private void OnTriggerEnter(Collider other)
    {
        // ダメージの起源かどうかを確認
        DamageSource src = other.GetComponent<DamageSource>();
        if (src == null)
            return;
      
        // 体力を減らし、0 以下にならないように制限する
        hp = Mathf.Max(0, hp - src.GetDamage());
    }
}

確認

確認するには、ゾンビにダメージの起源を追加しなければならない。Zombieに新しい子オブジェクトを追加し、トリガーと DamageSource を追加してください

image.png

そして、Player に HitPoints を追加し、Debug.Log で体力を表示してください。

UIの更新(体力)

Debug.Logでカッコ悪いので、UIも作成しましょう。画面の左に、3つのハート画像をグループとして配置しましょう

image.png

そして、UIManager を更新し、ハートの表示・非表示しましょう。

// UIを管理する
public class UIManager : MonoBehaviour
{
    // コインのテキスト
    [SerializeField] 
    private TextMeshProUGUI coinText;
    
    // 鍵のテキスト
    [SerializeField] 
    private TextMeshProUGUI keyText;

    // ハートの配列
    [SerializeField] 
    private GameObject [] hearts;

    // 表示したいハートの数
    public void SetHearts(int count)
    {
        for(int i = 0; i < hearts.Length; i++)
            hearts[i].SetActive(i < count);
    }

    // (省略)
}

SetHeartsブロック崩しゲームの「SetLives」と同じ処理であるが、書き方を短くしただけである。後は、HitPoints の体力が変わったら、UI を更新するにで、 HitPoints を修正しましょう:

using UnityEngine;

// 体力を管理するクラス
public class HitPoints : MonoBehaviour
{
    // 体力を表示するため
    [SerializeField] 
    private UIManager uiManager;
    
    // 体力
    private int hp = 3;

    private void Start()
    {
        uiManager.SetHearts(hp);
    }

    // トリガーに入ったら
    private void OnTriggerEnter(Collider other)
    {
        // ダメージの起源かどうかを確認
        DamageSource src = other.GetComponent<DamageSource>();
        if (src == null)
            return;
      
        // 体力を減らし、0 以下にならないように制限する
        hp = Mathf.Max(0, hp - src.GetDamage());
        uiManager.SetHearts(hp);
    }
}

本番のシーンを作成

今まで、作業用のシーンでゲームに必要な各部品を開発してきた。これで、本番のステージを作れるので、まず、再利用するもを1つのグループにし、プレハブ化にしましょう

image.png

共通のゲームオブジェクト(Player、Base(カメラ)、Canvas(UI)と EventSystem)をグループ化し、再利用できるプレハブにする。Zombieは別のプレハブとして保存。

つぎ、新し「Game」シーンを作成し、「Meshes/PlatformKit」から好きなパーツを選んで、ステージを作成してください。「Common」プレハブを挿入し、ゲームを遊びましょう~

適切にコライダー、ナビゲーションメッシュなどを作成してね~

image.png

課題

Timmyの伝説に新たな機能を追加し、よりも面白くする!

例えば…

ルール

提出

提出するのは、プロジェクトの以下のフォルダのみ

また、「変更点.txt」を作成し、何を変えたのかを説明してください(速く見つけるため)

この3つのフォルダと「変更点.txt」をZIPファイルに圧縮し、提出フォルダにコピーしてください。