Продолжаю описание внутреннего устройства шаблона 3D-игры с ходьбой по ленте Мёбиуса.
В прошлой статье рассмотрена генерация самой ленты Мёбиуса и расчет вектора локальной гравитации. Если есть гравитация, значит, есть прыжки и падения. Их и рассмотрим.
Большинство FPS-контроллеров упрощают механику прыжка до мгновенного «выстрела» игроком. Пример из популярного контроллера для Godot Engine:
func _physics_process(delta: float) -> void:
# здесь идет получение пользовательского ввода...
if is_on_floor():
if Input.is_action_just_pressed(&"jump"):
velocity.y = jump_height
else:
velocity.y -= gravity * delta
А вот код из контроллера для Unity:
//Do Jump
if (jump && _jumpTimeoutDelta <= 0.0f)
{
_verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
}
Различие примененных формул не должно вводить в заблуждение: механика прыжка абсолютно одинакова. В C# версии стартовая скорость определяется как физически корректная функция от желаемой высоты в верхней точке. В версии на gdscript стартовая вертикальная скорость «и есть высота прыжка», что некорректно, но интуитивно ясно, к тому же может быть оправдано получившейся микрооптимизацией (ни одного квадратного корня).
В обоих случаях нажатие пробела вызывает следующую реакцию: персонаж мгновенно получает заданную вертикальную скорость подъема, после чего летит по баллистической траектории.
Отсутствие сопротивления воздуха в игре заметить по ощущениям практически невозможно, слишком мало его влияние на динамику настоящих прыжков. В то же время энергичному прыжку вверх в реальном мире должно предшествовать вполне заметное «приседание» с последующим коротким разгоном вверх.
Реалистичное приседание не должно, в свою очередь, игнорировать ньютоновскую механику: инертность в движениях живого человека видна невооруженным глазом. То есть подготовительное движение игрока вниз должно состоять из этапов вертикального разгона и торможения.
Формализуем задачу для доработки игровой механики: по команде «jump» уровень глаз (камеры) должен быть смещен вниз на фиксированную отрицательную высоту в два этапа — с ускорением и замедлением до остановки в нижней точке, далее выполняется разгон вверх до поднятия на начальную высоту с достижением максимальной вертикальной скорости, последующий свободный полет вверх в условиях гравитации может работать по аналогии с представленными выше примерами.
Прежде, чем приступить к реализации «физичного приседания», рассмотрим механику «мягкой посадки» персонажа на поверхность. Реалистичное столкновение с «землей» - процесс плавного перехода от движения вниз к остановке на нулевой высоте. В ходе этого процесса замедленное движение уровня глаз вниз (ноги персонажа сгибаются в коленях, принимая на себя удар о поверхность) должно смениться S-образным подъемом к нулевой высоте (персонаж выпрямляет ноги, принимая обычную позу).
При чём здесь PD-контроллер?
Для работы ног персонажа в прыжках и приземлениях есть близкая аналогия из техники. Подвеска автомобиля при наезде на неровности (то есть положительном и отрицательном изменении высоты) делает именно то, что должны сделать «ноги» персонажа. Автомобили не прыгают по команде, но для выполнения прыжка достаточно вдавить автомобиль вниз и отпустить из этого положения.
Подвеска автомобиля работает как механический PD-контроллер. Пружина создает усилие, пропорциональное удалению от нейтрального положения, то есть ошибке. Это слагаемое P. Амортизатор отвечает за слагаемое D, реагируя на скорость движения.
Рассмотрим код, реализующий PD-контроллер взаимодействия персонажа с поверхностью.
Для большей наглядности объяснений будем считать, что вся прыгучесть обусловлена свойствами внешней среды, то есть имеем твердое тело персонажа и грунт, обладающий одновременно упругостью и вязкостью. Невидимые ноги персонажа работали бы аналогичным образом.
В каждый момент времени к телу персонажа приложены следующие силы:
сила тяжести;
упругая сила грунта, пропорциональная погружению в него;
сопротивление среды, пропорциональное скорости движения.
Предоставленный сам себе бездействующий персонаж под действием силы тяжести погружается на некоторую глубину, где реакция грунта равна его весу. С точки зрения PD-контроллера это и есть положение, в котором ошибка равна нулю.
При отклонении от этого положения равновесие сил нарушается и равнодействующая сил тяжести и упругости стремится вернуть персонажа в равновесное состояние.
Теперь рассмотрим код (в проекте находится в классе WorldPhysics).
static public Vector3 GetTotalAccel(Vector3 position, Vector3 velocity)
{
float depth = GetDepth(position);
// прочие действия
Vector3 dragAccel = velocity * dragCoef;
Vector3 localGravityDir = GetLocalGravity(position);
if (depth > 0) return localGravityDir * (GROUND_ELASTIC_COEF * depth + GRAVITY_COEF) + dragAccel;
return localGravityDir * GRAVITY_COEF + dragAccel;
}
Сначала по текущей позиции определяется глубина depth, она же величина деформации. Далее определяется сила сопротивления dragAccel (для простоты принята масса персонажа 1 кг, соответственно значения сил можно считать значениями ускорений) с помощью скалярного коэффициента сопротивления dragCoef. Проверив условие погруженности персонажа в грунт (depth > 0), прикладываем к нему силы упругости, тяжести и сопротивления.
Единичный вектор localGravityDir дает направление местной вертикали (напомню, мы находимся на поверхности ленты Мёбиуса, и вертикальная ось может быть направлена вообще куда угодно). Имена скалярных констант GROUND_ELASTIC_COEF и GRAVITY_COEF дают исчерпывающую информацию о них.
Из трех приложенных сил только dragAccel может иметь ненулевую проекцию на местную горизонтальную плоскость, что используется в логике горизонтальной составляющей движения. Здесь же важно, что вектор скорости velocity и, соответственно, вектор dragAccel может иметь ненулевую вертикальную составляющую.
PD-контроллер готов, осталось рассмотреть код, вдавливающий автомобиль вниз для прыжка.
Он помещен в метод MoveUpdate() класса состояния персонажа WalkingState.
if (jump && WorldPhysics.GetDepth(character.GetTransform().position) > JUMP_DEPTH)
{
jump = false;
}
if (jump && WorldPhysics.GetDepth(character.GetTransform().position) < JUMP_DEPTH)
{
acceleration += WorldPhysics.GetLocalGravity(character.GetTransform().position) * JUMP_ACCEL_COEF;
}
character.MoveBody(acceleration);
Здесь jump – признак выполнения прыжка. В первом блоке if выполняется проверка, не превышает ли текущая глубина погружения в грунт заданной величины JUMP_DEPTH. Это условие прекращения выполнения прыжка. А в чем состоит выполнение прыжка, определяет следующий блок.
Прыжок выполняется приложением к персонажу вертикальной силы, равной по модулю JUMP_ACCEL_COEF. Выражение WorldPhysics.GetLocalGravity(character.GetTransform().position) отвечает за местную вертикаль, как localGravityDir в коде WorldPhysics.GetTotalAccel().
Говоря простым языком, мы вдавливаем персонажа в грунт с постоянной силой, затем, на заданной глубине погружения отпускаем его. На этом собственно выполнение прыжка окончено, остальное упругий грунт (он же ноги, он же «подвеска автомобиля», он же PD-контроллер) сделает сам.
При правильном подборе значений констант все движения будут как и задумано плавными, а динамику прыжка можно заметно варьировать.