Stage 6: Прочее

Предметы

2.5D Renderer

2D Renderer

Управление камерой WASD и ZX

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

В описание сектора добавляем массив items. Каждый Item хранит позицию в плоскости карты, необязательное смещение по высоте, радиус и тип. Поле spriteоставлено для следующего шага: позже вместо одноцветного прямоугольника можно будет читать пиксели из bitmap.

  interface Sector {
    // ..
    items?: Item[];
  }

  interface Item {
    type: string;
    x: number;
    y: number;
    z?: number;
    radius: number;
    sprite?: {
      width: number;
      height: number;
      colors: Color[];
      bitmap: number[][];
    };
  }

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

  const stepSector2: Sector = {
    // ..
    floorHeight: 750,
    ceilHeight: 10_000,
    items: [
      {
        x: startX + 50 * 1.5,
        y: startY + 25,
        radius: 10,
        type: 'health'
      }
    ]
  };

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

Сначала предмет переводится в координаты относительно камеры. Скалярное произведение с направлением взгляда дает расстояние вперед, а угол до предмета превращается в координату screenX. Если предмет находится за камерой или его угловая область не пересекает FOV, он не рисуется.

  const dx = item.x - camera.x;
  const dy = item.y - camera.y;
  const forwardDistance = dx * camera.angle.cos + dy * camera.angle.sin;
  const distance = Math.hypot(dx, dy);

  const angleToItem = Math.atan2(dy, dx) * 180 / Math.PI;
  const relativeAngle = angleToItem - camera.angle.degrees;
  const screenX =
    (relativeAngle / camera.fov.degrees) * camera.screen.width
    + camera.screen.width / 2;

Горизонтальная ширина берется из углового радиуса предмета. Чем ближе предмет, тем большеMath.atan2(radius, distance), а значит шире его область на экране. Вертикальные границы считаются отдельно: низ прямоугольника привязан к полу сектора плюсitem.z, верх — к потолку сектора.

  const itemAngularRadius = Math.atan2(item.radius, distance) * 180 / Math.PI;
  const screenHalfWidth =
    (itemAngularRadius / camera.fov.degrees) * camera.screen.width;

  const projectionScale = 1 / Math.max(forwardDistance, 1);
  const floorZ = (sector.floorHeight ?? 0) + (item.z ?? 0);
  const ceilZ = sector.ceilHeight ?? floorZ;

  const screenBottomY = screenCenterY - (floorZ - camera.z) * projectionScale;
  const screenTopY = screenCenterY - (ceilZ - camera.z) * projectionScale;

В итоге у предмета получается прямоугольник в экранных координатах:x1,x2,y1 иy2. Эти границы обрезаются по размеру canvas, а пустые прямоугольники отбрасываются.

  const x1 = Math.max(0, Math.floor(screenX - screenHalfWidth));
  const x2 = Math.min(camera.screen.width, Math.ceil(screenX + screenHalfWidth));
  const y1 = Math.max(0, Math.floor(screenTopY));
  const y2 = Math.min(camera.screen.height, Math.ceil(screenBottomY));

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

Реализация шага на GitHub