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

Попробуем сделать небольшую демку minecraft в браузере. Пригодятся знания JS и three.js.
Немного условностей. Я не претендую на звание лучшее приложение столетия. Это всего лишь моя реализация для данной задачи. Также есть видео версия для тех кому лень читать(там тот же смысл, но другими словами).
В конце статьи есть все нужные ссылки. Постараюсь как можно меньше воды в тексте. Объяснять работу каждой строки не буду. Вот теперь можно начать.
Для начала чтобы понимать какой будет итог, то вот демка игры.
Разделим статью на несколько частей:
- Структура проекта
- Игровой цикл
- Настройки игры
- Генерация карты
- Камера и управление
Структура проекта
Вот так выглядит структура проекта.

index.html — Расположение канваса, немного интерфейса и подключение стилей, скриптов.
style.css — Стили только для внешнего вида. Самое важное это кастомный курсор для игры который располагается в центре экрана.
texture — Здесь лежат текстуры для курсора и блока земли для игры.
core.js — Основной скрипт где происходит инициализация проекта.
perlin.js — Это библиотека для шума Перлина.
PointerLockControls.js — Камера от three.js.
controls.js — Управление камерой и игроком.
generationMap.js — Генерация мира.
three.module.js — Сам three.js в виде модуля.
settings.js — Настройки проекта.
index.html
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="style/style.css"> <title>Minecraft clone</title> </head> <body> <canvas id="game" tabindex="1"></canvas> <div class="game-info"> <div> <span><b>WASD: </b>Передвижение</span> <span><b>ЛКМ: </b> Поставить блок</span> <span><b>ПКМ: </b> Удалить блок</span> </div> <hr> <div id="debug"> <span><b></b></span> </div> </div> <div id="cursor"></div> <script src="scripts/perlin.js"></script> <script src="scripts/core.js" type="module"></script> </body> </html>
style.css
body { margin: 0px; width: 100vw; height: 100vh; } #game { width: 100%; height: 100%; display: block; } #game:focus { outline: none; } .game-info { position: absolute; left: 1em; top: 1em; padding: 1em; background: rgba(0, 0, 0, 0.9); color: white; font-family: monospace; pointer-events: none; } .game-info span { display: block; } .game-info span b { font-size: 18px; } #cursor { width: 16px; height: 16px; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-image: url("../texture/cursor.png"); background-repeat: no-repeat; background-size: 100%; filter: brightness(100); }
Игровой цикл
В core.js нужно провести инициализацию three.js, настроить его и добавить все нужные модули от игры + обработчики событий… ну и игровой цикл запустить. В учет того, что все настройки стандартные, то объяснять их нет смысла. Поговорить можно про map (он принимает сцену игры для добавления блоков) и contorls т.к. он принимает несколько параметров. Первый это камера от three.js, сцену для добавления блоков и карту чтобы можно было взаимодействовать с ней. update отвечает за обновление камеры, GameLoop — игровой цикл, render- стандарт от three.js для обновления кадра, событие resize также стандарт для работы с канвасом (это реализация адаптива).
core.js
import * as THREE from './components/three.module.js'; import { PointerLockControls } from './components/PointerLockControls.js'; import { Map } from "./components/generationMap.js"; import { Controls } from "./components/controls.js"; // стандартные настройки three.js const canvas = document.querySelector("#game"); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x00ffff); scene.fog = new THREE.Fog(0x00ffff, 10, 650); const renderer = new THREE.WebGLRenderer({canvas}); renderer.setSize(window.innerWidth, window.innerHeight); const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(50, 40, 50); // Создание карты let mapWorld = new Map(); mapWorld.generation(scene); let controls = new Controls( new PointerLockControls(camera, document.body), scene, mapWorld ); renderer.domElement.addEventListener( "keydown", (e)=>{ controls.inputKeydown(e); } ); renderer.domElement.addEventListener( "keyup", (e)=>{ controls.inputKeyup(e); } ); document.body.addEventListener( "click", (e) => { controls.onClick(e); }, false ); function update(){ // передвижение/камера controls.update(); }; GameLoop(); // Игровой цикл function GameLoop() { update(); render(); requestAnimationFrame(GameLoop); } // Рендер сцены(1 кадра) function render(){ renderer.render(scene, camera); } // обновление размера игры window.addEventListener("resize", function() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
Настройки
В настройки можно было вынести и другие параметры, например, настройки three.js, но я сделал без них и сейчас здесь лишь пара параметров отвечающие за размер блоков.
settings.js
export class Settings { constructor() { // площадь блока this.blockSquare = 5; // размер и площадь чанка this.chunkSize = 16; this.chunkSquare = this.chunkSize * this.chunkSize; } }
Генерация карты
В классе Map у нас есть несколько свойство которые отвечают за кеш материалов и параметры для шума Перлина. В методе generation мы загружаем текстуры, создаем геометрию и меш. noise.seed отвечает за стартовое зерно для генерации карты. Можно рандом заменить на статичное значение чтобы карты всегда была одинаковая. В цикле по X и Z координатам начинаем расставлять кубы. Y координата генерируется за счет библиотеки pretlin.js. В конечном итоге мы добавляем куб с нужными координатами на сцену через scene.add( cube );
generationMap.js
import * as THREE from './three.module.js'; import { Settings } from "./settings.js"; export class Map { constructor(){ this.materialArray; this.xoff = 0; this.zoff = 0; this.inc = 0.05; this.amplitude = 30 + (Math.random() * 70); } generation(scene) { const settings = new Settings(); const loader = new THREE.TextureLoader(); const materialArray = [ new THREE.MeshBasicMaterial( { map: loader.load("../texture/dirt-side.jpg") } ), new THREE.MeshBasicMaterial( { map: loader.load('../texture/dirt-side.jpg') } ), new THREE.MeshBasicMaterial( { map: loader.load('../texture/dirt-top.jpg') } ), new THREE.MeshBasicMaterial( { map: loader.load('../texture/dirt-bottom.jpg') } ), new THREE.MeshBasicMaterial( { map: loader.load('../texture/dirt-side.jpg') } ), new THREE.MeshBasicMaterial( { map: loader.load('../texture/dirt-side.jpg') } ) ]; this.materialArray = materialArray; const geometry = new THREE.BoxGeometry( settings.blockSquare, settings.blockSquare, settings.blockSquare); noise.seed(Math.random()); for(let x = 0; x < settings.chunkSize; x++) { for(let z = 0; z < settings.chunkSize; z++) { let cube = new THREE.Mesh(geometry, materialArray); this.xoff = this.inc * x; this.zoff = this.inc * z; let y = Math.round(noise.perlin2(this.xoff, this.zoff) * this.amplitude / 5) * 5; cube.position.set(x * settings.blockSquare, y, z * settings.blockSquare); scene.add( cube ); } } } }
Камера и управление
Я уже говорил, что controls принимает параметры в виде камеры, сцены и карты. Также в конструкторе мы добавляем массив keys для клавиш и movingSpeed для скорости. Для мыши у нас есть 3 метода. onClick определяет какая кнопка нажата, а onRightClick и onLeftClick уже отвечают за действия. Правый клик(удаление блока) происходит через raycast и поиска пересеченных элементов. Если их нет, то прекращаем работу, если есть, то удаляем первый элеент. Левый клик работает по схожей системе. Для начала создаем блок. Запускаем рейкаст и если есть блок который пересек луч, то получаем координаты этого блока. Далее определяем с какой стороны произошел клик. Меняем координаты для созданного куба в соответствии со стороной к которой мы добавляем блок. градация в 5 единиц т.к. это размер блока(да здесь можно было использовать свойство из settings).
Как работает управление камерой?! У нас есть три метода inputKeydown, inputKeyup и update. В inputKeydown мы добавляем кнопку в массив keys. inputKeyup отвечает за очистку кнопок из массива которые отжали. В update идет проверка keys и вызывается moveForward у камеры, параметры которые принимает метод это скорость.
controls.js
import * as THREE from "./three.module.js"; import { Settings } from "./settings.js"; export class Controls { constructor(controls, scene, mapWorld){ this.controls = controls; this.keys = []; this.movingSpeed = 1.5; this.scene = scene; this.mapWorld = mapWorld; } // клик onClick(e) { e.stopPropagation(); e.preventDefault(); this.controls.lock(); if (e.button == 0) { this.onLeftClick(e); } else if (e.button == 2) { this.onRightClick(e); } } onRightClick(e){ // Удаление элемента по клику const raycaster = new THREE.Raycaster(); raycaster.setFromCamera( new THREE.Vector2(), this.controls.getObject() ); let intersects = raycaster.intersectObjects( this.scene.children ); if (intersects.length < 1) return; this.scene.remove( intersects[0].object ); } onLeftClick(e) { const raycaster = new THREE.Raycaster(); const settings = new Settings(); // Поставить элемент по клику const geometry = new THREE.BoxGeometry(settings.blockSquare, settings.blockSquare, settings.blockSquare); const cube = new THREE.Mesh(geometry, this.mapWorld.materialArray); raycaster.setFromCamera( new THREE.Vector2(), this.controls.getObject() ); const intersects = raycaster.intersectObjects( this.scene.children ); if (intersects.length < 1) return; const psn = intersects[0].object.position; switch(intersects[0].face.materialIndex) { case 0: cube.position.set(psn.x + 5, psn.y, psn.z); break; case 1: cube.position.set(psn.x - 5, psn.y, psn.z); break; case 2: cube.position.set(psn.x, psn.y + 5, psn.z); break; case 3: cube.position.set(psn.x, psn.y - 5, psn.z); break; case 4: cube.position.set(psn.x, psn.y, psn.z + 5); break; case 5: cube.position.set(psn.x, psn.y, psn.z - 5); break; } this.scene.add(cube); } // нажали на клавишу inputKeydown(e) { this.keys.push(e.key); } // отпустили клавишу inputKeyup(e) { let newArr = []; for(let i = 0; i < this.keys.length; i++){ if(this.keys[i] != e.key){ newArr.push(this.keys[i]); } } this.keys = newArr; } update() { // Движение камеры if ( this.keys.includes("w") || this.keys.includes("ц") ) { this.controls.moveForward(this.movingSpeed); } if ( this.keys.includes("a") || this.keys.includes("ф") ) { this.controls.moveRight(-1 * this.movingSpeed); } if ( this.keys.includes("s") || this.keys.includes("ы") ) { this.controls.moveForward(-1 * this.movingSpeed); } if ( this.keys.includes("d") || this.keys.includes("в") ) { this.controls.moveRight(this.movingSpeed); } } }
Ссылки
Как и обещал. Весь материал который пригодится.
Если есть желание, то на можете добавить свой функционал к проекту на гитхаб.
ссылка на оригинал статьи https://habr.com/ru/post/535992/
Добавить комментарий