Advertisement
  1. Game Development
  2. Platformer

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

Scroll to top
Read Time: 14 min
This post is part of a series called Basic 2D Platformer Physics .
Basic 2D Platformer Physics, Part 3
Basic 2D Platformer Physics, Part 5: Object vs. Object Collision Detection

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

Захват уступа

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

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

Установка переменных

Давайте обратимся к нашему классу Character, в котором мы реализуем захват уступов. Нет смысла делать это в классе MovingObject, так как большинство объектов не имеют возможности захвата уступа, поэтому будет бессмысленно делать какие-либо расчеты в этом направлении в нем.

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

1
public const float cGrabLedgeStartY = 0.0f;
2
public const float cGrabLedgeEndY = 2.0f;

cGrabLedgeStartY и cGrabLedgeEndY это смещения от верхнего края AABB, первая константа это начальная точка сенсора, а вторая - конечная точка сенсора. Как видите, персонажу нужно будет найти уступ в пределах 2 пикселей.

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

1
public const float cGrabLedgeTileOffsetY = -4.0f;

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

1
public Vector2i mLedgeTile;

Реализация

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

  • Вертикальная скорость меньше или равна нулю (персонаж падает)
  • Персонаж не находится на потолке - если вы не можете спрыгнуть с него, бессмысленно захватывать уступ.
  • Персонаж столкнулся со стеной и двигается в ее направлении.
1
if (mOnGround)
2
{
3
    //if there's no movement change state to standing

4
    if (KeyState(KeyInput.GoRight) == KeyState(KeyInput.GoLeft))
5
    {
6
        mCurrentState = CharacterState.Stand;
7
        mSpeed = Vector2.zero;
8
        mAudioSource.PlayOneShot(mHitWallSfx, 0.5f);
9
    }
10
    else    //either go right or go left are pressed so we change the state to walk

11
    {
12
        mCurrentState = CharacterState.Walk;
13
        mSpeed.y = 0.0f;
14
        mAudioSource.PlayOneShot(mHitWallSfx, 0.5f);
15
    }
16
}
17
else if (mSpeed.y <= 0.0f 
18
    && !mAtCeiling
19
    && ((mPushesRightWall && KeyState(KeyInput.GoRight)) || (mPushesLeftWall && KeyState(KeyInput.GoLeft))))
20
{
21
}

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

1
Vector2 aabbCornerOffset;
2
3
if (mPushesRightWall && mInputs[(int)KeyInput.GoRight])
4
    aabbCornerOffset = mAABB.halfSize;
5
else
6
    aabbCornerOffset = new Vector2(-mAABB.halfSize.x - 1.0f, mAABB.halfSize.y);

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


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

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

1
int tileX, topY, bottomY;

Давайте получим координату X угла AABB.

1
int tileX, topY, bottomY;
2
tileX = mMap.GetMapTileXAtPoint(mAABB.center.x + aabbCornerOffset.x);

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

1
if ((mPushedLeftWall && mPushesLeftWall) || (mPushedRightWall && mPushesRightWall))
2
{
3
    topY = mMap.GetMapTileYAtPoint(mOldPosition.y + mAABBOffset.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
4
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
5
}

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

1
if ((mPushedLeftWall && mPushesLeftWall) || (mPushedRightWall && mPushesRightWall))
2
{
3
    topY = mMap.GetMapTileYAtPoint(mOldPosition.y + mAABBOffset.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
4
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
5
}
6
else
7
{
8
    topY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeStartY);
9
    bottomY = mMap.GetMapTileYAtPoint(mAABB.center.y + aabbCornerOffset.y - Constants.cGrabLedgeEndY);
10
}

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

1
for (int y = topY; y >= bottomY; --y)
2
{
3
}

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

  • Тайл пуст.
  • Тайл под ним является сплошным тайлом (это тайл, за который мы хотим ухватиться).
1
for (int y = topY; y >= bottomY; --y)
2
{
3
    if (!mMap.IsObstacle(tileX, y)
4
        && mMap.IsObstacle(tileX, y - 1))
5
    {
6
    }
7
}

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

1
if (!mMap.IsObstacle(tileX, y)
2
        && mMap.IsObstacle(tileX, y - 1))
3
{
4
    var tileCorner = mMap.GetMapTilePosition(tileX, y - 1);
5
    tileCorner.x -= Mathf.Sign(aabbCornerOffset.x) * Map.cTileSize / 2;
6
    tileCorner.y += Map.cTileSize / 2;
7
}

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

1
if (!mMap.IsObstacle(tileX, y)
2
        && mMap.IsObstacle(tileX, y - 1))
3
{
4
    var tileCorner = mMap.GetMapTilePosition(tileX, y - 1);
5
    tileCorner.x -= Mathf.Sign(aabbCornerOffset.x) * Map.cTileSize / 2;
6
    tileCorner.y += Map.cTileSize / 2;
7
    
8
    if (y > bottomY ||
9
        ((mAABB.center.y + aabbCornerOffset.y) - tileCorner.y <= Constants.cGrabLedgeEndY
10
        && tileCorner.y - (mAABB.center.y + aabbCornerOffset.y) >= Constants.cGrabLedgeStartY))
11
    {
12
    }
13
}

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

1
if (y > bottomY ||
2
    ((mAABB.center.y + aabbCornerOffset.y) - tileCorner.y <= Constants.cGrabLedgeEndY
3
    && tileCorner.y - (mAABB.center.y + aabbCornerOffset.y) >= Constants.cGrabLedgeStartY))
4
{
5
    mLedgeTile = new Vector2i(tileX, y - 1);
6
}

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

1
mPosition.y = tileCorner.y - aabbCornerOffset.y - mAABBOffset.y - Constants.cGrabLedgeStartY + Constants.cGrabLedgeTileOffsetY;

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

1
mPosition.y = tileCorner.y - aabbCornerOffset.y - mAABBOffset.y - Constants.cGrabLedgeStartY + Constants.cGrabLedgeTileOffsetY;
2
3
mSpeed = Vector2.zero;
4
mCurrentState = CharacterState.GrabLedge;
5
break;

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

Управление захватом уступа

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

Реализация управления

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

1
bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
2
bool ledgeOnRight = !ledgeOnLeft;

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

  • нажать клавишу вниз
  • нажать клавишу влево, когда мы держимся за уступ справа, или
  • нажать клавишу вправо, когда мы держимся за уступ слева
1
bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
2
bool ledgeOnRight = !ledgeOnLeft;
3
4
if (mInputs[(int)KeyInput.GoDown]
5
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
6
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
7
{
8
}

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

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

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

1
public int mCannotGoLeftFrames = 0;
2
public int mCannotGoRightFrames = 0;

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

1
bool ledgeOnLeft = mLedgeTile.x * Map.cTileSize < mPosition.x;
2
bool ledgeOnRight = !ledgeOnLeft;
3
4
if (mInputs[(int)KeyInput.GoDown]
5
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
6
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
7
{
8
    if (ledgeOnLeft)
9
        mCannotGoLeftFrames = 3;
10
    else
11
        mCannotGoRightFrames = 3;
12
13
    mCurrentState = CharacterState.Jump;
14
}

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

1
if (mCannotGoLeftFrames > 0)
2
{
3
    --mCannotGoLeftFrames;
4
    mInputs[(int)KeyInput.GoLeft] = false;
5
}
6
if (mCannotGoRightFrames > 0)
7
{
8
    --mCannotGoRightFrames;
9
    mInputs[(int)KeyInput.GoRight] = false;
10
}
11
12
if (mSpeed.y <= 0.0f && !mAtCeiling
13
    && ((mPushesRightWall && mInputs[(int)KeyInput.GoRight]) || (mPushesLeftWall && mInputs[(int)KeyInput.GoLeft])))
14
{

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

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

Если персонаж не спрыгнул вниз с уступа, нам нужно проверить, не была ли нажата клавиша прыжка; если да, то нам нужно установить вертикальную скорость прыжка и изменить состояние:

1
if (mInputs[(int)KeyInput.GoDown]
2
    || (mInputs[(int)KeyInput.GoLeft] && ledgeOnRight)
3
    || (mInputs[(int)KeyInput.GoRight] && ledgeOnLeft))
4
{
5
    if (ledgeOnLeft)
6
        mCannotGoLeftFrames = 3;
7
    else
8
        mCannotGoRightFrames = 3;
9
10
    mCurrentState = CharacterState.Jump;
11
}
12
else if (mInputs[(int)KeyInput.Jump])
13
{
14
    mSpeed.y = mJumpSpeed;
15
    mCurrentState = CharacterState.Jump;
16
}

Вот, в общем- то и все! Теперь захват уступов должен работать правильно в любых ситуациях.

Позволим персонажу прыгать сразу после покидания платформы

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

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

1
public const int cJumpFramesThreshold = 4;

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

1
protected int mFramesFromJumpStart = 0;

Теперь давайте устанавливать в 0 mFramesFromJumpStart каждый раз, когда мы оторвались от земли. Давайте сделаем это сразу после вызова UpdatePhysics.

1
UpdatePhysics();
2
3
if (mWasOnGround && !mOnGround)
4
    mFramesFromJumpStart = 0;

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

1
case CharacterState.Jump:
2
3
    ++mFramesFromJumpStart;

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

1
++mFramesFromJumpStart;
2
3
if (mFramesFromJumpStart <= Constants.cJumpFramesThreshold)
4
{
5
    if (mAtCeiling || mSpeed.y > 0.0f)
6
        mFramesFromJumpStart = Constants.cJumpFramesThreshold + 1;
7
}

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

1
if (mFramesFromJumpStart <= Constants.cJumpFramesThreshold)
2
{
3
    if (mAtCeiling || mSpeed.y > 0.0f)
4
        mFramesFromJumpStart = Constants.cJumpFramesThreshold + 1;
5
    else if (KeyState(KeyInput.Jump))
6
        mSpeed.y = mJumpSpeed;
7
}

Вот и все! Мы можем установить cJumpFramesThreshold в большое значение, например 10 кадров, чтобы убедиться, что это работает.

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

Масштабирование объектов

Давайте добавим возможность масштабирования объектов. У нас уже есть mScale в классе MovingObject, поэтому все, что нам нужно сделать, это убедиться, что эта переменная корректно влияет на AABB и его смещение.

Сначала давайте отредактируем наш класс AABB, так, чтобы он получил компонент масштаба.

1
public struct AABB
2
{
3
    public Vector2 scale;
4
    public Vector2 center;
5
  public Vector2 halfSize;
6
    
7
    public AABB(Vector2 center, Vector2 halfSize)
8
    {
9
        scale = Vector2.one;
10
        this.center = center;
11
        this.halfSize = halfSize;
12
    }

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

1
public Vector2 scale;
2
public Vector2 center;
3
4
private Vector2 halfSize;
5
public Vector2 HalfSize
6
{
7
    set { halfSize = value; }
8
    get { return new Vector2(halfSize.x * scale.x, halfSize.y * scale.y); }
9
}

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

1
public float HalfSizeX
2
{
3
    set { halfSize.x = value; }
4
    get { return halfSize.x * scale.x; }
5
}
6
7
public float HalfSizeY
8
{
9
    set { halfSize.y = value; }
10
    get { return halfSize.y * scale.y; }
11
}

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

1
private Vector2 mAABBOffset;
2
public Vector2 AABBOffset
3
{
4
    set { mAABBOffset = value; }
5
    get { return new Vector2(mAABBOffset.x * mScale.x, mAABBOffset.y * mScale.y); }
6
}

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

1
public float AABBOffsetX
2
{
3
    set { mAABBOffset.x = value; }
4
    get { return mAABBOffset.x * mScale.x; }
5
}
6
7
public float AABBOffsetY
8
{
9
    set { mAABBOffset.y = value; }
10
    get { return mAABBOffset.y * mScale.y; }
11
}

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

1
private Vector2 mScale;
2
public Vector2 Scale
3
{
4
    set {
5
        mScale = value;
6
        mAABB.scale = new Vector2(Mathf.Abs(value.x), Mathf.Abs(value.y));
7
    }
8
    get { return mScale; }
9
}
10
public float ScaleX
11
{
12
    set
13
    {
14
        mScale.x = value;
15
        mAABB.scale.x = Mathf.Abs(value);
16
    }
17
    get { return mScale.x; }
18
}
19
public float ScaleY
20
{
21
    set
22
    {
23
        mScale.y = value;
24
        mAABB.scale.y = Mathf.Abs(value);
25
    }
26
    get { return mScale.y; }
27
}

Все, что нам осталось сделать, это убедиться всегда, когда мы напрямую используем переменные, мы работаем с ними через процедуры их получения и установки. Всегда, когда мы использовали halfSize.x, мы будем использовать HalfSizeX, всегда, когда мы использовали halfSize.y, мы будем использовать HalfSizeY, и т.д. Несколько вызовов функции поиска и замены должны справиться с этим хорошо.

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

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

Заключение

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

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

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.