ティミーの伝説
初めての3Dゲームを作ってみましょう
- プロジェクト設定
- Timmyの移動を実装
- 主人公を追いかけるカメラ
- Timmyがカメラに相対的に移動する
- ジャンプを実装
- アニメーションを追加
- アイテムを拾う
- 宝箱を作成
- UIを作成
- ゾンビを実装
- 本番のシーンを作成
- 課題
プロジェクト設定
まず、新規プロジェクトを作成しましょう。2Dゲームであるため「Universal 3D」を選んでください。プロジェクト名は「legend」にしましょう。
素材を追加
- 3Dモデル
- 画像、テクスチャ
- プロトタイプ用のテクスチャ
- UIのスプライト
- プロトタイプ用のテクスチャ
- フォント(Google Fonts)
すべての素材をプロジェクトに追加しましょう。
プロトタイプ用のシーンを構築
本番のシーンを作る前、各部品を個別で開発しなければならない。キャラクターの移動やカメラの操作などを作業用のシーンで開発し、出来上がったら本番シーンに入れると快適に仕事ができる。
それでは、「Scenes」フォルダーを作成し「Prototype」の新しいシーンを追加してください。その後、床面になる「キューブ」を追加し、サイズは [50 x 0.1 x 50] で広い遊ぶ場所を準備しましょう
ただし、このままだと、真っ白な床面で動きなどが把握できないので、単純なプロトタイプ用のテクスチャ(greybox_grey_grid)をドラッグドロップで追加しましょう。ただし、床面のサイズは50倍大きくなったので、テクスチャも50回をタイル化しましょう。床の「Material」(マテリアル)を選択し、「Tiling」を 50 x 50 にしてください。
また、坂を上るとジャンプのテストもやりたいので、ランプと箱を追加しましょう。ランプはキューブから作成し、斜めに倒せば良いでしょう:
最後、ただのキューブを作成し、ランプとつながるようにする:
素材を作成する前に、ゲームの操作、面白さ、楽しさを確かめらためのプロトタイプシーンは「Grayboxing」(グレーボクシング)という。ある程度コストを削減し、まずゲーム性を確かめるためのシンプルなレベルである。
Timmyの移動を実装
主人公のゲームオブジェクトを作成
主人公のキャラクターの移動を実装しましょう。まず、空のゲームオブジェクトを作成し、「Player」という名前を付けてください。このゲームオブジェクトはプレーヤーの役割にする。
3Dモデルを追加
Characters/Timmy/Models から Timmy のモデルを追加してください。Player の子オブジェクトとして追加しましょう:
よく見ると、色が多少おかしい。これはノーマルマップ(※)の問題であるので、解決しましょう。Characters/Timmy/Textures の中にあるノーマルテクスチャ(青い画像)を選択し、ノーマルマップであることを指定し、最後に「Apply」を押してください:
|
(※)ノーマルマップについては、また「ゲームエンジンII」で勉強する予定 |
|
物理処理の準備
キャラクターを物理的に移動するべきなので、コライダーとリジッドボディが必要。Player に Rigidbody と CapsuleCollider を追加してください。なお、コライダーの形がある程度 3Dモデルの体に合わせましょう:
スクリプト
Inputクラスを使用し、キャラクターを動かしましょう。後で改善するべきですが、とりあえず、WASD のキーで前後左右を移動しましょう:
AとDでX軸(赤い軸)で移動し、W とSでZ軸(青い軸)で移動する。なお、各入力は:
Input.GetAxis("Horizontal"): 入力の「水平軸」(つまりAとD)の入力をの取得ができる- Aの場合は -1 を返す
- Dの場合は1を返す
- 入力のない場合は0を返す
Input.GetAxis("Vertical"): 入力の「垂直軸」(つまりWとS)の入力をの取得ができる- Wの場合は1 を返す
- Dの場合は-1を返す
- 入力のない場合は0を返す
また、移動速度を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(単位ベクトル)にする。今回の処理は以下の図で解析してある:
上下、または左右 のみ で移動する場合は、GetAxis が -1~1 の数値を返すので、移動速度「speed」を掛けると、問題なく、長さ5の速度ベクトルができる。一方、同時に入力すると(斜め移動)
掛け算の前に単位ベクトルにすると:
実行確認
このスクリプトを「Player」にアタッチし、実行してみてください。移動し、ランプを上り、ジャンプしてみてください。なお、カメラの処理まだ行っていないので、Game ビューと Scene ビューを同時に表示するのはおすすめ。
問題:倒れたり、回転したりする
すべての動きをリジッドボディに任せているため、移動だけではなく、回転も自由になっている。今後、回転はスクリプトで実現するので、リジッドボディが回転ができないようにすれば良いでしょう。制約設定で回転を固定しましょう:
問題:ゆっくり落ちる
回転の問題を解決できたが、ランプから飛び出すと、ゆっくりに落ちる。なぜなら、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」にしてください。
改善:セルフィスティック
今の処理はとても単純で、カメラはプレーヤーの足元に行ってしまいました。もう少し賢い作戦を考えてみましょう。「三人称」といえば:
セルフィスティックは良いでしょう!シーンにあるカメラの親子関係を変えて、同じ仕組みになるようにしましょう。具体的に:
- Base(基礎) :主人公を追いかける根。セルフィスティックなら、「人間が立っている場所」
- Height(高さ) :地面からの高さ。だいたいTimmyの頭までにすれば良いでしょう。
- Pole(ポール) :スティックのそのもの。上がるか下がることにより、撮影角度を変えられる
- Camera(カメラ):スティックの端にあるカメラ。適切な距離にし、Timmyが全身が見えるようにしましょう。
これはヒエラルキー(とその位置)はこうなる:
|
|
|
|
|
|
なお、「CameraController」のスクリプトは、カメラではなく、実際に動く基礎(Base)アタッチすべき。Main Camera からスクリプトを削除するのは、右側にある「⋮」を押し、「Remove Component」(コンポーネントを削除)でできる。
では、「CameraController」をBaseにアタッチし、もう一度確認しましょう。
カメラをマウスで回転
カメラは主人公に追いかけても、周りを自由に見たいので、マウスでカメラを回転させたい。今のカメラの作りだと簡単に責任を分けることができる!
- Height オブジェクトをY軸まわりに回転させると、360度の回転ができる。
- Pole オブジェクトをX軸まわりに回転させると、上下の角度を変えることができる。
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」(入力マネージャー)から確認ができる。
ここで「Horizontal」という入力名は「Positive」(正の数)と「Negative」(負の数)があるので、‐1~1の間の「軸」であることを確認できる。つまり、Negative Buttonの「a」を押すと、Input.GetAxis("Horizontal") が -1 を返す、Positive Button の「d」を押すとInput.GetAxis("Horizontal") が1を返す。この行動がすでに使用し、Timmyを動かしました。
その他には、Fire1~Fire3 や Jump の入力名がある。これはデフォルトとして定義されている入力であるが、変えたり、消したり、増やしたりすることが可能である。
「Jump」を確認してみましょう:
この入力は「Positive」のみで、「Negative」がない。つまり、この入力は「軸」ではなく「ボタン」である。ボタンは「Input.GetButtonXXX("入力名")」で取得でき、「true」または「false」を返す。GetMouseButtonXXX と同様に3パターンがある:
デフォルト設定としては「Jump」という入力は「space」に設置されているので、スペースキーを押したら GetButtonDown("Jump") は true になる!
もちろん、これらあくまで初期設定なので、キーを変えて良いし、名前も変えても大丈夫(例えば、「Kick」「Punch」「Attack」など)
もうちょっと見ると、多くの入力は2回が現れる!もう一つの "Horizontal" を確認すると:
あれ?「Positive」も「Negative」もない?なにこれ?
これは、"Horizontal" に対するもう一つの入力処理である。Typeを詳しくみると、「Joystick」というキーワードが書かれている。
そう!これはコントローラーに対応する入力。A/Dを使えば、"Horizontal" が発動するし、コントローラーの「X Axis」を使うと、同じ "Horizontal" が発動する。
つまり、入力マネージャーが入力ハードウェアを抽象化してくれる機能である!
キーワード&マウス、コントローラーを関係なく、C#で「Horizontal」を処理すれば十分。Unity が裏側で入力を通訳してくれる。
カメラ操作の入力を作成
すでに「MouseX」と「MouseY」の入力を定義されているので、これを再利用しましょう。MouseX はマウスの横の動きなので、これを「CamRotY」にしましょう。同様に MouseY を「CamRotX」に書き換えましょう。
これで、後ほどコントローラーの対応を追加したいなら、同じ名前の新しい入力を追加し、適切なボタンやスティックを割り当てることが可能。
スクリプト
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 で 最大角度に飛んでしまう!
解決には、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軸に沿って動くので、カメラの向きが変わったら、おかしく感じる。
この問題を解決するには、世界に絶対的に動くのではなく、カメラの向きに合わせて相対的に移動すること。
|
|
|
|
この場合は、カメラ前方(青い矢印)が世界の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;
}
}
これでジャンプを確認しましょう。
問題:多重ジャンプ
|
ジャンプできるようになったが、空中であってもジャンプができるので、ジャンプし続けば空に飛んでしまう!これを防ぐには、現状にジャンプできるかどうかの確認が必要。
今回は「足元の下に何もなければ(つまり空中であること)、ジャンプができない」ことがジャンプの条件にしましょう。
では、どうやって足元に地面の存在確認できるのか?これを学んだ「 |
|
では、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」にする
魔雑「0」弾み「0」で、どちらにも「最低数値」を使用。 Player のコライダーに「PlayerSlide」をアサインすれば完成
アニメーションを追加
Timmy がずっと「T-ポース」でなかなか面白くないので、走るときとジャンプの時もアニメーションを再生しましょう。このため「アニメーションコントローラー」(Animation Controller)および「アニメーター」(Animator) コンポーネントを使用し、条件でアニメーションを切り替えて、主人公を適切に動かしましょう。
シーン設定
動くのは Timmy なので、Timmy (Playerではなく) に Animator コンポーネントを追加してください。
ここで、「Controller」と「Avatar」を設定しないといけない。
Controller とは
Controller(アニメーションコントローラー)はアニメーションの遷移を管理するところである。以下のような画面でアニメーションを挿入し、「Transition」(遷移)で、アニメーションの切り替えが管理できるものである。
Timmy のコントローラーがまだできていないので、新しいのを作りましょう。Characters / Timmy / Animations の中で新しい「アニメーション ➡ アニメーションコントローラー」を作成し、「TimmyController」の名前にしてください:
これを「Animator」コントローラーの「Controller」に設定ください。
Avatar とは
Avatar は、3Dモデルの「骸骨」(スケルトン)である。アバターの「骨」(ボーン)を動かすことにより、モデルが正しく変形する。この情報3Dモデルのデザイナーさんが作るべき。開発者はモデルからデータを抽出するだけ。プロジェクトフォルダでTimmyのモデルを選択しましょう(Characters / Timmy / Models / Timmy)
ここで「Rig」を確認しましょう。このモデルからアバターを作るので、いくつかの設定を変えないといけない。
- Animation Type:Generic から Humanoid に更新(一般 → 人間っぽい)
- Avatar Definition:No Avatar から Create From This Model (アバターなし → このモデルから作成)
その後は「Apply」ボタンを押してください。大きく変わらないが、ボーンを確認ができる。「Configure」ボタンを押すと
アバターが抽出されたことが確認ができる。アバターができたので、Timmy の Animator コンポーネントの Avatar を設定しましょう
アニメーションファイルのアバターも!
これで、キャラクターの骸骨(アバター)を設定できたが、次に、このモデルが使うアニメーションにもアバターの設定が必要。Characters / Timmy / Animation の中にいろんなファイルが入っているが、TimmyController 以外はすべてアニメーションである:
このアニメーションは「Timmyのアニメーションだよ!」を指定するために、このファイルに作ったばかりのアバターを指定しなければならない。すべてのアニメーションを選択し、また「Rig」を確認しましょう。ここでは
- Animation Type:Generic から Humanoid に更新(一般 → 人間っぽい)
- Avatar Definition:No Avatar から Copy From Other Avatar(アバターなし → 他のアバターからコピーする)
- Source:TimmyAvatar を選択し、同じアバターを利用
最後に、「Apply」を押してください。これで、アニメーションファイルと Timmy の3Dモデルの紐づけができる。
アニメーション
待機/アイドル(Idle)
まず、キャラクターが何もしてないときのアイドルアニメーションを作成しましょう。コントローラーの中に「idle」のアニメーションをドラッグドロップで追加
今回のアニメーションが Mixamo からダウンロードしたので、アニメーションの名前は「mixamo_com」になってしまう。これでわかりにくいので、Inspector で「Idle」という名前にしましょう。これがデフォルトアニメーションになるので、このままで実行してみましょう:
「T-ポーズ」ではなく、待機アニメーションになった!ただし、少し長めに待つと、アニメーションが止まってしまいます。なぜなら、デフォルトとしてアニメーションがループしないから。プロジェクトで「idle」を選択し「Animation」の設定を Inspector で更新しましょう:
ここで「Loop Time」のチェックが外しているため、アニメーションがループはしない。チェックを入れ、もう一度確認しましょう。
走る(Running)
つぎに、走るアニメーションを追加しましょう。プロジェクトから「running」をアニメーションコントローラーに追加し、アニメーション名を「Running」にしましょう。このアニメーションもループできるように設定を変えてくださいね。
次、遷移(Transition)を作らないといけない。Idle → Running と Running → Idle それぞれの遷移を「右クリック」→「Make Transition」で作成しましょう:
これで実行すると、待機→走る→待機 の繰り返しが続ける。走り始めると、Idle → Running の遷移し、止まったら Running → Idle に戻るようにしましょう。そのため、「今走っているのか?」のパラメータを追加しましょう。アニメーターコントローラーで Bool 型の IsRunning のパラメータを追加してください:
これが遷移するかしないかのパラメータにしましょう。Idle → Running で IsRunning は true だったら、遷移する。そして、Running → Idle には、 IsRunning が false の時遷移しましょう:
|
|
これで実行し、アニメーションコントローラーのパラメータ 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つのパーツに分割されている
- ジャンプ開始(jumping up):ジャンプが始まるときに再生
- 空中(falling idle):高いところからジャンプすると、ずっとこのアニメーションを再生する
- 着陸:2種類がある
- 移動中の着陸:着地→転がって→立つ(falling to roll)
- 停止中の着陸:着地→立つ(hard landing)
これで遷移が難しくなるが、少し考えて遷移を整理しましょう。赤は遷移のパラメター条件(Condition)
- 待機、または走行からジャンプキーを押すと、jumping upを再生(トリガー使用)
- Idle → [JumpTrigger] → JumpUp
- Running → [JumpTrigger] → JumpUp
- 現状が関係なく、空中だったら falling idle を再生
- Any State → [IsAir == true] → Falling
- Any State → [IsAir == true] → Falling
- 空中ではなくったら、かつ、走っていれば、転がる
- Falling → [isAir == false] [isRunning == true] → Roll → Running
- Falling → [isAir == false] [isRunning == true] → Roll → Running
- 空中ではなくったら、かつ、止まっていれば、hard landing を再生
- Falling → [isAir == false] [isRunning == false] → Land → Idle
まず、遷移図を作りましょう。アニメションを追加し、わかりやすい名前にしてください:
※falling idle は ループするので、アニメションの設定を忘れずに
スクリプト
遷移とその条件ができたら、次、スクリプトでパラメターの設定が必要。ジャンプに関する処理が「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倍の間にお好みの数値で調整し、確かめてください。
着陸したと、また移動中かどうかを確認
以下のように遷移を調整し、Land か Roll の再生中に、走行の状況が変わったら、すぐ適切なアニメーションへ遷移する。
ジャンプポーズでフリーズする
|
ジャンプ中に、連続にジャンプすれば、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 型の トリガーを追加してください
※注目: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」フォルダーの中に入れ、プレハブ化にしてください
インベントリ
つぎ、主人公のインベントリ(アイテム管理クラス)を作成しましょう。このクラスは 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}");
}
}
いくつかのコインを配置し、確認してみてください。
鍵
コインと同じ手順を従い、鍵のプレハブを作成してください。ただし:
- 親オブジェクトの名前:Key
- タグ:Key
にしてください。そして、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つがある:
- 新しいアイテムを増やすのは大変:何度も同じ処理を追加しないといけない
- 処理を直すのも手間がかかる:何か直したい場合、1か所ではなく、アイテム1個ずつ直さないといけない。
できる限りに、統一している処理を一体にすると、ゲームの整備が楽になる!このため、新しい 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!
- 整数(int)
- 文字列(string)
- 他のオブジェクト(GameObject)
- なんでも良い(比較で等しいかどかを確認できれば)
書式
ディクショナリーの宣言は、以下のようになる:
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倍ぐらい大きくしましょう。
なお、ちゃんとぶつかるように、宝箱はコライダーが必要。3Dモデルの子オブジェクトに BoxCollider を追加し、サイズを調整してください:
アニメーション
もし、主人公が会議を持っていれば、宝箱が開くので、ふたが開くアニメーションが必要。アニメーションパネルを使用し、「OpenChest」作ってください:
| フレーム | 0 | 30 |
| lid の 回転 | 0, 0, 0 | -120, 0, 0 |
なお、ループしないように、出来上がったアニメーションを Project フォルダーで探し、Loop Time のチェックを外してください
このまま実行すると、宝箱が勝手に開いてしまうので、アニメーションコントローラーの調整が必要。現状を見ると:
ゲーム開始のときに「OpenChest」が実行するので、勝手に開いていしまうことが確認できる。そうしないように、空のアニメーション「Empty」を作成しましょう。アニメーターパネルの中に右クリックし、Create State → Empty を選択してください。
この新しいアニメーションの名前を「Empty」にし、デフォルトアニメーションにしましょう。アニメーションの上に右クリックし、「Set as Layer Default State」を選択しましょう、これで、ゲーム開始の時、何もしない。
今回のアニメーションは、ただの切り替えなので、遷移を使用しない。このため、パラメータを作成する必要なく、Transition も使わない。
スクリプト
今回のスクリプトは:
- 何か衝突のとき、
- 衝突したゲームオブジェクトが Inventory があるのかを確認 → なければなにもしない
- Inventory の中に鍵の数を問い合わせる。
- 鍵なければ → なにもしない
- 1つ以上あれば、鍵を消費し、宝箱が開く。
- 開いていれば、2度もチェックしないこと。
宝箱を開くスクリプト
では「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 のコンポーネントがあるか」のを確認している。そのコンポーネントがないと、inventory が null になるので、必ずプレーヤーであることを保証ができる。
主なポイント②:遷移なしのアニメーション再生
// 遷移せず、直接にアニメーションを再生
Animator anime = GetComponent<Animator>();
anime.Play("OpenChest");
今まで、パラメータを使用し、遷移でアニメーションを切り替えが行ってきたが、直接に「このアニメーションを再生しろ」のもできる。今回の単純な切り替えで、これで十分。
主なポイント③:在庫確認と消費
// 鍵の数を求める
int count = inventory.GetCount("Key");
// 鍵を消費
inventory.UseItem("Key", 1);
Inventoryの編集
上記の2つのメソッドまだ作成していないので、実装しましょう。Inventory クラスを編集し、
- 在庫を返す
GetCountのメソッドを作成。引数は確認したいアイテムのタグ名 - アイテムを消費する
UseItemのメソッドを作成。引数は消費したいアイテムのタグ名とその数
// アイテムの数を返す
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 を使用すれば、画面が大きくなっても、小さくなっても、いつも正しく表示される
|
Canvas Scaler 無し:FullHD から 4K に切り替えると、UIが小さくなってしまう |
|
Canvas Scaler で想定サイズを入力すると、4K に切り替えても、UIがのサイズを保持する |
今回、上記の設定で進めましょう。
コインの数
画面の右上にコインの数を表示しましょう。
Canvas の中で、以下のようにヒエラルキーを作ってください:
|
こうなるように:
|
|
Coinの画像が付かない?
3Dゲームを作るときに、すべての画像が 3Dモデルで使う「Texture (テクスチャ)」と思われる。テクスチャが UI で使えないので、スプライトになるように設定しなければならない。
UIで使う画像を選択し、Inspector で設定を変えてください:
これで、「Sprite」(2Dゲームと UI)にし、スプライトモードは「Single」(単体)にする。
※:同じ画像にたくさんのスプライトがある場合はここは「Multiple」(多数)になる
鍵の数
同じ手順を従って、画面の右下に鍵の数が表示できるUIを作成してください
スクリプト
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 をアタッチし、各種のテキストを設定:
そして、Inventory と UIManager の紐づけも
これでもう一度確認しましょう。
ゾンビを実装
主人公を追いかける「ゾンビ」を実現しましょう!
シーンの設定
ゲームオブジェクトと3Dモデル
Timmyと同様に、ゾンビのゲームオブジェクトを作成しましょう。空のゲームオブジェクトを作成し、その下に子オブジェクトとしてZombieのモデルを追加する:
ただし、ゾンビは物理的に動かないので、コライダーとリジッドボディが要らない。ゾンビの動かした方については、下記で説明する。
アニメーション
今回は、アニメーションは1つしかなくて、ずっと歩くようにしましょう。ただし、Timmy と同様に「アバター」の設定が必要。
そして、ゾンビのアニメーターコントローラーを作成し、「ZombieWalk」のアニメションをデフォルトにする
これで実行してみてください(ループを忘れずに~)
自動的に道を探す「ナビゲーション」
人間を制御するキャラクターが、当たり判定を確認するだけで、動きの実装ができる。人間が勝手に障害物などを避けて動く。一方、CPUキャラクターがとても馬鹿であり、障害物を避けたらすることができない。
この問題を解決するには、Unityが「Navigation」(ナビゲーション)を提供する。ナビゲーションとは、自動車のナビと同じように、目的地を設定し、最適なルートを探す処理である。
ナビゲーションは以下の主なコンポーネントを組み合わせて、作成する
その他にも、ナビゲーションの影響にあるコンポーネント(NavMesh Link、NavMesh Obstacle、など)もあるが、最低限でこの2つの組み合わせで十分。
ナビゲーションメッシュ(NavMesh Surface)
ナビの地図である。地図を作るのは、シーンを固定してから!シーンが変わったら(壁を増やした、障害物の位置を変えた)、地図も更新しないといけないので、ご注意ください。
まず、地図に使うオブジェクトをグループ化にしないといけないので、シーンを整理し、ゾンビと主人公の間にいくつかの壁を作ってみましょう。空ゲームオブジェクト「Stage」を作成し、中には床面、ランプそして新しい壁を追加してください。
ここで「ナビゲーションメッシュ」を作りましょう。「Stage」を選択し、「NavMesh Surface」を追加してください
ここで地図を作る時に必要なパラメータを設定できるが、最も重要なのは、何を基づいて地図を作るのか。これは「Object Collection」から設定ができる。今回は「Stageとその子オブジェクトを使用」にしたいので「Current Object Hierarchy」(現在オブジェクトのヒエラルキー)を選択しましょう。
そして、ナビゲーションメッシュを作成するには「Bake」(焼く)ボタンを押すだけ。このボタンを押すと、Stageとその子オブジェクトのすべてを処理し、歩ける「地図」を水色で表示される:
壁にあまり近づかないよう、周囲に「歩けない」領域が現れる。この地図が焼いているので、ステージが変わっても地図が変わらない! 1つの壁を削除しても、「歩けない」領域が残る:
ナビゲーションメッシュを焼いた後にシーンが変わったら、もう一度「Bake」してください。
エージェント(NavMesh Agent)
エージェントは、水色の「地図」(ナビゲーションメッシュ)上で歩くものである。目的地を設定すれば、現在地から最適な道を探し、自動的に動く。
今回のゲームで、ゾンビが動いてほしいので、エージェントにしましょう。ゾンビ(親オブジェクト)に「NavMesh Agent」を追加してください。
ここでエージェントのパラメータを調整ができる。主なパラメータは:
| 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)で指定ができる
まず、ダメージの起源を作成しましょう:
// ダメージの起源
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 を追加してください
そして、Player に HitPoints を追加し、Debug.Log で体力を表示してください。
UIの更新(体力)
Debug.Logでカッコ悪いので、UIも作成しましょう。画面の左に、3つのハート画像をグループとして配置しましょう
そして、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つのグループにし、プレハブ化にしましょう
共通のゲームオブジェクト(Player、Base(カメラ)、Canvas(UI)と EventSystem)をグループ化し、再利用できるプレハブにする。Zombieは別のプレハブとして保存。
つぎ、新し「Game」シーンを作成し、「Meshes/PlatformKit」から好きなパーツを選んで、ステージを作成してください。「Common」プレハブを挿入し、ゲームを遊びましょう~
適切にコライダー、ナビゲーションメッシュなどを作成してね~
課題
Timmyの伝説に新たな機能を追加し、よりも面白くする!
例えば…
- HP回復アイテムを実装
- 罠を作成(消えるプラットフォーム→穴に落ちる)
- ドアを実装
- 定めたコインの数がないと開かない
- また、レバーを引くと開く
- HPゼロのときに、倒れるアニメーションを再生し、ゲームオーバーする
- 宝箱を開くと、コイン10個が飛んで出てくる
- サウンドを追加(アイテムを拾うとき、宝箱を開くとき)
- 移動プラットフォームを実装
- ボールを投げ、ゾンビを倒す
- その他の好きな機能(自由)
ルール
- 1つの機能を実装すると:合格(最低限)
- 難しいことを頼んでいない!
- 自分のスキルレベルに合わせて適切な機能を選択し、実装してください。
- 2つ以上の機能、または複雑な機能を実装すると:点数向上
- 基本として、授業の時間で実装するべきが、自宅で完成度を高めたいなら問題ない
- 一人でやるべき(お友達からコピーするのはNG)
- ネット、教科書、資料、今まで作ってきたプログラムを参照してもOK
- ただし、AI(ChatGPT、Geminiなど)はNG
- ネットで見つけたスクリプトのコピーぺーについて
- 当然、把握せずにコピーしないでください
- プログラムの動きを解析し、分かれば、使ってもOK
- 説明してほしいなら、先生を呼んでください。
- それでも進まないなら、先生に聞いてもOK(ヒントを出す)
提出
提出するのは、プロジェクトの以下のフォルダのみ:
- Assets
- Packages
- ProjectSettings
また、「変更点.txt」を作成し、何を変えたのかを説明してください(速く見つけるため)
- 例:Timmyが玉を打ち、攻撃できるようになった(スクリプト:○○.cs)
この3つのフォルダと「変更点.txt」をZIPファイルに圧縮し、提出フォルダにコピーしてください。