Делаю игрулю на Playdate на чистом C. Глава 2

Я пишу игру на игровую консоль Playdate на чистом C. Игра в жанре "выживальщик" наподобие Vampire Survivors. Так как в чистом C отсутствуют многие современные объектно-ориентированные удобства мне приходится по-всякому изворачиваться чтобы адаптировать свои идеи в код. В этих заметках ты узнаешь из первых уст как создаётся игруля с нуля от идеи до публикации.

В прошлой главе я описал сеттинг, показал видео с тем что получилось после первой итерации (оно продублировано ниже), а также детально рассказал как я реализовал в коде свой динамический массив с нуля, потому что ничего подобного ни сишка, ни Playdate SDK мне не предоставляют из коробки. Если ты не читал прошлую главу, то лучше начать с неё.

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

Как ты помнишь логику игры я задумал вынести в класс Game. Да, это именно класс, ну по крайней мере в моей системе координат. А компилятор сишки, конечно же, вообще не в курсе что такое класс, но нас это не огорчает. У класса Game есть функции

  • GameCreate

  • GameSetup

  • GameUpdate

  • GameDestroy

Интерфейс класса Game выглядит вот так:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Содержимое файла Game.h

Обрати внимание на слово typedef на строке 12. Это слово придётся встречать в моём коде часто. Зачем оно тут нужно? Чтобы я мог использовать тип Game как отдельно-живущий полностью самостоятельный тип без постоянного указания слова struct перед ним. То есть, в сишке по-умолчанию если написать так:

struct MyStruct { ... };

а потом попробовать инстанс структуры MyStruct объявить вот так

MyStruct myStruct;

или попробовать MyStruct передать в функцию

void myFunction(const MyStruct *myStruct)

то мы получим ошибку компиляции. А всё потому что надо добавить слово struct перед названием типа:

struct MyStruct myStruct;

и

void myFunction(const struct MyStruct *myStruct)

Ты спросишь "нахуа?". А я отвечу "ну потому что вот так, потому что это сишка, детка". Если в сишке ты объявляешь структуру, то каждый инстанс с типом этой структуры должен иметь приписку что это структура чтобы не дай Боб компилятор не заподозрил что ты имеешь в виду что-то другое.

Полагаю, если ты шарпер или свифтер ты сидишь в афиге от этого, потому что и в C#, и во Свифте просто объявленая структура это самостоятельный тип без всяких приписок. Да, всё так, но C# и Свифт это современные языки, а сишка создавалась примерно за миллион лет до красной революции, а тогда тренды и привычки в разработке были совсем другие, и, в частности, типы данных struct и union были чем-то диковинным, потому при инстанциировании их нужно писать дополнительно слово struct и union соответственно. Время шло, struct и union из равноправных сущностей изменились в статусе: struct стал мегапопулярным (из него появились объекты в ООП), а union остался местечковой заморочкой. Примерно как USB-флэшки и компакт-диски: когда-то и те, и другие были плюс-минус равноправными способами передачи информации, но со временем мы пришли в точку повествования, где современное поколение не в курсе что такое компакт-диск. Да и я сам забыл когда использовал компакт-диски. А нет, вспомнил: когда у стоматолога делал снимок зубов. Я понятия не имею почему стоматологи снимки зубов принципиально скидывают на компакт-диски, а не на USB-флэшки. Но, возможно, чтобы это понять нужно получить медицинское образование вместе со способностью писать от руки непонятным почерком.

Так, со странностью описания структур в сишке понятно, но причём тут слово typedef? А это совершенно другая фича, которая в некотором виде существует во всех современных языках программирования: конструкция typedef existing new; это объявление алиаса примерно как

using new = existing; // в С++

typealias new = existing // в Swift

(как это делать в C# я забыл. Напиши в коментах если знаешь).

Вот только, как ты видишь, в конструкции typedef новое и старое значения переставлены местами. Почему? Потому что сишка, не задавай вопросы!

В итоге, конструкция типа

typedef struct {...} MyStruct;

это указание того, что я буду считать выражение MyStruct алиасом на struct {...} указанную в этой же строке. Иногда можно встретить вот такое:

typedef struct MyStruct {...} MyStruct;

Это то же самое, но с избыточным указанием имени в оригинальной структуре. Первый вариант не имеет имени, и по сути объявляет анонимную структуру (да, в сишке есть анонимные структуры!) и заводит на неё синоним. А второй вариант создаёт именованную структуру, которую можно из коробки использовать с припиской struct, но так как мы тут же объявляем на неё алиас с тем же именем, то struct можно опустить.

Думаешь "ну и дичь!"? А что если я тебе скажу, что в современном C++ эта особенность сохранилась, но только в виде необязательной фичи? Смотри: можно вот прям сегодня открыть твою любимую плюсовую IDE и написать не

std::vector<int> vec;

а

class std::vector<int> vec;

и оно скомпилится! Я когда впервые об этом узнал несколько лет назад моя реакция была примерно такой

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Простой программист осознающий, что его жизнь поделилась на "до" и "после"

Что ж, с typedef'ом разобрались, фух! Давай приступим к сути: к функциям. Пойдём сначала прямо внутрь GameCreate.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Реализация функции GameCreate в файле Game.c

Задача GameCreate создать инстанс игры - это билдер-функция, аналог конструктора в C++. Инстанс мы создаём в куче - это позволяет не создавать инстанс на старте приложения, а отложить его создание до получения init-события от операционной системы Playdate. Там, конечно, разница во времени почти нулевая между стартом игры и получением init-события, но чисто технически эта разница существует, потому делаем так.

Единственный аргумент, который нужен для создания инстанса игры это указатель на PlaydateAPI. Без него мы не можем вызывать API у операционной системы Playdate. Указатель на PlaydateAPI это как контекст в Android'е - без него можно писать код конечно, но только сферический код в вакууме, а не реальный код, который нагло и невозбранно взаимодействует с API системы направо и налево.

Так как я имитирую ООП, то некоторые ООП-шные вещи мне следует реализовывать на ручной тяге. В частности, инициализация всех полей структуры. То есть, я дал себе слово, что create-функции у меня должны обязательно инициализовать все поля создаваемой структуры как это сделано в Свифте из коробки, например. Почему это так важно? Потому что если ты создаёшь инстанс чего либо в куче, то гарантию того, что в поезде выделенных байтов не будет мусора, никто тебе не даст. Выделять память в куче это примерно как сесть покушать за стол на фуд-корте в торговом центре. Стол может оказаться кристально чистым, а может быть невероятно грязным словно на нём только что ела семья потомственных свиней.

На строке 16 происходит как раз выделение памяти для инстанса Game, который функция вернёт в конце. Далее мы заполняем все поля ничего не пропуская.

Указатель на PlaydateAPI мы прихранили в игре на строке 17 - это важно. Далее, мы создаём инстанс машины - той самой, на которой катается игрок и расстреливает животных (смотри видео). Кстати, про машину - это отдельный класс, инстанс которого хранится в игре в единственном экземпляре, так как машина только одна. Создаётся инстанс машины тоже в куче потому что в начале я думал, что инстансы всего буду создавать в куче. Спойлер: уже буквально после машины я передумал, так как достаточно хранить всё что нужно в виде значений как есть внутри игры как часть игры. Но с машиной пока так.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Описание класса PlayerVehicle в файле PlayerVehicle.h

Машина имеет совсем мало полей: ей много и не надо. Это:

  • позиция в формате Vec2f (вектор имеющий поля x и y в формате float) - строка 9

  • направление angle, один из восьми вариантов направлений машины. Это перечисление (enum), объявление которого я покажу очень скоро - строка 10

  • булевое значение isMoving, которое равно 0 если машина стоит, и 1 если машина движется. Так как типа bool в сишке нет мы нагло используем int - строка 11

  • значение ускорения, которое нужно для реализации физики движения в формате float - строка 12

И ещё две функции: конструктор и деструктор (строки 15 и 16 соответственно). Как и API массива эти функции принимают указатель на функцию realloc так как эту функцию нам предоставляет PlaydateAPI.

Теперь покажу как выглядит PlayerVehicleAngle:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Описание перечисления PlayerVehicleAngle

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

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Как бы выглядел PlayerVehicleAngle если бы проект писался на Свифте где-то в альтернативной вселенной

Ну или на C#:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Как бы выглядело описание PlayerVehicleAngle если бы проект писался на C# в другой альтернативной вселенной

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Как бы выглядело использование PlayerVehicleAngle если бы проект писался на C#

И как же без С++:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Как бы выглядело описание PlayerVehicleAngle если бы проект писался на C++ (где-то упала одна моя скупая мужская слеза) в ещё одной альтернативной вселенной

С направлением всё понятно, полагаю, как и с тем, как я описываю перечисления. Теперь вернёмся к машине, то есть, ко классу PlayerVehicle. Его объявление я тебе показал, давай теперь покажу реализацию.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Реализация класса PlayerVehicle

Как видишь, всё очень просто - создаём инстанс также в куче, присваиваем все без исключения поля и возвращаем что получилось. Это я говорю про конструктор - функцию PlayerVehicleCreate. А деструктор, PlayerVehicleDestroy, просто чистит выделенную под машину память.

Возвращаемся в игру туда, откуда мы ушли: в GameCreate, конструктор игры. На строке 19 я присваиваю cameraOffset - это смещение камеры, которое нужно для отрисовки позиции всего. Об этом подробнее позже. Далее я присваиваю NULL в поля cactusImage и sandImage. Это вот как раз то, о чём я говорил: это важно сделать, потому что иначе эти значения будут иметь мусор, а так как их тип это указатель (LCDBitmap *cactusImage), то без инициализации нулём на старте эти указатели будут указывать чёрт знает куда, и если я вдруг решу обратиться по этому адресу, то моя программа пойдёт по другому известному адресу, то есть, упадёт, крашнется или словит segfault (или 'сегфолт' по-русски если верить словарю Даля). Этого мы не хотим, потому что не для этого американцы создавали эту маленькую жёлтую консоль, а для бесконечного фана.

Далее прошу обратить внимание на три строки: 22, 23 и 24. В этих строках мы инициализируем наши динамические массивы. Первый это кактусы (cactuses), второй - песчаные горочки (просто sands), третий - перекати-поля (tumbleweeds). Если у тебя после прочтения первой главы остался вопрос как инициализировать массив, так скрупулёзно созданный мной байтик за байтиком, то вот это как раз тот самый пример.

Далее строку 25 давай пока пропустим - потом всё объясню. А вот после у нас идёт заполнение массивов кактусов и песков (буду говорить так вместо "песчаных горочек" , российский усатый политик тут ни при чём). Самые главные строчки это 40 и 43. В них мы непосредственно добавляем свежесозданный кактус либо песок в соответствующий массив вызывая уже известную из первой главы функцию ArrayPushBack.

Как именно заполняется карта игры песком и кактусами и что за такие RangeCreate (строки 31 и 33) и RandomIntInRange (строка 37)? Range это невероятно удобный вспомогательный класс, который я создал подглядев идею в стандартной библиотеке Свифта. Range(x, y) это диапазон значений по аналогии как в математике указывается [x, y), что означает диапазон от числа x включая x (об этом свидетельствует квадратная скобка) и до числа y НЕ включая y (об этом свидетельствует круглая скобка).

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Содержимое файла Range.h

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Содержимое файла Range.c

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

Теперь давай я расскажу как же я заполняю игровое поле кактусами, песком, и почему в этом коде нет перекати-поле. Самый тупой вариант это нагеренить N объектов (кактусов и песков) со случайными координатами x и y в пределах размера поля (размер поля, кстати, от -1000 до 1000, то есть, 2000). Но так делать не надо потому что с таким подходом чисто теоретически может получиться, что на достаточно большом куске поля (например, размером с экран, то есть 400 на 240) не выпадет ни одного объекта. И в таком случае поле будет выглядеть пустым и будет казаться, что машина едет по белому листу в MS Paint'е. Такое нам не нужно. Вместо этого нужно чтобы игрок регулярно видел реквизит, напоминающий о том, что мы едем именно по пустыне. Можно, конечно, повтыкать через одно и то же расстояние последовательно кактусы и пески как будто их вкопали солдаты квадратно-гнездовым методом, но это будет выглядеть обсосно и без души. Важно найти золотую середину - использовать рандом, но укротить его. Потому алгоритм я выбрал такой: я делю карту на прямоугольные блоки 150 на 100 (строка 27)

const Vec2i blockSize = Vec2iCreate(75 * 2, 50 * 2);

и в каждый блок в случайную точку этого блока ставлю один объект: либо кактус, либо песок. Поставить ли кактус или песок я определяю так же рандомом на строке 37:

const int isCactus = RandomIntInRange(RangeCreate(0, 2));

И если isCactus равна 1, значит ставим кактус, иначе - песок.

Вообще, конечно, можно попробовать ради простого теста сгенерить столько же кактусов и песков с абсолютно рандомной позицией без всяких ограничений в виде блоков. Давай вот прям щас и попробуем это сделать. Смотри: всего кактусов/песков на карте создаётся

ceil(2000 / 150) * (2000 / 100)

ceil это функция округления вверх. В математике она обозначается как квадратные скобки без нижних пип (у квадратных скобок есть верхние и нижние горизонтальные пипы, вот если нижние убрать, то получится округление вверх). Почему я добавил эту функцию сюда - потому что деление 2000 на 150 не даст целое число, а если нам нужно точное значение, то надо решить что делаем с остатком. Исходя из условия нашего цикла на строке 28

for (float xMin = -1000; xMin <= 1000; xMin += blockSize.x + 20) {

остаток следует учитывать. Упс, я только что заметил, что есть ещё +20 на той же строке 28 - это я делаю пробел между блоками для пущей правдоподобности чтобы не было двух кактусов приклеенных друг к другу. Хм, тогда пересчитываем всё. По оси x я делаю отступ 20, по оси y - 15. Значит, всего кактусов/песков на карте создаётся

ceil(2000 / (150 + 20)) * ceil(2000 / (100 + 15)

это упрощается в

12 * 18 = 216

Значит, всего 216 объектов. Теперь давай перепишем генерацию кактусов/песков на рандомную позицию от -1000 до 1000 и посмотрим что получится.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Изменённый алгоритм генерации кактусов и песка

В целом такой вариант тоже выглядит неплохо, однако бывают большие участки вообще без ничего. А один из тысячи вариантов генерации точно сотворит такое, что будет огромная дыра, а такого мне не надо. Так что поигрались и хватит - возвращаем старый алгоритм и идём дальше.

Кстати, возможно логичнее было бы положить создание кактусов в GameSetup, а не GameCreate. Ну да ладно - как сделали так сделали.

На этом с функцией GameCreate мы закончили. Далее идёт GameSetup - небольшая функция, которая стартует игру. Как я говорил, она по логике похожа на GameCreate потому что тоже вызывается единожды и тоже до всех обновлений (тиков) игры, но строго после GameCreate.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Реализация функции GameSetup

Тут кода мало: мы сначала вызываем функцию с забавным названием srand, которая инициализирует генератор случайных чисел. Можно её и не вызывать, но тогда на пятидесятую игру ты начнёшь замечать, что все случайности в игре (например, позиции кактусов) ничуть не случайны. Нам это не нужно. Далее мы вызываем GamePreloadImages для загрузки картинок (помнишь, мы все картинки проинициализировали как NULL в GameCreate?). После этого мы устанавливаем значение в cameraOffset равным половине экрана - так надо. Давай я подробнее расскажу про загрузку картинок - там есть интересные вещи.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Исходный код функции GamePreloadImages

Все картинки у меня лежат в папке images в проекте. Кадры машины, которых 8 штук по одному для каждого направления, имеют гениальные названия: 1.png, 2.png и так далее до восьми. Для загрузки одной картинки мне нужно вызвать функцию loadImageAtPath, которая принимает путь к картинке в пределах проекта (images/1.png, images/2.png и так далее) и необходимый указатель на PlaydateAPI.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Реализация функции loadImageAtPath

(LCDBitmap это тип из Playdate SDK, который означает картинку) Я честно позаимствовал эту функцию в примере игры на сишке у самого Playdate, так что тут ничего особенного нет. А вот то, как я загружаю, это следует понять. Как вообще загрузить 8 картинок с последовательными названиями? Конечно же, можно сделать просто 8 строк типа такого:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Пример того, как делать не стоит

Такой подход в простонародье называют китайский код. Конечно же, я делаю не так, а реализовал цикл. Имя картинки в цикле можно сгенерить зная индекс итерации. И в языках высокого уровня использовалась бы функция форматирования строки. Вот как я бы такое сделал на Свифте:

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Потенциальная реализация загрузки картинок на Свифте

В коде на Свифте важно обратить внимание на генерацию полного пути к файлу картинки. Конструкция покрашенная красным создаёт строку вставляя в неё значение переменной-итератора i, которое меняется от 1 до 8 включительно. В сишке, к большому-пребольшому сожалению, нет простого API для подобной генерации динамических строк. Да, есть функция sprintf, которая позволяет вытворять что-то похожее, однако она не заботится о размере строки потому что это наша забота, а такое нам не нужно. Потому этой функцией я тоже не пользуюсь. Вместо этого я в своём сишном коде создал один раз шаблон пути к файлу "images/0.png" (строка 205) и в каждой итерации просто подменяю одну буковку, точнее, циферку перед точкой (строка 207):

imagePath[7] = '0' + i;

Что же тут такого особенного? А то, что в варианте на Свифте (или на любом другом языке высокого уровня в том числе С++ с той же либой fmt) каждую итерацию будет выделяться и чиститься по одной динамической строке, а у нас в сишке выделяется 0 динамических строк, есть только одна статическая (потому что нам заранее известен её размер), а мы аккуратно скальпелем меняем в ней один байтик и вуаля - никаких строк не нужно генерить. Не нужно использовать хитрый форматер, я сам себе форматер!

Зачем я так делаю? Прекрасный вопрос, спасибо. Я это делаю точно не для того чтобы уменьшить нагрузку на процессор консоли потому что это получится экономия на спичках. Я так делаю лишь потому что это сделать очень просто в сишке и ещё потому что в сишке нет нормальной либы для форматирования динамических строк.

Кстати, подробнее про строку в коде imagePath[7] = '0' + i;. Тут происходит магия ascii-символов. Ноль в одинарных кавычках это литерал одиночного символа равный нулю, как строка, но только один её элемент. Если строка это поезд, то ноль в одинарных кавычках это один вагон. Если к нему прибавить целое число, то символ станет другим. Если прибавить один, то символ изменится на один вперёд в таблице ascii. А в таблице ascii цифры стоят последовательно, к счастью. То есть, если к нулю я прибавлю 1, то получу символ единицы. Если бы в таблице ascii цифры были бы в беспорядке, то данный фокус бы не сработал, и задачу пришлось бы решать либо форматтером, либо китайским кодом.

Фух. Как самочувствие? Не устал ещё? Мы прошли хороший путь: разобрали GameCreate, GameSetup, а сейчас я хочу завершить эту главу функцией, которой завершается игра - GameDestroy.

Делаю игрулю на Playdate на чистом C. Глава 2 Программа, Гайд, Видео, Гифка, Длиннопост

Реализация функции GameDestroy

Тут всё суперпросто: мы уничтожаем объект машины (строка 186) так как он создавался в куче как и массивы, потом уничтожаем все три массива, которые аналогично последовательно создавали внутри GameCreate (кактусы, пески и перекати-поле), а потом уничтожаем саму игру.

Кстати, в самом начале функции я проверяю game на NULL (строка 182), и если это так, то просто выхожу из функции. Я сначала думал, что это логично и позволяет писать безопасный код. В частности, на С++ я так делаю всегда. Однако на сишке в отличие от С++ почти всё передаётся через указатели, и ты просто запаришься проверять всё на NULL (например, представь если везде проверять указатель PlaydateAPI на NULL). Потому это был первый и последний раз когда я осуществил такую проверку - далее я просто решил для себя, что везде где ожидаются ненулевые данные я просто буду верить себе на слово, что там не NULL.

Итого в первый день я создал машину и поле, а во второй - кактусы и песчаные насыпи.

А что же с функцией GameUpdate? GameUpdate это самая важная функция в моём коде на данный момент потому что она в отличие от всех прочих функций класса Game вызывается стабильно каждый тик примерно так же, как сердце гоняет кровь туда и обратно по организму каждую секунду. Без этой функции игры не будет. Но разберём эту функцию мы уже в следующей главе.

UPD:

Глава 3

Лига Разработчиков Видеоигр

6.6K поста22.1K подписчиков

Добавить пост

Правила сообщества

ОБЩИЕ ПРАВИЛА:

- Уважайте чужой труд и используйте конструктивную критику

- Не занимайтесь саморекламой, пишите качественные и интересные посты

- Никакой политики


СТОИТ ПУБЛИКОВАТЬ:

- Посты о Вашей игре с историей её разработки и описанием полученного опыта

- Обучающие материалы, туториалы

- Интервью с опытными разработчиками

- Анонсы бесплатных мероприятий для разработчиков и истории их посещения;
- Ваши работы, если Вы художник/композитор и хотите поделиться ими на безвозмездной основе

НЕ СТОИТ ПУБЛИКОВАТЬ:

- Посты, содержащие только вопрос или просьбу помочь
- Посты, содержащие только идею игры

- Посты, единственная цель которых - набор команды для разработки игры

- Посты, не относящиеся к тематике сообщества

Подобные посты по решению администрации могут быть перемещены из сообщества в общую ленту.

ЗАПРЕЩЕНО:

- Публиковать бессодержательные посты с рекламой Вашего проекта (см. следующий пункт), а также все прочие посты, содержащие рекламу/рекламные интеграции

- Выдавать чужой труд за свой

Подобные посты будут перемещены из сообщества в общую ленту, а их авторы по решению администрации могут быть внесены в игнор-лист сообщества.


О РАЗМЕЩЕНИИ ССЫЛОК:

Ссылка на сторонний ресурс, связанный с игрой, допускается только при следующих условиях:

- Пост должен быть содержательным и интересным для пользователей, нести пользу для сообщества

- Ссылка должна размещаться непосредственно в начале или конце поста и только один раз

- Cсылка размещается в формате: "Страница игры в Steam: URL"