JSで作るみんなで楽しく自由に回せるルービックキューブ

アニメーション

JSで作るみんなで楽しく自由に回せるルービックキューブ

投稿日2026/02/01

更新日2026/1/28

立体オブジェクトを表示するだけでなく、「触って操作できる」ことが伝わるかどうかは、体験の質を大きく左右します。

本デモでは、Three.jsを用いてルービックキューブを立体的に描画し、マウスやタッチ操作によって、実際に回して遊べるインタラクションを実装しました。

単なる3D表現ではなく、操作感と視認性の両立を重視した構成になっています。

Preview プレビュー

Code コード

<div id="kumonosu-ui">
    <h1 class="kumonosu-title">Rubik's Cube</h1>
</div>
<div id="kumonosu-controls">
    <button id="kumonosu-shuffle-btn" class="kumonosu-btn">Shuffle</button>
    <button id="kumonosu-solve-btn" class="kumonosu-btn">Solve</button>
    <button id="kumonosu-reset-btn" class="kumonosu-btn">Reset</button>
</div>
<div id="kumonosu-game-container"></div>
body {
    margin: 0;
    overflow: hidden;
    background: #050505;
    font-family: 'Segoe UI', 'Hiragino Kaku Gothic ProN', sans-serif;
    user-select: none;
}
#kumonosu-game-container {
    width: 100vw;
    height: 100vh;
    cursor: grab;
}
#kumonosu-game-container:active {
    cursor: grabbing;
}
#kumonosu-ui {
    position: absolute;
    top: 5%;
    text-align: center;
    width: 100%;
    pointer-events: none;
    color: white;
}
.kumonosu-title {
    font-size: 2rem;
    font-weight: 900;
    letter-spacing: 0.3rem;
    opacity: 0.7;
    margin-bottom: 10px;
    text-transform: uppercase;
}
#kumonosu-controls {
    position: absolute;
    bottom: 8%;
    width: 100%;
    display: flex;
    justify-content: center;
    gap: 15px;
    pointer-events: none;
    flex-wrap: wrap;
}
.kumonosu-btn {
    pointer-events: auto;
    padding: 12px 20px;
    font-size: 0.85rem;
    font-weight: bold;
    color: white;
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.3);
    border-radius: 30px;
    cursor: pointer;
    backdrop-filter: blur(5px);
    transition: all 0.3s ease;
    text-transform: uppercase;
    letter-spacing: 0.1rem;
    outline: none;
    min-width: 100px;
}
.kumonosu-btn:hover {
    background: rgba(255, 255, 255, 0.2);
    border-color: rgba(255, 255, 255, 0.8);
    transform: translateY(-2px);
}
.kumonosu-btn:active {
    transform: translateY(0);
}
/* Solveボタンを目立たせる */
#kumonosu-solve-btn {
    background: rgba(20, 164, 90, 0.2);
    border-color: rgba(20, 164, 90, 0.5);
}
#kumonosu-solve-btn:hover {
    background: rgba(20, 164, 90, 0.4);
    border-color: rgba(20, 164, 90, 1);
}
class KumonosuRubiksGame {
    constructor() {
        this.container = document.getElementById('kumonosu-game-container');
        this.scene = new THREE.Scene();
        
        this.camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.camera.position.set(8, 9, 15);
        this.camera.lookAt(0, -0.5, 0);
        this.scene.add(this.camera);

        this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        this.container.appendChild(this.renderer.domElement);

        this.displayGroup = new THREE.Group();
        this.cubeRoot = new THREE.Group();
        this.displayGroup.add(this.cubeRoot);
        this.scene.add(this.displayGroup);

        // 操作履歴を保存する配列
        this.moveHistory = [];

        this.initLights();
        this.initCube();
        this.initGround();
        
        this.raycaster = new THREE.Raycaster();
        this.mouse = new THREE.Vector2();
        
        this.isAnimating = false;
        this.dragMode = null;
        this.lastMousePos = new THREE.Vector2();
        this.idleTimer = 0;

        this.initEvents();
        this.animate();
    }

    initLights() {
        this.scene.add(new THREE.AmbientLight(0xffffff, 0.3));
        this.mainLight = new THREE.DirectionalLight(0xffffff, 1.2);
        this.mainLight.position.set(-10, 10, 5); 
        this.mainLight.castShadow = true;
        this.mainLight.shadow.mapSize.set(1024, 1024);
        this.camera.add(this.mainLight);

        const fillLight = new THREE.PointLight(0x99aaff, 0.6);
        fillLight.position.set(10, -10, -5);
        this.camera.add(fillLight);
    }

    initGround() {
        const plane = new THREE.Mesh(
            new THREE.PlaneGeometry(100, 100),
            new THREE.ShadowMaterial({ opacity: 0.3 })
        );
        plane.rotation.x = -Math.PI / 2;
        plane.position.y = -5;
        plane.receiveShadow = true;
        this.scene.add(plane);
    }

    createStickerTexture(color) {
        const canvas = document.createElement('canvas');
        canvas.width = 512; canvas.height = 512;
        const ctx = canvas.getContext('2d');
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, 512, 512);
        const border = 40; const radius = 50; 
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.roundRect(border, border, 512-border*2, 512-border*2, radius);
        ctx.fill();
        ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
        ctx.beginPath();
        ctx.roundRect(border, 400, 512-border*2, 50, [0, 0, radius, radius]);
        ctx.fill();
        return new THREE.CanvasTexture(canvas);
    }

    initCube() {
        this.cubelets = [];
        const colors = { 
            red: '#d51532', orange: '#f48c34', white: '#e6e6e6', 
            yellow: '#f4dc47', green: '#14a45a', blue: '#0c53a6', internal: '#111' 
        };
        const tex = { 
            r: this.createStickerTexture(colors.red), l: this.createStickerTexture(colors.orange), 
            t: this.createStickerTexture(colors.white), b: this.createStickerTexture(colors.yellow), 
            f: this.createStickerTexture(colors.green), bk: this.createStickerTexture(colors.blue), 
            k: this.createStickerTexture(colors.internal) 
        };

        const spacing = 1.02;
        for (let x = -1; x <= 1; x++) {
            for (let y = -1; y <= 1; y++) {
                for (let z = -1; z <= 1; z++) {
                    const mats = [
                        new THREE.MeshStandardMaterial({ map: x === 1 ? tex.r : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: x === -1 ? tex.l : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: y === 1 ? tex.t : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: y === -1 ? tex.b : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: z === 1 ? tex.f : tex.k, roughness: 0.4 }),
                        new THREE.MeshStandardMaterial({ map: z === -1 ? tex.bk : tex.k, roughness: 0.4 })
                    ];
                    const cubelet = new THREE.Mesh(new THREE.BoxGeometry(0.98, 0.98, 0.98), mats);
                    cubelet.position.set(x * spacing, y * spacing, z * spacing);
                    cubelet.castShadow = true;
                    cubelet.receiveShadow = true;
                    
                    cubelet.userData.homePos = cubelet.position.clone();
                    cubelet.userData.homeQuat = cubelet.quaternion.clone();

                    this.cubeRoot.add(cubelet);
                    this.cubelets.push(cubelet);
                }
            }
        }
    }

    initEvents() {
        window.addEventListener('resize', () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
        });

        document.getElementById('kumonosu-shuffle-btn').addEventListener('click', () => this.shuffle());
        document.getElementById('kumonosu-reset-btn').addEventListener('click', () => this.reset());
        document.getElementById('kumonosu-solve-btn').addEventListener('click', () => this.solve());

        const getPos = (e) => e.touches ? {x: e.touches[0].clientX, y: e.touches[0].clientY} : {x: e.clientX, y: e.clientY};
        
        const onDown = (e) => {
            if (this.isAnimating) return;
            const pos = getPos(e);
            this.mouse.x = (pos.x / window.innerWidth) * 2 - 1;
            this.mouse.y = -(pos.y / window.innerHeight) * 2 + 1;
            this.raycaster.setFromCamera(this.mouse, this.camera);
            
            const intersects = this.raycaster.intersectObjects(this.cubelets);
            this.lastMousePos.set(pos.x, pos.y);
            this.idleTimer = 0;

            if (intersects.length > 0) {
                this.dragMode = 'layer';
                this.intersectedObject = intersects[0].object;
                this.intersectedFaceNormal = intersects[0].face.normal.clone().applyQuaternion(this.intersectedObject.getWorldQuaternion(new THREE.Quaternion()));
                this.dragStartPos = new THREE.Vector2(pos.x, pos.y);
            } else {
                this.dragMode = 'view';
            }
        };

        const onMove = (e) => {
            if (!this.dragMode) return;
            const pos = getPos(e);
            const dx = pos.x - this.lastMousePos.x;
            const dy = pos.y - this.lastMousePos.y;

            if (this.dragMode === 'view') {
                this.displayGroup.rotation.y += dx * 0.01;
                this.displayGroup.rotation.x += dy * 0.01;
            }
            this.lastMousePos.set(pos.x, pos.y);
            this.idleTimer = 0;
        };

        const onUp = () => {
            if (this.dragMode === 'layer') {
                const dx = this.lastMousePos.x - this.dragStartPos.x;
                const dy = this.lastMousePos.y - this.dragStartPos.y;
                if (Math.sqrt(dx*dx + dy*dy) > 15) this.calculateLayerRotation(dx, dy);
            }
            this.dragMode = null;
        };

        this.container.addEventListener('mousedown', onDown);
        window.addEventListener('mousemove', onMove);
        window.addEventListener('mouseup', onUp);
        this.container.addEventListener('touchstart', onDown, {passive: false});
        window.addEventListener('touchmove', onMove, {passive: false});
        window.addEventListener('touchend', onUp);
    }

    calculateLayerRotation(dx, dy) {
        const worldDrag3D = new THREE.Vector3(dx, -dy, 0).unproject(this.camera).sub(new THREE.Vector3(0,0,0).unproject(this.camera)).normalize();
        const rotAxisWorld = new THREE.Vector3().crossVectors(this.intersectedFaceNormal, worldDrag3D);
        const localRotAxis = rotAxisWorld.clone().applyQuaternion(this.displayGroup.quaternion.clone().invert());
        
        let finalAxis = 'x', maxVal = 0;
        ['x', 'y', 'z'].forEach(a => { 
            if (Math.abs(localRotAxis[a]) > maxVal) { 
                maxVal = Math.abs(localRotAxis[a]); 
                finalAxis = a; 
            } 
        });
        
        const val = Math.round(this.intersectedObject.position[finalAxis] * 100) / 100;
        const dir = localRotAxis[finalAxis] > 0 ? 1 : -1;
        this.rotateLayer(finalAxis, val, dir, 0.4, true);
    }

    // --- 回転ロジック(履歴保存機能付き) ---
    rotateLayer(axis, value, direction, duration = 0.4, record = true) {
        return new Promise(resolve => {
            this.isAnimating = true;
            if (record) {
                this.moveHistory.push({ axis, value, direction });
            }

            const targets = this.cubelets.filter(c => Math.abs(c.position[axis] - value) < 0.1);
            const pivot = new THREE.Group();
            this.cubeRoot.add(pivot);
            targets.forEach(t => pivot.attach(t));
            
            gsap.to(pivot.rotation, { 
                [axis]: (Math.PI / 2) * direction, 
                duration: duration, 
                ease: "power2.inOut", 
                onComplete: () => {
                    targets.forEach(t => {
                        this.cubeRoot.attach(t);
                        t.position.set(Math.round(t.position.x*100)/100, Math.round(t.position.y*100)/100, Math.round(t.position.z*100)/100);
                        t.updateMatrix();
                        const m = t.matrix.elements;
                        for(let i=0; i<12; i++) if (i % 4 !== 3) m[i] = Math.round(m[i]);
                        t.matrix.decompose(t.position, t.quaternion, t.scale);
                    });
                    this.cubeRoot.remove(pivot);
                    this.isAnimating = false;
                    resolve();
                }
            });
        });
    }

    async shuffle() {
        if (this.isAnimating) return;
        const axes = ['x', 'y', 'z'], layers = [-1.02, 0, 1.02], dirs = [1, -1];
        for (let i = 0; i < 15; i++) {
            const a = axes[Math.floor(Math.random()*3)];
            const l = layers[Math.floor(Math.random()*3)];
            const d = dirs[Math.floor(Math.random()*2)];
            await this.rotateLayer(a, l, d, 0.15, true);
        }
    }

    // 正解(履歴を逆再生)
    async solve() {
        if (this.isAnimating || this.moveHistory.length === 0) return;
        
        // 履歴をコピーして逆順にする
        const historyToUndo = [...this.moveHistory].reverse();
        this.moveHistory = []; // 解決中に新しい履歴が溜まらないようクリア

        for (const move of historyToUndo) {
            // 同じ軸・同じ層を、逆方向に回転させる
            await this.rotateLayer(move.axis, move.value, -move.direction, 0.2, false);
        }
    }

    reset() {
        if (this.isAnimating) return;
        this.moveHistory = [];
        gsap.to(this.displayGroup.rotation, { x: 0, y: 0, z: 0, duration: 1, ease: "power2.inOut" });
        this.cubelets.forEach(c => {
            gsap.to(c.position, { x: c.userData.homePos.x, y: c.userData.homePos.y, z: c.userData.homePos.z, duration: 1, ease: "power2.inOut" });
            gsap.to(c.quaternion, { x: c.userData.homeQuat.x, y: c.userData.homeQuat.y, z: c.userData.homeQuat.z, w: c.userData.homeQuat.w, duration: 1, ease: "power2.inOut" });
        });
        this.idleTimer = 0;
    }

    animate() {
        requestAnimationFrame(() => this.animate());
        this.idleTimer++;
        if (!this.dragMode && !this.isAnimating && this.idleTimer > 180) {
            this.displayGroup.rotation.y += 0.003;
            this.displayGroup.rotation.x += 0.001;
        }
        this.renderer.render(this.scene, this.camera);
    }
}

window.onload = () => new KumonosuRubiksGame();
https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js

Explanation 詳しい説明

仕様

本デモは、Three.jsを使って3Dシーンを構築しています。
27個の小さなキューブ(キューブレット)を組み合わせることで、ルービックキューブ全体を構成しています。

各キューブレットは独立したオブジェクトとして管理されており、特定の軸・層をまとめて回転させることで、実際のルービックキューブに近い挙動を再現しています。

操作方法と挙動

  • 何もない場所をドラッグすると、全体の視点を回転
  • キューブの面をドラッグすると、その層だけが回転
  • 一定時間操作がない場合は、自動でゆっくり回転

Raycaster を使って、どの面を操作しているかを判定し、ドラッグ方向から回転軸を決定しています。

シャッフルと自動解決

シャッフル時には、ランダムな回転操作を連続で実行します。
各操作は履歴として保存されており、「Solve」ボタンを押すことで、その履歴を逆再生し、元の状態へ自動的に戻す仕組みになっています。

アルゴリズムによる完全解法ではなく、操作履歴を利用した実装のため、挙動が分かりやすく、実装意図も明確です。

見た目と演出

ライティングには複数の光源を使用し、キューブの角や面の陰影がはっきり見えるよう調整しています。

ステッカー部分はCanvasTextureで生成しており、単色ではなく、わずかなハイライトを加えることで、平面的になりすぎない質感を持たせています。

カスタマイズ

以下の要素を変更することで、挙動や見た目を調整できます。

  • カメラ位置や視野角
  • ライトの色や強度
  • 回転アニメーションの速度
  • シャッフル回数
  • 自動回転の有無と速度

用途に応じて、デモ寄りにもゲーム寄りにも調整可能です。

注意

WebGLを使用しているため、古いブラウザや低性能な端末では動作が重くなる場合があります。

また、タッチ操作では意図しないドラッグが発生しやすいため、必要に応じて判定の調整が必要です。

まとめ

立体的なオブジェクトは、「見せる」だけでなく「触れる」ことで理解が深まります。

本デモは、Three.jsを使ってインタラクティブな3D表現を実装する際のひとつの実践例です。