Создание Autotable

08.09.2021

Autotable - это настольный онлайн-симулятор Riichi Mahjong. Это проект, над которым мне очень понравилось работать, и, несмотря на простую основную идею, я приложил много усилий для улучшений и настроек. Надеюсь, из этого получится интересный рассказ.

Зачем это делать?

Я не очень хорошо играю в маджонг.

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

Посмотрите это, чтобы поднять настроение.

Вот почему мне не нравятся компьютерные игры маджонг или платформы вроде Tenhou. Они делают игру совсем не похожей на "настоящую". Игра быстрая и оптимизированная. Они делают паузу, чтобы спросить меня о звонках (пон / чи), или предлагают 3 разных способа объявить риичи, прежде чем я пойму, что могу это сделать. Компьютер знает мою руку лучше, чем я, и то, что мне действительно понравилось в игре, уходит автоматически.

Поэтому, когда мы все сидели дома, дистанцировавшись от общества, и у меня возникла тяга к маджонгу, я исследовал настольные онлайн-симуляторы. Есть Tabletopia и Tabletop Simulator. Вы можете играть в маджонг в обоих режимах. Tabletop Simulator особенно известен своим сообществом моддеров, и есть несколько настроек для маджонга, созданных игроками.

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

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

Однако я не тратил много времени на изучение Tabletop Simulator. Могут быть моды получше.

Так я пришел к своей идее. Что, если бы существовал симулятор, специально предназначенный для игры в маджонг? Игра, которая позволила вам делать все, что требуется в игре маджонг, но делать это самостоятельно.

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

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

Или, другими словами: это должна быть игра, которая допускает, по крайней мере, некоторые реальные манеры маджонга, "анти-манеры" и идиосинкразии (например, слишком рано вытаскивать плитку или делать что-то определенным образом). порядок).

А как насчет более яркого жульничества, например, сложить стену и поменять ее рукой? Возможно когда-нибудь! :)

Появление

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

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

Однако я был обескуражен, когда понял, что если я вручную указываю, как рисовать плитки, мне придется делать это для каждого возможного поворота: стоя, лежа лицом вверх, лежа лицом вниз, боком…

Поэтому я отказался от идеи 2D-графики и использовал 3D-движок под названием three.js. Я решил, что все еще могу использовать его для создания простых «2D-визуальных» визуальных эффектов, но, по крайней мере, вся математика, связанная с позициями объектов в геометрии, будет учтена.

Как оказалось, я все еще использую для игры простую орфографическую проекцию. Я думаю, это выглядит красиво и чисто. Также есть «перспективный режим», который некоторые игроки предпочитают для погружения. Я думаю, что это сложнее в использовании, но, эй, это функция, которую я получил в основном бесплатно.

Орфографический вид имеет одну проблему. Трудно добраться до самой нижней плитки в стене:

Чтобы сделать его более удобным, мне пришлось сохранить угол обзора в 45 градусов (при более крутом угле нижняя плитка полностью скрыта) и закрасить его темнее. Это все еще могло бы работать лучше - я планирую увеличить «хитбокс», чтобы эту плитку было легче захватить, даже когда курсор мыши не касается ее.

Текстуры и модели

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

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

Наконец, я пригубил пулю и использовал Blender для редактирования модели. Раньше я не слишком много использовал Blender, и у меня сложилось впечатление, что это очень сложная и трудная в использовании программа. Но на самом деле для моего варианта использования я смог относительно быстро изучить все, что мне нужно.

Для закругленных углов используется модификатор Bevel. Это как граница-радиус 3D!

Еще одна приятная функция - это возможность редактировать UV-карты, которые управляют тем, как текстура обтекает модель. Раньше мне приходилось вручную указывать это с помощью чисел в моем коде.

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

Я также смог построить довольно хороший конвейер данных для построения моделей. С помощью одной команды make я могу заставить Blender экспортировать файл glTF, содержащий все модели и текстуры, который затем three.js может импортировать.

Геймплей

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

Как видите, плитки можно вращать в разных направлениях, в зависимости от слота. У каждого слота есть список разрешенных вращений: в руке плитка может быть вертикальной или открытой (открытой); в стене - лицевой стороной вниз, лицевой стороной вверх и т. д.

Угловые шкафы

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

«Правее», конечно, зависит от позиции игрока. А иногда толчок на самом деле идет справа налево:

Я делаю это следующим образом: между слотами существует «толкающая» связь: слот A толкает слот B. Когда плитка поворачивается, я просматриваю взаимосвязи и проверяю, толкают ли какие-либо плитки в этих слотах друг друга.

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

Другой, более интересный угловой случай - это рисование плитки в руке. Раньше это работало так:

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

Лучшее решение - сразу же повернуть плитку, когда вы наводите ее на руку, например:

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

Это автоповорот кажется настолько естественным, что, когда я его добавил, игроки привыкли к нему, даже не заметив, что что-то изменилось!

Другой угловой случай - это «секретные» слоты, которые обычно неактивны. Например, когда у вас заканчивается место для сброса, вы должны продолжить с последней строки:

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

Платит

В Riichi Mahjong вы платите другим игрокам клюшками. Вы также используете клюшку на 1000 пунктов в качестве депозита при объявлении риичи. Это было важно для ощущения игры, поэтому я хотел сохранить это.

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

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

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

Положение мыши

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

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

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

Я всегда показываю перетаскиваемый объект поверх всего остального. В противном случае он прошел бы через другие объекты!

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

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

Подводя итог, вот правила определения положения вашей мыши в 3D:

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

Это почти вся история. Также есть прямоугольник для выбора множества объектов.

Движение мыши

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

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

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

Затем я попытался плавно интерполировать положение. Улучшение было резким. Это действительно выглядело так, как будто другой человек двигал мышью!

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

Как я упоминал ранее, сообщения могут приходить с неравными интервалами. Нет гарантии, что путевые точки будут приходить ровно каждые 100 миллисекунд, и если вы просто визуализируете их по мере их прибытия, движение также будет неравномерным. Из-за этого сообщения имеют отметку времени. Конечно, я не могу использовать временную метку с удаленного компьютера напрямую - ваша временная метка не совпадает с моей, но этого достаточно, чтобы вычислить интервал между удаленными временными метками (обычно 100 мс) и, возможно, применить некоторые ограничения, если путевая точка прибывает подозрительно. поздно или рано.

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

Мои опасения оказались по большей части беспочвенными. Это не такая динамичная игра, как FPS, и задержки никто не замечает. В добавлении задержек в других частях кода не было необходимости: игрок роняет плитку немного раньше, чем подсказывает движение мыши, но падение плитки обычно не происходит «на полной скорости» - игрок сначала замедляет мышь, и проверяет, находится ли он в нужном месте. Есть, так сказать, "время посадки".

Сортировка плитки

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

К сожалению, это взаимодействие, без которого я не мог обойтись. Если бы игра автоматически отсортировывала руки, это было бы действительно удобно для игрока, но это уже не та игра, которую я намеревался делать.

Это действительно заняло у меня много времени. Я провел несколько сеансов по часу ночи, пытаясь понять это правильно, каждый раз только для того, чтобы понять, что на полпути я сделал ужасный беспорядок в своем коде, и я слишком сонный, чтобы писать хороший код, и выбросить все, что я написал с помощью git reset - -жесткий . Наконец, с третьей или четвертой попытки я получил что-то, что работало и не наносило слишком большого ущерба окружающему коду.

Окончательный алгоритм работает примерно так:

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

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

Первоначально «сдвинутые плитки» перемещались только локально, пока вы не отбрасывали удерживаемые плитки в место назначения. Позже я решил также отправить их по сети, чтобы все видели, как вы сортируете плитки. И вот где обнаружилась задержка в 100 миллисекунд! Поскольку движение мыши задерживается по сравнению со всем остальным, кажется, что плитки движутся впереди времени :

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

Оптимизация

Мой стол для маджонга не представляет собой очень сложную трехмерную сцену, но если вы посчитаете объекты - 136 плиток, 60 палочек, несколько разных простых сеток - все складывается. На мощной машине это не проблема, но когда я впервые попробовал игру на своем ноутбуке, частота кадров была довольно низкой. В режиме энергосбережения (от батареи) игра еле достигла 20 FPS. Я хотел, чтобы по возможности получился маслянисто-гладкий 60!

Оказывается, часто советуют «объединить геометрию». Статические сетки, как и декорации, часто можно объединить в один более крупный объект и отправить в графический процессор за один вызов отрисовки.

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

Вот демонстрация создания экземпляров на three.js.

Однако было два осложнения:

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

Есть много визуальных эффектов. Плитка может быть…

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

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

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

Инстансы тайлов были огромным улучшением. Объедините это с некоторыми местами, где я избегаю пересчета слишком многих вещей (поскольку, ну, плитки редко меняют положение), и теперь я достигаю 60 FPS на большинстве компьютеров, на которых я тестирую игру. Когда я снимаю ограничение, Google Chrome на моем настольном компьютере может рассчитывать почти 1500 кадров в секунду!

Мой ноутбук Qubes OS показывает 20 кадров в секунду. Я считаю это успехом, поскольку я запускаю игру внутри виртуальной машины без доступа к графическому процессору.

Конечно, мне было бы намного легче работать с производительностью, если бы я использовал не 3D-движок, а 2D-спрайты. Также может помочь использование чего-то другого, кроме JavaScript / TypeScript и браузера. Тем не менее, я очень доволен своим выбором технологий - сложно сделать более портативную игру, чем браузерная игра HTML5, а графический движок довольно гибкий.

Синхронизация базы данных

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

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

Позже, по мере роста, этих двух «типов объектов» оказалось недостаточно. Было больше данных - состояние матча (кто является дилером), какие плитки мы используем, положение мыши и так далее.

Вместо того, чтобы жестко закодировать протокол, я решил сделать сервер простой базой данных. Он превратился в простое хранилище ключей и значений, позволяющее изменять такие объекты, как «ник 2» или «вещь 151». Объекты разделены на разные коллекции (здесь: ники, вещи и так далее). В клиентском коде я могу ссылаться на эти коллекции.

Например, в клиентском коде я мог бы написать что-то вроде:

Что отправляет следующие данные через веб-сокет:

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

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

  • Ограничение скорости (для обновлений мыши). Хорошо, это только на стороне клиента.
  • Слабые ссылки на игроков. Когда игрок отключается, я хочу, чтобы сервер очистил некоторые данные (например, его ник или положение мыши).
  • Уникальные атрибуты. Это сделано для предотвращения конфликтов: в редких случаях два игрока могут попытаться положить разные плитки в один и тот же слот. Это нарушит игру, поэтому сервер отклонит обновление, которое нарушает это ограничение.
  • Эфемерные сообщения. Я использую сервер для передачи звуков (например, падения плитки на доску) другим игрокам. Я достигаю этого, как и все остальное, путем отправки объекта на сервер. Однако я не хочу, чтобы объект запомнился там, потому что тогда любой игрок, который подключится позже, услышит звук.

Настройка звучит странно, и я мог бы заново изобрести что-то уже существующее (например, Firebase, может быть?), Что мне больше подошло бы здесь. Ну что ж ... он делает то, что я хочу, и это местный оптимум.

Повторное подключение

Сначала я беспокоился о публикации игры и о том, чтобы позволить посетителям из Интернета играть в нее, потому что у меня не было процедуры для развертывания новой версии сервера. Придется перезапустить сервер, выбросив все игры, хранящиеся в памяти! Для Minefield Mahjong решение этой проблемы включало базу данных SQLite и отдельную подсистему «воспроизведения».

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

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

Кстати о читерстве, обнаружился интересный баг. Если вы выбрали несколько плиток, и кто-то нажал «Сделать», они перемешались со всеми остальными, но остались выбранными для вас:

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

Подводя итоги

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

Обновление: проект теперь с открытым исходным кодом: https://github.com/pwmarcz/autotable

Что меня действительно зацепило, так это то, что в этом проекте у меня были пользователи с самого начала . Как только в игре было достаточно возможностей, мы начали тестировать ее с друзьями. Затем я создал новые функции на основе их отзывов, и кое-чего, чего мы все заметили, не хватало.

Было здорово иметь такое «давление продукта». Я провел много рефакторинга и очистки, и я все еще делаю это, но всегда для того, чтобы создать больше функций. Часто я добавлял что-то на скорую руку, даже складывал несколько подобных изменений и позже реорганизовывал, потому что понял, что это становится необходимым.

Спасибо всем, кто играл и помогал мне развиваться! Мне очень, очень понравилось работать над этим проектом.

Сергей Иващенко

08.09.2021

Подписывайтесь на наши социальные сети!