Contents
【リズムSTG制作】の動画内で表示されるコード
サムネイルをクリックすると、Youtubeの動画ページに移動します。
#2:音楽と同期したアニメーションを作ってみる
MIDI Animation Trackを使用して、音楽と同期したアニメーションを作成します。

manifest.json
{
"scopedRegistries": [
{
"name": "Keijiro",
"url": "https://registry.npmjs.com",
"scopes": [ "jp.keijiro" ]
}
],
"dependencies": {
"jp.keijiro.klak.timeline.midi": "1.0.5",
...
#3:音楽に合わせてオブジェクトを生成してみる
MIDI Signal ReceiverでNote On Eventにオブジェクトを生成する関数を登録します。

ノートを生成
// 生成するノート(1)
[SerializeField] GameObject noteRed;
// 生成するノート(2)
[SerializeField] GameObject noteYel;
public void NoteOnC()
{
// C(ド)の音でノートを生成
Instantiate(noteRed, new Vector2(7, 3), transform.rotation);
}
public void NoteOnD()
{
// D(レ)の音でノートを生成
Instantiate(noteYel, new Vector2(7, 2), transform.rotation);
}
ノートの移動と削除
// ノートの移動速度
[SerializeField] float speed;
void Update()
{
// 左に移動
transform.position += Vector3.left * speed * Time.deltaTime;
}
void OnTriggerExit2D(Collider2D col)
{
// 判定ラインを通過したら
if (col.CompareTag("Judge"))
{
// 爆発エフェクト
// Explosion();
// 削除
Destroy(gameObject);
}
}
#5:ランダム要素の追加
乱数で敵ユニットの配置や行動が毎回変化するようにします。

ランダムに敵弾を選択
// 敵弾
[SerializeField] GameObject[] bullets;
void Shot()
{
// 配列の中からランダムに選択する
int index = Random.Range(0, bullets.Length);
// 乱数で選ばれた敵弾を生成
Instantiate(bullets[index], transform.position, Quaternion.identity);
}
void Initialize()
{
// 現在時刻のミリ秒でシード値を設定
Random.InitState(DateTime.Now.Millisecond);
}
配列のシャッフル
// 生成するノート
[SerializeField] GameObject noteRed;
// 敵ユニットの生成位置
Vector2[] array = new [] {new Vector2(7, 3), new Vector2(7, 2), ... };
void Start()
{
// 並べ替えを実行
Shuffle(array);
}
// 配列(Vector2型)のシャッフル
void Shuffle (Vector2[] deck)
{
for (int i = 0; i < deck.Length; i++)
{
var temp = deck[i];
int randomIndex = Random.Range(i, deck.Length);
deck[i] = deck[randomIndex];
deck[randomIndex] = temp;
}
}
public void NoteOnC()
{
// シャッフル後の配列で敵ユニットを生成
Instantiate(noteRed, array[0], transform.rotation);
}
#6:プレイヤーのカスタマイズ
Sprite Library Assetを使用して、プレイヤーが乗っている箒をカスタマイズできるようにします。

ドロップダウンでパーツを選択
using TMPro;
using UnityEngine.U2D.Animation;
--------------------------------
SpriteResolver spriteResolver1;
// ドロップダウン
[SerializeField] TMP_Dropdown dropdown1;
void Start()
{
// SpriteResolver取得
spriteResolver1 = GameObject.FindWithTag("Broom1").GetComponent<SpriteResolver>();
}
public void CustomParts1()
{
// 一番上の選択肢が選ばれたら
if (dropdown1.value == 0)
{
// 選択肢のスプライトに切り替える
spriteResolver1.SetCategoryAndLabel("broom1", "broom_1A");
// 二番目の選択肢が選ばれたら
} else if (dropdown1.value == 1) {
...
}
}
#7:オブジェクトプール
Unityの標準機能に追加されたオブジェクトプールを使用して、プレイヤーのショットを管理します。

ObjectPool
using UnityEngine;
using UnityEngine.Pool;
-------------------------
// オブジェクトプールで管理するアイテム
[SerializeField] GameObject playerBullet;
// ショットを管理するプール
private ObjectPool<GameObject> bulletPool;
void Awake()
{
bulletPool = new ObjectPool<GameObject>(
CreatePoolObject,
OnGetFromPool,
OnReleaseToPool,
OnDestroyPoolObject,
collectionCheck: true,
defaultCapacity: 5,
maxSize: 10
);
}
private GameObject CreatePoolObject()
{
// プレイヤーのショットを生成
return Instantiate(playerBullet);
}
private void OnGetFromPool(GameObject target)
{
// オブジェクトを表示
target.SetActive(true);
}
private void OnReleaseToPool(GameObject target)
{
// オブジェクトを非表示
target.SetActive(false);
}
private void OnDestroyPoolObject(GameObject target)
{
// オブジェクトを破棄
Destroy(target);
}
public GameObject GetPoolBullet(Vector3 shotPos)
{
// プールからショットを取り出す
GameObject obj = bulletPool.Get();
obj.transform.position = shotPos;
return obj;
}
public void ReleaseBullet(GameObject obj)
{
// ショットをプールに戻す
bulletPool.Release(obj);
}
プールから取り出す・返却するサンプル
[SerializeField] Manager manager;
Vector3 shotPosition;
void Update()
{
// ショットキーが入力されている場合
if (fire.ReadValue<float>() > 0)
{
// プールから取り出す
manager.GetPoolBullet(shotPosition);
}
}
--------------------------------------------
// Releaseエリアに接触したらプールに返却
void OnTriggerEnter2D(Collider2D col)
{
manager.ReleaseBullet(col.gameObject);
}
#8:スワイプでプレイヤーを操作
Input Systemを使用して、スワイプ操作を実装します。

スワイプ操作のサンプル
using UnityEngine;
using UnityEngine.InputSystem;
-------------------------------
// スワイプの開始位置
Vector2 startPos;
// 現在位置
Vector2 currentPos;
// プレイヤーの移動方向
Vector2 direction;
// 移動量の調整
float adjustValue;
// ワールド座標に変換するカメラ
Camera cam;
void Start()
{
cam = Camera.main;
}
void Update()
{
// 画面に触れた瞬間
if (Pointer.current.press.wasPressedThisFrame)
{
startPos = Pointer.current.position.ReadValue();
}
// 画面に触れている間
if (Pointer.current.press.isPressed)
{
currentPos = Pointer.current.position.ReadValue();
// スクリーン座標をワールド座標に変換
var worldPosS = cam.ScreenToWorldPoint(startPos);
var worldPosC = cam.ScreenToWorldPoint(currentPos);
var vector = worldPosC - worldPosS;
// スワイプ量に応じてプレイヤーを移動させる
direction = new Vector2(vector.x, vector.y);
direction *= adjustValue;
// ベクトルの起点を更新
startPos = currentPos;
}
// 画面から指が離れたら
if (Pointer.current.press.wasReleasedThisFrame)
{
// プレイヤーを停止
direction = Vector2.zero;
}
// プレイヤーを移動させる処理
transform.position += direction * speed * Time.deltaTime;
}
#9:コンボ数を表示する
コンボ数を表示するUIを作成します。

コンボ数を表示(シンプル)
using UnityEngine;
using TMPro;
------------------------------
// コンボ数を表示するCanvasGroup
[SerializeField] CanvasGroup grCombo;
[SerializeField] TextMeshProUGUI comboText;
// コンボ数
private int combo;
// 経過時間
private float elapsedTime;
// コンボ数を表示する時間
private float displayTime = 1.0f;
// コンボ数が表示されたか
private bool isCombo;
void Update()
{
// コンボ数が表示されたら計測開始
if (isCombo == true)
{
elapsedTime += Time.deltaTime;
// 一定時間経過したらコンボ数を非表示
if (elapsedTime >= displayTime)
{
grCombo.alpha = 0f;
isCombo = false;
}
}
}
public void ShowCombo()
{
// コンボ数をセットして表示
comboText.SetText("{0}", combo);
grCombo.alpha = 1f;
// 経過時間をリセットして計測開始
elapsedTime = 0f;
isCombo = true;
}
コンボ数を表示(アニメーションあり)
using UnityEngine;
using DG.Tweening;
using TMPro;
------------------------------
// コンボ数を表示するCanvasGroup
[SerializeField] CanvasGroup grCombo;
[SerializeField] TextMeshProUGUI comboText;
// テキストのRectTransform
[SerializeField] RectTransform rectCombo;
// コンボ数
private int combo;
// 通常時のアニメーション
Sequence sequence1;
// 特定のコンボ数で再生するアニメーション
Sequence sequence2;
void Start()
{
// 通常時のアニメーション
sequence1 = DOTween.Sequence()
.Append(grCombo.DOFade(1f, 0.2f))
.Join(rectCombo.DOScale(new Vector3(1.8f, 1.8f, 1.0f), 0.2f))
// コンボ数を表示する時間
.AppendInterval(0.4f)
// フェードアウト
.Append(grCombo.DOFade(0f, 0.4f))
// アニメーションを再利用する
.Pause().SetAutoKill(false).SetLink(gameObject);
// 5Comboごとに再生するアニメーション
sequence2 = DOTween.Sequence()
.Append(grCombo.DOFade(1f, 0.2f))
.Join(rectCombo.DOScale(new Vector3(2.5f, 2.5f, 1.0f), 0.2f))
// 拡大しながらフェードアウト
.Append(grCombo.DOFade(0f, 0.4f))
.Join(rectCombo.DOScale(new Vector3(4.0f, 4.0f, 1.0f), 0.4f))
.Pause().SetAutoKill(false).SetLink(gameObject);
}
public void ShowComboAnim()
{
// コンボ数をセットして初期化
comboText.SetText("{0}", combo);
rectCombo.localScale = Vector3.one;
// 5Comboごとのアニメーション
if (combo % 5 == 0)
{
// ひとつ前のアニメーションが再生中であればスキップ
if (sequence1.IsPlaying()) sequence1.Complete();
// アニメーション再生
sequence2.Restart();
// 通常時のアニメーション
} else {
if (sequence2.IsPlaying()) sequence2.Complete();
sequence1.Restart();
}
}
#10:スコアのカウントアップアニメーション
DOTweenでカウントアップアニメーションを2種類作成します。

カウントアップ(シンプル)
using UnityEngine;
using DG.Tweening;
using TMPro;
------------------------------
// スコアを表示するText
[SerializeField] TextMeshProUGUI scoreText;
// スコア
private int score;
// 加算前のスコア
private int previousValue;
// カウントアップ中か
private bool isCountUp;
// カウントアップアニメーション
Sequence sequence;
void Update()
{
// カウントアップのアニメーション中であれば
if (isCountUp == true)
{
// スコア表示を更新する(0埋め)
scoreText.SetText("{0:000000}", previousValue);
}
}
public void AddPoint(int point)
{
// 得点が加算される前の値
previousValue = score;
// スコアを更新
score += point;
// アニメーションが再生中であればスキップ
if (isCountUp == true) sequence.Kill(true);
// カウントアップアニメーション開始
CountUpAnim();
}
void CountUpAnim()
{
isCountUp = true;
sequence = DOTween.Sequence()
.Append(DOTween.To(() => previousValue, num => previousValue = num, score, 0.5f))
// 少し待機してから
.AppendInterval(0.1f)
// スコア表示の更新を停止
.AppendCallback(() => isCountUp = false);
}
カウントアップ(縦回転)
using UnityEngine;
using DG.Tweening;
using TMPro;
------------------------------
// スコア
private int score;
// アニメーションが再生中か
private bool isCountUp;
// スコアの各桁の数字
private int[] digits;
// 数字(画像)の位置
[SerializeField] RectTransform rectScore1;
[SerializeField] RectTransform rectScore10;
[SerializeField] RectTransform rectScore100;
[SerializeField] RectTransform rectScore1k;
[SerializeField] RectTransform rectScore10k;
[SerializeField] RectTransform rectScore100k;
private int[] GetAllDigits(int num)
{
// スコアは最大6桁
digits = new int[6];
// 上限を超えている場合は何もしない
if (num >= 1000000) return digits;
// すべての桁の数字を取り出す
for (int i = 0; num > 0; i++)
{
int digit = num % 10;
digits[i] = digit;
num /= 10;
}
return digits;
}
public void CountUpAnim2()
{
// スコアの各桁の数字を取り出す
GetAllDigits(score);
if (isCountUp == false)
{
isCountUp = true;
// 一の位のアニメーション
rectScore1.DOAnchorPosY(239f, 2.0f)
.OnComplete(() => {
// アニメーションが完了したら初期位置に戻す
rectScore1.localPosition = new Vector3(89f, -241f, 0f);
isCountUp = false;
});
// 十の位のアニメーション
rectScore10.DOAnchorPosY(239f, 1.5f)
.OnComplete(() => rectScore10.localPosition = new Vector3(53f, -241f, 0f));
}
// 百の位は更新頻度が高いので、スキップ処理で対応
rectScore100.DOKill(true);
// 百の位の数字
switch (digits[2])
{
// 数字が表示される位置まで移動
case 0:
rectScore100.DOAnchorPosY(239f, 1f)
// 0の時だけはアニメーション完了後に初期位置に戻す
.OnComplete(() => rectScore100.localPosition = new Vector3(17f, -241f, 0f));
break;
case 1:
rectScore100.DOAnchorPosY(-193f, 1f);
break;
case 2:
rectScore100.DOAnchorPosY(-145f, 1f);
break;
...
}
// スコアが1000点未満であれば終了
if (score < 1000) return;
// 千の位の数字
switch (digits[3])
{
// 数字が表示される位置まで移動
case 0:
// 千の位に変化がなければアニメーションなし
if (rectScore1k.localPosition.y == -241f) break;
rectScore1k.DOAnchorPosY(239f, 1f)
.OnComplete(() => rectScore1k.localPosition = new Vector3(-19f, -241f, 0f));
break;
case 1:
// 千の位が1だった場合
if (rectScore1k.localPosition.y == -193f) break;
rectScore1k.DOAnchorPosY(-193f, 1f);
break;
...
}
// 一万の位の処理(digits[4])
// 十万の位の処理(digits[5])
}
