今回はゲームプレイのゴーストを表示する機能を作成していきます。
以前作成した
の機能を自分なりに作成したものです。
今回の機能を作成すると、例えばレーシングゲームで自分の車のゴーストを表示させたり、アクションゲームで「このように攻略してください」といった例をゴーストとして表示する時に応用して使えると思います。
ただし完全に正確なゴーストを作成出来るわけでもなく多少ズレるかもしれません。
今回の機能ではキャラクターの動きを保存し、そのデータをその場で再生出来る機能だけでなくファイルにゴーストデータを保存して後からデータを読み出してゴーストの再生を出来るようにしていきます。
今回の機能を作成すると
↑のようなものが出来上がります。
動的に動く床のゲームオブジェクトとはスタートが同期していないのでゴーストキャラクターの位置と動く床の位置は一致しません。
ゴースト機能の概要
まずはゴースト機能をどのように作るかを考えていきます。
キャラクターの動きを後から再生すればいいので、Update毎にキャラクターの位置や角度、アニメーションパラメータの値等をデータとして保持しておき、あらかじめ用意しておいたゴーストキャラに通常の操作キャラクターと同じAnimatorControllerを取り付け、ゴーストキャラクターの位置や角度、アニメーションを保持したデータで再生すれば出来そうです。
ただ毎回Updateメソッドが呼ばれる度にデータを追加していくとデータ量が多くなりすぎるので、データを取る時間間隔の設定とデータの数の制限を設ける事にします。
ゴースト機能の作成
それではゴースト機能を作成していきましょう。
操作キャラクターの準備
まずは通常の操作キャラクターを用意します。
キャラクターにはCharacterControllerを取り付け、コライダのサイズを調整します。
キャラクターにはAnimatorControllerを作成し取り付けます。
AnimatorControllerにはIdle、Walk、Jump状態を作成します。
アニメーションパラメータにFloatのSpeed、TriggerのJumpを作成します。
Idle→WalkはSpeedがGreaterで0.1
Walk→IdleはSpeedがLessで0.1
Idle→Jump、Walk→IdleはJumpがトリガーされた時
Jump→IdleはHas Exit Timeにチェックを入れておきます。
キャラクターを動かすスクリプトは
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | using System.Collections; using System.Collections.Generic; using UnityEngine; public class GhostChara : MonoBehaviour { private CharacterController characterController; private Animator animator; private Vector3 velocity; [SerializeField] private float walkSpeed = 1.5f; [SerializeField] private float jumpPower = 5f; // Use this for initialization void Start () { characterController = GetComponent<CharacterController> (); animator = GetComponent<Animator> (); velocity = Vector3.zero; } // Update is called once per frame void Update () { if (characterController.isGrounded) { velocity = Vector3.zero; var input = new Vector3 (Input.GetAxis ("Horizontal"), 0f, Input.GetAxis ("Vertical")); if (input.magnitude > 0f) { transform.LookAt (transform.position + input); animator.SetFloat ("Speed", input.magnitude); velocity = input * walkSpeed; } else { animator.SetFloat ("Speed", 0f); } if (Input.GetButtonDown ("Jump") && !animator.GetCurrentAnimatorStateInfo(0).IsName("Jump") ) { animator.SetTrigger ("Jump"); velocity.y += jumpPower; } } velocity.y += Physics.gravity.y * Time.deltaTime; characterController.Move (velocity * Time.deltaTime); } } |
を取り付けます。
最小限の移動とジャンプが出来るスクリプトです。
これで操作キャラクターは完成です。
ゴーストキャラクターの作成
ゴーストキャラクターはキャラクターのモデルにCharacterControllerと操作キャラクターに設定したAnimatorControllerをゴーストキャラクターにも取り付けます。
ゴーストキャラクターのモデルにはEthanを使用していて、EthanBodyとEthanGrassesにはデフォルトでEthaWhiteマテリアルが設定されています。
そこでゴーストキャラクター用のGhostMaterialを作成し、それをゴーストキャラクターのEthanBodyとEthanGrassesにドラッグ&ドロップします。
GhostMaterialは↑のように半透明なマテリアルにします。
ゴーストキャラクターは
↑のように半透明になります。
ゴーストキャラクターはGhostという名前にし、最初は非表示にする為インスペクタの名前の横のチェックを外しておきます。
ゴースト機能スクリプトの作成
ヒエラルキー上に空のゲームオブジェクトを作成し、名前をRecorderとします。
Recorderゲームオブジェクトには新しくRecorderスクリプトを作成し取り付けます。
フィールド宣言部とStartメソッド
まずはフィールド宣言とStartメソッドを見ていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | using System.Collections; using System.Collections.Generic; using UnityEngine; using System; using System.IO; public class Recorder : MonoBehaviour { // 操作キャラクター [SerializeField] private GhostChara ghostChara; // AnimatorController private Animator animator; // 現在記憶しているかどうか private bool isRecord; // 保存するデータの最大数 [SerializeField] private int maxDataNum = 2000; // 記録間隔 [SerializeField] private float recordDuration = 0.005f; // Jumpキー private string animKey = "Jump"; // 経過時間 private float elapsedTime = 0f; // ゴーストデータ private GhostData ghostData; // 再生中かどうか private bool isPlayBack; // ゴースト用キャラ [SerializeField] private GameObject ghost; // ゴーストデータが1周りした後の待ち時間 [SerializeField] private float waitTime = 2f; // 保存先フォルダ private string saveDataFolder = "/Projects/Ghost"; // 保存ファイル名 private string saveFileName = "/ghostdata.dat"; void Start() { animator = ghostChara.GetComponent<Animator> (); } |
インスペクタで操作キャラクタースクリプトghostCharaを設定出来るようにし、そのキャラクターのデータを保持出来るようにします。
isRecordは今ゴーストを記録しているかどうか
maxDataNumはデータを保持する最大数で、この数字を超えたらゴーストの記録を停止します。
recordDurationはキャラクターのデータを取る時間間隔です。
animKeyはInputManagerのキーの名前を指定します。
elapsedTimeは前回記録してからの経過時間を入れます。
ghostDataはゴーストデータをクラスとして持たせて、そのインスタンスを入れておくフィールドです。
isPlayBackは今ゴーストを再生しているかどうか
ghostはゴーストとして動かすゴーストキャラクターのゲームオブジェクトをインスペクタで設定します。
waitTimeはゴーストの再生が終わった後は最初から再生し直しますが、その間の待ち時間を設定します。
saveDataFolderは保存先のフォルダのパスです。
saveFileNameは保存ファイル名です。
StartメソッドではGhostCharaスクリプトからAnimatorコンポーネントを取得しanimatorに保持しておきます。
似たようなフィールド名になっていますが、ghostCharaはGhostCharaスクリプト、ghostはゴーストキャラクターのゲームオブジェクトになります。
ゴーストデータクラス
Recorderクラスの中にゴーストデータを保持するクラスを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // ゴーストデータクラス [Serializable] private class GhostData { // 位置のリスト public List<Vector3> posLists = new List<Vector3>(); // 角度リスト public List<Quaternion> rotLists = new List<Quaternion>(); // アニメーションパラメータSpeed値 public List<float> speedLists = new List<float>(); // Jumpアニメーションリスト public List<bool> jumpAnimLists = new List<bool>(); } |
後でクラスごとシリアライズするので[Serializable]アトリビュートをクラスに付けておきます。
それぞれリストでデータを保持出来るようにします。
データを記録するUpdateメソッド
次はデータを記録する処理を書いているUpdateメソッドを見ていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // Update is called once per frame void Update () { if (isRecord) { elapsedTime += Time.deltaTime; if (elapsedTime >= recordDuration) { ghostData.posLists.Add (ghostChara.transform.position); ghostData.rotLists.Add (ghostChara.transform.rotation); ghostData.speedLists.Add(animator.GetFloat("Speed")); // ジャンプデータの記憶 if (Input.GetButtonDown (animKey)) { ghostData.jumpAnimLists.Add (true); } else { ghostData.jumpAnimLists.Add (false); } elapsedTime = 0f; // データ保存数が最大数を超えたら記録をストップ if (ghostData.posLists.Count >= maxDataNum) { StopRecord (); } } } } |
isRecordでデータを記録していたら経過時間を足して記録間隔を超えていたらghostCharaスクリプトのTransformから位置と角度を取得しGhostDataクラスのリストに追加します。
ジャンプだけはアニメーションパラメータをトリガーで作成しているので、ジャンプキーを押したか押してないかのbool値を追加します。
記録したデータ数がmaxDataNumを超えたらStopRecordメソッドを呼び出しデータの保存をやめることにします。
UIのボタンを押した時に呼び出す処理
次にUIのボタンを押した時に呼び出す処理を記述します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | // キャラクターデータの保存 public void StartRecord() { // 保存する時はゴーストの再生を停止 StopAllCoroutines (); StopGhost (); isRecord = true; elapsedTime = 0f; ghostData = new GhostData (); Debug.Log ("StartRecord"); } // キャラクターデータの保存の停止 public void StopRecord() { isRecord = false; Debug.Log ("StopRecord"); } // ゴーストの再生ボタンを押した時の処理 public void StartGhost() { Debug.Log ("StartGhost"); if (ghostData == null) { Debug.Log ("ゴーストデータがありません"); } else { isRecord = false; isPlayBack = true; ghost.transform.position = ghostData.posLists [0]; ghost.transform.rotation = ghostData.rotLists [0]; ghost.SetActive (true); StartCoroutine (PlayBack ()); } } // ゴーストの停止 public void StopGhost() { Debug.Log ("StopGhost"); StopAllCoroutines (); isPlayBack = false; ghost.SetActive (false); } |
StartRecordメソッドは「ゴーストの保存」ボタンを押した時に呼び出す処理で、コルーチンの全停止とStopGhostメソッドを呼び出しゴーストの再生を止めます。
GhostDataクラスのインスタンスを作成しghostDataに参照を入れます。
StopRecordは「ゴーストの保存の停止」ボタンを押した時に呼び出します。
StartGhostは「ゴーストの再生」ボタンを押した時に呼び出す処理で、保存したゴーストを再生する処理です。
ゴーストが保存されていればisPlayBackをtrue、ゴーストキャラクターであるghostをアクティブにしてPlayBackメソッドをコルーチンで呼び出します。
StopGhostメソッドは「ゴーストの停止」ボタンを押した時に呼び出しゴーストの再生をやめゴーストキャラクターを非アクティブにします。
ゴーストの再生処理
ゴーストの再生処理はPlayBackメソッドで行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | // ゴーストの再生 IEnumerator PlayBack() { var i = 0; var ghostAnimator = ghost.GetComponent<Animator> (); Debug.Log ("データ数: " + ghostData.posLists.Count); while (isPlayBack) { yield return new WaitForSeconds (recordDuration); ghost.transform.position = ghostData.posLists [i]; ghost.transform.rotation = ghostData.rotLists [i]; ghostAnimator.SetFloat("Speed", ghostData.speedLists[i]); if (ghostData.jumpAnimLists [i]) { ghostAnimator.SetTrigger ("Jump"); } i++; // 保存データ数を超えたら最初から再生 if (i >= ghostData.posLists.Count) { ghostAnimator.SetFloat ("Speed", 0f); ghostAnimator.ResetTrigger ("Jump"); // アニメーション途中で終わった時用に待ち時間を入れる yield return new WaitForSeconds (waitTime); ghost.transform.position = ghostData.posLists [0]; ghost.transform.rotation = ghostData.rotLists [0]; i = 0; } } } |
PlayBackメソッドではisPlayBackがtrueである間ghostDataから順番にデータを取り出しゴーストキャラクターの位置や角度、アニメーションの再生をします。
ゴーストデータはrecordDurationの間隔で保存したので、その間隔経過してから位置や角度、アニメーションを設定します。
保存データ数を超えた場合はまずSpeedを0、Jumpのトリガーをキャンセルした後、指定の時間待たせます。
その後ゴーストキャラの位置や角度を最初のデータの位置や角度にし、また最初から再生するようにします。
ファイルにゴーストデータを保存、読み出しする処理
最後はゴーストデータをファイルに保存したり読み出したりする処理を作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public void Save() { if (ghostData != null) { // GhostDataクラスをJSONデータに書き換え var data = JsonUtility.ToJson (ghostData); // ゲームフォルダにファイルを作成 File.WriteAllText (Application.dataPath + saveDataFolder + saveFileName, data); Debug.Log ("ゴーストデータをセーブしました"); } } public void Load() { if (File.Exists (Application.dataPath + saveDataFolder + saveFileName)) { string readAllText = File.ReadAllText (Application.dataPath + saveDataFolder + saveFileName); // ghostDataに読み込んだデータを書き込む if (ghostData == null) { ghostData = new GhostData (); } JsonUtility.FromJsonOverwrite (readAllText, ghostData); Debug.Log ("ゴーストデータをロードしました。"); } } void OnApplicationQuit() { Debug.Log ("アプリケーション終了"); Save (); } |
Saveメソッドは「ゴーストデータをファイルに保存」ボタンを押した時に呼び出す処理で、ghostDataをJSON形式に変換したものを指定したファイルに一括で書き出しています。
LoadメソッドはファイルからJSON形式で保存したデータをreadAllTextに一括で読み出しそれをGhostDataクラスの形式に戻してghostDataに値を入れています。
Save、Loadメソッドの処理に関しては、
を参照してください。
OnApplicationQuitはMonoBehaviourクラスで定義されているメソッドでアプリケーションの終了時に呼び出されます。
アプリケーション終了時にSaveメソッドを呼び出しファイルにデータを書き出しています。
ボタンのみでファイルに保存したい場合はOnApplicationQuitメソッドは要らないです。
これでスクリプトが完成しました。
UIの作成
スクリプトが完成したので、UIのボタンを作成しメソッドを呼び出すようにします。
UIのButtonを6つ作成しそれぞれ対応する名前を付けます。
「ゴーストの保存」ボタンはRecordButton
「ゴーストの保存の停止」ボタンはStopRecordButton
「ゴーストの再生」ボタンはPlayBackButton
「ゴーストの停止」ボタンはStopGhostButton
「ゴーストデータをファイルに保存」ボタンはOutputGhostDataButton
「ゴーストデータを読み込み」ボタンReadGhostDataButton
という名前にしました。
実際の並びは
↑のようにしています。
ボタンのOnClickに呼び出す処理を設定する
次はボタンのOnClickに呼び出す処理を設定します。
RecordButtonはRecorderのStartRecordメソッドを指定します。
他のボタンもRecorderスクリプトの該当するメソッドを指定します。
StopRecordButtonはStopRecordメソッド
PlayBackButtonはStartGhostメソッド
StopGhostButtonはStopGhostメソッド
OutputGhostDataButtonはSaveメソッド
ReadGhostDataButtonはLoadメソッド
をそれぞれ指定します。
これでUIのボタンを押した時に呼び出すメソッドの設定が終わりました。
ゴースト機能の完成
RecorderゲームオブジェクトのRecorderスクリプトの設定は、
↑のようになります。
GhostCharaには操作キャラクターをドラッグ&ドロップし、Ghostにはゴーストキャラクターのゲームオブジェクトをドラッグ&ドロップします。
これでゴースト機能が完成しました。
ジャンプ途中で「ゴーストの保存の停止」ボタンや「ゴーストの再生」ボタンを押すとゴーストの再生の最後でジャンプ終了時に高い所で止まってしまいます。
これはジャンプ途中の位置までしか記録せず保存を停止している為です。
ジャンプアニメーションは前のデータでトリガーされているので最後まで再生されます。
記事の最初に記したように動く床とゴーストキャラクターの位置が一致しません。
またデータに記録間隔を長くすると、実際のジャンプと位置が一致しなくなります。
さらにはジャンプボタンを押したかでbool値をデータに入れているので、アニメーション自体がされなくなる事もあります。
その為、記録間隔自体の処理をなくすか、ジャンプアニメーションに関してはジャンプボタンを押した時の経過時間を保持しておき、その時間が過ぎたらJumpアニメーションのトリガーをするという方法にした方がいいかもしれません。
機能を確認すると記事の最初に紹介した動画のようになります。(^^)/
ジャンプデータを時間で計測するバージョン
完成したゴースト機能ではジャンプのデータをキーを押したかどうかで記録していますが、記録する間隔(recordDuration)が長いと押していてもデータが取れないことがあります。
そこでジャンプキーを押したかどうかはrecordDurationによらず常に確認するようにし、bool値のデータでなく押すまでの時間でデータを取るようにしてみます。
あらかじめ言っておきますが、時間で計測する為、本来の動きとのズレが発生します。
ですが、recordDurationを長めに取っても時間でデータを記録するのでジャンプデータの取りこぼしがありません。
Recordスクリプトの改造
ジャンプキーを押したかどうかを時間で保持する為にRecordスクリプトを改造していきます。
まずはフィールドを追加します。
1 2 3 4 5 6 | // ゴーストデータを取り始めた時間、または前のデータ private float startTime; // ゴーストの位置・角度のデータを最後まで再生したかどうか private bool isLoopReset; |
位置や角度と同じタイミングでジャンプのデータを取るわけではないので、ゴーストの再生が終わったらisLoopResetをtrueにし、それに伴ってジャンプのゴーストデータも最初に戻します。
次はGhostDataクラスのジャンプのデータを書き換えます。
1 2 3 4 | // Jumpアニメーションリスト public List<float> jumpAnimLists = new List<float>(); |
データはfloatで持つようにします。
次にUpdateメソッドでジャンプのデータを取る部分の変更です。
元のジャンプのデータを取る部分を削除し、if文の外に以下の処理を追加します。
1 2 3 4 5 6 7 | // ジャンプは押した時間を保持 if (Input.GetButtonDown (animKey)) { ghostData.jumpAnimLists.Add (Time.realtimeSinceStartup - startTime); startTime = Time.realtimeSinceStartup; } |
ジャンプキーを押した時にデータの記録を取り始めた時間からの経過時間、または前回ジャンプキーを押した時からの経過時間をリストに保存します。
startTimeには現在のゲーム開始からの時間を入れておきます。
次にStartRecordメソッドにstartTimeにゲーム開始からのリアルタイム秒を入れておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // キャラクターデータの保存 public void StartRecord() { // 保存する時はゴーストの再生を停止 StopAllCoroutines (); StopGhost (); isRecord = true; elapsedTime = 0f; ghostData = new GhostData (); startTime = Time.realtimeSinceStartup; Debug.Log ("StartRecord"); } |
次にStartGhostメソッドでPlayBackAnimメソッドのコルーチン処理を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // ゴーストの再生ボタンを押した時の処理 public void StartGhost() { Debug.Log ("StartGhost"); if (ghostData == null) { Debug.Log ("ゴーストデータがありません"); } else { isRecord = false; isPlayBack = true; ghost.transform.position = ghostData.posLists [0]; ghost.transform.rotation = ghostData.rotLists [0]; ghost.SetActive (true); StartCoroutine (PlayBack ()); StartCoroutine (PlayBackAnim ()); } } |
今まではPlayBackでジャンプのデータの再生もしてましたが、ジャンプはPlayBackAnimの方に移します。
PlayBackメソッドの処理を変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | // ゴーストの位置と角度の再生 IEnumerator PlayBack() { var i = 0; var ghostAnimator = ghost.GetComponent<Animator> (); Debug.Log ("データ数: " + ghostData.posLists.Count); while (isPlayBack) { if (isLoopReset) { yield return null; } yield return new WaitForSeconds (recordDuration); ghost.transform.position = ghostData.posLists [i]; ghost.transform.rotation = ghostData.rotLists [i]; ghostAnimator.SetFloat("Speed", ghostData.speedLists[i]); i++; // 保存データ数を超えたら最初から再生 if (i >= ghostData.posLists.Count) { ghostAnimator.SetFloat ("Speed", 0f); // アニメーション途中で終わった時用に待ち時間を入れる yield return new WaitForSeconds (waitTime); // ジャンプアニメーションと同期させる為、isLoopResetをtrueにする isLoopReset = true; ghost.transform.position = ghostData.posLists [0]; ghost.transform.rotation = ghostData.rotLists [0]; i = 0; } } } |
isLoopResetがtrueの時はゴーストの再生が終わり、ジャンプとの同期を取る為に待つためyield return nullでその後の処理をさせません。
次は新しく作ったPlayBackAnimメソッドです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | // ゴーストのアニメーションの再生 IEnumerator PlayBackAnim() { Debug.Log ("アニメーションデータ: " + ghostData.jumpAnimLists.Count); var i = 0; var ghostAnimator = ghost.GetComponent<Animator> (); while (isPlayBack) { // キャラクターの位置や角度の終了を待ってからアニメーションデータも最初に戻す if (isLoopReset) { ghostAnimator.ResetTrigger ("Jump"); i = 0; isLoopReset = false; } // データ数を超えていなければアニメーションの再生 if (i < ghostData.jumpAnimLists.Count) { yield return new WaitForSeconds (ghostData.jumpAnimLists [i]); ghostAnimator.SetTrigger ("Jump"); i++; // それ以外はnullを返す } else { yield return null; } } } |
ジャンプのデータは押した時間を記録していくので、位置や角度のデータと数が違います。
そこでisLoopResetがtrueの時にジャンプデータを最初に戻します。
ジャンプデータは前に押した時からの時間を保存しているので、その時間待った後にJumpアニメーションパラメータのトリガーをしています。
それ以外はyield return nullで何もしません。
これで修正は完了です。
確認すると、
↑のようにrecordDurationを0.2と長くしてもジャンプアニメーションがちゃんと再生されているのがわかります。
終わりに
位置や角度と同じようにジャンプデータを取るか、押すまでの経過時間でジャンプデータを取るかで多少変わります。
経過時間で取った方が記録間隔にかかわらずジャンプアニメーションを再生させる事が出来ますが、位置や角度と完全な同期をしていない為、多少ズレてしまうかもしれません。
兎にも角にも簡単なゲームプレイの保存をしてゴーストの再生をする機能は出来たのではないかと思います。
(゜゜)~