В этом уроке мы напишем несколько C# скриптов, охватывающих несколько графиков функций (от простого к сложному) в Unity3d 4 версии. Вы научитесь: 1) работать с графикой (от созданий простых линий до сложных анимированных форм). 2) управлять системами частиц. 3) использовать различные математические функции. 4) менять поведение в режиме воспроизведения. 5) использовать функции Start и Update. 6) использовать циклы (в том числе вложенные). 7) использовать массивы, перечисления (enumerations) и делегаты.
Подразумевается, что у вас есть небольшой опыт работы с С# скриптингом в Unity. Учтите, что я часто буду пропускать куски кода, которые остались без изменений.
Приготовления.
Создадим новый проект без дополнительных пакетов. График функции будем создавать внутри кубической области, размещенной между (0,0,0) и (1,1,1). Настроим редактор так, чтобы хорошо видеть эту область. 4 Split самый удобный для нас вариант окна, поэтому давайте выберем его. Выберем Window / Layout / 4 Spit в выпадающем меню сверху экрана. Переведем режим каждого окна в Textured. Теперь создадим куб GameObject / Create Other / Cube и назначим ему позицию (0.5, 0.5, 0.5). Куб позволит нам откалибровать окна. Теперь мастштабированием и перемещением сфокусируем окна на кубе. И, последнее, выберем главную камеру и сделаем так чтобы она совпадала с видом перспективы GameObject / Align With View. Если отображение в окне Game некорректное, нажмите GameObject/Align View To Selected, после чего настройте изображение в окне перспективы, выделите камеру и еще раз нажмите GameObject / Align With View.
окна сцены и камера, сфокусированная на кубе
Куб нам больше не нужен, удалим его. Теперь создадим систему частиц GameObject / Create Other / Particle System и сбросим настройки transform (выделем систему частиц, в окне Inspector напротив Transform нажмем reset). Сейчас мы наблюдаем рандомные партиклы, которые нам не нужны. Отключим все кроме визуализации. Отключим Looping, Play On Awake, Emission, Shape, и Resimulate. Теперь у нас неактивная система частиц, которую можно использовать для построения графиков.
неативная система частиц.
Создаем первый график.
Начнем с создания простой линии, у которой значение по оси Y зависит от значения по оси X. Чтобы отобразить это, используем позиции систем частиц. Переименуем нашу систему частиц в Graph1, создадим C# скрипт. Назовем его Grapher1, перетягиванием назначим его объекту Graph1.
Graph1 с пустым компонентом Grapher1
Сначала нам нужно создать системы частиц, которые служили бы точками для наших графиков. Создадим их в функции Start(), которая вызовается в Unity при загрузке уровня. Сколько партиклов будем использовать? Чем больше, тем выше будет разрешение графиков. Давайте сделаем эту величину регулируемой, с разрешением по умолчанию 10.
Код
using UnityEngine; public class Grapher1 : MonoBehaviour { public int resolution = 10; private ParticleSystem.Particle[] points; // объявляем массив систем частиц void Start () { points = new ParticleSystem.Particle[resolution]; // задаем длину массива } }
Grapher1 с регулируемым разрешением
Теперь разрешение можно выставить любое. В том числе нулевое. Очень большое разрешение приведет к падению производительности. При создании массива мы не задали ему границ. Если разрешение выйдет за границы, мы сбросим его в минимум и выведем сообщение в лог. Возьмем диапазон 10 - 100.
Код
void Start () { if (resolution < 10 || resolution > 100) { Debug.LogWarning ("Разрешение графика вышло за границы, сброшено в минимум",this); resolution = 10; } points = new ParticleSystem.Particle[resolution]; }
Теперь, когда у нас есть точки, пришло время расположить их вдоль оси X. Первая точка должна быть размещена в 0, последняя - в 1. Остальные точки разместим между ними. Поэтому расстояние между двумя точками составляет 1 (resolution - 1). Кроме положения, мы можем использовать цвет. Например красный. Пусть его интенсивность зависит от положения точки на оси Х. Чтобы назначить каждой точке позицию и цвет используем цикл for. Будем использовать переменные типа Vector3 и Color. Также нам нужно назначить размер частиц, в противном случае они не будут отображатся. Подойдет размер 0.1.
Код
void Start () { if (resolution < 10 || resolution > 100) { Debug.LogWarning("Разрешение графика вышло за границы, сброшено в минимум", this); resolution = 10; } points = new ParticleSystem.Particle[resolution]; float increment = 1f / (resolution - 1); for (int i = 0; i < resolution; i++) { float x = i * increment; points[i].position = new Vector3(x, 0f, 0f); points[i].color = new Color(x, 0f, 0f); points[i].size = 0.1f; } }
На данный момент функция не отрисовывается. Запустив проект, увидим что ничего не произойдет. Это потому что нужно назначить частицы. Для этого у нас есть доступ к каждому компоненту, имеющиму свойство particleSystem. Все что нам нужно - вызвать его метод SetParticles и назначить массив частиц. Добавим его в метод Update, чтобы действие выполнялось каждый кадр.
Получаем линию точек, плавно меняющих цвет, вдоль оси Х. Количество точек мы можем регулировать, изменяя resolution. Это нужно делать до запуска проекта.
Линия с разрешением 10 и 100
Сейчас разрешение учитывается только когда график иницилизируется. Сделаем так, чтобы можно было менять разрешение в режиме воспроизведения. Самый простой способ обнаружить изменение разрешения это сохранить его два раза, а затем постоянно проверять остались ли эти значения прежними. Если в какой то момент они будут отличатся, нужно будет восстановить графику. Для этого создадим приватную переменную currentResolution. Поскольку при восстановлении точки займут их первоначальное положение, добавим в наш код приватный метод под названием CreatePoints.
Код
using UnityEngine; public class Grapher1 : MonoBehaviour { public int resolution = 10; private int currentResolution; private ParticleSystem.Particle[] points; void Start () { CreatePoints(); } private void CreatePoints () { if (resolution < 10 || resolution > 100) { Debug.LogWarning("Разрешение вышло за границы, сброшено в минимум.", this); resolution = 10; } currentResolution = resolution; points = new ParticleSystem.Particle[resolution]; float increment = 1f / (resolution - 1); for(int i = 0; i < resolution; i++){ float x = i * increment; points[i].position = new Vector3(x, 0f, 0f); points[i].color = new Color(x, 0f, 0f); points[i].size = 0.1f; } } void Update () { if (currentResolution != resolution) { CreatePoints(); } particleSystem.SetParticles(points, points.Length); } }
Теперь график меняется сразу при изменении нами разрешения. Тем не менее, сообщение в консоль при выходе разрешения за пределы будет выходить каждый раз, даже во время печатания. Вместо ручного ввода цифр можно сделать слайдер. В этом случае не будет выхода за границы разрешения.
Код
[Range(10, 100)] public int resolution = 10;
Grapher1 со слайдером изменения разрешения.
Пришло время назначить точкам позицию по оси Y. Начнем с простого, сделаем Y равной X. Другими словами, визуализируем математическое уравнение y = x, или функцию f(x) = x. Чтобы сделать это, нам нужно зациклить все точки, получить их позиции, использовать X значение для рассчета Y. Затем - назначить новую позицию. Мы уже использовали цикл for, теперь используем его в методе Update().
Код
void Update () { if (currentResolution != resolution) { CreatePoints(); } for (int i = 0; i < resolution; i++) { Vector3 p = points[i].position; p.y = p.x; points[i].position = p; } particleSystem.SetParticles(points, points.Length); }
Функция f(x) = x
Далее сделаем так, чтобы зеленый компонент точки зависил от ее Y позиции. Поскольку совмещение красного и зеленого цветов дают желтый, наша линия будет желтой.
Код
void Update () { if (currentResolution != resolution) { CreatePoints(); } for (int i = 0; i < resolution; i++) { Vector3 p = points[i].position; p.y = p.x; points[i].position = p; Color c = points[i].color; c.g = p.y; points[i].color = c; } particleSystem.SetParticles(points, points.Length); }
Красный + зеленый = желтый.
Если сохраним скрипт и вернемся в Unity, находясь при этом в режиме воспроизведения, вылезет ошибка NullReferenceException. Это потому что наша приватная переменная points не назначается Unity при перезагрузке. Можем убрать это через проверку на null наших точек. Это позволит нам оставатся в режиме воспроизведения и редактировать код, очень удобно. Соответственно, эта проверка в функции Start() больше не нужна, убираем ее.
Код
using UnityEngine; public class Grapher1 : MonoBehaviour { [Range(10, 100)] public int resolution = 10; private int currentResolution; private ParticleSystem.Particle[] points; private void CreatePoints () { currentResolution = resolution; points = new ParticleSystem.Particle[resolution]; float increment = 1f / (resolution - 1); for(int i = 0; i < resolution; i++){ float x = i * increment; points[i].position = new Vector3(x, 0f, 0f); points[i].color = new Color(x, 0f, 0f); points[i].size = 0.1f; } } void Update () { if (currentResolution != resolution || points == null) { CreatePoints(); } particleSystem.SetParticles(points, points.Length); } }
Делаем несколько графиков.
Один график - это не интересно. Будет лучше если мы их покажем несколько. Все что нужно - это различные варианты вычисления p.y(позиции по оси Y), остальная часть кода может остатся прежней. Давайте выделим фрагмент кода, осуществляющий вычисление p.y и занесем его в отдельный метод, который назовем Linear. Этот метод будет имитировать математическую функцию f(x) = x. Сделаем этот метод статическим. Все что нам нужно это входное значение.
Код
void Update () { if(currentResolution != resolution){ CreatePoints(); } for(int i = 0; i < resolution; i++){ Vector3 p = points[i].position; p.y = Linear(p.x); points[i].position = p; Color c = points[i].color; c.g = p.y; points[i].color = c; } particleSystem.SetParticles(points, points.Length); } private static float Linear (float x) { return x; }
Можно добавить другие математические функции, создать еще методы и вызывать их вместо Linear. Давайте добавим три новых метода. Первый - Exponential, будет рассчитыватся по формуле f(x) = (x^2). Второй - Parabola, рассчитывается по f(x) = (2x-1)^2. Третий метод - Sine, рассчитывается по f(x) = (sin(2πx) + 1) / 2.
Код
private static float Exponential (float x) { return x * x; } private static float Parabola (float x){ x = 2f * x - 1f; return x * x; } private static float Sine (float x){ return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x); }
Графики четырех функций.
Изменим код таким образом, чтобы переключатся между этими функциями было максимально удобно, чтобы можно было это делать в режиме воспроизведения. Давайте создадим enum список, содержащий графики функции. Назовем его FunctionOption. Но, поскольку мы определим его внутри нашего класса, фактически он будет называтся Grapher1.FunctionOption. Создадим публичную переменную нашего нового типа, назовем ее function. Так мы получим поле в окне Инспектор для выбора функции.
Код
public enum FunctionOption { Linear, Exponential, Parabola, Sine } public FunctionOption function;
Выберем какую функцию использовать
Выбор функции в инспекторе - удобная опция, но сейчас она не работает. Нужно сделать возможность постоянно менять функции и их значения. Для этого есть различные способы. Мы используем массив делегатов. Сначала мы определим тип делегатов для методов, имеющих переменную типа float в качестве входного и выходного значения, который соответствует нашим методам функции. Назовем его FunctionDelegate. Затем добавим статический массив с именем functionDelegates и заполним его делегатами наших методов, в том же порядке что объявляли их в enam. Теперь можем выбрать нужный делегат из массива в соответствии с переменной нашей функции, преобразуя ее в целое число. Сохраним делегат во временную переменную и будем его использовать для вычисления значения Y.
Код
private delegate float FunctionDelegate (float x); private static FunctionDelegate[] functionDelegates = { Linear, Exponential, Parabola, Sine }; void Update () { if(currentResolution != resolution){ CreatePoints(); } FunctionDelegate f = functionDelegates[(int)function]; for(int i = 0; i < resolution; i++){ Vector3 p = points[i].position; p.y = f(p.x); points[i].position = p; Color c = points[i].color; c.g = p.y; points[i].color = c; } particleSystem.SetParticles(points, points.Length); }
Теперь можно менять график функции в режиме воспроизведения. Мы должны воссоздать график каждый раз когда мы выбираем другую функцию, в остальное время никаких изменений не происходит. Значит, нам не нужно каждый кадр вычислять точки. Но все изменится, если мы добавим к функциям время. В качестве примера давайте изменим метод Sin (синус), который вычисляется по формуле f(x) = (sin(2πx + Δ) + 1) / 2, где Δ - игровое время. В результате, получим медленную анимацию синусоидов. Вот скрипт:
Код
using UnityEngine; public class Grapher1 : MonoBehaviour { public enum FunctionOption { Linear, Exponential, Parabola, Sine } private delegate float FunctionDelegate (float x); private static FunctionDelegate[] functionDelegates = { Linear, Exponential, Parabola, Sine }; public FunctionOption function; [Range(10, 100)] public int resolution = 10; private int currentResolution; private ParticleSystem.Particle[] points; private void CreatePoints () { currentResolution = resolution; points = new ParticleSystem.Particle[resolution]; float increment = 1f / (resolution - 1); for (int i = 0; i < resolution; i++) { float x = i * increment; points[i].position = new Vector3(x, 0f, 0f); points[i].color = new Color(x, 0f, 0f); points[i].size = 0.1f; } } void Update () { if (currentResolution != resolution || points == null) { CreatePoints(); } FunctionDelegate f = functionDelegates[(int)function]; for (int i = 0; i < resolution; i++) { Vector3 p = points[i].position; p.y = f(p.x); points[i].position = p; Color c = points[i].color; c.g = p.y; points[i].color = c; } particleSystem.SetParticles(points, points.Length); } private static float Linear (float x) { return x; } private static float Exponential (float x) { return x * x; } private static float Parabola (float x){ x = 2f * x - 1f; return x * x; } private static float Sine (float x){ return 0.5f + 0.5f * Mathf.Sin(2 * Mathf.PI * x + Time.timeSinceLevelLoad); } }
Добавляем дополнительное измерение.
До этого пункта в качестве входного параметра мы использовали значения оси Х, и в одном случае время. Теперь создадим новый график, использующий еще и ось Z. Отображать он будет не линию, а сетку. Убедимся что режим воспроизведения выключен. Создадим новый Unity объект, почти такой же как Graph1, только со скриптом нового графика. Назовем его Graph2, на него назначим скрипт Grapher2. Можно сделать это быстро - дублировать объект и проделать необходимые операции. Выключим Graph1 щелкнув на чекбоксе напротив поля имени, больше его использовать не будем. Скопируем код из Grapher1 в Grapher2, только изменим имя класса на Grapher2. Теперь доработаем этот код.
Переключаемся на новый график.
Чтобы сделать из линии квадратную сетку, мы изменим метод CreatePoints в скрипте Grapher2. Нам нужно создать намного больше точек и использовать цикл for для инициализации их. Затем назначим позиции по Z и голубой цвет компонента.
Код
private void CreatePoints () { currentResolution = resolution; points = new ParticleSystem.Particle[resolution * resolution]; float increment = 1f / (resolution - 1); int i = 0; for (int x = 0; x < resolution; x++) { for (int z = 0; z < resolution; z++) { Vector3 p = new Vector3(x * increment, 0f, z * increment); points[i].position = p; points[i].color = new Color(p.x, 0f, p.z); points[i++].size = 0.1f; } } }
Плоскость.
Получаем плоскую сетку. Но ведь она не должна показывать линейную функцию, а в данный момент она ее показывает первой строчкой точек вдоль оси Z. Если выберем другие функции, только эти точки будут менятся, в то время как остальные останутся без изменений. Это потому что в методе Update циклом перебираются только resolution точки, тогда как должны перебиратся все.
Код
void Update () { if (currentResolution != resolution || points == null) { CreatePoints(); } FunctionDelegate f = functionDelegates[(int)function]; for (int i = 0; i < points.Length; i++) { Vector3 p = points[i].position; p.y = f(p.x); points[i].position = p; Color c = points[i].color; c.g = p.y; points[i].color = c; } particleSystem.SetParticles(points, points.Length); }
Четыре функции.
Теперь мы снова видим наши функции, на этот раз включая ось Z. Тем не менее, что то не так. Попробуйте повращать сцену в виде перспективы с активной функцией parabola. В некоторых ракурсах график выглядит неправильно. Это потому что партиклы отрисовываются в том порядке, в котором мы их создаем, не принимая во внимание направление, по которому мы на них смотрим. Можем это исправить, установив в режиме Sort Mode (модуля Renderer системы частиц) "By Distance" вместо "None". После этого графики функций будут корректно отображатся со всех сторон, но производительность существенно упадет. Поэтому не советую использовать это с большим количеством точек. К счастью, если выбрать правильный ракурс, эта погрешность будет незаметна.
Sort режимы "None" и "By Distance".
Давайте изменим код таким образом, чтобы можно было задействовать ось Z. Сначала изменим тип входных параметров FunctionDelegate в Vector3 и float (раньше было только float). Можно конечно указать X и Z позиции отдельно, но проще задать вектор позиции. Также сделаем так, чтобы текущее время не находилось внутри функций.
Теперь нужно соответственно обновить методы функций, изменить имя делегата. Сейчас можно включить ось Z в математические функции. Например, изменим функцию parabola таким образом f(x,z) = 1 - (2x - 1)2 × (2z - 1)2. Также можно попробовать наложить несколько синусов чтобы получить интересный колебательный эффект.
Пришло время добавить третье измерение. Превратим наш график из сетки в куб, который уже будет объемным. Дублируем Graph2 и Grapher2, переименуем их соответственно в Graph3 и Grapher3, так же, как делали для второго графика. Не забудем выключить Graph2.
Все три графика.
Внесем несколько изменений в Grapher3. Сначала установим разрешению лимит в 30, примерно будет 27,000 точек. Убедимся, что ползунок разрешения находится в данном диапазоне. Теперь инициализируем Y позицию точки и зеленый цвет компонента.
Код
[Range(10, 30)] public int resolution = 10; private void CreatePoints () { currentResolution = resolution; points = new ParticleSystem.Particle[resolution * resolution * resolution]; float increment = 1f / (resolution - 1); int i = 0; for (int x = 0; x < resolution; x++) { for (int z = 0; z < resolution; z++) { for (int y = 0; y < resolution; y++) { Vector3 p = new Vector3(x, y, z) * increment; points[i].position = p; points[i].color = new Color(p.x, p.y, p.z); points[i++].size = 0.1f; } } } }
Сейчас Graph3 выглядит как Graph2, разве что немного более плотный. Это потому что мы до сих пор не установили Y позиции в методе Update(). Таким образом все точки, имеющие одинаковые X и Z позиции получат такую же Y позицию. Значит Y позицию можно не назначать, но альфа компонент цвета установить нужно. Наши функции будут определятся плотностью объема.
Код
void Update () { if (currentResolution != resolution || points == null) { CreatePoints(); } FunctionDelegate f = functionDelegates[(int)function]; float t = Time.timeSinceLevelLoad; for (int i = 0; i < points.Length; i++) { Color c = points[i].color; c.a = f(points[i].position, t); points[i].color = c; } particleSystem.SetParticles(points, points.Length); }
Объемный кубический график с разрешением 10 и 30.
Теперь наш график выглядит как цельный плотный куб. Функции не очень прослеживаются, потому что они не меняются по оси Y. Лишь две функции, синуса и пульсации дают некий интересный эффект. Давайте изменим вычисление линейной функции (Linear), сделаем f(x,y,z) = 1 - x - y - z. Таким образом он будет менять плотность. То же самое можем сделать с Exponential. Оживим их немного, чтобы смотрелось поинтереснее.
Изменим параболу таким образом, чтобы ее график был похож на цилиндр с небольшими анимированными пульсациями. Добавим третье измерение к функции Ripple, создадим эффект пульсирующей сферы.
Изменим функцию синуса. Перемножим площади синусов X,Y,Z между собой. В резуальтате получится эффект, напоминающий 8 капель. Анимируем только Z состовляющую синуса. Верхняя и нижняя половина графика будут двигатся в противоположных направлениях.
Код
private static float Sine (Vector3 p, float t){ float x = Mathf.Sin(2 * Mathf.PI * p.x); float y = Mathf.Sin(2 * Mathf.PI * p.y); float z = Mathf.Sin(2 * Mathf.PI * p.z + (p.y > 0.5f ? t : -t)); return x * x * y * y * z * z; }
Функция синуса в объеме напоминает капли.
Неплохим вариантом будет такой, где все вокселы (аналоги пикселей для трехмерного пространства) будут либо полностью видимы, либо полностью прозрачны. Это приведет к плотной, но неровной поверхности. Давайте сделаем переключатель в это состояние (поле Absolute), добавив также возможность определять каким плотным должен быть воксел чтобы стать видимым. Постоянно будем проверять включен ли этот режим. Если он включен - точкам добавиляем соответвующую альфу. Вол полный скрипт:
Код
using UnityEngine;
public class Grapher3 : MonoBehaviour {
public enum FunctionOption { Linear, Exponential, Parabola, Sine, Ripple }
Как работают массивы? Массивы фиксированной длины содержат линейную последовательность переменных. Массив объявляется почти так же, как переменная, только добавляем квадратные скобки. Например int myVariables - объявление целочисленной переменной, а int[] myVariable - массив целочисленных переменных. Доступ к одному из элементов внутри массива осуществляется посредством помещения в индекс массива - в квадратные скобки после переменной. Например myVariable[0] - даст нам первое значение массива, myVariable[1] - второе и так далее. Создание массива и присвоение ему переменных осуществляется таким образом: myVariable = new int[10]; в данном случае получим массив из 10 значений. Кроме того можно создавать массив перечисляя свои начальные значения в фигурных скобках myVariable = {1, 2, 3};
Что такое ParticleSystem.Particle? ParticleSystem.Particle это структура типа для хранения данных частицы. Точка указана потому что это - вложенный тип. Тип Particle определен внутри типа ParticleSystem. Обратите внимание что есть еще унаследованный тип Particle, не связанный с ParticleSystem, в Shuriken particle system он не используется. Что осуществляет new? Дескриптор new используется для создания нового экземпляра объекта или структуры значения. Это сопровождается вызовом специального метода конструктора, который имеет такое же имя, что и класс или структура, которой он принадлежит.
Что осуществляет Debug.LogWarning? Это статический метод из Unity класса Debug, который позволяет делать текстовые выводы в консоль. Можно использовать Log, LogWarning, или LogError для определения типа своих сообщений. Первый аргумент этих методов - текст, который будет выходить в лог. Необязательный второй аргумент позволяет связать сообщение с объектом, который будет выделен в редакторе при нажатие на сообщение.
Как работает цикл for? Цикл for - самый удобный способ для перебора каких либо значений. В этом примере будем перебирать целочисленный итератор i. Сначала объявляем итератор, потом проверяем состояние цикла, и наращиваем итератор.
Код
for(int i = 0; i < 10; i++) { DoStuff(i); }
Можно также использовать цикл while
Код
int i = 0; while(i < 10) { DoStuff(i); i++; }
Что такое делегаты? Если вам нужно сохранить значение, или ссылку на объект в переменной, можно сохранить его как метод. Это называется делегатом. Вы определяете тип делегата так же, как создаете метод, за исключением того что у него нет тела кода. После этого вы можете использовать этот тип для создания делегата переменной, которому можно назначить любой метод, соответствующий типу. Затем вы можете использовать эту переменную как метод. На самом деле, у делегатов намного больше возможностей. Они ведут себя как списки и могут быть использованы для сложной обработки событий. Но в данном обучении нам это не потребуется.
Также если вы считаете, что данный материал мог быть интересен и полезен кому-то из ваших друзей, то вы бы могли посоветовать его, отправив сообщение на e-mail друга:
Игровые объявления и предложения:
Если вас заинтересовал материал «Графики функций в Unity3d. Визуализация данных», и вы бы хотели прочесть что-то на эту же тему, то вы можете воспользоваться списком схожих материалов ниже. Данный список сформирован автоматически по тематическим меткам раздела.
Предлагаются такие схожие материалы:
Если вы ведёте свой блог, микроблог, либо участвуете в какой-то популярной социальной сети, то вы можете быстро поделиться данной заметкой со своими друзьями и посетителями.
Можете что-нибудь посоветовать? Хотелось бы заставить частицы дыигаться вдоль графиков, скажем вдоль параболы. Так что бы рядком проезжали по ней и исчезали на конце.
print - самый простой вывод в лог. Debug.LogWarning - вывод сообщения в виде предупреждения (желтым треугольником). Debug.LogError - сообщение в лог в виде ошибки, оно остановит выполнение программы.
Нет, это только на первый взгляд так кажется ) Начни разбиратся, каждую строчку, собирай в юньке такой же проект и все поймешь. Там может быть немного непонятно будет про делегаты - почитай об этом дополнительно, на офф сайте вот есть очень понятный урок
Добавлять комментарии могут только зарегистрированные пользователи. [ Регистрация | Вход ]