Деконструкция математики движка провода из моей игры DETOUR

В прошлый раз я рассказывал, как мой экшен-паззл DETOUR взял 8-ое место по "Инновации" на прошедшем Ludum Dare. В этот раз я расскажу про то, как я реализовал поведение провода в игре (а потом написал с нуля нормальный математический движок). Начну с понятного разбора на пальцах без единой формулы, закончу текущей реализацией и её кодом. Поехали!

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

Техническое задание

Определимся с ТЗ. Чего мы хотим по итогу от провода (который я буду иногда непроизвольно называть верёвкой)?

  • Хотим рисовать линию от зарядки до игрока — это тот самый провод, который питает робота-игрока
  • Когда провод касается препятствия ("колонна"), хотим, чтобы он огибал препятствие, превращаясь в ломаную линию с изломом в точке касания
  • Если начинаем идти обратно, в какой-то момент провод должен "отпустить угол", убирая излом
  • Повторять для каждого угла, который мы встречаем на своём пути
Что-то похожее было в Worms, только нам не нужна гравитация и законы сохранения энергии/момента импульса у персонажа

Решение 1.0

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

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

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

1) Для визуальной составляющей я использовал Line2D, который просто по порядку соединяет линией заданные точки.

2) Эти точки хранятся в коде в массиве. Первая точка всегда неподвижная зарядная станция, последняя — всегда робот-игрок. Игрок может двигаться, мы тогда обновляем координаты последней точки.

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

В целом, коллайдеры — это единственное "не-математическое", что я использовал во время написания игры. По итогу сэкономил много времени, но и работало далеко не стабильно, что и послужило потом поводом переписать с нуля всё :) Но об этом потом.
В целом, коллайдеры — это единственное "не-математическое", что я использовал во время написания игры. По итогу сэкономил много времени, но и работало далеко не стабильно, что и послужило потом поводом переписать с нуля всё :) Но об этом потом.

4) У объектов-колонн добавляем по небольшому квадратному коллайдеру в каждом из углов. Когда такой коллайдер детектирует столкновение с каким-то коллайдером провода из предыдущего пункта, то он посылает сигнал со своими координатами в код, где мы работаем с массивом точками провода. Код просто добавляет по этим координатам новую точку в массив на предпоследнее место, перед роботом — фактически деля последний отрезок провода на две части.

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

Гифка, записанная на старте разработки и отражающая первые пять пунктов реализации логики

Уже выглядит неплохо, но самое сложное впереди: нам нужно научиться отлеплять провод от углов. Тут и пригодится математика!

6) Как проверить, нужно ли всё ещё касаться угла? В целом, весь секрет — сравнивать нужные углы! Допустим, последний отрезок в проводе составляет угол Y с горизонтом, а предпоследний — угол Y:

Последний отрезок — между игроком и предпоследней точкой, а предпоследний отрезок — между предпоследней и пред-предпоследней
Последний отрезок — между игроком и предпоследней точкой, а предпоследний отрезок — между предпоследней и пред-предпоследней

До тех пор пока Y>X, верёвка натянута вокруг угла. Если игрок начнёт идти влево-вниз, в какой-то момент Y станет меньше чем X (на рисунке это пространство слева от пунктирной линии), и тогда надо отлипнуть от угла, что в коде равнозначно удалению предпоследней точки.

7) Вычисления выше верны, если мы заходим за угол слева; справа всё почти то же самое, кроме того, что надо ждать, пока Y станет больше чем X, а не меньше. Как нам проверять "сторону"? Опять сравнивая углы! В момент столкновения верёвки и колонны сравниваем два угла:

Сравниваем углы (с горизонтом) у последней точки + угла и угла + предпоследней точки. Хотя на самом деле тут не так важно, какие пары точек составить из этих трёх точек.
Сравниваем углы (с горизонтом) у последней точки + угла и угла + предпоследней точки. Хотя на самом деле тут не так важно, какие пары точек составить из этих трёх точек.

8) В зависимости от знака больше/меньше получаем сторону, сохраняем её в коде в соседнем с точками массиве, и используем в момент "отлипания верёвки", чтобы выбрать знак больше/меньше в пункте 6.

_______________

Вот и всё! Как это выглядит?

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

Поиграть в итоговую конкурсную версию игры можно тут:

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

  • Так как провод очень непослушно отклеивался от углов, пришлось много играться с несовпадением коллайдера в углу колонны и реальной "поворотной точки", а также пришлось добавить "подгоночный отрицательный угол", чтобы провод не сразу отклеивался
  • Я дурак и вместо векторного/скалярного произведения векторов руками сравнивал углы, из-за чего пришлось изобретать велосипед на стыке +- 180 градусов
  • Никак не поддерживается движение других точек, кроме последней, то есть подвижные колонны были неподвластны
  • Если за один фрейм провод "касался" сразу нескольких углов, то начинался сущий кошмар из-за невозможности определить, в каком порядке надо соединять точки (и какие вообще надо касаться). Из-за этого пришлось запороть уровень с лабиринтом, по которому бегали бы враги:
На самом деле, этот баг остался и в паре уровней в релизной джем-версии игры, но их надо поискать
На самом деле, этот баг остался и в паре уровней в релизной джем-версии игры, но их надо поискать

Решение 2.0

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

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

  • Все углы колонн теперь — точечные объекты, а не коллайдеры. Если эти углы двигаются, то с ними двигаются и все точки верёвки с такими же координатами
  • В коде храним двумерный массив — связь каждого угла с каждым отрезком верёвки. В этой связи лежат 4 параметра: находимся ли мы "внутри" отрезка (не конкретно на отрезке, но ещё и в пространстве перпендикулярно в обе стороны от него), находимся ли мы слева/справа от отрезка, совпадает ли угол с началом отрезка, и совпадает ли угол с концом отрезка. Каждый фрейм мы пересчитываем эти связи, и в зависимости от результатов решаем, где нужно добавить новые точки, а где убрать
  • Если за один кадр отрезок касается нескольких углов одновременно, то добавляем точки в порядке их удалённости от начала отрезка
  • При отлипании точек происходит чёрная магия, которую я варил три дня и постарел на пару лет точно:
Но если на пальцах, то иногда при отлипании от угла надо заново пересчитать связь угла с отрезком, а иногда надо заменять часть связей на связи с соседними отрезками... Непонятно? Мне тоже, но оно работает
Но если на пальцах, то иногда при отлипании от угла надо заново пересчитать связь угла с отрезком, а иногда надо заменять часть связей на связи с соседними отрезками... Непонятно? Мне тоже, но оно работает

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

Если происходящее кажется вам магией — поверьте, это и правда магия, что оно работает.

Я не очень знаю, для кого эта статья будет полезной, и зачем её вообще писал. Возможно, показать что сложные концепты можно всегда разбить на простые? Или может просто дать "behind the scenes" для людей, далёких от разработки игр, но которым очень интересно?

Или выпендриться, какой классный движок я по итогу написал?..

Деконструкция математики движка провода из моей игры DETOUR

Поиграйте в эту игру, что ли: https://nozomu57.itch.io/detour

До следующих встреч, пишите комменты, подписывайтесь, надеюсь ещё увидимся.

113113
17 комментариев

Я не очень знаю, для кого эта статья будет полезной, и зачем её вообще писал. Возможно, показать что сложные концепты можно всегда разбить на простые? Или может просто дать "behind the scenes" для людей, далёких от разработки игр, но которым очень интересно?Для таких как я скучающих веб-кодеров желающих в настоящее программирование (игры и весь этот сложный матан). Очень интересно! Всё думаю как бы перекатиться в эту сферу, пугает и одновременно завораживает огромный пласт новых концепций и техник кодинга.

9
Ответить

Комментарий недоступен

5
Ответить

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

4
Ответить

Не похоже на физику. Это просто прямые линии, которые соединены под разным углом. В Cut the Rope больше на физику похоже.

2
Ответить

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

Но поменяю название, да

3
Ответить

Я тебя в твиттере видел, у тебя недавно день рождения был!

2
Ответить

Очень любопытно, мне понравилась твоя статья и самокритичность)), что ж, однозначно ты не дурак, как говоришь.. но ещё больше понравилось, что в режиме стресса и ограниченного времени ты смекнул, нашёл выход и выдал таки результат! Пусть не "стройный", не отполишенный, но это реально МАГИЯ! Молодец!)

2
Ответить