
Необязательная предыстория
Я всегда был фанатом шутеров с видом сверху. Hotline Miami и по сей день остаётся моей любимой инди-игрой, а недавно я решил поиграть в её духовную наследницу — OTXO.
Он всегда был моим любимым жанром. Моя первая «серьёзная» игра, Alien Killer (написанная на Flash, поэтому сегодня в неё практически не поиграешь), была шутером с видом сверху, вдохновлённым старой игрой Net Yaroze: Psychon.

Несколько лет спустя, когда я каждую пару месяцев выпускал новую игру на HTML5, я написал Warsim. На этот раз это была игра с процедурно генерируемыми картами, управлением только с клавиатуры и фиксацией на цели в стиле Tomb Raider.
В ней даже был многопользовательский режим, который помог мне понять, почему multiplayer так отличается от игры для одного.

Прошло больше десяти лет, но я всё равно почему-то возвращаюсь к этому жанру.
Чем-то он меня восхищает. Стрелять из дробовика в лица врагов, качая головой в ритм агрессивного повторяющегося саундтрека — это моя атмосфера.
За прошедшие годы я пробовал и другие подходы, например, прототип игры с видом сверху, который рендерится только ASCII-артом:
Хоть мне по-прежнему нравится этот стиль, в итоге готовую игру я так и не сделал, но, возможно, однажды к этому вернусь.
Также я пробовал работать в настоящем 3D, но без особого успеха.
Больше всего мне нравится 2D, особенно когда можно использовать трюки для имитации перспективы. В этом есть собственный шарм без ограничений 3D.
(Мне пришлось поискать эти видео этих прототипов: 1, 2, 3, 4, 5, 6)
[SWAGSHOT]
Изначально [SWAGSHOT] был очень простым проектом. Я хотел создать шутер с видом сверху с прицеливанием мышью и механикой замедления времени. В изначальной идее почти ничего другого не было, и, возможно, именно поэтому я с таким трудом пытался превратить прототип в игру.

Но чего я хотел на самом деле, так это того, чтобы игра выглядела немного иначе. Мне хотелось попробовать немного другую перспективу камеры. По-прежнему в виде сверху и в 2D, но с большим упором на персонажа.
Копировать Hotline Miami смысла нет. Я не могу с ней конкурировать и не хочу создавать игру, которая уже существует.
Когда камера находится прямо над игроком, а всё создано в 2D, то ты довольно сильно ограничен в том, что можно передать графически.
Поэтому я задался вопросом: смогу ли я создать 2D-игру, которая рендерится процедурно (то есть без спрайтов) и выглядит достаточно трёхмерной?
После нескольких часов работы появился первый прототип:
Он довольно сильно отличается от того, как игра выглядит сегодня, но он убедил меня, что эта задача решаема.
Можно вручную анимировать несколько точек в пространстве, соединять их и создавать то, что выглядит, как персонаж-человек.
Кодинг
Создание 3D-скелета
Объясню, как я это делаю.
Первым делом я создаю 3D-скелет персонажа, которого хочу рендерить. Это выглядит так:
export class HumanoidSkeleton extends Skeleton { readonly center = this.addPoint(); readonly leftFoot = this.addPoint(); readonly rightFoot = this.addPoint(); readonly leftKnee = this.addPoint(); readonly rightKnee = this.addPoint(); readonly leftHip = this.addPoint(); readonly rightHip = this.addPoint(); // ... }
Когда у меня есть достаточно точек для приблизительного описания человека, мне нужно разместить их в пространстве.
Для этого нужно немного ручной работы, но это не так сложно, как можно было бы представить:
class TPoseAnimation extends ViewAnimation<HumanoidSkeleton> { apply(skeleton: HumanoidSkeleton, age: number): void { skeleton.center.x = 0; skeleton.center.y = 0; skeleton.center.z = 0; skeleton.pelvis.x = 0; skeleton.pelvis.y = 0; skeleton.pelvis.z = 25; skeleton.waist.x = 0; skeleton.waist.y = 0; skeleton.waist.z = 29; // ... } }
Так мы описали одну анимацию, но их намного больше: анимация ходьбы, держания пистолета, рывка, переката…
Затем можно просто обновлять скелет следующим образом:
const skeleton = new HumanoidSkeleton(); const animation = new TPoseAnimation(); animation.apply(skeleton, 0); // ... при необходимости применяем другие анимации ...
После этого все точки будут размещены в 3D-пространстве.
Рендеринг скелета
Но это просто точки, их нужно ещё отрендерить. Можно создать view, который рендерит эти точки:
export class HumanoidView extends SkeletonRenderingView { skeleton = new HumanoidSkeleton(); // Части leftKnee = this.addPiece(new SphereView(this.skeleton.leftKnee, { color: 0xff0000, radius: 2 })); rightKnee = this.addPiece(new SphereView(this.skeleton.rightKnee, { color: 0xff0000, radius: 2 })); // ... update() { for (const piece of this.pieces) { piece.update(); } } }
Здесь каждая точка скелета рендерится просто в виде «сферы». Сферы — это просто круглый спрайт, отрисовываемый в нужной точке.
Выглядит это примерно так:
export class SphereView { sprite = new PIXI.Sprite(circleTexture); constructor(readonly pointIn3D: Vector3, params: { color: number, radius: number }) {} update() { sprite.position.x = this.pointIn3D.x; sprite.position.y = this.pointIn3D.y; // Обратите внимание, что пока мы игнорируем координату "z" } }
Если отрендерить всё в таком виде, то это будет выглядеть так:

Не особо интересно, правда?
Может, пусть он держит пистолет?

Всё ещё не очень похоже?
Трюк заключается в следующем: вместо того, чтобы брать 3D-точку и рендерить её на плоскости, полностью игнорируя координаты Z, мы учитываем их при вычислении 2D-координат.
Дополним наш класс SphereView:
const PERSPECTIVE = 1; // С этим значением можно поэкспериментировать и подобрать подходящее export class SphereView { sprite = new PIXI.Sprite(circleTexture); constructor(readonly pointIn3D: Vector3, params: { color: number, radius: number }) {} update() { this.sprite.position.x = this.pointIn3D.x; this.sprite.position.y = this.pointIn3D.y + this.pointIn3D.z * PERSPECTIVE; } }
Вот, как теперь выглядит «гуманоид».

Если вы всё ещё не верите, то посмотрите, как это выглядит, когда я использую разные анимации и перемещаюсь:

Форма человека видна, но она выглядит, как движущаяся группа точек.
Давайте соединим эти точки жёлтыми линиями и увеличим сферу, обозначающую голову:
export class HumanoidView extends SkeletonRenderingView { skeleton = new HumanoidSkeleton(); // Части leftTibia = this.addPiece(new LineView(this.skeleton.leftKnee, this.skeleton.leftFoot, { color: 0xffff00, thickness: 2 })); rightTibia = this.addPiece(new LineView(this.skeleton.rightKnee, this.skeleton.rightFoot, { color: 0xffff00, thickness: 2 })); head = this.addPiece(new SphereView(this.skeleton.headCenter, { color: 0xffff00, radius: 2 })); // ... }
И вот результат:

Теперь это уже походит на человека.
Избавимся от точек и добавим цвета:

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

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

Голова становится слишком маленькой, а мушка пистолета — слишком длинной.
Чтобы добиться нужной картинки, приходится экспериментировать.
Тени
Теперь, когда у нас есть красиво анимированный персонаж, похожий на человека, добавим ему тень.
Трюк с тенями заключается в том, что мы рендерим точно такой же вид, но проекцию выполняем немного иначе.
Создадим функцию проецирования:
const PERSPECTIVE = 1; export function projectWorldPoint(vec3: Vector3, vec2: Vector2) { vec2.x = vec3.x; vec2.y = vec3.y - vec3.z * PERSPECTIVE; } export class SphereView { sprite = new PIXI.Sprite(circleTexture); projection = projectWorldPoint; update() { // Как и раньше, но с преобразованием в projectWorldPoint this.projection(this.pointIn3D, this.sprite.position); } }
Теперь для рендеринга тени можно создать другую проекцию:
export function projectShadowPoint(vec3: Vector3, vec2: Vector2) { vec2.x = vec3.x - vec3.z * 0.5; vec2.y = vec3.y + vec3.z * 0.5; }
Добавим персонажу игрока второй HumanoidView, но дадим ему эту новую проекцию:
const playerView = new HumanoidView(); const shadowView = new HumanoidView(); for (const piece of shadowView.pieces) { piece.projection = projectShadowPoint; } function updatePlayerView() { for (const view of [playerView, shadowView]) { // ... Применяем к скелетам анимации ... } }
При рендеринге это будет выглядеть так:

Почти готово. Можно поменять цвета тени или применить цветовой фильтр:
const filter = new PIXI.ColorMatrixFilter(); // Заменяем все цвета на чёрный filter.matrix[0] = 0; filter.matrix[6] = 0; filter.matrix[12] = 0; // Умножаем альфа-канал на 0.3 filter.matrix[18] = 0.3; shadowView.filters = [filter];
Результат:

Также я реализовал несколько простых эффектов. Например, можно имитировать изменение источника освещения:

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