Этот проект — для тех, кто уже разбирается в JavaScript. Сейчас попробуем сделать простую трёхмерную игру и поработаем сразу с двумя движками: для отображения на экране и реалистичной физики.
⭐ Если это звучит пока сложновато — посмотрите, как устроен простой пинг-понг на JavaScript.
❤️ Можно сразу поиграть, чтобы понять, как это будет выглядеть: https://mihailmaximov.ru/projects/stack/index.html
Что делаем
Сегодня сделаем игру «Собери пирамиду»:
- У нас есть основание пирамиды и блоки, которые вылетают справа или слева.
- Как только блок пролетает над верхней частью пирамиды, мы нажимаем пробел или кнопку мыши, и блок останавливается.
- Та часть блока, которая вылезла за границу верхушки пирамиды, отсекается и падает вниз. Соответственно, пирамида становится выше на один ярус, но может уменьшиться в размерах.
- Новый блок, который появляется, по размерам совпадает с размерами верхушки пирамиды. Чем меньше верхушка, тем меньшего размера появится новый блок.
- С каждым поставленным блоком увеличивается счётчик очков.
- Если игрок останавливает новый блок и промахивается мимо верхушки — игра останавливается. Если блок просто вылетает за границы сцены — тоже останавливается.
- В игре нельзя выиграть, поэтому задача игрока — набрать максимальное количество очков.
Three.js и Cannon.js
В игре мы будем использовать два движка — Three.js и Cannon.js, чтобы не писать всё с нуля, а использовать уже готовые разработки.
Three.js — движок трёхмерной графики, основанный на WebGL. Мы уже использовали WebGL в проекте с красивой Луной, теперь выйдем на новый уровень. На всякий случай напомним, как работают движки трёхмерной графики и что такое сцена.
Сцена — это то, что видит зритель после запуска кода. Обычно сцена состоит:
- из камеры — с какой точки зритель увидит всю картину;
- источника света — у него есть яркость, направление и удалённость от кадра;
- объектов, которые видит зритель.
Задача движка — обработать свет от источника так, чтобы он правильно всё осветил, тени упали куда нужно и чтобы всё двигалось так, как задумано. Если мы сменим положение камеры, то движок всё пересчитает на лету.
Cannon.js — это физический движок, который позволяет добавлять реалистичное поведение объектов при взаимодействии друг с другом. Например, с ним можно настроить гравитацию, падения, отскоки друг от друга, столкновения и прочие штуки.
Cannon.js ничего не знает про то, как выглядят предметы в сцене, а Three.js — про то, как предметы взаимодействуют друг с другом, поэтому мы будем передавать координаты объектов из одного движка в другой. Так мы получим видимый объект, который ведёт себя точно так же, как если бы он находится в реальном мире.
Подключаются эти движки, как обычно, в конце веб-страницы:
<!-- подключаем Three.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r124/three.min.js'></script>
<!-- подключаем Cannon.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js'></script>
Готовим страницу
На странице у нас будет три блока: с инструкцией о том, как играть, с результатами игры и блок с очками. Всё остальное мы сделаем через скрипт.
Также сразу подключим свои стили из файла style.css и свой скрипт script.js. Ещё добавим два движка, о которых говорили выше.
Задача страницы на этом этапе — просто хранить всю текстовую информацию, чтобы, когда она понадобилась, её можно было сразу вывести на экран.
<!DOCTYPE html>
<html lang="ru" >
<head>
<meta charset="UTF-8">
<title>Пирамида</title>
<!-- подключаем стили -->
<link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- блок с инструкциями -->
<div id="instructions">
<div class="content">
<p>Ставьте блоки друг на друга.</p>
<p>Щёлкните мышкой или нажмите пробел, когда блок будет над пирамидой. Сможете дойти до синих блоков?</p>
<p>Щёлкните мышкой или нажмите пробел, чтобы начать игру.</p>
</div>
</div>
<!-- блок с результатами игры -->
<div id="results">
<div class="content">
<p>Вы промахнулись</p>
<p>Для перезапуска игры нажмите R</p>
</div>
</div>
<!-- блок с очками -->
<div id="score">0</div>
<!-- подключаем Three.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r124/three.min.js'></script>
<!-- подключаем Cannon.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js'></script>
<!-- подключаем наш скрипт -->
<script src="./script.js"></script>
</body>
</html>
Наполняем стили
За графику у нас отвечает отдельный движок, поэтому задача стилей — просто сделать красивый вывод текста на экран и временно скрыть лишнее. На старте нам нужно показать правила и вывести счётчик очков, а блок с результатами скрыть.
Создадим файл style.css и заполним его стилями:
/* общие настройки страницы */
body {
/* убираем отступы */
margin: 0;
/* цвет шрифта и сам шрифт */
color: white;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
/* выводим сообщение о конце игры и инструкцию по центру своих блоков */
#results, #instructions {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
/* затемняем фон */
background-color: rgba(20, 20, 20, 0.75);
}
/* сообщение о конце игры не показываем на старте */
#results {
display: none;
cursor: default;
}
/* отступы в текстах */
#results .content,
#instructions .content {
max-width: 300px;
padding: 50px;
border-radius: 20px;
}
Стало лучше, но нет счётчика очков. Поместим его в правый верхний угол и сделаем заметнее основного текста:
/* настройка вывода набранных очков в правом верхнем углу */
#score {
position: absolute;
color: white;
font-size: 3em;
font-weight: bold;
top: 30px;
right: 30px;
}
Теперь всё готово, можно переходить к скрипту.
Создаём скрипт
Первым делом заводим и заполняем стартовыми значениями все переменные, которые нам понадобятся для игры. На внешнем виде страницы это никак не отразится, но нам будет проще писать код дальше. Ещё сделаем хитрость: сразу перехватим фокус на окно браузера, чтобы пользователь мог сразу нажать пробел и начать игру:
// сразу переводим фокус на окно, чтобы можно было начать игру
window.focus();
// объявляем переменные ThreeJS — камеру, сцену и рендер
let camera, scene, renderer;
// и сразу объявляем «мир» CannonJs
let world;
// время последней анимации
let lastTime;
// тут храним части пирамиды, которые уже стоят друг на друге
let stack;
// падающие части деталей, которые не поместились в границы пирамиды
let overhangs;
// высота каждой детали
const boxHeight = 1;
// исходная высота и ширина каждой детали
const originalBoxSize = 3;
// переменные для игры на автопилоте и конца игры
let autopilot;
let gameEnded;
// точность, с которой алгоритм будет играть на заставке
let robotPrecision;
// получаем доступ на странице к разделам с очками, правилами и результатами
const scoreElement = document.getElementById("score");
const instructionsElement = document.getElementById("instructions");
const resultsElement = document.getElementById("results");
Теперь сделаем две базовые вещи, на которых будет строиться вся наша игра: добавление нового игрового слоя и отрисовка блока на этом уровне.
Игровой слой — это уровень, или виртуальная высота пирамиды, на котором происходят основные события. На этом уровне движется текущий блок и игрок принимает решение, когда его останавливать. Задача этой функции — сгенерировать новую высоту и направление движения.
Дальше вступает в игру вторая функция — создание и отрисовка игрового блока. Общая логика такая:
- Мы знаем, на каком уровне нам нужен блок.
- Создаём его в трёхмерном виде с помощью движка Three.js.
- Добавляем его в сцену, чтобы блок мог появиться на экране.
- Создаём виртуальный блок в физическом мире игры, который по размерам полностью совпадает с тем, что мы только что отрисовали. Это делаем с помощью Cannon.js.
- Рассчитываем его размеры и вес.
- Помещаем этот блок в физический мир движка
Вот как это выглядит в коде:
// добавление нового слоя
function addLayer(x, z, width, depth, direction) {
// получаем высоту, на которой будем работать
const y = boxHeight * stack.length;
// создаём новый слой на этой высоте
const layer = generateBox(x, y, z, width, depth, false);
// устанавливаем направление движения
layer.direction = direction;
// добавляем слой в массив с пирамидой
stack.push(layer);
}
// отрисовка игрового блока
function generateBox(x, y, z, width, depth, falls) {
// используем ThreeJS для создания коробки
const geometry = new THREE.BoxGeometry(width, boxHeight, depth);
// создаём цвет, материал и полигональную сетку, которая создаст нам коробку
const color = new THREE.Color(`hsl(${30 + stack.length * 4}, 100%, 50%)`);
const material = new THREE.MeshLambertMaterial({ color });
const mesh = new THREE.Mesh(geometry, material);
// устанавливаем координаты новой полигональной сетки
mesh.position.set(x, y, z);
// добавляем сетку-коробку в сцену
scene.add(mesh);
// применяем физику CannonJS
// создаём новый виртуальный блок, который совпадает с отрисованной на предыдущем этапе
const shape = new CANNON.Box(
new CANNON.Vec3(width / 2, boxHeight / 2, depth / 2)
);
// смотрим по входным параметрам, падает такой блок или нет
let mass = falls ? 5 : 0;
// уменьшаем массу блока пропорционально его размерам
mass *= width / originalBoxSize;
mass *= depth / originalBoxSize;
// создаём новую фигуру на основе блока
const body = new CANNON.Body({ mass, shape });
// помещаем его в нужное место
body.position.set(x, y, z);
// добавляем фигуру в физический мир
world.addBody(body);
// возвращаем полигональные сетки и физические объекты, которые у нас получились после создания нового игрового блока
return {
threejs: mesh,
cannonjs: body,
width,
depth
};
}
Обрезка блоков
Вторая задача, которую нам нужно решить в игре, — обрезка свисающих частей блока после того, как игрок установил очередной элемент. Для этого мы разделим эту задачу на две функции: добавление свеса и формирование новой верхушки.
Добавление свеса работает просто: мы берём высоту уровня, на котором сейчас идёт игра, берём размер свеса и создаём новый блок на основе функции, которую сделали в предыдущем разделе. Этот блок будет жить своей отдельной жизнью, а параметр fall будет сразу в режиме true — это значит, что этот отрезанный кусочек сразу начнёт падать после своего появления.
// рисуем отрезанную часть блока
function addOverhang(x, z, width, depth) {
// получаем высоту, на которой будем работать
const y = boxHeight * (stack.length - 1);
// создаём новую фигуру, которая вышла за свес
const overhang = generateBox(x, y, z, width, depth, true);
// добавляем её в свой массив
overhangs.push(overhang);
}
А вот с обрезкой интереснее — мы сначала находим ось, по которой двигался блок, по этой оси смотрим, насколько фигура вылезла за координаты предыдущего блока, и обрезаем её по этим границам. После этого мы заменяем верхний блок на только что посчитанные значения — так мы формируем верхушку с новым размером. Ещё сразу добавляем её в физический мир игры, чтобы следующие блоки могли на неё опереться:
// обрезаем игровой блок
function cutBox(topLayer, overlap, size, delta) {
// получаем направление движения
const direction = topLayer.direction;
// и новую ширину и глубину
const newWidth = direction == "x" ? overlap : topLayer.width;
const newDepth = direction == "z" ? overlap : topLayer.depth;
// обновляем параметры верхнего блока
topLayer.width = newWidth;
topLayer.depth = newDepth;
// обновляем верхний блок в ThreeJS
topLayer.threejs.scale[direction] = overlap / size;
topLayer.threejs.position[direction] -= delta / 2;
// обновляем верхний блок в CannonJS
topLayer.cannonjs.position[direction] -= delta / 2;
// заменяем верхний блок меньшим, обрезанным блоком
const shape = new CANNON.Box(
new CANNON.Vec3(newWidth / 2, boxHeight / 2, newDepth / 2)
);
// добавляем обрезанную часть фигуры в физическую модель сцены
topLayer.cannonjs.shapes = [];
topLayer.cannonjs.addShape(shape);
}
Формируем стартовую сцену
Чтобы подготовить всё к запуску, нужно сделать много предварительной работы:
- создать нужные массивы;
- запустить физический движок и добавить в него гравитацию и поддержку сталкивающихся объектов;
- посчитать пропорции окна браузера, чтобы отрисовать всё красиво;
- запустить движок трёхмерной графики и добавить игровую сцену;
- включить виртуальную камеру и установить её в нужное место;
- добавить освещение;
- отрендерить сцену и добавить её на страницу.
Ещё мы запустим фоном демоверсию игры на автопилоте, как будто кто-то играет в неё на заднем фоне. Так игрок легче поймёт, что здесь будет происходить и что ему нужно сделать.
За это будет отвечать функция init(). Создадим её и сразу запустим:
// подготовка игры к запуску
function init() {
// включаем автопилот
autopilot = false;
// игра не закончилась
gameEnded = false;
// анимации ещё не было
lastTime = 0;
// в пирамиде и в обрезках ничего нет
stack = [];
overhangs = [];
// задаём точность игры на автопилое
robotPrecision = Math.random() * 1 - 0.5;
// запускаем движок CannonJS
world = new CANNON.World();
// формируем гравитацию
world.gravity.set(0, -10, 0);
// включаем алгоритм, который находит сталкивающиеся объекты
world.broadphase = new CANNON.NaiveBroadphase();
// точность работы физики (по умолчанию — 10)
world.solver.iterations = 40;
// высчитываем соотношения высоты и ширины, чтобы пирамида выглядела пропорционально окну браузера
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
// Включаем ThreeJs и добавляем камеру, от лица которой мы будем смотреть на пирамиду
camera = new THREE.OrthographicCamera(
width / -2,
width / 2,
height / 2,
height / -2,
0,
100
);
// устанавливаем камеру в нужную точку и говорим, что она смотрит точно на центр сцены
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
// создаём новую сцену
scene = new THREE.Scene();
// основание пирамиды
addLayer(0, 0, originalBoxSize, originalBoxSize);
// Настраиваем свет в сцене
// фоновая подсветка
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
// прямой свет на пирамиду
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(10, 20, 0);
scene.add(dirLight);
// настройки рендера
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
// добавляем на страницу отрендеренную сцену
document.body.appendChild(renderer.domElement);
renderer.render(scene, camera);
}
// подготавливаемся к запуску
init();
Обрабатываем нажатия
Если мы сейчас попробуем что-то нажать, то ничего не сработает — у нас нет обработчиков событий нажатия. Добавим три обработчика:
- нажатие мыши и тачпада;
- нажатие клавиатуры — R для перезапуска игры и пробел, чтобы поставить текущий блок.
Идея в том, что по любому нажатию мыши, тачпада или пробела мы будем или запускать игру (если она не начата), или ставить блок на место. А при нажатии R мы просто запустим заново главную функцию игры. Она пока будет пустая, но это мы исправим ниже.
// отслеживаем нажатия на клавиши и мышь + тачпад
window.addEventListener("mousedown", eventHandler);
window.addEventListener("touchstart", eventHandler);
window.addEventListener("keydown", function (event) {
// если нажат пробел
if (event.key == " ") {
// отключаем встроенную обработку нажатий браузера
event.preventDefault();
// запускаем свою
eventHandler();
return;
}
// если нажата R (в русской или английской раскладке)
if (event.key == "R" || event.key == "r" || event.key == "к"|| event.key == "К") {
// отключаем встроенную обработку нажатий браузера
event.preventDefault();
// запускаем игру
startGame();
// выходим из обработчика
return;
}
});
// своя обработка нажатия пробела и мыши с тачпадом
function eventHandler() {
// если включено демо — запускаем игру
if (autopilot) startGame();
// иначе обрезаем блок как есть и запускаем следующий
else splitBlockAndAddNextOneIfOverlaps();
}
В последней строчке обработчика пробела у нас есть вызов функции с длинным названием: splitBlockAndAddNextOneIfOverlaps(). Это самая важная функция в игре — когда блок остановился, она делит его на две части, одна из которых остаётся на верхушке, а другая падает вниз. Именно для неё мы готовили все функции до этого. Следите за логикой, чтобы проще было разобраться в коде:
- Берём верхние два блока — текущий и нынешнюю верхушку.
- Смотрим направление движения блока — по какой оси он ехал.
- Считаем разницу по этой оси между двумя верхними блоками.
- На основе этой разницы считаем размер свеса.
- Если есть свес (он больше нуля), то отрезаем его и то, что осталось, делаем верхушкой.
- То, что отрезали, тоже делаем блоком и отправляем его в свободное падение.
- Сразу после этого формируем новый блок, который войдёт в игру. Он получает те же размеры, что и верхушка пирамиды.
- Меняем направление движения нового блока, чтобы оно отличалось от того, что только что было.
- Добавляем его в сцену.
- Если игрок промахнулся мимо верхушки — вызываем обработчик промаха и запускаем конец игры.
// обрезаем блок как есть и запускаем следующий
function splitBlockAndAddNextOneIfOverlaps() {
// если игра закончилась — выходим из функции
if (gameEnded) return;
// берём верхний блок и тот, что под ним
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
// направление движения блока
const direction = topLayer.direction;
// если двигались по оси X, то берём ширину блока, а если нет (по оси Z) — то глубину
const size = direction == "x" ? topLayer.width : topLayer.depth;
// считаем разницу между позициями этих двух блоков
const delta =
topLayer.threejs.position[direction] -
previousLayer.threejs.position[direction];
// считаем размер свеса
const overhangSize = Math.abs(delta);
// размер отрезаемой части
const overlap = size - overhangSize;
// если есть что отрезать (если есть свес)
if (overlap > 0) {
// отрезаем
cutBox(topLayer, overlap, size, delta);
// считаем размер свеса
const overhangShift = (overlap / 2 + overhangSize / 2) * Math.sign(delta);
// если обрезка была по оси X
const overhangX =
direction == "x"
? topLayer.threejs.position.x + overhangShift
: topLayer.threejs.position.x;
// если обрезка была по оси Z
const overhangZ =
direction == "z"
// то добавляем размер свеса к начальным координатам по этой оси
? topLayer.threejs.position.z + overhangShift
: topLayer.threejs.position.z;
// если свес был по оси X, то получаем ширину, а если по Z — то глубину
const overhangWidth = direction == "x" ? overhangSize : topLayer.width;
const overhangDepth = direction == "z" ? overhangSize : topLayer.depth;
// рисуем новую фигуру после обрезки, которая будет падать вних
addOverhang(overhangX, overhangZ, overhangWidth, overhangDepth);
// формируем следующий блок
// отодвигаем их подальше от пирамиды на старте
const nextX = direction == "x" ? topLayer.threejs.position.x : -10;
const nextZ = direction == "z" ? topLayer.threejs.position.z : -10;
// новый блок получает тот же размер, что и текущий верхний
const newWidth = topLayer.width;
const newDepth = topLayer.depth;
// меняем направление относительно предыдущего
const nextDirection = direction == "x" ? "z" : "x";
// если идёт подсчёт очков — выводим текущее значение
if (scoreElement) scoreElement.innerText = stack.length - 1;
// добавляем в сцену новый блок
addLayer(nextX, nextZ, newWidth, newDepth, nextDirection);
// если свеса нет и игрок полностью промахнулся мимо пирамиды
} else {
// обрабатываем промах
missedTheSpot();
}
}
// обрабатываем промах
function missedTheSpot() {
// получаем номер текущего блока
const topLayer = stack[stack.length - 1];
// формируем срез (который упадёт) полностью из всего блока
addOverhang(
topLayer.threejs.position.x,
topLayer.threejs.position.z,
topLayer.width,
topLayer.depth
);
// убираем всё из физического мира и из сцены
world.remove(topLayer.cannonjs);
scene.remove(topLayer.threejs);
// помечаем, что наступил конец игры
gameEnded = true;
// если есть результаты и сейчас не была демоигра — выводим результаты на экран
if (resultsElement && !autopilot) resultsElement.style.display = "flex";
}
Добавляем анимацию
Если мы сейчас обновим страницу и попробуем сыграть в игру, то увидим такое:
У нас исчезает текст условий (и это хорошо), появляется новый блок в левом верхнем углу, но он никуда не двигается. А всё потому, что у нас не настроена анимация. Чтобы это исправить, добавим в конец функции init такую строчку:
renderer.setAnimationLoop(animation);
И после этого пропишем все условия анимации:
- смотрим, сколько времени прошло с отображения прошлого кадра;
- устанавливаем скорость движения;
- получаем координаты верхних двух уровней;
- двигаем верхний блок, если его нужно двигать;
- если он улетел за пирамиду — обрабатываем промах;
- если он остановился и не двигается — значит, это играет автопилот, обрезаем лишнее и запускаем новый блок;
- поднимаем камеру повыше после установки блока;
- обновляем физику в сцене (это сделаем позже);
- рендерим новый кадр;
- запоминаем время последнего рендера.
Теперь запишем это в виде кода:
// анимация игры
function animation(time) {
// если прошло сколько-то времени с момента прошлой анимации
if (lastTime) {
// считаем, сколько прошло
const timePassed = time - lastTime;
// задаём скорость движения
const speed = 0.008;
// берём верхний и предыдущий слой
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
// верхний блок должен двигаться
// ЕСЛИ не конец игры
// И это не автопилот
// ИЛИ это всё же автопилот, но алгоритм ещё не довёл блок до нужного места
const boxShouldMove =
!gameEnded &&
(!autopilot ||
(autopilot &&
topLayer.threejs.position[topLayer.direction] <
previousLayer.threejs.position[topLayer.direction] +
robotPrecision));
// если верхний блок должен двигаться
if (boxShouldMove) {
// двигаем блок одновременно в сцене и в физическом мире
topLayer.threejs.position[topLayer.direction] += speed * timePassed;
topLayer.cannonjs.position[topLayer.direction] += speed * timePassed;
// если блок полностью улетел за пирамиду
if (topLayer.threejs.position[topLayer.direction] > 10) {
// обрабатываем промах
missedTheSpot();
}
// если верхний блок двигаться не должен
} else {
// единственная ситуация, когда это возможно, это когда автопилот только-только поставил блок на место
// в этом случае обрезаем лишнее и запускаем следующий блок
if (autopilot) {
splitBlockAndAddNextOneIfOverlaps();
robotPrecision = Math.random() * 1 - 0.5;
}
}
// после установки блока поднимаем камеру
if (camera.position.y < boxHeight * (stack.length - 2) + 4) {
camera.position.y += speed * timePassed;
}
// обновляем физические события, которые должны произойти
updatePhysics(timePassed);
// рендерим новую сцену
renderer.render(scene, camera);
}
// ставим текущее время как время последней анимации
lastTime = time;
}
Добавляем физику
У нас почти всё готово, но отрезанные части блоков не падают вниз, а остаются на месте:
Это из-за того, что мы не обновили физические события, которые произошли в сцене с момента её последней отрисовки. Возьмём функцию updatePhysics() и в ней перенесём все события из Cannon.js в графический движок Three.js:
// обновляем физические события
function updatePhysics(timePassed) {
// настраиваем длительность событий
world.step(timePassed / 1000); // Step the physics world
// копируем координаты из Cannon.js в Three.js2
overhangs.forEach((element) => {
element.threejs.position.copy(element.cannonjs.position);
element.threejs.quaternion.copy(element.cannonjs.quaternion);
});
}
Теперь всё работает как нужно:
Последний штрих — обрабатываем изменение размеров окна
Чтобы картинка не ломалась при изменении размеров окна, добавим ещё один обработчик: при изменении он пересчитает соотношения сторон, сдвинет камеру снова в центр и запишет в сцену новые размеры:
// обрабатываем изменение размеров окна
window.addEventListener("resize", () => {
// выравниваем положение камеры
// получаем новые размеры и ставим камеру пропорционально новым размерам
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
camera.top = height / 2;
camera.bottom = height / -2;
// обновляем внешний вид сцены
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.render(scene, camera);
});
Поиграть в игру на странице проекта
<!DOCTYPE html>
<html lang="ru" >
<head>
<meta charset="UTF-8">
<title>Пирамида</title>
<!-- подключаем стили -->
<link rel="stylesheet" href="./style.css">
</head>
<body>
<!-- блок с инструкциями -->
<div id="instructions">
<div class="content">
<p>Ставьте блоки друг на друга.</p>
<p>Щёлкните мышкой или нажмите пробел, когда блок будет над пирамидой. Сможете дойти до синих блоков?</p>
<p>Щёлкните мышкой или нажмите пробел, чтобы начать игру.</p>
</div>
</div>
<!-- блок с результатами игры -->
<div id="results">
<div class="content">
<p>Вы промахнулись</p>
<p>Для перезапуска игры нажмите R</p>
</div>
</div>
<!-- блок с очками -->
<div id="score">0</div>
<!-- подключаем Three.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r124/three.min.js'></script>
<!-- подключаем Cannon.js -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js'></script>
<!-- подключаем наш скрипт -->
<script src="./script.js"></script>
</body>
</html>
/* общие настройки страницы */
body {
/* убираем отступы */
margin: 0;
/* цвет шрифта и сам шрифт */
color: white;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
/* выводим сообщение о конце игры и инструкцию по центру своих блоков */
#results, #instructions {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
/* затемняем фон */
background-color: rgba(20, 20, 20, 0.75);
}
/* сообщение о конце игры не показываем на старте */
#results {
display: none;
cursor: default;
}
/* отступы в текстах */
#results .content,
#instructions .content {
max-width: 300px;
padding: 50px;
border-radius: 20px;
}
/* настройка вывода набранных очков в правом верхнем углу */
#score {
position: absolute;
color: white;
font-size: 3em;
font-weight: bold;
top: 30px;
right: 30px;
}
// сразу переводим фокус на окно, чтобы можно было начать игру
window.focus();
// объявляем переменные ThreeJS — камеру, сцену и рендер
let camera, scene, renderer;
// и сразу объявляем физический мир CannonJs
let world;
// время последней анимации
let lastTime;
// тут храним части пирамиды, которые уже стоят друг на друге
let stack;
// падающие части деталей, которые не поместились в границы пирамиды
let overhangs;
// высота каждой детали
const boxHeight = 1;
// исходная высота и ширина каждой детали
const originalBoxSize = 3;
// переменные для игры на автопилоте и конца игры
let autopilot;
let gameEnded;
// точность, с которой алгоритм будет играть на заставке
let robotPrecision;
// получаем доступ на странице к разделам с очками, правилами и результатами
const scoreElement = document.getElementById("score");
const instructionsElement = document.getElementById("instructions");
const resultsElement = document.getElementById("results");
// добавление нового слоя
function addLayer(x, z, width, depth, direction) {
// получаем высоту, на которой будем работать
const y = boxHeight * stack.length;
// создаём новый слой на этой высоте
const layer = generateBox(x, y, z, width, depth, false);
// устанавливаем направление движения
layer.direction = direction;
// добавляем слой в массив с пирамидой
stack.push(layer);
}
// отрисовка игрового блока
function generateBox(x, y, z, width, depth, falls) {
// используем ThreeJS для создания коробки
const geometry = new THREE.BoxGeometry(width, boxHeight, depth);
// создаём цвет, материал и полигональную сетку, которая создаст нам коробку
const color = new THREE.Color(`hsl(${30 + stack.length * 4}, 100%, 50%)`);
const material = new THREE.MeshLambertMaterial({ color });
const mesh = new THREE.Mesh(geometry, material);
// устанавливаем координаты новой полигональной сетки
mesh.position.set(x, y, z);
// добавляем сетку-коробку в сцену
scene.add(mesh);
// применяем физику CannonJS
// создаём новый виртуальный блок, который совпадает с отрисованной на предыдущем этапе
const shape = new CANNON.Box(
new CANNON.Vec3(width / 2, boxHeight / 2, depth / 2)
);
// смотрим по входным параметрам, падает такой блок или нет
let mass = falls ? 5 : 0;
// уменьшаем массу блока пропорционально его размерам
mass *= width / originalBoxSize;
mass *= depth / originalBoxSize;
// создаём новую фигуру на основе блока
const body = new CANNON.Body({ mass, shape });
// помещаем его в нужное место
body.position.set(x, y, z);
// добавляем фигуру в физический мир
world.addBody(body);
// возвращаем полигональные сетки и физические объекты, которые у нас получились после создания нового игрового блока
return {
threejs: mesh,
cannonjs: body,
width,
depth
};
}
// рисуем отрезанную часть блока
function addOverhang(x, z, width, depth) {
// получаем высоту, на которой будем работать
const y = boxHeight * (stack.length - 1);
// создаём новую фигуру, которая вышла за свес
const overhang = generateBox(x, y, z, width, depth, true);
// добавляем её в свой массив
overhangs.push(overhang);
}
// обрезаем игровой блок
function cutBox(topLayer, overlap, size, delta) {
// получаем направление движения
const direction = topLayer.direction;
// и новую ширину и глубину
const newWidth = direction == "x" ? overlap : topLayer.width;
const newDepth = direction == "z" ? overlap : topLayer.depth;
// обновляем параметры верхнего блока
topLayer.width = newWidth;
topLayer.depth = newDepth;
// обновляем верхний блок в ThreeJS
topLayer.threejs.scale[direction] = overlap / size;
topLayer.threejs.position[direction] -= delta / 2;
// обновляем верхний блок в CannonJS
topLayer.cannonjs.position[direction] -= delta / 2;
// заменяем верхний блок меньшим, обрезанным блоком
const shape = new CANNON.Box(
new CANNON.Vec3(newWidth / 2, boxHeight / 2, newDepth / 2)
);
// добавляем обрезанную часть фигуры в физическую модель сцены
topLayer.cannonjs.shapes = [];
topLayer.cannonjs.addShape(shape);
}
// подготавливаемся к запуску и показываем демку на автопилоте
init();
// подготовка игры к запуску
function init() {
// включаем автопилот
autopilot = true;
// игра не закончилась
gameEnded = false;
// анимации ещё не было
lastTime = 0;
// в пирамиде и в обрезках ничего нет
stack = [];
overhangs = [];
// задаём точность игры на автопилое
robotPrecision = Math.random() * 1 - 0.5;
// запускаем движок CannonJS
world = new CANNON.World();
// формируем гравитацию
world.gravity.set(0, -10, 0);
// включаем алгоритм, который находит сталкивающиеся объекты
world.broadphase = new CANNON.NaiveBroadphase();
// точность работы физики (по умолчанию — 10)
world.solver.iterations = 40;
// высчитываем соотношения высоты и ширины, чтобы пирамида выглядела пропорционально окну браузера
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
// Включаем ThreeJs и добавляем камеру, от лица которой мы будем смотреть на пирамиду
camera = new THREE.OrthographicCamera(
width / -2,
width / 2,
height / 2,
height / -2,
0,
100
);
// устанавливаем камеру в нужную точку и говорим, что она смотрит точно на центр сцены
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
// создаём новую сцену
scene = new THREE.Scene();
// основание пирамиды
addLayer(0, 0, originalBoxSize, originalBoxSize);
// первый слой
addLayer(-10, 0, originalBoxSize, originalBoxSize, "x");
// Настраиваем свет в сцене
// фоновая подсветка
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
// прямой свет на пирамиду
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(10, 20, 0);
scene.add(dirLight);
// настройки рендера
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setAnimationLoop(animation);
// добавляем на страницу отрендеренную сцену
document.body.appendChild(renderer.domElement);
renderer.render(scene, camera);
}
// запуск игры
function startGame() {
// выключаем автопилот
autopilot = false;
// сбрасываем все настройки
gameEnded = false;
lastTime = 0;
stack = [];
overhangs = [];
// если на экране есть инструкции или результат — скрываем их
if (instructionsElement) instructionsElement.style.display = "none";
if (resultsElement) resultsElement.style.display = "none";
// если видны очки — обнуляем их
if (scoreElement) scoreElement.innerText = 0;
// если физический мир уже создан — убираем из него все объекты
if (world) {
while (world.bodies.length > 0) {
world.remove(world.bodies[0]);
}
}
// если сцена уже есть, тоже убираем из неё всё, что было
if (scene) {
while (scene.children.find((c) => c.type == "Mesh")) {
const mesh = scene.children.find((c) => c.type == "Mesh");
scene.remove(mesh);
}
// добавляем основание
addLayer(0, 0, originalBoxSize, originalBoxSize);
// и первый слой
addLayer(-10, 0, originalBoxSize, originalBoxSize, "x");
}
// если уже есть камера — сбрасываем её настройки
if (camera) {
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
}
}
// отслеживаем нажатия на клавиши и мышь
window.addEventListener("mousedown", eventHandler);
window.addEventListener("touchstart", eventHandler);
window.addEventListener("keydown", function (event) {
// если нажат пробел
if (event.key == " ") {
// отключаем встроенную обработку нажатий браузера
event.preventDefault();
// запускаем свою
eventHandler();
return;
}
// если нажата R (в русской или английской раскладке)
if (event.key == "R" || event.key == "r" || event.key == "к"|| event.key == "К") {
// отключаем встроенную обработку нажатий браузера
event.preventDefault();
// запускаем игру
startGame();
// выходим из обработчика
return;
}
});
// своя оббраотка нажатия пробела
function eventHandler() {
// если включено демо — запускаем игру
if (autopilot) startGame();
// иначе обрезаем блок как есть и запускаем следующий
else splitBlockAndAddNextOneIfOverlaps();
}
// обрезаем блок как есть и запускаем следующий
function splitBlockAndAddNextOneIfOverlaps() {
// если игра закончилась - выходим из функции
if (gameEnded) return;
// берём верхний блок и тот, что под ним
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
// направление движения блока
const direction = topLayer.direction;
// если двигались по оси X, то берём ширину блока, а если нет (по оси Z) — то глубину
const size = direction == "x" ? topLayer.width : topLayer.depth;
// считаем разницу между позициями этих двух блоков
const delta =
topLayer.threejs.position[direction] -
previousLayer.threejs.position[direction];
// считаем размер свеса
const overhangSize = Math.abs(delta);
// размер отрезаемой части
const overlap = size - overhangSize;
// если есть что отрезать (если есть свес)
if (overlap > 0) {
// отрезаем
cutBox(topLayer, overlap, size, delta);
// считаем размер свеса
const overhangShift = (overlap / 2 + overhangSize / 2) * Math.sign(delta);
// если обрезка была по оси X
const overhangX =
direction == "x"
? topLayer.threejs.position.x + overhangShift
: topLayer.threejs.position.x;
// если обрезка была по оси Z
const overhangZ =
direction == "z"
// то добавляем размер свеса к начальным координатам по этой оси
? topLayer.threejs.position.z + overhangShift
: topLayer.threejs.position.z;
// если свес был по оси X, то получаем ширину, а если по Z — то глубину
const overhangWidth = direction == "x" ? overhangSize : topLayer.width;
const overhangDepth = direction == "z" ? overhangSize : topLayer.depth;
// рисуем новую фигуру после обрезки, которая будет падать вних
addOverhang(overhangX, overhangZ, overhangWidth, overhangDepth);
// формируем следующий блок
// отодвигаем их подальше от пирамиды на старте
const nextX = direction == "x" ? topLayer.threejs.position.x : -10;
const nextZ = direction == "z" ? topLayer.threejs.position.z : -10;
// новый блок получает тот же размер, что и текущий верхний
const newWidth = topLayer.width;
const newDepth = topLayer.depth;
// меняем направление относительно предыдущего
const nextDirection = direction == "x" ? "z" : "x";
// если идёт подсчёт очков — выводим текущее значение
if (scoreElement) scoreElement.innerText = stack.length - 1;
// добавляем в сцену новый блок
addLayer(nextX, nextZ, newWidth, newDepth, nextDirection);
// если свеса нет и игрок полностью промахнулся мимо пирамиды
} else {
// обрабатываем промах
missedTheSpot();
}
}
// обрабатываем промах
function missedTheSpot() {
// получаем номер текущего блока
const topLayer = stack[stack.length - 1];
// формируем срез (который упадёт) полностью из всего блока
addOverhang(
topLayer.threejs.position.x,
topLayer.threejs.position.z,
topLayer.width,
topLayer.depth
);
// убираем всё из физического мира и из сцены
world.remove(topLayer.cannonjs);
scene.remove(topLayer.threejs);
// помечаем, что наступил конец игры
gameEnded = true;
// если есть результаты и сейчас не была демоигра — выводим результаты на экран
if (resultsElement && !autopilot) resultsElement.style.display = "flex";
}
// анимация игры
function animation(time) {
// если прошло сколько-то времени с момента прошлой анимации
if (lastTime) {
// считаем, сколько прошло
const timePassed = time - lastTime;
// задаём скорость движения
const speed = 0.008;
// берём верхний и предыдущий слой
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
// верхний блок должен двигаться
// ЕСЛИ не конец игры
// И это не автопилот
// ИЛИ это всё же автопилот, но алгоритм ещё не довёл блок до нужного места
const boxShouldMove =
!gameEnded &&
(!autopilot ||
(autopilot &&
topLayer.threejs.position[topLayer.direction] <
previousLayer.threejs.position[topLayer.direction] +
robotPrecision));
// если верхний блок должен двигаться
if (boxShouldMove) {
// двигаем блок одновременно в сцене и в физическом мире
topLayer.threejs.position[topLayer.direction] += speed * timePassed;
topLayer.cannonjs.position[topLayer.direction] += speed * timePassed;
// если блок полностью улетел за пирамиду
if (topLayer.threejs.position[topLayer.direction] > 10) {
// обрабатываем промах
missedTheSpot();
}
// если верхний блок двигаться не должен
} else {
// единственная ситуация, когда это возможно, это когда автопилот только-только поставил блок на место
// в этом случае обрезаем лишнее и запускаем следующий блок
if (autopilot) {
splitBlockAndAddNextOneIfOverlaps();
robotPrecision = Math.random() * 1 - 0.5;
}
}
// после установки блока поднимаем камеру
if (camera.position.y < boxHeight * (stack.length - 2) + 4) {
camera.position.y += speed * timePassed;
}
// обновляем физические события, которые должны произойти
updatePhysics(timePassed);
// рендерим новую сцену
renderer.render(scene, camera);
}
// ставим текущее время как время последней анимации
lastTime = time;
}
// обновляем физические события
function updatePhysics(timePassed) {
// настраиваем длительность событий
world.step(timePassed / 1000); // Step the physics world
// копируем координаты из Cannon.js в Three.js2
overhangs.forEach((element) => {
element.threejs.position.copy(element.cannonjs.position);
element.threejs.quaternion.copy(element.cannonjs.quaternion);
});
}
// обрабатываем изменение размеров окна
window.addEventListener("resize", () => {
// выравниваем положение камеры
// получаем новые размеры и ставим камеру пропорционально новым размерам
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
camera.top = height / 2;
camera.bottom = height / -2;
// обновляем внешний вид сцены
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.render(scene, camera);
});