【実践記事】Unityで「ジャンプ避けランナーゲーム」を作ってみた|素材選びから当たり判定まで初心者がハマった所、全部見せます

初心者向け実践開発

対応バージョン:Unity 6.3 LTS(6000.3系) 前回の「アイテムキャッチゲーム」と同じ環境・同じプロジェクトの作り方をベースにしています。MonoBehaviour ScriptやActive Input Handlingまわりの基本は前回の記事で詳しく解説しているので、迷ったらそちらもあわせてご覧ください。

前回の実践記事では、降ってくるアイテムをキャッチするゲームを作りました。今回は趣向を変えて、プレイヤーが自動で前進し続け、ジャンプで障害物を避ける横スクロールランナーゲームを作ります。

「移動」「当たり判定」「UI」という基本要素は前回と共通しつつ、今回は新しく「背景のループスクロール」「地面判定つきのジャンプ」「素材選び」という、横スクロールゲームならではのテーマが加わります。今回も実際に手を動かしながら、つまずいたところをそのまま記事にしています。

目次

  • 今回作るもの
  • 準備:素材を選ぶ・プロジェクトを作る
  • 第1関門:ジャンプキーを連打すると空を飛べてしまう
  • 第2関門:背景が一瞬で流れ切ってしまう
  • 第3関門:障害物が同じ場所に重なって出てくる
  • 第4関門:障害物にぶつかってもゲームオーバーにならない
  • 仕上げ:生存スコアと難易度調整
  • 完成して振り返って分かったこと
  • 次にやること
  • あわせて読みたいおすすめ書籍【PR】
  • この記事を作成するにあたって参考にしたサイト

今回作るもの

プレイヤーキャラクターが画面奥に向かって自動的に走り続け、スペースキーでジャンプして地面の障害物を避けるゲームです。

  • プレイヤーは自動で前進し続ける(操作はジャンプのみ)
  • スペースキーでジャンプして障害物を飛び越える
  • 生存時間がそのままスコアになる
  • 障害物にぶつかるとゲームオーバー
  • 時間が経つほど障害物の出現間隔が短くなる

「1ボタンで遊べるゲーム」は、操作説明がほぼ不要な分、企画・実装ともに初心者が完走しやすいジャンルです。

完成したランナーゲームのプレイ画面

準備:素材を選ぶ・プロジェクトを作る

今回は「それっぽい背景・キャラクター」を用意するところから始めます。プロジェクトの作り方自体は前回とほぼ同じです。

背景・キャラクター素材をどう用意するか

初心者がまずぶつかる壁が「絵を描けないから前に進めない」という悩みです。今回は、著作権面で安全に使える2つの方法を紹介します。

方法1:フリー素材サイトから既製のスプライトを使う

  • Kenney.nl ── CC0(著作権表記不要・商用利用可)のゲーム素材を大量に配布しているサイト。横スクロール用の背景・キャラクター・地形タイルのセットが豊富
  • OpenGameArt.org ── ライセンスがアセットごとに異なるため、CC0またはCC-BYなど利用条件を必ず確認してから使う
  • itch.io の「Free Game Assets」── インディー作家が配布している無料アセットが多数。各ページのライセンス表記を必ず確認する

⚠️ 注意:フリー素材を使う場合でも「商用利用可か」「クレジット表記が必要か」をダウンロードページで必ず確認してください。特にゲームを公開・配布する予定がある場合は重要です。

方法2:AI画像生成で自作する

特定のキャラクターや既存ゲームに似せず、オリジナルのシンプルな素材として生成したい場合は、以下のようなプロンプトが使えます(DALL-E・Midjourneyなど、お使いの生成ツールに合わせて調整してください)。

横スクロールゲーム用のキャラクタースプライト。
シンプルな2頭身のデフォルメキャラクター、走っているポーズ。
背景は透過(白背景でも可)。
フラットなベクターイラスト風、太めの輪郭線、明るい配色。
特定の実在キャラクターや既存ゲームのデザインを参照せず、
オリジナルのシンプルなデザインにする。
正面ではなく横向き(サイドビュー)のシルエットで、
ゲームのスプライトとして使いやすい構図にする。
横スクロールゲームの背景イラスト。
昼間の草原または砂漠をイメージした、奥行きのある2Dの背景。
遠景・中景・近景の3レイヤーに分けて、レイヤーごとに独立したイラストとして生成する
(パララックス効果を付けるため)。
フラットなベクターイラスト風、彩度の高い明るい配色。
実在の地名やランドマーク、特定の作品の世界観を参照しない、
オリジナルの抽象的な風景にする。

ℹ️ 補足:AI生成画像を商用ゲームに使う場合は、利用する生成ツールの利用規約・商用利用条件も別途確認してください。

プロジェクトの下ごしらえ

🔧 詳しい手順

  1. Unity Hubで「New project」→ Editor versionでUnity 6.3 LTSを選択 → 「Core」タブから**「Universal 2D」**を選んでプロジェクトを作成する(前回と同じ手順。「2D(Built-in Render Pipeline)」は旧式なので選ばない)
  2. 用意した背景画像・キャラクター画像をProjectウィンドウの Assets フォルダにインポートする。手順は以下のとおり
    • Assetsフォルダ内に右クリック→「Create」→「Folder」で「Sprites」フォルダを作る
    • パソコンのファイルエクスプローラー(Windows)/Finder(Mac)から画像を直接ドラッグ&ドロップできればそれでよいが、別ウィンドウ間のドラッグがうまく反応しないこともある。そのときは、UnityのProjectウィンドウでSpritesフォルダを右クリック→「Show in Explorer」(Mac版は「Reveal in Finder」)を選んで実際のフォルダを開き、そこに画像ファイルを直接コピー&ペーストする方法に切り替えると確実
    • インポートした画像を選択し、Inspectorの「Texture Type」が「Sprite (2D and UI)」になっているか確認する
  3. Hierarchyで右クリック→「2D Object」→「Sprites」→「Square」を作成し、インポートしたキャラクター画像をSprite Rendererの「Sprite」欄に設定する。名前を「Player」に変更し、Position を (-3, -1, 0) くらいに設定する
  4. 地面用に、Hierarchyで右クリック→「2D Object」→「Sprites」→「Square」をもう1つ作成し、名前を「Ground」に変更する。横に長く引き伸ばし、画面下に配置する
  5. Groundに Add Component →「Box Collider 2D」を追加する(Is Triggerはオフのままにする。プレイヤーが乗っかる地面として使うため)
  6. Scriptsフォルダ、Prefabsフォルダを作成しておく(前回と同じ要領)

⚠️ フリー素材選びでのつまずき フリー素材サイトでキャラクター素材をダウンロードしたところ、tilesheet(パーツ一覧画像)、Limbs(腕・脚などのパーツ単体)、Poses(完成済みポーズ)という3種類のフォルダに分かれた、ボーンアニメーション用の素材が入っていることがありました。これは本来パーツを組み合わせて自分でアニメーションを作る上級者向けの素材で、tilesheetLimbsまで全部インポートする必要はありません。今回のようにSprite Rendererに1枚だけ設定するシンプルな使い方であれば、Posesフォルダの中から気に入った1枚(走っているように見えるポーズなど)だけをインポートすれば十分でした。

⚠️ Playerが背景に隠れる問題 画像をPlayer・Groundに設定してSceneビューを確認したところ、背景しか表示されず、PlayerもGroundも見えないという状態になりました。原因は、あとから配置した背景の方が、UnityのSprite描画順のルールで手前に表示されてしまっていたことでした。HierarchyでBackground用のオブジェクトを選択し、Inspectorの「Sprite Renderer」コンポーネントにある「Order in Layer」を -1 に変更すると(PlayerやGroundは0のまま)、数字が小さいものほど奥に描画されるようになり、背景の手前にPlayerとGroundが表示されるようになりました。背景を複数枚用意するこのあとの手順でも、すべての背景オブジェクトにこの設定をしておく必要があります。

キャラクターと地面を配置したシーン

第1関門:ジャンプキーを連打すると空を飛べてしまう

まずはジャンプ処理です。Rigidbody2Dに上向きの力を加えるシンプルな実装から始めました。

using UnityEngine;

public class PlayerJump : MonoBehaviour
{
    public float jumpPower = 8f;
    private Rigidbody2D rb;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            rb.AddForce(Vector2.up * jumpPower, ForceMode2D.Impulse);
        }
    }
}

再生してスペースキーを押すとジャンプはするのですが、連打すると空中でも何度でもジャンプできてしまい、そのままずっと宙に浮いていられるという状態になってしまいました。「地面に着地しているときだけジャンプできる」という条件が抜けていたのが原因です。

🔧 詳しい手順

  1. HierarchyでPlayerオブジェクトを選択する
  2. Add Component →「Rigidbody 2D」を追加する(Body Typeはデフォルトの「Dynamic」のまま)
  3. Add Component →「Box Collider 2D」を追加する
  4. Scriptsフォルダで右クリック→Create→「MonoBehaviour Script」→名前を「PlayerJump」にする
  5. 上のコードを書いて保存し、PlayerにAdd Componentで追加する

修正のために、地面に接しているかどうかを判定する処理を追加しました。今回はシンプルに、Box Collider 2Dどうしの接触判定(OnCollisionEnter2DOnCollisionExit2D)を使っています。

using UnityEngine;

public class PlayerJump : MonoBehaviour
{
    public float jumpPower = 8f;
    private Rigidbody2D rb;
    private bool isGrounded = false;

    void Start()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space) && isGrounded)
        {
            rb.AddForce(Vector2.up * jumpPower, ForceMode2D.Impulse);
            isGrounded = false;
        }
    }

    void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject.CompareTag("Ground"))
        {
            isGrounded = true;
        }
    }
}

🔧 詳しい手順(地面にタグを付ける)

  1. HierarchyでGroundオブジェクトを選択する
  2. Inspector左上の「Tag」のドロップダウン→「Add Tag…」→「+」ボタンで「Ground」という名前のタグを新規作成し、保存する
  3. もう一度GroundオブジェクトのTagドロップダウンから「Ground」を選び直す

⚠️ Unity 6特有の注意点 ジャンプの入力にInput.GetKeyDownを使っていますが、これも前回の記事で触れた「Input Manager(旧)」のAPIです。実行時にInvalidOperationExceptionが出る場合は、Edit → Project Settings → Player → Active Input Handling を「Both」に変更してください(詳しい原因は前回の記事を参照)。

これで、地面に着地している間だけジャンプでき、空中で連打しても反応しなくなりました。

地面判定つきでジャンプするプレイヤー

第2関門:背景が一瞬で流れ切ってしまう

次に、背景を左に流してスクロールしているように見せる処理です。背景画像1枚をひたすら左に動かすだけのコードを書きました。

using UnityEngine;

public class BackgroundScroller : MonoBehaviour
{
    public float scrollSpeed = 2f;

    void Update()
    {
        transform.position += Vector3.left * scrollSpeed * Time.deltaTime;
    }
}

再生すると、背景は確かに左に流れるのですが、画面の外まで行ったらそれっきりで、何もない背景が見えてしまいました。1枚の画像を動かしているだけなので、当然ながら画像がなくなった時点で背景が途切れてしまうのです。

ここから、同じ背景を2枚並べてループさせる対応に入ったのですが、これが今回の記事の中で一番長く格闘した関門になりました。順を追って振り返ります。

つまずき1:手動で測った横幅が微妙にズレる

最初は、画像を2枚並べて配置し、片方が画面外に出たら反対側へ瞬間移動させる方式にしました。

public float scrollSpeed = 2f;
public float backgroundWidth = 16f;

void Update()
{
    transform.position += Vector3.left * scrollSpeed * Time.deltaTime;

    if (transform.position.x < -backgroundWidth)
    {
        transform.position += Vector3.right * backgroundWidth * 2f;
    }
}

backgroundWidthには、Sceneビューで2枚の画像をなるべくぴったり並べたときの座標の差を目分量で測って入力しました。ところが再生すると、継ぎ目にうっすら隙間が見えてしまいます。目視で並べた数値には、コンマ数単位の誤差がどうしても出てしまうことが分かりました。

つまずき2:横幅を自動計算しても、初期配置のズレで隙間が残る

そこで、手動入力をやめて、スクリプト側で画像の実際の横幅を自動計算する方式に変えました。

using UnityEngine;

public class BackgroundScroller : MonoBehaviour
{
    public float scrollSpeed = 2f;
    private float backgroundWidth;

    void Start()
    {
        backgroundWidth = GetComponent<SpriteRenderer>().bounds.size.x;
    }

    void Update()
    {
        transform.position += Vector3.left * scrollSpeed * Time.deltaTime;

        if (transform.position.x < -backgroundWidth)
        {
            transform.position += Vector3.right * backgroundWidth * 2f;
        }
    }
}

GetComponent<SpriteRenderer>().bounds.size.xで、画像が実際に描画される横幅をUnity自身に計算させる方法です。これで計算上の横幅は正確になったのですが、それでもまだ隙間が出ました。原因は、2枚の背景の「初期配置の間隔」が、この自動計算した横幅と完全には一致していなかったことです。横幅の数値だけ正確にしても、初期位置が手動のままズレていれば意味がなかったのです。

つまずき3:ピボット位置を考えずに「位置+横幅」で計算していた

初期配置も自動で揃えようと、2枚の背景の右端と左端をスクリプトで正確に合わせる処理を追加しました。

using UnityEngine;

public class BackgroundAligner : MonoBehaviour
{
    public Transform background1;
    public Transform background2;

    void Awake()
    {
        SpriteRenderer sr1 = background1.GetComponent<SpriteRenderer>();
        SpriteRenderer sr2 = background2.GetComponent<SpriteRenderer>();

        float gap = sr1.bounds.max.x - sr2.bounds.min.x;
        background2.position += new Vector3(gap, 0, 0);
    }
}

bounds.max.x(右端の座標)とbounds.min.x(左端の座標)という、画像の中心点(ピボット)がどこにあっても正しく計算できる値を使うことで、ようやく隙間のない初期配置ができました。

つまずき4:スクリプトを増やしたら、古いスクリプトの消し忘れで設定が衝突した

ここからさらに「2枚だと画面の横幅によっては足りないかもしれない」と考え、3枚構成・1つのスクリプトで一括管理する形に作り直しました。

using UnityEngine;

public class BackgroundLoop : MonoBehaviour
{
    public Transform[] tiles;
    public float scrollSpeed = 2f;
    private float tileWidth;

    void Start()
    {
        tileWidth = tiles[0].GetComponent<SpriteRenderer>().bounds.size.x;

        for (int i = 1; i < tiles.Length; i++)
        {
            tiles[i].position = new Vector3(
                tiles[0].position.x + tileWidth * i,
                tiles[0].position.y,
                tiles[0].position.z
            );
        }
    }

    void Update()
    {
        foreach (Transform tile in tiles)
        {
            tile.position += Vector3.left * scrollSpeed * Time.deltaTime;
        }

        foreach (Transform tile in tiles)
        {
            if (tile.position.x < -tileWidth)
            {
                tile.position += Vector3.right * tileWidth * tiles.Length;
            }
        }
    }
}

ところが、これに切り替えたあとも隙間が再発しました。原因を調べたところ、管理用オブジェクトに古いBackgroundAlignerスクリプトを削除し忘れたまま、新しいBackgroundLoopスクリプトを追加していたことが分かりました。2つのスクリプトが同時に背景の位置を調整しようとして、計算が競合していたのです。古いスクリプトをコンポーネントごと削除し、BackgroundLoopだけが残っている状態にしたところ、3枚の間隔自体は完璧(実測でぴったり同じ幅ずつ)になりました。

つまずき5:本当の原因は「画像の横幅」と「カメラの表示範囲」の取り違えだった

それでも、ループするタイミングで一瞬だけ隙間が見えるという症状が残りました。一時停止して3枚それぞれの座標を確認したところ、間隔は寸分の狂いもなく揃っていました。つまり、3枚の並べ方自体は正しかったのです。

最終的に分かった原因は、「いつループさせるか」の判定基準そのものが間違っていたことでした。コードではtile.position.x < -tileWidth、つまり「画像の横幅」を基準にループ判定をしていたのですが、本来比べるべきは「カメラに実際に映っている範囲」でした。たまたまカメラの表示幅と画像の横幅が近い数値であれば気づきにくいのですが、今回のように両者が異なる場合、画像が完全に画面の外に出る前にループが起きたり、逆にループが遅れて画面の端に隙間が生まれたりしてしまいます。

そこで、カメラの実際の表示範囲を基準にループするよう修正しました。

using UnityEngine;

public class BackgroundLoop : MonoBehaviour
{
    public Transform[] tiles;
    public float scrollSpeed = 2f;
    private float tileWidth;
    private float cameraLeftEdge;

    void Start()
    {
        tileWidth = tiles[0].GetComponent<SpriteRenderer>().bounds.size.x;

        for (int i = 1; i < tiles.Length; i++)
        {
            tiles[i].position = new Vector3(
                tiles[0].position.x + tileWidth * i,
                tiles[0].position.y,
                tiles[0].position.z
            );
        }

        Camera cam = Camera.main;
        float camHalfWidth = cam.orthographicSize * cam.aspect;
        cameraLeftEdge = cam.transform.position.x - camHalfWidth;
    }

    void Update()
    {
        foreach (Transform tile in tiles)
        {
            tile.position += Vector3.left * scrollSpeed * Time.deltaTime;
        }

        foreach (Transform tile in tiles)
        {
            if (tile.position.x + tileWidth / 2f < cameraLeftEdge)
            {
                tile.position += Vector3.right * tileWidth * tiles.Length;
            }
        }
    }
}

Camera.mainorthographicSize(カメラの縦の表示半径)とaspect(画面の縦横比)から、カメラが実際に映している範囲の左端を計算し、「画像の右端が、カメラの左端を完全に通り過ぎたとき」にループさせるよう変更しました。これで、画面の解像度やアスペクト比が変わっても、常に正しいタイミングでループするようになり、隙間は完全に解消しました。

🔧 詳しい手順

  1. Hierarchyで右クリック→「Create Empty」を選択し、名前を「BackgroundManager」にする
  2. 同じ背景画像をHierarchyに3つドラッグ&ドロップし、それぞれ「Background1」「Background2」「Background3」という名前にする(位置は仮で構わない。あとでスクリプトが自動的に並べ直す)
  3. Background1・Background2・Background3、それぞれのSprite Rendererの「Order in Layer」を -1 に設定する(PlayerやGroundより奥に表示するため)
  4. Scriptsフォルダで「BackgroundLoop」という名前でMonoBehaviour Scriptを作成し、上の最終版のコードを書いて保存する
  5. BackgroundManagerにAdd ComponentでBackgroundLoopを追加する
  6. InspectorのTiles欄のSizeを「3」にし、Element 0・1・2にBackground1・Background2・Background3をそれぞれドラッグ&ドロップする

ℹ️ 補足:このバグは見た目にも気づきにくく、原因の特定にもかなり時間がかかるタイプのものでした。「数値はすべて正しいはずなのに、結果だけがおかしい」という場合は、計算そのものではなく、「何を基準に計算しているか」を疑ってみると突破口が見つかることがあります。

これで、背景が途切れることなく、無限にスクロールしているように見えるようになりました。

背景が途切れていた事象を解決

第3関門:障害物が同じ場所に重なって出てくる

障害物を一定間隔で右側から出現させる処理です。最初はSpawnerに固定間隔で生成するコードを書きました。

using UnityEngine;

public class ObstacleSpawner : MonoBehaviour
{
    public GameObject obstaclePrefab;
    public float spawnInterval = 2f;

    void Start()
    {
        InvokeRepeating(nameof(SpawnObstacle), 2f, spawnInterval);
    }

    void SpawnObstacle()
    {
        Vector3 spawnPos = new Vector3(10f, -2f, 0);
        Instantiate(obstaclePrefab, spawnPos, Quaternion.identity);
    }
}

これ自体は動いたのですが、spawnIntervalを短くして難易度を上げようとしたところ、障害物どうしの間隔が詰まりすぎて、ジャンプでは絶対に避けられない密集状態が発生してしまいました。一定間隔で出すだけでは「出現タイミング」は制御できても、「直前の障害物との距離」までは保証できないことに気づいていなかったのです。

そこで、出現間隔そのものに多少のランダム幅を持たせつつ、最低間隔を下回らないように調整しました。

using UnityEngine;

public class ObstacleSpawner : MonoBehaviour
{
    public GameObject obstaclePrefab;
    public float minInterval = 1.2f;
    public float maxInterval = 2.2f;

    void Start()
    {
        ScheduleNextSpawn();
    }

    void ScheduleNextSpawn()
    {
        float nextInterval = Random.Range(minInterval, maxInterval);
        Invoke(nameof(SpawnObstacle), nextInterval);
    }

    void SpawnObstacle()
    {
        Vector3 spawnPos = new Vector3(10f, -2f, 0);
        Instantiate(obstaclePrefab, spawnPos, Quaternion.identity);
        ScheduleNextSpawn();
    }
}

🔧 詳しい手順

  1. 障害物用のオブジェクトをHierarchyで作成する(2D Object → Sprites → Square など)。名前を「Obstacle」にする
  2. Add Component →「Box Collider 2D」を追加し、「Is Trigger」にチェックを入れる
  3. Add Component →「Rigidbody 2D」を追加し、Body Typeを「Kinematic」に変更する(重力で落下しないようにするため)
  4. Scriptsフォルダで「ObstacleMover」という名前のMonoBehaviour Scriptを作成し、プレイヤーと逆方向に一定速度で動き続ける処理を書く(transform.position += Vector3.left * speed * Time.deltaTime;)。これをObstacleにアタッチする
  5. Obstacleをプレハブ化し、Prefabsフォルダに入れる
  6. Spawner用の空のGameObjectをHierarchyに作り、「ObstacleSpawner」という名前でMonoBehaviour Scriptを作成。上のコードを書いてアタッチし、Inspector上のObstacle Prefab欄にプレハブをドラッグ&ドロップする
  7. 画面外(X座標が-10を下回るなど)に出たObstacleを自動でDestroyする監視処理も、ObstacleMoverの中に追加しておく

InvokeRepeatingの代わりにInvokeを1回ずつスケジュールし直す形にしたことで、「最低でもこれだけは間隔を空ける」という制御ができるようになりました。

つまずき:障害物がまったく動かない

設定し終えて再生したところ、障害物は次々に生成されるのに、その場から一切動かず、画面にどんどん溜まっていきました。原因を確認したところ、Obstacleをプレハブ化したタイミングが、ObstacleMoverスクリプトをアタッチする前後で前後してしまい、プレハブ側にはObstacleMoverが付いていない状態になっていたことが分かりました。Hierarchy上の元オブジェクトを直接編集しても、プレハブ側に反映されていなければ、生成されるのは「コンポーネントが抜けた状態」のクローンになってしまいます。

ProjectウィンドウでObstacleプレハブをダブルクリックして編集モードを開き、Add ComponentでObstacleMoverを追加し直すことで解決しました。新しいプレハブを作るときは、必要なコンポーネントを一通り付け終えてからプレハブ化する、もしくはプレハブ化したあとに編集モードで確認する、という順番を意識すると事故が減ります。

つまずき:障害物がGroundの下に隠れる

動くようにはなったものの、今度は障害物がGroundと重なったときに、Groundの裏に隠れてしまうことがありました。これも準備の章で触れた「Order in Layer」の問題で、ObstacleとGroundが同じ描画順(デフォルトの0)になっていたのが原因でした。Obstacleプレハブの Sprite Renderer の「Order in Layer」を 1 に設定し、Groundより手前に描画されるようにして解決しました。

つまずき:障害物が地面に埋まってジャンプで避けられない

最後に、出現位置の高さ(spawnPosのY座標)が低すぎて、障害物がGroundに半分埋まったような状態になっていました。GroundのPosition Y(-1.8161)とScale Y(0.5121723)から地面の上面の座標を計算し、そこにObstacleの高さの半分を足した約-1.06を出現位置のYに設定し直すことで、ちょうど地面の上にきちんと乗った状態で出現するようになりました。

Vector3 spawnPos = new Vector3(10f, -1.06f, 0);

ℹ️ 補足:地面オブジェクトのサイズを変えた場合、この数値も計算し直す必要があります。計算式は「Groundの上面のY座標(Ground.Position.Y + Ground.Scale.Y ÷ 2) + Obstacleの高さの半分」です。

適度な間隔で障害物が流れてくる様子

第4関門:障害物にぶつかってもゲームオーバーにならない

プレイヤーが障害物に衝突したときの判定です。最初は次のように書きました。

void OnTriggerEnter2D(Collider2D other)
{
    if (other.gameObject.tag == "Obstacle")
    {
        Debug.Log("ゲームオーバー");
    }
}

ところが何度ぶつかってもConsoleに何も表示されません。原因を確認したところ、Obstacleにタグを設定し忘れていて、デフォルトの「Untagged」のままになっていました。第3関門で「Groundタグ」を作ったのと同じ要領で、「Obstacle」というタグも別途作る必要があったのに、それを忘れていたのです。

タグを設定したあとも、もう一つ問題がありました。プレイヤー側のCollider(Box Collider 2D)に「Is Trigger」のチェックを入れていなかったため、OnTriggerEnter2Dではなく地面と同じOnCollisionEnter2Dが呼ばれるべき状態になっていたのです。プレイヤーには地面用の物理衝突(Is Trigger オフ)と、障害物検知用のトリガー(Is Trigger オン)の両方が必要でした。

🔧 詳しい手順

  1. HierarchyでObstacleプレハブを開き、Inspector左上のTagドロップダウンから「Add Tag…」→「Obstacle」という名前で新規作成し、設定する
  2. HierarchyでPlayerオブジェクトを選択する
  3. Add Component →「Box Collider 2D」をもう1つ追加する(地面検知用とは別に、障害物検知専用のColliderを用意する)
  4. 2つ目に追加したBox Collider 2Dの「Is Trigger」にチェックを入れる
  5. Scriptsフォルダで「GameOverChecker」という名前のMonoBehaviour Scriptを作成し、下のコードを書いて保存する
  6. PlayerにAdd ComponentでGameOverCheckerを追加する
using UnityEngine;

public class GameOverChecker : MonoBehaviour
{
    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.gameObject.CompareTag("Obstacle"))
        {
            Time.timeScale = 0f;
            Debug.Log("ゲームオーバー");
        }
    }
}

タグの比較も、文字列の==ではなくCompareTag()に統一しました。動作としてはどちらでも大きな違いはありませんが、CompareTag()の方がタイプミスに気づきやすく、わずかに高速という利点もあります。

障害物に衝突してゲームが停止した瞬間

仕上げ:生存スコアと難易度調整

最後に、生き残った時間をスコアとして表示し、時間とともに障害物の出現間隔を短くしていきます。

using UnityEngine;
using TMPro;

public class SurvivalTimer : MonoBehaviour
{
    public TMP_Text timeText;
    private float elapsedTime = 0f;
    private bool isRunning = true;

    void Update()
    {
        if (!isRunning) return;

        elapsedTime += Time.deltaTime;
        timeText.text = "Time: " + elapsedTime.ToString("F1");
    }

    public void StopTimer()
    {
        isRunning = false;
    }
}

🔧 詳しい手順

  1. Hierarchyで右クリック→「UI」→「Text – TextMeshPro」を選択し、名前を「TimeText」にする。前回の記事と同様、初回は「TMP Importer」ダイアログでImportする
  2. Rect Transformのアンカープリセットを、Shift+Altを押しながら右上に設定し、画面右上に配置する
  3. Hierarchyで右クリック→「Create Empty」を選択し、名前を「GameManager」にする
  4. Scriptsフォルダで「SurvivalTimer」という名前のMonoBehaviour Scriptを作成し、上のコードを書いて保存する
  5. GameManagerにAdd ComponentでSurvivalTimerを追加し、InspectorのTime Text欄にTimeTextオブジェクトをドラッグ&ドロップする
  6. GameOverCheckerのコードの中で、Time.timeScale = 0f;の前後にFindFirstObjectByType<SurvivalTimer>().StopTimer();を追加し、ゲームオーバー時にタイマーを止める

isRunningのチェックを入れているのは、Time.timeScale = 0fにしてもUpdate()自体は呼ばれ続けるため、タイマーの数字だけ動き続けてしまうのを防ぐためです。前回の記事で学んだ「Time.timeScaleは万能ではなく、Updateの中身までは止めてくれない」という点を、今回もそのまま踏襲しています。

難易度調整は、第3関門のObstacleSpawnerで使ったminIntervalmaxIntervalを、経過時間に応じて少しずつ縮める処理を追加することで対応しました。

void Update()
{
    elapsedTime2 += Time.deltaTime;
    if (elapsedTime2 > 10f && minInterval > 0.6f)
    {
        minInterval -= 0.1f;
        maxInterval -= 0.1f;
        elapsedTime2 = 0f;
    }
}

elapsedTime2はObstacleSpawner側に新しく追加するfloat型の変数です。10秒ごとに間隔の最小・最大をそれぞれ0.1ずつ縮めていき、minIntervalが0.6を下回らないようにしています。)

つまずき:難易度が上がっている実感がない

実装してしばらく遊んでみたものの、「本当に難しくなっているのか?」という確認が、プレイ感覚だけでは分かりませんでした。バグなのか、単に変化が緩やかすぎて気づけないだけなのかを切り分けるため、値が変化するタイミングで一時的にログを出すようにしました。

if (elapsedTime2 > 10f && minInterval > 0.6f)
{
    minInterval -= 0.1f;
    maxInterval -= 0.1f;
    elapsedTime2 = 0f;
    Debug.Log("minInterval: " + minInterval);
}

再生して放置したところ、10秒後にConsoleへ「minInterval: 1.1」と正しく表示されました。つまりロジック自体は正常に動いていて、単に「10秒で0.1」という変化量が体感しづらかっただけだったと分かりました。見た目やプレイ感覚で「効いているか分からない」処理は、こうして数値を直接ログに出して確認すると、バグなのか体感の問題なのかを素早く切り分けられます。確認が済んだら、このデバッグ用のDebug.Logは削除しておきます。

ℹ️ 補足:難易度の上がり方をもっとはっきり感じさせたい場合は、minInterval -= 0.1f;の数値を大きくする(例:0.2や0.3)と、変化がより分かりやすくなります。お好みで調整してみてください。

生存時間スコアが表示された状態でプレイしている画面

完成して振り返って分かったこと

  • ジャンプ系のゲームでは「地面に接しているかどうか」の判定が必須。これを忘れると多重ジャンプ・空中浮遊といった、見た目にも分かりやすいバグになる
  • 1枚絵の背景を動かすだけではスクロールに見えない。ループさせるには複数枚を並べて、画面外に出たら反対側へ回す必要がある
  • 背景のループは「画像の横幅」と「カメラの表示範囲」を混同しやすい。数値(位置や間隔)がすべて正しくても、ループ判定の基準そのものが間違っていると隙間は消えない。「結果がおかしいのに数字は合っている」ときは、計算の中身ではなく「何を基準に計算しているか」を疑うと突破口になる
  • 古いスクリプトを削除し忘れたまま新しいスクリプトを追加すると、お互いの処理が競合して原因が分かりにくくなる。設計を作り直すときは、古いコンポーネントが本当に削除されているかを必ず確認する
  • フリー素材は、パーツ分けされたアニメーション用アセット(tilesheet・Limbs・Posesなど)と、1枚絵で完結する素材があり、初心者のうちは後者の方が圧倒的に扱いやすい
  • ドラッグ&ドロップでのインポートがうまくいかないときは、「Show in Explorer」「Reveal in Finder」で実際のフォルダを開き、ファイルを直接コピー&ペーストすると確実
  • 新しく配置したSpriteが既存のSpriteを隠してしまうのは、Sprite Rendererの「Order in Layer」が原因のことが多い。数字が小さいほど奥に描画される
  • 「一定間隔で出す」処理と「適切な間隔を空けて出す」処理は似ているようで別物。InvokeRepeatingは手軽だが、間隔そのものを動的に調整したい場合はInvokeを都度呼び直す方が柔軟
  • 当たり判定用のタグ作成を忘れる、Colliderの役割(物理衝突用かトリガー用か)を混同する、というミスは前回のキャッチゲームでも今回のランナーゲームでも共通して発生した。新しいオブジェクトを作るたびに「タグは設定したか」「Is Triggerの有無は正しいか」をセットで確認する癖をつけると事故が減る
  • プレハブ化のタイミングによっては、必要なスクリプトが付け忘れられたまま量産されることがある。新しいプレハブを作ったら、編集モードで構成コンポーネントを一通り見直すと安心
  • 出現位置などの座標は「だいたいこのあたり」で決めず、地面のPositionとScaleから逆算すると、見た目のズレに悩まされにくい
  • 「効いているのか分からない」処理は、プレイ感覚だけで判断せず、Debug.Logで実際の数値を確認すると、バグなのか単に変化が緩やかなだけなのかをすぐに切り分けられる
  • Time.timeScale = 0fはオブジェクトの移動を止めてくれるが、Update()内のロジックそのものは止まらない。タイマーやUI更新など、止めたい処理には専用のフラグ(isRunningなど)を用意する必要がある

前回のキャッチゲームと今回のランナーゲームを通して、「移動」「当たり判定」「UI」「終了処理」という基本パターンが、ジャンルが変わってもほぼ共通して使えることが体感できました。一方で「背景のループ」のように、見た目はシンプルでも内部の計算が奥深いテーマもあり、作るゲームの種類を変えるたびに新しい発見があるのもゲーム開発の面白さだと感じます。

次にやること

ここからさらに磨き込むなら、次のような方向が考えられます。

  • ジャンプ中だけアニメーションを切り替える、着地時にエフェクトを出すなど、見た目の気持ちよさを強化する
  • 障害物の種類を増やし、ジャンプの高さによって避け方を変える必要がある障害物を追加する
  • STEP5で紹介したデバッグ・テストプレイの手順に沿って、実際に第三者に遊んでもらい、難易度カーブが適切かフィードバックをもらう
  • STEP6・STEP7を参考に、完成したゲームをitch.ioなどで公開し、X(Twitter)で進捗を発信してみる

横スクロールアクションは「1ボタンで遊べる」というシンプルさゆえに、操作性やテンポの細かい調整が完成度を大きく左右します。今回作ったベースをもとに、ジャンプの重さや障害物の出現パターンを自分なりに調整してみてください。

あわせて読みたいおすすめ書籍【PR】

今回のような横スクロールアクション・ジャンプアクションの仕組みをさらに学びたい方には、以下の書籍もおすすめです。本セクションにはアフィリエイトリンクを含みます。

この記事を作成するにあたって参考にしたサイト

コメント

タイトルとURLをコピーしました