Home:0
きままな個人開発ブログ
Google SpreadsheetからJSONファイルとクラス定義を生成しScriptableObjectを作成するまで
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/EditorJSONファイルと下記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といった型もわかりやすく表示されています。

ScriptableObject_MasterData