game-engine / devlog / climbing-mechanic

Механика вскарабкивания для игры от первого лица (как в Thief)

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

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

Готовый прототип можно попробовать в билде 0.17.


Алгоритм

На схемах ниже отражены все этапы определения уступа и проверки коллизии. Код алгоритма целиком приведён в конце статьи.

1. Проверка условия для старта вскарабкивания

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

Капсула игрока Ближайший уступ

Мы начинаем искать уступ только если:

Если все условия соблюдены — считаем уступ валидным

2. Коррекция целевой точки

Без коррекции игрок оказался бы на самом краю и мог бы соскользнуть. Поэтому:

forward_dir

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

3. Тест проникновения капсулы

Мы создаём виртуальную капсулу игрока в целевой точке, чтобы проверить, не блокирует ли что-то ей путь:

4. Анимация

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

Анимация разбита на 2 фазы:

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


Итоговый код

Полная реализация из моей игры: поиск уступа, проверка коллизии и анимация подъёма

static BOOL Player_DetectLedge(const Object *player, Vector forward_dir, OUT Point *out_point)
{
    Capsule capsule = {
        .start  = Vector_Add(Player_GetHeadPosition(player), Vector_Mul(forward_dir, PLAYER_RADIUS)), // move forward a bit
        .end    = Vector_Add(Player_GetFootPosition(player), Vector_Mul(forward_dir, PLAYER_RADIUS)),
        .radius = 0.4f // ledge check radius
    };
    return Collision_FindClosestEdgeToCapsule(&capsule, out_point);
}

static void Player_StartClimbing(const Object *player, const Point *ledge)
{
    player_state.climb_start = (Vector){ player->x, player->y, player->z };
    player_state.climb_end = *ledge;
    player_state.climb_animation_start_time = game_time;
    player_state.climb_animation_duration = Vector_Length(Vector_Sub(player_state.climb_end, player_state.climb_start));
    player_state.is_climbing = TRUE;

    // Reset player velocity at climb start to prevent jump momentum from launching the player
    player_state.vertical_velocity = 0;
    player_state.movement_velocity = (Vector){ 0.0f, 0.0f, 0.0f };

    PlaySound(climbing_sounds[0]);
}

static void Player_StopClimbing(Object *player)
{
    player->x = player_state.climb_end.x;
    player->y = player_state.climb_end.y;
    player->z = player_state.climb_end.z;

    player_state.is_climbing = FALSE;
}

static void Player_AnimateClimbing(Object *player, float t)
{
    // Climbing animation has two phases:
    // 1. Move player up to the ledge (vertical movement)
    // 2. Move player forward to the ledge (horizontal movement)
    const float first_phase_duration = 0.5f; // the first phase takes 50% of the time, which gives a more natural climbing motion
    float t1 = t;
    float t2 = Remap(max(t, first_phase_duration), first_phase_duration, 1.0f, 0.0f, 1.0f); // scale second phase to [0, 1] range
    player->x = Lerp(player_state.climb_start.x, player_state.climb_end.x, t2);
    player->y = Lerp(player_state.climb_start.y, player_state.climb_end.y, t1);
    player->z = Lerp(player_state.climb_start.z, player_state.climb_end.z, t2);
}

void Player_UpdateClimbing(Object *player)
{
    // Start looking for a ledge if player in a jumping state and holding jump button
    if (   !player_state.is_climbing
        && !player_state.is_grounded && keys[VK_SPACE]
        &&  player_state.vertical_velocity > 0
        &&  player_state.in_air_time > 0.15f) {

        // Find valid ledge
        const Vector forward_dir = (Vector){ sinf(player->yaw), 0.0f, cosf(player->yaw) };
        Point ledge;
        if (!Player_DetectLedge(player, forward_dir, &ledge)) {
            return;
        }
        if (ledge.y > (player->y - PLAYER_STEP_HEIGHT)) {
            return; // the ledge is below player step
        }

        // Move a bit further in a direction of ledge
        Vector ledge_direction = Vector_Sub(ledge, (Vector){ player->x, player->y, player->z });
        ledge_direction.y = 0; // remove vertical component
        ledge_direction = Vector_Normalize(ledge_direction);
        ledge = Vector_Add(ledge, Vector_Mul(ledge_direction, PLAYER_RADIUS / 2));

        // Check path space at the edge is clear
        Capsule capsule;
        capsule.p1 = (Point){ ledge.x, ledge.y - PLAYER_STEP_HEIGHT, ledge.z };
        capsule.p2 = (Point){ ledge.x, ledge.y - PLAYER_HEIGHT,      ledge.z };
        capsule.radius = PLAYER_RADIUS;
        if (Collision_FindCapsulePenetration(&capsule).hit) {
            return; // something is blocking the target position
        }

        // Start climbing
        Player_StartClimbing(player, &ledge);
    }

    // Process climbing state
    if (player_state.is_climbing) {

        // Calculate animation time
        float t = Clip((game_time - player_state.climb_animation_start_time) / player_state.climb_animation_duration, 0.0f, 1.0f);
        t = Smoothstep(t);

        player_state.climb_time += time_delta;

        // Climbing is done
        if (t >= 1.0f) {
            Player_StopClimbing(player);
            return;
        }

        // Cancel animation if jump button released during the animation
        if (player_state.is_climbing && !keys[VK_SPACE] && t <= 0.5f) {
            player_state.is_climbing = FALSE;
            return;
        }

        // Animate climbing
        Player_AnimateClimbing(player, t);
    }
}
игрок механики анимации коллизии