Created
March 27, 2025 07:54
-
-
Save shricodev/1c7ed9967d03c6f5f9b8fd2ad46bcba1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>3D Rubik's Cube - Three.js</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #222; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 100; | |
| } | |
| button { | |
| padding: 8px 15px; | |
| margin-right: 5px; | |
| cursor: pointer; | |
| background-color: #4caf50; | |
| color: white; | |
| border: none; | |
| border-radius: 3px; | |
| } | |
| button:disabled { | |
| background-color: #aaa; | |
| cursor: not-allowed; | |
| } | |
| button:hover:not(:disabled) { | |
| background-color: #45a049; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <button id="scrambleBtn">Scramble</button> | |
| <button id="solveBtn">Solve</button> | |
| </div> | |
| <canvas id="canvas"></canvas> | |
| <!-- Dependencies --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.js"></script> | |
| <!-- Main Script --> | |
| <script src="script.js"></script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Import necessary modules if using ES6 modules (as indicated by type="module") | |
| // Note: OrbitControls is added to the THREE namespace globally by its script. | |
| // TWEEN is also available globally. | |
| // --- Constants --- | |
| const CUBE_SIZE = 3; | |
| const CUBIE_SIZE = 1; | |
| // const CUBIE_SPACING = 0.08; // Small gap between cubies - ORIGINAL | |
| const CUBIE_SPACING = 0.12; // Increased gap for better visibility | |
| const TOTAL_CUBIE_SIZE = CUBIE_SIZE + CUBIE_SPACING; | |
| const MOVE_DURATION = 300; // milliseconds for one face turn animation | |
| // Standard Rubik's Colors (Hex) | |
| const COLORS = { | |
| WHITE: 0xffffff, | |
| YELLOW: 0xffff00, | |
| BLUE: 0x0000ff, | |
| GREEN: 0x00ff00, | |
| RED: 0xff0000, | |
| ORANGE: 0xffa500, | |
| BLACK: 0x111111, // Inner color | |
| }; | |
| // --- Global Variables --- | |
| let scene, camera, renderer, controls; | |
| let cubies = []; // Array to hold all the individual cubie meshes | |
| let pivot; // Helper object for rotations | |
| let isAnimating = false; | |
| let scrambleSequence = []; // To store the moves used for scrambling | |
| // --- DOM Elements --- | |
| const scrambleBtn = document.getElementById("scrambleBtn"); | |
| const solveBtn = document.getElementById("solveBtn"); | |
| // --- Initialization --- | |
| function init() { | |
| // Scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x222222); | |
| // Camera | |
| camera = new THREE.PerspectiveCamera( | |
| 75, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000, | |
| ); | |
| camera.position.set(4, 4, 6); // Adjusted for better initial view | |
| camera.lookAt(0, 0, 0); | |
| // Renderer | |
| renderer = new THREE.WebGLRenderer({ | |
| canvas: document.getElementById("canvas"), | |
| antialias: true, | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| // Lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6); | |
| directionalLight.position.set(5, 10, 7.5); | |
| scene.add(directionalLight); | |
| // Controls | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.1; | |
| controls.screenSpacePanning = false; // Keep panning relative to the ground plane | |
| // Pivot for Rotation | |
| pivot = new THREE.Object3D(); | |
| scene.add(pivot); | |
| // Create Cube | |
| createRubiksCube(); | |
| // Event Listeners | |
| window.addEventListener("resize", onWindowResize); | |
| scrambleBtn.addEventListener("click", scrambleCube); | |
| solveBtn.addEventListener("click", solveCube); | |
| solveBtn.disabled = true; // Can't solve initially | |
| // Start Animation Loop | |
| animate(); | |
| } | |
| // --- Create Rubik's Cube --- | |
| function createRubiksCube() { | |
| const materials = { | |
| [COLORS.WHITE]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.WHITE, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.YELLOW]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.YELLOW, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.BLUE]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.BLUE, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.GREEN]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.GREEN, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.RED]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.RED, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.ORANGE]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.ORANGE, | |
| roughness: 0.3, | |
| metalness: 0.1, | |
| }), | |
| [COLORS.BLACK]: new THREE.MeshStandardMaterial({ | |
| color: COLORS.BLACK, | |
| roughness: 0.5, | |
| metalness: 0.0, | |
| }), | |
| }; | |
| const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); | |
| // Offset to center the cube | |
| const offset = ((CUBE_SIZE - 1) / 2) * TOTAL_CUBIE_SIZE; | |
| for (let x = 0; x < CUBE_SIZE; x++) { | |
| for (let y = 0; y < CUBE_SIZE; y++) { | |
| for (let z = 0; z < CUBE_SIZE; z++) { | |
| // Skip the core piece (not visible) | |
| if ( | |
| x > 0 && | |
| x < CUBE_SIZE - 1 && | |
| y > 0 && | |
| y < CUBE_SIZE - 1 && | |
| z > 0 && | |
| z < CUBE_SIZE - 1 | |
| ) { | |
| continue; | |
| } | |
| const cubieMaterials = []; | |
| // Order: Right (+X), Left (-X), Top (+Y), Bottom (-Y), Front (+Z), Back (-Z) | |
| cubieMaterials.push( | |
| x === CUBE_SIZE - 1 ? materials[COLORS.RED] : materials[COLORS.BLACK], | |
| ); // Right | |
| cubieMaterials.push( | |
| x === 0 ? materials[COLORS.ORANGE] : materials[COLORS.BLACK], | |
| ); // Left | |
| cubieMaterials.push( | |
| y === CUBE_SIZE - 1 | |
| ? materials[COLORS.WHITE] | |
| : materials[COLORS.BLACK], | |
| ); // Top | |
| cubieMaterials.push( | |
| y === 0 ? materials[COLORS.YELLOW] : materials[COLORS.BLACK], | |
| ); // Bottom | |
| cubieMaterials.push( | |
| z === CUBE_SIZE - 1 | |
| ? materials[COLORS.BLUE] | |
| : materials[COLORS.BLACK], | |
| ); // Front | |
| cubieMaterials.push( | |
| z === 0 ? materials[COLORS.GREEN] : materials[COLORS.BLACK], | |
| ); // Back | |
| const cubie = new THREE.Mesh(geometry, cubieMaterials); | |
| cubie.position.set( | |
| x * TOTAL_CUBIE_SIZE - offset, | |
| y * TOTAL_CUBIE_SIZE - offset, | |
| z * TOTAL_CUBIE_SIZE - offset, | |
| ); | |
| // Store original logical position for easier face selection | |
| cubie.userData = { initialX: x, initialY: y, initialZ: z }; | |
| scene.add(cubie); | |
| cubies.push(cubie); | |
| } | |
| } | |
| } | |
| } | |
| // --- Animation Loop --- | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| TWEEN.update(); // Update animations | |
| controls.update(); // Required if damping is enabled | |
| renderer.render(scene, camera); | |
| } | |
| // --- Window Resize Handler --- | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| // --- Cube Rotation Logic --- | |
| function rotateFace(face, direction) { | |
| if (isAnimating) return; | |
| isAnimating = true; | |
| updateButtonStates(); | |
| const cubiesToRotate = []; | |
| const axis = new THREE.Vector3(); | |
| let layer = 0; // Layer index (0, 1, or 2) | |
| // Determine axis, layer, and select cubies | |
| // Use a slightly larger threshold to reliably catch cubies near the edge | |
| const threshold = TOTAL_CUBIE_SIZE * 0.6; // Adjusted threshold | |
| const centerOffset = ((CUBE_SIZE - 1) / 2) * TOTAL_CUBIE_SIZE; | |
| switch (face) { | |
| case "U": // Up (+Y) | |
| axis.set(0, 1, 0); | |
| layer = CUBE_SIZE - 1; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.y - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| case "D": // Down (-Y) | |
| axis.set(0, 1, 0); | |
| layer = 0; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.y - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| case "R": // Right (+X) | |
| axis.set(1, 0, 0); | |
| layer = CUBE_SIZE - 1; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.x - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| case "L": // Left (-X) | |
| axis.set(1, 0, 0); | |
| layer = 0; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.x - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| case "F": // Front (+Z) | |
| axis.set(0, 0, 1); | |
| layer = CUBE_SIZE - 1; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.z - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| case "B": // Back (-Z) | |
| axis.set(0, 0, 1); | |
| layer = 0; | |
| cubies.forEach((c) => { | |
| if ( | |
| Math.abs(c.position.z - (layer * TOTAL_CUBIE_SIZE - centerOffset)) < | |
| threshold | |
| ) | |
| cubiesToRotate.push(c); | |
| }); | |
| break; | |
| default: | |
| console.error("Invalid face:", face); | |
| isAnimating = false; | |
| updateButtonStates(); | |
| return; | |
| } | |
| // Attach selected cubies to the pivot | |
| cubiesToRotate.forEach((cubie) => pivot.attach(cubie)); | |
| // Determine rotation angle based on direction and face conventions | |
| // direction = 1 (CW), direction = -1 (CCW) | |
| // Positive rotation is CCW around axis using right-hand rule | |
| // U/R/F CW needs negative rotation. D/L/B CW needs positive rotation. | |
| let angle = (Math.PI / 2) * direction; | |
| if (["U", "R", "F"].includes(face)) { | |
| // Keep angle as is for CCW (dir=-1 -> angle=-PI/2), invert for CW (dir=1 -> angle=-PI/2) | |
| angle *= -1; | |
| } else { | |
| // D, L, B | |
| // Keep angle as is for CW (dir=1 -> angle=+PI/2), invert for CCW (dir=-1 -> angle=+PI/2) | |
| // This logic seems slightly off in the previous comment, let's stick to the code: | |
| // angle = (PI/2) * dir. If URF, angle = -(PI/2)*dir. If DLB, angle = (PI/2)*dir. This seems correct. | |
| // Let's re-test mentally: | |
| // U CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +Y) | |
| // U CCW (dir=-1): angle = +PI/2 (Correct: Positive rotation around +Y) | |
| // D CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +Y) | |
| // D CCW (dir=-1): angle = -PI/2 (Correct: Negative rotation around +Y) | |
| // R CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +X) | |
| // L CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +X) | |
| // F CW (dir=1): angle = -PI/2 (Correct: Negative rotation around +Z) | |
| // B CW (dir=1): angle = +PI/2 (Correct: Positive rotation around +Z) | |
| // OKAY, THE LOGIC BELOW SEEMS CORRECT NOW. | |
| if (!["U", "R", "F"].includes(face)) { | |
| angle *= -1; // This was the missing inversion for DLB compared to URF | |
| } | |
| } | |
| // The final correct logic (after fixing the above inversion): | |
| // direction = 1 (CW), direction = -1 (CCW) | |
| angle = (Math.PI / 2) * direction; | |
| if (!["D", "L", "B"].includes(face)) { | |
| // U, R, F | |
| angle *= -1; | |
| } | |
| // Use TWEEN for animation | |
| return new Promise((resolve) => { | |
| new TWEEN.Tween(pivot.rotation) | |
| .to( | |
| { | |
| x: pivot.rotation.x + axis.x * angle, | |
| y: pivot.rotation.y + axis.y * angle, | |
| z: pivot.rotation.z + axis.z * angle, | |
| }, | |
| MOVE_DURATION, | |
| ) | |
| .easing(TWEEN.Easing.Quadratic.InOut) | |
| .onComplete(() => { | |
| // Detach cubies from pivot and add back to scene | |
| // This applies the pivot's transformation to the cubies | |
| pivot.updateMatrixWorld(); // Ensure matrix is up-to-date | |
| while (pivot.children.length > 0) { | |
| const cubie = pivot.children[0]; | |
| scene.attach(cubie); | |
| } | |
| // Reset pivot rotation | |
| pivot.rotation.set(0, 0, 0); | |
| // Snap rotations to grid (optional but good practice) | |
| // Position snapping removed to preserve gaps | |
| cubiesToRotate.forEach((c) => { | |
| // c.position.round(); // <<<< THIS LINE IS REMOVED/COMMENTED OUT | |
| // Simple rotation snapping - keep this | |
| c.rotation.x = | |
| Math.round(c.rotation.x / (Math.PI / 2)) * (Math.PI / 2); | |
| c.rotation.y = | |
| Math.round(c.rotation.y / (Math.PI / 2)) * (Math.PI / 2); | |
| c.rotation.z = | |
| Math.round(c.rotation.z / (Math.PI / 2)) * (Math.PI / 2); | |
| }); | |
| isAnimating = false; | |
| updateButtonStates(); | |
| resolve(); // Resolve the promise when animation completes | |
| }) | |
| .start(); | |
| }); | |
| } | |
| // --- Scramble Function --- | |
| function scrambleCube() { | |
| if (isAnimating) return; | |
| const moves = ["U", "D", "L", "R", "F", "B"]; | |
| const modifiers = ["", "'", "2"]; // Clockwise, Counter-Clockwise, Double | |
| const numScrambleMoves = 20; | |
| scrambleSequence = []; // Clear previous scramble | |
| let promiseChain = Promise.resolve(); | |
| for (let i = 0; i < numScrambleMoves; i++) { | |
| const randomMove = moves[Math.floor(Math.random() * moves.length)]; | |
| const randomModifier = | |
| modifiers[Math.floor(Math.random() * modifiers.length)]; | |
| const moveString = randomMove + randomModifier; | |
| scrambleSequence.push(moveString); // Store the applied move | |
| // Chain the animations using Promises | |
| promiseChain = promiseChain.then(() => applyMove(moveString)); | |
| } | |
| // Enable solve button after scrambling is done | |
| promiseChain.then(() => { | |
| solveBtn.disabled = isAnimating; // Should be false if no longer animating | |
| console.log("Scramble complete. Sequence:", scrambleSequence.join(" ")); | |
| }); | |
| } | |
| // --- Solve Function --- | |
| function solveCube() { | |
| if (isAnimating || scrambleSequence.length === 0) return; | |
| // Reverse the scramble sequence | |
| const solveSequence = scrambleSequence | |
| .slice() | |
| .reverse() | |
| .map((move) => { | |
| if (move.endsWith("'")) return move.slice(0, 1); // U' -> U | |
| if (move.endsWith("2")) return move; // U2 -> U2 | |
| return move + "'"; // U -> U' | |
| }); | |
| let promiseChain = Promise.resolve(); | |
| solveSequence.forEach((move) => { | |
| promiseChain = promiseChain.then(() => applyMove(move)); | |
| }); | |
| // Clear scramble sequence and disable solve button after solving | |
| promiseChain.then(() => { | |
| scrambleSequence = []; // Cube is now solved (theoretically) | |
| solveBtn.disabled = true; | |
| console.log("Solve complete."); | |
| }); | |
| } | |
| // --- Apply a single move string (e.g., "U'", "F2") --- | |
| async function applyMove(moveString) { | |
| if (isAnimating) return; // Should not happen with promise chaining, but safety first | |
| const face = moveString.charAt(0); | |
| const modifier = moveString.charAt(1) || ""; | |
| let direction = 1; // Clockwise | |
| let turns = 1; | |
| if (modifier === "'") { | |
| direction = -1; // Counter-clockwise | |
| } else if (modifier === "2") { | |
| turns = 2; | |
| } | |
| // Perform the rotation(s) | |
| for (let i = 0; i < turns; i++) { | |
| await rotateFace(face, direction); // Wait for animation to finish | |
| } | |
| } | |
| // --- UI Update --- | |
| function updateButtonStates() { | |
| scrambleBtn.disabled = isAnimating; | |
| // Solve button is enabled only if NOT animating AND there is a scramble sequence to reverse | |
| solveBtn.disabled = isAnimating || scrambleSequence.length === 0; | |
| } | |
| // --- Start --- | |
| init(); |
Author
Fascinating! I'd argue that just reversing the scramble isn't really "solving the cube" though.
It's Amazing.
I have tested it on https://jsfiddle.net/ and it works perfect as in video.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Output: https://youtu.be/1g0hKzYMDzQ