在 Unity 多人遊戲中實現語音對話

聲網Agora發表於2019-04-03

我們曾經不止一次為大家分享過遊戲中的實時音視訊,例如怎麼實現遊戲中的聽聲辨位、狼人殺遊戲中的語音聊天挑戰等。基本上,都是從技術原理和 Agora SDK 出發來分享的。這次我們換一個角度。我們將從 Unity 開發者的角度分享一下,在 Unity 中如何給自己的多人線上遊戲增加實時語音通話功能。

在 Unity 多人遊戲中實現語音對話

我們在這裡利用了 Unity 上流行的 “Tanks!!! asset reference” 坦克遊戲作為多人線上遊戲作為基礎,相信很多人都不會陌生。大家可以在 Unity Asset Store 中搜到它。然後,我們會利用 Unity Asset Store 中的 Agora Voice SDK 為它增加多人語音聊天功能。

在開始前,你需要做以下準備:

  • 安裝 Unity 並註冊 Unity 賬號
  • 瞭解如果在 Unity 中建立 iOS、Android 專案
  • 一款跨移動平臺多玩家的 Unity 遊戲(本文中我們選擇的是 Tanks)
  • 瞭解 C# 和 Unity 指令碼
  • 註冊一個 Agora 開發者賬戶
  • 至少兩個移動裝置(如果有一個 iOS 裝置,一個 Android 裝置就再理想不過了)
  • 安裝 Xcode

新建 Unity 專案

我們預設大家都是用過 Unity 的開發者,但是為了照顧更多的人。我們還是要從頭講起。當然,開始的操作步驟很簡單,所以我們會盡量以圖片來說明。

首先,開啟 Unity 後,讓我們先建立一個新的專案。

在 Unity 多人遊戲中實現語音對話

如果你之前已經下載過 Tanks!!! ,那麼我們點選頁面旁邊的“Add Asset Package”按鈕,選擇新增它即可。

在 Unity 多人遊戲中實現語音對話

如果你還未下載過 Tanks!!! 那麼可以在 Unity Store 中下載它。

在 Unity 多人遊戲中實現語音對話

在將 Tanks!!! 參考專案部署到手機之前,還有幾步需要做。首先,我們需要在 Unity Dashboard 中,為這個專案開啟 Unity Live Mode。該設定的路徑是:project → Multiplayer → Unet Config。儘管 Tanks!!! 只支援最多四個玩家4,但我們在將“Max Player per room”設定為6。

在 Unity 多人遊戲中實現語音對話

在 Unity 多人遊戲中實現語音對話
圖:這個介面說明 Unity Live Mode 已經開啟

Building for iOS

現在我們已經準備好來建立 iOS 版本了。開啟 Build Setting,將系統平臺切換到 iOS,然後 Build。在切換系統平臺後,請記得更新 Bundle Identifier(如下圖所示)。

在 Unity 多人遊戲中實現語音對話

在 Unity 多人遊戲中實現語音對話
圖:建立了一個“Build”資料夾用於儲存 iOS 專案

在 Unity 多人遊戲中實現語音對話
圖:Build 完成

讓我們開啟 Unity-iPhone.xcodeproj,sign 並讓它在測試裝置上執行。

在 Unity 多人遊戲中實現語音對話

現在我們已經完成了 iOS 專案的建立。接下來我們要建立 Android 專案了。

Building for Android

Android 專案相比 iOS 來講要更簡單一些。因為 Unity 可以直接建立、sign 和部署執行,無需藉助 Android Studio。我預設大家已經將 Unity 與 Android SDK 資料夾關聯起來了。現在我們要開啟 Build Setting,然後將系統平臺切換到 Android。

在我們建立並執行之前,我們還需要對程式碼做出一些簡單的調整。我們只需要註釋掉幾行程式碼,加一個簡單的返回宣告,再替換一個檔案。

背景資訊:Tanks!!! Android 包含了 Everyplay 外掛,用以實現遊戲螢幕錄製和分享。問題是,Everyplay 在2018年十月停止了服務,而外掛仍然存在一些未解決的問題,如果我們不對其進行處理會導致編譯失敗。

首先,我們要糾正一下 Everyplay 外掛 build.gradle 檔案中的語法錯誤。該檔案的路徑是:Plugins → Android → everyplay → build.gradle。

在 Unity 多人遊戲中實現語音對話

現在,我們開啟了 gradle 檔案,全選所有程式碼,然後將下方的程式碼替換上去。Tanks!!! 團隊在 Github 上更新了程式碼,但是不知道為什麼並沒能更新到外掛中。

// UNITY EXPORT COMPATIBLE
apply plugin: 'com.android.library'

repositories {
    mavenCentral()
}

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.0.0'
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
}

android {
    compileSdkVersion 23
    buildToolsVersion "25.0.3"
    defaultPublishConfig "release"

    defaultConfig {
        versionCode 1600
        versionName "1.6.0"
        minSdkVersion 16
    }

    buildTypes {
        debug {
            debuggable true
            minifyEnabled false
        }

        release {
            debuggable false
            minifyEnabled true
            proguardFile getDefaultProguardFile('proguard-android.txt')
            proguardFile 'proguard-project.txt'
        }
    }

    sourceSets {
        main {
            manifest.srcFile 'AndroidManifest.xml'
            java.srcDirs = ['src']
            aidl.srcDirs = ['src']
            renderscript.srcDirs = ['src']
            res.srcDirs = ['res']
            jniLibs.srcDirs = ['libs']
        }
    }

    lintOptions {
        abortOnError false
    }
}
複製程式碼

最後我們要做的修改就是關閉 Everyplay。你可能想問:為什麼我們要關閉 Everyplay 呢?因為當外掛初始化時會導致 Android 應用崩潰。我發現最快速的方法就是在 EveryPlaySettings.cs 檔案中更新幾行程式碼(該檔案的路徑:Assets → Plugins → EveryPlay → Scripts),如此一來,每當 Everyplay 檢視檢測自身是否處於開啟狀態時,我們都會給它返回“false”。

public class EveryplaySettings : ScriptableObject
{
    public string clientId;
    public string clientSecret;
    public string redirectURI = "https://m.everyplay.com/auth";

    public bool iosSupportEnabled;
    public bool tvosSupportEnabled;
    public bool androidSupportEnabled;
    public bool standaloneSupportEnabled;

    public bool testButtonsEnabled;
    public bool earlyInitializerEnabled = true;
    
    public bool IsEnabled
    {
        get
        {
            return false;
        }
    }

#if UNITY_EDITOR
    public bool IsBuildTargetEnabled
    {
        get
        {
            return false;
        }
    }
#endif

    public bool IsValid
    {
        get
        {
            return false;
        }
    }
}
複製程式碼

現在我們已經準備好 Build 了。在 Unity 中開啟 Build Settings,選擇 Android 平臺,然後按下“Switch Platform”按鈕。隨後,在 Player Settings 中為 Android App 修改 bundle id。在這裡,我使用的是 com.agora.tanks.voicedemo。

在 Unity 多人遊戲中實現語音對話

整合語音聊天功能

接下來,我們要利用 Unity 中的 Agora voice SDK for Unity 來給跨平臺專案增加語音聊天功能了。我們開啟 Unity Asset Store ,搜尋 Agora Voice SDK for Unity。

在 Unity 多人遊戲中實現語音對話

在 Unity 多人遊戲中實現語音對話

當外掛頁面完成載入後,點選“Download”開始下載。下載完成後,選擇“Import”,將它整合到你的專案中。

我們需要建立一個指令碼來讓遊戲與 Agora Voice SDK 進行互動。我們在專案中新建一個 C# 檔案(AgoraInterface.cs),然後在 Visual Studio 中開啟它。

在這個指令碼中有兩個很重要的變數:

static IRtcEngine mRtcEngine;
public static string appId = "Your Agora AppId Here";
複製程式碼

先要將“Your Agora AppId Here” 替換成 App ID,我們可在登入 Agora.io ,進入 Agora Dashboard 獲取。mRtcEngine是靜態的,這樣在OnUpdate 呼叫的時候,才不會丟失。由於遊戲中的其它指令碼可能會引用 App ID,所以它是public static

考慮到節省時間,我已經將AgoraInterface.cs的程式碼寫好了(如下所示)。大家可以直接使用,避免重複造車輪。

在這裡簡單解釋一下程式碼。首先,我們在開頭有一些邏輯,用於 check/requset Android Permission。然後我們用 App ID 初始化 Agora RTC Engine,然後我們附加了一些事件回撥,這部分很簡單易懂。

mRtcEngine.OnJoinChannelSuccess表示使用者已經成功加入指定頻道。

最後一個重要功能就是update,當啟用了 Agora RTC Engine 時,我們想要呼叫引擎的.Pull()方法,它對於外掛是否能執行起來很關鍵。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;

using agora_gaming_rtc;

#if(UNITY_2018_3_OR_NEWER)
using UnityEngine.Android;
#endif

public class AgoraInterface : MonoBehaviour
{
   static IRtcEngine mRtcEngine;

    // PLEASE KEEP THIS App ID IN SAFE PLACE
    // Get your own App ID at https://dashboard.agora.io/
    // After you entered the App ID, remove ## outside of Your App ID
    public static string appId = "Your Agora AppId Here";

    void Awake()
    {
        QualitySettings.vSyncCount = 0;
        Application.targetFrameRate = 30;
    }

    // Start is called before the first frame update
    void Start()
    {
#if (UNITY_2018_3_OR_NEWER)
        if (Permission.HasUserAuthorizedPermission(Permission.Microphone))
        {

        }
        else
        {
            Permission.RequestUserPermission(Permission.Microphone);
        }
#endif

        mRtcEngine = IRtcEngine.GetEngine(appId);
        Debug.Log("Version : " + IRtcEngine.GetSdkVersion());

        mRtcEngine.OnJoinChannelSuccess += (string channelName, uint uid, int elapsed) => {
            string joinSuccessMessage = string.Format("joinChannel callback uid: {0}, channel: {1}, version: {2}", uid, channelName, IRtcEngine.GetSdkVersion());
            Debug.Log(joinSuccessMessage);
        };

        mRtcEngine.OnLeaveChannel += (RtcStats stats) => {
            string leaveChannelMessage = string.Format("onLeaveChannel callback duration {0}, tx: {1}, rx: {2}, tx kbps: {3}, rx kbps: {4}", stats.duration, stats.txBytes, stats.rxBytes, stats.txKBitRate, stats.rxKBitRate);
            Debug.Log(leaveChannelMessage);
        };

        mRtcEngine.OnUserJoined += (uint uid, int elapsed) => {
            string userJoinedMessage = string.Format("onUserJoined callback uid {0} {1}", uid, elapsed);
            Debug.Log(userJoinedMessage);
        };

        mRtcEngine.OnUserOffline += (uint uid, USER_OFFLINE_REASON reason) => {
            string userOfflineMessage = string.Format("onUserOffline callback uid {0} {1}", uid, reason);
            Debug.Log(userOfflineMessage);
        };

        mRtcEngine.OnVolumeIndication += (AudioVolumeInfo[] speakers, int speakerNumber, int totalVolume) => {
            if (speakerNumber == 0 || speakers == null)
            {
                Debug.Log(string.Format("onVolumeIndication only local {0}", totalVolume));
            }

            for (int idx = 0; idx < speakerNumber; idx++)
            {
                string volumeIndicationMessage = string.Format("{0} onVolumeIndication {1} {2}", speakerNumber, speakers[idx].uid, speakers[idx].volume);
                Debug.Log(volumeIndicationMessage);
            }
        };

        mRtcEngine.OnUserMuted += (uint uid, bool muted) => {
            string userMutedMessage = string.Format("onUserMuted callback uid {0} {1}", uid, muted);
            Debug.Log(userMutedMessage);
        };

        mRtcEngine.OnWarning += (int warn, string msg) => {
            string description = IRtcEngine.GetErrorDescription(warn);
            string warningMessage = string.Format("onWarning callback {0} {1} {2}", warn, msg, description);
            Debug.Log(warningMessage);
        };

        mRtcEngine.OnError += (int error, string msg) => {
            string description = IRtcEngine.GetErrorDescription(error);
            string errorMessage = string.Format("onError callback {0} {1} {2}", error, msg, description);
            Debug.Log(errorMessage);
        };

        mRtcEngine.OnRtcStats += (RtcStats stats) => {
            string rtcStatsMessage = string.Format("onRtcStats callback duration {0}, tx: {1}, rx: {2}, tx kbps: {3}, rx kbps: {4}, tx(a) kbps: {5}, rx(a) kbps: {6} users {7}",
                stats.duration, stats.txBytes, stats.rxBytes, stats.txKBitRate, stats.rxKBitRate, stats.txAudioKBitRate, stats.rxAudioKBitRate, stats.users);
            Debug.Log(rtcStatsMessage);

            int lengthOfMixingFile = mRtcEngine.GetAudioMixingDuration();
            int currentTs = mRtcEngine.GetAudioMixingCurrentPosition();

            string mixingMessage = string.Format("Mixing File Meta {0}, {1}", lengthOfMixingFile, currentTs);
            Debug.Log(mixingMessage);
        };

        mRtcEngine.OnAudioRouteChanged += (AUDIO_ROUTE route) => {
            string routeMessage = string.Format("onAudioRouteChanged {0}", route);
            Debug.Log(routeMessage);
        };

        mRtcEngine.OnRequestToken += () => {
            string requestKeyMessage = string.Format("OnRequestToken");
            Debug.Log(requestKeyMessage);
        };

        mRtcEngine.OnConnectionInterrupted += () => {
            string interruptedMessage = string.Format("OnConnectionInterrupted");
            Debug.Log(interruptedMessage);
        };

        mRtcEngine.OnConnectionLost += () => {
            string lostMessage = string.Format("OnConnectionLost");
            Debug.Log(lostMessage);
        };

        mRtcEngine.SetLogFilter(LOG_FILTER.INFO);

        // mRtcEngine.setLogFile("path_to_file_unity.log");

        mRtcEngine.SetChannelProfile(CHANNEL_PROFILE.GAME_FREE_MODE);

        // mRtcEngine.SetChannelProfile (CHANNEL_PROFILE.GAME_COMMAND_MODE);
        // mRtcEngine.SetClientRole (CLIENT_ROLE.BROADCASTER); 
    }

    // Update is called once per frame
    void Update ()
    {
        if (mRtcEngine != null) {
            mRtcEngine.Poll ();
        }
    }
}
複製程式碼

注意,以上程式碼可複用於所有 Unity 專案。

離開頻道

如果你曾經使用過 Agora SDK,你可能注意到了,這裡沒有加入頻道和離開頻道。讓我們先從“離開頻道”開始動手,建立一個新的 C# 指令碼LeaveHandler.cs,我們需要在使用者返回到主選單的時候呼叫 theleaveHandler。最簡單的方法就是在 LobbyScene 開啟後,為特定遊戲物件開啟該方法。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using agora_gaming_rtc;

public class LeaveHandler : MonoBehaviour
{
    // Start is called before the first frame update
    void OnEnable()
    {
        // Agora.io Implimentation
        IRtcEngine mRtcEngine = IRtcEngine.GetEngine(AgoraInterfaceScript.appId); // Get a reference to the Engine
        if (mRtcEngine != null)
        {
            Debug.Log("Leaving Channel");
            mRtcEngine.LeaveChannel();// leave the channel
        }

    }
}
複製程式碼

在這裡,我們要找的遊戲物件是 LeftSubPanel (如下圖,MainPanel → MenuUI → LeftSubPanel )。

在 Unity 多人遊戲中實現語音對話

Tanks!!! 中有兩種方法加入多人遊戲,一種是建立新遊戲,另一種是加入遊戲。所以有兩個地方,我們需要增加“加入頻道”的命令。

讓我們先找到 UI Script Asset 資料夾(該資料夾路徑:Assets → Scripts → UI),然後開啟CreateGame.cs檔案。在第61行,你會找到遊戲用於匹配玩家的方法,在這裡我們可以加入一些邏輯用於加入頻道。首先我們要做的就是應用 Agora SDK 庫。

using agora_gaming_rtc;
複製程式碼

StartMatchmakingGame()的第78行,我們需要加入一些邏輯來獲取正在執行中的Agora RTC Engine,然後將“使用者輸入的內容”作為頻道名稱(m_MatchNameInput.text)。

private void StartMatchmakingGame()
{
  GameSettings settings = GameSettings.s_Instance;
  settings.SetMapIndex(m_MapSelect.currentIndex);
  settings.SetModeIndex(m_ModeSelect.currentIndex);

  m_MenuUi.ShowConnectingModal(false);

  Debug.Log(GetGameName());
  m_NetManager.StartMatchmakingGame(GetGameName(), (success, matchInfo) =>
    {
      if (!success)
      {
        m_MenuUi.ShowInfoPopup("Failed to create game.", null);
      }
      else
      {
        m_MenuUi.HideInfoPopup();
        m_MenuUi.ShowLobbyPanel();
        
        // Agora.io Implimentation
        
        var channelName = m_MatchNameInput.text; // testing --> prod use: m_MatchNameInput.text
        IRtcEngine mRtcEngine = IRtcEngine.GetEngine(AgoraInterfaceScript.appId); // Get a reference to the Engine
        mRtcEngine.JoinChannel(channelName, "extra", 0); // join the channel with given match name
        Debug.Log("joining channel:" + channelName);
      }
    });
}
複製程式碼

StartMatchmakingGame()包含了加入頻道

現在我們需要開啟LobbyServerEntry.cs(Assets → Scripts → UI),然後加入一些邏輯,以實現讓使用者可以通過“Find a Game”來加入其他人的房間。

在 Visual Studio 開啟 LobbyServerEntry.cs,然後找到第63行,這裡有一個 JoinMatch()。我們在第80行增加幾行程式碼。

private void JoinMatch(NetworkID networkId, String matchName)
{
  MainMenuUI menuUi = MainMenuUI.s_Instance;

  menuUi.ShowConnectingModal(true);

  m_NetManager.JoinMatchmakingGame(networkId, (success, matchInfo) =>
    {
      //Failure flow
      if (!success)
      {
          menuUi.ShowInfoPopup("Failed to join game.", null);
      }
      //Success flow
      else
      {
          menuUi.HideInfoPopup();
          menuUi.ShowInfoPopup("Entering lobby...");
          m_NetManager.gameModeUpdated += menuUi.ShowLobbyPanelForConnection;

          // Agora.io Implimentation
          var channelName = matchName; // testing --> prod use: matchName
          IRtcEngine mRtcEngine = IRtcEngine.GetEngine(AgoraInterfaceScript.appId); // Get a reference to the Engine
          mRtcEngine.JoinChannel(channelName, "extra", 0); // join the channel with given match name

          // testing
          string joinChannelMessage = string.Format("joining channel: {0}", channelName);
          Debug.Log(joinChannelMessage);
      }
    }
  );
}
複製程式碼

完成了!

現在我們已經完成了Agora SDK 的整合,並且已經準備好進行 iOS 端和 Android 端的 Build 與測試。我們可以參照上述內容中的方法來進行 Building 與部署。

為了便於大家參考,我已經將這份 Tutorial 中的指令碼上傳了一份到 Github: github.com/digitallysa…

如果你遇到 Agora SDK API 呼叫問題,可以參考我們的官方文件(docs.agora.io),也歡迎在 RTC 開發者社群 的 Agora 版塊與我們的工程師和更多同行交流、分享。

相關文章