惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
博客园 - 【当耐特】
Cloudbric
Cloudbric
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Attack and Defense Labs
Attack and Defense Labs
爱范儿
爱范儿
The Cloudflare Blog
腾讯CDC
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
云风的 BLOG
云风的 BLOG
Recent Announcements
Recent Announcements
C
Check Point Blog
Schneier on Security
Schneier on Security
S
Schneier on Security
J
Java Code Geeks
B
Blog RSS Feed
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
Stack Overflow Blog
Stack Overflow Blog
博客园_首页
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
A
About on SuperTechFans
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Google DeepMind News
Google DeepMind News
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
A
Arctic Wolf
S
Secure Thoughts
P
Palo Alto Networks Blog
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
量子位
U
Unit 42
I
InfoQ
D
DataBreaches.Net
P
Privacy International News Feed
T
Troy Hunt's Blog
博客园 - 叶小钗
T
Threatpost
博客园 - Franky
K
Kaspersky official blog
Hugging Face - Blog
Hugging Face - Blog
IT之家
IT之家
www.infosecurity-magazine.com
www.infosecurity-magazine.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
C
Cisco Blogs

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я собрал кубик Рубика в браузере на чистом Canvas
Laborant_Cod · 2026-05-20 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение12 мин

Охват и читатели15

Туториал

Введение

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