在 Unity 多人游戏中实现语音对话

我们曾经不止一次为大家分享过游戏中的实时音视频,例如怎么实现游戏中的听声辨位、狼人杀游戏中的语音聊天挑战等。基本上,都是从技术原理和 Agora SDK 出发来分享的。这次我们换一个角度。我们将从 Unity 开发者的角度分享一下,在 Unity 中如何给自己的多人在线游戏增加实时语音通话功能。

在 Unity 多人游戏中实现语音对话_第1张图片

我们在这里利用了 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 多人游戏中实现语音对话_第2张图片

如果你之前已经下载过 Tanks!!! ,那么我们点击页面旁边的“Add Asset Package”按钮,选择添加它即可。

在 Unity 多人游戏中实现语音对话_第3张图片

如果你还未下载过 Tanks!!! 那么可以在 Unity Store 中下载它。

在 Unity 多人游戏中实现语音对话_第4张图片

在将 Tanks!!! 参考项目部署到手机之前,还有几步需要做。首先,我们需要在 Unity Dashboard 中,为这个项目开启 Unity Live Mode。该设置的路径是:project → Multiplayer → Unet Config。尽管 Tanks!!! 只支持最多四个玩家4,但我们在将“Max Player per room”设置为6。

在 Unity 多人游戏中实现语音对话_第5张图片

在 Unity 多人游戏中实现语音对话_第6张图片
图:这个界面说明 Unity Live Mode 已经开启

Building for iOS

现在我们已经准备好来创建 iOS 版本了。打开 Build Setting,将系统平台切换到 iOS,然后 Build。在切换系统平台后,请记得更新 Bundle Identifier(如下图所示)。

在 Unity 多人游戏中实现语音对话_第7张图片

在 Unity 多人游戏中实现语音对话_第8张图片
图:创建了一个“Build”文件夹用于储存 iOS 项目

在 Unity 多人游戏中实现语音对话_第9张图片
图:Build 完成

让我们打开 Unity-iPhone.xcodeproj,sign 并让它在测试设备上运行。

在 Unity 多人游戏中实现语音对话_第10张图片

现在我们已经完成了 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 多人游戏中实现语音对话_第11张图片

现在,我们打开了 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 多人游戏中实现语音对话_第12张图片

集成语音聊天功能

接下来,我们要利用 Unity 中的 Agora voice SDK for Unity 来给跨平台项目增加语音聊天功能了。我们打开 Unity Asset Store ,搜索 Agora Voice SDK for Unity。

在 Unity 多人游戏中实现语音对话_第13张图片

在 Unity 多人游戏中实现语音对话_第14张图片

当插件页面完成加载后,点击“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 多人游戏中实现语音对话_第15张图片

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:
https://github.com/digitallys...

如果你遇到 Agora SDK API 调用问题,可以参考我们的官方文档(docs.agora.io),也欢迎在 RTC 开发者社区 的 Agora 版块与我们的工程师和更多同行交流、分享。

你可能感兴趣的:(unity3d)