書籍資訊

  • 書名:《Unityで作るリズムゲーム》
  • 作者:長崎大学マルチメディア研究会

這一篇在講什麼

前兩篇把空間關係和時間換算先講清楚,這一篇才正式把它們落到程式結構上。

這裡的重點不是一次把遊戲做完,而是先把幾個責任拆乾淨:

  • 譜面資料怎麼保存
  • BPM 變化怎麼保存
  • 播放進度由誰維護
  • Single Note 與 Long Note 各自怎麼更新畫面

先把整體責任拆成幾個類別

這一版的基礎類別可以先這樣理解:

  • NoteProperty:保存單顆 Note 的基本資料
  • TempoChange:保存變速點資訊
  • BeatMap:保存整張譜面的 Notes 與 BPM 段,並負責拍子 / 秒數換算
  • PlayerController:保存當前播放進度
  • NoteControllerBase:所有 Note 控制器的共同基底
  • SingleNoteController:更新單押 Note
  • LongNoteController:更新長押 Note

這樣拆的好處很明顯:資料歸資料,顯示歸顯示,時間進度則集中在一個地方管理。

NoteProperty:先定義一顆 Note 需要知道什麼

一顆 Note 最基本要知道:

  • 自己是不是長押
  • 自己在哪一條軌道
  • 自己的起點拍子
  • 如果是長押,終點拍子在哪裡

原始結構如下:

public enum NoteType
{
    Single,
    Long
}

public class NoteProperty
{
    public float beatBegin;
    public float beatEnd;
    public int lane;
    public NoteType noteType;

    public NoteProperty(float beatBegin, float beatEnd, int lane, NoteType noteType)
    {
        this.beatBegin = beatBegin;
        this.beatEnd = beatEnd;
        this.lane = lane;
        this.noteType = noteType;
    }
}

這裡的核心概念很簡單:不要把顯示邏輯塞進資料類裡,先讓 NoteProperty 專心表達譜面事實。

TempoChange:保存變速資訊

變速資料需要知道兩件事:

  • 從第幾拍開始變速
  • 變成多少 BPM
public class TempoChange
{
    public float beat;
    public float tempoAfterChanged;

    public TempoChange(float beat, float tempoAfterChanged)
    {
        this.beat = beat;
        this.tempoAfterChanged = tempoAfterChanged;
    }
}

有了這個結構,後面做秒數 / 拍子換算時,就不必靠一堆零散變數硬湊。

BeatMap:把譜面資料和換算能力放在一起

BeatMap 除了保存所有 Note 與 BPM 段,也負責提供換算方法:

public class BeatMap
{
    public List<NoteProperty> noteDatas = new List<NoteProperty>();
    public List<TempoChange> tempoChangeDatas = new List<TempoChange>();

    public static float BeatToSecWithFixedTempo(float beat, float tempo)
    {
        return beat / (tempo / 60);
    }

    public static float SecToBeatWithFixedTempo(float sec, float tempo)
    {
        return sec * (tempo / 60);
    }
}

如果曲子有變速,再額外提供「考慮所有速度段」的版本:

public static float BeatToSecWithVarTempo(float beat, List<TempoChange> tempoChanges)
{
    // 依據變速段逐段累加
}

public static float SecToBeatWithVarTempo(float sec, List<TempoChange> tempoChanges)
{
    // 找出 sec 落在哪個變速段,再補足剩餘拍數
}

這樣做的價值在於:後面任何地方只要需要從時間轉拍子,或從拍子轉時間,都可以透過同一套接口取得結果,不需要每個控制器自己各算一份。

PlayerController:集中保存目前播放進度

這裡的 PlayerController 還不是在處理玩家輸入,而是更像整個譜面播放系統的進度管理器。

public class PlayerController : MonoBehaviour
{
    public static float scrollSpeed = 1.0f;
    public static float currentSec = 0f;
    public static float currentBeat = 0f;

    public static BeatMap beatMap;
    float startOffset = 1.0f;

    private void Awake()
    {
        currentSec = 0f;
        currentBeat = 0f;
    }

    private void Update()
    {
        currentSec = Time.time - startOffset;
        currentBeat = BeatMap.SecToBeatWithVarTempo(currentSec, beatMap.tempoChangeDatas);
    }
}

這一層做的事情只有兩件:

  • 更新目前經過了幾秒
  • 根據目前秒數換算出當前拍子

而所有 Note 的位置更新,都直接依賴這兩個值。

NoteControllerBase:讓所有 Note 共享同一種入口

所有 Note 控制器都會用到 NoteProperty,所以先抽一個共用基底會比較乾淨:

public abstract class NoteControllerBase : MonoBehaviour
{
    public NoteProperty noteProperty;
}

這樣不管生成的是 Single Note 還是 Long Note,外部都能先透過同一個介面把譜面資料灌進去。

SingleNoteController:用目前拍子更新位置

Single Note 的邏輯相對單純:

public class SingleNoteController : NoteControllerBase
{
    private void Update()
    {
        Vector2 pos = new Vector2();
        pos.x = noteProperty.lane - 2;
        pos.y = (noteProperty.beatBegin - PlayerController.currentBeat)
            * PlayerController.scrollSpeed;
        transform.localPosition = pos;
    }
}

它的本質就是:

  • x 由軌道決定
  • ybeatBegin - currentBeat 決定
  • 再乘上 scrollSpeed 做視覺縮放

LongNoteController:多處理一段中間本體

Long Note 和 Single Note 的差別,不在於另一套完全不同的座標邏輯,而是它同時需要更新:

  • 起點 Begin
  • 終點 End
  • 中間拉伸區塊 Mid
public class LongNoteController : NoteControllerBase
{
    [SerializeField] Transform begin = null;
    [SerializeField] Transform mid = null;
    [SerializeField] Transform end = null;

    private void Update()
    {
        Vector2 beginPos = new Vector2();
        beginPos.x = noteProperty.lane;
        beginPos.y = (noteProperty.beatBegin - PlayerController.currentBeat)
            * PlayerController.scrollSpeed;

        Vector2 endPos = new Vector2();
        endPos.x = noteProperty.lane;
        endPos.y = (noteProperty.beatEnd - PlayerController.currentBeat)
            * PlayerController.scrollSpeed;

        begin.transform.localPosition = beginPos;
        end.transform.localPosition = endPos;

        Vector2 midPos = (beginPos + endPos) / 2f;
        mid.transform.localPosition = midPos;

        Vector2 midScale = mid.transform.localScale;
        midScale.y = endPos.y - beginPos.y;
        mid.transform.localScale = midScale;
    }
}

這裡其實還是在用同一套時間邏輯,只是 Long Note 額外利用起點與終點位置,推回 Mid 的位置和長度。

我自己的整理

這一篇最大的收穫,不是哪個單一類別寫得多漂亮,而是它把節奏遊戲的責任分工定得很乾淨。

很多早期原型很容易把資料、進度、顯示與生成全部揉在一個腳本裡,結果功能一擴充就會失控。這裡先把這些概念拆開,後面不管是接 BMS、做判定,還是加 UI 統計,都會順很多。

先記住的重點

  • NoteProperty 只負責描述 Note 資料,不要急著塞顯示邏輯。
  • BeatMap 不只存譜面,也適合集中管理拍子 / 秒數換算。
  • PlayerController 可以先當成播放進度管理器來看。
  • Single Note 與 Long Note 的位置更新,本質上仍然建立在同一套拍子差公式上。