Unlimited WordPress themes, graphics, videos & courses! Unlimited asset downloads! From $16.50/m
Advertisement
  1. Game Development
  2. Platformer

Основы физики 2D платформера, часть 2

by
Difficulty:IntermediateLength:LongLanguages:
This post is part of a series called Basic 2D Platformer Physics .
Basic 2D Platformer Physics, Part 1
Basic 2D Platformer Physics, Part 3

Russian (Pусский) translation by Alexey Malyutin (you can also view the original English article)

Геометрия уровня

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

У обоих подходов есть свои "за" и "против". Мы будем использовать сетку, поэтому давайте рассмотрим, какие приемущества она имеет перед другим методом:

  • Лучшая производительность - в большинстве случаев обнаружение столкновений при использовании сетки обходится дешевле, чем в случае свободно размещенных объектов.
  • Она делает гораздо проще поиск пути.
  • Тайлы более точны и предсказуемы, чем свободно размещенные объекты, особенно когда берутся в учет такие вещи, как разрушаемый рельеф.

Построение класса карты

Давайте начнем с создания класса Map. Он будет содержать все конкретные данные карты.

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

В демо тип тайла напрямую соотносится с типом столкновения с тайлом, которое мы хотим реализовать, но в реальной игре это не обязательно будет так. По мере того, как у вас будет появляться больше визуально различающихся тайлов, будет лучше добавить новые типы, такие как GrassBlock (блок травы), GrassOneWay (односторонняя трава) и т.д., чтобы позволить перечислению TileType определить не только тип столкновения, но и внешний вид тайла.

Теперь мы можем добавить в класс карты массив тайлов.

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

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

Ширина и высота, в тайлах.

И размер тайла: в демо мы будем работать с относительно маленьким размером тайла, равным 16 на 16 пикселей.

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

Как видите, эта функция берет Vector2 в качестве параметра и возвращает Vector2i, который является простым двумерным вектором, оперирующим целыми числами вместо чисел с плавающей запятой.

Конвертирование мировой позиции в позицию карты очень прямолинейно - нам просто нужно сдвинуть точку point в позицию mPosition, поэтому мы возвращаем тайл относительно позиции на карте и затем делим результат на размер тайла.

Обратите внимание, что мы должны сдвинуть точку point дополнительно на cTileSize / 2.0f, потому что точка опоры тайла находится в его центре. Давайте также добавим две дополнительные функции, которые будут возвращать только X и Y компоненту позиции в пространстве карты. Это нам понадобится позже.

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

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

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

Следующей в очереди будет функция проверки, является ли тайл препятствием.

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

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

И наконец, давайте добавим функции IsOneWayPlatform и IsEmpty в том же стиле.

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

Столкновение Персонаж - Карта

Давайте вернемся к классу MovingObject. Нам нужно создать ряд функций, которые будут определять, столкнулся ли персонаж с тайлом карты.

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


Желтый прямоугольник обозначает AABB персонажа, и мы будем проверять тайлы вдоль красных линий. Если любая из них пересечется с тайлом, мы установим в истину соответсвующую переменную столкновения (из таких переменных, как mOnGroundmPushesLeftWallmAtCeiling or mPushesRightWall).

Давайте начнем с создания функции HasGround, которая будет проверять, столкнулся ли персонаж с тайлом земли.

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

Первое, что мы хотим сделать, это рассчитать центр AABB.

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

Переменные bottomLeft и bottomRight представляют два конца сенсора. Теперь, когда они у нас есть, мы можем посчитать, какие тайлы нам надо проверить. Давайте начнем с создания цикла, в котором мы пройдем по  тайлам слева направо.

Заметьте, что здесь нет условия для выхода из цикла, мы сделаем это в конце цикла.

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

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

Сначала давайте рассчитаем позицию верха тайла.

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

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

Это самая базовая версия проверки. Давайте теперь попытаемся заставить ее работать. Вернемся в функцию UpdatePhysics, наша старая проверка земли выглядит вот так.

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

Если условие выполнено, нам нужно переместить персонажа на верх тайла, с которым мы столкнулись.

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

Если наша вертикальная скорость больше нуля или мы не касаемся никакой твердой поверхности, нам нужно установить mOnGround в false.

Теперь давайте посмотрим, как это работает.

Как видите, все работает неплохо! Обнаружение столкновения для стен с обоих сторон и сверху персонажа все еще отсутствует, но персонаж останавливается каждый раз, когда встречается с землей. Нам все еще нужно приложить еще немного усилий к функции проверки столкновений, чтобы сделать ее по настоящему сильной.

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

Эта ситуация не случится сейчас, потому что мы ограничили максимальную скорость падения разумным значением и обновляем физику с частотой 60 FPS, поэтому различия в позициях между кадрами достаточно малы. Давайте посмотрим, что произойдет, если мы будем обновлять физику только 30 раз в секунду.

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

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

Нам также нужно получить позицию сенсора в предыдущем кадре.

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

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

Наконец, нам нужна следующая итерация цикла. Теперь, перед тем, как мы заполним кодом наш внешний цикл, давайте представим следующий сценарий.

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

Давайте переименуем bottomLeft и bottomRight в newBottomLeft и newBottomRight, тогда мы будем знать, что это позиции сенсора в новом кадре.

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

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

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

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

Резюме

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

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

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

There are two basic approaches to building platformer levels. One of them is to use a grid and place the appropriate tiles in cells, and the other is a more free-form one, in which you can loosely place level geometry however and wherever you want. 

There are pros to cons to both approaches. We'll be using the grid, so let's see what kind of pros it has over the other method:

  • Better performance—collision detection against the grid is cheaper than against loosely placed objects in most cases.
  • Makes it much easier to handle pathfinding.
  • Tiles are more accurate and predictable than loosely placed objects, especially when considering things like destructible terrain.

Building a Map Class

Let's start by creating a Map class. It will hold all of the map specific data.

Now we need to define all the tiles that the map contains, but before we do that, we need to know what tile types exist in our game. For now, we're planning on only three: an empty tile, a solid tile, and a one-way platform.

In the demo, tile types correspond directly to the type of collision we'd like to have with a tile, but in a real game that's not necessarily so. As you have more visually different tiles, it would be better to add new types such as GrassBlock, GrassOneWay and so on, to let the TileType enum define not only the collision type but also the appearance of the tile.

Now in the map class we can add an array of tiles.

Of course, a tilemap that we can't see is not of much use to us, so we also need sprites to back up the tile data. Normally in Unity it is extremely inefficient to have each tile be a separate object, but since we're just using this to test our physics, it's OK to make it this way in the demo.

The map also needs a position in the world space, so that if we need to have more than just a single one, we can move them apart.

Width and height, in tiles.

And the tile size: in the demo we'll be working with a fairly small tile size, which is 16 by 16 pixels.

That would be it. Now we need a couple of helper functions to let us access the map's data easily. Let's start by making a function which will convert world coordinates to the map's tile coordinates.

As you can see, this function takes a Vector2 as a parameter and returns a Vector2i, which is basically a 2D vector operating on integers instead of floats.

Converting the world position to the map position is very straightforward—we simply need to shift the point by mPosition so we return the tile relative to the map's position and then divide the result by the tile size.

Note that we had to shift the point additionally by cTileSize / 2.0f, because the tile's pivot is in its center. Let's also make two additional functions which will return only the X and Y component of the position in the map space. It'll be useful later.

We also should create a complementary function which, given a tile, will return its position in the world space.

Aside from translating positions, we also need to have a couple of functions to see whether a tile at a certain position is empty, is a solid tile or is a one-way platform. Let's start with a very generic GetTile function, which will return a type of a specific tile.

As you can see, before we return the tile type, we check if the given position is out of bounds. If it is, then we want to treat it as a solid block, otherwise we return a true type.

The next in queue is a function to check whether a tile is an obstacle. 

In the same way as before, we check if the tile is out of bounds, and if it is then we return true, so any tile out of bounds is treated as an obstacle.

Now let's check whether the tile is a ground tile. We can stand on both a block and a one-way platform, so we need to return true if the tile is any of these two.

Finally, let's add IsOneWayPlatform and IsEmpty functions in the same manner.

That's all that we need our map class to do. Now we can move on and implement the character collision against it.

Character-Map Collision

Let's go back to the MovingObject class. We need to create a couple of functions which will detect whether the character is colliding with the tilemap.

The method by which we'll know whether the character collides with a tile or not is very simple. We'll be checking all the tiles that exist right outside the moving object's AABB.

The yellow box represents the character's AABB, and we'll be checking the tiles along the red lines. If any of those overlap with a tile, we set a corresponding collision variable to true (such as mOnGround, mPushesLeftWall, mAtCeiling or mPushesRightWall).

Let's start by creating a function HasGround, which will check if the character collides with a ground tile. 

This function returns true if Character overlaps with any of the bottom tiles. It takes the old position, the current position and the current speed as parameters, and also returns the Y position of the top of the tile we are colliding with and whether the collided tile is a one-way platform or not.

The first thing we want to do is to calculate the center of AABB.

Now that we've got that, for the bottom collision check we'll need to calculate the beginning and end of the bottom sensor line. The sensor line is just one pixel below the AABB's bottom contour.

The bottomLeft and bottomRight represent the two ends of the sensor. Now that we've got these, we can calculate which tiles we need to check. Let's start by creating a loop in which we'll be going through the tiles from the left to the right.

Note that there's no condition to exit the loop here—we'll do that at the end of the loop. 

The first thing we should do in the loop is to make sure that the checkedTile.x is not greater than the right end of the sensor. This might be the case because we move the checked point by multiples of the tile size, so for example, if the character is 1.5 tiles wide, we need to check the tile on the left edge of the sensor, then one tile to the right, and then 1.5 tiles to the right instead of 2.

Now we need to get the tile coordinate in the map space to be able to check the tile's type.

First, let's calculate the tile's top position.

Now, if the currently checked tile is an obstacle, we can easily return true.

Finally, let's check whether we already looked through all the tiles that intersect with the sensor. If that's the case, then we can safely exit the loop. After we exit the loop not finding a tile we collided with, we need to return false to let the caller know that there is no ground below the object.

That's the most basic version of the check. Let's try to get it to work now. Back in the UpdatePhysics function, our old ground check looks like this.

Let's replace it using the newly created method. If the character is falling down and we have found an obstacle on our way, then we need to move it out of the collision and also set the mOnGround to true. Let's start with the condition.

If the condition is fulfilled then we need to move the character on the top of the tile we collided with.

As you can see, it's very simple because the function returns the ground level to which we should align the object. After this, we only need to set the vertical speed to zero and set mOnGround to true.

If our vertical speed is greater than zero or we're not touching any ground, we need to set the mOnGround to false.

Now let's see how this works.

As you can see, it works well! The collision detection for the walls on both sides and at the top of the character are still not there, but the character stops each time it meets the ground. We still need to put a little more work into the collision-checking function to make it robust.

One of the issues we need to solve is visible if the character's offset from one frame to the other is too big to detect the collision properly. This is illustrated in the following picture.

This situation does not happen now because we locked the maximum falling speed to a reasonable value and update the physics with 60 FPS frequency, so the differences in positions between the frames are rather small. Let's see what happens if we update the physics only 30 times per second. 

As you can see, in this scenario our ground collision check fails us. To fix this, we can't simply check if the character has ground beneath him at the current position, but we rather need to see whether there were any obstacles along the way from the previous frame's position.

Let's go back to our HasGround function. Here, besides calculating the center, we'll also want to calculate the previous frame's center.

We'll also need to get the previous frame's sensor position.

Now we need to calculate at which tile vertically we are going to start checking whether there is a collision or not, and at which we will stop.

We start the search from the tile at the previous frame's sensor position, and end it at the current frame's sensor position. That's of course because when we check for a ground collision we assume we are falling down, and that means we're moving from the higher position to the lower one.

Finally, we need to have another iteration loop. Now, before we fill the code for this outer loop, let's consider the following scenario.

Here you can see an arrow moving fast. This example shows that we need not only to iterate through all the tiles we would need to pass vertically, but also to interpolate the object's position for each tile we go through to approximate the path from the previous frame's position to the current one. If we simply kept using the current object's position, then in the above case a collision would be detected, even though it shouldn't be.

Let's rename the bottomLeft and bottomRight as newBottomLeft and newBottomRight, so we know that these are the new frame's sensor positions.

Now, within this new loop, let's interpolate the sensor positions, so that at the beginning of the loop we're assuming the sensor to be at the previous frame's position, and at its end it's going to be in the current frame's position.

Note that we interpolate the vectors based on the difference in tiles on the Y axis. When old and new positions are within the same tile, the vertical distance will be zero, so in that case we wouldn't be able to divide by the distance. So to solve this issue, we want the distance to have a minimum value of 1, so that if such a scenario were to happen (and it's going to happen very often), we'll simply be using the new position for collision detection. 

Finally, for each iteration, we need to execute the same code we did already for checking the ground collision along the width of the object. 

That's pretty much it. As you may imagine, if the game's objects move really fast, this way of checking collision can be quite a bit more expensive, but it also reassures us that there will be no weird glitches with objects moving through solid walls.

Summary

Phew, that was more code than we thought we'd need, wasn't it? If you spot any errors or possible shortcuts to take, let me and everyone know in the comments! The collision check should be robust enough so that we don't have to worry about any unfortunate events of objects slipping through the tilemap's blocks. 

A lot of the code was written to make sure that there are no objects passing through the tiles at big speeds, but if that's not a problem for a particular game, we could safely remove the additional code to increase the performance. It might even be a good idea to have a flag for specific fast-moving objects, so that only those use the more expensive versions of the checks.

We still have a lot of things to cover, but we managed to make a reliable collision check for the ground, which can be mirrored pretty straightforwardly to the other three directions. We'll do that in the next part.

Advertisement
Advertisement
Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.