- - はじめに
- - Google SpreadsheetからJSONへ
- - 各シートから1対多の箇所をマッピング
- - JSONからクラスファイルを作成する
- - JSONからScriptableObjectへ
目次
はじめに
ゲーム構築を劇的にスマートにするScriptableObjectの 3つの活用方法やそちらに影響を受けて作られたアセットScriptableObject-Architectureを見ていいると、ScriptableObjectを値ごとに細かく分けて使われていました。(これはあくまで例なので実際は複数の値をまとめていたり、複数のScriptableObjectをまとめたものも利用されているかもしれません。)
複数人での開発や動かしながら1オブジェクトごと調整したりと、細かくファイルを分ける利点は拡張性/保守性を考えると必須だと思います。しかしながら私は一人で開発していることもあり、開発スピードも考慮するとゲームバランスに関するマスターデータは1ファイルとしてGoogle Spreadsheetに一元管理することにしました。
Unity-QuickSheetなら、How to work with Google Spreadsheeに記載があるようにUnity上から直接インポートし、ScriptableObjectを作成することができます。
しかし複数シートに渡った1対多のデータをゲーム側で都度探すことになりそうと思ったので一度JSONに変換することにしました。
ちなみにglideというサービスでは、Google Spreadsheet連携にてリレーションを作成することができるようです。Introduction to relations簡単にアプリが作れるといったサービスなので今回は用途が異なりますがとても便利そうです。
Google SpreadsheetからJSONへ
Node.js Quickstartに記載されているコードをTypeScriptに書き換えts-nodeで実行してみました。(初回はURLが出力されWeb上でログインすると表示されるコードを入力するように求められます。2回目以降はtoken.jsonに保存されるのでそのままデータを取得することができます。)
上記サンプルの取得部分を書き換え一度に全てのシート取得するようにしました。
const SPREAD_SHEET_ID = 'xxxx'
const fetchSpreadSheets = async (auth: OAuth2Client) => {
const sheets = google.sheets({ version: 'v4', auth })
try {
//すべてのシート名を取得
const allSheetNames: string[] =
(
await sheets.spreadsheets.get({
spreadsheetId: SPREAD_SHEET_ID,
ranges: [],
})
).data.sheets?.flatMap?.((v) => v.properties?.title || '') || []
//すべてのシート、すべての範囲を取得
const data = (
await sheets.spreadsheets.values.batchGet({
spreadsheetId: SPREAD_SHEET_ID,
ranges: allSheetNames,
})
).data.valueRanges
return data
} catch (e) {
console.log(`Data Not Found: ${e}`)
}
}
レスポンス1シート分を抜粋
[{
"range": "BuildingConstructionCost!A1:Z1000",
"majorDimension": "ROWS",
"values": [
["id", "building_id", "item_id", "amount"],
["1", "tent", "gold", "100"],
["1", "tent", "wood", "30"]
]
}]
各シートから1対多の箇所をマッピング
各シートにはidを必須にし、今回はbuilding_idのセルを対象にJOINさせています。シート数が増えてきたら辛くなってきそうですね。。。
import { sheets_v4 } from 'googleapis/build/src/apis/sheets/v4'
// 必要最低限の型定義
interface InteractionItem {
id: string
building_id: string
}
interface Building {
id: string
constructionCosts: InteractionItem[]
}
const convertValue = (value: string) => {
if (value === 'FALSE') {
return false
}
if (value === 'TRUE') {
return true
}
return value
}
const valuesToDict = <T extends { id: string }>(
values: string[][]
): T[] => {
const headLine = values[0]
return values.reduce<T[]>((resultArray, row, index) => {
if (index != 0 && row[0]) {
const result = row.reduce((dic, value, index) => {
return { ...dic, [headLine[index]]: convertValue(value) }
}, {})
resultArray.push(result as T)
}
return resultArray
}, [])
}
const filterWithName = (data: sheets_v4.Schema$ValueRange, name: string) => {
return data.range && data.values && data.range.split('!')[0] === name
}
const aggregateOfGameData = (data: sheets_v4.Schema$ValueRange[]): Data => {
const buildings = valuesToDict<Building>(
data.filter((v) => filterWithName(v, 'Building'))[0].values || []
)
const buildingConstructionCost = valuesToDict<InteractionItem>(
data.filter((v) => filterWithName(v, 'BuildingConstructionCost'))[0].values || []
)
buildings.map((building) => {
building.constructionCosts = buildingConstructionCost.filter(
(v) => v.building_id === building.id
)
})
return {
buildings
}
}
呼び出し側にてJSONファイルへ保存しています。
import fs from 'fs'
import path from 'path'
const EXPORT_JSON_PATH = path.resolve(process.cwd(), 'master-data.json')
// dataはSpreadsheetから取得したvalueRanges
const masterData = aggregateOfGameData(data)
fs.writeFileSync(
EXPORT_JSON_PATH,
JSON.stringify(masterData, null, '\t'),
'utf8'
)
これで1対多のデータも表現することができます。(この場合はテントを作るのにGoldが100と木材が30必要)
{
"buildings": [{
"id": "tent",
"name": "Tent",
"category": "refuge",
"constructionCosts": [{
"id": "1",
"building_id": "tent",
"item_id": "gold",
"amount": "100"
},
{
"id": "1",
"building_id": "tent",
"item_id": "wood",
"amount": "30"
}
]
}]
}
JSONからクラスファイルを作成する
こちらのJSONファイルからC#クラスを生成するために、quicktypeを使いました。 C#以外にも様々な言語に対応していてオプションも豊富でした。quicktype appにてWebからも利用できます。
今回はJSONから変換しましたが内容に不満が出てきたら、TypeScriptに全定義を記載しそちらからの変換も試してみたいと思います。
import { main as quickType } from 'quicktype'
const EXPORT_CS_PATH = path.resolve(process.cwd(), 'MasterData.cs')
const generateCSFile = async () => {
await quickType({
lang: 'C#',
topLevel: 'MasterData',
src: [EXPORT_JSON_PATH],
srcLang: 'json',
out: EXPORT_CS_PATH,
noRender: false,
rendererOptions: {
namespace: 'AutoGenerated',
'csharp-version': '6',
dense: 'normal',
'array-type': 'array',
'number-type': 'double',
features: 'complete',
},
quiet: false,
})
// { get; set; } では正常に動かなかったので削除
const output = fs
.readFileSync(EXPORT_CS_PATH, 'utf8')
.replace(/ { get; set; }/g, ';')
fs.writeFileSync(EXPORT_CS_PATH, output, 'utf8')
}
本来getterとsetterを分けるべきとは思いますが、ScriptableObjectに変換した際にAssetへの値が保持されなかったので正規表現で削除しました。
// ×
public string Id { get; set; }
// ○
public string Id;
// ○
string id;
public string Id { get {return id; } set { id = value;} }
JSONからScriptableObjectへ
だいぶ長い道のりでしたがようやく本題です。UnityプロジェクトのAssets/Scripts/AutoGenerated/
に先程変換したMasterData.csと下記MasterDataScriptableObject.csを配置しました。ScriptableObjectの実際の値は.assetファイル内にyaml形式にて保存されます。そのためC#に変換したクラスattributeにSerializableを記載しました。幸いpartial型で生成してくれていたので、自動生成されたMasterData.csファイルを手動で編集せずに済みました。
using AutoGenerated;
using UnityEngine;
using System;
namespace AutoGenerated
{
[Serializable]
public partial class MasterData {}
[Serializable]
public partial class Building {}
[Serializable]
public partial class Item {}
[Serializable]
public partial class ConstructionCost {}
}
[System.Serializable]
public class MasterDataScriptableObject: ScriptableObject
{
public MasterData Data;
}
UnityではAssetsフォルダ内の任意の場所にEditorフォルダを設けることで開発用の機能拡張ができ、その中はビルドに含まれないようになっています SpecialFolders。こちらにAssets/Scripts/AutoGenerated/Editor
JSONファイルと下記MasterDataAssetPostProcessor.csを用意しました。
using AutoGenerated;
using UnityEngine;
using UnityEditor;
using System.IO;
public class MasterDataAssetPostProcessor: AssetPostprocessor
{
private static readonly string filePath = "Assets/Scripts/AutoGenerated/Editor/master-data.json";
private static readonly string assetFilePath = "Assets/Scripts/AutoGenerated/MasterData.asset";
static void OnPostprocessAllAssets (string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
{
foreach (string asset in importedAssets)
{
if (!filePath.Equals (asset))
continue;
// master-data.jsonが作成/変更された際に実行される
Debug.Log("Master Data Changed!");
MasterDataScriptableObject data = (MasterDataScriptableObject)AssetDatabase.LoadAssetAtPath(assetFilePath, typeof(MasterDataScriptableObject));
if (data == null) {
// MasterDataScriptableObjectのAssetがなければ作成
data = ScriptableObject.CreateInstance<MasterDataScriptableObject> ();
AssetDatabase.CreateAsset((MasterDataScriptableObject)data, assetFilePath);
}
// JSONファイルからMasterDataに変換しAssetに代入
string json = File.ReadAllText(asset);
MasterData masterData = MasterData.FromJson(json);
data.Data = masterData;
// データが保存されるようマークする
ScriptableObject obj = AssetDatabase.LoadAssetAtPath (assetFilePath, typeof(ScriptableObject)) as ScriptableObject;
EditorUtility.SetDirty (obj);
}
}
}
作成されたMasterData.assetをEditor上で見るとネストの表現やenum,boolといった型もわかりやすく表示されています。