提示词:
你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。
一、 交付规范
单文件架构:HTML/CSS/JS 必须合并在一个文件中。
依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。
零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。
二、 视觉与物理标准
模型构建:
场景需包含 27 个独立的小方块(Cubies)。
物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。
倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。
光影环境:
必须开启 ShadowMap。
配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。
三、 核心逻辑考点(数据结构与变换)
禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案:
动态层级筛选:
不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。
Pivot 变换机制(关键考点):
实现旋转时,必须创建一个临时的 Pivot(轴心对象)。
核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。
作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。
坐标清洗:
每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。
四、 交互系统考点(算法重难点)
这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验:
操作分离:
左键拖拽:旋转魔方的某一层。
右键拖拽:旋转视角(OrbitControls)。
基于投影向量的手势识别算法:
射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。
意图判断:
根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。
将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。
计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。
方向修正与实时跟随:
实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。
符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。
磁吸效果:
松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。
五、 代码质量
代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。
具备 Scramble(打乱)和 Reset(重置)功能按钮。
测试结果
概述:
有颜色错乱和转动轴向错乱问题
打乱按钮无效
测试花费 0.6元
思考时间(不含输出答案时间) 346.7s
测试轮数: 1 轮
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3x3 魔方 - 物理级高保真手势交互</title>
<style>
:root {
--bg: #1a1a2e;
--panel-bg: #16213e;
--text: #e0e0e0;
--accent: #e94560;
--accent2: #0f3460;
--gold: #d4a574;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
height: 100vh;
width: 100vw;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
cursor: default;
}
#canvas-container {
position: fixed;
inset: 0;
z-index: 1;
}
#canvas-container canvas {
display: block;
}
#canvas-container.grabbing canvas {
cursor: grabbing;
}
#canvas-container.right-drag canvas {
cursor: grab;
}
/* 底部控制面板 */
.control-panel {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 16px;
align-items: center;
}
.btn {
padding: 12px 28px;
border: none;
border-radius: 30px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
outline: none;
position: relative;
overflow: hidden;
}
.btn-scramble {
background: rgba(233, 68, 96, 0.85);
color: #fff;
box-shadow: 0 8px 24px rgba(233, 68, 96, 0.35);
}
.btn-scramble:hover {
background: rgba(245, 80, 108, 0.95);
box-shadow: 0 12px 32px rgba(233, 68, 96, 0.5);
transform: translateY(-3px);
}
.btn-scramble:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(233, 68, 96, 0.3);
}
.btn-reset {
background: rgba(255, 255, 255, 0.12);
color: #e0e0e0;
border: 1.5px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.btn-reset:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.45);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
transform: translateY(-3px);
}
.btn-reset:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
transform: none !important;
}
/* 提示标签 */
.hint-tag {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.55);
color: #ccc;
font-size: 13px;
padding: 8px 20px;
border-radius: 20px;
letter-spacing: 0.4px;
pointer-events: none;
transition: opacity 0.5s;
}
/* 响应式 */
@media (max-width: 600px) {
.btn {
padding: 10px 20px;
font-size: 13px;
border-radius: 24px;
}
.control-panel {
gap: 10px;
bottom: 18px;
}
.hint-tag {
font-size: 11px;
top: 12px;
padding: 6px 14px;
}
}
</style>
</head>
<body>
<div id="canvas-container"></div>
<div class="hint-tag" id="hint-tag">🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放</div>
<div class="control-panel">
<button class="btn btn-scramble" id="btn-scramble" title="随机打乱魔方">🔀 打乱</button>
<button class="btn btn-reset" id="btn-reset" title="重置魔方到初始状态">↺ 重置</button>
</div>
<!-- Import Map:ES Modules 依赖 -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
"@tweenjs/tween.js": "https://unpkg.com/@tweenjs/tween.js@20.0.0/dist/tween.esm.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import * as TWEEN from '@tweenjs/tween.js';
// ==================== DOM 元素 ====================
const container = document.getElementById('canvas-container');
const hintTag = document.getElementById('hint-tag');
const btnScramble = document.getElementById('btn-scramble');
const btnReset = document.getElementById('btn-reset');
// ==================== 常量与配置 ====================
const CUBIE_SIZE = 0.9; // 小方块边长
const GRID_SPACING = 1.0; // 格点间距
const GAP = GRID_SPACING - CUBIE_SIZE; // 物理间隙 ≈ 0.1
const EPSILON = 0.15; // 层级筛选阈值
const ANGLE_PER_NDC = Math.PI; // NDC距离→弧度映射系数(1 NDC ≈ 180°)
const MAGNETIC_DURATION = 280; // 磁吸动画时长(ms)
const SCRAMBLE_STEPS = 20; // 打乱步数
const SCRAMBLE_STEP_DURATION = 160; // 打乱每步动画时长(ms)
// 魔方标准颜色(贴纸颜色)
const STICKER_COLORS = {
right: '#E53935', // +X 红
left: '#FB8C00', // -X 橙
top: '#FAFAFA', // +Y 白
bottom: '#FDD835', // -Y 黄
front: '#43A047', // +Z 绿
back: '#1E88E5', // -Z 蓝
};
const PLASTIC_COLOR = '#1a1a1a'; // 塑料黑边颜色
// ==================== Three.js 初始化 ====================
const scene = new THREE.Scene();
scene.background = new THREE.Color('#1a1a2e');
scene.fog = new THREE.Fog('#1a1a2e', 6, 22);
// 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
const canvas = renderer.domElement;
// 相机
const camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.5, 40);
camera.position.set(5.5, 3.8, 6.5);
camera.lookAt(0, 0, 0);
// OrbitControls —— 仅响应右键旋转 & 中键缩放
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.minDistance = 4;
controls.maxDistance = 14;
controls.maxPolarAngle = Math.PI * 0.75;
// 关键:左键=null(留给魔方操作),中键=缩放,右键=旋转
controls.mouseButtons = {
LEFT: null,
MIDDLE: THREE.MOUSE ? THREE.MOUSE.DOLLY : 1,
RIGHT: THREE.MOUSE ? THREE.MOUSE.ROTATE : 0,
};
controls.touches = {
ONE: THREE.TOUCH ? THREE.TOUCH.ROTATE : 0,
TWO: THREE.TOUCH ? THREE.TOUCH.DOLLY_PAN : 1,
};
controls.update();
// ==================== 光照与阴影 ====================
// 环境光 —— 提供基础照明
const ambientLight = new THREE.AmbientLight('#8899bb', 1.6);
scene.add(ambientLight);
// 半球光 —— 模拟天空/地面散射
const hemiLight = new THREE.HemisphereLight('#bcc8e8', '#3a3040', 1.0);
scene.add(hemiLight);
// 主平行光 —— 投射阴影
const sunLight = new THREE.DirectionalLight('#fffef5', 5.5);
sunLight.position.set(8, 14, 6);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 50;
sunLight.shadow.camera.left = -10;
sunLight.shadow.camera.right = 10;
sunLight.shadow.camera.top = 10;
sunLight.shadow.camera.bottom = -10;
sunLight.shadow.bias = -0.00025;
sunLight.shadow.normalBias = 0.02;
scene.add(sunLight);
// 补光 —— 减少暗面过黑
const fillLight = new THREE.DirectionalLight('#c8d0ff', 2.2);
fillLight.position.set(-3, 2, -4);
scene.add(fillLight);
// ==================== 地面(接收阴影) ====================
const groundGeo = new THREE.PlaneGeometry(20, 20);
const groundMat = new THREE.MeshStandardMaterial({
color: '#2a2a35',
roughness: 0.65,
metalness: 0.05,
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.position.y = -3.5;
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
ground.name = 'ground';
scene.add(ground);
// 微妙的网格辅助线(装饰)
const gridHelper = new THREE.PolarGridHelper(9, 48, 32, 64, '#333344', '#222233');
gridHelper.position.y = -3.49;
scene.add(gridHelper);
// ==================== 贴纸纹理生成(Canvas API) ====================
/**
* 使用 HTML5 Canvas 程序化生成带圆角矩形的高光贴纸纹理
* @param {string} colorHex - 贴纸颜色
* @returns {THREE.CanvasTexture}
*/
function createStickerTexture(colorHex) {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// 1. 塑料黑底(模拟魔方塑料边框)
ctx.fillStyle = PLASTIC_COLOR;
ctx.fillRect(0, 0, size, size);
// 2. 圆角矩形贴纸
const margin = 22;
const radius = 34;
const x = margin;
const y = margin;
const w = size - margin * 2;
const h = size - margin * 2;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
// 填充贴纸颜色
ctx.fillStyle = colorHex;
ctx.fill();
// 3. 高光渐变(模拟贴纸表面的微妙光泽)
const highlightGrad = ctx.createLinearGradient(x, y, x, y + h);
highlightGrad.addColorStop(0, 'rgba(255,255,255,0.22)');
highlightGrad.addColorStop(0.25, 'rgba(255,255,255,0.06)');
highlightGrad.addColorStop(0.55, 'rgba(0,0,0,0.0)');
highlightGrad.addColorStop(0.8, 'rgba(0,0,0,0.04)');
highlightGrad.addColorStop(1, 'rgba(0,0,0,0.14)');
ctx.fillStyle = highlightGrad;
ctx.fill();
// 4. 贴纸边缘微妙的暗线(模拟贴纸厚度)
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
ctx.lineWidth = 2.5;
ctx.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
return texture;
}
// 预生成6种颜色的贴纸纹理(缓存复用)
const stickerTextureCache = {};
for (const [key, colorHex] of Object.entries(STICKER_COLORS)) {
stickerTextureCache[key] = createStickerTexture(colorHex);
}
// 塑料面材质(无贴纸,纯黑)
const plasticMaterial = new THREE.MeshStandardMaterial({
color: 0x1a1a1a,
roughness: 0.55,
metalness: 0.08,
});
// ==================== 构建魔方 ====================
/**
* 所有27个小方块的引用数组
* @type {THREE.Mesh[]}
*/
const allCubies = [];
/**
* 每个cubie的初始状态(用于Reset)
* @type {Map<THREE.Mesh, {position: THREE.Vector3, rotation: THREE.Euler}>}
*/
const initialStates = new Map();
/**
* 根据cubie的初始格点位置判断某个面是否外露
* @param {number} gridX - 格点X坐标 (-1, 0, 1)
* @param {number} gridY - 格点Y坐标 (-1, 0, 1)
* @param {number} gridZ - 格点Z坐标 (-1, 0, 1)
* @returns {Object} 6个面是否外露的布尔映射
*/
function getExposedFaces(gridX, gridY, gridZ) {
return {
posX: Math.abs(gridX - 1) < 0.01, // +X面外露(右面,红)
negX: Math.abs(gridX + 1) < 0.01, // -X面外露(左面,橙)
posY: Math.abs(gridY - 1) < 0.01, // +Y面外露(顶面,白)
negY: Math.abs(gridY + 1) < 0.01, // -Y面外露(底面,黄)
posZ: Math.abs(gridZ - 1) < 0.01, // +Z面外露(前面,绿)
negZ: Math.abs(gridZ + 1) < 0.01, // -Z面外露(后面,蓝)
};
}
/**
* 为单个cubie创建6个面的材质数组
* BoxGeometry的面顺序:+X(0), -X(1), +Y(2), -Y(3), +Z(4), -Z(5)
*/
function createCubieMaterials(gridX, gridY, gridZ) {
const exposed = getExposedFaces(gridX, gridY, gridZ);
const faceConfigs = [
{ exposed: exposed.posX, stickerKey: 'right' }, // +X → 右面红
{ exposed: exposed.negX, stickerKey: 'left' }, // -X → 左面橙
{ exposed: exposed.posY, stickerKey: 'top' }, // +Y → 顶面白
{ exposed: exposed.negY, stickerKey: 'bottom' }, // -Y → 底面黄
{ exposed: exposed.posZ, stickerKey: 'front' }, // +Z → 前面绿
{ exposed: exposed.negZ, stickerKey: 'back' }, // -Z → 后面蓝
];
return faceConfigs.map((config) => {
if (config.exposed) {
const tex = stickerTextureCache[config.stickerKey];
return new THREE.MeshStandardMaterial({
map: tex,
roughness: 0.32,
metalness: 0.02,
color: 0xffffff,
});
} else {
return plasticMaterial.clone();
}
});
}
// 创建27个cubie
const cubieGroup = new THREE.Group(); // 用于组织所有cubie
cubieGroup.name = 'cubieGroup';
scene.add(cubieGroup);
for (let gx = -1; gx <= 1; gx++) {
for (let gy = -1; gy <= 1; gy++) {
for (let gz = -1; gz <= 1; gz++) {
const geo = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
const materials = createCubieMaterials(gx, gy, gz);
const mesh = new THREE.Mesh(geo, materials);
mesh.position.set(gx * GRID_SPACING, gy * GRID_SPACING, gz * GRID_SPACING);
mesh.rotation.set(0, 0, 0);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = `cubie_${gx}_${gy}_${gz}`;
// 存储初始格点坐标(用于调试和初始状态判断)
mesh.userData = {
initialGrid: { x: gx, y: gy, z: gz },
};
cubieGroup.add(mesh);
allCubies.push(mesh);
initialStates.set(mesh, {
position: mesh.position.clone(),
rotation: mesh.rotation.clone(),
});
}
}
}
// ==================== 交互状态机 ====================
const InteractionState = Object.freeze({
IDLE: 'idle',
DRAGGING: 'dragging',
ANIMATING: 'animating',
SCRAMBLING: 'scrambling',
});
let interactionState = InteractionState.IDLE;
let activePivot = null; // 当前旋转轴心(THREE.Group)
let activeAxisWorld = null; // 当前旋转轴(世界空间Vector3)
let activeLayerValue = null; // 当前旋转层的坐标值
let activeAxisIndex = null; // 轴索引:0=X, 1=Y, 2=Z
let dragStartNDC = null; // 鼠标按下时的NDC坐标
let projectedAxisScreen = null; // 投影到屏幕空间的轴方向(归一化Vector2)
let currentDragAngle = 0; // 当前拖拽累积角度
let selectedCubies = []; // 当前选中的cubie列表
// ==================== 工具函数 ====================
/**
* 将3D世界坐标投影到NDC空间
*/
function worldToNDC(worldPos) {
const ndc = worldPos.clone().project(camera);
return new THREE.Vector2(ndc.x, ndc.y);
}
/**
* 获取鼠标事件的NDC坐标
*/
function mouseToNDC(event) {
return new THREE.Vector2(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1
);
}
/**
* 将3D世界轴向量投影到屏幕空间,返回归一化的2D方向
* @param {THREE.Vector3} worldAxis - 世界空间中的轴向量(如(1,0,0))
* @param {THREE.Vector3} referencePoint - 参考点(用于投影)
* @returns {THREE.Vector2} 屏幕空间中的归一化方向
*/
function projectAxisToScreen(worldAxis, referencePoint) {
const p1 = worldToNDC(referencePoint);
const p2 = worldToNDC(referencePoint.clone().add(worldAxis));
const dir = new THREE.Vector2(p2.x - p1.x, p2.y - p1.y);
const len = dir.length();
if (len < 1e-9) return new THREE.Vector2(1, 0); // 退化情况
return dir.normalize();
}
/**
* 坐标清洗:消除浮点数累积误差
* - position 四舍五入到整数
* - rotation 欧拉角四舍五入到最接近的 π/2 倍数
*/
function snapCubieTransform(cubie) {
// 清洗位置
cubie.position.x = Math.round(cubie.position.x);
cubie.position.y = Math.round(cubie.position.y);
cubie.position.z = Math.round(cubie.position.z);
// 清洗旋转(欧拉角 → 最接近的 π/2 倍数)
const halfPi = Math.PI / 2;
cubie.rotation.x = Math.round(cubie.rotation.x / halfPi) * halfPi;
cubie.rotation.y = Math.round(cubie.rotation.y / halfPi) * halfPi;
cubie.rotation.z = Math.round(cubie.rotation.z / halfPi) * halfPi;
}
/**
* 对所有cubie执行坐标清洗
*/
function snapAllCubies() {
allCubies.forEach(snapCubieTransform);
}
/**
* 动态层级筛选:根据世界坐标和轴索引,找出属于同一旋转层的所有cubie
* @param {number} axisIndex - 轴索引 0=X, 1=Y, 2=Z
* @param {number} layerValue - 该层的坐标值
* @returns {THREE.Mesh[]}
*/
function getCubiesInLayer(axisIndex, layerValue) {
const result = [];
const axisKey = ['x', 'y', 'z'][axisIndex];
for (const cubie of allCubies) {
// 获取cubie在世界空间中的位置
const worldPos = new THREE.Vector3();
cubie.getWorldPosition(worldPos);
if (Math.abs(worldPos[axisKey] - layerValue) < EPSILON) {
result.push(cubie);
}
}
return result;
}
/**
* 获取cubie当前世界坐标在指定轴上的值
*/
function getCubieWorldAxisValue(cubie, axisIndex) {
const worldPos = new THREE.Vector3();
cubie.getWorldPosition(worldPos);
return ['x', 'y', 'z'][axisIndex] === 'x' ? worldPos.x : ['x', 'y', 'z'][axisIndex] === 'y' ? worldPos.y :
worldPos.z;
}
// ==================== Pivot 旋转机制 ====================
/**
* 创建轴心对象并挂载选中的cubie
* @param {THREE.Mesh[]} cubies - 要挂载的cubie列表
* @returns {THREE.Group} pivot
*/
function createPivotAndAttach(cubies) {
const pivot = new THREE.Group();
pivot.name = 'rotationPivot';
// Pivot放在世界原点,旋转轴通过原点
scene.add(pivot);
for (const cubie of cubies) {
// pivot.attach() 保持世界变换,将cubie挂载到pivot下
pivot.attach(cubie);
}
return pivot;
}
/**
* 释放pivot:将所有cubie放回场景,然后移除pivot
* @param {THREE.Group} pivot
*/
function releasePivot(pivot) {
const children = [...pivot.children]; // 复制数组(遍历时会被修改)
for (const cubie of children) {
// scene.attach() 保持世界变换,将cubie放回场景
scene.attach(cubie);
// 放回cubieGroup以保持组织结构
cubieGroup.attach(cubie);
}
scene.remove(pivot);
if (pivot.geometry) pivot.geometry.dispose();
if (pivot.material) {
if (Array.isArray(pivot.material)) {
pivot.material.forEach(m => m.dispose());
} else {
pivot.material.dispose();
}
}
}
/**
* 执行完整的层旋转(带磁吸动画)
* @param {THREE.Group} pivot
* @param {THREE.Vector3} axisWorld - 世界空间旋转轴
* @param {number} targetAngle - 目标角度(弧度)
* @param {number} duration - 动画时长(ms)
* @returns {Promise<void>}
*/
function animatePivotToTarget(pivot, axisWorld, targetAngle, duration) {
return new Promise((resolve) => {
// 获取pivot当前绕该轴的角度
const axisKey = ['x', 'y', 'z'][activeAxisIndex];
const startAngle = pivot.rotation[axisKey];
// 使用Tween.js
const tweenData = { angle: startAngle };
const tween = new TWEEN.Tween(tweenData)
.to({ angle: targetAngle }, duration)
.easing(TWEEN.Easing.Cubic.Out)
.onUpdate(() => {
pivot.rotation[axisKey] = tweenData.angle;
})
.onComplete(() => {
pivot.rotation[axisKey] = targetAngle;
resolve();
})
.start();
});
}
// ==================== 手势投影算法(核心考点) ====================
/**
* 根据面法线确定两个潜在旋转轴
* 原理:用户可以绕与法线垂直的任意轴旋转
* @param {THREE.Vector3} worldNormal - 世界空间中的面法线
* @returns {{axisA: THREE.Vector3, axisB: THREE.Vector3, indexA: number, indexB: number}}
*/
function getPotentialAxes(worldNormal) {
const absX = Math.abs(worldNormal.x);
const absY = Math.abs(worldNormal.y);
const absZ = Math.abs(worldNormal.z);
// 法线的主要分量决定了两个潜在旋转轴
if (absX >= absY && absX >= absZ) {
// 法线接近X轴 → 潜在旋转轴为Y和Z
return {
axisA: new THREE.Vector3(0, 1, 0),
axisB: new THREE.Vector3(0, 0, 1),
indexA: 1,
indexB: 2,
};
} else if (absY >= absX && absY >= absZ) {
// 法线接近Y轴 → 潜在旋转轴为X和Z
return {
axisA: new THREE.Vector3(1, 0, 0),
axisB: new THREE.Vector3(0, 0, 1),
indexA: 0,
indexB: 2,
};
} else {
// 法线接近Z轴 → 潜在旋转轴为X和Y
return {
axisA: new THREE.Vector3(1, 0, 0),
axisB: new THREE.Vector3(0, 1, 0),
indexA: 0,
indexB: 1,
};
}
}
/**
* 计算鼠标滑动向量与两个投影轴的点积,选择匹配度最高的轴
* @param {THREE.Vector2} dragVector - 鼠标滑动2D向量(NDC空间)
* @param {THREE.Vector2} projA - 轴A在屏幕空间的投影方向
* @param {THREE.Vector2} projB - 轴B在屏幕空间的投影方向
* @returns {{chosenIndex: number, dotProduct: number, chosenProj: THREE.Vector2}}
*/
function selectBestAxis(dragVector, projA, projB, indexA, indexB) {
const dotA = Math.abs(dragVector.dot(projA));
const dotB = Math.abs(dragVector.dot(projB));
if (dotA >= dotB) {
return {
chosenIndex: indexA,
dotProduct: dragVector.dot(projA),
chosenProj: projA,
};
} else {
return {
chosenIndex: indexB,
dotProduct: dragVector.dot(projB),
chosenProj: projB,
};
}
}
// ==================== 鼠标事件处理 ====================
let mouseDownWorldPoint = null; // 鼠标按下时的3D交点
let hasMovedSignificantly = false; // 是否产生了有效拖拽
const MIN_DRAG_THRESHOLD_NDC = 0.015; // 最小拖拽阈值(NDC单位)
canvas.addEventListener('pointerdown', (event) => {
// 仅处理左键(button === 0)
if (event.button !== 0) return;
// 非空闲状态不响应
if (interactionState !== InteractionState.IDLE) return;
// 射线检测
const ndc = mouseToNDC(event);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera);
const intersects = raycaster.intersectObjects(allCubies, false);
if (intersects.length === 0) return; // 未命中魔方
const intersect = intersects[0];
const clickedCubie = intersect.object;
const faceNormalLocal = intersect.face.normal.clone();
// 将面法线转换到世界空间
const worldNormal = faceNormalLocal.transformDirection(clickedCubie.matrixWorld).round();
// 获取交点世界位置
mouseDownWorldPoint = intersect.point.clone();
// 【手势投影算法】根据法线确定两个潜在旋转轴
const { axisA, axisB, indexA, indexB } = getPotentialAxes(worldNormal);
// 将两个3D轴投影到屏幕空间
const projA = projectAxisToScreen(axisA, mouseDownWorldPoint);
const projB = projectAxisToScreen(axisB, mouseDownWorldPoint);
// 存储以备mousemove使用(此时尚未决定选哪个轴)
dragStartNDC = ndc;
hasMovedSignificantly = false;
currentDragAngle = 0;
selectedCubies = [];
activePivot = null;
activeAxisWorld = null;
activeLayerValue = null;
activeAxisIndex = null;
projectedAxisScreen = null;
// 暂存潜在轴信息
canvas._gestureData = {
axisA,
axisB,
indexA,
indexB,
projA,
projB,
clickedCubie,
worldNormal,
};
interactionState = InteractionState.DRAGGING;
container.classList.add('grabbing');
controls.enabled = false; // 拖拽期间禁用OrbitControls
hintTag.style.opacity = '0.4';
});
canvas.addEventListener('pointermove', (event) => {
if (interactionState !== InteractionState.DRAGGING) return;
if (!canvas._gestureData) return;
const currentNDC = mouseToNDC(event);
const dragVector = new THREE.Vector2(
currentNDC.x - dragStartNDC.x,
currentNDC.y - dragStartNDC.y
);
const dragLength = dragVector.length();
// 检查是否超过最小阈值
if (!hasMovedSignificantly && dragLength < MIN_DRAG_THRESHOLD_NDC) {
return; // 微小移动,忽略
}
const gd = canvas._gestureData;
if (!hasMovedSignificantly) {
// 首次显著移动:选择最佳旋转轴
hasMovedSignificantly = true;
const selection = selectBestAxis(dragVector, gd.projA, gd.projB, gd.indexA, gd.indexB);
activeAxisIndex = selection.chosenIndex;
const rawDot = selection.dotProduct;
// 确定世界旋转轴
const axisVectors = [
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, 0, 1),
];
activeAxisWorld = axisVectors[activeAxisIndex].clone();
projectedAxisScreen = selection.chosenProj.clone();
// 动态层级筛选:根据被点击cubie的世界坐标确定旋转层
const clickedCubie = gd.clickedCubie;
const worldPos = new THREE.Vector3();
clickedCubie.getWorldPosition(worldPos);
const axisKey = ['x', 'y', 'z'][activeAxisIndex];
activeLayerValue = Math.round(worldPos[axisKey]);
// 筛选同一层的所有cubie
selectedCubies = getCubiesInLayer(activeAxisIndex, activeLayerValue);
// 创建Pivot并挂载选中的cubie
activePivot = createPivotAndAttach(selectedCubies);
activePivot.rotation.set(0, 0, 0);
currentDragAngle = 0;
// 更新gestureData
gd._rawDotSign = rawDot;
}
// 计算旋转角度:鼠标滑动向量在投影轴上的投影(带符号)
const projectionOnAxis = dragVector.dot(projectedAxisScreen);
// 转换为弧度(1 NDC ≈ π 弧度 ≈ 180°)
const angle = projectionOnAxis * ANGLE_PER_NDC;
// 符号修正:确保"向右滑=向右转"的视觉逻辑
// 使用叉乘判断:dragVector × projectedAxisScreen 的符号决定旋转方向
const crossZ = dragVector.x * projectedAxisScreen.y - dragVector.y * projectedAxisScreen.x;
const signCorrection = crossZ >= 0 ? 1 : -1;
const correctedAngle = Math.abs(angle) * signCorrection;
currentDragAngle = correctedAngle;
// 实时更新pivot旋转(1:1跟手)
const axisKey = ['x', 'y', 'z'][activeAxisIndex];
activePivot.rotation.set(0, 0, 0);
activePivot.rotation[axisKey] = correctedAngle;
});
window.addEventListener('pointerup', async (event) => {
if (interactionState !== InteractionState.DRAGGING) return;
container.classList.remove('grabbing');
hintTag.style.opacity = '1';
if (!hasMovedSignificantly || !activePivot || selectedCubies.length === 0) {
// 无有效拖拽:清理
if (activePivot) {
releasePivot(activePivot);
}
cleanupDragState();
controls.enabled = true;
return;
}
// 【磁吸效果】:计算最近的90度倍数
const halfPi = Math.PI / 2;
const rawAngle = currentDragAngle;
const targetAngle = Math.round(rawAngle / halfPi) * halfPi;
// 如果目标角度为0(即回到原位),直接释放
if (Math.abs(targetAngle) < 0.001) {
releasePivot(activePivot);
snapAllCubies();
cleanupDragState();
controls.enabled = true;
return;
}
// 执行磁吸动画
interactionState = InteractionState.ANIMATING;
btnScramble.disabled = true;
btnReset.disabled = true;
const pivotRef = activePivot;
const axisRef = activeAxisWorld;
const cubiesRef = selectedCubies;
await animatePivotToTarget(pivotRef, axisRef, targetAngle, MAGNETIC_DURATION);
// 动画完成:释放cubie回场景
releasePivot(pivotRef);
// 【坐标清洗】:消除浮点数累积误差
snapAllCubies();
cleanupDragState();
controls.enabled = true;
btnScramble.disabled = false;
btnReset.disabled = false;
interactionState = InteractionState.IDLE;
});
/**
* 清理拖拽状态
*/
function cleanupDragState() {
activePivot = null;
activeAxisWorld = null;
activeLayerValue = null;
activeAxisIndex = null;
dragStartNDC = null;
projectedAxisScreen = null;
currentDragAngle = 0;
selectedCubies = [];
hasMovedSignificantly = false;
mouseDownWorldPoint = null;
delete canvas._gestureData;
if (interactionState === InteractionState.DRAGGING) {
interactionState = InteractionState.IDLE;
}
container.classList.remove('grabbing');
}
// 防止右键菜单弹出(在canvas上)
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
// ==================== Scramble(打乱) ====================
/**
* 执行打乱序列
* 随机选择层和轴,执行一系列90度旋转
*/
async function scramble() {
if (interactionState !== InteractionState.IDLE) return;
interactionState = InteractionState.SCRAMBLING;
btnScramble.disabled = true;
btnReset.disabled = true;
controls.enabled = false;
hintTag.textContent = '🔀 正在打乱...';
hintTag.style.opacity = '1';
const axisVectors = [
new THREE.Vector3(1, 0, 0), // X轴
new THREE.Vector3(0, 1, 0), // Y轴
new THREE.Vector3(0, 0, 1), // Z轴
];
const layerValues = [-1, 0, 1]; // 三层
const angleOptions = [-Math.PI / 2, Math.PI / 2]; // ±90°
// 生成随机打乱序列(避免连续同轴旋转)
const steps = [];
let lastAxisIndex = -1;
for (let i = 0; i < SCRAMBLE_STEPS; i++) {
let axisIndex;
do {
axisIndex = Math.floor(Math.random() * 3);
} while (axisIndex === lastAxisIndex && SCRAMBLE_STEPS > 3);
lastAxisIndex = axisIndex;
const layerValue = layerValues[Math.floor(Math.random() * 3)];
const angle = angleOptions[Math.floor(Math.random() * 2)];
steps.push({ axisIndex, layerValue, angle });
}
for (let i = 0; i < steps.length; i++) {
const { axisIndex, layerValue, angle } = steps[i];
const cubies = getCubiesInLayer(axisIndex, layerValue);
if (cubies.length === 0) continue;
const pivot = createPivotAndAttach(cubies);
const axisKey = ['x', 'y', 'z'][axisIndex];
pivot.rotation.set(0, 0, 0);
await animatePivotToTarget(pivot, axisVectors[axisIndex], angle, SCRAMBLE_STEP_DURATION);
releasePivot(pivot);
snapAllCubies();
// 小延迟让动画呼吸
if (i < steps.length - 1) {
await new Promise(r => setTimeout(r, 40));
}
}
snapAllCubies();
controls.enabled = true;
btnScramble.disabled = false;
btnReset.disabled = false;
interactionState = InteractionState.IDLE;
hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放';
hintTag.style.opacity = '1';
}
// ==================== Reset(重置) ====================
/**
* 重置所有cubie到初始状态(使用动画平滑过渡)
*/
async function resetCube() {
if (interactionState !== InteractionState.IDLE) return;
interactionState = InteractionState.ANIMATING;
btnScramble.disabled = true;
btnReset.disabled = true;
controls.enabled = false;
hintTag.textContent = '↺ 正在重置...';
hintTag.style.opacity = '1';
// 收集所有需要动画的cubie及其目标状态
const animations = [];
for (const cubie of allCubies) {
const initState = initialStates.get(cubie);
if (!initState) continue;
const targetPos = initState.position.clone();
const targetRot = initState.rotation.clone();
// 如果当前位置/旋转与目标相同,跳过
const posDist = cubie.position.distanceTo(targetPos);
const rotDist = Math.abs(cubie.rotation.x - targetRot.x) +
Math.abs(cubie.rotation.y - targetRot.y) +
Math.abs(cubie.rotation.z - targetRot.z);
if (posDist < 0.001 && rotDist < 0.001) continue;
animations.push({
cubie,
startPos: cubie.position.clone(),
targetPos,
startRot: {
x: cubie.rotation.x,
y: cubie.rotation.y,
z: cubie.rotation.z,
},
targetRot: {
x: targetRot.x,
y: targetRot.y,
z: targetRot.z,
},
});
}
if (animations.length === 0) {
// 无需动画
snapAllCubies();
finishReset();
return;
}
// 使用Tween并行动画所有cubie
const tweenData = { progress: 0 };
const duration = 400;
const tween = new TWEEN.Tween(tweenData)
.to({ progress: 1 }, duration)
.easing(TWEEN.Easing.Cubic.InOut)
.onUpdate(() => {
const t = tweenData.progress;
for (const anim of animations) {
anim.cubie.position.lerpVectors(anim.startPos, anim.targetPos, t);
anim.cubie.rotation.x = anim.startRot.x + (anim.targetRot.x - anim.startRot.x) * t;
anim.cubie.rotation.y = anim.startRot.y + (anim.targetRot.y - anim.startRot.y) * t;
anim.cubie.rotation.z = anim.startRot.z + (anim.targetRot.z - anim.startRot.z) * t;
}
})
.onComplete(() => {
for (const anim of animations) {
anim.cubie.position.copy(anim.targetPos);
anim.cubie.rotation.set(anim.targetRot.x, anim.targetRot.y, anim.targetRot.z);
}
snapAllCubies();
finishReset();
})
.start();
// 等待tween完成
await new Promise(resolve => {
const check = setInterval(() => {
if (tweenData.progress >= 1) {
clearInterval(check);
resolve();
}
}, 50);
// 安全超时
setTimeout(() => { clearInterval(check);
resolve(); }, duration + 200);
});
}
function finishReset() {
snapAllCubies();
controls.enabled = true;
btnScramble.disabled = false;
btnReset.disabled = false;
interactionState = InteractionState.IDLE;
hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放';
hintTag.style.opacity = '1';
}
// ==================== 按钮事件 ====================
btnScramble.addEventListener('click', () => {
if (interactionState === InteractionState.IDLE) {
scramble();
}
});
btnReset.addEventListener('click', () => {
if (interactionState === InteractionState.IDLE) {
resetCube();
}
});
// 键盘快捷键
window.addEventListener('keydown', (event) => {
if (event.key === 's' || event.key === 'S') {
if (interactionState === InteractionState.IDLE) scramble();
}
if (event.key === 'r' || event.key === 'R') {
if (interactionState === InteractionState.IDLE) resetCube();
}
});
// ==================== 窗口大小适配 ====================
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ==================== 渲染循环 ====================
function animate(timestamp) {
requestAnimationFrame(animate);
// 更新Tween动画
TWEEN.update(timestamp);
// 更新OrbitControls(仅在非拖拽/非动画状态)
if (interactionState === InteractionState.IDLE) {
controls.enabled = true;
controls.update();
} else if (interactionState === InteractionState.DRAGGING) {
controls.enabled = false;
}
// 渲染
renderer.render(scene, camera);
}
// ==================== 启动 ====================
console.log('🧊 3x3 魔方已就绪');
console.log(' - 左键拖拽:手势旋转层(投影向量匹配算法)');
console.log(' - 右键拖拽:OrbitControls 旋转视角');
console.log(' - 滚轮:缩放');
console.log(' - 按钮/快捷键 S=打乱 R=重置');
console.log(' - Pivot挂载机制 + 坐标清洗 + 磁吸效果');
console.log(' - 物理间隙:', GAP.toFixed(2), '单位');
console.log(' - 倒角贴纸:Canvas程序化生成圆角矩形纹理');
requestAnimationFrame(animate);
</script>
</body>
</html>
1 个帖子 - 1 位参与者