Advertisement
  1. Game Development
  2. Platformer

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

Scroll to top
Read Time: 12 min
This post is part of a series called Basic 2D Platformer Physics .
Basic 2D Platformer Physics, Part 2
Basic 2D Platformer Physics, Part 4

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

Односторонние платформы

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

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

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

1
public const float cOneWayPlatformThreshold = 2.0f;

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

1
public bool mOnOneWayPlatform = false;

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

1
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY, ref bool onOneWayPlatform)

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

1
if (mMap.IsObstacle(tileIndexX, tileIndexY))
2
    return true;
3
else if (mMap.IsOneWayPlatform(tileIndexX, tileIndexY))
4
    onOneWayPlatform = true;

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

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

1
if (mMap.IsObstacle(tileIndexX, tileIndexY))
2
    return true;
3
else if (mMap.IsOneWayPlatform(tileIndexX, tileIndexY)
4
        && Mathf.Abs(checkedTile.y - groundY) <= Constants.cOneWayPlatformThreshold + mOldPosition.y - position.y)
5
    onOneWayPlatform = true;

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

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

1
if (mMap.IsObstacle(tileIndexX, tileIndexY))
2
{
3
    onOneWayPlatform = false;
4
    return true;
5
}
6
else if (mMap.IsOneWayPlatform(tileIndexX, tileIndexY)
7
        && Mathf.Abs(checkedTile.y - groundY) <= Constants.cOneWayPlatformThreshold + mOldPosition.y - position.y)
8
    onOneWayPlatform = true;

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

1
if (mMap.IsObstacle(tileIndexX, tileIndexY))
2
{
3
    onOneWayPlatform = false;
4
    return true;
5
}
6
else if (mMap.IsOneWayPlatform(tileIndexX, tileIndexY)
7
        && Mathf.Abs(checkedTile.y - groundY) <= Constants.cOneWayPlatformThreshold + mOldPosition.y - position.y)
8
    onOneWayPlatform = true;
9
10
if (checkedTile.x >= bottomRight.x)
11
{
12
    if (onOneWayPlatform)
13
        return true;
14
    break;
15
}

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

1
if (KeyState(KeyInput.GoDown))
2
{
3
    if (mOnOneWayPlatform)
4
        mPosition.y -= Constants.cOneWayPlatformThreshold;
5
}

Давайте посмотрим, как он работает.

Все работает правильно.

Управление столкновениями для потолка

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

  • Линия сенсора находится над AABB вместо того, чтобы быть снизу
  • Мы проверяем тайл потолка снизу вверх, по мере того, как двигаемся вниз
  • Не нужно поддерживать односторонние платформы

Вот измененная функция.

1
public bool HasCeiling(Vector2 oldPosition, Vector2 position, out float ceilingY)
2
{
3
    var center = position + mAABBOffset;
4
    var oldCenter = oldPosition + mAABBOffset;
5
6
    ceilingY = 0.0f;
7
8
    var oldTopRight = oldCenter + mAABB.halfSize + Vector2.up - Vector2.right;
9
10
    var newTopRight = center + mAABB.halfSize + Vector2.up - Vector2.right;
11
    var newTopLeft = new Vector2(newTopRight.x - mAABB.halfSize.x * 2.0f + 2.0f, newTopRight.y);
12
13
    int endY = mMap.GetMapTileYAtPoint(newTopRight.y);
14
    int begY = Mathf.Min(mMap.GetMapTileYAtPoint(oldTopRight.y) + 1, endY);
15
    int dist = Mathf.Max(Mathf.Abs(endY - begY), 1);
16
17
    int tileIndexX;
18
19
    for (int tileIndexY = begY; tileIndexY <= endY; ++tileIndexY)
20
    {
21
        var topRight = Vector2.Lerp(newTopRight, oldTopRight, (float)Mathf.Abs(endY - tileIndexY) / dist);
22
        var topLeft = new Vector2(topRight.x - mAABB.halfSize.x * 2.0f + 2.0f, topRight.y);
23
24
        for (var checkedTile = topLeft; ; checkedTile.x += Map.cTileSize)
25
        {
26
            checkedTile.x = Mathf.Min(checkedTile.x, topRight.x);
27
28
            tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
29
30
            if (mMap.IsObstacle(tileIndexX, tileIndexY))
31
            {
32
                ceilingY = (float)tileIndexY * Map.cTileSize - Map.cTileSize / 2.0f + mMap.mPosition.y;
33
                return true;
34
            }
35
36
            if (checkedTile.x >= topRight.x)
37
                break;
38
        }
39
    }
40
41
    return false;
42
}

Поддержка столкновений для левой стены

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

  • Сенсорная линия находится на левом краю AABB.
  • Внутренний цикл for должен обойти тайлы по вертикали, потому что теперь сенсор является вертикальной линией.
  • Внешний цикл должен обойти тайлы горизонтально, чтобы посмотреть, не пропустили ли мы стену при движении с большой горизонтальной скоростью.
1
public bool CollidesWithLeftWall(Vector2 oldPosition, Vector2 position, out float wallX)
2
{
3
    var center = position + mAABBOffset;
4
    var oldCenter = oldPosition + mAABBOffset;
5
6
    wallX = 0.0f;
7
8
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.right;
9
    var newBottomLeft = center - mAABB.halfSize - Vector2.right;
10
    var newTopLeft = newBottomLeft + new Vector2(0.0f, mAABB.halfSize.y * 2.0f);
11
12
    int tileIndexY;
13
14
    var endX = mMap.GetMapTileXAtPoint(newBottomLeft.x);
15
    var begX = Mathf.Max(mMap.GetMapTileXAtPoint(oldBottomLeft.x) - 1, endX);
16
    int dist = Mathf.Max(Mathf.Abs(endX - begX), 1);
17
18
    for (int tileIndexX = begX; tileIndexX >= endX; --tileIndexX)
19
    {
20
        var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endX - tileIndexX) / dist);
21
        var topLeft = bottomLeft + new Vector2(0.0f, mAABB.halfSize.y * 2.0f);
22
23
        for (var checkedTile = bottomLeft; ; checkedTile.y += Map.cTileSize)
24
        {
25
            checkedTile.y = Mathf.Min(checkedTile.y, topLeft.y);
26
27
            tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
28
29
            if (mMap.IsObstacle(tileIndexX, tileIndexY))
30
            {
31
                wallX = (float)tileIndexX * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.x;
32
                return true;
33
            }
34
35
            if (checkedTile.y >= topLeft.y)
36
                break;
37
        }
38
    }
39
40
    return false;
41
}

Поддержка столкновений с правой стеной

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

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

1
public bool CollidesWithRightWall(Vector2 oldPosition, Vector2 position, out float wallX)
2
{
3
    var center = position + mAABBOffset;
4
    var oldCenter = oldPosition + mAABBOffset;
5
6
    wallX = 0.0f;
7
8
    var oldBottomRight = oldCenter + new Vector2(mAABB.halfSize.x, -mAABB.halfSize.y) + Vector2.right;
9
    var newBottomRight = center + new Vector2(mAABB.halfSize.x, -mAABB.halfSize.y) + Vector2.right;
10
    var newTopRight = newBottomRight + new Vector2(0.0f, mAABB.halfSize.y * 2.0f);
11
12
    var endX = mMap.GetMapTileXAtPoint(newBottomRight.x);
13
    var begX = Mathf.Min(mMap.GetMapTileXAtPoint(oldBottomRight.x) + 1, endX);
14
    int dist = Mathf.Max(Mathf.Abs(endX - begX), 1);
15
16
    int tileIndexY;
17
18
    for (int tileIndexX = begX; tileIndexX <= endX; ++tileIndexX)
19
    {
20
        var bottomRight = Vector2.Lerp(newBottomRight, oldBottomRight, (float)Mathf.Abs(endX - tileIndexX) / dist);
21
        var topRight = bottomRight + new Vector2(0.0f, mAABB.halfSize.y * 2.0f);
22
23
        for (var checkedTile = bottomRight; ; checkedTile.y += Map.cTileSize)
24
        {
25
            checkedTile.y = Mathf.Min(checkedTile.y, topRight.y);
26
27
            tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
28
29
            if (mMap.IsObstacle(tileIndexX, tileIndexY))
30
            {
31
                wallX = (float)tileIndexX * Map.cTileSize - Map.cTileSize / 2.0f + mMap.mPosition.x;
32
                return true;
33
            }
34
35
            if (checkedTile.y >= topRight.y)
36
                break;
37
        }
38
    }
39
40
    return false;
41
}

Перемещение объекта из состояния столкновения

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

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

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

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

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

1
float groundY = 0.0f, ceilingY = 0.0f;
2
float rightWallX = 0.0f, leftWallX = 0.0f;

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

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

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

1
if (mSpeed.x <= 0.0f 
2
    && CollidesWithLeftWall(mOldPosition, mPosition, out leftWallX)
3
    && mOldPosition.x - mAABB.halfSize.x + mAABBOffset.x >= leftWallX)
4
{
5
}

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

1
if (mSpeed.x <= 0.0f 
2
    && CollidesWithLeftWall(mOldPosition, mPosition, out leftWallX)
3
    && mOldPosition.x - mAABB.halfSize.x + mAABBOffset.x >= leftWallX)
4
{
5
    mPosition.x = leftWallX + mAABB.halfSize.x - mAABBOffset.x;
6
    mSpeed.x = Mathf.Max(mSpeed.x, 0.0f);
7
    mPushesLeftWall = true;
8
}

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

1
if (mSpeed.x <= 0.0f 
2
    && CollidesWithLeftWall(mOldPosition, mPosition, out leftWallX))
3
{
4
    if (mOldPosition.x - mAABB.HalfSizeX + AABBOffsetX >= leftWallX)
5
    {
6
        mPosition.x = leftWallX + mAABB.HalfSizeX - AABBOffsetX;
7
        mPushesLeftWall = true;
8
    }
9
    mSpeed.x = Mathf.Max(mSpeed.x, 0.0f);
10
}
11
else
12
    mPushesLeftWall = false;

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

1
if (mSpeed.x >= 0.0f 
2
    && CollidesWithRightWall(mOldPosition, mPosition, out rightWallX))
3
{
4
    if (mOldPosition.x + mAABB.HalfSizeX + AABBOffsetX <= rightWallX)
5
    {
6
        mPosition.x = rightWallX - mAABB.HalfSizeX - AABBOffsetX;
7
        mPushesRightWall = true;
8
    }
9
10
    mSpeed.x = Mathf.Min(mSpeed.x, 0.0f);
11
}
12
else
13
    mPushesRightWall = false;

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

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

1
if (mSpeed.y >= 0.0f 
2
    && HasCeiling(mOldPosition, mPosition, out ceilingY))
3
{
4
    mPosition.y = ceilingY - mAABB.halfSize.y - mAABBOffset.y - 1.0f;
5
    mSpeed.y = 0.0f;
6
    mAtCeiling = true;
7
}
8
else
9
    mAtCeiling = false;

Скругляем углы

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

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

1
Vector2 RoundVector(Vector2 v)
2
{
3
    return new Vector2(Mathf.Round(v.x), Mathf.Round(v.y));
4
}

Теперь давайте используем эту функцию в каждой проверке столкновения. Сначала давайте исправим функцию HasCeiling.

1
var oldTopRight = RoundVector(oldCenter + mAABB.HalfSize + Vector2.up - Vector2.right);
2
        
3
var newTopRight = RoundVector(center + mAABB.HalfSize + Vector2.up - Vector2.right);
4
var newTopLeft = RoundVector(new Vector2(newTopRight.x - mAABB.HalfSizeX * 2.0f + 2.0f, newTopRight.y));

Следующая будет OnGround.

1
var oldBottomLeft = RoundVector(oldCenter - mAABB.HalfSize - Vector2.up + Vector2.right);
2
3
var newBottomLeft = RoundVector(center - mAABB.HalfSize - Vector2.up + Vector2.right);
4
var newBottomRight = RoundVector(new Vector2(newBottomLeft.x + mAABB.HalfSizeX * 2.0f - 2.0f, newBottomLeft.y));

PushesRightWall.

1
var oldBottomRight = RoundVector(oldCenter + new Vector2(mAABB.HalfSizeX, -mAABB.HalfSizeY) + Vector2.right);
2
3
var newBottomRight = RoundVector(center + new Vector2(mAABB.HalfSizeX, -mAABB.HalfSizeY) + Vector2.right);
4
var newTopRight = RoundVector(newBottomRight + new Vector2(0.0f, mAABB.HalfSizeY * 2.0f));

И наконец, PushesLeftWall.

1
var oldBottomLeft = RoundVector(oldCenter - mAABB.HalfSize - Vector2.right);
2
3
var newBottomLeft = RoundVector(center - mAABB.HalfSize - Vector2.right);
4
var newTopLeft = RoundVector(newBottomLeft + new Vector2(0.0f, mAABB.HalfSizeY * 2.0f));

Это должно решить наши проблемы!

Проверим результаты

Это будет самое оно. Давайте проверим, как наши столкновения работают теперь.

Заключение

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

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

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
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.