30.10.2026 16 мин чтения Просмотров:0

Склонение после чисел в Unity: «1 монета, 5 монет» без ошибок

Как выбрать форму слова после числа в русском интерфейсе игры: правило «1, 2, 5» на C#, дроби и сокращения, несколько языков и Smart Strings в Unity Localization с их подводными камнями.

Склонение после чисел в Unity: «1 монета, 5 монет» без ошибок

«Получено 21 монет», «Осталось 1 дней», «У вас 3 новых сообщений» — такие строки встречаются даже в релизных играх, и русскоязычный игрок замечает их сразу. В этой статье разберём, по каким правилам слово меняет форму после числа, напишем небольшой класс для русского языка с тестами, научимся правильно обрабатывать дробные значения и сокращения, а затем посмотрим, как то же самое делается в пакете Unity Localization через Smart Strings и где он может подвести.

Почему «count == 1 ? … : …» не работает

Код почти любого прототипа написан с оглядкой на английский язык, где у существительного две формы: 1 coin и всё остальное — coins. Поэтому первая попытка обычно выглядит так:

label.text = count == 1 ? $"{count} монета" : $"{count} монет";

Для 1, 5 и 10 всё выглядит прилично, а дальше начинаются ошибки: «2 монет», «21 монет», «103 монет». Русскому языку нужно три формы, и выбор зависит не от того, равно ли число единице, а от его последних цифр:

  • one — «1 монета»: число оканчивается на 1, но не на 11 (1, 21, 101, 1001);
  • few — «2 монеты»: оканчивается на 2, 3 или 4, но не на 12, 13, 14 (2, 23, 104);
  • many — «5 монет»: всё остальное целое, включая 0, 11–14 и числа, оканчивающиеся на 0 или 5–9.

Названия категорий взяты из CLDR — общего репозитория языковых данных Unicode, на который опираются ICU, .NET, браузеры и большинство библиотек локализации. В CLDR у русского есть и четвёртая категория, other: она для дробных чисел, о них поговорим отдельно. Для английского там же определены только one и other, для японского и китайского — одна other, а у арабского их шесть. Поэтому решение «на два варианта» ломается не только на русском.

Правило для русского языка на C#

Правило укладывается в несколько строк. Важно брать остаток от модуля числа, а не модуль самого числа: Math.Abs(long.MinValue) бросает OverflowException, а с остатком такой проблемы нет.

using System;

public enum PluralCategory
{
    One,
    Few,
    Many,
    Other
}

public static class RussianPlural
{
    public static PluralCategory Select(long n)
    {
        long mod100 = Math.Abs(n % 100);
        long mod10 = mod100 % 10;

        if (mod10 == 1 && mod100 != 11)
            return PluralCategory.One;

        if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
            return PluralCategory.Few;

        return PluralCategory.Many;
    }

    public static string Pick(long n, string one, string few, string many)
    {
        switch (Select(n))
        {
            case PluralCategory.One: return one;
            case PluralCategory.Few: return few;
            default: return many;
        }
    }
}

Использование:

string word = RussianPlural.Pick(coins, "монета", "монеты", "монет");
label.text = $"{coins} {word}";

Между числом и словом стоит неразрывный пробел  . Без него TextMeshPro при узкой плашке переносит слово на новую строку, а число остаётся висеть в конце предыдущей. Неразрывный пробел есть в стандартных шрифтах, но если вы генерируете свой Font Asset только с кириллицей и латиницей, добавьте в набор символов U+00A0, иначе на его месте появится квадрат.

Порядок форм — один на весь проект

Самая частая ошибка после внедрения такого класса — перепутанный порядок аргументов: где-то пишут «монета, монет, монеты», где-то «монет, монета, монеты». Договоритесь о порядке «1, 2, 5» и держите его везде — в коде, в таблицах переводов, в JSON. Запомнить легко: формы идут так, как вы считаете: одна монета, две монеты, пять монет.

Чтобы не передавать три строки в каждый вызов, удобно завести неизменяемую структуру и разбирать её из строки с разделителем. Такой формат легко хранить в ScriptableObject, CSV или JSON:

public readonly struct PluralForms
{
    public readonly string One;
    public readonly string Few;
    public readonly string Many;

    public PluralForms(string one, string few, string many)
    {
        One = one;
        Few = few;
        Many = many;
    }

    // "монета|монеты|монет"
    public static PluralForms Parse(string packed)
    {
        string[] parts = packed.Split('|');
        if (parts.Length != 3)
            throw new FormatException($"Нужно три формы через '|': \"{packed}\"");

        return new PluralForms(parts[0], parts[1], parts[2]);
    }

    public string For(long n)
    {
        return RussianPlural.Pick(n, One, Few, Many);
    }
}

Разбор делайте один раз — при загрузке данных, а не в Update. Split выделяет память под массив и строки, и если счётчик обновляется каждый кадр, сборщик мусора это заметит.

Склоняется не только существительное

Пока речь о «5 монет», хватает одного слова. Но в живом интерфейсе от числа зависят и соседние слова:

  • «1 новое сообщение», «2 новых сообщения», «5 новых сообщений» — меняется прилагательное;
  • «Остался 1 день», «Осталось 2 дня», «Осталось 5 дней» — меняется глагол;
  • «Найден 1 предмет», «Найдено 3 предмета» — меняется причастие.

Можно попытаться склеивать фразу из кусочков, у каждого из которых свои три формы, но это тупик: переводчик на другой язык не сможет переставить слова, а в коде появится шаблонный ад. Надёжнее хранить три варианта целой фразы с местом под число:

var daysLeft = PluralForms.Parse(
    "Остался {0} день|Осталось {0} дня|Осталось {0} дней");

label.text = string.Format(daysLeft.For(days), days);

Да, текст частично дублируется, зато каждая строка читается целиком, её легко вычитать и перевести, а порядок слов внутри может быть любым. Это же правило действует в любой системе локализации: склонять фразу, а не слово.

Дробные числа: «1,5 монеты», а не «1,5 монет»

С дробями правило другое: после любого числа с дробной частью русский язык требует родительный падеж единственного числа — «0,5 литра», «1,5 километра», «2,7 секунды». Это и есть категория other из CLDR. По форме она совпадает с few («2 монеты» и «1,5 монеты»), поэтому отдельная четвёртая строка не нужна: для дробей достаточно взять форму few.

Тонкость в том, что дробным считается не значение переменной, а то, что видит игрок. Если расстояние 4,98 м вы выводите без знаков после запятой, на экране будет «5», и слово должно быть «метров». А если формат «0.0», то даже ровное 5 превращается в «5,0», и по правилам CLDR это уже дробное число: «5,0 метра». Поэтому форму нужно выбирать после округления и с учётом количества знаков:

using System;
using System.Globalization;

public static class QuantityText
{
    static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU");

    public static string Format(double value, int decimals, PluralForms forms)
    {
        double rounded = Math.Round(value, decimals, MidpointRounding.AwayFromZero);
        string number = rounded.ToString("N" + decimals, Ru);

        // При decimals > 0 на экране всегда есть дробная часть ("5,0"),
        // а это категория other, которая в русском совпадает с few.
        string word = decimals == 0 ? forms.For((long)rounded) : forms.Few;

        return number + " " + word;
    }
}
var km = PluralForms.Parse("километр|километра|километров");

QuantityText.Format(4.98, 0, km);  // "5 километров"
QuantityText.Format(1.5, 1, km);   // "1,5 километра"
QuantityText.Format(21, 0, km);    // "21 километр"
QuantityText.Format(5, 1, km);     // "5,0 километра"

Обратите внимание на явную культуру ru-RU: она даёт запятую в дробной части и пробел между разрядами («1 250»). Если оставить текущую культуру потока, результат будет зависеть от настроек Windows игрока. Разделитель разрядов в русской культуре — неразрывный пробел, причём в зависимости от рантайма это может быть U+00A0 или узкий U+202F. Проверьте вывод на целевой платформе и наличие глифа в шрифте.

Сокращения и большие числа

Когда числа становятся большими, интерфейс обычно переходит к сокращениям: «1,2 тыс.», «3 млн». Здесь легко ошибиться ещё раз. После сокращённого «тыс.» или «млн» существительное зависит уже не от числа, а от слова «тысяча» или «миллион», которое стоит перед ним, — и это всегда родительный падеж множественного числа: «1,2 тыс. монет», «21 тыс. монет», «3 млн монет». То есть после сокращения нужна форма many, независимо от цифр:

public static string FormatCompact(long value, PluralForms forms)
{
    var ru = CultureInfo.GetCultureInfo("ru-RU");

    if (value >= 1_000_000)
        return (value / 1_000_000d).ToString("0.#", ru) + " млн " + forms.Many;

    if (value >= 10_000)
        return (value / 1_000d).ToString("0.#", ru) + " тыс. " + forms.Many;

    return value.ToString("N0", ru) + " " + forms.For(value);
}

Порог в 10 000 для тысяч выбран намеренно: «9 999 монет» читается нормально, а «9,9 тыс. монет» прячет точное значение без особой нужды. Подберите пороги под свой UI.

Когда склонение можно обойти

Иногда лучший способ не ошибиться в склонении — не склонять вовсе. Это не лень, а распространённый приём в интерфейсах, где мало места:

  • подпись перед числом: «Монеты: 21», «Убийства: 3» — слово в именительном падеже не зависит от числа;
  • сокращённые единицы: «5 мин», «12 сек», «300 м», «×5» — сокращения не склоняются;
  • иконка вместо слова: значок монеты рядом с числом понятен на любом языке.

Но в связном тексте — описаниях наград, сообщениях, туториале — так делать не стоит: «Вы получили монеты: 21» звучит как машинный перевод. Там склонение нужно делать честно.

Несколько языков: правило на каждый язык

Если игра выходит не только на русском, класс RussianPlural превращается в одно правило из нескольких. Удобно описать их общим интерфейсом и выбирать по двухбуквенному коду языка:

using System;
using System.Collections.Generic;

public interface IPluralRule
{
    // Сколько форм нужно этому языку.
    int FormCount { get; }

    // Индекс формы для целого числа.
    int Select(long n);
}

sealed class EnglishRule : IPluralRule
{
    public int FormCount => 2;
    public int Select(long n) => n == 1 ? 0 : 1;
}

sealed class RussianRule : IPluralRule
{
    public int FormCount => 3;
    public int Select(long n) => (int)RussianPlural.Select(n);
}

sealed class PolishRule : IPluralRule
{
    public int FormCount => 3;

    public int Select(long n)
    {
        if (n == 1) return 0;

        long mod100 = Math.Abs(n % 100);
        long mod10 = mod100 % 10;
        if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
            return 1;

        return 2;
    }
}

sealed class SingleFormRule : IPluralRule
{
    public int FormCount => 1;
    public int Select(long n) => 0;
}

public static class PluralRuleTable
{
    static readonly Dictionary<string, IPluralRule> Rules = new()
    {
        ["en"] = new EnglishRule(),
        ["ru"] = new RussianRule(),
        ["uk"] = new RussianRule(),
        ["be"] = new RussianRule(),
        ["pl"] = new PolishRule(),
        ["ja"] = new SingleFormRule(),
        ["zh"] = new SingleFormRule(),
        ["ko"] = new SingleFormRule(),
    };

    public static IPluralRule For(string languageCode)
    {
        return Rules.TryGetValue(languageCode, out var rule) ? rule : Rules["en"];
    }
}

Украинский и белорусский для целых чисел используют то же правило, что и русский. Польский похож, но отличается важной деталью: «21» там относится к many («21 monet»), а не к one, поэтому у него своя ветка. Не пытайтесь объединить их в одну реализацию «для славянских языков».

Свойство FormCount полезно для проверки данных: при загрузке таблицы перевода сравните количество форм в строке с тем, что ожидает язык, и выведите ошибку в консоль. Такая проверка ловит большую часть опечаток в переводах ещё до того, как их увидит тестировщик. Для языков, которых нет в словаре, лучше брать полные правила из CLDR, а не придумывать их по нескольким примерам.

То же самое в Unity Localization: Smart Strings

Если в проекте уже стоит официальный пакет com.unity.localization, свой класс почти не нужен: склонение умеют Smart Strings. Они построены на форке библиотеки SmartFormat, и в нём есть форматтер plural (короткое имя — p) с правилами CLDR для многих языков, включая русский. Запись в таблице строк выглядит так:

Вы получили {count:p:{} монету|{} монеты|{} монет}

Внутри фигурных скобок формы перечисляются через вертикальную черту в порядке категорий языка — для русского это one, few, many, то есть та же схема «1, 2, 5». Пустые фигурные скобки {} внутри формы подставляют само число. Правило по умолчанию берётся из локали таблицы, в которой лежит строка, поэтому одну и ту же запись на английском вы пишете с двумя формами, а на русском — с тремя. Принудительно задать язык правила можно в скобках после имени форматтера: {count:p(ru):…}.

Чтобы запись обрабатывалась как Smart String, у неё должен быть включён флаг Smart в таблице, иначе фигурные скобки выведутся как обычный текст. Из кода это свойство IsSmart у записи таблицы.

Передача числа из кода

Самый простой вариант — передать аргументы в LocalizedString и подписаться на StringChanged. Событие сработает и при обновлении значения, и при смене языка игроком:

using TMPro;
using UnityEngine;
using UnityEngine.Localization;

public class CoinsRewardLabel : MonoBehaviour
{
    // В таблице: "Вы получили {0:p:{} монету|{} монеты|{} монет}"
    [SerializeField] LocalizedString rewardText;
    [SerializeField] TMP_Text label;

    void OnEnable()
    {
        rewardText.StringChanged += OnStringChanged;
    }

    void OnDisable()
    {
        rewardText.StringChanged -= OnStringChanged;
    }

    public void Show(int coins)
    {
        rewardText.Arguments = new object[] { coins };
        rewardText.RefreshString();
    }

    void OnStringChanged(string value)
    {
        label.text = value;
    }
}

Для строк, которые обновляются часто — счётчик монет в углу экрана, таймер, — удобнее локальные переменные: LocalizedString работает как словарь именованных переменных, и изменение их значения само вызывает обновление строки без лишнего массива аргументов:

using TMPro;
using UnityEngine;
using UnityEngine.Localization;
using UnityEngine.Localization.SmartFormat.PersistentVariables;

public class CoinsCounter : MonoBehaviour
{
    // В таблице: "{count:p:{} монета|{} монеты|{} монет}"
    [SerializeField] LocalizedString counterText;
    [SerializeField] TMP_Text label;

    IntVariable count;

    void Awake()
    {
        count = new IntVariable { Value = 0 };
        counterText.Add("count", count);
    }

    void OnEnable()
    {
        counterText.StringChanged += OnStringChanged;
    }

    void OnDisable()
    {
        counterText.StringChanged -= OnStringChanged;
    }

    public void SetCoins(int value)
    {
        // Строка пересоберётся и придёт в OnStringChanged.
        count.Value = value;
    }

    void OnStringChanged(string value)
    {
        label.text = value;
    }
}

Где Smart Strings подводят

Есть три ловушки, на которые натыкаются почти все, кто переводит игру на русский.

Две формы вместо трёх. Переводчик копирует английскую строку и пишет {0:p:{} монета|{} монет}. Для 1 и 21 всё работает, для 2 тоже — правило вернёт индекс 1. А вот на числе 5 правило вернёт индекс 2, которого нет, и форматирование завершится ошибкой о неверном количестве параметров. Ошибка проявится только на определённых числах, поэтому её легко пропустить. Лечится проверкой таблиц: пройдите по всем Smart-записям русской таблицы и убедитесь, что в каждом p: ровно три формы.

Дроби. Встроенное русское правило SmartFormat написано для целых чисел и не выделяет категорию other: для 1,5 оно выберет индекс many, и вы получите «1,5 монет». Если в игре есть дробные величины — расстояния, вес, проценты, — зарегистрируйте своё правило и укажите его явно:

using UnityEngine;
using UnityEngine.Localization.SmartFormat.Utilities;

static class RussianFractionPluralRule
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void Register()
    {
        PluralRules.IsoLangToDelegate["ru-frac"] = (value, pluralCount) =>
        {
            // Любая дробь — категория other, по форме совпадает с few.
            if (value != decimal.Truncate(value))
                return 1;

            return (int)RussianPlural.Select((long)value);
        };
    }
}

В таблице такая строка выглядит как {distance:p(ru-frac):{} километр|{} километра|{} километров}. Помните, что правило видит значение переменной, а не то, что выводится на экран. Если вы форматируете число с округлением, округляйте его до передачи в строку, иначе форма разойдётся с цифрами. Индексатор вместо Add выбран, чтобы повторная регистрация (например, при отключённой перезагрузке домена в редакторе) не падала на дубликате ключа. Атрибут RuntimeInitializeOnLoadMethod срабатывает только при запуске игры; если строки с этим правилом нужно видеть и в предпросмотре редактора, повесьте тот же метод ещё и на [InitializeOnLoadMethod] внутри #if UNITY_EDITOR.

Число и слово в разных местах. Smart String не заставляет ставить число рядом со словом, и иногда переводчик пишет форму без {}, а само число — где-то ещё во фразе. Это допустимо, но при правке оригинала такие строки чаще всего расходятся с текстом. Если число есть во фразе, держите его внутри формы.

Проверяем тестами

Правила склонения — идеальный кандидат для unit-тестов: чистая функция, никаких сцен и компонентов. Набор значений ниже ловит все типичные ошибки — подростковые 11–14, переход через сотню и отрицательные числа:

using NUnit.Framework;

public class RussianPluralTests
{
    [TestCase(0, PluralCategory.Many)]
    [TestCase(1, PluralCategory.One)]
    [TestCase(2, PluralCategory.Few)]
    [TestCase(4, PluralCategory.Few)]
    [TestCase(5, PluralCategory.Many)]
    [TestCase(11, PluralCategory.Many)]
    [TestCase(12, PluralCategory.Many)]
    [TestCase(14, PluralCategory.Many)]
    [TestCase(21, PluralCategory.One)]
    [TestCase(22, PluralCategory.Few)]
    [TestCase(25, PluralCategory.Many)]
    [TestCase(101, PluralCategory.One)]
    [TestCase(111, PluralCategory.Many)]
    [TestCase(112, PluralCategory.Many)]
    [TestCase(1_000_000, PluralCategory.Many)]
    [TestCase(-1, PluralCategory.One)]
    [TestCase(-23, PluralCategory.Few)]
    [TestCase(long.MinValue, PluralCategory.Many)]
    public void SelectsCategory(long n, PluralCategory expected)
    {
        Assert.AreEqual(expected, RussianPlural.Select(n));
    }

    [TestCase(4.98, 0, "5 километров")]
    [TestCase(1.5, 1, "1,5 километра")]
    [TestCase(21, 0, "21 километр")]
    [TestCase(5, 1, "5,0 километра")]
    public void FormatsQuantity(double value, int decimals, string expected)
    {
        var km = PluralForms.Parse("километр|километра|километров");
        Assert.AreEqual(expected, QuantityText.Format(value, decimals, km));
    }
}

Тест с long.MinValue здесь не для красоты: он гарантирует, что никто не «упростит» код до Math.Abs(n). Значение оканчивается на 08, поэтому ожидается many. Тесты на форматирование заодно проверяют культуру: если кто-то заменит ru-RU на текущую культуру потока, они упадут на машине с английской Windows.

Если вы используете Smart Strings, добавьте тест в режиме редактора, который загружает русскую таблицу и форматирует каждую Smart-запись с набором чисел 0, 1, 2, 5, 11, 21 и 1,5. Он поймает и строки с двумя формами, и опечатки в синтаксисе раньше, чем игроки.

Производительность

Само правило — пара операций остатка и сравнений, его стоимость можно не учитывать. Память расходуется на сборку строки: интерполяция, string.Format и ToString создают новые строки при каждом вызове. Для текста, который меняется раз в несколько секунд, это не важно. Для таймера, который обновляется каждый кадр, есть два простых шага:

  • обновлять текст только при изменении отображаемого значения — для таймера в секундах это раз в секунду, а не 60 раз;
  • число выводить через TMP_Text.SetText с плейсхолдером, а склоняемое слово брать из заранее подготовленных строк: три формы не создаются заново, они уже лежат в PluralForms.
int lastShown = -1;

void Update()
{
    int seconds = Mathf.CeilToInt(timeLeft);
    if (seconds == lastShown)
        return;

    lastShown = seconds;
    // Форма — готовая строка, число подставляет TextMeshPro.
    label.SetText(secondsForms.For(seconds), seconds);
}

Здесь в PluralForms лежат строки вида "Осталась {0} секунда", "Осталось {0} секунды", "Осталось {0} секунд": SetText понимает плейсхолдер {0} и подставляет число без создания промежуточной строки.

Чек-лист

  1. Для русского всегда три формы в порядке «1, 2, 5»: монета, монеты, монет.
  2. Склоняйте целую фразу, а не одно слово: от числа зависят прилагательные и глаголы.
  3. Форму выбирайте по числу, которое видит игрок, после округления. Любая дробь — форма «2»: «1,5 монеты».
  4. После «тыс.» и «млн» — всегда форма «5»: «1,2 тыс. монет».
  5. Между числом и словом — неразрывный пробел, и он должен быть в шрифте.
  6. Форматируйте числа с явной культурой, а не с культурой системы.
  7. В Smart Strings проверяйте количество форм и регистрируйте своё правило, если есть дроби.
  8. Покройте правило тестами: 0, 1, 2, 5, 11–14, 21, 101, 111 и отрицательные числа.

Склонение после числа — мелочь, но именно по таким мелочам игрок определяет, делали ли перевод люди, которые сами говорят по-русски. Пара десятков строк кода и одна проверка таблиц избавляют от «21 монет» во всех частях игры.

Комментарии

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

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

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

Как импортировать .unitypackage в Unity: пошагово
31.08.2026

Как импортировать .unitypackage в Unity: пошагово

Пошаговая инструкция по импорту файлов .unitypackage в Unity. Разбираемся, как установить ассет в проект, где найти импортированные файлы и что делать при возникновении ошибок.