Статьи

Создание JavaScript-редактора Minecraft 3D

Эта статья была рецензирована Полом О’Брайеном . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Заставка Minecraft

Я всегда хотел построить 3D-игру. У меня просто никогда не было времени и сил для изучения тонкостей трехмерного программирования. Потом я обнаружил, что мне не нужно …

Работая однажды, я подумал, что, возможно, я смогу смоделировать трехмерную среду, используя CSS-преобразования. Я наткнулся на старую статью о создании трехмерных миров с помощью HTML и CSS .

Я хотел симулировать мир Майнкрафта (или хотя бы крошечную его часть). Minecraft — игра-песочница, в которой вы можете разбивать и размещать блоки. Я хотел такой же функциональности, но с HTML, JavaScript и CSS.

Приходите, как я опишу то, что я узнал, и как это может помочь вам быть более креативным с вашими преобразованиями CSS!

Примечание. Большую часть кода для этого руководства можно найти на Github . Я протестировал его в последней версии Chrome. Не могу обещать, что в других браузерах он будет выглядеть точно так же, но основные концепции универсальны.

Это только половина приключения. Если вы хотите узнать, как сохранить проекты на реальном сервере, ознакомьтесь с родственным постом, PHP Minecraft Mod . Там мы исследуем способы взаимодействия с сервером Minecraft, манипулирования им в режиме реального времени и реагирования на ввод пользователя.

Вещи, которые мы уже делаем

Я написал свою справедливую долю CSS и довольно хорошо понял ее для целей создания веб-сайтов. Но это понимание основано на предположении, что я собираюсь работать в 2D-пространстве.

Давайте рассмотрим пример:

.tools { position: absolute; left: 35px; top: 25px; width: 200px; height: 400px; z-index: 3; } .canvas { position: absolute; left: 0; top: 0; width: 100%; height: 100%; z-index: 2; } 

Здесь у нас есть элемент canvas, начинающийся в верхнем левом углу страницы и растягивающийся до правого нижнего угла. Кроме того, мы добавляем элемент инструментов. Он начинается 25 25px слева и 35px сверху страницы и имеет ширину 400px высоту 400px .

В зависимости от порядка div.tools и div.canvas к разметке, вполне возможно, что div.canvas может перекрывать div.tools . Это за исключением стилей z-index применяемых к каждому.

Вы, вероятно, привыкли думать об элементах, стилизованных таким образом, как 2D-поверхности с возможностью перекрытия друг друга. Но это перекрытие по сути является третьим измерением. left , top и z-index могут быть переименованы в x , y и z . Пока мы предполагаем, что каждый элемент имеет фиксированную глубину 1px , а z-index имеет неявную единицу px , мы уже думаем в трехмерных терминах.

Некоторые из нас склонны бороться с понятиями вращения и перемещения в этом третьем измерении …

Теория трансформаций

Переводы CSS дублируют эту знакомую функциональность в API, который выходит за пределы ограничений top , left и z-index . Можно заменить некоторые из наших предыдущих стилей переводами:

 .tools { position: absolute; background: green; /* left: 35px; top: 25px; */ transform-origin: 0 0; transform: translate(35px, 25px); width: 200px; height: 400px; z-index: 3; } 

Вместо определения left и top смещений (с предполагаемым началом 0px слева и 0px сверху) мы можем объявить явное начало. Мы можем выполнить все виды преобразований для этого элемента, для которых в качестве центра используется 0 0 . translate(35px, 25px) перемещает элемент на 35px вправо и на 25px вниз. Мы можем использовать отрицательные значения для перемещения элемента влево и / или вверх.

Имея возможность определить происхождение наших преобразований, мы можем начать делать и другие интересные вещи. Например, мы можем вращать и масштабировать элементы:

 transform-origin: center; transform: scale(0.5) rotate(45deg); 

Каждый элемент начинается с исходной точки transform-origin умолчанию 50% 50% 0 , но значение center устанавливает x , y и z в эквивалент 50% . Мы можем масштабировать наш элемент до значения от 0 до 1 и поворачивать его (по часовой стрелке) на градусы или радианы. И мы можем конвертировать между ними с помощью:

  • (45 * Math.PI) / 180 45deg = (45 * Math.PI) / 1800.79rad
  • 0.79rad = (0.79 * 180) / Math.PI45deg

Чтобы повернуть элемент против часовой стрелки, нам просто нужно использовать отрицательное значение deg или rad .

Что еще интереснее в этих преобразованиях, так это то, что мы можем использовать их 3D-версии .

Браузеры Evergreen имеют довольно хорошую поддержку для этих стилей, хотя они могут требовать префиксов поставщиков. CodePen имеет аккуратную опцию «autoprefix», но вы можете добавить библиотеки, такие как PostCSS, в ваш локальный код, чтобы добиться того же.

Первый Блок

Давайте начнем создавать наш трехмерный мир. Мы начнем с того, что сделаем пространство для размещения наших блоков. Создайте новый файл с именем index.html :

 <!doctype html> <html> <head> <style> html, body { padding: 0; margin: 0; width: 100%; height: 100%; } .scene { position: absolute; left: 50%; top: 50%; margin: -192px 0 0 -192px; width: 384px; height: 384px; background: rgba(100, 100, 255, 0.2); transform: rotateX(60deg) rotateZ(60deg); transform-style: preserve-3d; transform-origin: 50% 50% 50%; } </style> </head> <body> <div class="scene"></div> <script src="https://code.jquery.com/jquery-3.1.0.slim.min.js"></script> <script src="http://ricostacruz.com/jquery.transit/jquery.transit.min.js"></script> <script> // TODO </script> </body> </html> 

Здесь мы растягиваем тело на полную ширину и высоту, сбрасывая отступ до 0px . Затем мы создаем маленький div.scene , который мы будем использовать для хранения различных блоков. Мы используем 50% left и top , а также отрицательный левый и верхний margin (равные половине width и height ) для горизонтального и вертикального центрирования. Затем мы слегка наклоняем его (используя трехмерное вращение), чтобы получить вид в перспективе того, где будут находиться блоки.

Обратите внимание, как мы определяем transform-style:preserve-3d . Это сделано для того, чтобы дочерними элементами можно было управлять в трехмерном пространстве.

Результат должен выглядеть примерно так:

Теперь давайте начнем добавлять форму блока к сцене. Нам нужно будет создать новый файл JavaScript с именем block.js :

 "use strict" class Block { constructor(x, y, z) { this.x = x; this.y = y; this.z = z; this.build(); } build() { // TODO: build the block } createFace(type, x, y, z, rx, ry, rz) { // TODO: return a block face } createTexture(type) { // TODO: get the texture } } 

Каждый блок должен иметь 6-стороннюю трехмерную форму. Мы можем разбить различные части конструкции на методы, чтобы (1) построить целый блок, (2) построить каждую поверхность и (3) получить текстуру каждой поверхности.

Каждое из этих поведений (или методов) содержится в классе ES6 . Это отличный способ сгруппировать структуры данных и методы, которые работают с ними вместе. Вы можете быть знакомы с традиционной формой:

 function Block(x, y, z) { this.x = x; this.y = y; this.z = z; this.build(); } var proto = Block.prototype; proto.build = function() { // TODO: build the block }; proto.createFace = function(type, x, y, z, rx, ry, rz) { // TODO: return a block face } proto.createTexture = function(type) { // TODO: get the texture } 

Это может выглядеть немного иначе, но это почти то же самое. В дополнение к более короткому синтаксису классы ES6 также предоставляют ярлыки для расширения прототипов и вызова переопределенных методов. Но я отвлекся …

Давайте работать снизу вверх:

 createFace(type, x, y, z, rx, ry, rz) { return $(`<div class="side side-${type}" />`) .css({ transform: ` translateX(${x}px) translateY(${y}px) translateZ(${z}px) rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) `, background: this.createTexture(type) }); } createTexture(type) { return `rgba(100, 100, 255, 0.2)`; } 

Каждая поверхность (или грань) состоит из повернутого и переведенного элемента div. Мы не можем сделать элементы толще, чем 1px , но мы можем имитировать глубину, закрывая все отверстия и используя несколько элементов, параллельных друг другу. Мы можем дать блоку иллюзию глубины, даже если она пустая.

Для этого метод createFace принимает набор координат: x , y и z для положения грани. Мы также предоставляем повороты для каждой оси, чтобы мы могли вызывать createFace с любой конфигурацией, и он будет createFace и поворачивать грань именно так, как мы этого хотим.

Давайте построим основную форму:

 build() { const size = 64; const x = this.x * size; const y = this.y * size; const z = this.z * size; const block = this.block = $(`<div class="block" />`) .css({ transform: ` translateX(${x}px) translateY(${y}px) translateZ(${z}px) ` }); $(`<div class="x-axis" />`) .appendTo(block) .css({ transform: ` rotateX(90deg) rotateY(0deg) rotateZ(0deg) ` }); $(`<div class="y-axis" />`) .appendTo(block) .css({ transform: ` rotateX(0deg) rotateY(90deg) rotateZ(0deg) ` }); $(`<div class="z-axis" />`) .appendTo(block); } 

Мы привыкли мыслить с точки зрения положения одного пикселя, но такая игра, как Minecraft, работает в большем масштабе. Каждый блок больше, и система координат имеет дело с положением блока, а не с отдельными пикселями, которые его составляют. Я хочу передать такую ​​же идею здесь …

Когда кто-то создает новый блок с размером 1 × 2 × 3 , я хочу, чтобы это означало 0px × 64px × 128px . Поэтому мы умножаем каждую координату на размер по умолчанию (в данном случае 64px , потому что это размер текстур в пакете текстур, который мы будем использовать).

Затем мы создаем контейнер div (который мы называем div.block ). Внутри мы помещаем еще 3 деления. Они покажут нам ось нашего блока — они похожи на направляющие в программе 3D-рендеринга. Мы также должны добавить новый CSS для нашего блока:

 .block { position: absolute; left: 0; top: 0; width: 64px; height: 64px; transform-style: preserve-3d; transform-origin: 50% 50% 50%; } .x-axis, .y-axis, .z-axis { position: absolute; left: 0; top: 0; width: 66px; height: 66px; transform-origin: 50% 50% 50%; } .x-axis { border: solid 2px rgba(255, 0, 0, 0.3); } .y-axis { border: solid 2px rgba(0, 255, 0, 0.3); } .z-axis { border: solid 2px rgba(0, 0, 255, 0.3); } 

Этот стиль похож на то, что мы видели раньше. Нам нужно не забыть установить transform-style:preserve-3d на .block , чтобы оси отображались в их собственном трехмерном пространстве. Мы даем каждому цвет, и делаем их немного больше, чем блок, в котором они содержатся. Это делается для того, чтобы они были видны, даже если у блока есть стороны.

Давайте создадим новый блок и добавим его в div.scene :

 let first = new Block(1, 1, 1); $(".scene").append(first.block); 

Результат должен выглядеть примерно так:

Теперь давайте добавим эти лица:

 this .createFace("top", 0, 0, size / 2, 0, 0, 0) .appendTo(block); this .createFace("side-1", 0, size / 2, 0, 270, 0, 0) .appendTo(block); this .createFace("side-2", size / 2, 0, 0, 0, 90, 0) .appendTo(block); this .createFace("side-3", 0, size / -2, 0, -270, 0, 0) .appendTo(block); this .createFace("side-4", size / -2, 0, 0, 0, -90, 0) .appendTo(block); this .createFace("bottom", 0, 0, size / -2, 0, 180, 0) .appendTo(block); 

Этот код показался мне методом проб и ошибок (из-за моего ограниченного опыта работы с 3D-перспективой). Каждый элемент начинается точно в той же позиции, что и div.z-axis . То есть в вертикальном центре div.block и лицом к вершине.

Итак, для «верхнего» элемента мне пришлось перевести его «вверх» на половину размера блока, но мне не нужно было никоим образом поворачивать его. Для «нижнего» элемента мне пришлось повернуть его на 180 градусов (вдоль оси x или y) и переместить его вниз на половину размера блока.

Используя подобное мышление, я повернул и перевел каждую из оставшихся сторон. Я также должен был добавить соответствующий CSS для них:

 .side { position: absolute; left: 0; top: 0; width: 64px; height: 64px; backface-visibility: hidden; outline: 1px solid rgba(0, 0, 0, 0.3); } 

Добавление backface-visibility:hidden элемент не отображает «нижнюю» сторону элементов. Обычно они выглядят одинаково (только зеркально) независимо от того, как они вращаются. Со скрытыми задними гранями отображается только «верхняя» сторона. Будьте осторожны при включении этого: ваши поверхности должны вращаться в правильном направлении или стороны блока просто исчезнут. Вот причина поворотов 90/270 / -90 / -270, которые я дал сторонам.

Давайте сделаем этот блок более реалистичным. Нам нужно создать новый файл с именем block.dirt.js и переопределить метод createTexture :

 "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; на "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; на "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; на "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; на "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; на "use strict" const DIRT_TEXTURES = { "top": [ "textures/dirt-top-1.png", "textures/dirt-top-2.png", "textures/dirt-top-3.png" ], "side": [ "textures/dirt-side-1.png", "textures/dirt-side-2.png", "textures/dirt-side-3.png", "textures/dirt-side-4.png", "textures/dirt-side-5.png" ] }; class Dirt extends Block { createTexture(type) { if (type === "top" || type === "bottom") { const texture = DIRT_TEXTURES.top.random(); return `url(${texture})`; } const texture = DIRT_TEXTURES.side.random(); return `url(${texture})`; } } Block.Dirt = Dirt; 

Мы собираемся использовать популярный пакет текстур, который называется Sphax PureBDCraft . Его можно бесплатно скачать и использовать (при условии, что вы не пытаетесь его продать), и он может быть разных размеров. Я использую версию x64 .

Мы начнем с определения справочной таблицы для текстур сторон и верхней части блока. Пакет текстур не определяет, какие текстуры следует использовать для нижнего слоя, поэтому мы просто повторно используем верхние текстуры.

Если сторона, нуждающаяся в текстуре, — это «верх» или «низ», то мы выбираем случайную текстуру из списка «верх». Случайный метод не существует, пока мы не определим его:

 Array.prototype.random = function() { return this[Math.floor(Math.random() * this.length)]; }; 

Точно так же, если нам нужна текстура для стороны, мы выбираем случайную. Эти текстуры являются бесшовными, поэтому рандомизация работает в нашу пользу.

Результат должен выглядеть примерно так:

Создание сцены

Как мы делаем это интерактивным? Ну, хорошее место для начала — сцена. Мы уже размещали блоки на сцене, так что теперь нам нужно только включить динамическое размещение!

Для начала мы можем визуализировать плоскую поверхность блоков:

 const $scene = $(".scene"); for (var x = 0; x < 6; x++) { for (var y = 0; y < 6; y++) { let next = new Block.Dirt(x, y, 0); next.block.appendTo($scene); } } 

Отлично, это дает нам плоскую поверхность для начала добавления блоков. Теперь давайте выделим поверхности при наведении на них курсора:

 .block:hover .side { outline: 1px solid rgba(0, 255, 0, 0.5); } 

Что-то странное происходит, хотя:

блоки с мерцанием

Это связано с тем, что поверхности пересекают друг друга случайно. Нет хорошего способа решить эту проблему, но мы можем предотвратить это, слегка уменьшив размеры блоков:

 const block = this.block = $(`<div class="block" />`) .css({ transform: ` translateX(${x}px) translateY(${y}px) translateZ(${z}px) scale(0.99) ` }); 

блоки без мерцания

Хотя это и делает вещи лучше, но это влияет на производительность, чем больше блоков в сцене. Поступайте легко при масштабировании множества элементов одновременно …

Давайте пометим каждую поверхность блоком и типом, который принадлежит ей:

 createFace(type, x, y, z, rx, ry, rz) { return $(`<div class="side side-${type}" />`) .css({ transform: ` translateX(${x}px) translateY(${y}px) translateZ(${z}px) rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) `, background: this.createTexture(type) }) .data("block", this) .data("type", type); } 

Затем, нажимая на поверхность, мы можем получить новый набор координат и создать новый блок:

 function createCoordinatesFrom(side, x, y, z) { if (side == "top") { z += 1; } if (side == "side-1") { y += 1; } if (side == "side-2") { x += 1; } if (side == "side-3") { y -= 1; } if (side == "side-4") { x -= 1; } if (side == "bottom") { z -= 1; } return [x, y, z]; } const $body = $("body"); $body.on("click", ".side", function(e) { const $this = $(this); const previous = $this.data("block"); const coordinates = createCoordinatesFrom( $this.data("type"), previous.x, previous.y, previous.z ); const next = new Block.Dirt(...coordinates); next.block.appendTo($scene); }); 

createCoordinatesFrom есть простая, но важная задача. Учитывая тип стороны и координаты блока, к которому она принадлежит, createCoordinatesFrom должен вернуть новый набор координат. Это где новый блок будет размещен.

Затем мы прикрепили прослушиватель событий. Он будет срабатывать для каждой стороны div.side которой div.side . Когда это происходит, мы получаем блок, которому принадлежит сторона, и получаем новый набор координат для следующего блока. Как только мы получим их, мы создадим блок и добавим его в сцену.

Результат удивительно интерактивный:

Видя Призраков

Было бы полезно увидеть схему блока, который мы собираемся разместить, прежде чем мы его разместим. Это иногда называют «показанием призрака» того, что мы собираемся сделать.

Код для включения этого очень похож на тот, который мы уже видели:

 let ghost = null; function removeGhost() { if (ghost) { ghost.block.remove(); ghost = null; } } function createGhostAt(x, y, z) { const next = new Block.Dirt(x, y, z); next.block .addClass("ghost") .appendTo($scene); ghost = next; } $body.on("mouseenter", ".side", function(e) { removeGhost(); const $this = jQuery(this); const previous = $this.data("block"); const coordinates = createCoordinatesFrom( $this.data("type"), previous.x, previous.y, previous.z ); createGhostAt(...coordinates); }); $body.on("mouseleave", ".side", function(e) { removeGhost(); }); 

Основное отличие состоит в том, что мы поддерживаем один экземпляр блока-призрака. Когда создается каждый новый, старый удаляется. Это может принести пользу от нескольких дополнительных стилей:

 .ghost { pointer-events: none; } .ghost .side { opacity: 0.6; pointer-events: none; -webkit-filter: brightness(1.5); } 

Если оставить активным, события указателя, связанные с элементами призрака, будут противодействовать mouseenter и mouseleave на стороне внизу. Поскольку нам не нужно взаимодействовать с элементами-призраками, мы можем отключить эти события указателя.

Этот результат довольно аккуратный:

Изменение перспективы

Чем больше интерактивности мы добавим, тем сложнее будет увидеть, что происходит. Кажется, пришло время сделать что-то с этим. Было бы здорово, если бы мы могли масштабировать и поворачивать окно просмотра, чтобы иметь возможность видеть, что происходит немного лучше …

Начнем с увеличения. Многие интерфейсы (и игры) позволяют масштабировать область просмотра, прокручивая колесико мыши. Различные браузеры обрабатывают события колеса мыши по-разному, поэтому имеет смысл использовать библиотеку абстракций .

После установки мы можем подключиться к событиям:

 let sceneTransformScale = 1; $body.on("mousewheel", function(event) { if (event.originalEvent.deltaY > 0) { sceneTransformScale -= 0.05; } else { sceneTransformScale += 0.05; } $scene.css({ "transform": ` scaleX(${sceneTransformScale}) scaleY(${sceneTransformScale}) scaleZ(${sceneTransformScale}) ` }); }); 

масштабирование сцены с помощью колеса мыши

Теперь мы можем контролировать масштаб всей сцены, просто прокручивая колесико мыши. К сожалению, в тот момент, когда мы это сделаем, вращения будут отменены. Нам нужно принять во внимание вращение, поскольку мы позволяем перетаскивать область просмотра мышью, чтобы отрегулировать ее:

 let sceneTransformX = 60; let sceneTransformY = 0; let sceneTransformZ = 60; let sceneTransformScale = 1; const changeViewport = function() { $scene.css({ "transform": ` rotateX(${sceneTransformX}deg) rotateY(${sceneTransformY}deg) rotateZ(${sceneTransformZ}deg) scaleX(${sceneTransformScale}) scaleY(${sceneTransformScale}) scaleZ(${sceneTransformScale}) ` }); }; 

Эта функция учитывает не только масштабный коэффициент сцены, но и коэффициенты вращения x, y и z. Нам также нужно изменить наш приемник событий масштабирования:

 $body.on("mousewheel", function(event) { if (event.originalEvent.deltaY > 0) { sceneTransformScale -= 0.05; } else { sceneTransformScale += 0.05; } changeViewport(); }); 

Теперь мы можем начать вращать сцену. Нам нужно:

  1. Прослушиватель событий, когда начинается действие перетаскивания
  2. Прослушиватель событий, когда мышь двигается (при перетаскивании)
  3. Прослушиватель событий, когда действие перетаскивания останавливается

Нечто подобное должно сделать свое дело:

 Number.prototype.toInt = String.prototype.toInt = function() { return parseInt(this, 10); }; let lastMouseX = null; let lastMouseY = null; $body.on("mousedown", function(e) { lastMouseX = e.clientX / 10; lastMouseY = e.clientY / 10; }); $body.on("mousemove", function(e) { if (!lastMouseX) { return; } let nextMouseX = e.clientX / 10; let nextMouseY = e.clientY / 10; if (nextMouseX !== lastMouseX) { deltaX = nextMouseX.toInt() - lastMouseX.toInt(); degrees = sceneTransformZ - deltaX; if (degrees > 360) { degrees -= 360; } if (degrees < 0) { degrees += 360; } sceneTransformZ = degrees; lastMouseX = nextMouseX; changeViewport(); } if (nextMouseY !== lastMouseY) { deltaY = nextMouseY.toInt() - lastMouseY.toInt(); degrees = sceneTransformX - deltaY; if (degrees > 360) { degrees -= 360; } if (degrees < 0) { degrees += 360; } sceneTransformX = degrees; lastMouseY = nextMouseY; changeViewport(); } }); $body.on("mouseup", function(e) { lastMouseX = null; lastMouseY = null; }); 

При mousedown мы mousedown начальные координаты мыши x и y . Когда мышь движется (если кнопка все еще нажата), мы корректируем sceneTransformZ и sceneTransformX в увеличенном sceneTransformX . Нет ничего плохого в том, чтобы значения превышали 360 градусов или ниже 0 градусов, но это выглядело бы ужасно, если бы мы хотели отобразить их на экране.

Вычисления в слушателе событий mousemove могут быть вычислительно дорогими из-за того, что этот даже слушатель может быть запущен. На экране потенциально могут быть миллионы пикселей, и этот слушатель может сработать при перемещении мыши к каждому из них. Вот почему мы выходим пораньше, если не удерживать кнопку мыши.

Когда кнопка мыши отпущена, мы lastMouseX и lastMouseY , так что слушатель mousemove перестает вычислять вещи. Мы могли бы просто очистить lastMouseX , но очистка обо мне кажется чище.

К сожалению, событие mousedown может помешать событию click на сторонах блока. Мы можем обойти это, предотвратив пузыри событий:

 $scene.on("mousedown", function(e) { e.stopPropagation(); }); 

Дай вихрь …

Удаление блоков

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

  1. Изменить цвет рамки наведения с зеленого на красный
  2. Отключить блок призраков

Это будет проще сделать с помощью CSS, если у нас есть класс body, чтобы указать, в каком режиме мы находимся (в обычном режиме) или в режиме вычитания:

 $body.on("keydown", function(e) { if (e.altKey || e.controlKey || e.metaKey) { $body.addClass("subtraction"); } }); $body.on("keyup", function(e) { $body.removeClass("subtraction"); }); 

Когда нажата клавиша-модификатор ( alt , control или command ), этот код гарантирует, что body имеет класс subtraction . Это облегчает нацеливание на различные элементы, используя этот класс:

 .subtraction .block:hover .side { outline: 1px solid rgba(255, 0, 0, 0.5); } .subtraction .ghost { display: none; } 

переключение в режим вычитания

Мы проверяем количество клавиш-модификаторов, поскольку разные операционные системы перехватывают разные модификаторы. Например, altKey и metaKey работают на macOS, тогда как controlKey работает на Ubuntu.

Если мы нажимаем на блок, когда мы находимся в режиме вычитания, мы должны удалить его:

 $body.on("click", ".side", function(e) { const $this = $(this); const previous = $this.data("block"); if ($body.hasClass("subtraction")) { previous.block.remove(); previous = null; } else { const coordinates = createCoordinatesFrom( $this.data("type"), previous.x, previous.y, previous.z ); const next = new Block.Dirt(...coordinates); next.block.appendTo($scene); } }); 

Это тот же .side событий щелчка .side который у нас был раньше, но вместо того, чтобы просто добавлять новые блоки при щелчке по стороне, мы сначала проверяем, находимся ли мы в режиме вычитания. Если мы, блок, который мы только что щелкнули, удаляется со сцены.

Финальная демоверсия

Финальная демоверсия удивительна для игры с:

Нам предстоит пройти долгий путь, прежде чем мы поддержим столько блоков и взаимодействий, сколько Minecraft, но это хорошее начало. Более того, нам удалось достичь этого без необходимости изучать передовые методы 3D. Это нестандартное (и креативное) использование CSS-преобразований!

Если вы хотите сделать больше с этим кодом, перейдите к другой половине этого приключения . Вам не нужно быть экспертом по PHP для взаимодействия с серверами Minecraft. И только представьте, какие удивительные вещи вы можете сделать с этим знанием …

И не забывайте: это только половина приключений. Если вы хотите узнать, как сохранить проекты на реальном сервере, ознакомьтесь с родственным постом, PHP Minecraft Mod . Там мы исследуем способы взаимодействия с сервером Minecraft, манипулирования им в режиме реального времени и реагирования на ввод пользователя.