Безрассудство и смелость: Создание игры Disciples 2 на Avalonia и WPF

Безрассудство и смелость: Создание игры Disciples 2 на Avalonia и WPF

Здравствуй, Хабр!

Поступая в университет на программиста, я мечтал стать разработчиком игр. Но жизненный путь привёл меня в десктоп на WPF (о чём я, на самом деле, не жалею). А в пет-проекте объединились опыт десктоп-разработчика и стремление писать игры. Так родился пингвинослон Disciples II на Avalonia и WPF.

Вот так оно и ощущается

В статьей пойдёт речь о получившемся «движке», об оригинальных ресурсах игры и том, как я их адаптировал под проект.

Итак, Бетрезен умирает.

Сразу с нюансов:

  1. Реализована только битва двух отрядов (в наличии все типы атак, но нет бонусов от снаряжения/заклинаний/зелий), а также несколько стартовых экранов. Есть режим битвы двух случайных отрядов.

  2. У меня нет опыта разработки игр, только классических десктопных приложений. Из-за этого архитектура «движка» игры разрабатывалась по наитию / потребностям, и не стоит его воспринимать всерьёз.

  3. Код доступен на GitHub под лицензией MIT. Но права на Disciples II принадлежат Strategy First.

Навигация

  1. О Disciples II

  2. С чего началась разработка

  3. Общий принцип работы

  4. База данных игры

  5. Вывод текста

  6. Файлы ресурсов, формат MQDB

  7. Изображения и анимация

  8. Музыка и звуки

  9. Видео

  10. Расположение элементов интерфейса

  11. Запуск на Linux

  12. Заключение

О Disciples II

Disciples II: Dark Prophecy (рус. «Последователи II: Тёмное Пророчество») — пошаговая стратегия для PC от Strategy First, выпущенная 24 января 2002 года. Вторая игра серии Disciples. Игра является продолжением игры 1999 года Disciples: Sacred Lands.

Игровой процесс включает три основных компонента:

  • Улучшение столицы игрока, что даёт доступ к новым войскам и возможностям. Изучение новых заклинаний.

  • Использование лидеров-героев, возглавляющих небольшие отряды, для разведки, атаки и захвата территорий.

  • Режим боя двух отрядов.

Сохранивший вышеперечисленные основные черты предшественника, геймплей Disciples II существенно усовершенствован.

Позже вышли дополнения «Возвращение Галлеана» (на самом деле, объединение двух дополнений «Гвардия света» и «Гвардия Тьмы»), а также «Восстание Эльфов» (которое добавило новую расу и кампанию за неё).

С чего началась разработка

Шёл 2018 год, я натолкнулся на выступление@kekekeks, где он показывал игру BattleCity на Avalonia UI. Меня поразило то, как мало разметки и кода необходимо написать, чтобы получить рабочую спрайтовую графику. Avalonia позиционировала себя как кроссплатформенная замена WPF, поэтому я решил объединить приятное с полезным: начать изучать фреймворк, разрабатывая игру (забавное наблюдение: таким способом Avalonia изучить не получилось).

Выбор пал на Disciples 2 по нескольким причинам:

  1. Я обожаю эту игру, и был неплохо знаком с её механикой.

  2. Она имеет спрайтовую графику, с которой легко работать.

  3. Существует большое количество статей и инструментов для создателей модов, которые помогают понять как работать с ресурсами оригинальной игры.

Быстро стало понятно, что от возможностей Avalonia используется очень мало: контролы Canvas, Image, TextBlock, плюс обработка событий ввода. Ради интереса выделил интерфейсы, сделал реализацию под WPF. Появилась возможность запускать игру на двух разных фреймворках и сравнивать поведение. Еще одно наблюдение: WPF рендерит гораздо медленнее, чем Avalonia.

Фраза «у самурая нет цели, только путь» отлично символизировала работу над проектом: я не знал для чего его делаю, но мне было интересно им заниматься. В последние пару лет этот проект стал для меня еще и отдушиной: на основной работе Skype заменил мне Visual Studio, и я сильно переживал, что теряю навык программирования.

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

Что ж, переходим от слов к коду!

Общий принцип работы

«Движок» игры построен на трёх основных типах:

IScene — сцена. Вся игра делится на сцены: сцена главного меню, сцена выбора сейва для загрузки, сцена битвы и т.д. Сцена содержит список объектов для отображения, управляет им и обрабатывает действия игрока.

ISceneObject — визуальный объект на сцене. Массив этих объектов помещается в Canvas и с помощью DataTemplate конвертируется в необходимый контрол: TextBlock, Image или VideoView. В итоге вся вёрстка занимает около 100 строк! Вот Avalonia, а вот WPF.

GameObject — объект игры. Пример объектов: кнопка, анимация, портрет юнита. Объединяет в себе некоторую логику, может рисовать элементы на сцене, хранить состояние, реагировать на действие пользователя. Поддерживает логику компонентов, которая позволяет собирать объект как конструктор. Например, кнопка и портрет юнита должны обрабатывать клик мышью по ним, поэтому оба содержат в себе MouseLeftButtonClickComponent, где реализована общая логика обработки нажатия.

Также есть таймер, который пытается сработать 60 раз в секунду (= 60 FPS). При каждом срабатывании происходит обработка действий игрока и при необходимости перерисовываются отдельные объекты (отображается следующий кадр анимации, происходит выделение объекта и т.д.).

Как видите, никакого рокет-сайенс здесь нет. Если добавить в массив новый ISceneObject, то он отобразится на экране, так как изменится ItemsSource у Canvas. С удалением аналогично. Также можно изменять объекты, за счёт INotifyPropertyChanged, они также будут перерисовываться. То есть всю работу на себя берёт Avalonia/WPF.

База данных игры

В оригинальной игре вся информация о юнитах, зданиях, артефактах и тому подобном лежит в .dbf файлах. Посмотреть их можно с помощью Microsoft Excel.

Часть таблицы, которая описывает тип юнита
Часть таблицы, которая описывает тип юнита

Эти файлы можно легко прочитать и распарсить с помощью NDbfReader (сейчас помечен deprecated). Но дальше работать с этим оказалось неудобно, душа требовала ORM, которые работают только с настоящими базами данных. Проблему решил в лоб: сделал мигратор, который читает данные из .dbf файлов и заливает в соответствующие таблицы БД sqlite. Игра взаимодействует только с sqlite с помощью EntityFramework.

Таблиц в игре очень много, пытаться разобраться что значит каждая колонка очень утомительно. Поэтому мигратор написан только для файлов, которые нужны для сцены битвы. Структура БД sqlite получилась следующей:

Количество связей между таблицами - одна из причин, почему я захотел использовать ORM
Количество связей между таблицами — одна из причин, почему я захотел использовать ORM

Ссылки на код:

  1. Парсер DBF файлов.

  2. Мигратор DBF -> sqlite.

  3. Проект sqlite + EF.

Вывод текста

Строки хранятся в БД в двух таблицах:

  1. GlobalTextResource (в оригинальной игре файл Tglobal.dbf) содержит названия юнитов, заклинаний, предметов и так далее. Все текстовые ссылки внутри БД ведут именно на эту таблицу (например, UnitType.NameTextId).

  2. InterfaceTextResource (в оригинальной игре файл TApp.DBF) содержит строки для элементов интерфейса: кнопок, подсказок, заголовков и так далее. Часть ссылок лежит в файле интерфейса (о нём будет ниже в «Расположение элементов интерфейса»), остальное зашивается хардкодом.

Строки, лежащие в InterfaceTextResource, также могут содержать форматирование и плейсхолдеры. Например, строка с идентификатором X005TA0423 содержит описание характеристик юнита и выглядит так:

\s110;\fmedbold;Level:\t\fnormal;%LEVEL%\n\fMedbold;XP:\t\fNormal;%XP%\n\fMedbold;HP:\t\fnormal;%HP1% / %HP2%\n\fMedbold;Armor:\t\fNormal;%ARMOR%\n\fMedbold;Immunities:\t\fNormal;\p110;%IMMU%\mL0;\fMedbold;Wards:\t\fNormal;\p110;%WARD%
А выводится вот так
А выводится вот так

Форматирование позволяет установить шрифт, указать любой цвет в формате RGB для текста/обводки текста/фона, а также задать выравнивание и отступы.

Шрифты в оригинальной игре лежат в папке Interf и имеют расширение .mft. Файл содержит пиксельную маску для каждого символа. Подробнее структура описана здесь.

Шрифт меню в утилите от Sergey A. Klochkov (HSerg)
Шрифт меню в утилите от Sergey A. Klochkov (HSerg)

Правильное решение для вывода текста состоит в том, чтобы с помощью файла шрифта формировать попиксельно изображение и располагать его на сцене, но я остановился на более простом варианте, поэтому текст выводится с помощью готового контрола TextBlock. Каждому .mft файлу сопоставлен размер шрифта, жирность, стиль (курсив/обычный). Так как внутри одной строки могут чередоваться шрифты, то используется InlineCollection, состоящий из отдельных Run/TextBlock с разными стилями.

Решение с TextBlock имеет следующие минусы:

  1. Используется не оригинальный шрифт игры, а визуально похожий PT Serif.

  2. Текст векторного формата, а картинки растрового — поэтому он выделяется на фоне растянутой картинки 800*600.

  3. Поддержка обводки текста оказалось слишком сложной, как и некоторых типов отступов, поэтому текст где-то сливается с фоном, а где-то некорректно отформатирован.

Ссылки на код:

  1. Парсер для стиля текста.

  2. Конвертер для создания InlineCollection Avalonia.

  3. Специальный TextBlock для WPF.

Файлы ресурсов, формат MQDB

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

MQDB
[28_MAGIC_BYTES]
MQRC[RECORD_1]MQRC[RECORD_2]...MQRC[RECORD_N]
[FILE_1][FILE_2]...[FILE_N]

MQDB и MQRC — это обычные строки.

Структура RECORD содержит идентификатор файла, его размер и начало в файле ресурса (смещение в байтах от начала файла).

Структура FILE — содержимое файла, который может быть чем угодно.

В описываемом контейнере всегда есть специальный файл, который содержит имена всех других файлов в ресурсе. Он имеет следующую структуру:

[FILENAME_1][RECORD_INDEX_1]
[FILENAME_2][RECORD_INDEX_2]
...
[FILENAME_X][RECORD_INDEX_X]

Игра обращается к файлам в ресурсе по имени. Чтобы найти его содержимое нужно:

  • Найти RECORD_INDEX по имени файла.

  • Найти структуру RECORD, получить смещение файла и размер.

  • Передвинуть файловый поток на указанную позицию и считать файл.

Также в ресурсе могут присутствовать другие специальные файлы (обычно имеют имя -{XXX}.DAT или -{XXX}.OPT), которые содержат дополнительные метаданные (примеры таких файлов приведены для изображений ниже).

Ссылки на код:

  1. Базовый парсер для MQDB файлов.

Изображения и анимация

Изображения и анимации, запакованные в файлах с расширением .ff (формат MQDB), в оригинальной игре можно найти в папках Imgs и Interf. Чтобы извлечь что-то из этого файла и вывести на экран, нужно долго и упорно танцевать с бубном. Почти все файлы внутри контейнера — это обычные изображения с расширением .png, но без альфа-канала. И почти каждое это изображение — пазл, который нужно собрать.

Изображения можно разделить условно на 4 типа:

  1. Сразу готовое изображение (встречается крайне редко).

Лицо Ашкаэля, стража столицы Легионов Проклятых
Лицо Ашкаэля, стража столицы Легионов Проклятых
  1. Можно собрать одно другое изображение.

Портрет Ашкаэля: до и после преобразования
Портрет Ашкаэля: до и после преобразования
  1. Можно собрать много небольших изображений (обычно это элементы интерфейса сцены).

Все элементы интерфейса сцены битвы
Все элементы интерфейса сцены битвы
  1. Собирается несколько изображений, которые образуют анимацию.

Анимация атаки Ашкаэля
Анимация атаки Ашкаэля
Итоговая анимация

Алгоритмы «нарезки» описаны в трёх специальных файлах:

-INDEX.OPT: содержит записи о том, что изображение с именем XXX находится в базовом изображении YYY.

-IMAGES.OPT: содержит записи из каких частей базового изображения YYY состоит изображение XXX. Также здесь есть дополнительные метаданные: палитра, прозрачный цвет, алгоритм применения прозрачности.

-ANIMS.OPT: содержит записи, что анимация с именем ZZZ состоит из изображений XXX1, XXX2, …, XXXN.

Вывод на сцену изображения XXX получился следующим образом:

  1. Находим файл базового изображения YYY, с помощью библиотеки SkiaSharp конвертируем его в байтовый массив, настраиваем прозрачность.

  2. Вычисляем итоговые размеры изображения XXX. Изображение может иметь размеры 800*600, но по факту непрозрачная часть будет 100*100. Именно её и будем выводить на сцену — это экономит память и ускоряет отрисовку.

  3. Создаём новый байтовый массив согласно размеру изображения, заполняем его, копируя части из изображения YYY.

  4. Отдаём байтовый массив фреймворку Avalonia/WPF, где он конвертируется во WritableBitmap и выводится на сцену.

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

  1. Свой «движок» игры я изначально строил на выводе готового спрайта, а не кусков исходного изображения, поэтому пришлось заранее всё склеивать.

  2. Алгоритм применения прозрачности — боль во всех моих частях тела.

    Во-первых, Avalonia/WPF напрямую не поддерживают colorkey (либо я не нашёл), из-за этого требуется перебирать каждый пиксель в изображении и выставлять прозрачность в 0.

    Во-вторых, в изображении может быть несколько полностью прозрачных цветов (BGR): #FF00FF, #FE00FF, #FF01FF и так далее (то есть в некотором диапазоне от истинного прозрачного цвета — #FF00FF), причём это никак не прописано в метаданных. Этот диапазон тоже приходится проверять и обрабатывать.

    В-третьих, часть изображений имеют вычисляемую прозрачность. Например, тени юнита имеют прозрачность 128, а различные анимации аур от 0 до 255, в зависимости от индекса цвета в палитре.

В итоге куча процессорного времени тратится на обработку изображений. Была идея перепаковать все изображения сразу со встроенным альфа-каналом, но пока решил оставить как есть.

Ссылки на код:

  1. Распаковщик изображений и анимаций.

  2. Создание битмапа Avalonia.

  3. Создание битмапа WPF.

Музыка и звуки

Часть аудио в оригинальной игре хранится отдельными файлами с расширением .wav в папке Music и Briefing. Здесь можно найти различную фоновую музыка для сцен (главное меню, битвы, карта), а также озвучку начала и завершения миссий.

Остальное аудио лежит в папке Sounds и запаковано в файлы с расширением .wdb (MQDB формат): здесь хранятся звуки юнитов во время битвы (Battle.wdb), звуки перемещения по глобальной карте (Midgard.wdb) и просто различные звуки окружения (AudioRgn.wdb).

Для юнитов также есть отдельный файл Battle.wdt, который имеет структуру MQDB, как и остальные. В нём для каждого типа юнита указаны его:

  1. Звуки атаки.

  2. Момент, когда нужно начать и завершить проигрывать звук (номера кадров анимации).

  3. Звуки попадания (если п. 1 проигрывается всегда при атаке, то этот звук только при успешном попадании по противнику).

  4. Момент, когда нужно начать и завершить проигрывать звук попадания (номера кадров анимации). Вместе с началом проигрывания этого звука также происходит расчет и отображение результата атаки (успешное попадание, промах и так далее).

  5. Звуки получения удара.

  6. Звуки передвижения по глобальной карте.

«Звуки» указаны во множественном числе, так как для каждого юнита на одно действие может приходиться несколько вариантов. При проигрывании выбирается случайный из них.

Для воспроизведения музыки и звуков используется кроссплатформенная библиотека ManagedBass.

Ссылки на код:

  1. Распаковщик ресурсов звуков.

  2. Распаковщик маппингов звуков.

  3. Класс для воспроизведения звуков.

Видео

Как и с аудио, часть видеороликов в оригинальной игре хранятся отдельными файлами: с расширением .bik, в директориях Video и Briefing. Здесь можно найти стартовые видеоролики, а также ролики начала и завершения миссий.

Остальные видеоролики запакованы в ресурсы, например, MenuAnim.ff (формат MQDB) — они используются как анимации перехода между страницами или портретами лидеров.

Примеры таких видеороликов
Переход из меню на страницу выбора расы
Переход из меню на страницу выбора расы
Выбор кампании за людей
Выбор кампании за людей

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

Ссылки на код:

  1. Распаковщик ресурсов (тот же, что и для изображений).

  2. Модель для контрола видео Avalonia.

  3. Модель для контрола видео WPF.

Расположение элементов интерфейса

В оригинальной игре интерфейс каждой сцены формируется динамически с помощью файла Interf\Interf.dlg. В нём содержится детальное описание интерфейса каждой сцены: её размеры, фон, курсор, а также список всех элементов (текст, изображения, анимации, кнопки т. д.) с их размерами. Для примера разберём сцену выбора сейва одиночной игры:

DIALOG	DLG_LOAD,0,0,800,600,DLG_LOAD_BG1TRANS,_CUDEFAUL,0,0,0,0,640,480,0
BEGIN
	BUTTON	BTN_BACK,381,553,451,600,DLG_LOAD_CUSTOM_TOURNEMENT_GCAN01BN,DLG_LOAD_CUSTOM_TOURNEMENT_GCAN01BH,DLG_LOAD_CUSTOM_TOURNEMENT_GCAN01BC,DLG_LOAD_CUSTOM_TOURNEMENT_GCAN01BD,"X100TA0040",0,27
	BUTTON	BTN_GAME_LIST_DOWN,500,245,537,282,_MENU_ARROW_DOWN_N,_MENU_ARROW_DOWN_H,_MENU_ARROW_DOWN_C,_MENU_ARROW_DOWN_D,"",1,40
	BUTTON	BTN_GAME_LIST_UP,500,87,537,124,_MENU_ARROW_UP_N,_MENU_ARROW_UP_H,_MENU_ARROW_UP_C,_MENU_ARROW_UP_D,"",1,38
	BUTTON	BTN_LOAD,730,553,800,600,DLG_LOAD_CUSTOM_TOURNEMENT_GOK01BN,DLG_LOAD_CUSTOM_TOURNEMENT_GOK01BH,DLG_LOAD_CUSTOM_TOURNEMENT_GOK01BC,DLG_LOAD_CUSTOM_TOURNEMENT_GOK01BD,"X100TA0117",0,13
	BUTTON	BTN_PG_DN,139,513,143,517,,,,,"",0,34
	BUTTON	BTN_PG_UP,62,512,66,516,,,,,"",0,33
	IMAGE	IMG_RACE_1,548,387,593,460,,""
	IMAGE	IMG_RACE_2,606,387,651,460,,""
	IMAGE	IMG_RACE_3,666,387,711,460,,""
	IMAGE	IMG_RACE_4,723,387,768,460,,""
	TLBOX	TLBOX_GAME_SLOT,550,24,765,374,1,0,0,BTN_GAME_LIST_UP,BTN_GAME_LIST_DOWN,,,BTN_PG_UP,BTN_PG_DN,BTN_LOAD,"\b255;255;255;","",,,0,"",0
	TEXT	TXT_DESC,425,472,765,570,\c000;000;000;,"",""
	IMAGE	TXT_FIREFLY,73,321,260,433,_FIREFLY,""
	TEXT	TXT_INFO,425,346,535,456,\c000;000;000;\fSmall;\vC;\hC;,"",""
END
Красными рамками выделено примерное расположение элементов согласно файлу
Красными рамками выделено примерное расположение элементов согласно файлу

Изображение фона сцены это DLG_LOAD_BG1TRANS, курсора — _CUDEFAUL. Также перечисляется список элементов:

  1. BUTTON — кнопка. Для них задаются изображения для каждого из состояний (обычное, выделена, зажата, заблокирована), подсказка, признак автоповтора нажатия (repeat button), горячие клавиши. На данной сцене всего шесть кнопок, две из которых невидимы (но имеют горячую клавишу, «страница вверх» / «страница вниз»).

  2. IMAGE — изображение или анимация. На сцене располагается одна анимация (TXT_FIREFLY с анимацией _FIREFLY) и 4 плейсхолдера для иконки рас (IMG_RACE_1/2/3/4).

  3. TLBOX — таблица с текстовыми контролами. Содержит множество данных: названия кнопок для управления, стиль текста. На сцене таблица содержит одну колонку с названиями сейвов.

  4. TEXT — текстовый элемент. В ресурсах может быть указан статичный текст, но в данном случае используется как плейсхолдер для описания сейва.

Более подробное описание полей можно посмотреть здесь или в коде.

Благодаря этим элементам любую сцену можно отрисовать следующим образом:

  1. Найти её описание в Interf.dlg, настроить размеры, фон и курсор.

  2. Для каждого элемента создать GameObject и присвоить ему начальное состояние (спрятать, заблокировать, задать обработку нажатия и так далее).

  3. Дополнительно управлять элементами в зависимости от событий на сцене (блокировать и активировать кнопки, заполнять плейсхолдеры).

Сложная сцена (например, битва) может требовать тонкого контроля над состоянием элементов интерфейса, но данный подход всё равно сильно экономит время.

Ссылки на код

  1. Распаковщик файла интерфейса.

  2. Создание объектов на сцене.

Запуск на Linux

Одна из киллерфич Avalonia — кроссплатформенность, поэтому давайте посмотрим его в деле на Virtual Box 7.0 и Ubuntu 22.04. Накатываем чистый образ, после ставим необходимые библиотеки: .NET 8.0 и vlc для видео.

sudo apt-get install -y dotnet-runtime-8.0
sudo apt install vlc
sudo apt install libvlc-dev

Приложение для Linux легко можно собрать в Windows, для этого настраиваем в Visual Studio публикацию с настройкой Target runtime linux-x64 (но можно выбрать любую другую разрядность, если её поддерживают нативные библиотеки).

Настройка публикации в Visual Studio
Настройка публикации в Visual Studio

Копируем получившийся билд на виртуалку и запускаем:

С чем я столкнулся для поддержки Linux:

  1. Пришлось искать кроссплатформенные библиотеки, например, популярная библиотека для аудиоNAudio не работает на Linux.

  2. Файловые пути: Windows понимает как прямой слэш \, так и обратный /, Linux только обратный /. Дабы не наступить на мои грабли, советую использовать Path.Combine или обратные слэши для путей.

  3. Оконная система Linux — X11 — имеет свои особенности. Чтобы правильно отмасштабировать экран игры при старте проверяются размеры окна. В случае Linux это отрабатывало некорректно, поэтому пришлось прибегнуть к небольшим ухищрениям..

Как видите, эти проблемы незначительны по сравнению с тем, что один и тот же код работает на двух разных ОС. На Linux приложение заметно подлагивает, но я списываю это на виртуальную машину.

Заключение

Таким образом, на практике доказано что на Avalonia можно разрабатывать игру двадцатилетней давности. WPF, увы, из коробки не вывозит (но всегда можно поискать оптимизации!). Тем не менее, продолжать разработку в том же направлении было бы совсем безумием даже для меня. Теоретически, можно было бы попытаться перенести текущие наработки на тот же Unity, но я не уверен дойдут ли у меня до этого руки.

Отдельно хотел отметить и выразить большую благодарность моддерам и разработчикам modding toolset for Disciples 2. Они помогли разобраться в работе с ресурсами, во внутренней логике игры и многом другом. Также они очень здорово прокачали движок оригинальной игры, добавив кучу уникальных вещей (в разработке находятся моды даже на новые расы!).

Спасибо что дочитали. Надеюсь, что у кого-то возникнет желание пройти Disciples II еще один раз :.

В завершении еще немного ссылок:

  1. Еще раз ссылка на репозиторий Git. В README можно найти ссылки на скомпилированную игру для Windows и для Linux.

  2. Фан-сообщество Disciples в Discord.

  3. Фан-сообщество Disciples во ВКонтакте.

  4. Популярные моды на Disciples II.

 

Источник

Читайте также