😑

高校生がUnityでゲーム制作にチャレンジしてみた

に公開

はじめに

学校の中間テストが終わり(二つの意味で)、少し落ち着いてきましたので記事を書こうと思います。
校内順位が低すぎたため、予備校に入ろうかどうか迷っています('_')

前置きはこのくらいにして、本記事は簡単で分かりやすくまとめました。
少しでも目を通していただければ幸いです!

※ このゲームは初めて外部公開した作品です。
クオリティはまだまだですが、これからさらに良いゲームを作れるよう日々精進していきます!
https://unityroom.com/games/easy3dzombie

ゲーム概要

今回作成した 3Dタワーディフェンス風ゲーム は、以下の4点で簡潔にまとめられます。

  1. フェーズ制でのゾンビ襲来
    大量のゾンビが四方からランダムに攻めてきます。

  2. 配置フェーズ
    フェーズ開始前に、ゾンビ誘導&足止め用の壁迎撃用タレット を自由に配置できます。

  3. タレットによる攻撃
    タレットが自動でゾンビを狙い撃ちします。

  4. 勝敗条件
    中央の基地を 5 フェーズ守り切れば勝ち、一定回数基地がゾンビに触れられたら負けとなります。

各要素の実装と苦労した点

1. フェーズ制で大量のゾンビ襲来

苦労度: ★★⭐︎⭐︎⭐︎

3Dゲームとして、NavMesh を活用し、ゾンビに中央基地までの最短ルートをたどらせました。
NavMesh には Bake 機能があり、どの地形を通るかを定義できますが、タレットなどの建築物を配置した際、Bake を更新しなければゾンビがタレットの位置を認識できず、詰まる問題が発生しました。

解決策:
フェーズ開始直前に Bake を更新することで、正しい経路が確保されるようにしました。

2. 壁とタレットの自由配置機能

苦労度: ★★★★⭐︎

この機能では、特定のキーを押すとプレイヤーの視線先に壁やタレット(以下「建築物」)を配置できるようにしています。
ただし、配置前にどこにどう置かれるかが見えないと不便ですよね。
そこで、フォートナイト の建築のように設置前に半透明のプレビューが追随する仕組みを実装しました。

実装の流れ

  1. プレイヤーの視線先から Ray を発射する
  2. Ray が床に当たった座標を取得する
  3. その座標に半透明の建築物を表示する
  4. 常に Ray の座標に合わせて移動し、設置可能かを判断する
  5. 設置可能な場所なら実物を配置する

主な苦労と解決策

  • 問題①: Ray が目的の座標ではなく、Player や半透明プレビューの座標を取得してしまう
    解決: レイヤーマスクを使用して、Player や半透明の建築物のレイヤーを除外することで改善しました。

  • 問題②: 建築物のコードが見づらい
    解決:

  1. Ray で取得した視線先に Empty オブジェクトを追加し追尾
  2. Y座標を若干高く設定し、地面へのめり込みを防止する
  3. Trigger をアタッチし、箱型に変更して接触判定を実装
  4. Empty に配置可否を判断するコードを組み込み
  5. 設置スクリプトには可否情報のみを伝達し、冗長性を削減

この方法により、スパゲッティ気味だったスクリプトの整理が進み、
より管理しやすいコード設計が実現しました。

3. タレットがゾンビを打ちまくる

苦労度: ★★★★★

タレットは設置するだけで、自動的に索敵し最適な敵を狙い撃ちします。
最適化に非常に苦労しました。
沢山のゾンビの座標を配列で処理しつつ、常に変化するシーン上の状況下でも安定して動作する設計が必要でした。

実装のポイント:

  • フィルタ条件:
    敵の座標の全探索ではなく、まず【射線が通っている】という条件でフィルタリングし、
    ユークリッド距離の計算を可能な限り削減することでパフォーマンス向上を目指しました。

  • 具体的なコード例:

    foreach (GameObject enemy in enemies)
    {
        Vector3 directionToEnemy = (enemy.transform.position - firePoint.position).normalized;
    
        if (Physics.Raycast(firePoint.position, directionToEnemy, out RaycastHit hit, Mathf.Infinity))
        {
            if (hit.collider.gameObject == enemy)
            {
                float distance = Vector3.Distance(transform.position, enemy.transform.position);
    
                if (distance < minDistance)
                {
                    minDistance = distance;
                    closestEnemy = enemy;
                }
            }
        }
    }
    
  • 追加の工夫:
    最適化によって完璧なタレット挙動はゲームとして面白くなくなるため、最適化した結果にノイズを入れ、意図的な乱れも付与しました。
    また、計算のタイミングを調整して処理を軽くしました。

  • 後悔ポイント:
    偏差射撃の実装は、敵の進行方向・速度、弾丸の速度および発射タイミングなど、多数の計算が必要なため、実装は断念しました。
    悔しいので次回の記事は偏差射撃の作り方をテーマに投稿する予定です。

演出をちょっと触った

爆発エフェクトの改善

タレットが撃った球が、ゾンビに当たらずステージを囲む壁に当たった際に爆発エフェクトを実装しました。
単なるエフェクトではなく、実際の爆発のように瞬間的に光が発生し、徐々に消える仕組みを導入しました。

using UnityEngine;
using System.Collections;

public class LightController : MonoBehaviour
{
    public Light explosionLight; // 爆発時のライト
    public float lightFadeDuration = 1f; // 光が消えるまでの時間(Inspectorで調整)

    void Start()
    {
        if (explosionLight == null)
        {
            explosionLight = GetComponentInChildren<Light>(); // 子オブジェクトのLightを取得
        }

        if (explosionLight != null)
        {
            StartCoroutine(FadeOutLight());
        }

        Destroy(gameObject, lightFadeDuration + 0.5f); // 爆発オブジェクトを自動削除
    }

    IEnumerator FadeOutLight()
    {
        float startIntensity = explosionLight.intensity;
        float timer = 0f;

        while (timer < lightFadeDuration)
        {
            timer += Time.deltaTime;
            explosionLight.intensity = Mathf.Lerp(startIntensity, 0f, timer / lightFadeDuration); // 徐々に光を消す
            yield return null;
        }

        explosionLight.enabled = false; // 完全に消えたらOFF
    }
}

とても簡素ではありますが、光の扱い方の学びにもなり、実装して良かったと感じています。


エンドロールの実装

ゲームを最後までプレイしてくださった方へ感謝の気持ちを込め、エンドロールを実装しました。
見たい方のみに表示させたいので、クリア画面からエンドロールに移れる仕組みを作り、途中でエンドロールをスキップできるようにもしました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class EndRollScript : MonoBehaviour
{
    [SerializeField]
    private float textScrollSpeed = 80;
    [SerializeField]
    private float limitPosition = 730f;

    private bool isStopEndRoll;
    private Coroutine endRollCoroutine;

    void Update()
    {
        // Lキーが押された場合、スタート画面に即時移動
        if (Input.GetKeyDown(KeyCode.L))
        {
            LoadStartScene();
            return; // 早期終了して他の処理をスキップ
        }
        else
        {
            // テキストがリミットを越えるまで動かす
            if (transform.position.y <= limitPosition)
            {
                transform.position = new Vector2(transform.position.x, transform.position.y + textScrollSpeed * Time.deltaTime);
            }
            else
            {
                isStopEndRoll = true;
            }
        }
    }

    private void LoadStartScene()
    {
        Cursor.lockState = CursorLockMode.None;
        Cursor.visible = true;
        SceneManager.LoadScene("startgame");
    }
}

シンプルではありますが、形にはなったので非常に嬉しかったです。

結び

以上が、今回特に苦労した点や工夫、努力のポイントになります。
このゲーム制作を通じて、膨大な数のオブジェクトが緻密に連動するシステムの奥深さを実感するとともに、Unity の機能を体得することができました。
培ったノウハウは次作にぜひ生かしていきたいと思います。

最後までお読みいただき、誠にありがとうございました。

https://unityroom.com/games/easy3dzombie

没になったサムネイルをここで供養しておきます…

Discussion