Игра «Собери пирамиду»
hard

Игра «Собери пирамиду»

Трёхмерная игра с реалистичной физикой

Этот проект — для тех, кто уже разбирается в JavaScript. Сейчас попробуем сделать простую трёхмерную игру и поработаем сразу с двумя движками: для отображения на экране и реалистичной физики. 

⭐ Если это звучит пока сложновато — посмотрите, как устроен простой пинг-понг на JavaScript.

❤️ Можно сразу поиграть, чтобы понять, как это будет выглядеть: https://mihailmaximov.ru/projects/stack/index.html 

Что делаем

Сегодня сделаем игру «Собери пирамиду»:

  1. У нас есть основание пирамиды и блоки, которые вылетают справа или слева.
  2. Как только блок пролетает над верхней частью пирамиды, мы нажимаем пробел или кнопку мыши, и блок останавливается. 
  3. Та часть блока, которая вылезла за границу верхушки пирамиды, отсекается и падает вниз. Соответственно, пирамида становится выше на один ярус, но может уменьшиться в размерах.
  4. Новый блок, который появляется, по размерам совпадает с размерами верхушки пирамиды. Чем меньше верхушка, тем меньшего размера появится новый блок.
  5. С каждым поставленным блоком увеличивается счётчик очков.
  6. Если игрок останавливает новый блок и промахивается мимо верхушки — игра останавливается. Если блок просто вылетает за границы сцены — тоже останавливается.
  7. В игре нельзя выиграть, поэтому задача игрока — набрать максимальное количество очков.

Игра «Собери пирамиду»

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");

Теперь сделаем две базовые вещи, на которых будет строиться вся наша игра: добавление нового игрового слоя и отрисовка блока на этом уровне.

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

Дальше вступает в игру вторая функция — создание и отрисовка игрового блока. Общая логика такая:

  1. Мы знаем, на каком уровне нам нужен блок.
  2. Создаём его в трёхмерном виде с помощью движка Three.js.
  3. Добавляем его в сцену, чтобы блок мог появиться на экране.
  4. Создаём виртуальный блок в физическом мире игры, который по размерам полностью совпадает с тем, что мы только что отрисовали. Это делаем с помощью Cannon.js.
  5. Рассчитываем его размеры и вес.
  6. Помещаем этот блок в физический мир движка

Вот как это выглядит в коде:

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

  1. Берём верхние два блока — текущий и нынешнюю верхушку.
  2. Смотрим направление движения блока — по какой оси он ехал.
  3. Считаем разницу по этой оси между двумя верхними блоками.
  4. На основе этой разницы считаем размер свеса.
  5. Если есть свес (он больше нуля), то отрезаем его и то, что осталось, делаем верхушкой.
  6. То, что отрезали, тоже делаем блоком и отправляем его в свободное падение.
  7. Сразу после этого формируем новый блок, который войдёт в игру. Он получает те же размеры, что и верхушка пирамиды.
  8. Меняем направление движения нового блока, чтобы оно отличалось от того, что только что было.
  9. Добавляем его в сцену.
  10. Если игрок промахнулся мимо верхушки — вызываем обработчик промаха и запускаем конец игры.

// обрезаем блок как есть и запускаем следующий
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);
});

Код:

Hunor Marton Borbely

Текст:

Михаил Полянин

Редактор:

Максим Ильяхов

Художник:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Кирилл Климентьев

Соцсети:

Виталий Вебер

Вы фронтендер? Гляньте сюда ↓
У нас есть курсы по прокачке навыков до уровня middle и далее. Начать можно бесплатно. Посмотрите, интересно ли вам это.
Начать бесплатно
Вы фронтендер? Гляньте сюда ↓ Вы фронтендер? Гляньте сюда ↓ Вы фронтендер? Гляньте сюда ↓ Вы фронтендер? Гляньте сюда ↓
Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Начать карьеру в ИТ
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
hard