【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る　パート3

# はじめに

この記事は、「[【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る　パート2](https://qiita.com/kousaku-maron/items/19aa693dacc40cfa9964)」の続きです。

読んでない方は、先にパート1、パート2から読んでください。

[【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る　パート1](https://qiita.com/kousaku-maron/items/42f801df2b87e37b1d91)

[【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る　パート2](https://qiita.com/kousaku-maron/items/19aa693dacc40cfa9964)

今回でラストです。
**UIの精錬(タイトル表示、スコア表示など...)、スコアのロジックを実装していきます**。

# 前回のおさらい

パート2では**曲の再生、ノーツをタイミングよく弾いたかチェックするロジック、ノーツを弾いたときのエフェクトを実装**しました。

DキーとKキーでノーツは弾けるようにし、エフェクトとして「不可」と「良」の表示、「ドン」と「カッ」の効果音を鳴らせるようにしましたね。

忘れてしまった方は、さらっとパート2を復習しましょう。

# UIの準備

タイトル、コンボ、スコアを表示するテキスト、コンボとスコアを表示する背景を設置していきます。

<img width="848" alt="スクリーンショット 2019-02-16 11.02.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/189146/d3598759-681b-bd07-12d8-f0c0ace089bf.png">

### キャンバスの設定

まずキャンバスの設定を変更します。**ディスプレイの解像度の違いでキャンバスに設置したUIObjectの大きさがかわらないように、UI Scale ModeをScale With Screen Sizeにします**。

※_パート1でPlayとSetChartボタンを作成しているので、UIObjectはあるはずです。_

<img width="416" alt="スクリーンショット 2019-02-16 11.12.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/189146/e4475906-2470-41b4-c28a-ac45f53e6d9e.png">

### タイトルのテキストを設置

ヒエラルキー内でTitleTextという名前でTextObjectを作成しましょう。

アンカーを右上になるように設定してから位置を微調整しましょう。

<img width="1440" alt="スクリーンショット 2019-02-16 11.15.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/189146/b05d831b-0fcd-fe85-ea84-751cfe8a4d2f.png">

### スコア、コンボのテキストと背景を設置

ヒエラルキー内でScoreAreaという名前で空のGameObjectを作成しましょう。
その子要素として、背景画像を入れるためのImageObject(TaikoBackground、Taiko)とComboText、ScoreTextという名前でTextObjectを作成しましょう。

ScoreAreaのアンカーを左中央になるように設定してから、子要素含め位置を微調整しましょう。

<img width="1440" alt="スクリーンショット 2019-02-16 11.19.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/189146/6c2cb7e5-c378-0e5a-8c6d-c73a93468bb7.png">

# 〇〇の達人のスコアの仕組み

### モードについて

〇〇の達人のスコアの集計方法として、通常モードど真打モードというものがあります。
通常モードはコンボ重視の集計方法、真打モードは精度重視の集計方法ならしい。

今回は、通常モードを採用します。

通常モードでは、**天井点(いわゆる満点)と言われる点数が存在します。通常の配点方式で天井点は105万点だそうです**。**はじめは配点が少ないが、コンボ数が増える配点が増加していきます**。

### 通常モードの配点方式を数式化

ここから先は数学の話になるため、理解しなくてもいいってかたは結論のみで大丈夫です。

※_理解したい方、難しくはないので心配はいりませんよ 笑_

配点は簡単な関数で表現できます。

`y = a * x + b`

|変数|説明|
|----|----|
|y|配点|
|x|コンボ補正(後で説明)|
|a|公差|
|b|初項|

**コンボ補正は、コンボ数に応じて増加していきます**。

|コンボ数|コンボ補正|
|----|----|
|1-9コンボ|0|
|10-29コンボ|1|
|30-49コンボ|2|
|50-99コンボ|4|
|100コンボ以上|8|

つまり、**初項50の公差30の設定において、14コンボ目でノーツを弾いた場合、コンボ補正が1となるので加算されるスコアは`30 * 1 + 50`で`80`となる**。

次に、天井点との関係を考慮して初項と公差を決める。

**天井点はいわゆる満点なので、フルコンボ時のスコアが105万点になるに初項と公差を設定する**。

コンボ補正が定数なら楽なのだが、変数なので場合分けが必要です。
**曲で出現するノーツの総数をmとします**。必要に応じて初項はこちらで任意に決めることにします。

**（ⅰ）mが9以下の時**

`1050000 = am`

つまり、初項は`1050000/m`、公差は使わないのでなんでもいい。

**（ⅱ）mが10以上29以下の時**

`1050000 = am + (m - 9)b`

初項を`300`とすると、公差は`(1050000 - 300m)/(m - 9)`になります。

**（ⅲ）mが30以上49以下の時**

`1050000 = am + (29 - 9)b + (m - 29)b * 2`
`1050000 = am + 2 * (m - 19)b`

初項を`300`とすると、公差は`(1050000 - 300m)/(2 * (m - 19))`になります。

**（ⅳ）mが50以上99以下の時**

`1050000 = am + (29 - 9)b + (49 - 29)b * 2 + (m - 49)b * 4`
`1050000 = am + 4 * (m - 34)b`

初項を300とすると、公差は`(1050000 - 300m)/(4 * (m - 39))`となります。

**（ⅴ）mが100以上の時**

`1050000 = am + (29 - 9)b + (49 - 29)b * 2 + (99 - 49)b * 4 + (m - 99)b * 8`
`1050000 = am + 4 * (3m - 232)b`

初項を300とすると、公差は`(1050000 - 300m)/(4 * (3m - 232))`となります。

### 結論

mを曲で出現するノーツの総数、aを初項、bを公差とする。
m >= 10 において、aは300とする。

```
m <= 9の時
  a = 1050000/m
  b = なんでもいい

10 <= m <= 29の時
  a = 300
  b = (1050000 - 300m)/(m - 9) 

30 <= m <= 49の時
  a = 300
  b = (1050000 - 300m)/(2 * (m - 19))

49 <= m <= 99の時
  a = 300
  b = (1050000 - 300m)/(4 * (m - 39))

100 <= mの時
  a = 300
  b = (1050000 - 300m)/(4 * (3m - 232))
```

Q.E.D

# スコアの実装

**GameManager(Script)でスコアの管理をさせます**。
CheckTimingIndexを基軸にして、コンボが続いているかを判断し、コンボ数、スコアの加算を制御させています。

```c#:GameManager.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers;

public class GameManager : MonoBehaviour {

  [SerializeField] string FilePath;
  [SerializeField] string ClipPath;

  [SerializeField] Button Play;
  [SerializeField] Button SetChart;
  [SerializeField] Text ScoreText; // 追加
  [SerializeField] Text ComboText; // 追加
  [SerializeField] Text TitleText; // 追加

  [SerializeField] GameObject Don;
  [SerializeField] GameObject Ka;

  [SerializeField] Transform SpawnPoint;
  [SerializeField] Transform BeatPoint;

  AudioSource Music;

  float PlayTime;
  float Distance;
  float During;
  bool isPlaying;
  int GoIndex;

  float CheckRange;
  float BeatRange;
  List<float> NoteTimings;

  float ComboCount; // 追加
  float Score; // 追加
  float ScoreFirstTerm; // 追加
  float ScoreTorerance; // 追加
  float ScoreCeilingPoint; // 追加
  int CheckTimingIndex; // 追加

  string Title;
  int BPM;
  List<GameObject> Notes;

  Subject<string> SoundEffectSubject = new Subject<string>();

  public IObservable<string> OnSoundEffect {
     get { return SoundEffectSubject; }
  }

  Subject<string> MessageEffectSubject = new Subject<string>();

  public IObservable<string> OnMessageEffect {
     get { return MessageEffectSubject; }
  }

  void OnEnable() {
    Music = this.GetComponent<AudioSource>();

    Distance = Math.Abs(BeatPoint.position.x - SpawnPoint.position.x);
    During = 2 * 1000;
    isPlaying = false;
    GoIndex = 0;

    CheckRange = 120;
    BeatRange = 80;

    ScoreCeilingPoint = 1050000;
    CheckTimingIndex = 0;

    Play.onClick
      .AsObservable()
      .Subscribe(_ => play());

    SetChart.onClick
      .AsObservable()
      .Subscribe(_ => loadChart());

    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Notes.Count > GoIndex)
      .Where(_ => Notes[GoIndex].GetComponent<NoteController>().getTiming() <= ((Time.time * 1000 - PlayTime) + During))
      .Subscribe(_ => {
        Notes[GoIndex].GetComponent<NoteController>().go(Distance, During);
        GoIndex++;
      });

    // 追加
    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Notes.Count > CheckTimingIndex)
      .Where(_ => NoteTimings[CheckTimingIndex] == -1)
      .Subscribe(_ => CheckTimingIndex++);

    // 追加
    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Notes.Count > CheckTimingIndex)
      .Where(_ => NoteTimings[CheckTimingIndex] != -1)
      .Where(_ => NoteTimings[CheckTimingIndex] < ((Time.time * 1000 - PlayTime) - CheckRange/2))
      .Subscribe(_ => {
        updateScore("failure");
        CheckTimingIndex++;
      });

    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Input.GetKeyDown(KeyCode.D))
      .Subscribe(_ => {
        beat("don", Time.time * 1000 - PlayTime);
        SoundEffectSubject.OnNext("don");
      });

    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Input.GetKeyDown(KeyCode.K))
      .Subscribe(_ => {
        beat("ka", Time.time * 1000 - PlayTime);
        SoundEffectSubject.OnNext("ka");
      });
  }

  void loadChart() {
    Notes = new List<GameObject>();
    NoteTimings = new List<float>();

    string jsonText = Resources.Load<TextAsset>(FilePath).ToString();
    Music.clip = (AudioClip)Resources.Load(ClipPath);

    JsonNode json = JsonNode.Parse(jsonText);
    Title = json["title"].Get<string>();
    BPM = int.Parse(json["bpm"].Get<string>());

    foreach(var note in json["notes"]) {
      string type = note["type"].Get<string>();
      float timing = float.Parse(note["timing"].Get<string>());

      GameObject Note;
      if (type == "don") {
        Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity);
      } else if (type == "ka") {
        Note = Instantiate(Ka, SpawnPoint.position, Quaternion.identity);
      } else {
        Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity); // default don
      }

      Note.GetComponent<NoteController>().setParameter(type, timing);

      Notes.Add(Note);
      NoteTimings.Add(timing);
    }

    TitleText.text = Title;  // 追加

    // 追加
    if(Notes.Count < 10) {
      ScoreFirstTerm = (float)Math.Round(ScoreCeilingPoint/Notes.Count);
      ScoreTorerance = 0;
    } else if(10 <= Notes.Count && Notes.Count < 30) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(Notes.Count - 9));
    } else if(30 <= Notes.Count && Notes.Count < 50) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(2 * (Notes.Count - 19)));
    } else if(50 <= Notes.Count && Notes.Count < 100) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(4 * (Notes.Count - 39)));
    } else {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(4 * (3 * Notes.Count - 232)));
    }
  }

  void play() {
    Music.Stop();
    Music.Play();
    PlayTime = Time.time * 1000; 
    isPlaying = true;
    Debug.Log("Game Start!");
  }

  void beat(string type, float timing) {
    float minDiff = -1;
    int minDiffIndex = -1;

    for (int i = 0; i < Notes.Count; i++) {
      if(NoteTimings[i] > 0) {
        float diff = Math.Abs(NoteTimings[i] - timing);
        if(minDiff == -1 || minDiff > diff) {
          minDiff = diff;
          minDiffIndex = i;
        }
      }
    }

    if(minDiff != -1 & minDiff < CheckRange) {
      if(minDiff < BeatRange & Notes[minDiffIndex].GetComponent<NoteController>().getType() == type) {
        NoteTimings[minDiffIndex] = -1;
        Notes[minDiffIndex].SetActive(false);

        MessageEffectSubject.OnNext("good");
        updateScore("good"); // 追加
        // Debug.Log("beat " + type + " success.");
      }
      else {
        NoteTimings[minDiffIndex] = -1;
        Notes[minDiffIndex].SetActive(false);

        MessageEffectSubject.OnNext("failure");
        updateScore("false"); // 追加
        // Debug.Log("beat " + type + " failure.");
      }
    }
    else {
      // Debug.Log("through");
    }
  }

  // 追加
  void updateScore(string result) {
    if(result == "good") {
      ComboCount++;
      
      float plusScore;
      if (ComboCount < 10) {
        plusScore = ScoreFirstTerm;
      }
      else if (10 <= ComboCount && ComboCount < 30) {
        plusScore = ScoreFirstTerm + ScoreTorerance;
      }
      else if (30 <= ComboCount && ComboCount < 50) {
        plusScore = ScoreFirstTerm + ScoreTorerance * 2;
      }
      else if (50 <= ComboCount && ComboCount < 100) {
        plusScore = ScoreFirstTerm + ScoreTorerance * 4;
      }
      else {
        plusScore = ScoreFirstTerm + ScoreTorerance * 8;
      }

      Score += plusScore;
    }
    else if (result == "failure") {
      ComboCount = 0;
    }
    else {
      ComboCount = 0; // default failure
    }

    ComboText.text = ComboCount.ToString();
    ScoreText.text = Score.ToString();
  }
}
```

**曲のタイトルとスコアとコンボ数を表示させるために、TitleText、ScoreText、ComboTextを作成する**。

TitleText ・・・ 曲のタイトルのTextObject
ScoreText ・・・ スコアのTextObject
ComboText ・・・ コンボのTextObject

**スコアとコンボ数を管理するために、ComboCount、Score、ScoreFirstTerm、ScoreTorerance、ScoreCelingPoint、CheckTimingIndexを作成する**。

ComboCount ・・・　コンボ数
Score ・・・ スコア
ScoreFirstTerm ・・・ スコア加算処理に必要な初項
ScoreTorerance ・・・ スコア加算処理に必要な公差
ScoreCeilingPoint ・・・ スコアの天井点
CheckTimingIndex ・・・ 弾くべきノーツのインデックス

ゲームマネージャー読み込み時に、天井点と弾くべきノーツのインデックスに初期値をセットする。

```c#
ScoreCeilingPoint = 1050000;
CheckTimingIndex = 0;
```

譜面読み込み時に、曲のタイトルを表示させ、**ノーツの総数がわかったタイミングで、初項と公差を先ほどの求めた計算式(〇〇の達人のスコアの仕組みで求めた式)で出し、セットしておく**。

```c#
void loadChart() {
    ...

    TitleText.text = Title;

    if(Notes.Count < 10) {
      ScoreFirstTerm = (float)Math.Round(ScoreCeilingPoint/Notes.Count);
      ScoreTorerance = 0;
    } else if(10 <= Notes.Count && Notes.Count < 30) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(Notes.Count - 9));
    } else if(30 <= Notes.Count && Notes.Count < 50) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(2 * (Notes.Count - 19)));
    } else if(50 <= Notes.Count && Notes.Count < 100) {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(4 * (Notes.Count - 39)));
    } else {
      ScoreFirstTerm = 300;
      ScoreTorerance = (float)Math.Floor((ScoreCeilingPoint - ScoreFirstTerm * Notes.Count)/(4 * (3 * Notes.Count - 232)));
    }
}
```

**コンボ数、スコアの更新を行うupdateScore関数を作成する**。
引数にノーツを弾いた時の結果を入れることで、コンボ数とスコアを計算している。

この関数を、**ノーツを弾いたタイミングとノーツを見逃したタイミングで発火させることでスコアシステムが完成します**。

```c#
void updateScore(string result) {
    if(result == "good") {
      ComboCount++;
      
      float plusScore;
      if (ComboCount < 10) {
        plusScore = ScoreFirstTerm;
      }
      else if (10 <= ComboCount && ComboCount < 30) {
        plusScore = ScoreFirstTerm + ScoreTorerance;
      }
      else if (30 <= ComboCount && ComboCount < 50) {
        plusScore = ScoreFirstTerm + ScoreTorerance * 2;
      }
      else if (50 <= ComboCount && ComboCount < 100) {
        plusScore = ScoreFirstTerm + ScoreTorerance * 4;
      }
      else {
        plusScore = ScoreFirstTerm + ScoreTorerance * 8;
      }

      Score += plusScore;
    }
    else if (result == "failure") {
      ComboCount = 0;
    }
    else {
      ComboCount = 0; // default failure
    }

    ComboText.text = ComboCount.ToString();
    ScoreText.text = Score.ToString();
}
```

ノーツを弾いたかチェックしているのはbeat関数なので、`good`と`failure`時、updateScore関数を発火させます。

```c#
void beat(string type, float timing) {
    ...

    if(minDiff != -1 & minDiff < CheckRange) {
      if(minDiff < BeatRange & Notes[minDiffIndex].GetComponent<NoteController>().getType() == type) {
        NoteTimings[minDiffIndex] = -1;
        Notes[minDiffIndex].SetActive(false);

        MessageEffectSubject.OnNext("good");
        updateScore("good"); // 発火
        // Debug.Log("beat " + type + " success.");
      }
      else {
        NoteTimings[minDiffIndex] = -1;
        Notes[minDiffIndex].SetActive(false);

        MessageEffectSubject.OnNext("failure");
        updateScore("false"); // 発火
        // Debug.Log("beat " + type + " failure.");
      }
    }
    else {
      // Debug.Log("through");
    }
 }
```

**ノーツを見逃したタイミングも`failure`として認識させないと、コンボが続いてしまいます**。

ノーツを見逃しているのかチェックさせるためにはCheckTimingIndexとNoteTimingsを使用します。

※_NoteTimingsはパート2で作成した、ノーツの弾かせるタイミングを入れたリストです。_

**ノーツの見逃しが確定する条件は、ノーツが弾いた判定処理つまりbeat関数が実行されておらず、ノーツを弾ける領域外に移動してしまったときです**。

上のオブザーバーでは、弾くべきノーツがbeat関数によって判定済みであれば、弾くべきノーツを次のノーツに更新しています。

下のオブザーバーでは、弾くべきノーツがbeat関数によって判定済みでなく、弾ける領域外に移動してしまった場合、updateScore関数を`failure`で発火させ、弾くべきノーツを次のノーツに更新しています。

```c#
this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Notes.Count > CheckTimingIndex)
      .Where(_ => NoteTimings[CheckTimingIndex] == -1)
      .Subscribe(_ => CheckTimingIndex++);

    this.UpdateAsObservable()
      .Where(_ => isPlaying)
      .Where(_ => Notes.Count > CheckTimingIndex)
      .Where(_ => NoteTimings[CheckTimingIndex] != -1)
      .Where(_ => NoteTimings[CheckTimingIndex] < ((Time.time * 1000 - PlayTime) - CheckRange/2))
      .Subscribe(_ => {
        updateScore("failure");
        CheckTimingIndex++;
      });
```

**GameManager(Script)にUIオブジェクトをドラッグアンドドロップでセットしましょう**。

<img width="413" alt="スクリーンショット 2019-02-16 15.09.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/189146/e6596725-0b28-9b8f-8456-c3bbfb13752a.png">

# 動きを確認する

ここまで実装できれば、動かしてみましょう。
スコアとコンボ数が表示されているはずです。

![taiko03.mov.gif](https://qiita-image-store.s3.amazonaws.com/0/189146/880f3a5e-11d5-fbcf-d922-2cf4b4de8025.gif)

# 最後に

これで音ゲーのコアな部分の実装はできていると思います。

あとは自分なりにノーツの種類を増やしたり、エフェクトにアニメーションを追加したりしてリッチに仕上げてあげてください。

**これで君も自称音ゲークリエーターだ！**