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

от автора

На днях мистер Дуб принял мой первый 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; playerPosition += playerVelocity * time; if( на платформе ) playerVelocity *= damping; 

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

Как видим, рассчётная траектория со временем всё сильнее расходится с ожидаемым результатом. Само по себе это обстоятельство не так страшно — страшным его делает одна скромная переменная — 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 добавляется заранее подобранная вертикально-наклонная составляющая, которая гарантированно (благодаря вышеописанной схеме с фиксированным шагом) доставит его в заданную точку, подобно артиллерийскому снаряду. Никаких особых ухищрений не надо — всё уже и так работает.

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

ссылка на оригинал статьи http://habrahabr.ru/post/231987/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *