Этот проект — для тех, кто уже разбирается в 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);
});
