Как я собрал кубик Рубика в браузере на чистом Canvas

Страницы:  1

Ответить
 

Professor Seleznov


pic
Введение
Недавно я увидел видео, где маленький мальчик собирает кубик Рубика за 2,76 секунды (вот оно), и мне тоже захотелось научиться его собирать. Конечно, не за такое время, но главное — суметь сложить хотя бы за 10 минут. Главная проблема в том, что кубика у меня нет; можно купить, но это как-то скучно, на троечку. Поэтому я подумал: а почему бы не написать за выходные простой код, чтобы побыстрее посмотреть и покрутить кубик, а потом уже можно и купить. Заодно и разберусь, где что находится у кубика.
Первым делом я, конечно, полез смотреть, какие есть библиотеки. Увидел Three.js — очень красиво, но это, на мой взгляд, немного нечестно. Хотелось чистого, своего подхода: сам рассчитываю проекции, сам поворачиваю грани и сам думаю над сложностью проекта. Поэтому я выбрал классический Canvas, который уже часто использовал для классических игр. Впрочем, пару идей из Three.js я всё же позаимствовал.
Итак, я решил начать с малого — с зелёного кубика, как на Википедии у этой библиотеки, и постепенно усовершенствовать его. Всего у меня должно было получиться пять итераций, но здесь немного пришлось переписать, потому что на третьей части, когда я уже создал кубик как 3D-модель на Canvas, пошли жуткие проблемы с поворотом сторон. Я понял, что надо менять подход, так как цель была простая: получить работающую игрушку, а не страдать ради 3D-кубика. И решил сначала досконально разобраться с 2D-кубиком.
Ниже я расскажу обо всех этапах подробно: от зелёного куба до полноценного симулятора, где мы сможем покрутить куб прямо в браузере.
pic
Мой образец
-
Этап 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-й части.
pic
-
Этап 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.x, projected.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>
Однако вращать пока можно было только весь куб, и пока ещё до Рубика было далеко.
pic
-
Этап 3. Кубик Рубика 2×2 (и первые серьёзные проблемы)
Следующая логичная вещь, это разделить один большой куб на 8 маленьких кубиков (2×2×2). Каждый маленький кубик — это независимый объект со своими цветами граней, чтобы можно было спокойно поворачивать. Вращение слоя означало поворот группы из четырёх кубиков вокруг одной оси.
Я быстро понял, что в чистом 3D с матрицами поворотов это превращается в ад: нужно следить за местоположением каждого кубика, обновлять его матрицу и правильно отрисовывать. А если добавить ещё и анимацию поворота слоя — начинается настоящий «матричный детектив».
Здесь уже код большой, поэтому можно посмотреть его на GitHub или в спойлере.
pic
Итог, чтобы не смотреть код
Проблема — или то, с чего я ушёл в тильт. После нескольких поворотов кубики начинали «плыть», их локальные оси путались, а цвета граней оказывались не на своих местах. Это происходило из-за того, что я неправильно применял композицию поворотов. Более того, я заметил, что после нескольких поворотов цвета граней начинают «съезжать» — кубик ведёт себя не как настоящий Рубик. Это были баги в расчётах осей и порядке применения поворотов.
Вот рабочий код моего 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;
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.x, projected.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.x, projected.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 ошибки, которые меня уже добили

pic
Это первая ошибка с прорисовкой и накладкой цветов
pic
Тут добавил линии, но, конечно, для них тоже работает правило
pic
Появление рандомного цвета
pic
Крутой поворот
На картинках выше видно, сколько проблем было с прорисовкой. Я плюнул и понял, что нужно что-то ещё, а то этот способ как-то не выглядит лёгким. Тогда я решил, что надо менять подход радикально. Цель была создать кубик Рубика, а как он будет выглядеть — не так важно, поэтому сделаю ещё один проект.
-
Этап 4. Отказ от настоящего 3D и переход на псевдо‑3D (изометрию)
После трёх этапов с 3D пора сделать изометрию. Нашёл фотографию для примера, как должен выглядеть окончательный проект.
pic
Пример
Вот этот пример и родил концепт второго проекта. И главное, чтобы они отличались, я решил здесь сделать куб 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[j] = cube[5][2-j];
}
Теперь у нас есть полный контроль: видно все шесть граней, хоть и в разных представлениях.
-
Итоговый код (финальная версия)
Вот что получилось в итоге. Вы можете скопировать код в файл .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>

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