27.10.2026 22 мин чтения Просмотров:0

Баланс из Google Таблиц в Unity: импорт CSV в ScriptableObject

Собираем импортёр, который скачивает листы Google Таблицы в CSV, проверяет все данные до первой записи и обновляет ScriptableObject-ассеты на месте, не ломая ссылки в префабах и сценах.

Баланс из Google Таблиц в Unity: импорт CSV в ScriptableObject

В этой статье соберём рабочий конвейер «гейм-дизайнер правит цифры в 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 checkout CSV-файла и повторным импортом;
  • можно положить в папку вручную подготовленный 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, а лишние ассеты не удаляются, а попадают в отчёт. Начните с одного листа — например, оружия, — обкатайте процесс с дизайнером, и следующие листы добавятся за полчаса каждый: структура строки, функция разбора и функция заполнения ассета.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также