書籍資訊
- 書名:《Unityで作るリズムゲーム》
- 作者:長崎大学マルチメディア研究会
這一篇在講什麼
前兩篇把空間關係和時間換算先講清楚,這一篇才正式把它們落到程式結構上。
這裡的重點不是一次把遊戲做完,而是先把幾個責任拆乾淨:
- 譜面資料怎麼保存
- BPM 變化怎麼保存
- 播放進度由誰維護
- Single Note 與 Long Note 各自怎麼更新畫面
先把整體責任拆成幾個類別
這一版的基礎類別可以先這樣理解:
NoteProperty:保存單顆 Note 的基本資料TempoChange:保存變速點資訊BeatMap:保存整張譜面的 Notes 與 BPM 段,並負責拍子 / 秒數換算PlayerController:保存當前播放進度NoteControllerBase:所有 Note 控制器的共同基底SingleNoteController:更新單押 NoteLongNoteController:更新長押 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由軌道決定y由beatBegin - 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 的位置更新,本質上仍然建立在同一套拍子差公式上。