はじめに
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);
}
}
外部から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){
const protect = sheet.protect();
protect.setDescription("Unity側のみ更新可能です");
protect.setWarningOnly(true);
}
最後に Unity側で処理を行った後に、正常に終わったかわからないのでResponseを返すようにします。
以下のようにレスポンスが返ってくるようにGAS側でレスポンスを作成します。

function ReturnResponse(sheetName){
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){
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){
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に変換
let characterName = {}
let characterDialogueCount = {}
let characterSheetLinkList = {}
const transpose = a=> a[0].map((_, c) => a.map(r => r[c]));
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];
}
if(characterSheetLinkList[name] == null){
let url = "https://docs.google.com/spreadsheets/d/" + ssId + "/edit#gid=" + sheet.getSheetId();
characterSheetLinkList[name] = '=HYPERLINK("' + url + '","' + name + '")'
}
}
}