Pull to refresh

Платформер на Three.js

Reading time 6 min
Views 38K
На днях мистер Дуб принял мой первый pull request с примером в Three.js, и на радостях я решился написать о нём хабропост. Если вам вдруг захочется написать трёхмерный платформер на Three.js, но вы не особо представляете себе, как это сделать, этот пример — для вас:



Весь код примера занимает менее 300 строк, щедро разбавленных переносами, разобраться в которых самостоятельно не составит особого труда. Однако, чтобы ещё больше облегчить вашу участь, я напишу немного ниже пару слов о ключевых моментах.

Предыстория


Мы все слышали о людях, способных написать шутер за два дня, но можем ли мы сами стать в один ряд с легендами? Чтобы проверить свои силы, я обложился уроками по Three.js гуглом и начал ваять свой 2х-дневный шедевр. Однако через часика два мне надоело, я закоммитил что там было и пошёл подышать свежим воздухом почитать интернеты. Так повторялось каждый раз, когда я возвращался к этой затее. Проходили дни, потом недели. Но капля продолжала точить камень, и где-то через месяц я таки выточил свой шутер, в котором можно набегать и расстреливать караваны монстров из дробовика.

Всё бросить и пойти расстрелять парочку монстров

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

Так шутер или платформер?


Возможно Вы спросите меня, почему я упорно называю по сути упрощённую версию своего шутера платформером. Мистер Дуб не только спросил, но и заставил меня переименовать пример обратно в шутер перед тем, как принять pull request. И тем не менее, я не считаю этот пример шутером. Как минимум потому, что в нём нельзя ни в кого стрелять. Зато можно бегать и прыгать по трёхмерной платформе. Код примера легко переделать под игру от третьего лица, добавив модель игрока и манипулируя ей вместо камеры, однако мне кажется это не принципиально.

Никто ведь не станет спорить что, например, Марио - таки платформер?


Короче, Склифосовский!


Да, я малость отвлёкся от темы. Итак, чтобы сделать платформер, первым делом мы должны добавить в игровой мир хотя бы одну платформу. Дело это нехитрое, взял 3D модель, экспортнул в свой любимый формат (из числа babylon, ctm, dae, obj, ply, stl, vtk или wrl), загрузил в редактор Three.js, снова экспортнул, и загружай себе на здоровье. Тут есть два варианта:

  1. Сначала загрузить платформу, потом создать сцену и добавить туда платформу
  2. Создать сцену и добавить на неё платформу, а потом загрузить её в фоновом режиме

Первый вариант, ясное дело, идеологически более правильный, однако большинство примеров Three.js (включая этот) не заморачиваются и работают по второму сценарию. Надо отметить, что особой разницы в коде между 1 и 2 как бы и нет — просто в первом случае Вам следует перенести вызов инициализации сцены в обработчик загрузки, а во втором случае надо в основном цикле добавить костыль проверку на состояние платформы, чтобы не улететь далеко вниз, пока она не загрузилась. Я пошёл именно по этому пути, т к правильная реализация первого варианта в случае предзагрузки множества ресурсов всё равно потребует намного больше кода и/или сторонних библиотек.

Посмотреть код загрузки платформы?
function makePlatform( jsonUrl, textureUrl, textureQuality ) {
	var placeholder = new THREE.Object3D();

	var texture = THREE.ImageUtils.loadTexture( textureUrl );
	texture.anisotropy = textureQuality;

	var loader = new THREE.JSONLoader();
	loader.load( jsonUrl, function( geometry ) {

		geometry.computeFaceNormals();

		var platform = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ map : texture }) );

		platform.name = "platform";

		placeholder.add( platform );
	});

	return placeholder;
};


Для ускорения загрузки я удалил нормали из json файла — поэтому Вы видите тут вызов computeFaceNormals — а platform.name устанавливается для упомянутой выше проверки наличия платформы. Без этого всего код мог бы выглядеть так:

loader.load( jsonUrl, function( geometry ) {
	placeholder.add( new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ map : texture }) ) );
});


Ладно, допустим Вы самостоятельно создали сцену, добавили в неё камеру и платформу. Далее, Вы должны заставить игрового персонажа как-то по ней двигаться, не пролетая и не проваливаясь сквозь неё. В этом деле Вам поможет класс Raycaster. Как несложно догадаться из названия, он рассчитывает пересечения заданного луча с выбранной геометрией, В данном случае мы просто направляем луч вниз, и находим ближайшее пересечение с платформой:

Просто, но есть нюансы. Например, нельзя использовать положение персонажа в качестве начала луча — в этом случае Вы не сможете найти пересечение с платформой, если персонаж по какой-либо причине провалится хотя бы на миллиметр, и отправите его в свободное падение вместо того, чтобы вытолкнуть обратно на платформу. Соответственно, начало луча должно находиться сверху, на высоте «птичьего полёта».

В этом месте поподробней, пожалуйста...
var raycaster = new THREE.Raycaster();
raycaster.ray.direction.set( 0, -1, 0 );

var birdsEye = 100;
...
// далее, в цикле
raycaster.ray.origin.copy( playerPosition );
raycaster.ray.origin.y += birdsEye;

var hits = raycaster.intersectObject( platform );


В случае многоэтажной архитектуры уровня эта высота, очевидно, ограничена минимальным расстоянием между платформами по вертикали. Далее, следует тщательно продумать, когда принимать решение о выталкивании провалившегося персонажа наверх. Если не ограничить максимально допустимую глубину «провала», персонаж будет мгновенно телепортироваться на платформу, просто зайдя (или залетев) под неё; если же ограничить её слишком сильно, персонаж сможет легко проходить сквозь платформу при приземлениях после прыжков.

Как это в коде выглядит то?
var kneeDeep = 0.4;
...
// далее, в цикле
// проверяем, сверху ли мы, или хотя бы не глубже чем по колено в платформе
if( ( hits.length > 0 ) && ( hits[0].face.normal.y > 0 ) ) {
	var actualHeight = hits[0].distance - birdsEye;

	// если не слишком глубоко, вытаскиваем персонажа наверх
	if( ( playerVelocity.y <= 0 ) && ( Math.abs( actualHeight ) < kneeDeep ) ) {
		playerPosition.y -= actualHeight;
		playerVelocity.y = 0;
	}
}

Внимательный читатель спросит, зачем тут проверка на playerVelocity.y <= 0? Ответ: для того, чтобы не создать проблем с отрывом от платформы при прыжке.

Теперь, собственно, надо заставить персонажа перемещаться в пространстве, подчиняясь базовым законам школьного курса физики. Положим, что в любой момент у персонажа известна скорость playerVelocity и положение в пространстве playerPosition; тогда расчёт движения персонажа на первый взгляд мог бы выглядеть так (псевдокод):

if( в воздухе ) playerVelocity.y -= gravity * time;
playerPosition += playerVelocity * time;
if( на платформе ) playerVelocity *= damping ^ time;

Увы, и тут всё не так просто. Читателям с нешкольным образованием или ветеранам игростроя этот псевдокод известен под названием «метод Эйлера», а также известно что этот метод — просто отстой. И вот почему (картинка стырена с википедии):

Как видим, расчётная траектория со временем всё сильнее расходится с ожидаемым результатом. Само по себе это обстоятельство не так страшно — страшным его делает одна скромная переменная — time. Представим себе, как изменится эта картинка, если time уменьшить на 10% (пересесть в более быстрый браузер, например):

Как видим, запустив игру в firefox, мы получим одну динамику, а запустив её в chrome — совершенно иную. Поведение персонажа будет «плавать» в зависимости от интенсивности фоновых задач и расположения звёзд. Что же делать?

Выход есть, и довольно простой. Необходимо заменить расчёт с длинным переменным шагом time на несколько расчётов с коротким фиксированным шагом. Например, если два последовательных интервала между отрисовками составляют 19 и 21 мс, мы должны рассчитать 3 шага по 5 мс для первой отрисовки и, добавив оставшиеся 4 мс к 21, рассчитать 5 шагов по 5 мс для второй.

Э-э-э, чего?
где-то так:
var timeStep = 5;
var timeLeft = timeStep + 1;
...
function( dt ) {
	// та самая проверочка ;)
	var platform = scene.getObjectByName( "platform", true );
	if( platform ) {

		timeLeft += dt;

		// несколько шагов фиксированной длины

		dt = 5;
		while( timeLeft >= dt ) {

			// метод Эйлера
			...

			timeLeft -= dt;
		}
	}
}


На этом практически всё, Вам осталось лишь задать параметры движения персонажа (playerVelocity например) в ответ на WASD или что-то подобное.

Ах да, совсем забыл. Полосатые выступы в примере отправляют персонажа в прыжок через всю платформу. Как? Всё очень просто — при приближении персонажа к выступу к playerVelocity добавляется заранее подобранная вертикально-наклонная составляющая, которая гарантированно (благодаря вышеописанной схеме с фиксированным шагом) доставит его в заданную точку, подобно артиллерийскому снаряду. Никаких особых ухищрений не надо — всё уже и так работает.

Теперь точно всё. Читайте моё, пишите своё, критика приветствуется. До связи!

Update: ещё раз ссылки в конце по желаниям ленивых трудящихся:
Tags:
Hubs:
+71
Comments 7
Comments Comments 7

Articles