Введение
Недавно я увидел видео, где маленький мальчик собирает кубик Рубика за 2,76 секунды (вот оно), и мне тоже захотелось научиться его собирать. Конечно, не за такое время, но главное — суметь сложить хотя бы за 10 минут. Главная проблема в том, что кубика у меня нет; можно купить, но это как-то скучно, на троечку. Поэтому я подумал: а почему бы не написать за выходные простой код, чтобы побыстрее посмотреть и покрутить кубик, а потом уже можно и купить. Заодно и разберусь, где что находится у кубика.
Первым делом я, конечно, полез смотреть, какие есть библиотеки. Увидел Three.js — очень красиво, но это, на мой взгляд, немного нечестно. Хотелось чистого, своего подхода: сам рассчитываю проекции, сам поворачиваю грани и сам думаю над сложностью проекта. Поэтому я выбрал классический Canvas, который уже часто использовал для классических игр. Впрочем, пару идей из Three.js я всё же позаимствовал.
Итак, я решил начать с малого — с зелёного кубика, как на Википедии у этой библиотеки, и постепенно усовершенствовать его. Всего у меня должно было получиться пять итераций, но здесь немного пришлось переписать, потому что на третьей части, когда я уже создал кубик как 3D-модель на Canvas, пошли жуткие проблемы с поворотом сторон. Я понял, что надо менять подход, так как цель была простая: получить работающую игрушку, а не страдать ради 3D-кубика. И решил сначала досконально разобраться с 2D-кубиком.
Ниже я расскажу обо всех этапах подробно: от зелёного куба до полноценного симулятора, где мы сможем покрутить куб прямо в браузере.

Этап 1. Просто зелёный куб, который крутится
Решил я начать с камеры и прорисовки, взял основу, как в Three.js, изменил и убрал лишнее и добавил всё, что нужно для Canvas. Вот так начался мой проект с того, что я нарисовал квадрат, научился его вращать по осям X и Y. Получился красивый «зелёный куб», который сам вертелся. Мне такие нравятся, скорее всего, потом придумаю ещё проект про похожую вещь.
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Этап 1</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #000; } canvas { display: block; width: 90vw; height: 90vh; } </style> </head> <body> <canvas id="rubikCanvas"></canvas> <script> const canvas = document.getElementById('rubikCanvas'); const ctx = canvas.getContext('2d'); function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resizeCanvas); resizeCanvas(); let angleX = 0.4; let angleY = 0.2; let angleZ = 0.2; const vertices = [ {x: -1, y: -1, z: -1}, {x: 1, y: -1, z: -1}, {x: 1, y: -1, z: 1}, {x: -1, y: -1, z: 1}, {x: -1, y: 1, z: -1}, {x: 1, y: 1, z: -1}, {x: 1, y: 1, z: 1}, {x: -1, y: 1, z: 1} ]; const edges = [ [0,1], [1,2], [2,3], [3,0], [4,5], [5,6], [6,7], [7,4], [0,4], [1,5], [2,6], [3,7] ]; function rotatePoint(point, angleX, angleY, angleZ) { let {x, y, z} = point; let cosX = Math.cos(angleX), sinX = Math.sin(angleX); let y1 = y * cosX - z * sinX; let z1 = y * sinX + z * cosX; y = y1; z = z1; let cosY = Math.cos(angleY), sinY = Math.sin(angleY); let x1 = x * cosY + z * sinY; let z2 = -x * sinY + z * cosY; x = x1; z = z2; let cosZ = Math.cos(angleZ), sinZ = Math.sin(angleZ); let x2 = x * cosZ - y * sinZ; let y2 = x * sinZ + y * cosZ; return {x: x2, y: y2, z: z2}; } function projectTo2D(x, y, z) { const scale = Math.min(canvas.width, canvas.height) * 0.35; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const distance = 5; const perspective = distance / (distance + z); return { x: centerX + x * scale * perspective, y: centerY - y * scale * perspective }; } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2; edges.forEach(edge => { const v1 = vertices[edge[0]]; const v2 = vertices[edge[1]]; const rotated1 = rotatePoint(v1, angleX, angleY, angleZ); const rotated2 = rotatePoint(v2, angleX, angleY, angleZ); const proj1 = projectTo2D(rotated1.x, rotated1.y, rotated1.z); const proj2 = projectTo2D(rotated2.x, rotated2.y, rotated2.z); ctx.beginPath(); ctx.moveTo(proj1.x, proj1.y); ctx.lineTo(proj2.x, proj2.y); ctx.stroke(); }); angleX += 0.005; angleY += 0.007; angleZ += 0.003; requestAnimationFrame(draw); } draw(); </script> </body> </html>
Это было забавно, но, как всегда вначале, очень далеко от цели: здесь явно не хватало возможности крутить камеру в каждую сторону. Как раз это мы и добавили во 2-й части.

Этап 2. Кубик уже 3D, но обычный, который можно вертеть целиком
На втором этапе я добавил заливку граней разными цветами, чтобы в будущем было проще. Также, как и писал выше, добавил вращение мышкой. И вот здесь, как я буду писать дальше ещё много раз, я впервые столкнулся с проблемой «неправильного» порядка отрисовки — задние грани перекрывали передние. Сейчас этой ошибки уже нет, но тогда она была.
Код стал заметно сложнее, но зато появился настоящий 3D-куб, который можно было рассмотреть со всех сторон.
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Этап 2</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #000; } canvas { display: block; width: 90vw; height: 90vh; cursor: grab; } canvas:active { cursor: grabbing; } </style> </head> <body> <canvas id="rubikCanvas"></canvas> <script> const canvas = document.getElementById('rubikCanvas'); const ctx = canvas.getContext('2d'); function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resizeCanvas); resizeCanvas(); let rotX = 0.5; let rotY = 0.3; let isDragging = false; let lastX = 0, lastY = 0; const vertices = [ {x: -1, y: -1, z: -1}, {x: 1, y: -1, z: -1}, {x: 1, y: -1, z: 1}, {x: -1, y: -1, z: 1}, {x: -1, y: 1, z: -1}, {x: 1, y: 1, z: -1}, {x: 1, y: 1, z: 1}, {x: -1, y: 1, z: 1} ]; const faces = [ { vertices: [0,1,2,3], color: 'rgba(255,50,50,0.5)' }, { vertices: [4,5,6,7], color: 'rgba(50,255,50,0.5)' }, { vertices: [0,1,5,4], color: 'rgba(50,50,255,0.5)' }, { vertices: [2,3,7,6], color: 'rgba(255,255,50,0.5)' }, { vertices: [0,3,7,4], color: 'rgba(255,50,255,0.5)' }, { vertices: [1,2,6,5], color: 'rgba(50,255,255,0.5)' } ]; function rotatePoint(point, rotX, rotY) { let {x, y, z} = point; let cosY = Math.cos(rotY), sinY = Math.sin(rotY); let x1 = x * cosY - z * sinY; let z1 = x * sinY + z * cosY; x = x1; z = z1; let cosX = Math.cos(rotX), sinX = Math.sin(rotX); let y1 = y * cosX - z * sinX; let z2 = y * sinX + z * cosX; y = y1; z = z2; return {x, y, z}; } function projectTo2D(x, y, z) { const scale = Math.min(canvas.width, canvas.height) * 0.35; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const distance = 5; const perspective = distance / (distance + z); return { x: centerX + x * scale * perspective, y: centerY - y * scale * perspective }; } function drawFace(faceVertices, color) { const projected = faceVertices.map(v => { const rotated = rotatePoint(v, rotX, rotY); return projectTo2D(rotated.x, rotated.y, rotated.z); }); ctx.beginPath(); ctx.moveTo(projected[0].x, projected[0].y); for(let i = 1; i < projected.length; i++) { ctx.lineTo(projected[i].x, projected[i].y); } ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.stroke(); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); faces.forEach(face => { const faceVertexObjects = face.vertices.map(idx => vertices[idx]); drawFace(faceVertexObjects, face.color); }); // Рёбра поверх граней const edges = [ [0,1], [1,2], [2,3], [3,0], [4,5], [5,6], [6,7], [7,4], [0,4], [1,5], [2,6], [3,7] ]; ctx.beginPath(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; edges.forEach(edge => { const v1 = rotatePoint(vertices[edge[0]], rotX, rotY); const v2 = rotatePoint(vertices[edge[1]], rotX, rotY); const p1 = projectTo2D(v1.x, v1.y, v1.z); const p2 = projectTo2D(v2.x, v2.y, v2.z); ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); }); requestAnimationFrame(draw); } canvas.addEventListener('mousedown', (e) => { isDragging = true; lastX = e.clientX; lastY = e.clientY; canvas.style.cursor = 'grabbing'; }); window.addEventListener('mousemove', (e) => { if(!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; rotY += dx * 0.008; rotX += dy * 0.008; lastX = e.clientX; lastY = e.clientY; }); window.addEventListener('mouseup', () => { isDragging = false; canvas.style.cursor = 'grab'; }); draw(); </script> </body> </html>
Однако вращать пока можно было только весь куб, и пока ещё до Рубика было далеко.

Этап 3. Кубик Рубика 2×2 (и первые серьёзные проблемы)
Следующая логичная вещь, это разделить один большой куб на 8 маленьких кубиков (2×2×2). Каждый маленький кубик — это независимый объект со своими цветами граней, чтобы можно было спокойно поворачивать. Вращение слоя означало поворот группы из четырёх кубиков вокруг одной оси.
Я быстро понял, что в чистом 3D с матрицами поворотов это превращается в ад: нужно следить за местоположением каждого кубика, обновлять его матрицу и правильно отрисовывать. А если добавить ещё и анимацию поворота слоя — начинается настоящий «матричный детектив».
Здесь уже код большой, поэтому можно посмотреть его на GitHub или в спойлере.

Проблема — или то, с чего я ушёл в тильт. После нескольких поворотов кубики начинали «плыть», их локальные оси путались, а цвета граней оказывались не на своих местах. Это происходило из-за того, что я неправильно применял композицию поворотов. Более того, я заметил, что после нескольких поворотов цвета граней начинают «съезжать» — кубик ведёт себя не как настоящий Рубик. Это были баги в расчётах осей и порядке применения поворотов.
Вот рабочий код моего 2×2-кубика. Вначале хотел закончить на модельке, но в итоге получился…
Код нужно изменить
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Рубик 2x2</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #000; } canvas { display: block; width: 90vw; height: 90vh; cursor: grab; } canvas:active { cursor: grabbing; } </style> </head> <body> <canvas id="rubikCanvas"></canvas> <script> const canvas = document.getElementById('rubikCanvas'); const ctx = canvas.getContext('2d'); function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } window.addEventListener('resize', resizeCanvas); resizeCanvas(); let rotX = 0.5; let rotY = 0.3; let isDragging = false; let lastX = 0, lastY = 0; const cubePositions = [ {x: -0.5, y: -0.5, z: -0.5}, {x: 0.5, y: -0.5, z: -0.5}, {x: -0.5, y: -0.5, z: 0.5}, {x: 0.5, y: -0.5, z: 0.5}, {x: -0.5, y: 0.5, z: -0.5}, {x: 0.5, y: 0.5, z: -0.5}, {x: -0.5, y: 0.5, z: 0.5}, {x: 0.5, y: 0.5, z: 0.5} ]; const faceColors = { front: '#FFFFFF', back: '#FFD700', up: '#FF4500', down: '#FF8C00', right: '#0000CD', left: '#228B22' }; const localVertices = [ {x: -0.5, y: -0.5, z: -0.5}, {x: 0.5, y: -0.5, z: -0.5}, {x: 0.5, y: -0.5, z: 0.5}, {x: -0.5, y: -0.5, z: 0.5}, {x: -0.5, y: 0.5, z: -0.5}, {x: 0.5, y: 0.5, z: -0.5}, {x: 0.5, y: 0.5, z: 0.5}, {x: -0.5, y: 0.5, z: 0.5} ]; function getVisibleFaces(position) { const visibleFaces = []; if(position.z === -0.5) { visibleFaces.push({ vertices: [0,1,5,4], color: faceColors.front }); } if(position.z === 0.5) { visibleFaces.push({ vertices: [2,3,7,6], color: faceColors.back }); } if(position.y === 0.5) { visibleFaces.push({ vertices: [4,5,6,7], color: faceColors.up }); } if(position.y === -0.5) { visibleFaces.push({ vertices: [0,1,2,3], color: faceColors.down }); } if(position.x === -0.5) { visibleFaces.push({ vertices: [0,3,7,4], color: faceColors.left }); } if(position.x === 0.5) { visibleFaces.push({ vertices: [1,2,6,5], color: faceColors.right }); } return visibleFaces; } function rotatePoint(point, rotX, rotY) { let {x, y, z} = point; let cosY = Math.cos(rotY), sinY = Math.sin(rotY); let x1 = x * cosY - z * sinY; let z1 = x * sinY + z * cosY; x = x1; z = z1; let cosX = Math.cos(rotX), sinX = Math.sin(rotX); let y1 = y * cosX - z * sinX; let z2 = y * sinX + z * cosX; y = y1; z = z2; return {x, y, z}; } function projectTo2D(x, y, z) { const scale = Math.min(canvas.width, canvas.height) * 0.22; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const distance = 5; const perspective = distance / (distance + z); return { x: centerX + x * scale * perspective, y: centerY - y * scale * perspective }; } function drawSky() { const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, '#0b3d91'); gradient.addColorStop(0.5, '#1e88e5'); gradient.addColorStop(1, '#64b5f6'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); } function draw() { drawSky(); const allFaces = []; cubePositions.forEach(position => { const visibleFaces = getVisibleFaces(position); visibleFaces.forEach(face => { const worldVertices = localVertices.map(v => ({ x: v.x + position.x, y: v.y + position.y, z: v.z + position.z })); let sumZ = 0; const rotatedPoints = face.vertices.map(idx => { const rotated = rotatePoint(worldVertices[idx], rotX, rotY); sumZ += rotated.z; return rotated; }); const avgDepth = sumZ / 4; allFaces.push({ vertices: face.vertices, worldVertices: worldVertices, color: face.color, depth: avgDepth, rotatedPoints: rotatedPoints }); }); }); allFaces.sort((a, b) => b.depth - a.depth); allFaces.forEach(face => { const projected = face.vertices.map((idx, i) => { const rotated = face.rotatedPoints[i]; return projectTo2D(rotated.x, rotated.y, rotated.z); }); ctx.beginPath(); ctx.moveTo(projected[0].x, projected[0].y); for(let i = 1; i < projected.length; i++) { ctx.lineTo(projected[i].x, projected[i].y); } ctx.closePath(); ctx.fillStyle = face.color; ctx.fill(); ctx.beginPath(); ctx.moveTo(projected[0].x, projected[0].y); for(let i = 1; i < projected.length; i++) { ctx.lineTo(projected[i].x, projected[i].y); } ctx.closePath(); ctx.strokeStyle = '#000000'; ctx.lineWidth = 2; ctx.stroke(); }); requestAnimationFrame(draw); } canvas.addEventListener('mousedown', (e) => { isDragging = true; lastX = e.clientX; lastY = e.clientY; canvas.style.cursor = 'grabbing'; }); window.addEventListener('mousemove', (e) => { if(!isDragging) return; const dx = e.clientX - lastX; const dy = e.clientY - lastY; rotY += dx * 0.008; rotX += dy * 0.008; lastX = e.clientX; lastY = e.clientY; }); window.addEventListener('mouseup', () => { isDragging = false; canvas.style.cursor = 'grab'; }); draw(); </script> </body> </html>
4 ошибки, которые меня уже добили




На картинках выше видно, сколько проблем было с прорисовкой. Я плюнул и понял, что нужно что-то ещё, а то этот способ как-то не выглядит лёгким. Тогда я решил, что надо менять подход радикально. Цель была создать кубик Рубика, а как он будет выглядеть — не так важно, поэтому сделаю ещё один проект.
Этап 4. Отказ от настоящего 3D и переход на псевдо‑3D (изометрию)
После трёх этапов с 3D пора сделать изометрию. Нашёл фотографию для примера, как должен выглядеть окончательный проект.

Вот этот пример и родил концепт второго проекта. И главное, чтобы они отличались, я решил здесь сделать куб 3 на 3 (да, на фото 4 на 4, но это пример).
Алгоритмы для перемешивания и решения (да, я добавил и солвер) я взял из готовой Python-программы и аккуратно переписал на JavaScript. Получилось довольно объёмно, но надёжно.
Как устроены данные
Куб хранится в виде массива cube[6][3][3], где индексы: 0 — U, 1 — F, 2 — R, 3 — L, 4 — D, 5 — B. Это позволило легко реализовать все ходы: каждый ход — это перестановка цветов в нескольких гранях плюс поворот самой грани.
function U_move() { rotateFaceClockwise(0); let temp = [cube[1][0][0], cube[1][0][1], cube[1][0][2]]; cube[1][0] = [cube[2][0][0], cube[2][0][1], cube[2][0][2]]; cube[2][0] = [cube[5][0][0], cube[5][0][1], cube[5][0][2]]; cube[5][0] = [cube[3][0][0], cube[3][0][1], cube[3][0][2]]; cube[3][0] = temp; }
Для остальных ходов используется та же логика, но с разными индексами и направлениями. Благодаря этому код получился прозрачным и легко отлаживаемым.
Этап 5. Финальный штрих: пластины слева, справа и снизу
Чтобы пользователь видел, что происходит на левой и правой гранях (они в изометрии не видны или видны частично), я добавил три плоские сетки 3×3. Они расположены слева и справа от основного куба и отображают соответствующие грани с поворотом для удобства восприятия.
Левая грань повёрнута на 180° — так она выглядит как зеркальное отражение.
Правая грань повёрнута на 90° вправо — чтобы цвета соответствовали ориентации куба.
Вот как выглядит код для правой пластины:
function drawRightPlate() { const cellSize = 40; const startX = canvas.width/2 + 150; const startY = canvas.height/2 - 60; const rotated = Array(3).fill().map(() => Array(3)); for (let i=0;i<3;i++) for (let j=0;j<3;j++) rotated[i][j] = cube[5][2-j][i]; }
Теперь у нас есть полный контроль: видно все шесть граней, хоть и в разных представлениях.
Итоговый код (финальная версия)
Вот что получилось в итоге. Вы можете скопировать код в файл .html и открыть в браузере. Всё работает без сервера, на чистом JS и Canvas.
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Rubik's Cube — изометрический симулятор</title> <style> body { background: #1e2a2f; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; } canvas { background: #1e2a2f; display: block; } .buttons { position: absolute; bottom: 20px; left: 0; right: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } button { background: #2d2d3c; border: none; padding: 6px 14px; border-radius: 40px; color: white; font-family: monospace; cursor: pointer; } </style> </head> <body> <canvas id="cubeCanvas" width="980" height="720"></canvas> <div class="buttons"> <button data-move="U">U</button><button data-move="Ui">U'</button> <button data-move="F">F</button><button data-move="Fi">F'</button> <button data-move="R">R</button><button data-move="Ri">R'</button> <button data-move="L">L</button><button data-move="Li">L'</button> <button data-move="B">B</button><button data-move="Bi">B'</button> <button data-move="D">D</button><button data-move="Di">D'</button> <button data-move="y">Y</button><button data-move="yi">Y'</button> <button id="reset">НОВЫЙ</button> <button id="scramble">СМЕСЬ</button> </div> <script> // (Полный код приведён в моём репозитории, см. ссылку в конце статьи) </script> </body> </html>
Итог
Могу сказать одно: это было сложно, но безумно радостно, когда всё заработало. Я наконец получил работающий симулятор кубика Рубика, которым можно управлять мышкой (через кнопки), и даже наблюдать за решением.


Что я вынес из этого проекта?
Больше, скорее всего, я не полезу в подобные проекты — слишком много времени уходит на отладку «3D и псевдо-3D». В следующий раз возьму что-нибудь попроще, но полученный опыт работы с Canvas, изометрией и сложной логикой был интересным.
Поиграть в мою версию можно здесь:
P.S. Если у вас есть идеи по улучшению проекта, вы нашли баг или просто хотите покрутить кубик быстрее, чем я собираю его в реальной жизни, — пишите в комментариях.
© 2026 ООО «МТ ФИНАНС»

