Unity + Twilio で作る代替現実ゲームアプリ開発 - 電話発信と自動音声読み上げを数行で導入

Unity + Twilio で作る代替現実ゲームアプリ開発 - 電話発信と自動音声読み上げを数行で導入

Unity と Twilio の組み合わせで「電話番号を入力すると実際に電話がかかってきて音声でパスコードが読み上げられるセキュリティドア風ゲームアプリ」を作成します。複雑そうな電話発信機能を Twilio で簡単に導入する一連の流れを、実際に動かせるコード例とともに紹介します。

はじめに

本記事では、 Unity と Twilio を組み合わせて、セキュリティドア風の代替現実ゲームアプリを作成します。このゲームでは、ユーザーが電話番号を入力すると、実際に入力した番号に電話がかかってきます。電話に出ると、自動音声によってパスコードが読み上げられ、そのパスコードをゲーム内で入力することでクリアとなります。まるでゲームの世界と現実のスマホがリンクしたような体験が得られます。

unity-twilio-voice-demo

本記事では、実際に手を動かしながらゲームアプリを作っていきます。 Twilio を使うことで、一見複雑そうな 電話発信+自動音声読み上げの実装 がいかに簡単に導入できるか、この記事を通して体験してみてください。

Twilio とは

Twilio は、通話・ SMS ・チャットなどの通信機能を API として提供するクラウドプラットフォームです。従来であれば複雑な実装が必要だった通信の機能を、数行のコードで実装できるのが大きな特徴です。

対象読者

  • Unity での基本的な開発経験がある方
  • アプリケーションに電話・ SMS 機能を追加したい方
  • 現実世界とバーチャルの世界を繋ぐゲームデザインに関心がある方

参考

実装

Twilio 側の準備

後ほど Unity で使用する Twilio の認証情報を取得します。Twilio Console で以下の情報を控えておきます。

  • Account SID
  • Auth Token
  • 取得した電話番号

Twilio Console

Unity 側の準備

プロジェクトの作成

  1. Unity Hub で新しいプロジェクトを作成
  2. テンプレートは「Universal 2D」を選択
  3. プロジェクト名は「TwilioVoiceDemo」など任意の名前

Twilio C# SDK のインストール

Twilio C# SDK を Unity プロジェクトにインストールします。今回は NuGetForUnity を使用します。

  1. NuGetForUnity のページで unitypackage をダウンロード
  2. Unity プロジェクトにインポート
  3. 上部メニューから「NuGet」→「Manage NuGet Packages」を選択
  4. 「Browse」タブで「Twilio」を検索
  5. 「Twilio」パッケージをインストール
    NuGet twilio package

Hierarchy にオブジェクトを追加

ゲームの UI を作成していきます。

Game UI

メイン UI パネル

  1. Hierarchy で右クリック > UI > Canvas を作成
  2. Canvas の子として Empty GameObject を作成し「SecurityDoorPanel」と命名
  3. RectTransform を Stretch に設定し、 Left, Top, Right, Bottom をすべて 0 に設定
  4. Image コンポーネントを追加し、背景色を設定

電話番号・パスコード表示エリア

SecurityDoorPanel の子として以下を作成します。

  • 「Your Phone Number」ボタン (phoneNumberModeButton)
  • 電話番号表示用 TextMeshPro (phoneNumberDisplay)
  • 「Passcode」ボタン (passcodeModeButton)
  • パスコード表示用 TextMeshPro (passcodeDisplay)

ダイヤルパッド

  1. SecurityDoorPanel の子として Empty GameObject を作成し「DialPad」と命名
  2. Grid Layout Group コンポーネントを追加
    • Cell Size: (80, 80)
    • Spacing: (10, 10)
    • Constraint: Fixed Column Count
      • Constraint Count: 3
  3. 12 個のボタンを作成し、以下の順序で配置
    1  2  3
    4  5  6  
    7  8  9
    C  0  E
    

Game Clear UI

  1. Canvas の子として Empry GameObject を作成し「GameClearPanel」と命名
  2. GameClearPanel の子として TextMeshPro を作成
  3. GameClearPanel オブジェクトを Disable 化

Manager オブジェクト

ゲームの動作を制御するための各 Object を作成します。以下の Empty GameObject を追加します。

  • AudioManager
  • GameManager
  • TwilioManager

スクリプトの作成

ゲームの動作を制御するスクリプトを書いていきます。

TwilioManager - Twilio API との通信

TwilioManager.cs
using UnityEngine;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
using System;

public class TwilioManager : MonoBehaviour
{
    [Header("Twilio Settings")]
    public string accountSid = "YOUR_ACCOUNT_SID";
    public string authToken = "YOUR_AUTH_TOKEN";
    public string twilioPhoneNumber = "YOUR_TWILIO_PHONE_NUMBER";

    void Start()
    {
        // Twilio クライアントを初期化
        TwilioClient.Init(accountSid, authToken);
    }

    public void MakeCallWithTwiML(string toPhoneNumber, string twimlContent, Action<bool, string> onComplete)
    {
        try
        {
            // TwiML を直接渡して通話を作成
            var call = CallResource.Create(
                twiml: new Twiml(twimlContent),
                to: new PhoneNumber(toPhoneNumber),
                from: new PhoneNumber(twilioPhoneNumber)
            );

            // 成功時のコールバック
            onComplete?.Invoke(true, call.Sid);
        }
        catch (Exception e)
        {
            // 失敗時のコールバック
            onComplete?.Invoke(false, e.Message);
        }
    }
}

GameManager - ゲームロジック管理

GameManager.cs
using UnityEngine;
using System;

public class GameManager : MonoBehaviour
{
    private string generatedPasscode;
    private bool isCallMade = false;

    // UI に通知するためのイベント
    public event Action<string> OnPasscodeGenerated;
    public event Action<bool> OnPasscodeVerified;
    public event Action<bool, string> OnCallCompleted;

    public void InitiateCall(string phoneNumber, TwilioManager twilioManager)
    {
        if (isCallMade) return;

        // 4桁のランダムパスコードを生成
        generatedPasscode = GeneratePasscode();

        // 音声読み上げ用の TwiML を作成
        string twimlContent = CreatePasscodeTwiML(generatedPasscode);

        // Twilio 経由で通話を開始
        twilioManager.MakeCallWithTwiML(phoneNumber, twimlContent, (success, message) => {
            if (success)
            {
                isCallMade = true;
                OnPasscodeGenerated?.Invoke(generatedPasscode);
                OnCallCompleted?.Invoke(true, "Call initiated successfully");
            }
            else
            {
                OnCallCompleted?.Invoke(false, message);
            }
        });
    }

    public bool VerifyPasscode(string inputPasscode)
    {
        if (!isCallMade) return false;

        // 入力されたパスコードと生成されたパスコードを比較
        bool isCorrect = inputPasscode == generatedPasscode;
        OnPasscodeVerified?.Invoke(isCorrect);

        return isCorrect;
    }

    private string GeneratePasscode()
    {
        System.Random random = new System.Random();
        return random.Next(1000, 9999).ToString();
    }

    private string CreatePasscodeTwiML(string passcode)
    {
        // パスコードを1桁ずつ区切って読み上げやすくする
        string spacedPasscode = string.Join(" ", passcode.ToCharArray());

        // TwiML で日本語音声読み上げを定義
        return $@"<?xml version=""1.0"" encoding=""UTF-8""?>
<Response>
    <Say voice=""alice"" language=""ja-JP"">
        セキュリティパスコードは {spacedPasscode} です。
        もう一度繰り返します。{spacedPasscode} です。
    </Say>
</Response>";
    }

    public void ResetGame()
    {
        generatedPasscode = "";
        isCallMade = false;
    }

    public bool IsCallMade()
    {
        return isCallMade;
    }
}

SecurityDoorController - UI の制御

SecurityDoorController.cs
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections;

public enum InputMode
{
    PhoneNumber,
    Passcode
}

public class SecurityDoorController : MonoBehaviour
{
    [Header("Phone Number UI")]
    public TextMeshProUGUI phoneNumberDisplay;
    public Button phoneNumberModeButton;

    [Header("Passcode UI")]
    public TextMeshProUGUI passcodeDisplay;
    public Button passcodeModeButton;

    [Header("Dial Pad")]
    public Button[] dialButtons; // 1-9, C, 0, E の順序

    [Header("Game Clear UI")]
    public GameObject gameClearPanel;

    [Header("Dependencies")]
    public TwilioManager twilioManager;
    public GameManager gameManager;
    public AudioManager audioManager;

    [Header("UI Colors")]
    public Color selectedModeColor = Color.green;
    public Color unselectedModeColor = Color.gray;

    private string phoneNumber = "+81";
    private string passcode = "";
    private InputMode currentInputMode = InputMode.PhoneNumber;

    private string[] dialPadValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9", "C", "0", "E"};

    void Start()
    {
        SetupDialPadButtons();
        SetupModeButtons();
        SetupGameManagerEvents();
        UpdateDisplays();
        UpdateModeSelection();
    }

    void SetupModeButtons()
    {
        phoneNumberModeButton.onClick.AddListener(() => SetInputMode(InputMode.PhoneNumber));
        passcodeModeButton.onClick.AddListener(() => SetInputMode(InputMode.Passcode));
    }

    void SetupGameManagerEvents()
    {
        // GameManager のイベントを購読
        gameManager.OnPasscodeGenerated += OnPasscodeGenerated;
        gameManager.OnPasscodeVerified += OnPasscodeVerified;
        gameManager.OnCallCompleted += OnCallCompleted;
    }

    void SetupDialPadButtons()
    {
        for (int i = 0; i < dialButtons.Length; i++)
        {
            int index = i;
            dialButtons[i].onClick.AddListener(() => OnDialPadPressed(dialPadValues[index]));
        }
    }

    void OnDialPadPressed(string value)
    {
        audioManager.PlayDialPadSound();

        switch (value)
        {
            case "C": // クリアボタン
                ClearCurrentInput();
                break;
            case "E": // 実行ボタン
                ExecuteCurrentAction();
                break;
            default: // 数字ボタン
                if (char.IsDigit(value[0]))
                {
                    AddDigit(value);
                }
                break;
        }
    }

    void SetInputMode(InputMode mode)
    {
        currentInputMode = mode;
        UpdateModeSelection();
    }

    void UpdateModeSelection()
    {
        // 選択中のモードをボタンの色で表示
        phoneNumberModeButton.GetComponent<Image>().color = 
            currentInputMode == InputMode.PhoneNumber ? selectedModeColor : unselectedModeColor;
        passcodeModeButton.GetComponent<Image>().color = 
            currentInputMode == InputMode.Passcode ? selectedModeColor : unselectedModeColor;
    }

    void AddDigit(string digit)
    {
        switch (currentInputMode)
        {
            case InputMode.PhoneNumber:
                if (phoneNumber.Length < 15)
                {
                    phoneNumber += digit;
                    UpdateDisplays();
                }
                break;
            case InputMode.Passcode:
                if (passcode.Length < 4)
                {
                    passcode += digit;
                    UpdateDisplays();
                }
                break;
        }
    }

    void ClearCurrentInput()
    {
        switch (currentInputMode)
        {
            case InputMode.PhoneNumber:
                phoneNumber = "+81";
                break;
            case InputMode.Passcode:
                passcode = "";
                break;
        }
        UpdateDisplays();
    }

    void ExecuteCurrentAction()
    {
        switch (currentInputMode)
        {
            case InputMode.PhoneNumber:
                MakeCall(); // 通話開始
                break;
            case InputMode.Passcode:
                VerifyPasscode(); // パスコード検証
                break;
        }
    }

    void UpdateDisplays()
    {
        phoneNumberDisplay.text = phoneNumber;

        // パスコードは * で表示
        string displayPasscode = "";
        for (int i = 0; i < 4; i++)
        {
            if (i < passcode.Length)
                displayPasscode += "*";
            else
                displayPasscode += "-";
        }
        passcodeDisplay.text = displayPasscode;
    }

    void MakeCall()
    {
        if (phoneNumber.Length > 3 && !gameManager.IsCallMade())
        {
            gameManager.InitiateCall(phoneNumber, twilioManager);
        }
    }

    void VerifyPasscode()
    {
        if (passcode.Length == 4)
        {
            gameManager.VerifyPasscode(passcode);
        }
    }

    // GameManager からのイベントハンドラー
    void OnPasscodeGenerated(string generatedPasscode)
    {
        // 通話成功時、自動的にパスコード入力モードに切り替え
        SetInputMode(InputMode.Passcode);
    }

    void OnCallCompleted(bool success, string message)
    {
        if (!success)
        {
            Debug.LogError($"Call failed: {message}");
        }
    }

    void OnPasscodeVerified(bool isCorrect)
    {
        if (isCorrect)
        {
            // 正解時: 成功音とゲームクリア演出
            audioManager.PlaySuccessSound();
            GameClear();
        }
        else
        {
            // 不正解時: エラー音と画面点滅
            audioManager.PlayErrorSound();
            StartCoroutine(ShowIncorrectPasscodeEffect());
        }
    }

    IEnumerator ShowIncorrectPasscodeEffect()
    {
        // パスコード表示を赤く点滅
        Color originalColor = passcodeDisplay.color;
        passcodeDisplay.color = Color.red;

        yield return new WaitForSeconds(0.5f);

        passcodeDisplay.color = originalColor;
        passcode = "";
        UpdateDisplays();
    }

    void GameClear()
    {
        audioManager.PlayDoorOpenSound();
        StartCoroutine(SlideOutUI());
    }

    IEnumerator SlideOutUI()
    {
        // UI を左にスライドアウト
        RectTransform panelRect = GetComponent<RectTransform>();
        Vector3 startPos = panelRect.anchoredPosition;
        Vector3 endPos = startPos + new Vector3(-Screen.width, 0, 0);

        float elapsed = 0f;
        float duration = 1.0f;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            float t = elapsed / duration;
            panelRect.anchoredPosition = Vector3.Lerp(startPos, endPos, t);
            yield return null;
        }

        // ゲームクリア UI をフェードイン表示
        if (gameClearPanel != null)
        {
            gameClearPanel.SetActive(true);
            StartCoroutine(FadeInGameClear());
        }
    }

    IEnumerator FadeInGameClear()
    {
        CanvasGroup canvasGroup = gameClearPanel.GetComponent<CanvasGroup>();
        if (canvasGroup == null)
            canvasGroup = gameClearPanel.AddComponent<CanvasGroup>();

        canvasGroup.alpha = 0f;
        float elapsed = 0f;
        float duration = 0.5f;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            canvasGroup.alpha = elapsed / duration;
            yield return null;
        }

        canvasGroup.alpha = 1f;
    }

    void OnDestroy()
    {
        // イベントの購読解除
        if (gameManager != null)
        {
            gameManager.OnPasscodeGenerated -= OnPasscodeGenerated;
            gameManager.OnPasscodeVerified -= OnPasscodeVerified;
            gameManager.OnCallCompleted -= OnCallCompleted;
        }
    }
}

AudioManager - 音声の制御

AudioManager.cs
using UnityEngine;

public class AudioManager : MonoBehaviour
{
    [Header("Audio Clips")]
    public AudioClip dialPadSound;
    public AudioClip doorOpenSound;
    public AudioClip successSound;
    public AudioClip errorSound;

    [Header("Audio Sources")]
    public AudioSource sfxAudioSource;

    void Start()
    {
        // AudioSource が設定されていない場合は自動で作成
        if (sfxAudioSource == null)
        {
            GameObject sfxObject = new GameObject("SFX AudioSource");
            sfxObject.transform.SetParent(transform);
            sfxAudioSource = sfxObject.AddComponent<AudioSource>();
        }
    }

    public void PlayDialPadSound()
    {
        if (dialPadSound != null && sfxAudioSource != null)
        {
            sfxAudioSource.PlayOneShot(dialPadSound);
        }
    }

    public void PlayDoorOpenSound()
    {
        if (doorOpenSound != null && sfxAudioSource != null)
        {
            sfxAudioSource.PlayOneShot(doorOpenSound);
        }
    }

    public void PlaySuccessSound()
    {
        if (successSound != null && sfxAudioSource != null)
        {
            sfxAudioSource.PlayOneShot(successSound);
        }
    }

    public void PlayErrorSound()
    {
        if (errorSound != null && sfxAudioSource != null)
        {
            sfxAudioSource.PlayOneShot(errorSound);
        }
    }
}

動作確認

各スクリプトを、対応する GameObject にアタッチし、以下の設定を行います。

  1. TwilioManager のインスペクタで Twilio の認証情報を入力
  2. SecurityDoorController のインスペクタで UI 要素と Manager への参照を設定
  3. AudioManager のインスペクタの Audio Clips にサウンドをアタッチ

Unity エディタの Play ボタンを押してゲームが動作することを確認します。

unity-twilio-voice-demo

  1. 電話番号を入力 (自分の携帯電話番号)
  2. E ボタンを押して自動音声を発信
  3. 電話に出てパスコードを聞く
  4. Passcode ボタンを押しパスコードを入力
  5. E ボタンで正しいパスコードを入力しゲームクリア

まとめ

この記事では、 Unity と Twilio を組み合わせたセキュリティドア風ゲームアプリを作成しました。電話がかかり、自動音声が読み上げられるという、複雑な通話機能がわずか数行のコードで実現できるのが、 Twilio の魅力です。本記事が、ARG (代替現実ゲーム) での演出など現実との通信を導入したいというユースケースにおいて参考になれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.