# 3D魔方游戲
這是一個基于Three.js的3D魔方游戲,支持2到6階魔方的模擬操作。
## 功能特點
- 支持2到6階魔方
- 真實的3D渲染效果
- 鼠標操作控制
- 隨機打亂功能
- 提示功能
- 重置功能
### 安裝依賴
```bash
npm install
```
### 啟動游戲
```bash
npm start
```
然后在瀏覽器中訪問 `http://localhost:8080` 即可開始游戲。
## 操作說明
- 使用鼠標拖拽可以旋轉整個魔方
- 按住Shift鍵并點擊魔方的某一面可以旋轉該面
- 使用界面上的下拉菜單可以選擇魔方的階數(2到6階)
- 點擊"隨機打亂"按鈕可以隨機打亂魔方
- 點擊"提示"按鈕可以獲取下一步的提示
- 點擊"重置"按鈕可以重置魔方到初始狀態
## 技術棧
- HTML5
- CSS3
- JavaScript (ES6+)
- Three.js (3D渲染庫)
## 瀏覽器兼容性
支持所有現代瀏覽器,包括:
- Chrome
- Firefox
- Safari
- Edge
## 許可證
ISC
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import TWEEN from 'three/examples/jsm/libs/tween.module.js';
import { Cube } from './cube.js';// 全局變量
let scene, camera, renderer, controls;
let cube;
let currentOrder = 3; // 默認3階魔方
let isCtrlPressed = false; // 跟蹤Ctrl鍵是否按下
let isDragging = false; // 跟蹤是否正在拖拽// 初始化場景
function init() {// 創建場景scene = new THREE.Scene();scene.background = new THREE.Color(0xf0f0f0);// 獲取容器const container = document.getElementById('cube-container');const containerWidth = container.clientWidth;const containerHeight = container.clientHeight || window.innerHeight * 0.6;// 強制設置容器高度container.style.height = `${containerHeight}px`;// 創建相機camera = new THREE.PerspectiveCamera(50, // 視角更廣containerWidth / containerHeight,0.1,1000);// 根據魔方階數調整相機位置const cameraDistance = 8 + currentOrder * 1.0; // 增加距離camera.position.set(cameraDistance, cameraDistance, cameraDistance);camera.lookAt(0, 0, 0);// 創建渲染器renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(containerWidth, containerHeight);renderer.setPixelRatio(window.devicePixelRatio);container.appendChild(renderer.domElement);// 添加軌道控制controls = new OrbitControls(camera, renderer.domElement);controls.enableDamping = true;controls.dampingFactor = 0.1;controls.minDistance = cameraDistance * 0.5;controls.maxDistance = cameraDistance * 2;controls.enableRotate = true;controls.rotateSpeed = 1.0;// 允許完全旋轉controls.minPolarAngle = 0;controls.maxPolarAngle = Math.PI;controls.minAzimuthAngle = -Infinity;controls.maxAzimuthAngle = Infinity;controls.target.set(0, 0, 0);// 默認禁用軌道控制controls.enabled = false;// 添加光源const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);scene.add(ambientLight);const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight.position.set(10, 20, 15);scene.add(directionalLight);// 添加第二個方向光源,照亮底部const bottomLight = new THREE.DirectionalLight(0xffffff, 0.6);bottomLight.position.set(-5, -10, -7);scene.add(bottomLight);// 添加第三個方向光源,照亮側面const sideLight = new THREE.DirectionalLight(0xffffff, 0.6);sideLight.position.set(-10, 5, -10);scene.add(sideLight);// 創建魔方createCube(currentOrder);// 添加窗口大小調整監聽window.addEventListener('resize', onWindowResize);// 添加交互控制setupInteraction();
}// 窗口大小調整
function onWindowResize() {const container = document.getElementById('cube-container');const containerWidth = container.clientWidth;const containerHeight = container.clientHeight || window.innerHeight * 0.6;camera.aspect = containerWidth / containerHeight;camera.updateProjectionMatrix();renderer.setSize(containerWidth, containerHeight);
}// 動畫循環
function animate() {requestAnimationFrame(animate);TWEEN.update(); // 更新動畫controls.update();renderer.render(scene, camera);
}// 創建魔方
function createCube(order) {if (cube) {scene.remove(cube.group);}cube = new Cube(order);scene.add(cube.group);
}// 設置交互控制
function setupInteraction() {const raycaster = new THREE.Raycaster();const mouse = new THREE.Vector2();let selectedFace = null;let startPoint = new THREE.Vector2();let endPoint = new THREE.Vector2();// 監聽Ctrl鍵window.addEventListener('keydown', function(event) {if (event.key === 'Control') {isCtrlPressed = true;controls.enabled = true;renderer.domElement.style.cursor = 'move';console.log('Ctrl鍵按下,啟用軌道控制');}});window.addEventListener('keyup', function(event) {if (event.key === 'Control') {isCtrlPressed = false;controls.enabled = false;renderer.domElement.style.cursor = 'default';console.log('Ctrl鍵釋放,禁用軌道控制');}});// 添加初始提示const infoDiv = document.createElement('div');infoDiv.style.position = 'absolute';infoDiv.style.bottom = '10px';infoDiv.style.left = '10px';infoDiv.style.backgroundColor = 'rgba(0,0,0,0.7)';infoDiv.style.color = 'white';infoDiv.style.padding = '5px 10px';infoDiv.style.borderRadius = '5px';infoDiv.style.fontSize = '14px';infoDiv.innerHTML = '按住Ctrl鍵可自由旋轉整個魔方<br>點擊或拖動魔方面可旋轉該面';document.getElementById('cube-container').appendChild(infoDiv);// 10秒后隱藏提示setTimeout(() => {infoDiv.style.opacity = '0';infoDiv.style.transition = 'opacity 1s';setTimeout(() => {infoDiv.remove();}, 1000);}, 10000);// 初始自動旋轉魔方,讓用戶看到所有面setTimeout(() => {// 先旋轉到一個角度,讓用戶看到更多面const startRotation = { x: 0, y: 0 };const endRotation = { x: Math.PI / 3, y: Math.PI / 4 };new TWEEN.Tween(startRotation).to(endRotation, 1500).easing(TWEEN.Easing.Quadratic.Out).onUpdate(() => {cube.group.rotation.x = startRotation.x;cube.group.rotation.y = startRotation.y;}).onComplete(() => {// 旋轉完成后,重置魔方位置setTimeout(() => {new TWEEN.Tween(cube.group.rotation).to({ x: 0, y: 0, z: 0 }, 1000).easing(TWEEN.Easing.Quadratic.Out).start();}, 1000);}).start();}, 500);// 鼠標按下事件renderer.domElement.addEventListener('mousedown', function(event) {// 如果按下Ctrl鍵,啟用軌道控制并跳過魔方面旋轉if (event.ctrlKey || isCtrlPressed) {controls.enabled = true;return;}// 禁用軌道控制controls.enabled = false;// 如果魔方正在動畫中,則不處理if (cube && cube.isAnimating) return;isDragging = false;const rect = renderer.domElement.getBoundingClientRect();mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;// 保存起始點startPoint.set(event.clientX, event.clientY);endPoint.copy(startPoint); // 初始化終點與起點相同raycaster.setFromCamera(mouse, camera);try {const allCubies = cube.getAllCubies();if (!allCubies || allCubies.length === 0) {console.warn('沒有找到魔方小塊');return;}// 遞歸設置為true,以檢測子對象const intersects = raycaster.intersectObjects(allCubies, true);if (intersects.length > 0) {// 確保我們有正確的對象和面let targetObject = intersects[0].object;// 如果點擊的是邊緣線段,獲取其父對象(實際的方塊)while (targetObject.parent && !(targetObject instanceof THREE.Mesh)) {targetObject = targetObject.parent;}// 創建一個新的交點對象,確保有正確的目標對象和面信息const correctedIntersect = {...intersects[0],object: targetObject};// 嘗試獲取面信息selectedFace = cube.getFaceFromIntersect(correctedIntersect);if (selectedFace) {console.log('選中面:', selectedFace);renderer.domElement.style.cursor = 'pointer';} else {console.log('未能確定選中的面');// 嘗試直接從物體位置確定面const position = targetObject.position.clone();const x = Math.round((position.x + cube.offset) / (cube.cubeSize + cube.gap));const y = Math.round((position.y + cube.offset) / (cube.cubeSize + cube.gap));const z = Math.round((position.z + cube.offset) / (cube.cubeSize + cube.gap));// 確定是哪個面if (x === 0) {selectedFace = { axis: 'x', value: -1, layer: 0 };} else if (x === cube.order - 1) {selectedFace = { axis: 'x', value: 1, layer: cube.order - 1 };} else if (y === 0) {selectedFace = { axis: 'y', value: -1, layer: 0 };} else if (y === cube.order - 1) {selectedFace = { axis: 'y', value: 1, layer: cube.order - 1 };} else if (z === 0) {selectedFace = { axis: 'z', value: -1, layer: 0 };} else if (z === cube.order - 1) {selectedFace = { axis: 'z', value: 1, layer: cube.order - 1 };}if (selectedFace) {console.log('從位置推斷的面:', selectedFace);renderer.domElement.style.cursor = 'pointer';}}} else {console.log('未選中任何面');selectedFace = null;}} catch (error) {console.error('射線檢測錯誤:', error);}});// 鼠標移動事件window.addEventListener('mousemove', function(event) {// 如果按下Ctrl鍵,讓軌道控制處理移動if (event.ctrlKey || isCtrlPressed) {controls.enabled = true;return;}// 如果沒有選中面,則不處理if (!selectedFace) return;// 更新終點位置endPoint.set(event.clientX, event.clientY);// 計算移動距離const moveDistance = Math.sqrt(Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2));// 如果移動距離超過閾值,標記為拖拽if (moveDistance > 8) { // 增加閾值,減少誤觸isDragging = true;renderer.domElement.style.cursor = 'grabbing';// 計算拖拽方向向量const dragVector = {x: endPoint.x - startPoint.x,y: endPoint.y - startPoint.y};// 顯示拖拽方向指示const direction = determineRotationDirection(selectedFace, dragVector);console.log('當前拖拽方向:', direction > 0 ? '順時針' : '逆時針');}});// 鼠標釋放事件window.addEventListener('mouseup', function(event) {renderer.domElement.style.cursor = 'default';// 如果按下Ctrl鍵,讓軌道控制處理釋放if (event.ctrlKey || isCtrlPressed) {return;}// 如果沒有選中面,則不處理if (!selectedFace) return;// 更新終點位置endPoint.set(event.clientX, event.clientY);// 如果是拖拽操作,根據拖拽方向確定旋轉方向if (isDragging) {// 計算拖拽方向向量const dragVector = {x: endPoint.x - startPoint.x,y: endPoint.y - startPoint.y};// 計算移動距離const moveDistance = Math.sqrt(Math.pow(dragVector.x, 2) + Math.pow(dragVector.y, 2));// 只有當移動距離足夠大時才執行旋轉,防止誤觸if (moveDistance > 15) {// 根據拖拽方向和選中的面確定旋轉方向const direction = determineRotationDirection(selectedFace, dragVector);console.log('旋轉方向:', direction);// 執行旋轉if (cube && typeof cube.rotateFace === 'function') {cube.rotateFace({axis: selectedFace.axis,layer: selectedFace.layer,direction: direction});} else {console.error('cube.rotateFace 不是一個函數');}} else {console.log('移動距離不足,取消旋轉');}} // 如果是點擊操作,使用默認方向旋轉else {console.log('點擊操作');if (cube && typeof cube.rotateFace === 'function') {cube.rotateFace(selectedFace);} else {console.error('cube.rotateFace 不是一個函數');}}selectedFace = null;isDragging = false;});// 添加觸摸支持renderer.domElement.addEventListener('touchstart', function(event) {if (cube && cube.isAnimating) return;event.preventDefault();isDragging = false;const rect = renderer.domElement.getBoundingClientRect();const touch = event.touches[0];mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;// 保存起始點startPoint.set(touch.clientX, touch.clientY);endPoint.copy(startPoint);raycaster.setFromCamera(mouse, camera);try {const allCubies = cube.getAllCubies();if (!allCubies || allCubies.length === 0) return;const intersects = raycaster.intersectObjects(allCubies, true);if (intersects.length > 0) {// 確保我們有正確的對象和面let targetObject = intersects[0].object;// 如果點擊的是邊緣線段,獲取其父對象(實際的方塊)while (targetObject.parent && !(targetObject instanceof THREE.Mesh)) {targetObject = targetObject.parent;}// 創建一個新的交點對象const correctedIntersect = {...intersects[0],object: targetObject};selectedFace = cube.getFaceFromIntersect(correctedIntersect);console.log('觸摸選中面:', selectedFace);}} catch (error) {console.error('觸摸檢測錯誤:', error);}});renderer.domElement.addEventListener('touchmove', function(event) {if (!selectedFace) return;event.preventDefault();const touch = event.touches[0];// 更新終點位置endPoint.set(touch.clientX, touch.clientY);// 計算移動距離const moveDistance = Math.sqrt(Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2));// 如果移動距離超過閾值,標記為拖拽if (moveDistance > 10) {isDragging = true;}});renderer.domElement.addEventListener('touchend', function(event) {if (!selectedFace) return;event.preventDefault();if (isDragging) {// 計算拖拽方向向量const dragVector = {x: endPoint.x - startPoint.x,y: endPoint.y - startPoint.y};// 根據拖拽方向和選中的面確定旋轉方向const direction = determineRotationDirection(selectedFace, dragVector);// 執行旋轉if (cube && typeof cube.rotateFace === 'function') {cube.rotateFace({axis: selectedFace.axis,layer: selectedFace.layer,direction: direction});}} else {if (cube && typeof cube.rotateFace === 'function') {cube.rotateFace(selectedFace);}}selectedFace = null;isDragging = false;});// 阻止右鍵菜單renderer.domElement.addEventListener('contextmenu', function(event) {event.preventDefault();});
}// 根據拖拽方向和選中的面確定旋轉方向
function determineRotationDirection(face, dragVector) {const { axis, value } = face;// 計算拖拽的主要方向和角度const dragAngle = Math.atan2(dragVector.y, dragVector.x) * 180 / Math.PI;console.log('拖拽角度:', dragAngle);// 根據角度確定拖拽的主要方向let dragDirection;if (dragAngle > -45 && dragAngle <= 45) {dragDirection = 'right';} else if (dragAngle > 45 && dragAngle <= 135) {dragDirection = 'down';} else if (dragAngle > 135 || dragAngle <= -135) {dragDirection = 'left';} else {dragDirection = 'up';}console.log('拖拽方向:', dragDirection);// 根據面的軸和拖拽方向確定旋轉方向// 1表示順時針,-1表示逆時針let direction = 1;switch (axis) {case 'x': // 左右面if (value > 0) { // 右面direction = (dragDirection === 'up' || dragDirection === 'right') ? 1 : -1;} else { // 左面direction = (dragDirection === 'up' || dragDirection === 'left') ? 1 : -1;}break;case 'y': // 上下面if (value > 0) { // 上面direction = (dragDirection === 'right' || dragDirection === 'down') ? 1 : -1;} else { // 下面direction = (dragDirection === 'right' || dragDirection === 'up') ? 1 : -1;}break;case 'z': // 前后面if (value > 0) { // 前面direction = (dragDirection === 'right' || dragDirection === 'up') ? 1 : -1;} else { // 后面direction = (dragDirection === 'left' || dragDirection === 'up') ? 1 : -1;}break;}console.log('旋轉方向:', direction > 0 ? '順時針' : '逆時針');return direction;
}// 初始化事件監聽
function initEventListeners() {// 魔方階數選擇document.getElementById('cube-order').addEventListener('change', (event) => {currentOrder = parseInt(event.target.value);createCube(currentOrder);updateLayerButtons(); // 更新層按鈕});// 隨機打亂按鈕document.getElementById('scramble-btn').addEventListener('click', () => {cube.scramble();});// 提示按鈕document.getElementById('hint-btn').addEventListener('click', () => {cube.showHint();});// 重置按鈕document.getElementById('reset-btn').addEventListener('click', () => {createCube(currentOrder);updateLayerButtons(); // 更新層按鈕});// 旋轉控制按鈕document.getElementById('rotate-left').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'y', angle: Math.PI / 4 });});document.getElementById('rotate-right').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'y', angle: -Math.PI / 4 });});document.getElementById('rotate-up').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'x', angle: Math.PI / 4 });});document.getElementById('rotate-down').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'x', angle: -Math.PI / 4 });});document.getElementById('rotate-clockwise').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'z', angle: -Math.PI / 4 });});document.getElementById('rotate-counter-clockwise').addEventListener('click', () => {rotateCubeWithAnimation({ axis: 'z', angle: Math.PI / 4 });});// 層選擇控制// 初始化層按鈕updateLayerButtons();// 軸選擇變化時更新層按鈕document.getElementById('axis-select').addEventListener('change', updateLayerButtons);// 層旋轉方向按鈕document.getElementById('rotate-clockwise-layer').addEventListener('click', () => {rotateSelectedLayer(1); // 順時針});document.getElementById('rotate-counter-clockwise-layer').addEventListener('click', () => {rotateSelectedLayer(-1); // 逆時針});// 添加鍵盤控制window.addEventListener('keydown', function(event) {// 如果魔方正在動畫中,則不處理if (cube && cube.isAnimating) return;// 如果按下Ctrl鍵,啟用軌道控制if (event.key === 'Control') {isCtrlPressed = true;controls.enabled = true;renderer.domElement.style.cursor = 'move';console.log('Ctrl鍵按下,啟用軌道控制');return;}// 鍵盤控制魔方旋轉switch (event.key) {// 旋轉整個魔方case 'ArrowLeft':if (event.shiftKey) {rotateCubeWithAnimation({ axis: 'y', angle: Math.PI / 4 });}break;case 'ArrowRight':if (event.shiftKey) {rotateCubeWithAnimation({ axis: 'y', angle: -Math.PI / 4 });}break;case 'ArrowUp':if (event.shiftKey) {rotateCubeWithAnimation({ axis: 'x', angle: Math.PI / 4 });}break;case 'ArrowDown':if (event.shiftKey) {rotateCubeWithAnimation({ axis: 'x', angle: -Math.PI / 4 });}break;// 旋轉魔方的面 (按鍵1-9對應九宮格位置)case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9':const keyNum = parseInt(event.key);let layer = 0;let axis = 'z';let direction = 1;// 根據按鍵確定旋轉的面和方向if (keyNum <= 3) { // 上層layer = cube.order - 1;axis = 'y';direction = keyNum === 1 || keyNum === 3 ? -1 : 1;} else if (keyNum <= 6) { // 中層layer = Math.floor(cube.order / 2);axis = 'y';direction = keyNum === 4 || keyNum === 6 ? -1 : 1;} else { // 下層layer = 0;axis = 'y';direction = keyNum === 7 || keyNum === 9 ? -1 : 1;}// 執行旋轉if (event.altKey) { // Alt鍵按下時旋轉X軸axis = 'x';} else if (event.ctrlKey) { // Ctrl鍵按下時旋轉Z軸axis = 'z';}cube.rotateFace({axis: axis,layer: layer,direction: direction});break;}});window.addEventListener('keyup', function(event) {if (event.key === 'Control') {isCtrlPressed = false;controls.enabled = false;renderer.domElement.style.cursor = 'default';console.log('Ctrl鍵釋放,禁用軌道控制');}});// 添加操作說明const keyboardInfo = document.createElement('div');keyboardInfo.className = 'keyboard-info';keyboardInfo.innerHTML = `<h3>鍵盤控制說明:</h3><p>- Shift + 方向鍵: 旋轉整個魔方</p><p>- 數字鍵1-9: 旋轉對應位置的面</p><p>- Alt + 數字鍵: 沿X軸旋轉</p><p>- Ctrl + 數字鍵: 沿Z軸旋轉</p><p>- 默認沿Y軸旋轉</p>`;document.querySelector('.instructions').appendChild(keyboardInfo);
}// 更新層按鈕
function updateLayerButtons() {if (!cube) return;const layerButtonsContainer = document.getElementById('layer-buttons');layerButtonsContainer.innerHTML = ''; // 清空現有按鈕const axis = document.getElementById('axis-select').value;// 為每一層創建按鈕for (let i = 0; i < cube.order; i++) {const button = document.createElement('button');button.className = 'layer-button';button.textContent = i + 1;button.dataset.layer = i;button.dataset.axis = axis;// 點擊選擇層button.addEventListener('click', function() {// 移除其他按鈕的選中狀態document.querySelectorAll('.layer-button').forEach(btn => {btn.classList.remove('selected');});// 添加當前按鈕的選中狀態this.classList.add('selected');});layerButtonsContainer.appendChild(button);}// 默認選中第一個按鈕if (layerButtonsContainer.firstChild) {layerButtonsContainer.firstChild.classList.add('selected');}
}// 旋轉選中的層
function rotateSelectedLayer(direction) {const selectedButton = document.querySelector('.layer-button.selected');if (!selectedButton || !cube) return;const layer = parseInt(selectedButton.dataset.layer);const axis = selectedButton.dataset.axis;cube.rotateFace({axis: axis,layer: layer,direction: direction});
}// 旋轉整個魔方的動畫函數
function rotateCubeWithAnimation({ axis, angle }) {if (!cube || !cube.group) return;const startRotation = { value: 0 };const endRotation = { value: angle };new TWEEN.Tween(startRotation).to(endRotation, 300).easing(TWEEN.Easing.Quadratic.Out).onUpdate(() => {if (axis === 'x') {cube.group.rotation.x += (endRotation.value - startRotation.value) / 10;} else if (axis === 'y') {cube.group.rotation.y += (endRotation.value - startRotation.value) / 10;} else if (axis === 'z') {cube.group.rotation.z += (endRotation.value - startRotation.value) / 10;}}).start();
}// 頁面加載完成后初始化
window.addEventListener('DOMContentLoaded', () => {init();initEventListeners();animate();
});