【Unity】2Dシューティングを作ってみる話 #21

記事をご覧いただき、誠にありがとうございます。

投稿主の無能です。

前回は、スコアの仕組みを実装しました。

今回はプレイヤーにHPを付けていきたいと思います。

プレイヤーにHPを付ける

さて、プレイヤーにHPを付けるという処理を、どのようにすればいいか見当を付けたいと思います。

プレイヤーが10というHPを持っていて、敵や敵の弾に当たる度にHPが減っていき、0になったらプレイヤーが破壊される、という仕組みにしたいと思います。

そうなると、敵の本体やショットに攻撃力を持たせる事が必要です。

プレイヤーのHPから受けた攻撃に応じて減算すれば良さそうです。

方針が決まったので、実装に向けて準備したいと思います。

プレイヤーのHPを表示するUIを作成する

プレイヤーのHPが分かるように、今回は数字の表記にあわせてゲージを使いたいと思います。

「PlayerStatus」という空のオブジェクトを作成し、GameStatusキャンバスの子オブジェクトにします。

「PlayerStatus」という空のオブジェクトを作成し、GameStatusキャンバスの子オブジェクトにする

このPlayerStatusの子オブジェクトにプレイヤー関連の情報が集まるようにします。

まずは数字でのHP表示が必要なので、PlayerStatusの子オブジェクトに「PlayerHP_Text」というテキストオブジェクトを作成します。

GameObject > UI > Text - TextMeshProでテキストオブジェクトを作成する

「PlayerHP_Text」という名前でPlayerStatusの子オブジェクトにする

今回は左上に表示させたいと思います。

PlayerHP_Textの配置

PlayerHP_Textの設定

次にHPゲージを作ります。

GameObject > UI > Slider でスライダーを作成し、名前を「PlayerHP_Gauge」にしてPlayerStatusの子オブジェクトにします。

GameObject > UI > Slider でスライダーを作成する

名前を「PlayerHP_Gauge」にしてPlayerStatusの子オブジェクトにする

そして位置をPlayerHP_Textの右隣にします。

PlayerHP_Gaugeの配置

PlayerHP_Gaugeの設定

今回はスライダーを操作するツマミの部分は必要ないので、Handle Slide Areaを子オブジェクトごと削除します。

Handle Slide Areaは不要なので子オブジェクトごと削除する

Handle Slide Areaを削除

ツマミの部分が削除された

後はゲージに色を付けたいと思います。

緑が残HP、赤がゲージの背景になるようにします。

PlayerHP_Gaugeの子オブジェクトにあるBackGroundがゲージの背景になるので、これを赤にします。

BackGroundの色を赤にする

ゲージの背景が赤になった

次は残HPの部分なので、残HPが半分の状態にして視認できるようにします。

PlayerHP_GaugeのSliderの項目を設定します。

Min Valueが最小値、Max Valueが最大値、そしてValueが現在の値になります。

という事は、プレイヤーのHPを考えると最小値が0、最大値が10で、視認しやすいように現在の値を5にしてやれば良いです。

そして整数しか扱わないので、Whole Numbersにチェックを入れます。

Sliderの設定

これで視認しやすくなりました。


あとはこの残HPの部分を緑にします。

PlayerHP_Gaugeの孫オブジェクトにあたるFillの色を緑にします。

Fillを緑にする

残HPの部分が緑になった

Sliderの他の詳細についてはドキュメントを参照してください。

Slider - Unity Documentation

これでプレイヤーのHP表示の下準備が出来たので、HPの増減と更新をスクリプトで書いていきます。

まずはダメージ計算を行うPlayerControllerからになります。

このようになります。

PlayerController.cs
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Rigidbody2D))]
public class PlayerController : MonoBehaviour
{
    #region
    [Header("移動関連")]
    [SerializeField] float moveSpeed = 1f;                  // プレイヤーの移動速度
    [SerializeField] float marginX = 1f, marginY = 1f;      // 余白x・y
    #endregion

    #region
    [Header("ショット関連")]
    [SerializeField] GameObject playerBullet;               // 弾のプレハブ
    [SerializeField] float bulletSpeed = 10f;               // 弾の速度
    [SerializeField] Transform playerFirePos;               // 発射ポイント
    [SerializeField] float bulletDelay = 1f;                // 発射の閾値
    #endregion

    [Header("プレイヤーのHP")]
    [SerializeField] int playerHPMax = 10;                  // プレイヤーのHP(初期値:10)
    [SerializeField] TMP_Text playerHPText;                 // プレイヤーのHP表示のテキスト
    [SerializeField] Slider playerHPGauge;                  // プレイヤーのHP表示のゲージ

    #region
    [Header("バーチャルスティック")][SerializeField] VariableJoystick joyStick;
    [Header("爆発エフェクト")][SerializeField] GameObject playerExplosion;

    [Header("ゲームマネージャー")][SerializeField] GameManager gameManager;
    [Header("エネミーコントローラー")][SerializeField] EnemyController enemyController;
    #endregion

    #region
    Rigidbody2D playerRB;                                   // プレイヤーのRigidbody
    Vector2 moveDirection, min, max;                        // プレイヤーのベクトル,画面サイズの最小ベクトル/最大ベクトル
    Vector2 playerPos;                                      // プレイヤーの移動制限用
    float bulletInterval;                                   // 弾の発射間隔管理用
    bool isShotPressed = false;                             // 発射ボタン管理用
    #endregion
    int playerHPCurrent, playerHPMin = 0;                   // プレイヤーの現在のHP,HPの最小値
    enum calcType { heal, damage, display }                 // HP表示・計算のタイプ

    #region
    #region
    void Start()
    {
        // プレイヤーのRigidbodyを取得
        playerRB = GetComponent<Rigidbody2D>();
        // プレイヤーのHPを初期化
        playerHPCurrent = playerHPMax;
        // プレイヤーのHPを表示
        PlayerHPDisplay();
    }
    #endregion

    #region
    void Update()
    {
        // 入力受付
        InputProcess();

        // バーチャルスティック処理 ---
        // バーチャルスティックに何らかの入力が合った場合
        // (joyStickが0では無い場合)
        if (joyStick.Direction != Vector2.zero)
        {
            // moveDirectionをバーチャルスティックの入力にする
            moveDirection = joyStick.Direction;
        }
        // --- ここまで

        // 弾の発射 ---
        // deltaTimeを加算
        bulletInterval += Time.deltaTime;
        // 発射間隔が閾値未満の場合
        if (bulletInterval < bulletDelay)
        {
            // 処理を中断
            return;
        }
        // スペースキーが押された場合/isShotPressedがtrueの場合
        if (Input.GetKey(KeyCode.Space) || isShotPressed)
        {
            // 弾の発射関数を呼ぶ
            PlayerShot(playerFirePos);
        }
        // 発射間隔をリセット
        bulletInterval = 0;
        // --- ここまで

    }
    #endregion

    #region
    void FixedUpdate()
    {
        // プレイヤーを動かす
        PlayerMove();
    }
    #endregion

    #region
    // 入力受付
    void InputProcess()
    {
        // 入力を正規化して受け取る
        float x = Input.GetAxisRaw("Horizontal");
        float y = Input.GetAxisRaw("Vertical");
        // 入力を正規化する
        moveDirection = new Vector2(x, y).normalized;
    }
    #endregion

    #region
    // プレイヤーを動かす
    void PlayerMove()
    {
        // 移動制限
        MoveClamp();
        // プレイヤーを動かす
        playerRB.velocity = moveDirection * moveSpeed;
    }
    #endregion

    #region
    // プレイヤーの移動制限
    void MoveClamp()
    {
        // 最後の移動地点を取得
        playerPos = transform.position;
        // ビューポート座標からワールド座標を取得
        min = Camera.main.ViewportToWorldPoint(Vector2.zero);
        max = Camera.main.ViewportToWorldPoint(Vector2.one);
        // 移動を制限する
        playerPos.x = Mathf.Clamp(playerPos.x, min.x + marginX, max.x - marginX);
        playerPos.y = Mathf.Clamp(playerPos.y, min.y + marginY, max.y - marginY);
        // プレイヤーの位置を取得した最後の地点にする
        transform.position = playerPos;
    }
    #endregion

    #region
    // 弾の発射
    void PlayerShot(Transform firePos)
    {
        // 弾を生成
        GameObject bulletClone = Instantiate(playerBullet, firePos.position, Quaternion.identity);
        // 弾のRigidbodyに速度ベクトルをつける
        bulletClone.GetComponent<Rigidbody2D>().velocity = new Vector2(bulletSpeed, 0);
    }
    #endregion

    #region
    // 接触判定
    private void OnTriggerEnter2D(Collider2D collision)
    {
        // 敵の場合
        if (collision.gameObject.CompareTag("Enemy"))
        {
            // ダメージの算出
            PlayerHPChanged((int)calcType.damage, EnemyController.bodyDamage, collision);
        }
        // 敵の弾の場合
        else if (collision.gameObject.CompareTag("EnemyBullet"))
        {
            // ダメージの算出
            PlayerHPChanged((int)calcType.damage, EnemyController.shotDamage, collision);
        }
        // プレイヤーの弾の場合
        else if (collision.gameObject.CompareTag("PlayerBullet"))
        {
            // 処理を中断
            return;
        }
    }
    #endregion

    #region
    // 爆発演出
    void PlayerExplosion(Collider2D collision)
    {
        // 接触した方を破棄
        Destroy(collision.gameObject);
        // 自身を破棄
        Destroy(gameObject);
        // プレイヤーの位置に爆発オブジェクトを作成
        Instantiate(playerExplosion, playerPos, Quaternion.identity);
    }
    #endregion

    #region
    // 発射ボタンが押された時のイベント
    public void OnPointerDown()
    {
        // 発射ボタンのフラグを変更
        isShotPressed = true;
    }

    // 発射ボタンが元に戻った時のイベント
    public void OnPointerUp()
    {
        // 発射ボタンのフラグを変更
        isShotPressed = false;
    }
    #endregion

    #region
    // コンティニュー
    void Continue()
    {
        // ゲームマネージャーのコンティニューUIを表示
        gameManager.VisibleUI_Continue();
    }
    #endregion

    #region
    // プレイヤーのHP表示・更新
    void PlayerHPDisplay()
    {
        // HPのテキストを表示・更新
        playerHPText.SetText("PlayerHP : " + playerHPCurrent + " / " + playerHPMax);
        // 現在のHPをゲージに反映
        playerHPGauge.value = playerHPCurrent;
    }
    #endregion
    #endregion

    // プレイヤーのダメージ算出
    void PlayerHPChanged(int cType, int damage, Collider2D collision)
    {
        // switchで条件分岐:対象→cType
        switch (cType)
        {
            // cTypeが回復だった場合
            case (int)calcType.heal:
                // ダメージ分を加算する
                playerHPCurrent += damage;
                // 接触したオブジェクトを消す
                Destroy(collision.gameObject);
                // プレイヤーのHPが最大値以上の場合
                if (playerHPCurrent >= playerHPMax)
                {
                    // プレイヤーのHPを最大値にする
                    playerHPCurrent = playerHPMax;
                }
                // switch文を抜ける
                break;

            // cTypeがダメージだった場合
            case (int)calcType.damage:
                // ダメージ分を減算する
                playerHPCurrent -= damage;
                // 接触したオブジェクトを消す
                Destroy(collision.gameObject);
                // プレイヤーのHPが0以下(最小値以下)だった場合
                if (playerHPCurrent <= playerHPMin)
                {
                    // 爆発演出
                    PlayerExplosion(collision);
                    // プレイヤーのHPを0(最小値)にする
                    playerHPCurrent = playerHPMin;
                    // コンティニュー処理
                    Continue();
                }
                // それ以外の場合
                else
                {
                    // switch文を抜ける
                    break;
                }
                // switch文を抜ける
                break;

            // cTypeが表示だった場合
            case (int)calcType.display:
                // switch文を抜ける
                break;

            // 該当なしの場合
            default:
                // HP表示の分岐へジャンプ
                goto case (int)calcType.display;
        }
        // プレイヤーのHPを表示
        PlayerHPDisplay();
    }
}

では見ていきましょう。

因みに、#regionと#endregionは無視してください。

これはVisual Studioの機能で、#region~#endregionの間を折りたためるようになります。

#region~#endregionもif文と同様にネスト(入れ子状態にする事)が出来るので、このような書き方になっています。

#region~#endregionの動きの例

もしかしたらブログ上で簡潔に見えるかな?と思って試したら反映されず惨敗でした…。

まあ前置きは置いといて見ていきましょう。

メンバ変数にプレイヤーのHPを表示するテキスト・ゲージを追加して、HPの最大値をInspectorビューで設定出来るようにしました。

HPの最大値がそのままプレイヤーのHPの初期値になるためです。

次にenemyControllerを用意しました。

これはダメージをEnemyCotrollerから取得するためです。

そしてプレイヤーの現在のHPと、最小値つまり0を用意しました。

変数では初めて見るenumというものが登場しました。

enumと言うのは列挙型というもので、{}内に定数規則をもって並べてひとまとめにして扱う事ができます

定数とは言葉の通り定まった数で、変更できないものを指します。

規則をもつというのは、簡単に言ってしまうと連番になります。

そしてその定数をひとまとめにして扱うのが列挙型のenumです。

イメージで言うと、八百屋さんやスーパーで果物が並んでいるようなイメージでしょうか。

果物という括りで並んでいる、と言うのが列挙型のイメージです。

何となくイメージ出来たでしょうか。

コードで言うと、calcTypeというenumの中にはheal・damege・displayという三つの定数があります。

enumは特に指定が無いと、左端のものから0で始まる自動的に連番を割り振ってくれます。

マウスオーバー(マウスカーソルを上に乗せる)すると、割り振られていることが分かります。




enumの詳細はドキュメントを参照してください。

Enum - Microsft Learn

このenumは後程使います。

Start関数内では、現在のプレイヤーのHPであるplayerHPCurrentへ最大値のplayerHPMaxを入れています。

そしてPlayerHPDisplay関数を呼び、HPの表示を更新しています。

OnTriggerEnter2D関数内も敵本体と敵の弾に接触した時の処理がPlayerHPChanged関数に変更しています。

PlayerHPDisplay関数ではHPのテキストとゲージの表示を更新しています。

もしプレイヤーのHPゲージを初期値の10から変更する場合は、PlayerHP_Gaugeの最大値もあわせて変更するようにしてください。

PlayerHPChanged関数では、switch文を使って条件を分岐しています。

switch文もifと同様に条件分岐文ですが、if文とswitch文では少々得意な事が違います。

if文の場合はifの後に記述する条件式を見て判断するので、その時々の状況に応じた条件で分岐することが得意です。

変わってswitch文はと言うと、条件に使用する変化する値が一つで、その値によって結果が分岐するものが得意です。

例えて言えばサイコロやおみくじなんかがピッタリですね。

例えばサイコロの目を変数にした場合、if文では条件式にサイコロの目が〇と等しい、などの条件式を記述すると思います。

switch文では、switchの隣の括弧に条件となるものが入ります。

その値が何かによって、caseに分岐していきます。

サイコロの場合であれば、条件にサイコロの目が入って、caseで1の場合、2の場合…と分岐します。

何となくif文とswitch文の違いについて理解できたでしょうか。

そしてenumとswitchはとても相性が良いです。

一つのものが変化するので、列挙型の特徴である何かの括りのものをひとまとめに扱う、というものがピッタリです。

ここでcalcTypeが登場します。

引数で受け取ったcTypeというint型をswitch文で条件にしてやり、cTypeの変更にあわせてcaseで分岐しています。

ただし、intとenumでは型が違うので、キャストという方法で型を変換しています。

ここでは(int)がキャストになります。

これでint同士になったので比較できるようになりました。

そしてcalcTypeはheal・damage・displayの三つの定数があるので、それぞれの分岐を見ていきます。

まずhealですが、これは後々回復を実装したいと考えているので、この分岐は一旦置いておきます。

次にdamageですが、初めに現在のHPから引数で受け取ったダメージを減算しています。

そして接触したオブジェクトを破棄しています。

次のif・else文では、現在のHPが最小値(0)以下の場合とそれ以外の場合に分岐しています。

現在のHPが0以下の場合は、まず爆発演出を表示し、現在のHPを最小値にしています。

この処理でHPの表示がマイナスにならないようにしています。

そしてそれらの後にコンティニューの処理を呼んでいます。

elseの場合は、処理を中断するのではなく、switch文を抜けるようにしています。

switch文は{}の中にcaseを記述して分岐を表現していますが、このswitch文の処理を抜けるのがbreakになります。

if文の括弧のようなものですね。

breakが無いとswitch文を抜けることが出来ないため、次のcaseも実行してしまいます。

わざとbreakを付けない場合は良いのですが、基本はcaseの後には必ずbreakで抜ける、という事を覚えておきましょう。

またbreakではなくても、returnでもswitch文から抜けられます。

要は何らかの形で、全てのcaseからswitch文を抜けられるようにしておく、という事になります。

そしてdisplayの場合ですが、何もせずにswitch文から抜けています。

最後に分岐の該当ない場合のdefaultですが、こちらはgoto文と言うものを使ってdisplayの分岐に強制的に処理を移動させています。

つまりは何もしない、ということですね。

そしてswitch文を抜けたPlayerHPChanged関数の最後にHPの表示を更新しています。

switch文、goto文については、各自で調べてみてください。

これでプレイヤーのHPの処理が実装出来ました。

次はEnemyCotrollerで、敵本体に接触した時と敵の弾のダメージを作ります。

このようになります。

EnemyCotroller.cs
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D))]
public class EnemyController : MonoBehaviour
{
    [Header("敵の移動スピード")][SerializeField] float moveSpeed = 1f;
    [Header("敵の弾の発射")]
    [SerializeField] GameObject enemyBullet;        // 敵の弾
    [SerializeField] Transform enemyFirePos;        // 発射ポイント
    [SerializeField] float shotSpeed = 5f;          // 弾の速度
    [SerializeField] float shotDelay = 2f;          // 弾の発射の閾値

    [Header("加算する点数")][SerializeField] int addScore = 100;
    [Header("爆発エフェクト")][SerializeField] GameObject enemyExplosion;

    [Header("ゲームマネージャー")] GameManager gameManager;

    Rigidbody2D enemyRB;                            // 敵のリジッドボディ
    float shotInterval = 0;                         // 弾の発射管理用

    public static int shotDamage { get; } = 1;      // 弾のダメージ
    public static int bodyDamage { get; } = 3;      // 本体のダメージ

    void Start()
    {
        // 敵のリジッドボディを取得
        enemyRB = GetComponent<Rigidbody2D>();
        // ゲームマネージャーを取得
        gameManager = GameObject.FindWithTag("GameManager").GetComponent<GameManager>();
    }

    void Update()
    {
        // 弾の発射 ---
        // deltaTimeを加算
        shotInterval += Time.deltaTime;
        // 発射間隔が閾値未満の場合
        if (shotInterval < shotDelay)
        {
            // 処理を中断
            return;
        }
        // 弾の発射関数を呼ぶ
        EnemyShot(enemyFirePos);
        // 発射間隔をリセット
        shotInterval = 0;
        // --- ここまで
    }

    void FixedUpdate()
    {
        // 敵を動かす
        EnemyMove();
    }

    // 敵を動かす
    void EnemyMove()
    {
        // 敵に左方向のベクトルを持たせる
        enemyRB.velocity = new Vector2(-moveSpeed, 0);
    }

    // 接触判定
    void OnTriggerEnter2D(Collider2D collision)
    {
        // プレイヤーに接触した場合
        if (collision.gameObject.CompareTag("Player"))
        {
            // 爆発演出
            EnemyExplosion(collision);
        }
        // プレイヤーの弾に接触した場合
        else if (collision.gameObject.CompareTag("PlayerBullet"))
        {
            // 爆発演出
            EnemyExplosion(collision);
            // スコアを加算
            gameManager.ScoreAdded(addScore);
        }
        // ObjectDestroyerに接触した場合
        else if (collision.gameObject.CompareTag("ObjectDestroyer"))
        {
            // 自身を破棄
            Destroy(gameObject);
        }
    }

    // 弾の発射
    void EnemyShot(Transform firePos)
    {
        // 弾を生成
        GameObject bulletClone = Instantiate(enemyBullet, firePos.position, Quaternion.identity);
        // 弾のRigidbodyに速度ベクトルをつける
        bulletClone.GetComponent<Rigidbody2D>().velocity = new Vector2(-shotSpeed, 0);
    }

    // 爆発演出
    void EnemyExplosion(Collider2D collision)
    {
        // 自身を破棄
        Destroy(gameObject);
        // 爆発エフェクトを生成
        Instantiate(enemyExplosion, transform.position, Quaternion.identity);
    }
}

メンバ変数にbodyDamage・shotDamageを追加しました。

publicは分かりますが、staticって何?となりますよね。

このstaticは静的な、という意味で、動かないものなどが該当します。

ここでbodyDamage・shotDamageはゲームが開始したら値は動きませんので、staticになります。

staticの変数は「クラス名.変数」という形でどこからでもアクセス可能になるのが特徴ですが、どこからでもアクセス可能が故にバグの温床になりやすいのもまた特徴です。

そこでアクセサーを使って、変数へのアクセスを読み取りのみにしています。

アクセサー(accessor)はこのように、外部から直接値を参照したり変更するのではなく、関数の様にその属性(プロパティ)にアクセスするものになります。

よく使うものだとSetActiveとかGetComponentとかでしょうか。

このようにアクセス用の方法を用意して、変数などを外部から分かり難くすることを「カプセル化」と言います。

アクセサーやカプセル化については必要に応じて、調べたり使ってみてください。
コードの記述がより洗練されたものになると思います。

残る変更点は接触判定で、プレイヤーが一撃でやられなくなったので、Destroyする処理が減ります。

この辺りは、どんな時に何が消えるのかを上手く調整してみてください。

これでHPの処理が実装できたので、Inspectorビューで追加したものを設定します。

追加項目を設定する

エネミーコントローラーはプレハブのEnemyでできます。

これで設定ができたので、ゲームを実行して確認します。

敵本体や弾に当たるとHPが減る

HP処理は大丈夫のようです。

プレイヤーは攻撃を受けても、HPがある限りやられなくなりました。

HPを0にしてコンティニュー処理が出るか確認します。

コンティニュー処理

コンティニュー処理も大丈夫なようですね。

これでプレイヤーのHPの実装が出来ました。

まとめ

今回は
  • プレイヤーのHPを実装した
という事をやりました。

次回は、プレイヤーに残機を持たせられるような実装をしたいと思います。

では、また次回!

コメント