Введение

Недавно я увидел видео, где маленький мальчик собирает кубик Рубика за 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 ООО «МТ ФИНАНС»