はじめに
Unityで会話実装を行う際にFungusを使用した会話機能の実装を以下のゲームで行いました。
しかし、セリフデータをFungus内のflowchartに入れてしまうとローカライズ対応やボイス実装の際に毎回確認や修正をするたびにUnityを開ける必要があります。手動だと大変なので一覧化してみようと思いましたので、実装方法を記事にしました
Demo
Inspectorにあるボタンを押すことで、ヒエラルキー上のすべてのflowchart内のセリフを Google Sheet上に反映することができます。
またキャラ数が数十体あるため、各シートの一覧化及びシートリンクも自動的に追加されるようになっています。
やりたいこと
- シーン上に存在する会話するNPCのセリフを取得
- セリフデータを Google Sheetへの反映
- 新規キャラがいる場合の、新規シート作成
- キャラクターシートを保護モードにして、変更できないようにする
- 各セリフデータやキャラの分析及び一覧化
- 一番上にパラメータ名の追加と行の固定
環境
- Unity 2022.2.0f1
- Fungus v3.13.8
- Google Sheet(2022/12 ~ 2023/1月時点のもの)
- Google Apps Script(2022/12 ~ 2023/1月時点のもの)
実装
1. シーン上に存在する会話NPCのセリフの取得
こちらは以下の記事で fungusのセリフデータが格納されているコンポーネント(flowchart)をjson形式に出力する方法について詳しく書いていますので、以下をご覧ください
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 + '")' } } }