2.5D Renderer

Stage 4: Улучшаем движение

Коллизии со стенами

2.5D Renderer

2D Renderer

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

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


  export const DEFAULT_CONFIG = {
    playerRadius: 5,
    stepHeight: 1000
  };

  export function isSolidWall(seg: Seg): boolean {
    return Boolean(!seg.isTwoSide || seg.isSolid);
  }

Чтобы понять, пересекся ли круг игрока со стеной, сначала ищем ближайшую точку на отрезке стены. Проекция точки на отрезок дает параметр t, а затем мы ограничиваем его диапазоном 0..1, чтобы ближайшая точка не ушла за концы стены.


  export function closestPointOnSegment(seg: Seg, point: Vertex): Vertex {
    const ax = seg.end.x - seg.start.x;
    const ay = seg.end.y - seg.start.y;
    const len2 = ax * ax + ay * ay;
    
    let t = ((point.x - seg.start.x) * ax + (point.y - seg.start.y) * ay) / len2;
    t = Math.max(0, Math.min(1, t));
    
    return {
      x: seg.start.x + ax * t,
      y: seg.start.y + ay * t
    };
  }

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


  export function circleSegmentCollision(center: Vertex, radius: number, seg: Seg) {
    const closest = closestPointOnSegment(seg, center);
    const dx = center.x - closest.x;
    const dy = center.y - closest.y;
    const distance = Math.hypot(dx, dy);
    
    if (distance >= radius) {
      return { collided: false, pushX: 0, pushY: 0, distance };
    }

    const overlap = radius - distance;

    return {
      collided: true,
      pushX: nx * overlap,
      pushY: ny * overlap,
      distance
    };
  }

В движении мы больше не записываем желаемые координаты напрямую. Сначала считаем, куда камера хочет попасть, затем передаем старую и новую позицию в checkCollisionOptimized. Функция возвращает уже исправленную позицию: если столкновения нет, это почти те же координаты; если есть, камера будет слегка сдвинута от стены.


  const collision = checkCollisionOptimized(
    camera.x,
    camera.y,
    camera.x + moveX,
    camera.y + moveY,
    DEFAULT_CONFIG.playerRadius,
    settings.level.linedefs,
    true
  );

  return {
    ...camera,
    x: collision.x,
    y: collision.y,
    z: newZ,
  };

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


  const oldFloorZ = camera.z! - height;
  const newFloorZ = sector.floorHeight!;
  const stepUp = newFloorZ - oldFloorZ;
  
  if (stepUp > 0 && stepUp <= DEFAULT_CONFIG.stepHeight) {
    newZ = sector.floorHeight! + height;
  } else if (stepUp > DEFAULT_CONFIG.stepHeight) {
    return { ...camera };
  }

Оптимизированная проверка не перебирает все стены уровня вслепую. Сначала строится небольшой прямоугольник вокруг перемещения камеры с учетом радиуса игрока, и в подробную проверку попадают только seg, чьи bounding box пересекают этот прямоугольник. Для учебного уровня это не критично, но такая привычка быстро окупается на больших картах.

Реализация коллизий на github

Использование в движении камеры