В этой статье соберём рабочий конвейер «гейм-дизайнер правит цифры в Google Таблице, программист нажимает одну кнопку в Unity»: скачиваем лист как CSV, разбираем его парсером, который не ломается на кавычках и запятых, проверяем данные целиком до первой записи и обновляем ScriptableObject-ассеты так, чтобы ссылки на них в префабах и сценах не слетали. Весь код — обычный C# для редактора, без сторонних пакетов, его можно забрать в проект почти как есть.
Зачем держать баланс в таблице
Пока в игре пять видов оружия, их удобно настраивать прямо в инспекторе. Когда оружия становится полсотни, а к нему добавляются враги, уровни прокачки и цены в магазине, инспектор превращается в тюрьму: чтобы сравнить урон двух пушек, нужно кликать между ассетами, а чтобы поднять все цены на 10 %, — править каждый файл руками. В таблице то же самое делается формулой за секунду.
Есть и командная причина. Гейм-дизайнеру не нужно ставить Unity и разбираться в Git, чтобы поменять урон. Он работает там, где ему удобно: видит всю картину, пишет формулы, строит графики кривых, оставляет комментарии. Программист же получает данные в типизированном виде, с проверками, и ассеты, на которые можно ссылаться из кода и префабов.
Главное решение, которое стоит принять заранее: таблица — источник правды только для цифр и текстов. Иконки, префабы, звуки и прочие ссылки на ассеты Unity живут в самих ScriptableObject и импортом не перезаписываются. Иначе одно нажатие кнопки сотрёт ручную работу художника.
Правила для таблицы
Импортёр будет надёжным ровно настолько, насколько предсказуема таблица. Договоритесь с командой о нескольких правилах и запишите их на отдельном листе прямо в документе:
- Первая строка — заголовки. Код ищет колонки по имени, а не по номеру, поэтому их можно переставлять и добавлять служебные колонки между ними.
- Есть колонка
idс постоянным строковым идентификатором:pistol,shotgun_mk2. По нему импортёр находит существующий ассет. Название для игрока — отдельная колонка, его можно менять сколько угодно. - Строки, где
idначинается с#, — комментарии, пустые строки пропускаются. Это позволяет дизайнеру временно выключить запись, не удаляя её. - Служебные колонки (расчёты, заметки, DPS для сравнения) начинаются с подчёркивания:
_dps,_note. Импортёр их игнорирует, а при неизвестном заголовке без подчёркивания пишет предупреждение — так ловятся опечатки вродеdamge. - Числа без разделителей тысяч и без процентов. Не
1 500и не15%, а1500и0.15. Формат ячеек «Обычный» или «Число» без группировки разрядов. - Один лист — один тип данных: «Оружие», «Враги», «Уровни».
Отдельно про десятичную запятую. CSV-экспорт Google Таблиц выдаёт значения так, как они отформатированы в ячейках, а это зависит от региональных настроек документа. В русской локали 1,5 уйдёт в CSV как "1,5" в кавычках. Самый спокойный вариант — переключить документ на английскую локаль (меню «Файл → Настройки», региональные настройки). Парсер ниже всё равно понимает оба варианта, но лучше, чтобы данные были однозначными с самого начала.
Как скачать лист в CSV
У каждого листа Google Таблицы есть адрес экспорта:
https://docs.google.com/spreadsheets/d/<ID_ДОКУМЕНТА>/export?format=csv&gid=<GID_ЛИСТА>
ID документа — длинная строка в его адресе между /d/ и /edit. GID — число после #gid= в адресе, когда открыт нужный лист; у первого листа это обычно 0. Чтобы ссылка отдавала файл без входа в аккаунт, в настройках доступа документа нужно выбрать «Все, у кого есть ссылка» с ролью «Читатель».
Подумайте, допустимо ли это для вашего проекта. Цифры баланса редко бывают секретом, но если в документе есть тексты невышедшего контента или заметки, которые не должны утечь, такой режим не подходит. Для закрытых таблиц используют Google Sheets API с сервисным аккаунтом: документ расшаривается на e-mail сервисного аккаунта, а редактор получает токен по его ключу. Это заметно больше кода, и ключ сервисного аккаунта ни в коем случае нельзя коммитить в репозиторий. Для большинства инди-проектов достаточно открытой ссылки на документ, где лежат только цифры.
Если Google вернул не CSV, а HTML-страницу входа, значит, доступа по ссылке нет. Это нужно проверять явно, иначе парсер честно разберёт HTML как одну огромную колонку и выдаст непонятную ошибку.
Где хранить скачанные данные
Не стоит импортировать прямо из сети в ассеты. Гораздо удобнее разделить процесс на два шага: сначала скачать CSV в папку проекта, затем импортировать ассеты из этих файлов. Папку с CSV коммитим в Git вместе с ассетами.
Что это даёт на практике:
- в истории Git видно, какие именно цифры поменялись, а не только что изменился бинарно-нечитаемый YAML ассета;
- импорт можно повторить без интернета и без доступа к документу — например, на сборочном сервере;
- можно откатить баланс к любой версии обычным
git checkoutCSV-файла и повторным импортом; - можно положить в папку вручную подготовленный CSV, если таблица временно недоступна.
Файлы кладём вне Assets, например в BalanceData/ рядом с ним. Так Unity не будет их импортировать как TextAsset и не создаст лишних .meta.
Настройки импорта
ID документа и GID листов не стоит зашивать в код. Заведём ScriptableObject с настройками, который лежит в папке Editor и в сборку не попадает:
using System;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Balance Settings")]
public class BalanceSettings : ScriptableObject
{
public string spreadsheetId;
public string csvFolder = "BalanceData";
public string assetsFolder = "Assets/Data/Balance";
public SheetSource[] sheets =
{
new SheetSource { name = "weapons", gid = "0" },
new SheetSource { name = "enemies", gid = "123456789" },
};
}
[Serializable]
public class SheetSource
{
public string name; // имя CSV-файла и ключ, по которому импортёр понимает тип данных
public string gid;
}
Парсер CSV, который не ломается на кавычках
Первое, что пишут для CSV, — line.Split(','). Это работает ровно до первого описания с запятой внутри. По стандарту RFC 4180 поле, в котором есть запятая, кавычка или перевод строки, берётся в двойные кавычки, а кавычка внутри поля удваивается. Google Таблицы экспортируют именно так, и многострочное описание предмета в одной ячейке — обычное дело.
Парсер посимвольный, с одним флагом «мы внутри кавычек». Заодно он срезает BOM в начале файла и понимает переводы строк и в стиле Windows, и в стиле Unix:
using System;
using System.Collections.Generic;
using System.Text;
public static class CsvParser
{
public static List<string[]> Parse(string text, char separator = ',')
{
var rows = new List<string[]>();
var row = new List<string>();
var field = new StringBuilder();
bool inQuotes = false;
int i = text.Length > 0 && text[0] == '' ? 1 : 0;
for (; i < text.Length; i++)
{
char c = text[i];
if (inQuotes)
{
if (c == '"')
{
// "" внутри кавычек — это одна кавычка в данных
if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; }
else inQuotes = false;
}
else field.Append(c);
continue;
}
if (c == '"') inQuotes = true;
else if (c == separator) { row.Add(field.ToString()); field.Clear(); }
else if (c == '\n')
{
row.Add(field.ToString()); field.Clear();
rows.Add(row.ToArray()); row.Clear();
}
else if (c != '\r') field.Append(c);
}
if (inQuotes)
throw new FormatException("CSV: незакрытая кавычка, файл обрезан или повреждён");
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row.ToArray());
}
return rows;
}
}
Обратите внимание: перевод строки внутри кавычек остаётся частью поля, а \r снаружи кавычек просто отбрасывается. Ошибка на незакрытой кавычке важна: она почти всегда означает, что файл скачался не полностью, и молча импортировать половину таблицы гораздо хуже, чем остановиться.
Парсер легко покрыть unit-тестами в Edit Mode: строка с запятой в кавычках, удвоенная кавычка, многострочное поле, пустые поля в конце строки, файл без завершающего перевода строки. Это пять коротких тестов, которые раз и навсегда снимают вопрос «а вдруг парсер».
Таблица с доступом по именам колонок
Сырой List<string[]> неудобен: код вида cells[3] сломается, как только дизайнер вставит колонку. Обернём данные в класс, который знает заголовки и номера строк, и соберём отчёт об ошибках вместо исключений. Исключение остановит импорт на первой проблеме, а дизайнеру гораздо полезнее увидеть сразу все двенадцать опечаток.
using System;
using System.Collections.Generic;
using System.Globalization;
public sealed class ImportReport
{
public readonly List<string> Errors = new();
public readonly List<string> Warnings = new();
public bool HasErrors => Errors.Count > 0;
}
public sealed class SheetTable
{
public readonly string Name;
public readonly List<SheetRow> Rows = new();
readonly Dictionary<string, int> _columns = new(StringComparer.OrdinalIgnoreCase);
public SheetTable(string name, List<string[]> raw, ImportReport report)
{
Name = name;
if (raw.Count == 0) { report.Errors.Add($"{name}: лист пустой"); return; }
string[] header = raw[0];
for (int c = 0; c < header.Length; c++)
{
string title = header[c].Trim();
if (title.Length == 0 || title.StartsWith("_")) continue;
if (!_columns.TryAdd(title, c))
report.Errors.Add($"{name}: колонка «{title}» встречается дважды");
}
if (!_columns.ContainsKey("id"))
report.Errors.Add($"{name}: нет колонки id");
for (int r = 1; r < raw.Count; r++)
{
var row = new SheetRow(this, raw[r], r + 1, report);
string id = row.Raw("id");
if (string.IsNullOrWhiteSpace(id) || id.StartsWith("#")) continue;
Rows.Add(row);
}
}
public bool TryGetColumn(string name, out int index) => _columns.TryGetValue(name, out index);
public IEnumerable<string> Columns => _columns.Keys;
}
Номер строки хранится сразу в «человеческом» виде, с учётом заголовка: ошибка «weapons, строка 14» должна указывать ровно на ту строку, которую дизайнер увидит в таблице.
Теперь сама строка с типизированными геттерами. Все числа разбираются через CultureInfo.InvariantCulture: без этого на компьютере с русской Windows float.Parse("1.5") бросит исключение, а на английской сработает — классический баг «у меня всё работает».
public readonly struct SheetRow
{
readonly SheetTable _table;
readonly string[] _cells;
readonly ImportReport _report;
public readonly int Line;
public SheetRow(SheetTable table, string[] cells, int line, ImportReport report)
{
_table = table; _cells = cells; Line = line; _report = report;
}
public string Raw(string column) =>
_table.TryGetColumn(column, out int i) && i < _cells.Length ? _cells[i].Trim() : "";
public void Error(string column, string message) =>
_report.Errors.Add($"{_table.Name}, строка {Line}, {column}: {message}");
public string String(string column, bool required = true)
{
string value = Raw(column);
if (required && value.Length == 0) Error(column, "пустое значение");
return value;
}
public int Int(string column, int min = int.MinValue, int max = int.MaxValue)
{
string value = Raw(column);
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result))
{
Error(column, $"«{value}» не целое число");
return 0;
}
if (result < min || result > max) Error(column, $"{result} вне диапазона {min}..{max}");
return result;
}
public float Float(string column, float min = float.MinValue, float max = float.MaxValue)
{
string value = Raw(column).Replace(',', '.');
if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float result))
{
Error(column, $"«{value}» не число");
return 0f;
}
if (result < min || result > max) Error(column, $"{result} вне диапазона {min}..{max}");
return result;
}
public bool Bool(string column)
{
string value = Raw(column).ToLowerInvariant();
switch (value)
{
case "1": case "true": case "да": case "+": return true;
case "0": case "false": case "нет": case "-": case "": return false;
default: Error(column, $"«{value}» не похоже на да/нет"); return false;
}
}
public T Enum<T>(string column) where T : struct, System.Enum
{
string value = Raw(column);
if (System.Enum.TryParse(value, true, out T result) && System.Enum.IsDefined(typeof(T), result))
return result;
Error(column, $"«{value}» нет в {typeof(T).Name}: {string.Join(", ", System.Enum.GetNames(typeof(T)))}");
return default;
}
}
Проверка Enum.IsDefined нужна потому, что Enum.TryParse охотно разберёт строку "42" в значение перечисления, которого не существует. А текст ошибки со списком допустимых значений избавляет дизайнера от вопроса «как правильно пишется».
Замена запятой на точку в Float — страховка на случай русской локали документа. Она безопасна только при договорённости «без разделителей тысяч»: строка 1,000 в английской локали с группировкой разрядов превратилась бы в единицу. Поэтому правило про формат чисел из начала статьи — не формальность.
Данные и промежуточный слой
Ассет оружия — обычный ScriptableObject. Часть полей приходит из таблицы, часть настраивается руками:
using UnityEngine;
public enum WeaponType { Pistol, Rifle, Shotgun, Melee }
[CreateAssetMenu(menuName = "Game/Weapon")]
public class WeaponData : ScriptableObject
{
[Header("Из таблицы")]
public string id;
public string displayName;
public WeaponType type;
public int damage;
public float fireRate;
public float range;
public int price;
public bool inShop;
[Header("Вручную")]
public Sprite icon;
public GameObject prefab;
}
Импорт делаем в две фазы. Сначала разбираем все листы в простые структуры и проверяем их. Если есть хоть одна ошибка, показываем отчёт и не трогаем ни одного ассета. Только когда данные целиком корректны, переходим ко второй фазе — записи. Это принцип «всё или ничего»: полуимпортированный баланс, где оружие уже новое, а враги ещё старые, отлаживать намного неприятнее, чем просто исправить опечатку и нажать кнопку снова.
public struct WeaponRow
{
public string Id, Name;
public WeaponType Type;
public int Damage, Price;
public float FireRate, Range;
public bool InShop;
}
static List<WeaponRow> ParseWeapons(SheetTable table)
{
var result = new List<WeaponRow>();
var ids = new HashSet<string>();
foreach (SheetRow row in table.Rows)
{
string id = row.String("id");
if (!ids.Add(id)) row.Error("id", $"повтор id «{id}»");
result.Add(new WeaponRow
{
Id = id,
Name = row.String("name"),
Type = row.Enum<WeaponType>("type"),
Damage = row.Int("damage", min: 0),
FireRate = row.Float("fire_rate", min: 0.01f, max: 50f),
Range = row.Float("range", min: 0f),
Price = row.Int("price", min: 0),
InShop = row.Bool("in_shop"),
});
}
return result;
}
Диапазоны в геттерах — дешёвая и очень полезная защита. Скорострельность 0 даст деление на ноль в коде стрельбы, а урон -50 внезапно начнёт лечить врагов. Такие ошибки лучше ловить в редакторе, а не на тестировании.
Ссылки между листами
Враги ссылаются на оружие: в листе «Враги» есть колонка weapon с id оружия. Проверять эту ссылку нужно по данным таблицы, а не по ассетам: новое оружие ещё не создано, оно появится только во второй фазе.
public struct EnemyRow
{
public string Id, Name, WeaponId;
public int Health, Reward;
public float Speed;
}
static List<EnemyRow> ParseEnemies(SheetTable table, HashSet<string> weaponIds)
{
var result = new List<EnemyRow>();
var ids = new HashSet<string>();
foreach (SheetRow row in table.Rows)
{
string id = row.String("id");
if (!ids.Add(id)) row.Error("id", $"повтор id «{id}»");
string weaponId = row.String("weapon", required: false);
if (weaponId.Length > 0 && !weaponIds.Contains(weaponId))
row.Error("weapon", $"оружия «{weaponId}» нет на листе weapons");
result.Add(new EnemyRow
{
Id = id,
Name = row.String("name"),
Health = row.Int("health", min: 1),
Speed = row.Float("speed", min: 0f, max: 30f),
Reward = row.Int("reward", min: 0),
WeaponId = weaponId,
});
}
return result;
}
Поэтому порядок разбора важен: сначала оружие, потом враги, которые на него ссылаются. Если зависимостей станет много, держите их порядок явным списком в импортёре, а не полагайтесь на порядок листов в документе.
Запись в ассеты без потери ссылок
Самая частая ошибка самодельных импортёров — удалять старые ассеты и создавать новые. У нового ассета новый GUID, и все ссылки на него из префабов, сцен и других ScriptableObject превращаются в Missing. Правильный путь — найти существующий ассет по id и обновить его поля на месте. GUID при этом не меняется, ссылки живут.
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
static Dictionary<string, T> LoadById<T>(Func<T, string> getId, ImportReport report) where T : ScriptableObject
{
var result = new Dictionary<string, T>();
foreach (string guid in AssetDatabase.FindAssets("t:" + typeof(T).Name))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if (asset == null) continue;
string id = getId(asset);
if (string.IsNullOrEmpty(id))
report.Warnings.Add($"{path}: пустой id, ассет не связан с таблицей");
else if (!result.TryAdd(id, asset))
report.Errors.Add($"{path}: id «{id}» уже занят ассетом {AssetDatabase.GetAssetPath(result[id])}");
}
return result;
}
Поиск по типу, а не по папке, — сознательный выбор: если кто-то перенёс ассет в другую папку, импортёр всё равно его найдёт и не создаст дубликат. Два ассета с одинаковым id — ошибка, которая обычно появляется после дублирования ассета через Ctrl+D; её тоже лучше поймать до записи.
Теперь применение данных. Чтобы не помечать изменёнными все ассеты подряд (и не засорять коммиты файлами, в которых ничего не поменялось), сравним сериализованное состояние до и после заполнения. EditorJsonUtility.ToJson сериализует объект теми же правилами, что и инспектор, так что сравнение честное:
static bool Fill<T>(T asset, Action<T> fill) where T : ScriptableObject
{
string before = EditorJsonUtility.ToJson(asset);
fill(asset);
if (EditorJsonUtility.ToJson(asset) == before) return false;
EditorUtility.SetDirty(asset);
return true;
}
static void ApplyWeapons(List<WeaponRow> rows, Dictionary<string, WeaponData> existing,
string folder, ImportStats stats)
{
var seen = new HashSet<string>();
foreach (WeaponRow r in rows)
{
seen.Add(r.Id);
if (!existing.TryGetValue(r.Id, out WeaponData asset))
{
asset = ScriptableObject.CreateInstance<WeaponData>();
asset.id = r.Id;
AssetDatabase.CreateAsset(asset, $"{folder}/Weapon_{r.Id}.asset");
existing[r.Id] = asset;
stats.Created++;
}
bool changed = Fill(asset, a =>
{
a.displayName = r.Name;
a.type = r.Type;
a.damage = r.Damage;
a.fireRate = r.FireRate;
a.range = r.Range;
a.price = r.Price;
a.inShop = r.InShop;
});
if (changed) stats.Changed++;
}
foreach (var pair in existing)
if (!seen.Contains(pair.Key))
stats.Orphans.Add(AssetDatabase.GetAssetPath(pair.Value));
}
public sealed class ParsedBalance
{
public List<WeaponRow> Weapons;
public List<EnemyRow> Enemies;
}
public sealed class ImportStats
{
public int Created, Changed;
public readonly List<string> Orphans = new();
}
Ассеты, которых больше нет в таблице, импортёр не удаляет, а только перечисляет в отчёте. Строку могли удалить по ошибке, а на ассет может ссылаться уровень. Удаление — решение человека, а не скрипта.
Враги заполняются так же, только ссылку на оружие разрешаем по словарю existing оружия, который к этому моменту уже содержит и только что созданные ассеты:
bool changed = Fill(asset, a =>
{
a.displayName = r.Name;
a.health = r.Health;
a.speed = r.Speed;
a.reward = r.Reward;
a.weapon = r.WeaponId.Length > 0 ? weapons[r.WeaponId] : null;
});
Пакетная запись
Каждый CreateAsset по умолчанию запускает импорт отдельно, и на сотне новых ассетов редактор заметно задумается. Вызовы стоит обернуть в AssetDatabase.StartAssetEditing и StopAssetEditing — тогда импорт произойдёт одним пакетом в конце. Пара обязательно в try/finally: если между ними вылетит исключение и StopAssetEditing не вызовется, редактор перестанет замечать изменения файлов до перезапуска.
static void WriteAssets(ParsedBalance data, BalanceSettings settings, ImportReport report)
{
EnsureFolder(settings.assetsFolder);
var weapons = LoadById<WeaponData>(w => w.id, report);
var enemies = LoadById<EnemyData>(e => e.id, report);
if (report.HasErrors) return;
var stats = new ImportStats();
AssetDatabase.StartAssetEditing();
try
{
ApplyWeapons(data.Weapons, weapons, settings.assetsFolder, stats);
ApplyEnemies(data.Enemies, enemies, weapons, settings.assetsFolder, stats);
}
finally
{
AssetDatabase.StopAssetEditing();
}
AssetDatabase.SaveAssets();
Debug.Log($"Баланс: создано {stats.Created}, изменено {stats.Changed}");
foreach (string path in stats.Orphans)
Debug.LogWarning($"Баланс: ассета нет в таблице — {path}", AssetDatabase.LoadMainAssetAtPath(path));
}
static void EnsureFolder(string path)
{
if (AssetDatabase.IsValidFolder(path)) return;
string parent = System.IO.Path.GetDirectoryName(path).Replace('\\', '/');
EnsureFolder(parent);
AssetDatabase.CreateFolder(parent, System.IO.Path.GetFileName(path));
}
Папку создаём до StartAssetEditing: внутри пакета только что созданная папка ещё не видна базе ассетов, и CreateAsset в неё может не сработать. Второй аргумент LogWarning — сам ассет: клик по сообщению в консоли подсветит его в окне проекта.
Скачивание из редактора
Для загрузки в редакторе удобно использовать обычный HttpClient. Экспорт Google отвечает перенаправлением на другой адрес, и HttpClient по умолчанию сам по нему проходит. После await в редакторе выполнение возвращается в главный поток через контекст синхронизации Unity, поэтому дальше можно спокойно вызывать AssetDatabase.
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
static async Task<string> DownloadSheet(string spreadsheetId, string gid)
{
string url = $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/export?format=csv&gid={gid}";
using HttpResponseMessage response = await Http.GetAsync(url);
response.EnsureSuccessStatusCode();
byte[] bytes = await response.Content.ReadAsByteArrayAsync();
string text = Encoding.UTF8.GetString(bytes);
string head = text.TrimStart().Substring(0, Math.Min(20, text.TrimStart().Length)).ToLowerInvariant();
if (head.StartsWith("<!doctype") || head.StartsWith("<html"))
throw new InvalidOperationException(
$"Лист gid={gid}: вместо CSV пришла веб-страница. Проверьте доступ «Все, у кого есть ссылка».");
return text;
}
Тело ответа декодируем как UTF-8 явно. Экспорт и так отдаёт UTF-8, но явное декодирование убирает зависимость от того, правильно ли клиент понял заголовок с кодировкой, а кириллица в названиях — первое, что ломается при путанице.
Скачанные листы сохраняем во временные файлы и только после успешной загрузки всех листов заменяем ими старые. Так оборванное соединение на третьем листе не оставит в папке смесь новых и старых данных:
static async Task DownloadAll(BalanceSettings settings)
{
Directory.CreateDirectory(settings.csvFolder);
var downloaded = new List<(string tmp, string target)>();
for (int i = 0; i < settings.sheets.Length; i++)
{
SheetSource sheet = settings.sheets[i];
EditorUtility.DisplayProgressBar("Баланс", $"Скачиваю {sheet.name}", (float)i / settings.sheets.Length);
string csv = await DownloadSheet(settings.spreadsheetId, sheet.gid);
CsvParser.Parse(csv); // проверка, что файл целый, до записи на диск
string target = Path.Combine(settings.csvFolder, sheet.name + ".csv");
string tmp = target + ".tmp";
File.WriteAllText(tmp, csv, new UTF8Encoding(false));
downloaded.Add((tmp, target));
}
foreach (var (tmp, target) in downloaded)
{
if (File.Exists(target)) File.Delete(target);
File.Move(tmp, target);
}
}
Кнопки в меню
Осталось связать всё вместе. Делаем две команды: «скачать и импортировать» для обычной работы и «импортировать из файлов» — для работы без сети, после переключения ветки в Git и для сборочного сервера.
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
public static class BalanceImporter
{
[MenuItem("Tools/Balance/Download and Import")]
static async void DownloadAndImport()
{
BalanceSettings settings = FindSettings();
if (settings == null) return;
try
{
await DownloadAll(settings);
ImportFromFiles(settings);
}
catch (Exception e)
{
Debug.LogException(e);
EditorUtility.DisplayDialog("Баланс", "Не удалось скачать таблицу:\n" + e.Message, "OK");
}
finally
{
EditorUtility.ClearProgressBar();
}
}
[MenuItem("Tools/Balance/Import from Files")]
public static void ImportFromFilesMenu()
{
BalanceSettings settings = FindSettings();
if (settings != null) ImportFromFiles(settings);
}
static void ImportFromFiles(BalanceSettings settings)
{
var report = new ImportReport();
SheetTable Load(string name)
{
string path = Path.Combine(settings.csvFolder, name + ".csv");
return new SheetTable(name, CsvParser.Parse(File.ReadAllText(path)), report);
}
SheetTable weaponsTable = Load("weapons");
SheetTable enemiesTable = Load("enemies");
var data = new ParsedBalance();
data.Weapons = ParseWeapons(weaponsTable);
var weaponIds = new HashSet<string>(data.Weapons.ConvertAll(w => w.Id));
data.Enemies = ParseEnemies(enemiesTable, weaponIds);
if (!report.HasErrors)
WriteAssets(data, settings, report);
foreach (string w in report.Warnings) Debug.LogWarning("Баланс: " + w);
if (report.HasErrors)
{
foreach (string e in report.Errors) Debug.LogError("Баланс: " + e);
EditorUtility.DisplayDialog("Баланс",
$"Импорт отменён, ошибок: {report.Errors.Count}. Ассеты не изменены, подробности в консоли.", "OK");
}
}
static BalanceSettings FindSettings()
{
string[] guids = AssetDatabase.FindAssets("t:" + nameof(BalanceSettings));
if (guids.Length == 1)
return AssetDatabase.LoadAssetAtPath<BalanceSettings>(AssetDatabase.GUIDToAssetPath(guids[0]));
Debug.LogError($"Баланс: нужен ровно один ассет BalanceSettings, найдено {guids.Length}");
return null;
}
}
async void здесь допустим только потому, что это обработчик меню верхнего уровня, и всё его тело обёрнуто в try/catch. Без этого исключение из асинхронного метода ушло бы в никуда, а прогресс-бар повис бы на экране. Предупреждения о неизвестных колонках легко добавить в SheetTable: сравните Columns с набором ожидаемых имён для листа и для каждого лишнего запишите строку в report.Warnings.
Публичный статический ImportFromFilesMenu без параметров удобно вызывать на сборочном сервере через -batchmode -executeMethod BalanceImporter.ImportFromFilesMenu перед сборкой: если CSV в репозитории и ассеты разошлись, сборщик это увидит. Чтобы в пакетном режиме ошибка импорта роняла сборку, а не только писала в лог, вместо диалога вызывайте EditorApplication.Exit(1) при Application.isBatchMode.
Один ассет или много
Мы создаём по ассету на каждую строку. Это удобно, когда на конкретное оружие ссылаются из префабов и уровней: в поле инспектора перетаскивается именно «Дробовик», а не строка shotgun. Плюс ручные поля вроде иконки естественно живут рядом с табличными.
Альтернатива — один ассет-база со списком записей внутри: WeaponDatabase с List<WeaponEntry>. Так проще для данных, на которые никто не ссылается напрямую: таблицы опыта по уровням, кривые цен, константы экономики. Импорт тогда сводится к очистке и заполнению списка, и вопрос сохранения GUID отпадает. Но ссылаться на конкретную запись придётся строковым id, и опечатку в нём компилятор не поймает — нужна отдельная проверка или PropertyDrawer с выпадающим списком.
Хорошая практика — смешивать: сущности, которые таскают в инспекторе (оружие, враги, предметы), — отдельными ассетами; числовые таблицы — одним ассетом на лист.
Чего не делать
- Не качать таблицу в рантайме из сборки. Игра не должна зависеть от доступности Google Docs и от того, что дизайнер прямо сейчас редактирует ячейку. Для удалённой настройки баланса после релиза есть сервисы удалённой конфигурации с версиями и откатом.
- Не держать в таблице ссылки на ассеты по путям. Путь
Assets/Art/Icons/pistol.pngсломается при первом переносе папки. Если иконку нужно задавать из таблицы, договоритесь об id и ищите ассет по имени или через Addressables-адрес, с проверкой в фазе валидации. - Не импортировать молча. Счётчики «создано / изменено / сирот» в консоли — это и есть та обратная связь, по которой видно, что нажатие кнопки сделало то, что ожидалось.
- Не править табличные поля в инспекторе. Следующий импорт их перезапишет. Чтобы не было соблазна, оберните табличные поля в атрибут только для чтения через PropertyDrawer или хотя бы подпишите заголовком «Из таблицы», как в примере выше.
Итог
Рабочая схема выглядит так: дизайнер правит Google Таблицу, программист нажимает «Download and Import», CSV-файлы и изменённые ассеты уходят в один коммит. Внутри всё держится на нескольких простых решениях: колонки ищутся по именам, CSV разбирается по стандарту, а не через Split, числа читаются в инвариантной культуре, все данные проверяются до первой записи, ассеты обновляются на месте по id и сохраняют GUID, а лишние ассеты не удаляются, а попадают в отчёт. Начните с одного листа — например, оружия, — обкатайте процесс с дизайнером, и следующие листы добавятся за полчаса каждый: структура строки, функция разбора и функция заполнения ассета.