ブロック崩しゲームの制作、第4回です。ちょこっと手直しして「ハイ、完成」となるはずでしたが、かなり大幅に作り直すことになってシマウマした。だって初心者なんだもーん。
- Mac mini (M1, 2020)
- Unity 2022.3.36f1
ハイスコアを保存する
前回せっかくスコア機能を実装したので、ハイスコアを保存しておいてタイトル画面に表示しちゃおうぜというわけです。
データの保存はUnityのPlayerPrefsという機能を使います。Unityの使うべきでない機能第1位なんて言われていますが、初心者なので使っちゃいます(なんでも初心者で済まそうとしている)。
TitleSceneにハイスコアを表示するためのオブジェクトを追加。オブジェクト名は「HighScoreText」です。こんな感じになりました。
なんか以前より画面が派手になってるような気が・・・。
GameManager.csを修正します。
public class GameManager : MonoBehaviour
{
...省略...
// ゲームオーバー
public void GameOver()
{
...省略...
SaveHighScore(); // ハイスコアを保存
}
// ゲームクリア
public void GameClear()
{
...省略...
SaveHighScore(); // ハイスコアを保存
}
// ハイスコアの保存
public void SaveHighScore()
{
int highScore = PlayerPrefs.GetInt("HighScore", 0); // ハイスコアをロード、保存されていなければ0を返す
if (score > highScore) // スコアがハイスコアより大きければ
{
PlayerPrefs.SetInt("HighScore", score); // スコアをハイスコアとして保存
}
}
// ハイスコアの取得
public string GetHighScore()
{
int highScore = PlayerPrefs.GetInt("HighScore", 0); // ハイスコアを取得
string formattedHighScore = highScore.ToString("D8"); // スコアを8桁の文字列に変換
return formattedHighScore;
}
}
PlayerPrefs.GetInt()
は保存されている整数型のデータを読み込む関数。第1引数はデータのキー、第2引数は保存データが存在しなかった場合の初期値です。
PlayerPrefs.SetInt()
は整数型のデータを保存する関数。第1引数はキーで第2引数は値です。
TitleManager.csを修正します。
...省略...
using TMPro;
public class TitleManager : MonoBehaviour
{
public TextMeshProUGUI HighScoreText; // HighScoreTextオブジェクト
void Start()
{
int highScore = PlayerPrefs.GetInt("HighScore", 0); // ハイスコアを取得
string formattedHighScore = highScore.ToString("D8"); // スコアを8桁の文字列に変換
HighScoreText.text = formattedHighScore; // ハイスコアテキストに設定
}
...省略...
}
プレイヤー(パドル)の形を変える
パドルにボールが当たって跳ね返るときに、物理法則通りだと入射角と反射角が等しくなるのでゲームがつまらなくなります。
この問題を解消するためによくやるのが、当たるパドルの位置によって反射角を変えるという手法なのですが、それだとスクリプトを書くだけになってしまうので面白くないです。とういわけで、パドルの形をおまんじゅう型にしたいと思います。
まず、おまんじゅう型のパドルの画像を透過PNGで作ります。
これをAssets/Imagesフォルダにインポートします。
Playerオブジェクトのインスペクターの「Sprite Renderer > Sprite」に、この画像をドラッグ&ドロップします。
Playerオブジェクトの形が変わったので、コライダーを変更します。「Box Collider 2D」を削除して、「Polygon Collider 2D」を追加します。
おわかりいただけるだろうか。緑色のラインがコライダーですね。このように複雑な形のオブジェクトで当たり判定をするときにはPolygon Colliderを使います。
これで、ボールが当たる位置によって反射角をコントロールできるようになりました。
ライフ制の導入
ボールを1個失ったらゲームオーバーでは厳しすぎるので、ボールを5個持てるようにします。
まず、ライフ表示用のオブジェクトを作成します。
画面の左上に配置して、名前を「LifeText」にしました。
BallController.csを修正します。
public class BallController : MonoBehaviour
{
...省略...
void OnCollisionEnter2D(Collision2D collision)
{
...省略...
if (collision.gameObject.name == "WallBottom") // ボールがWallBottomに当たった場合
{
...省略...
// gameManager.GameOver(); <- この行を削除
gameManager.OnBallLost(); // ボールが失われたことをGameManagerに通知
}
}
}
ボールが下の壁に当たったときに、ゲームオーバーにする処理を削除して、GameManager.csのOnBallLost()
関数を実行するように修正しました。
GameManager.csを修正します。
public class GameManager : MonoBehaviour
{
...省略...
public TextMeshProUGUI lifeText; // ライフを表示するテキスト
public int ballCount = 5; // 残りボール数
public GameObject ballPrefab; // ボールのPrefab
public Transform ballSpawnPoint; // ボールの生成位置
void Start()
{
SpawnNewBall(); // 初期ボールを生成
...省略...
}
// ボールを生成
void SpawnNewBall()
{
Instantiate(ballPrefab, ballSpawnPoint.position, Quaternion.identity);
ResetCombo(); // コンボをリセット
}
// ボールが失われたときの処理
public void OnBallLost()
{
ballCount--; // ボールの数を減らす
lifeText.text = (ballCount - 1).ToString(); // ライフの表示
if (ballCount > 0) // 残りボールがあれば
{
SpawnNewBall(); // 新しいボールを生成
}
else // 残りボールがなければ
{
GameOver(); // ゲームオーバー
}
}
...省略...
}
ボールが失われたら、ボールの数を減らし、ライフの表示を更新します。残りボールがあれば新しいボールをプレハブから生成し、残りボールがなければゲームオーバーになります。
Ballのプレハブを作ります。ヒエラルキーのBallオブジェクトをAssets/Prefabsフォルダにドラッグ&ドロップするだけです。
で、GameManager.csを見ればわかる通り、ゲーム開始時の最初のボールもプレハブから作成するので、ヒエラルキーにあるBallオブジェクトは削除しちゃいます。
GameManagerのインスペクターで、Life Text、Ball Prefab、Ball Spawn Pointにオブジェクトをアサインします。
Ball Spawn PointにはBallのプレハブをドラッグ&ドロップすればOKです。
一時停止機能をつける
ゲームの途中で、どうしてもうんこが我慢できないときってありますよね。そんなときのために一時停止機能をつけましょう。
ポーズ(
)とプレイ( )の画像を用意して、Assets/Imagesフォルダに入れておきます。Canvasの上で右クリック「UI > Button – TextMeshPro」を選択してオブジェクトを作成します。名前を「PauseButton」に変更します。
PauseButtonのインスペクターで「Image > Source Image」にポーズ画像(pause.png)をアサインします。「Preserve Aspect」にチェックを入れると画像のアスペクト比が固定されます。
Rect Transformをゴニョゴニョして、PauseButtonの大きさや配置を決めます。
C#スクリプトPauseManager.csを作成してPauseButtonオブジェクトにアタッチします。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class PauseManager : MonoBehaviour
{
public bool isPaused = false; // 一時停止フラグ
public Sprite pauseSprite; // ポーズ画像
public Sprite playSprite; // プレイ画像
private Image buttonImage;
void Start()
{
buttonImage = GetComponent<Image>();
}
// ボタンの画像を切り替える
public void ChangeSprite()
{
buttonImage.sprite = buttonImage.sprite == pauseSprite ? playSprite : pauseSprite;
}
// ゲームの一時停止・再生を切り替える
public void PauseGame()
{
isPaused = !isPaused; // フラグ反転
Time.timeScale = isPaused ? 0 : 1; // フラグに応じてゲーム時間の切り替え
ChangeSprite(); // ボタン画像切り替え
}
}
PauseButtonのインスペクターで「Pause Sprite」と「Play Sprite」にそれぞれポーズ画像とプレイ画像をアサインします。
ポーズボタンにイベントを設定します。
PauseButtonのインスペークターの「Button > On Click ()」で「+」をクリックし、以下のようにPauseGame()
関数を設定します。
これで、ボタンがクリックされるとPauseGame()
が実行されるようになりました。
設定画面を作る
設定といっても、BGMとSEの音量設定だけです。簡単だと思っていました。
まず、タイトル画面に設定を開くためのボタンを作って・・・と思っていたのですが断念しました。というのは、今回タイトル画面とゲーム画面を別々のシーンとして作っています。Unityでは別のシーンのオブジェクトを参照することができないんですね。無理すればできそうなのですが今回は遠慮して、ゲームシーンで設定をするようにしたいと思います。
上で一時停止機能をつけたので、一時停止したときに設定画面が表示されるようにしましょう。あら、スマート。
まず、一時停止したときに表示される設定画面のオブジェクトを作ります。パネルとテキストとスライダーで作りました。初めてのスライダー。
BGMPlayer.csの名前を「SoundManager.cs」に変更しました(もはやBGMを再生するためだけのスクリプトではないので)。そして修正します。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class SoundManager : MonoBehaviour
{
public Slider bgmSlider;
public Slider seSlider;
public TextMeshProUGUI bgmText;
public TextMeshProUGUI seText;
public AudioSource bgmAudioSource; // BGMのAudioSource
public AudioSource seAudioSource; // SEのAudioSource
void Start()
{
...省略...
float savedBGMVolume = PlayerPrefs.GetFloat("BGMVolume", 0.75f); // 保存されたBGM音量を読み込む
float savedSEVolume = PlayerPrefs.GetFloat("SEVolume", 0.75f); // 保存されたSE音量を読み込む
bgmSlider.value = savedBGMVolume; // スライダーにBGM音量を設定
seSlider.value = savedSEVolume; // スライダーにSE音量を設定
UpdateVolume();
}
public void UpdateVolume()
{
bgmAudioSource.volume = bgmSlider.value; // BGM音量を設定
seAudioSource.volume = seSlider.value; // SE音量を設定
bgmText.text = "BGM " + (int)(bgmSlider.value * 100) + "%"; // BGM音量テキストを設定
seText.text = "SE " + (int)(seSlider.value * 100) + "%"; // SE音量テキストを設定
PlayerPrefs.SetFloat("BGMVolume", bgmSlider.value); // BGM音量を保存
PlayerPrefs.SetFloat("SEVolume", seSlider.value); // SE音量を保存
}
}
BGMManagerのインスペクターでBGMとSEのスライダー、テキスト、AudioSourceをアサインします。
スライダーの On Value Changed イベントを設定します。
各スライダーのインスペクターの「Slider > On Value Changed」で「+」をクリックしてイベントを追加。以下のようにSoundManager.UpdateVolume()関数をアサインします。
以上で、BGMとSEの音量設定ができるようになるはずだったのですが、なぜかSEの音量が変更できません。BGMのほうはうまくいっています。
SEの鳴らし方を見直す
なぜSEの音量設定が効かないのか。悩み続けて早3年が経過しました(嘘)。
結論を申し上げると、SE、つまりボールが他のオブジェクトに当たったときに鳴る効果音はBallController.csで制御しています。このスクリプトではBallオブジェクトに追加したAudio Sourceコンポーネントを参照しています。
そして、ライフ制を導入したときに、ボールはプレハブから生成されるようになりました。プレハブから生成されるボールが持つコンポーネントはそれぞれ別個のコンポーネントなのです。新しいボールが生成されたら、そのボールが持つAudio Sourceコンポーネントも新しくなるので、参照し直さなければならないのです。
やだー、面倒くさーい。というわけで、BallオブジェクトにAudio Sourceを持たせるのをやめて、SEManagerというAudio Sourceオブジェクトを作って、こいつにSEを任せることにしました。
これでうまくいきました。わーい。
スマホで操作できるようにする
イマドキの若者はパソコンが使えないそうです。なんでもスマホでやるんだってさ。というわけで、若者にも遊んでもらいたいならばスマホに対応するべきです。
PlayerController.csを修正します。Update()
の中身をごっそり削除して以下のように書き換えます。
public class PlayerController : MonoBehaviour
{
...省略...
void Update()
{
// 入力があれば移動可能にする
if (Input.GetMouseButtonDown(0) || (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began))
{
canMove = true;
}
// PCとスマホ両方の移動処理
if (canMove)
{
Vector3 inputPosition;
if (Input.touchCount > 0)
{
// スマホのタッチ位置を取得
Touch touch = Input.GetTouch(0);
inputPosition = touch.position;
}
else
{
// PCのマウス位置を取得
inputPosition = Input.mousePosition;
}
// スクリーン座標をワールド座標に変換
Vector3 targetPosition = Camera.main.ScreenToWorldPoint(inputPosition);
targetPosition = new Vector3(targetPosition.x, transform.position.y, transform.position.z);
// プレイヤーを目標位置へ移動
transform.position = Vector3.Lerp(transform.position, targetPosition, speed * Time.deltaTime);
// プレイヤーの位置を制限
Vector3 newPosition = transform.position;
newPosition.x = Mathf.Clamp(newPosition.x, minX, maxX);
transform.position = newPosition;
}
}
...省略...
}
BallController.csを修正します。Update()
の中身をごっそり削除して以下のように書き換えます。また、LaunchBall()
関数を追加しました。
public class BallController : MonoBehaviour
{
...省略...
void Update()
{
// ボールがまだ発射されていない場合
if (!isLaunched)
{
// PCのマウスクリック
if (Input.GetMouseButtonDown(0))
{
LaunchBall(Camera.main.ScreenToWorldPoint(Input.mousePosition));
}
// スマホのタッチ入力
else if (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began)
{
LaunchBall(Camera.main.ScreenToWorldPoint(Input.GetTouch(0).position));
}
}
}
// ボールを発射する
private void LaunchBall(Vector2 targetPosition)
{
Vector2 direction = (targetPosition - (Vector2)transform.position).normalized; // 発射方向を計算して正規化
rb.velocity = direction * initialSpeed; // ボールを発射
isLaunched = true; // 発射フラグを設定
if (guideMessageText) guideMessageText.SetActive(false); // ガイドメッセージを非表示
}
...省略...
}
画面サイズが変わると表示が崩れる問題
ゲームができ上がった(と思った)ので、ビルドしてプレイしてみたところ、愕然としてしまいました。画面サイズを変えると表示が崩れまくるのです。
というわけで、ゲームの画面サイズを変更してもアスペクト比が固定されるようにします。
Canvasのインスペクターで「Canvas Scaler > UI Scale Mode」を「Scale With Screen Size」に変更します。各モードの違いは次のとおりです。
Constant Pixel Size | 画面サイズが変わっても、UI要素のピクセルサイズは常に一定に保たれる。 高解像度でも低解像度でも、UI要素の見た目を一定にしたい場合に使う。 |
---|---|
Scale With Screen Size | 画面サイズに合わせて、UI要素が拡大縮小される。 様々なサイズの画面に対応した、レスポンシブなUIを作成したい場合に使う。 |
Constant Physical Size | 物理的な単位(cmなど)でUI要素のサイズを指定し、実際のデバイスの物理サイズに合わせてスケーリングされる。 VRやARなど、現実世界との連携を考慮したUIを作成する場合に使う。 |
「Reference Resolution」(参考解像度)を設定します。今回はアスペクト比を 9:16 にしたいので、X: 540、Y: 960 にしました。
「Screen Match Mode」を「Expand」にします。各モードの違いは次のとおりです。
Match Width Or Height | Canvasの幅または高さを画面の幅または高さに合わせるように調整する。 |
---|---|
Expand | Canvasが画面全体を覆うように拡大される。 |
Shrink | Canvasが画面内に収まるように縮小される。 |
Canvasの下に空のオブジェクトを作り、名前を「AspectRatioFixer」にします。名前は何でもいいです。
AspectRatioFixerのインスペクターで「Rect Transform」の「Width」を540に、「Height」を960にします。
ヒエラルキーでAspectRatioFixerの下にすべてのUIオブジェクトを入れます。
以上で、画面サイズを変更してもアスペクト比が固定され、表示が崩れなくなりました。
UIオブジェクトを手前に表示する
もうひとつ気になることがありまして、なぜかUIオブジェクト(スコアやライフの表示)が非UIオブジェクト(ブロック、ボールなど)の後ろに表示されてしまっています。普通、UIって最前面に表示されるんじゃないの?
これは「Canvas > Render Mode」を「Screen Space – Overlay」にすることで解決しました。
前回、カメラの表示領域とキャンバスの表示領域が一致していないのがイヤだーと言って「Render Mode」を「Screen Space – Camera」に変更したんですよね。余計なことでした。
さいごに
以上で完成しました。いえーい。のつもりだったのですが、もうちょっと要素を追加したくなったので、開発を続けます。次回はアイテムを導入します。ブロックを壊したら落ちてくるアレですね。
でわでわ
コメント