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

На днях мистер Дуб принял мой первый pull request с примером в Three.js, и на радостях я решился написать о нём хабропост. Если Вам вдруг захочется написать трёхмерный платформер на Three.js, но Вы не особо представляете себе как это сделать, этот пример — для Вас: 9cb86fac75484d9f832dd8b37fbab600.jpgВесь код примера занимает менее 300 строк, щедро разбавленных переносами, разобраться в которых самостоятельно не составит особого труда. Однако, чтобы ещё больше облегчить Вашу участь, я напишу немного ниже пару слов о ключевых моментах.

Предыстория Мы все слышали о людях, способных написать шутер за два дня, но можем ли мы сами стать в один ряд с легендами? Чтобы проверить свои силы, я обложился уроками по Three.js гуглом и начал ваять свой 2х-дневный шедевр. Однако через часика два мне надоело, я закоммитил что там было и пошёл подышать свежим воздухом почитать интернеты. Так повторялось каждый раз, когда я возвращался к этой затее. Проходили дни, потом недели. Но капля продолжала точить камень, и где-то через месяц я таки выточил свой шутер, в котором можно набегать и расстреливать караваны монстров из дробовика.Всё бросить и пойти расстрелять парочку монстров d377f154adc845419d1f16f63081d98c.jpg Теперь было самое время оглянуться на проделанный путь и подумать, что я сделал сносно, а где повернул не туда. Собственно, пример платформера, о котором речь в этой статье — одна из вещей, попавших, как мне кажется, в первую категорию.  Так шутер или платформер? Возможно Вы спросите меня, почему я упорно называю по сути упрощённую версию своего шутера платформером. Мистер Дуб не только спросил, но и заставил меня переименовать пример обратно в шутер перед тем, как принять pull request. И тем не менее, я не считаю этот пример шутером. Как минимум потому, что в нём нельзя ни в кого стрелять. Зато можно бегать и прыгать по трёхмерной платформе. Код примера легко переделать под игру от третьего лица, добавив модель игрока и манипулируя ей вместо камеры, однако мне кажется это не принципиально.Никто ведь не станет спорить что, например, Марио — таки платформер? [embedded content]

Короче, Склифосовский! Да, я малость отвлёкся от темы. Итак, чтобы сделать платформер, первым делом мы должны добавить в игровой мир хотя бы одну платформу. Дело это нехитрое, взял 3D модель, экспортнул в свой любимый формат (из числа babylon, ctm, dae, obj, ply, stl, vtk или wrl), загрузил в редактор Three.js, снова экспортнул, и загружай себе на здоровье. Тут есть два варианта: Сначала загрузить платформу, потом создать сцену и добавить туда платформу Создать сцену и добавить на неё платформу, а потом загрузить её в фоновом режиме Первый вариант, ясное дело, идеологически более правильный, однако большинство примеров 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. Как несложно догадаться из названия, он рассчитывает пересечения заданного луча с выбранной геометрией, В данном случае мы просто направляем луч вниз, и находим ближайшее пересечение с платформой: 91d0669a701345cc9aebeb140829109f.jpg Просто, но есть нюансы. Например, нельзя использовать положение персонажа в качестве начала луча — в этом случае Вы не сможете найти пересечение с платформой, если персонаж по какой-либо причине провалится хотя бы на миллиметр, и отправите его в свободное падение вместо того, чтобы вытолкнуть обратно на платформу. Соответственно, начало луча должно находиться сверху, на высоте «птичьего полёта».В этом месте поподробней, пожалуйста… 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 Теперь, собственно, надо заставить персонажа перемещаться в пространстве, подчиняясь базовым законам школьного курса физики. Положим, что в любой момент у персонажа известна скорость 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 добавляется заранее подобранная вертикально-наклонная составляющая, которая гарантированно (благодаря вышеописанной схеме с фиксированным шагом) доставит его в заданную точку, подобно артиллерийскому снаряду. Никаких особых ухищрений не надо — всё уже и так работает.

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

© Habrahabr.ru