Как стать автором
Обновить

Tabletop Simulator — редактор колод

Время на прочтение11 мин
Количество просмотров6.5K

Я занимался созданием стола для карточной игры в Tabletop Simulator (TTS) и ощутил нехватку удобного инструмента для управления колодами. Из того что можно найти на youtube есть два способа: первый - это вручную в любом графическом редакторе сеткой расставлять карточки; второй - приложение, которое находится в папке с игрой, которое делает то же самое, только чуть удобнее. Оно позволяет мышкой расставить карточки по слотам с номерами. Неудобно в этом способе все. При импорте такой колоды в игру вам нужно вручную вводить имена и описания для карт, а если вы ошиблись, то делать импорт повторно и вводить данные снова. Так же если у вас карт больше, чем может вместить одна колода (69 карт на страницу), то нужно вручную размещать на нескольких страницах и отдельно импортировать их. Это приложение работает только под Windows, хотя сам TTS спокойно работает как на Linux, так и на MacOS. В этой статье речь пойдет о приложении, которое я долгое время разрабатываю и вот, наконец-то, я решил его представить на публике.

Вторая статья

Интерфейс официального приложения
Интерфейс официального приложения

Что такое колода в TTS и как ее добавить

Для начала, вкратце, как добавляются колоды вручную и какие есть ограничения.
Для того чтобы добавить колоду в игру, нам нужна картинка, на которой сеткой расположены карты с ограничением в 10 карт по ширине и в 7 карт по высоте. Еще есть ограничения по минимальной ширине и высоте в 2 карты.

Так выглядит сетка максимального размера.
Так выглядит сетка максимального размера.

В итоге, за один раз мы можем добавить максимум 69 карт, а если у нас их больше, то эту операцию придется повторить несколько раз. Правый нижний слот на сетке используется для отображения рубашки этой колоды, когда карта находится в чужих руках и мы не должны ее видеть.

Окно импорта колоды
Окно импорта колоды

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

В этом окне есть другие настройки, но в данный момент я их опущу, т.к. я не буду затрагивать их в дальнейшем.

Как я и говорил, при импорте мы можем указать лишь картинки. Это означает, что у нас нет возможности указать какие-либо текстовые данные для карт при формировании колоды сторонним приложением. А нас интересует наименование карты, описание и переменные lua, которые можно задать для каждого объекта в TTS, чтобы писать скрипты для стола и автоматизировать некоторые действия в игре, например, когда мы помещаем монстра в активный слот, счетчик жизней в слоте автоматически выставлялся в то значение, каким запасом обладает именно этот монстр.

DeckBuilder. Мое представление удобного инструмента

Техническое описание проекта

Т.к. я хотел добиться максимальной кроссплатформенности и иметь на выходе один бинарный файл, бэкэнд было принято писать на golang. А чтобы универсально отображать графический интерфейс, было принято делать его обычным web'ом, использовать можно было любой фреймворк, но из-за опыта разработки был выбран Vue 3.

На go поднимается http сервер, где на каждый API зарегистрировано действие над данными. Найти API можно по адресу http://localhost:5000/docs. Там отображается swagger со всеми API. Web интерфейс собирается в dist директорию, а затем эта директория эмбеддится в бинарный файл и статически обслуживается тем же самым http сервером. В итоге на выходе мы имеем один единственный бинарный файл.

Swagger проекта.
Swagger проекта.

Хранить файлы я хотел в максимально удобном для чтения формате, чтобы всегда можно было зайти самому и посмотреть все что там есть, поэтому я все храню в папках и файлах. Каждая сущность - это папка, внутри которой есть файл .info.json, хранящий информацию об объекте, такую как: id, name, description, createdAt, updatedAt. Внутри сущности может быть другая сущность, которая так же является папкой, а внутри могут быть файлы и другие папки и т.д.

Какие логически сущности используются в DeckBuilder

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

Главная верхняя сущность - это игра (Game).

Игра содержит коллекции (Collection), в которые попадают такие сущности как базовая игра, дополнение 1, дополнение 2 и т.д.

Каждая коллекция содержит список колод (Deck). Тут, соответственно, лежат колоды, например, монстры, награды и т.д.

А уже внутри колоды лежат сами карты (Card). Если у прошлых сущностей из основных атрибутов было только название и описание, то у карт есть еще их количество и переменные с указанием значений, например, уровень здоровья монстра или же его сила атаки.

Как это выглядит и как это работает внутри

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

go
func openBrowser(url string) {
	var cmd string
	var args []string

	switch runtime.GOOS {
	case "windows":
		cmd = "cmd"
		args = []string{"/c", "start"}
	case "darwin":
		cmd = "open"
	default: // "linux", "freebsd", "openbsd", "netbsd"
		cmd = "xdg-open"
	}
	args = append(args, url)
	err := exec.Command(cmd, args...).Start()
	if err != nil {
		logger.Error.Fatal("Can't run browser")
	}
}

Сам интерфейс выглядит минималистично.

Главное меню приложения
Главное меню приложения

Слева вверху находится наименование приложения, справа - группа кнопок управления. Их назначения слева направо: создание игры, импорт игры, сортировка объектов на странице. Посмотрим процесс создания на примере игры Four Souls т.к. у них есть официальный сайт со списком всех карт и, надеюсь, у них не возникнет вопросов к статье :D

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

Создадим игру, нажав на кнопку в правом верхнем углу.

Модальное окно создания игры
Модальное окно создания игры

В первом поле мы указываем название игры, во втором - ссылку на картинку (в будущем будет реализована поддержка загрузка файла картинки с диска), в третьем - описание для игры. Предпросмотр реализован следующим образом: в элемент img подставляется ссылка из второго поля. Однако, после создания игры, на бэке происходит скачивание изображения в файл .img.bin и после этого для отрисовки не требуется обращения в интернет. Все загруженные изображения можно получать от веб сервера на localhost:5000. Расширение bin для картинок было выбрано исключительно для удобства обращения к нему из кода. На данный момент поддерживаются форматы png, gif, jpeg.

Для каждого объекта из имени генерируется идентификатор. Вся строка преобразуется к нижнему регистру, все пробелы заменяются на нижнее подчеркивание, из строки удаляются все символы, кроме латиницы, кириллицы, цифр и нижних подчеркиваний. Максимальная длина идентификатора 200 символов, если строка длиннее, то она обрезается. Такое решение было принято, чтобы ни одна файловая система не ругалась на возможные странные названия. Файловая система обеспечивает их уникальность. Да, в теории два разных названия могут считаться одинаковыми, приложение выдаст ошибку и попросит придумать новое название.

Список игр
Список игр

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

Тултип с описанием игры
Тултип с описанием игры

Для того чтобы приложение не ощущалось как обычная веб страница, было заменено контекстное меню браузера при нажатии на правую клавишу мыши, на контекстное меню приложения.

Контекстное меню DeckBuilder
Контекстное меню DeckBuilder

Из меню над игрой мы можем совершить несколько действий:

  • Изменить игру

  • Экспортировать в zip файл, чтобы можно было удобно поделиться наработками с другим человеком

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

  • Запустить сборку (рассмотрим позже)

  • Удалить игру

При нажатии левой клавиши мы переходим в список коллекций выбранной игры.

Список коллекций созданной игры.
Список коллекций созданной игры.

Пока список коллекций пустой. Но мы можем заметить что breadcrumb в левом верхнем углу получил новый элемент "Four Souls v2". Нажимая на них, мы можем перемещаться на уровни выше. В правом верхнем углу у нас недоступна кнопка импорта, т.к. эта опция доступна только для игр.

Окно создания выглядит в точности так же, как и окно создания игры, поэтому создадим сразу несколько коллекций.

Список созданных коллекций
Список созданных коллекций

После их создания мы видим следующей картинку. Теперь мы можем проверить функционал сортировки карт по имени и дате создания в возрастающем или убывающем порядке.

Опции сортировки коллекций.
Опции сортировки коллекций.

Контекстное меню у коллекций содержит два пункта Change и Delete (позже планируется добавить Duplicate для коллекций и колод). А у колод и карт контекстное меню идентично коллекциям.

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

Список созданных колод
Список созданных колод

Теперь зайдем в колоду монстров и создадим там несколько карт.

Интерфейс создания карт
Интерфейс создания карт

Интерфейс создания карт немного отличается от предыдущих. У нас появилось несколько полей. Первое поле - количество карт в колоде. Если посмотреть обучающие видео, то люди часто копируют одно и то же изображение по сетке, чтобы получить копии карт, что негативно сказывается на конечном размере изображения, которое приходится скачивать всем, кто собирается играть в вашу игру. В данном случае копия карты создается лишь в json файле описания объекта, а на сетке будет одна картинка.

Так же у нас есть возможность динамически создавать произвольное количество переменных lua, в коде будут выглядеть как HP=3, которые потом можно будет получить в самой игре. При помощи кнопки "+" можно добавить еще одно поле, а при помощи "-" удалить выбранное поле.

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

Карта добычи
Карта добычи

Как видно на изображении выше, если карта не в единичном экземпляре, то ее количество отображается рядом с названием.

Теперь мы можем вернуться в главное меню, экспортировать игру и попробовать импортировать ее.

Экспортирование игры в zip файл
Экспортирование игры в zip файл

После этого мы можем открыть окно импорта игры из файла.

Окно импорта игры из файла
Окно импорта игры из файла

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

Как это все загрузить в TTS

Теперь мы можем вернуться к пункту Render, который мы пропустили при описании пунктов меню у игры. Визуально процесс выглядит так: все блокируется и мы видим кольцо прогресса в центре экрана.

Процесс постройки файлов для TTS
Процесс постройки файлов для TTS

При завершении кольцо просто пропадает и мы снова можем пользоваться приложением. Разберем действия происходящие в момент сборки файлов.

Проходим по всем картам, которые есть в наших колодах. Если колоды лежат в разных коллекциях, но они идентичные (одинаковое название, одинаковая ссылка на картинку), то мы объединяем эти карты в один список. После того как мы получили разные колоды, проверяем вмещается ли каждая колода на одну сетку. Если карт меньше, то мы ищем оптимальный размер, чтобы влезли все карты и осталось минимальное количество свободного места. Если карты одной колоды не влезли на одну сетку, то разбиваем на несколько.

После того как у нас есть изображения сеток для каждой колоды, нам нужно в понятном для TTS формате описать json файл. Базовый json выглядит следующим образом.

{
	"ObjectStates": [
	]
}

Внутрь мы добавляем объекты. Чтобы удобно было управлять объектами, все колоды я складываю в игровой объект TTS - мешок. Его описание выглядит следующим образом.

{
	"Name": "Bag",
	"Transform": {
		"posX": 0,
		"posY": 0,
		"posZ": 0,
		"scaleX": 1,
		"scaleY": 1,
		"scaleZ": 1
	},
	"Nickname": "",
	"Description": "",
	"ContainedObjects": [
	]
}
  • Name - Определяет тип объекта. Так же это может быть Deck, Card

  • Transform - Описание трансформации объекта. Его поворот и масштабирование. Обязательное поле, иначе игра не сможет отобразить объект

  • Nickname - Имя объекта в игре

  • Description - Описание объекта в игре

  • ContainedObjects - Список объектов, находящихся внутри мешка

Перед колодой разберем объект карты, т.к. колода включает в себя их список.

{
	"Name": "Card",
	"Nickname": "Isaac",
	"Description": "",
	"CardID": 100,
	"LuaScript": "AT=1\nHP=2",
	"Transform": {
		"posX": 0,
		"posY": 0,
		"posZ": 0,
		"scaleX": 1,
		"scaleY": 1,
		"scaleZ": 1
	},
	"CustomDeck": {
		"1": {
			"FaceURL": "grid_image.png",
			"BackURL": "backside_image.png",
			"NumWidth": 2,
			"NumHeight": 2,
			"BackIsHidden": false,
			"UniqueBack": false,
			"Type": 0
		}
	}
}
  • Name - Тип объекта

  • Nickname - Имя объекта в игре

  • Description - Описание объекта в игре

  • CardID - Номер карты на сетке. Первая цифра берется из номера CustomDeck, а вторая и третья - это индекс карты на сетке, начиная с нуля

  • LuaScript - Хранится скрипт объекта, но нам кроме переменных пока ничего не требуется. Каждая должна быть на новой строке

  • Transform - Описание трансформации объекта. Обязательное поле, иначе игра не сможет отобразить объект

  • CustomDeck - В данной секции описывается колода, в которой находится карта. Вообще, если эта карта находится в колоде, то можно не указывать эту информацию. Но если карта одна, то она должна быть вне колоды, т.к. колода должна состоять как минимум из 2 карт. В этом случае и потребуется эта информация. Но для простоты заполним ее для всех карт.

Теперь посмотрим на описание колоды.

{
	"Name": "Deck",
	"Transform": {
		"posX": 0,
		"posY": 0,
		"posZ": 0,
		"scaleX": 1,
		"scaleY": 1,
		"scaleZ": 1
	},
	"Nickname": "Character",
	"Description": "",
	"DeckIDs": [
		100,
		101
	],
	"CustomDeck": {
		"1": {
			"FaceURL": "grid_image.png",
			"BackURL": "backside_image.png",
			"NumWidth": 2,
			"NumHeight": 2,
			"BackIsHidden": false,
			"UniqueBack": false,
			"Type": 0
		}
	},
	"ContainedObjects": [
		{
			"Name": "Card",
			"CardID": 100,
			...
		},
		{
			"Name": "Card",
			"CardID": 101,
			...
		}
	]
}
  • DeckIDs - Хранится список идентификаторов карт, которые находятся в колоде

  • CustomDeck - Хранится список колод в объекте колода. Одна колода соответствует одному изображению сетки. Если одной сетки было недостаточно, то добавляем в этот список "2", "3" и т.д.

  • CustomDeck.(FaceURL, BackURL) - Тут может быть как путь до файла на локальном диске, так и URL до расположения в интернете. В случае если мы просто проверяем, то можно хранить файлы на диске для быстроты, чтобы не нужно было ждать загрузки картинок в интернет. Но если мы хотим сохранить карты на столе, чтобы другие могли играть, то мы обязательно должны хранить их в интернете

  • CustomDeck.(NumWidth, NumHeight) - Количество карт на сетке по ширине и по высоте

  • ContainedObjects - Список карт, которые лежат в колоде

Если все это сложить в мешок, а файлы, допустим, лежат по пути "/home/user/images" в UNIX системе, то выглядит это следующим образом.

Пример json
{
  "ObjectStates": [
    {
      "Name": "Bag",
      "Transform": {
        "posX": 0,
        "posY": 0,
        "posZ": 0,
        "scaleX": 1,
        "scaleY": 1,
        "scaleZ": 1
      },
      "Nickname": "",
      "Description": "",
      "ContainedObjects": [
        {
          "Name": "Deck",
          "Transform": {
            "posX": 0,
            "posY": 0,
            "posZ": 0,
            "scaleX": 1,
            "scaleY": 1,
            "scaleZ": 1
          },
          "Nickname": "Character",
          "Description": "",
          "DeckIDs": [
            100,
            101
          ],
          "CustomDeck": {
            "1": {
              "FaceURL": "file:////home/user/images/grid_image.png",
              "BackURL": "file:////home/user/images/backside_image.png",
              "NumWidth": 2,
              "NumHeight": 2,
              "BackIsHidden": false,
              "UniqueBack": false,
              "Type": 0
            }
          },
          "ContainedObjects": [
            {
              "Name": "Card",
              "Nickname": "Isaac",
              "Description": "",
              "CardID": 100,
              "LuaScript": "AT=1
HP=2",
              "Transform": {
                "posX": 0,
                "posY": 0,
                "posZ": 0,
                "scaleX": 1,
                "scaleY": 1,
                "scaleZ": 1
              },
              "CustomDeck": {
                "1": {
                  "FaceURL": "file:////home/user/images/grid_image.png",
                  "BackURL": "file:////home/user/images/backside_image.png",
                  "NumWidth": 2,
                  "NumHeight": 2,
                  "BackIsHidden": false,
                  "UniqueBack": false,
                  "Type": 0
                }
              }
            },
            {
              "Name": "Card",
              "Nickname": "Maggy",
              "Description": "",
              "CardID": 101,
              "LuaScript": "",
              "Transform": {
                "posX": 0,
                "posY": 0,
                "posZ": 0,
                "scaleX": 1,
                "scaleY": 1,
                "scaleZ": 1
              },
              "CustomDeck": {
                "1": {
                  "FaceURL": "file:////home/user/images/grid_image.png",
                  "BackURL": "file:////home/user/images/backside_image.png",
                  "NumWidth": 2,
                  "NumHeight": 2,
                  "BackIsHidden": false,
                  "UniqueBack": false,
                  "Type": 0
                }
              }
            }
          ]
        }
      ]
    }
  ]
}

Все данные приложения хранятся в папке DeckBuilderData. В Windows и Linux она создается рядом с бинарным файлом, а в MacOS, из-за ограничений, эту папку находится в домашней директории. После завершения генерации в папке приложения можно найти папку result, внутри которой и лежат картинки и json файл результата.

Вообще TTS открывает TCP сокет и слушает команды снаружи, можно было бы сразу отправлять ей этот json, чтобы объект появлялся на столе, но в бете данная функция еще не реализована. Поэтому чтобы проверить, что все это работает придется ручками скопировать получившийся json файл в папку сохранений игры. Для начала нужно найти где игра хранит свои сохранения.

  • Windows - %USERPROFILE%\Documents\My Games\Tabletop Simulator

  • Linux - .local/share/Tabletop Simulator

  • MacOS - ~/Library/Tabletop Simulator

Внутри папки сохранений переходим по пути "Saves/Saved Objects" и кладем наш json файл в указанную директорию. После этого запускаем игру, создаем игру и мы должны видеть пустой стол.

Изображение только что созданного стола в TTS
Изображение только что созданного стола в TTS

Нажимаем сверху на кнопку Ojbects.

Меню Ojbects в TTS
Меню Ojbects в TTS

Мы должны увидеть следующее меню. Заходим в Saved Objects.

Меню Saved Objects в TTS
Меню Saved Objects в TTS

И тут мы должны увидеть наш json файл. В данном случае он называется decks. Наводим мышкой на decks, нажимаем на точки у этого файла в правом верхнем углу и выбираем Spawn, либо же просто перетаскиваем объект на стол.

На столе должен появиться мешочек, в котором лежат все карты, которые мы описали в графическом интерфейсе.

Колода из 6 карт
Колода из 6 карт
Карточка, с названием и описанием
Карточка, с названием и описанием
Прописанные lua переменные
Прописанные lua переменные

Заключение

Была проделана большая работа, но еще много чего нужно реализовать, чтобы я мог назвать это финальным релизом. Из глобального осталось только несколько функций. При сборке колоды иметь выбор: хранить ли файлы локально или же загрузить их в интернет хранилище. Автоматически отправлять json файл в TTS, чтобы вручную не перемещать json файл в список сохраненных объектов.

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

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


Ссылки:

https://github.com/HardDie/DeckBuilder - Backend (golang)

https://github.com/lmm1ng/DeckBuilderGUI - Frontend (vue3)

https://foursouls.com/ - Сайт с картами игры Four Souls

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии0

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань