FungusのFlowchart内にあるセリフデータをGoogle Sheetに反映させる【Unity】【GoogleAppsScript】

はじめに

Unityで会話実装を行う際にFungusを使用した会話機能の実装を以下のゲームで行いました。
しかし、セリフデータをFungus内のflowchartに入れてしまうとローカライズ対応やボイス実装の際に毎回確認や修正をするたびにUnityを開ける必要があります。手動だと大変なので一覧化してみようと思いましたので、実装方法を記事にしました

store.steampowered.com

Demo

Inspectorにあるボタンを押すことで、ヒエラルキー上のすべてのflowchart内のセリフを Google Sheet上に反映することができます。

またキャラ数が数十体あるため、各シートの一覧化及びシートリンクも自動的に追加されるようになっています。

やりたいこと

  • シーン上に存在する会話するNPCのセリフを取得
  • セリフデータを Google Sheetへの反映
    • 新規キャラがいる場合の、新規シート作成
    • キャラクターシートを保護モードにして、変更できないようにする
  • 各セリフデータやキャラの分析及び一覧化
    • 一番上にパラメータ名の追加と行の固定

環境

実装

1. シーン上に存在する会話NPCのセリフの取得

こちらは以下の記事で fungusのセリフデータが格納されているコンポーネント(flowchart)をjson形式に出力する方法について詳しく書いていますので、以下をご覧ください

ayousanz.hatenadiary.jp

2. Google Sheetにセリフデータを反映させる

1にてセリフデータを以下のようなjsonクラスに変換できました。

次に以下のような流れで Unity側のセリフデータクラスを Google Sheetに反映させていきます。

Unity側の実装

Unity側でデータをGAS側にPostする関数を作成します。

        public static async UniTask<bool> PostGameInfo(string url, string sheetName, string postData)
        {
            var request = new UnityWebRequest($"{url}?sheetName={sheetName}", "POST");
            var data = Encoding.UTF8.GetBytes(postData);
            request.uploadHandler = new UploadHandlerRaw(data);
            request.downloadHandler = new DownloadHandlerBuffer();
            request.SetRequestHeader("Content-Type", "application/json");

            await request.SendWebRequest();
            Debug.Log(request.downloadHandler.text);
            return request.result == UnityWebRequest.Result.Success;
        }

前回のjsonに変換する関数と組み合わせたUnity側の関数は以下のようになります。

        private async UniTask ExportGoogleSheet()
        {
            foreach (var dialogue in _cardEventDialogueList)
            {
                var sheetName = dialogue.dialogueNo;
                var json = JsonExtension.ListClassToJson(dialogue, sheetName);
                await GoogleSheetExtension.PostGameInfo(GoogleSheet.CardEventDialogueSheet, sheetName, json);
            }
        }

Google Apps Script側の実装

外部からPostできる API doPost(e)を以下のように作成します。

以下では、シート名を指定して処理を行えるようにパラメータに sheetNameを入れています。 またpost dataのbodyには キャラセリフデータが入ります。

function doPost(e){
  const sheetName = e.parameter.sheetName;
  const postContent = e.postData.getDataAsString();
  const params = JSON.parse(postContent)["Data"]["dialogues"];
}

以下で postされた セリフデータを GASで扱える Object側に変換して、必要なデータ(セリフが入っているリスト部分)を 取得しています。

  const params = JSON.parse(postContent)["Data"]["dialogues"];

今後はこの関数内に処理を追加していきます。

次に Postしたキャラデータのシートがまだ作成されていない場合、新規シートを作成します。

  let sheet = SpreadsheetApp.getActive().getSheetByName(sheetName);
  if(sheet == null){
    sheet = NewSheet(sheetName);
  }

function NewSheet(sheetName){
  //スプレッドシートに新しいシートを追加挿入
  let newSheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet();
  //追加挿入したシートに名前を設定
  newSheet.setName(sheetName);
  return newSheet;
}

次に前回のシート情報から差分があった場合、シート内容がおかしくならないように初期化を行います。

  //前回の情報を初期化する
  sheet.clearContents();

シートの作成やセリフデータが取得できたので、シートに書き込むための処理を行います。
まずは 書き込む範囲の指定を sheet.getRange で行います。
その後 セリフ分をfor文で回してリストに入れていきます。

function SetDialogueData(sheet,dialogue,setHeaderSheetInfo){
  const dialogueLength = dialogue.length;
  let range = sheet.getRange(1,1,dialogueLength + 1,setHeaderSheetInfo.length);
  let dataList = []

  // シートの一番上に列の情報を入力する
  dataList.push(setHeaderSheetInfo);
  for(let i = 0;i < dialogueLength;i++){
    var data = new Array();
    var d = dialogue[i];
    data.push(d["characterName"],d["characterViewName"],d["text"],d["commandIndex"],d["dialogueCount"])
    dataList.push(data)
  }
  range.setValues(dataList);
}

書き込むための処理を行った後に、見た目を整えるために以下の処理を行います * 一行目の固定 * 各列の幅を自動で揃える

function SetSheetStyle(sheet,sheetRowCount){
  //一番上の行を固定にする
  sheet.setFrozenRows(1);

  //必要のない列を非表示にする
  sheet.hideColumns(sheetRowCount + 1,26 - sheetRowCount);

  //行の幅をそろえる
  sheet.autoResizeColumns(1,sheetRowCount);
}

Unity側の更新が優先されるために間違えてシートの変更がユーザーによってされないようにシートに保護モードもつけておきます

function SetProtect(sheet){
  //Unity側しか更新できないように保護モードに変更
  const protect = sheet.protect();
  //保護内容の説明文章を設定
  protect.setDescription("Unity側のみ更新可能です");
  //保護を入力不可ではなく、入力時に警告を表示
  protect.setWarningOnly(true);

}

最後に Unity側で処理を行った後に、正常に終わったかわからないのでResponseを返すようにします。

以下のようにレスポンスが返ってくるようにGAS側でレスポンスを作成します。

function ReturnResponse(sheetName){
  // レスポンスとしてJsonを返す
  const response = JSON.stringify({ message: "success for " + sheetName });
  let output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);
  output.setContent(response);
  return output;
}

以上の内容は以下のようなプログラムになっています。 (Unity側からPostして、シート作成後データを書き込むコード)

function doPost(e){
  const sheetName = e.parameter.sheetName;
  const postContent = e.postData.getDataAsString();
  const params = JSON.parse(postContent)["Data"]["dialogues"];

  const sheetHeaderInfo = ["character name","character view name","dialogues","commnadIndex","dialogue count"];

  let sheet = SpreadsheetApp.getActive().getSheetByName(sheetName);
  if(sheet == null){
    sheet = NewSheet(sheetName);
  }

  //前回の情報を初期化する
  sheet.clearContents();
  sheet.showColumns(1,26);
  
  SetDialogueData(sheet,params,sheetHeaderInfo);
  SetSheetStyle(sheet,sheetHeaderInfo.length);
  SetProtect(sheet);
  return ReturnResponse(sheetName);
}

function SetProtect(sheet){
  //Unity側しか更新できないように保護モードに変更
  const protect = sheet.protect();
  //保護内容の説明文章を設定
  protect.setDescription("Unity側のみ更新可能です");
  //保護を入力不可ではなく、入力時に警告を表示
  protect.setWarningOnly(true);

}

function SetDialogueData(sheet,dialogue,setHeaderSheetInfo){
  const dialogueLength = dialogue.length;
  let range = sheet.getRange(1,1,dialogueLength + 1,setHeaderSheetInfo.length);
  let dataList = []

  // シートの一番上に列の情報を入力する
  dataList.push(setHeaderSheetInfo);
  for(let i = 0;i < dialogueLength;i++){
    var data = new Array();
    var d = dialogue[i];
    data.push(d["characterName"],d["characterViewName"],d["text"],d["commandIndex"],d["dialogueCount"])
    dataList.push(data)
  }
  range.setValues(dataList);
}

function SetSheetStyle(sheet,sheetRowCount){
  //一番上の行を固定にする
  sheet.setFrozenRows(1);

  //必要のない列を非表示にする
  sheet.hideColumns(sheetRowCount + 1,26 - sheetRowCount);

  //行の幅をそろえる
  sheet.autoResizeColumns(1,sheetRowCount);
}

function NewSheet(sheetName){
  //スプレッドシートに新しいシートを追加挿入
  let newSheet = SpreadsheetApp.getActiveSpreadsheet().insertSheet();
  //追加挿入したシートに名前を設定
  newSheet.setName(sheetName);
  return newSheet;
}

function ReturnResponse(sheetName){
  // レスポンスとしてJsonを返す
  const response = JSON.stringify({ message: "success for " + sheetName });
  let output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);
  output.setContent(response);
  return output;
}

3. セリフデータの分析及び一覧化

キャラシートが数十枚あるので、以下のように一覧にしてわかりやすく見れるシートを作成します。

処理の流れは以下のようになっています 1. 自分自身以外のすべてのシートの取得 2. 各シートからキャラ名・表示名・セリフ文字を取得してグローバル変数に追加 3. (行と列が逆になっているため)辞書型リストからSheetに書き込むためのArrayListに変換

//character nameとcharacte view nameを紐づいている変数
let characterName = {}

// charcter nameとセリフ文字数を紐づいている変数
let characterDialogueCount = {}

// character nameとハイパーリンク
let characterSheetLinkList = {}

//行と列を入れ替える関数
const transpose = a=> a[0].map((_, c) => a.map(r => r[c]));

//自身のスプレッドシートid
let ssId = "";

function UpdateSummary(){
  var allSheets = SpreadsheetApp.getActiveSpreadsheet().getSheets();
  const sheet = SpreadsheetApp.getActiveSpreadsheet();
  ssId = sheet.getId();
  let summarySheet;
  // 一覧シートに書き込む範囲
  let editRange;

  var sheetCount = allSheets.length;
  for(let i = 0;i < sheetCount;i++){
    var sheetName = allSheets[i].getName();
    var targetSheet = sheet.getSheetByName(sheetName);
    if(sheetName === "キャラ一覧"){
      editRange = targetSheet.getRange(2,1,targetSheet.getLastRow(),3);
      editRange.clearContent();
      summarySheet = targetSheet;
    }else
    {
      // 各カードイベントのセリフシートからデータの取得と更新
      GetData(targetSheet);
    }
  }

  //データの書き込み
  let [nameList,characterViewName,link] = [Object.keys(characterName),Object.values(characterName),Object.values(characterSheetLinkList)]
  let count = Object.values(characterDialogueCount);
  let nameAndCountData = []
  nameAndCountData.push(nameList)
  nameAndCountData.push(characterViewName)
  nameAndCountData.push(count)
  nameAndCountData.push(link)
  editRange = summarySheet.getRange(2,1,nameList.length,nameAndCountData.length);
  editRange.setValues(transpose(nameAndCountData))
}

function GetData(sheet){
  if(sheet.getLastRow() <= 1) return;
  console.log("sheet name:" + sheet.getName())
  var npcInfoListInSheet = sheet.getRange(2,1,sheet.getLastRow() - 1,5).getValues();
  for(var i = 0;i < npcInfoListInSheet.length;i++){
    //キャラ名と表示名の更新処理
    var characterInfo = npcInfoListInSheet[i];
    var name = characterInfo[0];
    if(!characterName[name]){
      characterName[name] = characterInfo[1]
    }
    // キャラ名とセリフ文字数更新処理
    if(characterDialogueCount[name]){
      characterDialogueCount[name] = characterDialogueCount[name] + characterInfo[4];
    }else{
      characterDialogueCount[name] = characterInfo[4];
    }

    // シートのURLからハイパーリンク文字列を組み立て
    if(characterSheetLinkList[name] == null){
      let url = "https://docs.google.com/spreadsheets/d/" + ssId + "/edit#gid=" + sheet.getSheetId();
      characterSheetLinkList[name] = '=HYPERLINK("' + url + '","' + name + '")'
    }
  }
}