2021年1月 Global Game Jam,我與葉梓濤做了一款著重聲音體驗的禪意動作遊戲《劍入禪境 Sword Zen》。受限於 GameJam 開發時間,當時的設計想法並沒有能夠徹底實現;不少玩家朋友們試玩之後發來了反饋,也讓我們有了新的靈感。因此正好把這個專案作為個人的遊戲開發練習,我從核心聲音體驗和玩法擴充套件性的角度入手開始重新編寫程式碼。

在完成了原有玩法的重構之後,將要進行的一個最大的設計改動就是要讓玩家的操作與遊戲中的音樂產生更強的關聯。音樂不再只是遊戲進度的提示和主觀情緒的表達,而是要能提供更類似於音樂節奏遊戲中“音樂作為關卡設計”的功能。比如,透過音樂中的節拍點來控制敵人的攻擊行為,這樣的話玩家作出相應反饋時的格擋成功與失敗的時間點都會統一在音樂整體的節奏中,給玩家一種類似音遊的打擊爽快感;同時,這也為之後互動音樂的設計提供了更多可能性,根據玩家單次或多次的格擋結果來順暢地銜接不同的音樂片段,引導玩家情緒和切換遊戲階段。

接下來我將使用遊戲引擎 Unreal Engine 和音訊中介軟體 Wwise 工具,以“音樂節拍資訊控制敵人攻擊行為”為例,來詳細分析一下設計思路和程式碼實現的具體過程。

開發環境與工具:

Unreal Engine 4。26 C++ & Blueprint

Audiokinetic Wwise 2019。2。9

明確設計需求

“音樂作為關卡設計” UE & Wwise 實踐案例

首先需要明確具體的設計需求。如上圖所示,敵人的攻擊行為由帶有一個引數的 HandleAttack(WeaponType) 函式控制,WeaponType 決定了所用武器的各類屬性,其中與此處設計相關的數值是 Charge Time,即不同武器有著不同的充能時間。HandleAttack 函式執行時首先獲取當前的 WeaponType,然後進行 Start Charge,在經過 Charge Time 之後才實施 Actual Attack。需要實現的設計需求是,敵人在使用不同武器時(即 Charge Time 可變的情況下),Actual Attack 實施的時間點都將與音樂中的節拍點保持一致。

其實在 Wwise 中透過音樂內的標記資訊來觸發函式的功能實現並不複雜,原生 API 中的 PostEvent() 函式本身就開放了回撥函式(Callback Function)。

只不過,這個案例的設計需求稍微有些不同的地方是,音樂中需要標記的時間點確實是在節拍點上,但實際呼叫函式的時間點卻是在標記的節拍點之前,且呼叫函式的提前時間量是由遊戲中的引數來實時決定的,因此每次實施攻擊時都需要進行額外的計算。

嘗試使用 Wwise Trigger 功能

如上一節所說,Wwise 本身已有根據音樂標記資訊觸發回撥函式的功能,對於需要提前觸發且對齊節拍點的設計需求,我首先想到的是利用 Wwise 提供的 Trigger 功能。

“音樂作為關卡設計” UE & Wwise 實踐案例

如上圖所示,使用 Trigger 功能進行實現的設計思路是:在 Stinger 聲音片段(此處可以理解為是開始攻擊時的武器 Whoosh 音效)中,調整 Sync Point 用於對齊 Music Track 中節拍點的 Custom Cue 標記資訊,並在 Stinger 片段開頭新增用於真正觸發回撥函式的 Custom Cue 標記資訊。遊戲執行過程中在任意時刻觸發 Trigger,受 Trigger 控制的 Stinger 片段都將響應 Music Track 中下一個標記資訊點,只要 Stinger 片段開頭的標記資訊能夠被觸發,那就驗證此方案可行。

“音樂作為關卡設計” UE & Wwise 實踐案例

但是!在 UE 中使用 Blueprint 進行快速驗證後發現,透過 PostEvent 的方式觸發 Trigger,由 Trigger 控制的 Stinger 中包含的 Custom Cue 資訊無法被獲取;進一步研究 Wwise SDK 後發現,透過 PostTrigger 的方式直接觸發 Trigger 也不可行,此函式並沒有開放任何與回撥函式相關的介面。

“音樂作為關卡設計” UE & Wwise 實踐案例

至此,嘗試使用 Wwise Trigger 功能來實現“巢狀式”的 Custom Cue 觸發回撥函式的方式驗證不可行。

那就自己動手寫吧

“音樂作為關卡設計” UE & Wwise 實踐案例

既然上述方案無法實現,那就只能跳出現有思路框架,自己動手實現了,需要解決的核心問題就是如何實時地計算出呼叫 HandleAttack() 函式的時間點。而所謂的時間點對應到 Music Track 上其實就是播放位置(Play Position),因此函式呼叫時機的判斷條件轉化為算術表示式就是:

* 節拍點位置(Cue Position)- 當前播放位置(Current Position)< 武器充能時間長度(Charge Time Length)

另外,考慮到音樂迴圈播放的情況,為了確保當前播放位置永遠是與當前播放片段中的下一個節拍點進行比較,還有一個判斷條件需要滿足:

* 節拍點位置(Cue Position)> 當前播放位置(Current Position)

所以,只要設法獲取到節拍點位置和當前播放位置的資訊就能計算出呼叫函式的時間點。

獲取當前播放位置資訊

“音樂作為關卡設計” UE & Wwise 實踐案例

配合使用 AkCallbackType 標誌

AK_EnableGetSourcePlayPosition

AK::SoundEngine::PostEvent

返回當前播放聲音片段的

AkPlayingID

,將此 ID 傳入

AK::SoundEngine::GetSourcePlayPosition

即可獲得當前的播放位置。

注:有需要的話可以使用

AK::MusicEngine::GetPlayingSegmentInfo

,從返回的

AkSegmentInfo

中獲取更多資訊。

首先在 AkGameplayStatics 類中建立一個封裝

AK::SoundEngine::GetSourcePlayPosition

的函式,便於之後在 C++ 或 Blueprint 中呼叫。

AkGameplayStatics。h

UFUNCTION(BlueprintCallable, BlueprintCosmetic, Category = “SZ | Wwise”)

static int32 GetSourcePlayPosition(int32 PlayingID);

AkGameplayStatics。cpp

int32 UAkGameplayStatics::GetSourcePlayPosition(int32 PlayingID)

{

AkTimeMs CurrentPosition = 0;

auto Result = AK::SoundEngine::GetSourcePlayPosition(PlayingID, &CurrentPosition);

if (Result == AK_Success)

{

return CurrentPosition;

}

else

{

GEngine->AddOnScreenDebugMessage(-1, 2。f, FColor::Red, TEXT(“GetSourcePlayPosition FAILED!”), false);

return -1;

}

}

需要注意的是,Blueprint 中的 PostEvent 節點預設隱藏了

AK_EnableGetSourcePlayPosition

標誌,因此需要做一些額外的修改將其暴露出來。在 AkGameplayTypes 類中的

enum class EAkCallbackType

部分新增以下資訊。

AkGameplayTypes。h

EnableGetSourcePlayPosition = 20 UMETA(Tooltip = “Enable play position info for AK::SoundEngine::GetSourcePlayPosition()。”),

CHECK_CALLBACK_TYPE_VALUE(EnableGetSourcePlayPosition);

這樣一來就能在 PostEvent 節點中的 Callback Mask 下拉選單裡選擇 Enable Get Source Play Position 選項了。

“音樂作為關卡設計” UE & Wwise 實踐案例

獲取節拍點位置資訊

首先在 DAW 中對音樂的節拍點進行標記,並將其匯出成 。csv 檔案。之後建立

MusicCueStruct。h

和相對應的 MusicCue Data Table,並將 。csv 檔案資訊匯入其中。

#pragma once

#include “CoreMinimal。h”

#include “Engine/DataTable。h”

#include “MusicCueStruct。generated。h”

USTRUCT(BlueprintType)

struct FMusicCueStruct : public FTableRowBase

{

GENERATED_USTRUCT_BODY()

// Music cue position in segment (s)

UPROPERTY(EditAnywhere, BlueprintReadOnly)

float CuePosition;

// Music cue name

UPROPERTY(EditAnywhere, BlueprintReadOnly)

FName CueName;

};

“音樂作為關卡設計” UE & Wwise 實踐案例

考慮到仍然需要使用 Wwise State 來切換音樂片段,切換的同時需要載入對應片段的 MusicCue Data Table,因此需要建立

MusicSegmentStruct。h

和相對應的 MusicSegment Data Table,將 Wwise State 和 MusicCue Data Table 進行統一管理和呼叫。

#pragma once

#include “CoreMinimal。h”

#include “Engine/DataTable。h”

#include “MusicSegmentStruct。generated。h”

USTRUCT(BlueprintType)

struct FMusicSegmentStruct : public FTableRowBase

{

GENERATED_USTRUCT_BODY()

// Music state group in Wwise

UPROPERTY(EditAnywhere, BlueprintReadOnly)

FName MusicStateGroup;

// Music state in Wwise

UPROPERTY(EditAnywhere, BlueprintReadOnly)

FName MusicState;

// The corresponding MusicCue Datatable

UPROPERTY(EditAnywhere, BlueprintReadOnly)

UDataTable* CueDataTable;

};

“音樂作為關卡設計” UE & Wwise 實踐案例

最後,建立

SetMusicStateToGetSegmentInfo()

函式用於在 Blueprint 中配合 GetDataTableRow 節點來調取上述資料。

UFUNCTION(BlueprintCallable)

void SetMusicStateToGetSegmentInfo(const FName StateGroup, const FName State, class UDataTable* CueDataTable);

void AMainLevelManager::SetMusicStateToGetSegmentInfo(const FName StateGroup, const FName State, UDataTable* CueDataTable)

{

// Set music state。。。

UAkStateValue* StateValue = nullptr;

UAkGameplayStatics::SetState(StateValue, StateGroup, State);

// Get corresponding cue data table。。。

MusicCueDataTable = CueDataTable;

// Get initial info。。。

GetHandleEnemyAttackCueInfo();

}

其中

GetHandleEnemyAttackCueInfo()

用於進一步處理 MusicCue Data Table,從中獲取節拍點位置和武器充能時間長度的資訊。

void AMainLevelManager::GetHandleEnemyAttackCueInfo()

{

if (MusicCueDataTable)

{

MusicCueRowNameArray = MusicCueDataTable->GetRowNames();

FName MusicCueRowName = MusicCueRowNameArray[MusicCueRowNameArrayIndex];

FMusicCueStruct* MusicCue = MusicCueDataTable->FindRow(MusicCueRowName, TEXT(“”), true);

if (ensure(MusicCue))

{

CuePosition = FMath::RoundToInt((*MusicCue)。CuePosition * 1000); // Convert to (int32) ms

WeaponType = (*MusicCue)。CueName;

}

}

if (WeaponPropertyDataTable)

{

FWeaponPropertyStruct* WeaponProperty = WeaponPropertyDataTable->FindRow(WeaponType, TEXT(“”), true);

if (ensure(WeaponProperty))

{

ChargeTimeLength = FMath::RoundToInt((*WeaponProperty)。WeaponChargeTime * 1000); // Convert to (int32) ms

}

}

}

計算函式呼叫時間點

獲取了當前播放位置和節拍點位置的資訊之後,就可以建立

HandleEnemyAttackCueCallback()

函式並依據上述兩點判斷條件來實時計算函式呼叫時間點了,且在每次判斷為真且執行之後,獲取下一個節拍點位置繼續進行判斷。

void AMainLevelManager::HandleEnemyAttackCueCallback(int32 AkPlayingID)

{

int32 CurrentPosition = UAkGameplayStatics::GetSourcePlayPosition(AkPlayingID);

if (CuePosition - CurrentPosition < ChargeTimeLength && CuePosition > CurrentPosition)

{

bool bIsCallbackTriggered = true;

if (bIsCallbackTriggered)

{

OnEnemyAttackCue。Broadcast(WeaponType);

bIsCallbackTriggered = false;

MusicCueRowNameArrayIndex += 1;

if (MusicCueRowNameArrayIndex > MusicCueRowNameArray。Num() - 1)

{

MusicCueRowNameArrayIndex = 0;

}

GetHandleEnemyAttackCueInfo();

}

}

}

其中

OnEnemyAttackCue。Broadcast(WeaponType)

透過 Delegate 的方式將當前的 WeaponType 資訊傳送出去,敵人的 HandleAttack() 函式便會根據當前武器型別做出相應的攻擊行為。

最終實現

“音樂作為關卡設計” UE & Wwise 實踐案例

如上圖,在 Blueprint 中簡單使用幾個節點即可實現最終的設計需求。

下圖為整體的實現思路概覽。

“音樂作為關卡設計” UE & Wwise 實踐案例

總結

最後,說回開頭提到的“音樂作為關卡設計”的概念。這個案例只是簡單地運用了節拍點這一元素,其實聲音或音樂中還有很多豐富的元素,比如響度(Loudness)、頻率(Frequency)、包絡(Envelope)、音色(Timbre)、音調(Pitch)、旋律(Melody)與和絃(Chord)等,都可以被用來驅動遊戲內各個方面的表現,甚至是影響玩法。

Reference

Wwise SDK - Integration Details - Events

Wwise SDK - Integration Details - Music Callbacks

Wwise SDK - Integration Details - Triggers

Wwise SDK - Integration Details - GetSourcePlayPosition

Wwise SDK - GetPlayingSegmentInfo

Wwise SDK - AkSegmentInfo Struct

Wwise SDK - AkCallbackType

Alessandro Fama - Playback position of sounds with Wwise + UE4

UE API - Get Data Table Row

希辰

2021。3。20