![](https://habrastorage.org/getpro/habr/upload_files/309/24d/f1d/30924df1d3bd020a8038c8a306a8497b.png)
Привет! На связи Кристина, фронтенд-разработчик в отделе рекламных спецпроектов KTS.
Наша команда создает визуально эффектные проекты, цель которых — привлечь внимание пользователей. Мы постоянно экспериментируем с разными технологиями и подходами, и вот, наконец, добрались до 3D-анимаций. Недавно я начала изучать Three.js и хочу поделиться своим опытом.
В сети есть множество статей и уроков по Three.js для начинающих, но большинство из них — это теоретический материал. Я же хочу показать, как создавать 3D-анимации на практике. Эта статья будет первой в цикле о разработке простой игры, в которой нужно строить башню из блоков.
Весь материал будет разбит на 3 статьи:
Часть 1 (эта статья) — познакомимся с основными понятиями в Three.js, научимся анимировать 3D-объекты и напишем логику игры.
Часть 2 — поработаем с текстурами, добавим в игру физику и поговорим об оптимизации.
Часть 3 — перепишем игру на React Fiber и встроим ее в react-приложение.
Для успешного прохождения туториала достаточно владеть JavaScript и ООП. Каждый этап сопровождается ссылкой на codepen с комментариями в коде.
Оглавление
Несколько слов о Three.js
Three.js — это JavaScript-библиотека для работы с 3D-графикой в браузере. Она служит высокоуровневой оберткой над WebGL и скрывает сложность работы с низкоуровневым API, предоставляя удобный набор классов и инструментов для работы с 3D-объектами.
Механика игры
Цель игры — построить максимально высокую башню, укладывая блоки друг на друга. Чтобы разместить блок, игрок должен кликнуть по экрану в нужный момент.
Если новый блок совпадает с предыдущим, башня остается стабильной. Выходящие за границы части отрезаются и падают. Со временем сложность игры растет - доступная площадь для размещения уменьшается. Игра заканчивается, если блок промахивается мимо башни.
![](https://habrastorage.org/getpro/habr/upload_files/c09/163/625/c09163625ca3fcf18d7d3d7e0edac1f7.gif)
Сетап и рендер первого объекта
Вся графика будет отображаться на canvas, встроенном в HTML. Добавим его на страницу:
<canvas id="canvas"></canvas>
Отрисовывать каждый кадр на этом холсте будет WebGLRenderer
. Создадим экземпляр рендерера и передадим ему наш canvas:
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#canvas'),
});
Создаем сцену
Любая 3D-графика в Three.js начинается с создания сцены. Scene
— это контейнер, в котором находятся все 3D-объекты, источники света, камеры и вспомогательные инструменты.
const scene = new THREE.Scene();
Добавляем камеру
Камера — обязательный элемент, без которого сцена не будет отображаться. Представьте, что Scene
— это съемочная площадка, а мы, зрители, видим только то, что попадает в объектив камеры. На сцену можно добавить несколько камер и переключаться между ними.
Есть несколько разновидностей камер. Для нашей задачи подойдет OrthographicCamera. Она отображает объекты без перспективного искажения, сохраняя их размеры неизменными, независимо от расстояния до камеры.
const camera = new THREE.OrthographicCamera();
Создаем первый объект
Попробуем отрисовать зеленый куб.
Как и каждый 3D-объект, он будет состоять из геометрии (формы) и материала (внешнего вида).
Библиотека предоставляет готовые классы для создания примитивных форм, таких как сферы, конусы, плоскости и параллелепипеды.
Создадим куб размером 1×1×1 (единицы измерения разберем чуть позже), покрасим его в лаймовый цвет и добавим на сцену.
// Создаём геометрию (ширина, высота, глубина)
const geometry = new THREE.BoxGeometry(1, 1, 1);
// Создаём материал цвета 'lime'
const material = new THREE.MeshBasicMaterial({ color: 'lime' });
// На основе геометрии и материала создаем 3D-модель
const mesh = new THREE.Mesh(geometry, material);
// Добавляем созданный объект на сцену
scene.add(mesh);
Отрисовываем первый кадр
Вызовем метод render()
у созданного ранее экземпляра WebGLRenderer
, передав в него сцену и камеру:
renderer.render(scene, camera);
Исправляем положение камеры
Сейчас на экране только черный фон. Что пошло не так?
По умолчанию все объекты размещаются в центре сцены с координатами (0, 0, 0). Однако и камера по умолчанию находится там же! Получается, что мы смотрим изнутри куба, поэтому ничего не видим.
Чтобы исправить это, оставим куб на месте, а камеру немного отодвинем. Переместим ее на 1 единицу по каждой оси:
camera.position.set(1, 1, 1);
![](https://habrastorage.org/getpro/habr/upload_files/e55/8d9/9be/e558d99be2d94f3ae3f9f2f05344aad8.png)
Теперь в углу экрана можно разглядеть часть куба. Так получилось, потому что мы сместили камеру, но не развернули ее.
У всех камер есть метод lookAt()
, который позволяет направить камеру на определенную точку в пространстве. Чтобы камера смотрела прямо на центр сцены (где находится куб), вызовем:
camera.lookAt(0, 0, 0);
Теперь фигура полностью появилась в кадре!
![](https://habrastorage.org/getpro/habr/upload_files/d26/e0b/1a7/d26e0b1a74e6a87154e1c1daa602eb2a.png)
Добавляем координатные оси
Чтобы лучше ориентироваться в 3D-пространстве, добавим вспомогательный инструмент — координатные оси (AxesHelper
).
// В параметре можно передать длину осей (по умолчанию 1)
const helper = new THREE.AxesHelper(20);
scene.add(helper);
Теперь на экране появились три цветные оси:
Зелёная — ось Y
Красная — ось X
Синяя — ось Z
Это поможет визуально понять расположение объектов в сцене.
Единицы измерения
Ранее мы задали размеры куба, передавая параметры в BoxGeometry(1, 1, 1)
, и сместили камеру с помощью camera.position.set(1, 1, 1)
. Попробуйте заменить единицы на другие значения и понаблюдайте за тем, как изменится куб и положение камеры.
Так какие же размеры имеет куб и какое расстояние проходит камера при перемещении?
В Three.js размеры объектов и расстояния измеряются не в пикселях, сантиметрах или метрах, а в абстрактных мировых единицах (world units).
Например:
Если вы создаёте комнату, можно считать, что 1 мировая единица = 1 метр.
Если строите город, удобнее задать 1 единицу как 1 километр.
Вы сами определяете, что будет означать единица измерения в вашей сцене, подстраивая масштаб под нужный контекст.
Настраиваем камеру
При создании OrthographicCamera
можно передать базовые параметры, определяющие область обзора:
const camera = new THREE.OrthographicCamera(
-10, // left: насколько далеко камера видит влево
10, // right: насколько далеко камера видит вправо
10, // top: насколько высоко камера видит вверх
-10, // bottom: насколько низко камера видит вниз
0.1, // near: минимальное расстояние видимости
20, // far: максимальное расстояние видимости
);
По умолчанию значения параметров left
, right
, top
и bottom
равны ±1
. Увеличив их до 10
, мы расширили область обзора, поэтому куб стал казаться меньше — теперь мы видим гораздо больше окружающего пространства.
![](https://habrastorage.org/getpro/habr/upload_files/672/b4d/237/672b4d237c3b0102d3674566fefeeeb7.png)
Пятый и шестой параметры — near
и far
— определяют диапазон видимости камеры. Если объект находится ближе near
или дальше far
, он просто не будет отрисовываться.
Исправляем искажения
Самые внимательные могли заметить, что наш куб выглядит сплюснутым. Хотя мы задали размеры (1, 1, 1)
, на экране он кажется прямоугольным параллелепипедом. Все дело в настройках камеры. Область обзора камеры по умолчанию заполняет весь холст (canvas). Чтобы избежать искажений, пропорции области обзора (left
, right
, top
, bottom
) должны соответствовать соотношению сторон холста.
В документации можно найти такой пример:
const camera = new THREE.OrthographicCamera(
width / -2,
width / 2,
height / 2,
height / -2,
);
Здесь width
и height
— ширина и высота области обзора.
Можно записать это другим способом:
/** Соотношение сторон холста */
const aspectRatio = 2 / 1;
/** На сколько далеко камера будет видеть по бокам
(значения сверху и снизу рассчитываются в зависимости от соотношения сторон холста) */
const distance = 2;
const camera = new THREE.OrthographicCamera(
-distance * aspectRatio, // left
distance * aspectRatio, // right
distance, // top
-distance, // bottom
);
С такой формой записи нам будет удобнее работать. Скоро поймете, почему.
Теперь камера настроена правильно, и искажений больше нет. Куб снова выглядит как куб:
![](https://habrastorage.org/getpro/habr/upload_files/deb/0bd/b0a/deb0bdb0a559d0602f2881a84115dbae.png)
Масштабируем canvas
Мы будем создавать игру на весь экран. У renderer есть метод setSize()
, который задает размер холста. Используем его, чтобы растянуть холст на весь экран браузера:
/** Размеры холста */
const sizes = {
width: window.innerWidth,
height: window.innerHeight
};
renderer.setSize(sizes.width, sizes.height);
Для того чтобы избавиться от лишних отступов и скрола, добавим несколько стилей:
Посмотреть CSS-код
* {
margin: 0;
padding: 0;
}
html,
body {
overflow: hidden;
}
Теперь выглядит намного лучше! Осталось сделать так, чтобы при изменении размера окна браузера холст автоматически менял свои размеры. Для этого добавим обработчик события resize
, который будет обновлять размеры холста и перерисовывать сцену:
window.addEventListener('resize', () => {
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
renderer.setSize(sizes.width, sizes.height);
renderer.render(scene, camera);
});
Все работает! Однако теперь при изменении размера окна изображение снова искажается. Чтобы этого избежать, при изменении размера окна нужно также обновлять параметры камеры. Мы передавали настройки обзора камеры в момент ее создания, но эти параметры можно изменить и позже, присвоив новые значения соответствующим полям экземпляра камеры.
/** Обновленное соотношение сторон холста */
const aspectRatio = sizes.width / sizes.height;
// Обновляем параметры камеры
camera.left = distance * -1 * aspectRatio;
camera.right = distance * aspectRatio;
// После изменения любых параметров камеры необходимо вызвать
// метод updateProjectionMatrix(), чтобы изменения вступили в силу
camera.updateProjectionMatrix();
Улучшаем качество отображения
Теперь, если мы посмотрим на наш куб на экране с плотностью пикселей больше 2, то можем заметить артефакты и увидеть мелкую "пилу" на прямых линиях:
![Отображение на экране с плотностью пикселей x1 Отображение на экране с плотностью пикселей x1](https://habrastorage.org/getpro/habr/upload_files/ea1/4a1/a1c/ea14a1a1ce070bd600d0649543356800.png)
![Отображение на экране с высокой плотностью пикселей (retina) Отображение на экране с высокой плотностью пикселей (retina)](https://habrastorage.org/getpro/habr/upload_files/4ad/d06/33c/4add0633c6477feec741b6d1b644e47c.png)
Это можно легко исправить, установив для renderer
нужное значение плотности пикселей:
renderer.setPixelRatio(window.devicePixelRatio);
Важно!
Чем выше значение
devicePixelRatio
, тем больше ресурсов потребуется для рендера. Это может повлиять на производительность, особенно на мобильных устройствах с ограниченными вычислительными возможностями. Почти у всех смартфонов плотность пикселей равна 2 или больше, а мощности мобильных устройств гораздо ниже, чем у десктопов. Поэтому имеет смысл установить ограничение на максимальное значение плотности пикселей:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Теперь всё готово! С полным кодом можно ознакомиться здесь:
Создание блоков башни
Немного подкапотной теории
Теперь перейдем к работе с 3D-объектами. Все объекты в Three.js — это экземпляры класса Mesh. Слово "Mesh" переводится как "сетка", и не случайно она называется именно так. В WebGL объекты представляют собой сетку, состоящую из множества треугольников, соединенных между собой. Чтобы увидеть эту "сетку", можно включить параметр wireframe: true
при создании материала:
const material = new THREE.MeshBasicMaterial({ wireframe: true });
![](https://habrastorage.org/getpro/habr/upload_files/7d5/2b4/e08/7d52b4e08711e69531c4558aaf885c82.png)
Даже такие формы, как сферы, состоят из множества треугольников. При создании геометрии можно самостоятельно задать количество треугольников, из которых будет состоять объект. Чем меньше треугольников, тем более угловатой будет фигура, но тем меньше ресурсов потребуется для ее рендеринга.
Пример: создадим сферу и конус с настроенным количеством треугольников для каждой геометрии:
Посмотреть JS-код
// Создание общего материала для всех объектов
const material = new THREE.MeshBasicMaterial({
color: 'MediumSlateBlue',
wireframe: true,
});
// Создание сферы
const sphereGeometry = new THREE.SphereGeometry(
0.75, // радиус
16, // количество треугольников по горизонтали
8 // количество треугольников по вертикали
);
const sphereMesh = new THREE.Mesh(sphereGeometry, material);
scene.add(sphereMesh);
// Создание конуса
const coneGeometry = new THREE.ConeGeometry(
0.75, // радиус
1.5, // высота
16, // количество треугольников по окружности конуса
1 // количество треугольников по вертикали на одной грани конуса
);
const coneMesh = new THREE.Mesh(coneGeometry, material);
scene.add(coneMesh);
Результат выглядит так:
Первые блоки башни
В нашей игре все блоки будут прямоугольными (BoxGeometry
). Нам не нужно настраивать количество сегментов, потому что по умолчанию каждая грань куба состоит всего из двух треугольников — этого более чем достаточно.
Создаем базовый блок
Пришло время создать первый, неподвижный блок башни. Отойдем от квадратных размеров и сделаем его более плоским:
const geometry = new THREE.BoxGeometry(10, 3, 10);
const material = new THREE.MeshBasicMaterial({ color: 'lime' });
const baseBlock = new THREE.Mesh(geometry, material);
scene.add(baseBlock);
Так как блок стал крупнее, нам нужно скорректировать обзор камеры, чтобы блок полностью попадал в кадр. Мы предусмотрели это заранее, поэтому достаточно изменить переменную viewDistance
, чтобы масштаб обзора автоматически изменился:
/* Увеличиваем обзор */
const viewDistance = 20;
const camera = new THREE.OrthographicCamera(
viewDistance * -1 * aspectRatio,
viewDistance * aspectRatio,
viewDistance,
viewDistance * -1,
);
camera.position.set(1, 1, 1);
camera.lookAt(0, 0, 0);
После увеличения обзора что-то пошло не так:
![](https://habrastorage.org/getpro/habr/upload_files/6d2/9b1/459/6d29b1459a61fa71e5d40f1335096d6e.png)
Корректируем положение камеры
Как мы уже знаем, помимо обзора у камеры есть параметры near
и far
, задающие границы видимости. По умолчанию near = 0.1
, что означает, что объекты ближе этой границы просто не рендерятся.
Проблема в том, что после увеличения размеров блока камера оказалась внутри него, из-за чего часть объекта невидима. Чтобы исправить это, нужно отодвинуть камеру дальше:
camera.position.set(30, 30, 30);
Теперь все отображается правильно:
![](https://habrastorage.org/getpro/habr/upload_files/220/ee6/08e/220ee608e956e86fdc2d4a09cf3aaade.png)
Добавляем второй блок
Создадим второй блок, который будет находиться над базовым и двигаться. Чтобы различать блоки, зададим им разные цвета:
/** Общая геометрия для всех блоков */
const geometry = new THREE.BoxGeometry(10, 3, 10);
// Создание нижнего (базового) блока башни
const baseBlockMaterial = new THREE.MeshBasicMaterial({ color: '#444' });
const baseBlock = new THREE.Mesh(geometry, baseBlockMaterial);
scene.add(baseBlock);
// Создание второго (движущегося) блока
const movingBlockMaterial = new THREE.MeshBasicMaterial({ color: '#f753e6' });
const movingBlock = new THREE.Mesh(geometry, movingBlockMaterial);
// Размещаем блок над базовым
movingBlock.position.y = 3;
// Сдвиг блока в сторону
movingBlock.position.x = -10;
scene.add(movingBlock);
Здесь стоит отметить важный момент:
Оба блока имеют одинаковую форму и размеры, поэтому можно создать один экземпляр геометрии и использовать его для обоих объектов.
А вот материал переиспользовать не получится, так как у блоков разные цвета.
Вот что получается:
Освещение
Сейчас наши объекты выглядят плоскими, потому что они залиты сплошным цветом. Давайте это исправим — добавим источники света, чтобы появились объем и тени.
Добавляем направленный свет (DirectionalLight)
В Three.js есть несколько типов освещения. Начнем с DirectionalLight — это аналог солнечного света, где лучи идут параллельно друг другу. Чтобы добавить источник света, создадим экземпляр соответствующего класса и добавим его на сцену:
const directionalLight = new THREE.DirectionalLight();
scene.add(directionalLight);
Но ничего не изменилось:
![](https://habrastorage.org/getpro/habr/upload_files/ecb/17c/02f/ecb17c02f2eb6ede8eb4e0dccb7acc06.png)
Дело в том, что сейчас мы используем MeshBasicMaterial
, который не реагирует на освещение. Нам нужно заменить его на любой другой тип материала, который чувствителен к свету. Например, на MeshLambertMaterial
— это самый производительный из подходящих для нас материалов:
// ...
const baseBlockMaterial = new THREE.MeshLambertMaterial({ color: '#444' });
// ...
const movingBlockMaterial = new THREE.MeshLambertMaterial({ color: '#f753e6' });
// ...
Теперь изменения стали заметны, но выглядит странно — кажется, что у блоков остались только верхние грани:
![](https://habrastorage.org/getpro/habr/upload_files/d68/92b/372/d6892b37297ef0f6c3b4fcbbcb891951.png)
Корректируем расположение света
Проблема в том, что по умолчанию DirectionalLight
находится в точке (0, 1, 0)
, то есть светит строго сверху вниз и освещает только верхние грани объектов.
Чтобы сделать проблему более очевидной, изменим цвет фона сцены — тогда неосвещенные грани не будут сливаться с черным фоном:
const scene = new THREE.Scene();
scene.background = new THREE.Color('#eee');
![](https://habrastorage.org/getpro/habr/upload_files/8d1/d21/129/8d1d2112986d25bad61fe5cf2234e87d.png)
Важно!
В Three.js используется цветовая модель
LinearSRGBColorSpace
. Чтобы корректно преобразовать привычные HEX, RGB или CSS-цвета изSRGBColorSpace
, можно использоватьTHREE.Color()
.
Теперь изменим положение источника света, чтобы он светил под углом:
directionalLight.position.set(10, 18, 6);
Чтобы во время разработки было проще видеть, где находится DirectionalLight
, добавим DirectionalLightHelper
:
const lightHelper = new THREE.DirectionalLightHelper(directionalLight);
scene.add(lightHelper);
Теперь источник света стал наглядным:
![](https://habrastorage.org/getpro/habr/upload_files/1c7/6b1/970/1c76b197003f83c616ceb472a92f18d9.png)
Настраиваем интенсивность и цвет освещения
По умолчанию в Three.js используется белый цвет и интенсивность = 1. Эти параметры можно задать при создании источника света:
const directionalLight = new THREE.DirectionalLight(
'white', // цвет
2 // интенсивность
);
Или изменить уже после создания:
directionalLight.color = new THREE.Color('pink');
directionalLight.intensity = 2;
Добавляем дополнительный источник света
На сцене можно использовать несколько источников света. Но чем их больше, тем ниже производительность, поэтому важно не злоупотреблять.
Сейчас блоки выглядят темнее, чем их исходный цвет. Чтобы исправить это, добавим AmbientLight — это мягкий всенаправленный свет, который одинаково освещает все грани объектов:
const ambientLight = new THREE.AmbientLight('white', 2);
scene.add(ambientLight);
У AmbientLight
нет позиции и хелпера, потому что он равномерно освещает всю сцену независимо от расположения объектов. Теперь объекты выглядят ярче!
Анимация
Пришло время оживить наши блоки с помощью покадровой анимации!
Сейчас сцена отрисовывается один раз при вызове renderer.render(scene, camera)
и обновляется только при изменении размеров окна. Чтобы заставить блок двигаться, нам нужно изменять его координаты (position
) и заново рендерить изображение.
Запускаем анимацию
Для реализации покадровой анимации будем использовать рекурсию и метод requestAnimationFrame()
. Этот метод вызывает переданный коллбэк перед каждым новым кадром.
Сначала проверим, как это работает, просто выводя сообщение в консоль:
const tick = () => {
// Выводим сообщение в консоль каждый раз перед рендерингом нового кадра
console.log('tick');
requestAnimationFrame(tick);
};
tick();
Далее заменим console.log()
на изменение координаты x
у верхнего блока:
const tick = () => {
movingBlock.position.x += 0.1;
renderer.render(scene, camera);
requestAnimationFrame(tick);
};
tick();
Теперь сцена рендерится на каждом кадре, поэтому можно удалить вызов рендера из функции updateRender()
, которая используется при старте и изменении размеров окна.
Анимация работает:
Корректируем скорость анимации
Однако есть одна проблема. На устройствах с разной частотой кадров (FPS) блоки будут двигаться с разной скоростью: чем выше FPS, тем быстрее они перемещаются. Для нашей игры это критично, ведь чем быстрее движутся блоки, тем выше сложность.
Решение — учитывать время, проходящее между кадрами (deltaTime
). Для этого воспользуемся классом Clock и его методом getElapsedTime()
, который возвращает значение времени, прошедшего с момента запуска.
const clock = new THREE.Clock();
/** Значение таймера на предыдущем кадре */
let prevTime = 0;
const tick = () => {
/** Время с момента запуска */
const elapsedTime = clock.getElapsedTime();
/** Время, прошедшее с предыдущего кадра */
const deltaTime = elapsedTime - prevTime;
/** Обновляем предыдущее время */
prevTime = elapsedTime;
// Корректируем положение движущегося блока
movingBlock.position.x += deltaTime * 5;
renderer.render(scene, camera);
requestAnimationFrame(tick);
};
tick();
Теперь скорость движения одинакова на всех устройствах.
Добавляем обратное движение блока
Сейчас верхний блок движется в одном направлении и со временем исчезает за границей экрана. Сделаем его движение возвратно-поступательным.
Сначала определим диапазон движения MOVING_RANGE
— максимальное расстояние, на которое блок может отдаляться от центра башни.
Также добавим переменную direction
, которая будет отвечать за направление движения:
1
— движение вправо.-1
— движение влево.
В tick()
добавим проверку: если блок выходит за границы MOVING_RANGE
, меняем direction
на противоположный.
/** Направление движения, может быть 1 (вперед) или -1 (назад) */
let direction = 1;
/** Максимальное отклонение блока от центра */
const MOVING_RANGE = 15;
const tick = () => {
// ...
// Меняем направление, если блок выходит за пределы допустимого диапазона
if (movingBlock.position.x >= MOVING_RANGE || movingBlock.position.x <= -MOVING_RANGE) {
direction = -direction;
}
// Двигаем блок в заданном направлении
movingBlock.position.x += deltaTime * 5 * direction;
// ...
};
Добавляем управление камерой
Хотя в этой игре управление камерой не требуется, давайте рассмотрим его применение.
В Three.js есть готовые классы для управления камерой. С полным списком можно ознакомиться в документации в разделе Extras → Controls.
В качестве примера используем OrbitControls. Он позволяет:
Вращать камеру вокруг сцены, зажав левую кнопку мыши.
Перемещать камеру влево/вправо и вверх/вниз, зажав правую кнопку мыши.
Изменять масштаб с помощью колесика мыши.
Все элементы управления камерой не входят в основной пакет Three.js, поэтому их нужно импортировать отдельно:
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
Чтобы управление заработало, достаточно создать экземпляр класса OrbitControls
, передав ему в качестве параметров камеру и DOM-элемент, который будет обрабатывать события мыши. Важно сделать это уже после инициализации камеры.
new OrbitControls(camera, renderer.domElement);
Готово. Теперь мы можем осмотреть сцену со всех сторон и убедиться, что работаем с настоящими 3D объектами.
![](https://habrastorage.org/getpro/habr/upload_files/3cc/fed/152/3ccfed152fc84eaae0996938370f5a61.gif)
Исправляем баг залипания
Если долго наблюдать за нашей зацикленной анимацией, то иногда можно поймать баг. В момент изменения направления движения блок залипает, дергается и не может вдвинуться с места.
![](https://habrastorage.org/getpro/habr/upload_files/7a2/fbb/c65/7a2fbbc656135a368c991c3404e914da.gif)
Почему это происходит?
Из-за пропуска кадров (например, на слабых устройствах) блок может сильно перескочить за пределы MOVING_RANGE
. Когда direction
меняется, блок все еще остается за границей, из-за чего направление снова инвертируется, и движение зацикливается.
Чем выше скорость движения и ниже производительность устройства, тем выше вероятность такого поведения.
Как исправить?
Просто не даем блоку выйти за пределы MOVING_RANGE
в момент смены направления:
if (movingBlock.position.x >= MOVING_RANGE) {
movingBlock.position.x = MOVING_RANGE;
direction = -direction;
}
if (movingBlock.position.x <= -MOVING_RANGE) {
movingBlock.position.x = -MOVING_RANGE;
direction = -direction;
}
Теперь блок больше не зависает, даже если пропущены кадры.
Результат:
Логика игры
Теперь, когда мы разобрались с основами Three.js, пришло время собрать нашу первую версию игры.
В этом разделе будет много кода, скрытого под спойлеры, но мы не станем разбирать его детально, поскольку он больше относится к ООП, а не к работе с Three.js.
Переписываем код на ООП
Сначала вынесем в константы основные настройки игры: размеры блоков, цвета, скорость движения. Это сделает код более гибким и удобным для изменений.
Смотреть код
/** Размеры блоков */
const BASE_BLOCK_SIZE = {
width: 10,
height: 3,
depth: 10,
};
const COLOR = {
background: '#eee',
baseBlock: '#444',
movingBlock: '#f753e6',
placedBlock: '#71ec38',
fallingBlock: '#f8f659',
};
/** Скорость движения блоков */
const SPEED_FACTOR = 10;
/** Максимальное отклонение блока от центра */
const MOVING_RANGE = 15;
Теперь создадим несколько классов, чтобы разбить код на независимые модули.
Stage — съемочная площадка
Класс Stage
отвечает за создание рендерера, сцены, камеры, освещения и обработку изменения размеров окна. Если бы слово Scene не было зарезервировано Three.js, то я назвала бы этот класс именно так.
Смотреть код
class Stage {
/** Размеры холста */
sizes = {
width: window.innerWidth,
height: window.innerHeight
};
/** Сцена Three.js */
scene = new THREE.Scene();
/** Источники света */
ambientLight = new THREE.AmbientLight('white', 2);
directionalLight = new THREE.DirectionalLight('white', 2);
constructor(canvas) {
this.renderer = new THREE.WebGLRenderer({ canvas });
this.camera = new CameraModel(this);
this._onResizeBound = this._onResize.bind(this);
this._init();
}
/** Соотношение сторон холста */
get aspectRatio() {
return this.sizes.width / this.sizes.height;
}
_init() {
// Задаем фон сцены
this.scene.background = new THREE.Color(COLOR.background);
// Добавляем на сцену источники света
this.directionalLight.position.set(10, 18, 6);
this.scene.add(this.directionalLight, this.ambientLight);
// Добавляем на сцену вспомогательные инструменты
// (координатные оси и отображение источника света)
const axesHelper = new THREE.AxesHelper(20);
const lightHelper = new THREE.DirectionalLightHelper(this.directionalLight);
this.scene.add(lightHelper, axesHelper);
// Задаем первоначальные настройки рендерера
this._updateRenderer();
// Подписываемся на изменение размеров окна
window.addEventListener('resize', this._onResizeBound);
}
/** Обновляет размеры холста */
_onResize() {
this.sizes.width = window.innerWidth
this.sizes.height = window.innerHeight
this.camera.update();
this._updateRenderer();
}
/** Обновляет настройки рендерера */
_updateRenderer() {
this.renderer.setSize(this.sizes.width, this.sizes.height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
/** Отрисовывает текущий кадр */
renderFrame() {
this.renderer.render(this.scene, this.camera.instance);
}
/** Отписывается от слушателей */
destroy() {
window.removeEventListener('resize', this._onResizeBound);
}
}
CameraModel — модель для работы с камерой
Камеру лучше вынести в отдельный класс CameraModel
, так как позже у нее появится дополнительная логика перемещения.
Смотреть код
class CameraModel {
/** Обзор камеры */
_viewDistance = 20;
/** Насколько близко видит камера */
_near = 0.1;
/** Насколько далеко видит камера */
_far = 100;
/** Начальное положение камеры */
_initialPosition = new THREE.Vector3(30, 30, 30);
constructor(stage) {
this._stage = stage;
this.instance = new THREE.OrthographicCamera(
this._viewDistance * -1 * this._stage.aspectRatio,
this._viewDistance * this._stage.aspectRatio,
this._viewDistance,
this._viewDistance * -1,
this._near,
this._far,
);
this._init();
}
_init() {
this.instance.position.set(...Object.values(this._initialPosition));
this.instance.lookAt(0, 0, 0);
this._stage.scene.add(this.instance);
}
/** Обновляет обзор камеры */
update() {
this.instance.left = this._viewDistance * -1 * this._stage.aspectRatio;
this.instance.right = this._viewDistance * this._stage.aspectRatio;
this.instance.updateProjectionMatrix()
}
}
BlockModel — базовая модель блока
Основной 3D-объект, с которым мы работаем в данной игре — это прямоугольный параллелепипед. Класс BlockModel
отвечает за базовые настройки этих объектов: размеры, цвет, положение в пространстве.
Позже от BlockModel
мы будем наследовать конкретные блоки: двигающийся блок, падающий блок и другие.
Смотреть код
class BlockModel {
constructor({
width,
height = BASE_BLOCK_SIZE.height,
depth,
initPosition = new THREE.Vector3(0, 0, 0),
color = COLOR.movingBlock,
}) {
this.width = width;
this.height = height;
this.depth = depth;
this.geometry = new THREE.BoxGeometry(this.width, this.height, this.depth);
this.material = new THREE.MeshLambertMaterial({ color });
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.mesh.position.set(...initPosition);
}
}
Для хранения трехмерных координат блока используем
THREE.Vector3()
. Иногда это удобнее, чем три отдельных переменных.
Game — главный класс игры
Создадим класс Game
, который будет управлять анимацией и игровой логикой, а также содержать съемочную площадку и башню.
Смотреть код
class Game {
canvas = document.querySelector('#canvas');
clock = new THREE.Clock();
/** Предыдущее значение таймера */
_prevTimer = 0;
/** Направление движения блока (1 — вперед, -1 — назад) */
_direction = 1;
constructor() {
this.stage = new Stage(this.canvas);
this.baseBlock = new BlockModel({
...BASE_BLOCK_SIZE,
color: COLOR.baseBlock,
});
this.movingBlock = new BlockModel({
...BASE_BLOCK_SIZE,
initPosition: new THREE.Vector3(-10, 3, 0),
color: COLOR.movingBlock,
});
this._init();
}
_init() {
this.stage.scene.add(this.baseBlock.mesh);
this.stage.scene.add(this.movingBlock.mesh);
this.tick();
}
/** Изменить направление движения верхнего блока на противоположное */
_reverseDirection() {
this._direction = this._direction * -1;
}
/** Запускает покадровую анимацию */
tick() {
const elapsedTime = this.clock.getElapsedTime();
const delta = elapsedTime - this._prevTimer;
this._prevTimer = elapsedTime;
// Меняем направление, если блок выходит за пределы допустимого диапазона
if (this.movingBlock.mesh.position.x > MOVING_RANGE) {
// Из-за возможности пропуска кадров есть вероятность залипания движения блока.
// Чтобы избежать этого, устанавливаем максимально допустимые координаты
this.movingBlock.mesh.position.x = MOVING_RANGE;
this._reverseDirection();
}
if (this.movingBlock.mesh.position.x < -MOVING_RANGE) {
this.movingBlock.mesh.position.x = -MOVING_RANGE;
this._reverseDirection();
}
this.movingBlock.mesh.position.x += delta * SPEED_FACTOR * this._direction;
this.stage.renderFrame();
requestAnimationFrame(() => this.tick())
}
}
Сейчас код структурирован и его легко расширять. Визуально ничего не изменилось, но теперь у нас хорошая архитектура для дальнейшей работы.
Добавляем новые сущности
LayerModel
Представляет один слой башни. В каждом слое есть двигающийся блок, который впоследствии разрезается на две части:
fallingBlock
— часть, которая падает вниз;placedBlock
— часть, остающаяся на башне.
Смотреть код
class LayerModel {
/** Блок, который отрезается и падает */
fallingBlock = null;
/** Блок, который остается на башне */
placedBlock = null;
constructor() {}
}
FallingBlockModel
Наследуется от BlockModel
, представляет собой падающий блок. Чтобы визуально выделить его, задаем ему свой цвет.
Смотреть код
class FallingBlockModel extends BlockModel {
constructor({ width, depth }) {
super({
width,
depth,
color: COLOR.fallingBlock,
})
}
}
PlacedBlockModel
Блок, который остается на башне. Тоже наследуется от BlockModel
.
Смотреть код
class PlacedBlockModel extends BlockModel {
constructor({ width, depth }) {
super({
width,
depth,
color: COLOR.placedBlock
});
}
}
Tower
Класс для управления всей башней. Состоит из статичного нижнего блока (baseBlock
) и слоёв (LayerModel
), которые размещаются выше. В конструктор передаем экземпляр Stage
, чтобы внутри башни можно было добавлять на сцену новые блоки.
Смотреть код
class Tower {
/** Массив всех слоев башни */
layers = [];
/** Направление движения верхнего блока (-1 или 1) */
_direction = 1;
/** Нижний статичный блок башни */
baseBlock = new BlockModel({
...BASE_BLOCK_SIZE,
color: COLOR.baseBlock,
});
constructor({ stage }) {
this._stage = stage;
this._init();
}
/** Индекс верхнего активного слоя башни */
get activeLayerIndex() {
return this.layers.length - 1;
}
/** Текущий активный слой */
get activeLayer() {
return this.layers[this.activeLayerIndex];
}
/** Предыдущий слой перед активным */
get prevLayer() {
return this.layers[this.activeLayerIndex - 1];
}
/** Самый верхний блок, лежащий на башне */
get lastPlacedBlock() {
return this.prevLayer?.placedBlock ?? this.baseBlock;
}
_init() {
this._stage.scene.add(this.baseBlock.mesh);
}
/** Изменить направление движения верхнего блока на противоположное */
_reverseDirection() {
this._direction = this._direction * -1;
}
}
В Game добавим инициализацию Tower
и удалим все, что связано с movingBlock
, так как двигающийся блок будет находиться внутри слоя LayerModel
:
Смотреть код
class Game {
// ...
constructor() {
// ...
this.tower = new Tower({
stage: this.stage,
});
}
/** Запускает покадровую анимацию */
tick() {
const elapsedTime = this.clock.getElapsedTime();
const delta = elapsedTime - this._prevTimer;
this._prevTimer = elapsedTime;
this.tower.tick(delta);
this.stage.renderFrame();
requestAnimationFrame(() => this.tick())
}
// ...
}
Добавляем движущиеся блоки
Блоки могут двигаться вдоль оси x
или z
, чередуясь на каждом новом уровне. Для этого добавим в LayerModel
свойство axis
и передадим сцену для добавления/удаления блоков:
Смотреть код
export class LayerModel {
constructor({
scene,
// Ось, вдоль которой движется верхний блок
axis = 'x',
}) {
this._scene = scene;
this.axis = axis;
}
get isAxisX() {
return this.axis === 'x';
}
get isAxisZ() {
return this.axis === 'z';
}
}
При создании слоя сразу же добавим движущийся блок:
Смотреть код
class LayerModel {
// ...
/** Начальное положение двигающегося блока по активной оси координат */
_initMovingBlockPosition = -MOVING_RANGE;
constructor({
scene,
// Ось, вдоль которой движется верхний блок
axis = 'x',
}) {
// ...
/** Двигающийся блок */
this.movingBlock = new BlockModel({
...BASE_BLOCK_SIZE,
initPosition: new THREE.Vector3(
this.isAxisX ? this._initMovingBlockPosition : 0,
BASE_BLOCK_SIZE.height,
this.isAxisZ ? this._initMovingBlockPosition : 0
),
});
this._scene.add(this.movingBlock.mesh);
}
}
При старте игры добавим первый слой над базовым блоком:
Смотреть код
class Tower {
// ...
_init() {
this._stage.scene.add(this.baseBlock.mesh);
this._addFirstLayer();
}
/** Добавляет первый слой над базовым блоком */
_addFirstLayer() {
const layer = new LayerModel({
scene: this._stage.scene,
});
this.layers.push(layer);
}
}
Теперь заставим верхний блок двигаться. Перенесем логику из Game
в Tower
:
Смотреть код
class Tower {
// ...
tick(delta) {
if (!this.activeLayer.movingBlock) {
return;
}
const activeAxisPosition = this.activeLayer.movingBlock.mesh.position[this.activeLayer.axis];
// Меняем направление, если блок выходит за пределы допустимого диапазона
if (activeAxisPosition > MOVING_RANGE) {
// Из-за возможности пропуска кадров есть вероятность залипания движения блока.
// Чтобы избежать этого, устанавливаем максимально допустимые координаты
this.activeLayer.movingBlock.mesh.position[this.activeLayer.axis] = MOVING_RANGE;
this._reverseDirection();
}
if (activeAxisPosition < -MOVING_RANGE) {
this.activeLayer.movingBlock.mesh.position[this.activeLayer.axis] = -MOVING_RANGE;
this._reverseDirection();
}
// Анимация верхнего двигающегося блока
this.activeLayer.movingBlock.mesh.position[this.activeLayer.axis] += delta * SPEED_FACTOR * this._direction;
}
class Game {
// ...
/** Запускает покадровую анимацию */
tick() {
// ...
this.tower.tick(delta);
// ...
}
}
Добавляем новые слои при клике
Теперь будем добавлять новый слой при клике на экран. Для этого в LayerModel
добавим параметр y
, определяющий его высоту:
Смотреть код
class LayerModel {
// ...
constructor({
scene,
// Ось, вдоль которой движется верхний блок
axis = 'x',
y = 0,
}) {
// ...
/** Двигающийся блок */
this.movingBlock = new BlockModel({
...BASE_BLOCK_SIZE,
initPosition: new THREE.Vector3(
this.isAxisX ? this._initMovingBlockPosition : 0,
y,
this.isAxisZ ? this._initMovingBlockPosition : 0
),
});
// ...
}
// ...
}
На каждом новом слое y
увеличивается:
Смотреть код
class Tower {
// ...
_addFirstLayer() {
const layer = new LayerModel({
scene: this._stage.scene,
y: BASE_BLOCK_SIZE.height,
});
this.layers.push(layer);
}
/** Добавляет новый слой башни */
_addLayer() {
const layer = new LayerModel({
scene: this._stage.scene,
axis: this.activeLayer.isAxisX ? 'z' : 'x',
y: (this.activeLayerIndex + 2) * BASE_BLOCK_SIZE.height,
});
this.layers.push(layer);
}
}
Навешиваем на холст обработчик события клика:
Смотреть код
class Game {
// ...
_init() {
this.tick();
this.canvas.addEventListener('click', () => {
this.tower._addLayer();
});
}
}
Теперь игра обретает более осмысленную форму, и в ней уже можно покликать мышкой!
Опускаем блок на башню
Когда игрок кликает по экрану, двигающийся блок (movingBlock
) частично перекрывает самый верхний слой башни и разрезается на две части: одна остается на башне (placedBlock
), а другая падает вниз (fallingBlock
). Если же перекрытия нет, игра завершается проигрышем.
В LayerModel
добавим свойство overlap
для хранения величины перекрытия и метод cut()
, который будет определять исход каждого хода:
Смотреть код
class LayerModel {
/** Величина, на которую верхний блок перекрывает нижний */
overlap = 0;
// ...
/**
* Разрезает двигающийся блок на placedBlock, который остается на башне,
* и на fallingBlock, который падает вниз
*
* @param prevPlacedBlock Самый верхний блок, который лежит на башне
*
* @returns {boolean}
* false - Весь двигающийся блок улетел вниз, игра проиграна
* true - Часть двигающегося блока осталась на башне, а часть упала, игра продолжается
*/
cut(prevPlacedBlock) {
// Рассчитываем величину перекрытия
this.overlap = this.isAxisX
? this.movingBlock.width - Math.abs(this.movingBlock.mesh.position.x - prevPlacedBlock.mesh.position.x)
: this.movingBlock.depth - Math.abs(this.movingBlock.mesh.position.z - prevPlacedBlock.mesh.position.z);
// Если двигающийся блок не перекрывает верхний блок башни, засчитывается проигрыш
if (this.overlap <= 0) {
return false;
}
return true;
}
}
Добавляем обработку проигрыша
При создании экземпляра Tower
теперь передаем не только stage
, но и коллбэк onFinish
, который отвечает за обработку проигрыша. Также добавим метод .place()
, который опускает двигающийся блок на башню:
Смотреть код
class Tower {
// ...
constructor({ stage, onFinish }) {
// Добавляем метод, который отвечает за действия во время проигрыша
this._finish = onFinish;
// ...
}
// ...
/** Опускает двигающийся блок на башню */
place() {
const result = this.activeLayer.cut(this.lastPlacedBlock);
// Если успешно положили двигающийся блок на башню, добавляем новый слой
if (result) {
this._addLayer();
return;
}
// Если промахнулись мимо башни, игра завершается
this._finish();
}
}
Обрабатываем клик игрока
В Game
добавим обработчик клика по холсту, который вызывает метод .place()
:
Смотреть код
class Game {
// ...
constructor() {
// ...
this.tower = new Tower({
stage: this.stage,
// Если юзер проиграл, пока просто выводим сообщение в консоль
onFinish: () => console.log('finish'),
});
}
// ...
_init() {
this.tick();
// При клике по холсту, кладем двигающийся блок на башню
this.canvas.addEventListener('click', () => {
this.tower.place();
});
}
}
Рассчитываем размеры и координаты placedBlock
Теперь вычислим размеры и координаты блоков, которые остаются на башне. Для этого понадобится новое свойство слоя isCuttingBehind
, которое определяет, где обрезается двигающийся блок (спереди или сзади):
Смотреть код
// Вычисление размеров и положения placedBlock вынесены в утилитарные функции,
// чтобы не загружать конструктор логикой вычисления
/** Рассчитывает сдвиг placedBlock относительно положения двигающегося блока */
const calcPlacedBlockShift = (sideSize, layer) => {
const shift = (sideSize - layer.overlap) / 2;
const sign = layer.isCuttingBehind ? 1 : -1;
return shift * sign;
};
/** Рассчитывает размеры и координаты блока, остающегося на башне */
const calcPlacedBlockProps = (layer) => {
const width = layer.isAxisX ? layer.overlap : layer.movingBlock.width;
const depth = layer.isAxisZ ? layer.overlap : layer.movingBlock.depth;
const x = layer.isAxisX
? layer.movingBlock.mesh.position.x + calcPlacedBlockShift(layer.movingBlock.width, layer)
: layer.movingBlock.mesh.position.x;
const z = layer.isAxisZ
? layer.movingBlock.mesh.position.z + calcPlacedBlockShift(layer.movingBlock.depth, layer)
: layer.movingBlock.mesh.position.z;
return {
width,
depth,
initPosition: new THREE.Vector3(x, layer.movingBlock.mesh.position.y, z),
color: COLOR.placedBlock,
};
};
class PlacedBlockModel extends BlockModel {
constructor(layer) {
const props = calcPlacedBlockProps(layer);
super(props);
}
}
Обновляем LayerModel
Теперь при создании нового слоя будем передавать размеры и координаты двигающегося блока. Также добавим вспомогательные методы removeMovingBlock()
и createPlacedBlock()
, которые удаляют двигающийся блок и создают блок, лежащий на башне. Эти методы вызываем внутри cut()
и в нем же вычисляем isCuttingBehind
:
Смотреть код
class LayerModel {
// ...
/** Если true, блок не доехал и обрезается сзади. Если false, обрезается спереди */
isCuttingBehind = false;
// Теперь в конструктор передаем ширину, глубину
// и координаты нового двигающегося блока
constructor({
scene,
// Ось, вдоль которой движется верхний блок
axis = 'x',
// Размеры двигающегося блока
width,
depth,
// Координаты двигающегося блока
x = 0,
y = 0,
z = 0,
}) {
// ...
/** Двигающийся блок */
this.movingBlock = new BlockModel({
width,
depth,
initPosition: new THREE.Vector3(
this.isAxisX ? this._initMovingBlockPosition : x,
y,
this.isAxisZ ? this._initMovingBlockPosition : z
),
});
}
// ...
/** Удалить двигающийся блок */
_removeMovingBlock() {
this._scene.remove(this.movingBlock?.mesh);
this.movingBlock = null;
}
/** Добавить placedBlock */
_createPlacedBlock() {
this.placedBlock = new PlacedBlockModel(this);
this._scene.add(this.placedBlock.mesh);
}
cut(prevPlacedBlock) {
// ...
// Если двигающийся блок не перекрывает верхний блок башни, засчитывается проигрыш
if (this.overlap <= 0) {
this._removeMovingBlock();
return false;
}
// Определяем, с какой стороны обрезается двигающийся блок
this.isCuttingBehind = this.movingBlock.mesh.position[this.axis] - prevPlacedBlock.mesh.position[this.axis] < 0;
// Заменяем двигающийся блок на статичный блок, лежащий на башне
this._createPlacedBlock();
this._removeMovingBlock();
return true;
};
}
Обновляем Tower
При добавлении нового слоя теперь передаем в него актуальные размеры и координаты:
Смотреть код
export class Tower {
// ...
/** Добавляет первый слой над базовым блоком */
_addFirstLayer() {
const layer = new LayerModel({
scene: this._stage.scene,
// Задаем дефолтные размеры двигающегося блока для первого слоя
width: BASE_BLOCK_SIZE.width,
depth: BASE_BLOCK_SIZE.depth,
y: BASE_BLOCK_SIZE.height,
});
this.layers.push(layer);
}
/** Добавляет новый слой башни */
_addLayer() {
const layer = new LayerModel({
scene: this._stage.scene,
axis: this.activeLayer.isAxisX ? 'z' : 'x',
// Размеры двигающегося блока в новом слое совпадают
// с размерами самого верхнего блока башни
width: this.activeLayer.placedBlock.width,
depth: this.activeLayer.placedBlock.depth,
// x и z координаты совпадают с координатами верхнего слоя башни
x: this.activeLayer.placedBlock.mesh.position.x,
y: (this.activeLayerIndex + 2) * BASE_BLOCK_SIZE.height,
z: this.activeLayer.placedBlock.mesh.position.z,
});
this.layers.push(layer);
}
// ...
}
Теперь при клике по экрану на башне появляется новый фиолетовый блок.
Отрезаем падающий блок
Теперь доработаем класс FallingBlockModel
и рассчитаем размеры и координаты падающего фрагмента блока:
Смотреть код
// По аналогии с PlacedBlock вынесем все вычисления во вспомогательные функции,
// а в конструкторе класса передадим в super() вычесленные свойства падающего блока
/** Рассчитывает размеры и координаты блока, который весь падает вниз */
const getLastBlockProps = (layer) => {
return {
width: layer.movingBlock.width,
depth: layer.movingBlock.depth,
initPosition: layer.movingBlock.mesh.position,
};
}
/** Рассчитывает координаты по оси движения */
const calcFallingPosition = (layer, axisPosition) => {
const shift = axisPosition + layer.overlap / 2;
return layer.isCuttingBehind ? shift - layer.overlap : shift;
};
/** Рассчитывает размеры и координаты той части блока, которая отрезается и падает вниз */
const getFallingBlockProps = (layer) => {
const props = {
width: layer.isAxisX
? layer.movingBlock.width - layer.overlap
: layer.movingBlock.width,
depth: layer.isAxisZ
? layer.movingBlock.depth - layer.overlap
: layer.movingBlock.depth,
};
const x = layer.isAxisX
? calcFallingPosition(layer, layer.movingBlock.mesh.position.x)
: layer.movingBlock.mesh.position.x;
const z = layer.isAxisZ
? calcFallingPosition(layer, layer.movingBlock.mesh.position.z)
: layer.movingBlock.mesh.position.z;
props.initPosition = new THREE.Vector3(
x,
layer.movingBlock.mesh.position.y,
z
);
return props;
};
class FallingBlockModel extends BlockModel {
constructor({
layer,
// Является ли падающий блок последним в игре
// (игрок промахнулся мимо башни и весь двигающийся блок падает вниз)
isLastFallingBlock
}) {
const props = isLastFallingBlock
? getLastBlockProps(layer)
: getFallingBlockProps(layer);
props.color = COLOR.fallingBlock;
super(props)
}
}
Далее обновим метод .cut()
, чтобы при разрезании двигающегося блока добавлялся не только placedBlock
, но и fallingBlock
:
Смотреть код
class LayerModel {
// ...
/** Создает отрезанный падающий блок */
_createFallingBlock = (isLastFallingBlock) => {
this.fallingBlock = new FallingBlockModel({ layer: this, isLastFallingBlock });
this._scene.add(this.fallingBlock.mesh);
};
cut(prevPlacedBlock) {
// ...
// Если двигающийся блок не перекрывает верхний блок башни, засчитывается проигрыш
if (this.overlap <= 0) {
// Добавляем на сцену падающий блок
this._createFallingBlock(true);
this._removeMovingBlock();
return false;
}
// ...
this._createPlacedBlock();
// Добавляем на сцену падающий блок
this._createFallingBlock();
this._removeMovingBlock();
return true;
}
}
Теперь при клике появляется желтый отрезанный кусок, но он пока остается в воздухе. Добавим анимацию падения:
Смотреть код
class FallingBlockModel extends BlockModel {
// ...
/** Анимирует падения блока */
tick(delta) {
this.mesh.position.y -= delta * 25;
}
}
export class Tower {
// ...
tick(delta) {
// Анимация всех падающих блоков
this.layers.forEach((layer) => layer.fallingBlock?.tick(delta));
// ...
}
}
Готово! Теперь обрезанный фрагмент плавно падает вниз:
Заставляем камеру следить за башней
Сейчас камера остается неподвижной, и когда башня становится слишком высокой, ее верхушка уходит за пределы экрана. Чтобы этого избежать, синхронизируем положение камеры с верхним блоком башни.
Один из способов — обновлять координаты камеры при каждом кадре, как мы делаем с анимированными блоками. Однако мы воспользуемся GSAP — удобной JavaScript-библиотекой для создания анимаций. GSAP позволяет плавно изменять значения параметров объекта от текущего состояния к новому за заданный промежуток времени. Это решение не только упростит код, но и сделает анимацию более плавной.
Смотреть код
// ...
import gsap from 'gsap';
class CameraModel {
// ...
/** Синхронизирует положение камеры с верхним блоком башни */
syncPosition({ x, y, z }) {
gsap.to(this.instance.position, {
ease: 'expo.out',
duration: 1,
x: this._initialPosition.x + x,
y: this._initialPosition.y + y,
z: this._initialPosition.z + z,
});
}
}
class Tower {
// ...
/** Добавляет новый слой башни */
_addLayer() {
// ...
// Синхронизируем камеру, чтобы она "смотрела" на верхний блок башни
this._stage.camera.syncPosition(
this.lastPlacedBlock.mesh.position
);
}
}
Результат:
Добавляем элементы интерфейса
Финальные штрихи! Добавим в HTML плашку с кнопкой рестарта, которая будет появляться при проигрыше и позволит перезапустить игру:
Смотреть код
<canvas id="canvas"></canvas>
<div class="board">
<p>Game Over</p>
<button id="button">Restart</button>
</div>
Стилизуем ее:
Смотреть код
@import url('https://fonts.googleapis.com/css2?family=Funnel+Display:wght@300..800&family=Mulish:ital,wght@0,200..1000;1,200..1000&display=swap');
:root {
--green: #32ff00;
--white: #fff;
--overlay: rgba(0, 0, 0, 0.7);
}
* {
margin: 0;
padding: 0;
font-family: "Mulish", serif;
}
html,
body {
overflow: hidden;
}
.board {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
min-width: 200px;
min-height: 150px;
padding: 30px 50px;
border-radius: 10px;
background: var(--overlay);
backdrop-filter: blur(10px);
color: var(--white);
}
.board p {
font-size: 24px;
font-weight: 600;
margin-bottom: .5em;
}
.board button {
display: block;
width: 100%;
padding: .3em .5em .4em;
font-size: 16px;
font-weight: 400;
letter-spacing: .05em;
border: solid 1px var(--white);
border-radius: 5px;
color: var(--white);
background: none;
cursor: pointer;
}
.board button:hover {
color: var(--green);
border-color: var(--green);
}
Теперь добавим методы сброса для всех ключевых классов, чтобы корректно перезапускать игру.
В CameraModel
добавим resetPosition()
, который возвращает камеру в исходное положение:
Смотреть код
class CameraModel {
// ...
_init() {
// Переносим установку первоначальных координат в метод,
//чтобы можно было сбросить положение извне класса
this.resetPosition();
this.instance.lookAt(0, 0, 0);
this._stage.scene.add(this.instance);
}
// ...
/** Сбрасывает положения камеры до первоначального */
resetPosition() {
this.instance.position.set(...Object.values(this._initialPosition));
}
}
В LayerModel
реализуем clear()
, удаляющий все блоки внутри слоя:
Смотреть код
class LayerModel {
// ...
/** Очищает слой от всех блоков */
clear() {
this._removeMovingBlock();
this._scene.remove(
this.placedBlock?.mesh,
this.fallingBlock?.mesh
);
this.placedBlock = null;
this.fallingBlock = null;
}
}
В Tower
создадим метод reset()
, который сбрасывает направление движения верхнего блока и очищает все слои башни:
Смотреть код
class Tower {
// ...
/** Сбрасывает башню до первоначального состояния */
reset() {
this._direction = 1;
this.layers.forEach((layer) => layer.clear());
this.layers = [];
this._addFirstLayer();
}
}
В Game
добавим флаг _isGameOver
, чтобы запретить клики по холсту после завершения игры. Также реализуем метод end()
, который вызывается при проигрыше и отображает плашку с кнопкой рестарта, и метод restart()
, полностью перезапускающий игру:
Смотреть код
class Game {
// Добавляем элементы интерфейса:
// плашку с сообщением о проигрыше и кнопку рестарта
board = document.body.querySelector('.board');
restartButton = document.body.querySelector('#button');
/** Флаг окончания игры */
_isGameOver = false;
// ...
constructor() {
// ...
this.tower = new Tower({
stage: this.stage,
// В момент проигрыша, вызываем метод end()
onFinish: () => this.end(),
});
}
_init() {
// ...
this.canvas.addEventListener('click', () => {
// Добавляем новое условие
// Положить блок на башню можно было только если игра не завершилась
if (this._isGameOver) {
return;
}
this.tower.place();
});
// Навешиваем обработчик события на кнопку рестарта
this.restartButton.addEventListener('click', () => this.restart());
}
// ...
/** Завершает игру */
end() {
// Переключаем флаг завершения игры
this._isGameOver = true;
// Показываем плашку с кнопкой рестарта
this.board.style.display = 'flex';
}
/** Перезапускает игру заново */
restart() {
// Сбрасываем положение камеры до первоначального
this.stage.camera.resetPosition();
// Сбрасываем башню до первоначального состояния
this.tower.reset();
// Переключаем флаг завершения игры
this._isGameOver = false;
// Скрываем плашку
this.board.style.display = 'none';
}
}
Готово! Посмотреть результат можно здесь:
Итоги
В этой статье мы разобрали основные концепции Three.js и шаг за шагом реализовали механику простой аркады. Теперь у нас есть базовая, но уже функциональная игра!
В следующей статье мы добавим физику, чтобы падающие блоки не проскальзывали сквозь другие, а реалистично сталкивались с ними. Добавим текстуры и поработаем над оптимизацией.
А чтобы ожидание продолжения было не таким томительным, предлагаю вам почитать другие статьи в нашем блоге. Мы с коллегами регулярно рассказываем о том, как разнообразить механики в ваших веб-проектах, как фронтендеру работать с определенными технологиями и как развиваться в веб-разработке:
Дополненная реальность в Web: какие библиотеки актуальны в 2025?
Как сделать анимацию разными способами: CSS, WebP, Canvas, Lottie, Spine и секвенции
Летающий Санта и танцующие снегири: опыт реализации и оптимизации CSS-анимации
Next.js + Playwright. Как мы начали писать автотесты и что из этого вышло
Искусство сетапа: автоматизируем подготовку стека под новые проекты
Не JavaScript’ом единым: как фронтенд-разработчику затащить на собесе
Спасибо, что дочитали до конца, и удачи в работе над вашими проектами!
Комментарии (2)
ArtyomOchkin
14.02.2025 18:43Большое спасибо, очень полезный и интересный мануал! Как раз сейчас экспериментирую с 3d и пытаюсь в нём разобраться получше.
CBET_TbMbI
Особо большое уважение тем писателям, которые не просто дают готовый код "как надо", но и рассмативают типичные ошибки и показывают, к чему они приводят. На неправильных примерах узнаёшь намного больше, чем на правильных.