Unity で テトリス風ゲームを作ってみる(はじめに)
Unityでゲームを作る
Unity でテトリス風ゲームを作る!
Unity を使ってゲームを開発する流れを理解するために、定番のテトリス風ゲームを作ってみます。
Unity Version 2022.3.4
目次
1. なにを作るか?
2. どう作るか?
3. 実装する
4. ソースコードとか
5. アニメーション
6. パーティクル(ParticleSystem)
-
(準備中)
7. WebGL版リリース
PCブラウザのみ、スマフォはNG
Unity で8番出口風ゲームを作る!#7
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
タイトル画面
タイトル画面やゲームクリア画面のUI は、以前テトリス風ゲームでUIを作った時と同様の方法で実装する。
タイトル画面は、下図のように透明なパネル上にテキストでタイトルを表示し、その下にボタンを配置するだけのシンプル構造。

タイトルUI パネルの構造は、Panel オブジェクトにText オブジェクトと Button オブジェクトを子オブジェクトとして配置する。ボタンオブジェクトにはコールバックとして、GameControl.OnStartGame メソッドを登録しておく。

GameControl.cs の抜粋
スタートボタンのコールバックメソッドでは、Player クラスのStartPlayer を呼び出して、ユーザ操作(マウスやWASDキー)ができるように設定を変更する。タイトル画面中は、逆にPlayer が動かないようにするためユーザ操作を無効にしている。
- public void OnStartGame()
- {
- _state = State.Playing;
- player.StartPlayer();
- uiControl.HideTitleUI();
- }
UIControl.cs
UIControl クラスは、タイトルUI とゲームクリアUI 画面の表示/非表示を制御するためのクラス。スタートボタンが押された時は、タイトルUI画面(Panel UI オブジェクト)を消してゲーム画面のみにする。表示/非表示の仕方は、対象のGameObject のSetActive メソッドを使ってオブジェクトの有効/無効を切り替えることで実現する。
- public class UIControl : MonoBehaviour
- {
- [SerializeField] private GameObject titleUI;
- [SerializeField] private GameObject gameClearUI;
- public void ShowTitleUI()
- {
- gameClearUI.SetActive(false);
- titleUI.SetActive(true);
- }
- public void ShowGameClearUI()
- {
- titleUI.SetActive(false);
- gameClearUI.SetActive(true);
- }
- public void HideTitleUI()
- {
- titleUI.SetActive(false);
- }
- public void HideGameClearUI()
- {
- gameClearUI.SetActive(false);
- }
- }
Player.cs の抜粋
UI 画面表示中は、後ろで表示されているゲーム画面のユーザ操作を止める必要がある。ユーザ操作の有効/無効を切り替えるには、シンプルには入力イベントを受信しないようにすればよい。今回は、入力イベントの大元であるPlayerInput コンポーネントのenabled メンバを使って実現する。例えば、このメンバにfalse を設定すれば、入力イベントを受信しないようにすることができる。
- public void StartPlayer()
- {
- var playerInput = GetComponent<PlayerInput>();
- playerInput.enabled = true;
- _isStarted = true;
- }
- public void StopPlayer()
- {
- var playerInput = GetComponent<PlayerInput>();
- playerInput.enabled = false;
- _isStarted = false;
- }
下のGIFが実装した結果をキャプチャしたもの。タイトル画面のスタートボタンが押されるまでは、マウスやキーを押しても画面は動かないが、スタート後は操作できるようになっているのがわかる。

ゲームクリア
8番出口風ゲームのクリア条件は、異変のあり/なしを特定の回数連続して当てた後に出口から脱出することである。異変あり/なしの正解を確認するタイミングは、プレーヤーが通路B を進んで、又は通路B から戻ってA1 ゲートから通路A に入る時と(下図の左側)、通路B を進んで、又は通路B から戻ってA2 ゲートから通路A に入るタイミング(下図の右側)の2ヵ所とする。(前方に進んでいるパターンと、後方に進んでいるパターンがあるので2パターンが必要)

プレーヤーがどのゲートを通過したかは、以前説明したとおりPlayer クラスで衝突検知(OnTriggerEnter)使って行い、それをGameControl クラスのOnPlayerEvent メソッドでコールバックする。プレーヤーがどの経路を歩いてきたかは、RootChecker クラスのCheckRoot メソッドで判定する。判定結果から看板の表記変更をUpdateKanban メソッドの中で行っている。また、正解が特定回数(MAX_COUNT)以上になった場合は、通路A の代わりに出口を生成するようにしている(PrepareExit)。
GameControl.cs の抜粋
- public void OnPlayerEvent(PlayerEventParams param)
- {
- GameObject path = null;
- PlayerRoot root = PlayerRoot.None;
- switch(param._type)
- {
- case PlayerEventParams.Type.TouchGateA2Out:
- Debug.Log("GateA2 Out");
- if (!_rootChecker.CheckRoot(RootChecker.GateRoot.A2Out))
- {
- UpdateKanban(false, param._path);
- }
- if (_count >= MAX_COUNT)
- {
- PrepareExit(true);
- ihenControl.DeleteObjects();
- }
- else
- {
- PreparePathA(true);
- }
- break;
- case PlayerEventParams.Type.TouchGateA1In:
- Debug.Log("GateA1 In");
- UpdateKanban(
- _rootChecker.CheckRoot(RootChecker.GateRoot.A1In),
- param._path,
- true);
- PreparePathB(true);
- break;
- case PlayerEventParams.Type.TouchGateA2In:
- Debug.Log("GateA2 In");
- UpdateKanban(
- _rootChecker.CheckRoot(RootChecker.GateRoot.A2In),
- param._path,
- false);
- PreparePathB(false);
- break;
- case PlayerEventParams.Type.TouchGateA1Out:
- Debug.Log("GateA1 Out");
- if (!_rootChecker.CheckRoot(RootChecker.GateRoot.A1Out))
- {
- UpdateKanban(false, param._path);
- }
- if (_count >= MAX_COUNT)
- {
- PrepareExit(false);
- ihenControl.DeleteObjects();
- }
- else
- {
- PreparePathA(false);
- }
- break;
- case PlayerEventParams.Type.TouchGateExit:
- Debug.Log("Exit");
- _state = State.Title;
- GameClear();
- break;
- case PlayerEventParams.Type.TouchGateRobotFWD:
- ihenControl.IhenStart();
- break;
- case PlayerEventParams.Type.TouchGateRobotBWD:
- ihenControl.IhenStart();
- break;
- }
- }
- private void UpdateKanban(bool isSuccess, GameObject path, bool isForward)
- {
- UpdateKanban(isSuccess, path);
- var kanban = path.GetComponent<KanbanControl>();
- kanban.SetDirection(isForward);
- }
- private void UpdateKanban(bool isSuccess, GameObject path)
- {
- _count = isSuccess ? _count + 1 : 0;
- Debug.Log($"CountUp {_count}");
- var kanban = path.GetComponent<KanbanControl>();
- kanban.SetTexture(_count);
- }
RootChecker.cs の抜粋
CheckRoot メソッドでは、前回の通過ゲートと今回の通過ゲートの2 つを使ってプレーヤーの歩いてきた経路を判断する。
- public bool CheckRoot(GateRoot current)
- {
- var root = CheckRoot(_prevGateRoot, current);
- if (root == PlayerRoot.Hikikaeshi)
- {
- SetGateRoot(GateRoot.None);
- }
- else
- {
- SetGateRoot(current);
- }
- return VerifyRoot(root, _isExistIhen);
- }
- private PlayerRoot CheckRoot(GateRoot prev, GateRoot curr)
- {
- if (prev == GateRoot.A1In)
- {
- if (curr == GateRoot.A2Out)
- {
- Debug.Log("Passing Root A");
- return PlayerRoot.Passing;
- }
- else if (curr == GateRoot.A1Out)
- {
- Debug.Log("Hikikaeshi");
- return PlayerRoot.Hikikaeshi;
- }
- }
- else if (prev == GateRoot.A1Out)
- {
- if (curr == GateRoot.A2In)
- {
- Debug.Log("Backword No Ihen");
- return PlayerRoot.BackwardNoIhen;
- }
- else if (curr == GateRoot.A1In)
- {
- Debug.Log("Backword Ihen");
- return PlayerRoot.ForwardIhen;
- }
- }
- else if (prev == GateRoot.A2In)
- {
- if (curr == GateRoot.A1Out)
- {
- Debug.Log("Passing Root A");
- return PlayerRoot.Passing;
- }
- else if (curr == GateRoot.A2Out)
- {
- Debug.Log("Hikikaeshi");
- return PlayerRoot.Hikikaeshi;
- }
- }
- else if (prev == GateRoot.A2Out)
- {
- if (curr == GateRoot.A1In)
- {
- Debug.Log("Forward No Ihen");
- return PlayerRoot.ForwardNoIhen;
- }
- else if (curr == GateRoot.A2In)
- {
- Debug.Log("Forward Ihen");
- return PlayerRoot.ForwardIhen;
- }
- }
- return PlayerRoot.None;
- }
- private bool VerifyRoot(PlayerRoot root, bool isIhen)
- {
- if (root == PlayerRoot.Hikikaeshi || root == PlayerRoot.None)
- {
- return false;
- }
- if (root == PlayerRoot.ForwardIhen || root == PlayerRoot.BackwardIhen)
- {
- if (isIhen)
- {
- return true;
- }
- else
- {
- return false;
- }
- }
- else if (root == PlayerRoot.ForwardNoIhen || root == PlayerRoot.BackwardNoIhen)
- {
- if (isIhen)
- {
- return false;
- }
- else
- {
- return true;
- }
- }
- return true;
- }
正解数を2回にして出口を試作した結果がこちら。
次の記事
(準備中)
Unity で8番出口風ゲームを作る!#6
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
異変の作成
8番出口の"異変"には大きく2種類が存在する。1つは、通常時と比べテクスチャや大きさ、位置などが変わっているもの。もう1つは、血の海(?)などプレーヤーが逃げないとぶつかった時点でゲームオーバーになってしまうもの。今回は、簡単のためにテクスチャや大きさが変わるタイプの"異変"のみを実装する。
8番出口で異変を起こすオブジェクトには、ポスターや非常口、監視カメラなど色々なものがあるが、異変発生時はテクスチャや大きさ、位置などが通常状態とは"異なる"という点で共通である。そこで、異変を起こすオブジェクトの共通のBase クラスを作成し、そこから派生する形でさまざまな異変オブジェクトクラスを作成する。
IhenBase.cs
異変オブジェクトクラスのベースクラス。異変のあるなしをSetIhen メソッドを通して管理クラスから設定する。実際の異変箇所は各派生クラスによって変わるため DoIhen メソッドをabstract にして、派生クラス側で内容を実装できるようにする。
- abstract public class IhenBase : MonoBehaviour
- {
- protected bool _isIhen;
- public void SetIhen(bool isIhen)
- {
- _isIhen = isIhen;
- if (_isIhen)
- {
- DoIhen();
- }
- }
- virtual public void Init(bool isForward)
- {
- _isIhen = false;
- }
- abstract protected void DoIhen();
- }
IhenBlock.cs
IhenBlock クラスは箱型の異変オブジェクトを表現するクラス。異変あり時は単に色を赤に変えるだけとする(DoIhen メソッド)。配置位置は通路B 内に設定するが、プレーヤーが進んでいる方向によって配置場所を前後左右を反転させる必要がある。これはプレーヤーがどちら向きから通路B に進んでも同様の位置にあるように見せるためで、通路 B自体は反転させない。配置位置は固定なので、予め定数として定義しておく。
- public class IhenBlock : IhenBase
- {
- private readonly Vector3 POSITION_FWD = new Vector3(-2.7f, 1.25f, 20f);
- private readonly Vector3 POSITION_BWD
- = new Vector3(2.7f, 1.25f, LevelControl.PATHB_LENGTH_H - 20f);
- override public void Init(bool isForward)
- {
- base.Init(isForward);
- if (isForward)
- {
- this.gameObject.transform.localPosition = POSITION_FWD;
- }
- else
- {
- this.gameObject.transform.localPosition = POSITION_BWD;
- }
- }
- override protected void DoIhen()
- {
- var renderer = this.gameObject.GetComponent<Renderer>();
- renderer.material.color = Color.red;
- }
- }
IhenPoster.cs
IhenPoster クラスはポスター型異変オブジェクトを表現するクラス。異変あり時はポスターのテクスチャーを変更するだけ。配置位置についはIhenBlock と同様に反転処理を入れる。
- public class IhenPoster : IhenBase
- {
- private readonly Vector3 POSITION_FWD = new Vector3(2.95f, 2f, 10f);
- private readonly Vector3 POSITION_BWD
- = new Vector3(-2.95f, 2f, LevelControl.PATHB_LENGTH_H - 10f);
- private readonly Vector3 SCALE = new Vector3(0.025f, 0.025f, 0.025f);
- private readonly Vector3 ROTATION_FWD = new Vector3(270f, 0f, 90f);
- private readonly Vector3 ROTATION_BWD = new Vector3(270f, 0f, 270f);
- override public void Init(bool isForward)
- {
- base.Init(isForward);
- if (isForward)
- {
- this.gameObject.transform.localPosition = POSITION_FWD;
- this.gameObject.transform.localScale = SCALE;
- this.gameObject.transform.localEulerAngles = ROTATION_FWD;
- }
- else
- {
- this.gameObject.transform.localPosition = POSITION_BWD;
- this.gameObject.transform.localScale = SCALE;
- this.gameObject.transform.localEulerAngles = ROTATION_BWD;
- }
- }
- override protected void DoIhen()
- {
- var renderer = this.gameObject.GetComponent<Renderer>();
- renderer.material.mainTexture = Resources.Load<Texture>("Sprites/PosterIhen");
- }
- }
IhenControl.cs の抜粋
各異変オブジェクトは、IhenControl クラスから生成される。各異変オブジェクトは予めPrefab 化しておき、通路B を生成するタイミングでロード/インスタンス化して、_ihenList メンバに保存しておく。異変ありモードか、なしモードかを CreateObjects メソッドの引数 isIhen で受け取り、異変ありの場合は、ランダムで特定の1つのオブジェクトだけを異変あり状態に設定する(IhenObjectIndex)。後は生成した異変オブジェクトのインスタンスからIhenBase クラスとして各スクリプトコンポーネント取得して、IhenBase.SetIhen メソッドを呼びだして、異変を設定する。
- public void Init()
- {
- LoadPrefabs();
- }
- public void CreateObjects(bool isForward, Transform path, bool isIhen)
- {
- int index = (isIhen) ? IhenObjectIndex() : -1;
- CreateIhenObjects(isForward, path, index);
- CreateRobot(isForward, path);
- }
- public void IhenStart()
- {
- robotControl.StartWalk();
- }
- private void LoadPrefabs()
- {
- foreach (var prefabName in _prefabNames)
- {
- var prefab = Resources.Load<GameObject>(prefabName);
- _prefabList.Add(prefab);
- }
- }
- private int IhenObjectIndex()
- {
- return Random.Range(0, _prefabList.Count);
- }
- private void CreateIhenObjects(bool isForward, Transform path, int index)
- {
- int i = 0;
- foreach (var prefab in _prefabList)
- {
- var obj = Instantiate(prefab, path.position, Quaternion.identity);
- obj.transform.SetParent(path.transform);
- var ihen = obj.GetComponent<IhenBase>();
- ihen.Init(isForward);
- ihen.SetIhen*1;
- _ihenList.Add(ihen);
- i++;
- }
- }
異変オブジェクトを通路に配置した結果がこちら。
次の記事
*1:i == index
Unity で8番出口風ゲームを作る!#5
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
歩くモブキャラとNavMesh
8番出口の特徴として、通路の先から歩いてくるモブ(おじさん)がある。今回はこのモブを作成する。と言っても、3D モデルを作るのは面倒なので、まずはフリーのアセットを活用する。利用するのは、Unity が無料で配布しているロボットのモデル(Kyle Robot)である。このアセットでは3D モデルのほか、歩く/走る/ジャンプするなどの基本的なアニメーションもセットになっているので、今回の目的にはちょうどよい。
ストアから購入(無料だが)後にPackage Manager からProject へインストールを行う。すると、Assets/Unity Technologies/SpaceRobotKyle/Models フォルダの配下に、KyleRobot というFBXファイル(3Dモデルのメッシュ、ボーン、マテリアル、アニメーションデータが一体になったファイル)が作成させるので、それをHierarchy 上にDrag & Drop してコピーする、さらにそれをAssets/Resources 配下のPrefab フォルダ(自分で作成)にDrag & Drop してPrefab化しておく。

まずはAnimator の設定から行う。3D モデルで"歩く"や"走る"などの動作をさせるときは3D モデルにアニメーションを適用する必要がある。アニメーションを適用するにはざっくり言うと、"Animator"、"Avatar"、"AnimationController"、"AnimationClip"が必要となる。
"Avatar" は、3D モデルのメッシュとボーン情報を紐づけているアセット。"AnimationController" は、3D モデルの状態別に何の"AnimationClip" を再生するかを管理するコンポーネント。"AnimationClip" は具体的なボーンのアニメーションデータを保持するアセット。最後の"Animator" は、"Avator" と"AnimationController" を紐づけて管理するためのコンポーネントとなっている。
今回利用するKyleRobot アセットには、これらのアセットやコンポーネントも付属しているので、そちらをそのまま利用すればよい。


Unity には、AnimationClip 間をパラメータに紐づけてシームレスにつなげるBlend Tree という仕組みがある。KyleRobot アセットでもこの仕組みを使って、立ちアニメ(Idle)、歩きアニメ(Walk_N)、 走るアニメ(Run_N)がBlend Tree で遷移するようになっている。各状態を遷移させるパラメータは"Speed" というfloat 型のパラメータで、これをスクリプト上から書き換えることで、アニメーションを遷移させることができる。例えば、Speed が 0の時はアイドル、0~1の時はその値の大きさに合わせてアイドルと歩くアニメのボージョン情報をブレンドしてアニメを作る。1の時は歩くアニメを適用するといったイメージ。こうすることで、アイドル状態から急に歩くアニメに1frameで遷移してギクシャクなることがなくなる。(もちろん、Speedの値を一気に変えると、ギクシャクなるが)

KyleRobot アセットのアニメーション制御でもう1点注意事項がある。"MotionSpeed" というパラメータがあり、これはAnimation State の"Speed"(左側のInspector内のパラメータ)に掛け合わされることになっている。この値が"0" のままだと、アニメーションが再生しないので注意が必要である。

Speed に"1.8"、MotionSpeedに"1.0"を設定したKyleRobotのアニメーションの結果がこちら。1.0 は歩くアニメーションになっている。

- NavMesh
AI Navigation v1.1.5
Unity には、キャラクタがゲームのステージ内の移動可能な場所を自動で判断して動きまわれるようにするNavMesh という仕組みが存在する。
AI Navigation | AI Navigation | 1.1.5
NavMesh を利用するにはPackge Manager から"AI Navigation" というPackage をインストールする必要がある。今回は、Version 1.1.5 を利用する。
NavMesh の詳細な情報は、マニュアルを確認するとして、基本的なコンポーネントとしては、以下の3つである。
- NavMesh Surface
NavMesh Agent がアタッチされたキャラクタが移動できる領域を示す。領域は手動で付ける必要はなく、静的または動的に自動生成させる。生成された領域は、下図のようにSence view上では水色で表示される。領域の生成ロジックはよくわからないが、NavMesh Surface をアタッチしたオブジェクトの子オブジェクトが全て領域生成の対象となる。よって今回は、通路A とB を子に持つオブジェクトにNavMesh Surface コンポーネントを追加する。通路A とB は動的に生成するが、動的に領域を作成するには、NavMeshSurface クラスのBuildNavMesh メソッドをスクリプトからコールすることで可能となる。つまり、通路A/B を動的に生成した後に、BuildNavMesh メソッドを呼び出すと新しい経路を自動で再生成してくれる。

- NavMesh Agent
NavMesh Surface 上の領域をキャラクターが動くようにするには、NavMesh Agent コンポーネントをキャラクタのオブジェクトにアタッチする必要がある。このコンポーネントをアタッチし、destination プロパティ(Vector3)を設定するだけで、現在位置からdestinationまでのルートを自動計算し、Speed プロパティで設定した速度にしたがって勝手に移動を開始してくれる。今回はこのコンポーネントをKyleRobot オブジェクトにアタッチしておく。8番出口では、"おじさん" は常に特定の位置から移動を開始して、特定の位置で移動を終了するので、destination プロパティは事前に計算して決めておくことができる。
NavMesh Agent には、destination に到達したことを検知するコールバックが存在しないため(たぶん)、よってdestination に到達したかどうかは自分で確認する必要がある(Vector3.Distance を使う)。

- NavMesh Obstacle
NavMesh Agent が避ける障害物。このコンポーネントがアタッチされているオブジェクトを避けるようにNavMesh Agent は移動を行う。障害物に指定するオブジェクトは、動いていても問題ない。今回は、プレーヤーにこのコンポーネントをアタッチしておく。そうすると、プレーヤーがおじさんの前に立ちはだかったとしても、それをさけて目的地まで移動してくれるようになる。

Robot.cs
KyleRobot Prefab に以下のスクリプトをアタッチして操作を行う。クラス外からターゲット位置と歩行開始の指示を受けられるようにpublic メソッド SetTargetPosition と StartWalk を作り、Update メソッド内で、ターゲット位置と現在の自分の位置の距離を測定し(Vector3.Distance)、一定値以下になったら歩行をやめるということをしている(StopWalke)。
OnFootstep という空のメソッドは、KyleRobot アセットに付属しているアニメーションクリップで、コールバックイベントが設定されているため、特に使う必要がないが定義しておかないとランタイムエラーが発生したので空のメソッドを準備した。
- using System.Collections;
- using System.Collections.Generic;
- using UnityEngine;
- using Unity.AI.Navigation;
- public class Robot : MonoBehaviour
- {
- [SerializeField] private Animator _animator;
- [SerializeField] private UnityEngine.AI.NavMeshAgent _navMeshAgent;
- private const float WALK_SPEED = 2.0f; // [m/s]
- private Vector3 _targetPosition;
- private bool _isWalking = false;
- public void SetTargetPosition(Vector3 targetPosition)
- {
- _targetPosition = targetPosition;
- }
- public void StartWalk()
- {
- _animator.SetFloat("Speed", 2.0f);
- _animator.SetFloat("MotionSpeed", 1.0f);
- _isWalking = true;
- _navMeshAgent.enabled = true;
- _navMeshAgent.speed = WALK_SPEED;
- _navMeshAgent.stoppingDistance = 0.5f;
- _navMeshAgent.destination = _targetPosition;
- }
- public void StopWalk()
- {
- _isWalking = false;
- _navMeshAgent.enabled = false;
- _animator.SetFloat("Speed", 0.0f);
- }
- public void OnFootstep()
- {
- }
- private bool IsArrived()
- {
- return Vector3.Distance(_targetPosition, this.transform.position) <= _navMeshAgent.stoppingDistance;
- }
- // Update is called once per frame
- void Update()
- {
- if (_isWalking)
- {
- if (IsArrived())
- {
- StopWalk();
- }
- }
- }
- }
ここまで試作した結果がこちら。
次の記事
Unity で8番出口風ゲームを作る!#4
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
無限に続く通路
8番出口の通路は、下図のように2種類の通路が交互に連続して接続されることによって構成されている。そしてプレーヤーは、前方にずっと先に進むことも、後方にずっと戻ることもできる。よって事前に(静的に)通路を生成しておいてそれを表示するということは難しい(メモリ次第では可能だが)。
今回は、下図のように通路Aの特定の位置を通過しことを検出して、その先の通路を先回りして生成して後方の通路は削除するということを繰り返すことで無限の通路を生成することを考える。

初期状態では、通路B + 通路A + 通路B の3つだけが存在するマップを作成する。プレーヤーは、ゲーム開始時に通路A の中央付近にスポーンさせる。例えば、プレーヤーが通路A を前方に進み特定のポイント(ゲート)を通過すると、その先の通路B の先に新しい通路A を作成する。それと同時に一番後方にある通路B は削除する。プレーヤーが逆方向に進む場合も同様の処理を行う。
通路A を生成するか通路B を生成するかの判断は、プレーヤーが通路Aのどの出口(入口)を出るか(入るか)で判定を行う。
生成ルールは以下のとおり
- 通路A の左出口(入口)を
- 出る:前方に通路A を生成する
- 入る:前方に通路B を生成する
- 通路A の右出口(入口)を
- 出る:後方に通路A を生成する
- 入る:後方に通路B を生成する
上記のルールを検知するには、通路A上のどの出口(位置)から出入りしたか(方向)を検知する仕組みが必要になる。このため、通路A にBox Collider をアタッチした透明なオブジェクト(=ゲート)を下図のように4つ配置し、それぞれに別のTag を付けてどのゲートをどの順番で通過したか検知できるようにした。

ゲートは、Box Collider コンポーネントが付いただけの透明なオブジェクトとする。Box Collider には"Is Trigger" のチェックを入れて、衝突検知イベントだけが発生するようにする。こうしておかないと、CharacterController のCollider と衝突したときに通り抜けられなくなるためである。

ゲートオブジェクトには、どの位置のゲートか衝突検知時に判断できるようにそれぞれ別のタグをつけておく。
衝突検知のイベント処理は、プレーヤーオブジェクトにアタッチしたスクリプトで行う。ゲートオブジェクトのBox Collider で"Is Trigger" にチェックを入れているため、衝突イベントは、OnTriggerEnter メソッドで受け取ることができる。
Player.cs の抜粋
OnTriggerEnter メソッドの引数 Collider は、プレーヤーと衝突した相手側のゲートのCollider になっている。Collider クラスには、その所有者であるGameObject への参照が含まれているので、そこからGameObject のTag 情報を引っ張ってくることができる。ここまでくれば後は、Tag 情報からどの位置のゲートと衝突したのかを特定すればよい。さらにプレーヤーが入ったのか出たのか、方向を確認するために前回検知したゲートは覚えておき、前回と今回分の2つを組合わせて方向を特定する。
- void OnTriggerEnter(Collider other)
- {
- if (other.gameObject.CompareTag("GateA2Right"))
- {
- if ( _prevDetectedGate == "GateA2Left")
- {
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateA2Backward));
- }
- _prevDetectedGate = "GateA2Right";
- }
- else if (other.gameObject.CompareTag("GateA2Left"))
- {
- if (_prevDetectedGate == "GateA2Right")
- {
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateA2Forward));
- }
- _prevDetectedGate = "GateA2Left";
- }
- else if (other.gameObject.CompareTag("GateA1Right"))
- {
- if (_prevDetectedGate == "GateA1Left")
- {
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateA1Backward));
- }
- _prevDetectedGate = "GateA1Right";
- }
- else if (other.gameObject.CompareTag("GateA1Left"))
- {
- if (_prevDetectedGate == "GateA1Right")
- {
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateA1Forward));
- }
- _prevDetectedGate = "GateA1Left";
- }
- else if (other.gameObject.CompareTag("GateRobotFWD"))
- {
- other.gameObject.SetActive(false);
- var path = other.gameObject.transform.parent.gameObject;
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateRobotFWD, path));
- }
- else if (other.gameObject.CompareTag("GateRobotBWD"))
- {
- other.gameObject.SetActive(false);
- var path = other.gameObject.transform.parent.gameObject;
- _playerEvent.Invoke(new PlayerEventParams(PlayerEventParams.Type.TouchGateRobotBWD, path));
- }
- }
- 通路オブジェクトの生成/管理
LevelControl.cs の抜粋
通路オブジェクトは、前方と後方の両方に延びていく可能性がある。そこで通常のList ではなくLinkedList を使って管理を行う。LinkedList であれば、List の先頭/末尾にオブジェクトを追加することや、List の先頭/末尾のオブジェクトを取得する/削除するといった操作を簡単に行うことができる。
- private LinkedList<GameObject> _pathList = new LinkedList<GameObject>();
通路A、B のPrefab はリソースから初期化時にロードしておく。
- private void LoadPrefabs()
- {
- _pathAPrefab = Resources.Load<GameObject>("Prefabs/PathA");
- _pathBPrefab = Resources.Load<GameObject>("Prefabs/PathB");
- }
新規に生成する通路オブジェクトの相対位置は、通路組み合わせから事前に決定することができる。

上図の赤と青のベクトルは、"B + A + B" と "A + B + A" の組み合わせでそれぞれが逆向きの関係になっているだけだということがわかる(-1を掛けたもの)。
最終的な新規通路の作成位置は、上記の相対位置に、接続する先頭または末尾の通路オブジェクトの位置を足したものになる。先頭または末尾の通路オブジェクトはLinkedList から取得する(offset)。
- private GameObject CreatePathObject(PathType type, bool isForward)
- {
- GameObject prefab = null;
- Vector3 connectPoint = Vector3.zero;
- if (type == PathType.A)
- {
- prefab = _pathAPrefab;
- connectPoint = (isForward) ? CONNECTION_B2 : CONNECTION_B1;
- }
- else
- {
- prefab = _pathBPrefab;
- connectPoint = (isForward) ? CONNECTION_A2 : CONNECTION_A1;
- }
- var path = Instantiate(prefab, Vector3.zero, Quaternion.identity);
- path.transform.SetParent(this.transform);
- var offset = (isForward) ?
- _pathList.First.Value.transform.localPosition : _pathList.Last.Value.transform.localPosition;
- path.transform.localPosition = offset + connectPoint;
- return path;
- }
作成した通路オブジェクトは、LinkedList(_pathList)の先頭 or 末尾に追加しておく。
- private GameObject CreatePath(PathType type, bool isForward = true, bool isNavRefresh = false)
- {
- var path = CreatePathObject(type, isForward);
- if (type == PathType.B)
- {
- EnableGateRobot(path.transform, isForward);
- }
- if (isForward)
- {
- Debug.Log("Add Forward");
- _pathList.AddFirst(path);
- }
- else
- {
- Debug.Log("Add Backward");
- _pathList.AddLast(path);
- }
- CheckAndDeletePath(isForward);
- if (isNavRefresh)
- {
- _navMeshSurface.BuildNavMesh();
- }
- return path;
- }
最後にLinkedList のサイズが4以上の場合は、進む方向とは逆の末尾/先頭のオブジェクトを抜き出して削除しておく。これで常に生成された通路はプレーヤー付近の3つ以内に制限させることになる。
ここまで試作した結果がこちら。
次の記事
Unity で8番出口風ゲームを作る!#3
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
TPS風のプレーヤーコントロールとカメラ
Unity では、TPS やFPS 用のキャラクターを制御するために、CharacterController と呼ばれるコンポーネントが準備されている。今回は、このコンポーネントを使ってプレーヤーオブジェクトを作成する。
まず空のGameObject を作成して"Player" に名前を変更する。そのオブジェクトにCharacterController と PlayerInput をアタッチする。8番出口ではプレーヤーの体は全く表示されない(透明)のでレンダラー系のコンポネントをアタッチする必要はない。

CharacterController にはCollider の機能も含まれている。Collider の形はCapsule になっている。Collider系で設定するパラメータは身長(Height)と胴回り(Radius)、体の中心(Center)の3点。今回は、身長 1.7 m、胴回り 0.25 m、体の中心(0, 0.85, 0)とした。
あと、TPS視点を実現するため、MainCamera をPlayer オブジェクトの子オブジェクトにして、頭の位置に移動しておく。

PlayerInput は、マウスとキー入力イベントを取得するためにアタッチする。Input Actionの定義は、以下の4つを追加する。
- Look
- 視野移動のアクション。マウスのポジション位置をDelta(前回フレームからの差分)で取得する。
- Move
- WASD キーによる前後左右の移動のアクション。Vector2 型で入力値を取得する。
- Dash
- Dash 移動のアクション。Shift キーを押しながらWASD キーを押した場合はDash 移動する。
- Lock

設定したInput Action にそれぞれコールバック関数を設定する。

PlayerAction.cs
OnLookEvent コールバックでマウスポジションを取得して視野の移動を行う。マウスの上下移動はカメラを上下に回転させるが、マウスの左右移動は、プレーヤーオブジェクトを左右回転させることで視野を移動させる。(Cameraはプレーヤーオブジェクトの子オブジェクトになっているため)
OnMoveEvent コールバックではWASDキーによる前後左右入力をVector2 型で取得して、プレーヤーの移動を行う。このメソッド内では移動方向を計算するだけで、実際の移動処理はUpdate メソッドからコールされるDoMove メソッドにて行う。
OnDashEvent コールバックはShiftキーイベントの取得を行う。このメソッドでは単にDash中であるかどうかを判定するためのフラグのオンオフだけを行い、DoMove メソッドで実際の移動速度計算に反映させる。
- using System.Collections;
- using System.Collections.Generic;
- using UnityEngine;
- using UnityEngine.InputSystem;
- public class PlayerActions : MonoBehaviour
- {
- [SerializeField] private CharacterController Controller;
- [SerializeField] private Camera MainCamera;
- private const float WALK_SPEED = 1.8f; // 1.8 [m/s]
- private const float DASH_SPEED = 1.8f * 3f; // 5.4 [m/s]
- private const float INVERT = -1f;
- private const float ROTATION_RATIO = 0.1f;
- private Vector3 _direction;
- private Vector3 _keyInput;
- private float _cameraAngle = 0f;
- private bool _isMoving = false;
- private bool _isDash = false;
- private bool _isLocked = false;
- public void OnLockEvent(InputAction.CallbackContext context)
- {
- if (context.phase == InputActionPhase.Started)
- {
- _isLocked = true;
- }
- else if (context.phase == InputActionPhase.Canceled)
- {
- _isLocked = false;
- }
- }
- public void OnLookEvent(InputAction.CallbackContext context)
- {
- #if UNITY_WEBGL
- if (!_isLocked) return;
- #endif
- if (context.phase == InputActionPhase.Performed)
- {
- Vector2 delta = context.ReadValue<Vector2>();
- _cameraAngle += delta.y * INVERT * ROTATION_RATIO;
- _cameraAngle = Mathf.Clamp(_cameraAngle, -89f, 89f);
- MainCamera.transform.localEulerAngles = new Vector3(_cameraAngle, 0f, 0f);
- this.transform.Rotate(new Vector3(0f, delta.x * ROTATION_RATIO, 0f), Space.Self);
- if (_isMoving)
- {
- _direction = this.transform.TransformVector(_keyInput);
- }
- }
- }
- public void OnMoveEvent(InputAction.CallbackContext context)
- {
- if (context.phase == InputActionPhase.Started)
- {
- _isMoving = true;
- Vector2 input = context.ReadValue<Vector2>();
- _keyInput = new Vector3(input.x, 0, input.y);
- _direction = this.transform.TransformVector(_keyInput);
- }
- else if (context.phase == InputActionPhase.Performed)
- {
- Vector2 input = context.ReadValue<Vector2>();
- _keyInput = new Vector3(input.x, 0, input.y);
- _direction = this.transform.TransformVector(_keyInput);
- }
- else if (context.phase == InputActionPhase.Canceled)
- {
- _isMoving = false;
- _direction = Vector2.zero;
- }
- }
- public void OnDashEvent(InputAction.CallbackContext context)
- {
- if (context.phase == InputActionPhase.Started)
- {
- _isDash = true;
- }
- else if (context.phase == InputActionPhase.Canceled)
- {
- _isDash = false;
- }
- }
- private void DoMove(float deltaTime)
- {
- var speed = (_isDash)? DASH_SPEED : WALK_SPEED;
- Controller.Move(_direction.normalized * deltaTime * speed);
- }
- // Update is called once per frame
- void Update()
- {
- DoMove(Time.deltaTime);
- }
- }
前回作成した通路Aを配置して、プレーヤーの移動のテストを行った結果がこちら。
次の記事
Unity で8番出口風ゲームを作る!#2
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
通路の準備
まずは2種類の通路A とB を作成する。通路は無限に続く(実際には最長でも連続8個分だが)ので、それぞれをPrefab 化してスクリプトから動的に生成できるようにする。テクスチャやマテリアルのことは後々考えるとして、一旦デフォルトの3D Cube オブジェクトを組み合わせて作成していく。
Cube オブジェクトのスケールをX=3m、Y=0.1m、 Z=3m に変更して床の基本部品を作成する。それを4つ組み合わせて、6m四方の大きさの床部品を作る。


壁も同様に、Cube オブジェクトのスケールをX=0.1m、Y=6m、Z=3.1m に変更して基本部品を作ってそれを2枚組み合わせて、6 x 6 m の壁を作る。(Zを3.1 にしているのは0.1だけ重るため。WebGL で表示させたときに、ピッタリだと隙間があるように描画されてしまったための対策)

作った床と壁の部品を組み合わせて通路A Prefabを作成する。

通路A の入口と出口には、Box Collider コンポーネントだけをアタッチした見えないゲートオブジェクトを2つずつ配置する。これは、プレーヤーが通路Aのどちらから入ったか or どちらから出たかを検出するため。

通路Bも同様に床と壁の部品を組み合わせて作成し、入口と出口付近にゲートを配置する。このゲートはおじさんを生成するトリガーを発生させるため。

次の記事
Unity で8番出口風ゲームを作る!#1
Unityでゲームを作る
Unity で8番出口風ゲームを作る!
Unity Version 2022.3.16
まずは仕様の確認
基本的にはサードパーソンシューティング視点の間違い探しゲームである。同じ構造が繰り返される地下通路から脱出できればゴール。ただし通路には"異変"が仕込まれている時があり、異変があった場合は引き返して逆方向に進み、異変がなかったらそのまま先に進む必要がある。異変に気付かずに先に進んだ場合や、異変がないのに引き返した場合は、最初からやり直しとなる。正解するごとにカウントが加算され、8回連続で正解すれば出口から出られる。
以上のようにゲームの仕様自体はすごく単純である。
ゲーム内の主な要素は、
- 地下通路
地下通路は2種類の形状を無限に組み合わせた形で構成される。通路A の側面には現在のクリアカウントが看板に表示され、直線の通路B には"異変"の有無が仕込まれる。通路A の看板は、正解するごとにカウントアップされ失敗するとゼロに戻る。"異変"はランダムに出現する(本物の8番出口がランダムかどうかは不明)。プレーヤーは前方にも後方にも無限に進むことができる。

正解・不正解の判定は、通路Aの看板が見えない位置(赤丸印付近)で行う。
- モブ(おじさん)
通路A から通路B に入ると通路Bの反対の曲がり角からモブ(おじさん)が出現する。おじさんは出現した場所と反対にある通路Aまで歩いて進んでいき、通路Aの途中で止まる。
- クリアカウントを表示する看板
通路A には現在のクリアカウントを示す看板が立っている。前方に進んで通路Aに入った場合と、通路B から引き返して通路Aに入った場合では、看板の位置が変わる(表現が難しいが、どちらも進んでも戻っても、同じ構造になるようになっている)。
次の記事