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