Написать пост

Создаем игровой движок с видом от первого лица за 265 строк кода на JavaScript

Аватарка пользователя Саша Ушатинская

В этой статье мы создадим небольшой игровой движок с видом от первого лица без сложной математики и техник 3D-визуализации, используя метод рейкастинга (трассировки, или «бросания», лучей).

Рейкастинг — один из методов рендеринга в компьютерной графике, при котором сцена строится на основе замеров пересечения лучей с визуализируемой поверхностью.

Игрок

Логично, что если мы создаем движок от первого лица, то наш игрок — это и есть точка, из которой будут выходить лучи. Для начала нам понадобится всего три свойства: координата x, координата y и направление:

			function Player(x, y, direction) {
  this.x = x;
  this.y = y;
  this.direction = direction;
}
		

Карта

Будем хранить карту с помощью двумерного массива. В нем 0 будет обозначать отсутствие стены, а 1 — её наличие. Для нашей реализации такой простой схемы будет достаточно.

			function Map(size) {
  this.size = size;
  this.wallGrid = new Uint8Array(size * size);
}
		

Бросаем луч

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

Создаем игровой движок с видом от первого лица за 265 строк кода на JavaScript 2

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

Найдем угол каждого луча

Угол зависит от трех параметров: направления, в котором смотрит игрок, фокусного расстояния камеры и колонки, которую мы в данный момент рисуем.

			var x = column / this.resolution - 0.5;
var angle = Math.atan2(x, this.focalLength);
var ray = map.cast(player, player.direction + angle, this.range);
		

Проследим за каждым лучом на сетке

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

Создаем игровой движок с видом от первого лица за 265 строк кода на JavaScript 3

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

			function ray(origin) {
  var stepX = step(sin, cos, origin.x, origin.y);
  var stepY = step(cos, sin, origin.y, origin.x, true);
  var nextStep = stepX.length2 < stepY.length2
    ? inspect(stepX, 1, 0, origin.distance, stepX.y)
    : inspect(stepY, 0, 1, origin.distance, stepY.x);

  if (nextStep.distance > range) return [origin];
  return [origin].concat(ray(nextStep));
}
		

Обнаружить пересечения на сетке легко: нужно просто найти все целочисленные x (1, 2, 3…). А потом найти соответствующие y с помощью умножения x на коэффициент угла наклона rise / run.

			var dx = run > 0 ? Math.floor(x + 1) - x : Math.ceil(x - 1) - x;
var dy = dx * (rise / run);
		

Прелесть этой части алгоритма в том, что размер карты не имеет значения. Мы рассматриваем только определенный набор точек на сетке — каждый раз примерно одно и то же количество. В нашем примере размер карты 32×32, но если бы он был 32 000×32 000, скорость загрузки была бы такой же.

Рисуем колонку

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

			var z = distance * Math.cos(angle);
var wallHeight = this.height * height / z;
		

Мы определяем высоту каждой стены, деля её максимальную высоту на z. Чем дальше стена, тем короче мы её рисуем.

Откуда взялся косинус? Если мы будем использовать «чистое» расстояние от игрока до стены, то в итоге получим эффект «рыбьего глаза». Представьте, что вы стоите лицом к стене. Края стены слева и справа находятся от вас дальше, чем центр стены. Но мы же не хотим, чтобы при отрисовке стена выпирала посередине? Для того, чтобы визуализировать плоскую стену так, как мы её видим в реальной жизни, мы строим треугольник из каждого луча и находим перпендикуляр к стене с помощью косинуса. Вот так:

Создаем игровой движок с видом от первого лица за 265 строк кода на JavaScript 4

В нашей статье это — самая сложная математика, с которой придется столкнуться ?

Визуализируем

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

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

			Camera.prototype.render = function(player, map) {
  this.drawSky(player.direction, map.skybox, map.light);
  this.drawColumns(player, map);
  this.drawWeapon(player.weapon, player.paces);
};
		

Самые важные свойства камеры — разрешение, фокусное расстояние и диапазон.

  • Разрешение определяет, сколько колонок мы рисуем (сколько лучей бросаем);
  • Фокусное расстояние определяет ширину линзы, через которую мы смотрим (углы лучей);
  • Диапазон определяет дальность обзора (максимальная длина каждого луча).

Собираем воедино

Используем объект Controls для снятия данных с клавиш-стрелок и сенсорной панели, а также объект GameLoop для вызова requestAnimationFrame. Цикл игры прописываем всего тремя строками:

			loop.start(function frame(seconds) {
  map.update(seconds);
  player.update(controls.states, map, seconds);
  camera.render(player, map);
});
		

Детали

Дождь

Дождь симулируем с помощью нескольких очень коротких стен, разбросанных произвольно:

			var rainDrops = Math.pow(Math.random(), 3) * s;
var rain = (rainDrops > 0) && this.project(0.1, angle, step.distance);

ctx.fillStyle = '#ffffff';
ctx.globalAlpha = 0.15;
while (--rainDrops > 0) ctx.fillRect(left, Math.random() * rain.top, 1, rain.height);
		

Задаем ширину стены в 1 пиксель.

Освещение и молнии

Освещение — это, вообще-то, работа с тенями: все стены рисуются со 100% яркостью, а потом покрываются черным прямоугольником какой-либо прозрачности. Прозрачность определяется как расстоянием до стены, так и её ориентацией (север / юг / запад / восток).

			ctx.fillStyle = '#000000';
ctx.globalAlpha = Math.max((step.distance + step.shading) / this.lightRange - map.light, 0);
ctx.fillRect(left, wall.top, width, wall.height);
		

Для симуляции молний, map.light случайным образом совершает резкий скачок до значения 2 и потом так же быстро гаснет.

Предупреждение столкновений

Для того, чтобы игрок не натыкался на стены, мы просто проверяем его следующую локацию по карте. Координаты x и y проверяем по отдельности, чтобы игрок мог идти вдоль стены:

			Player.prototype.walk = function(distance, map) {
  var dx = Math.cos(this.direction) * distance;
  var dy = Math.sin(this.direction) * distance;
  if (map.get(this.x + dx, this.y) <= 0) this.x += dx;
  if (map.get(this.x, this.y + dy) <= 0) this.y += dy;
};
		

Текстура стен

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

			step.offset = offset - Math.floor(offset);
var textureX = Math.floor(texture.width * step.offset);
		

Например, для пересечения в точке (10, 8,2) остаток равен 0,2. Это значит, что пересечение находится в 20% от левого края стены (8) и 80% от правого края (9). Поэтому мы умножаем 0,2 на texture.width чтобы найти x-координату для изображения текстуры.

Можно посмотреть результат на сайте автора, а также изучить код на GitHub.

Следите за новыми постами
Следите за новыми постами по любимым темам
31К открытий31К показов