ds-v4-pro max、glm5.3、kimi k3魔方测试
あ
2026-08-18 02:13
1
渠道均是OpenCode Go订阅,感觉现在魔方测试效果都还不错了诶
deepseek-v4-pro(加了双赛博眼睛),思考强度选择Max,用时:34分(做了大量验证工作)
glm5.3+Zcode,思考强度:Max,用时:44分16秒(也是做了大量验证工作)
Kimi K3+Kimi Code,思考强度选择Max,用时:23分05秒(验证工作做得少)
验证工作主要是harness设计导致的,总体来看做的效果都挺好的,感觉国产模型也是越来越好起来了
最新回复 (3)
-
あ 楼主 08-18 02:141楼deepseek-v4-pro的:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>3×3 魔方 · 物理级手势交互(Three.js 单文件)</title>
<style>
:root { --accent: #4fc3f7; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background:
radial-gradient(1100px 750px at 72% 18%, #2d3951 0%, rgba(45,57,81,0) 62%),
radial-gradient(900px 700px at 18% 82%, #1c2636 0%, rgba(28,38,54,0) 58%),
#131822;
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
user-select: none; -webkit-user-select: none;
}
#app { position: fixed; inset: 0; display: block; touch-action: none; cursor: default; }
#ui { position: fixed; inset: 0; pointer-events: none; z-index: 10; }
#title {
position: absolute; top: 24px; left: 0; right: 0; text-align: center;
font-size: 26px; font-weight: 700; letter-spacing: .18em; color: #eaf1fa;
text-shadow: 0 2px 18px rgba(0,0,0,.55);
}
#title .dot { color: var(--accent); margin: 0 .35em; }
#title .sub {
display: block; margin-top: 7px; font-size: 12px; font-weight: 400;
letter-spacing: .32em; color: rgba(214,226,244,.55);
}
#controls {
position: absolute; top: 22px; right: 24px; display: flex; gap: 10px; pointer-events: auto;
}
#controls button {
padding: 10px 24px; border-radius: 12px; font-size: 14px; letter-spacing: .08em;
color: #dce6f2; cursor: pointer; font-family: inherit;
background: rgba(18,24,36,.55); border: 1px solid rgba(255,255,255,.14);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
box-shadow: 0 6px 18px rgba(0,0,0,.35); transition: all .18s ease;
}
#controls button:hover:not(:disabled) {
background: rgba(79,195,247,.16); border-color: rgba(79,195,247,.5);
transform: translateY(-1px); box-shadow: 0 10px 22px rgba(0,0,0,.4);
}
#controls button:active:not(:disabled) { transform: translateY(0); }
#controls button:disabled { opacity: .4; cursor: not-allowed; }
#hint {
position: absolute; bottom: 22px; left: 0; right: 0; text-align: center;
font-size: 13px; letter-spacing: .1em; color: rgba(214,226,244,.6);
text-shadow: 0 1px 8px rgba(0,0,0,.5);
}
</style>
</head>
<body>
<canvas id="app"></canvas>
<div id="ui">
<header id="title">
3×3 魔方<span class="dot">·</span>手势交互
<span class="sub">PIVOT 挂载 · 投影手势识别 · 动态层级筛选</span>
</header>
<div id="controls">
<button id="btnScramble" type="button">打乱 SCRAMBLE</button>
<button id="btnReset" type="button">重置 RESET</button>
</div>
<footer id="hint">左键拖拽 · 旋转层 | 右键拖拽 · 旋转视角 | 滚轮 · 缩放 | 松开自动磁吸对齐</footer>
</div>
<!-- importmap:统一从 unpkg 引入 Three.js / OrbitControls / Tween.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"@tweenjs/tween.js": "https://unpkg.com/@tweenjs/[email protected]/dist/tween.esm.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import * as TWEEN from '@tweenjs/tween.js';
/* ============================================================
* 常量与全局状态
* ============================================================ */
const CUBIE_SCALE = 0.95; // 方块缩放,制造 0.05 的物理间隙(Spacing)
const STICKER_SIZE = 0.84; // 贴纸边长
const STICKER_OFF = 0.501; // 贴纸略微浮于塑料表面之上,避免 z-fighting
const EPS = 0.01; // 层级判定的坐标阈值(世界坐标取整后为整数格点)
const LOCK_PX = 10; // 锁定旋转轴所需的最小滑动距离(像素)
const QUARTER = Math.PI / 2;
const CENTER = new THREE.Vector3(0, 0, 0);
const IDENTITY_Q = new THREE.Quaternion();
// 世界坐标轴索引表:层级筛选与手势识别共用
const AXES = [
{ char: 'x', vec: new THREE.Vector3(1, 0, 0) },
{ char: 'y', vec: new THREE.Vector3(0, 1, 0) },
{ char: 'z', vec: new THREE.Vector3(0, 0, 1) },
];
// 六个外表面的贴纸定义(初始态外露的面才贴贴纸,内表面保持黑色塑料)
const FACE_DEFS = [
{ axis: 'x', sign: 1, color: '#b71234' }, // 右 R · 红
{ axis: 'x', sign: -1, color: '#ff5800' }, // 左 L · 橙
{ axis: 'y', sign: 1, color: '#f4f4f0' }, // 上 U · 白
{ axis: 'y', sign: -1, color: '#ffd500' }, // 下 D · 黄
{ axis: 'z', sign: 1, color: '#009e60' }, // 前 F · 绿
{ axis: 'z', sign: -1, color: '#0051ba' }, // 后 B · 蓝
];
/* ============================================================
* 渲染器 / 场景 / 相机 / 灯光
* ============================================================ */
const canvas = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true; // 开启 ShadowMap
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 120);
camera.position.set(6.4, 5.6, 8.6);
// 程序化环境光照:RoomEnvironment 是纯代码生成的场景,零外部贴图依赖,
// 为黑色塑料与贴纸提供柔和反射,增强"物理级"质感。
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
pmrem.dispose();
// 环境光 + 平行光(主光,投射阴影)+ 低强度补光
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.05);
keyLight.position.set(7, 11, 8.5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
const sCam = keyLight.shadow.camera;
sCam.left = -9; sCam.right = 9; sCam.top = 9; sCam.bottom = -9;
sCam.near = 1; sCam.far = 45;
sCam.updateProjectionMatrix();
keyLight.shadow.bias = -0.0003;
keyLight.shadow.normalBias = 0.03;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xbfd4ff, 0.3);
fillLight.position.set(-6, 2.5, -4);
scene.add(fillLight);
// 地面:ShadowMaterial 只接收阴影,背景透出 CSS 渐变 —— 完全零贴图
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(90, 90),
new THREE.ShadowMaterial({ opacity: 0.32 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -3.3;
ground.receiveShadow = true;
scene.add(ground);
// OrbitControls:左键让位给"层旋转",右键负责旋转视角,中键/滚轮缩放
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 4.5;
controls.maxDistance = 22;
controls.mouseButtons = {
LEFT: null, // 左键:不旋转视角(留给层旋转)
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE, // 右键:旋转视角
};
controls.update();
/* ============================================================
* 程序化贴纸纹理(HTML5 Canvas,零外部素材)
* 圆角矩形 + 对角受光渐变 + 边缘内阴影 + 左上角镜面高光
* ============================================================ */
const texCache = new Map();
function createStickerTexture(colorHex) {
if (texCache.has(colorHex)) return texCache.get(colorHex);
const S = 256;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
const m = S * 0.045; // 贴纸与贴纸面边缘的留白(塑料黑边)
const r = S * 0.16; // 圆角半径
const x = m, y = m, w = S - 2 * m, h = S - 2 * m;
// 手动 arcTo 绘制圆角矩形路径(兼容性最好)
const path = () => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
// ① 底色:左上亮 → 右下暗的对角渐变,模拟曲面受光
const base = new THREE.Color(colorHex);
const light = base.clone().lerp(new THREE.Color(0xffffff), 0.22);
const dark = base.clone().multiplyScalar(0.62);
const grad = ctx.createLinearGradient(x, y, x + w, y + h);
grad.addColorStop(0, `#${light.getHexString()}`);
grad.addColorStop(1, `#${dark.getHexString()}`);
path();
ctx.fillStyle = grad;
ctx.fill();
// ② 边缘内阴影:贴纸剪裁区内描边 + 阴影模糊,模拟贴纸"贴附"于塑料的立体边缘
ctx.save();
path();
ctx.clip();
ctx.lineWidth = 14;
ctx.strokeStyle = 'rgba(0,0,0,0.34)';
ctx.shadowColor = 'rgba(0,0,0,0.55)';
ctx.shadowBlur = 16;
ctx.shadowOffsetY = 6;
path();
ctx.stroke();
ctx.restore();
// ③ 左上角镜面高光:半透明白色径向渐变 → 塑料贴纸的"高光"
const sheen = ctx.createRadialGradient(S * 0.34, S * 0.26, S * 0.03, S * 0.40, S * 0.32, S * 0.80);
sheen.addColorStop(0, 'rgba(255,255,255,0.42)');
sheen.addColorStop(0.4, 'rgba(255,255,255,0.10)');
sheen.addColorStop(1, 'rgba(255,255,255,0)');
ctx.save();
path();
ctx.clip();
ctx.fillStyle = sheen;
ctx.fillRect(0, 0, S, S);
ctx.restore();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
texCache.set(colorHex, tex);
return tex;
}
const plasticMat = new THREE.MeshStandardMaterial({ color: 0x0d0d0f, roughness: 0.30, metalness: 0.06 });
plasticMat.envMapIntensity = 0.9;
const plasticGeo = new RoundedBoxGeometry(1, 1, 1, 4, 0.06); // 倒角塑料块
const stickerGeo = new THREE.PlaneGeometry(STICKER_SIZE, STICKER_SIZE);
const stickerMats = new Map(); // 颜色 → 贴纸材质(缓存复用)
function stickerMaterial(colorHex) {
if (!stickerMats.has(colorHex)) {
const mat = new THREE.MeshStandardMaterial({
map: createStickerTexture(colorHex),
roughness: 0.26,
metalness: 0.0,
});
mat.envMapIntensity = 0.55; // 压低环境反射,保持贴纸色彩饱和度
stickerMats.set(colorHex, mat);
}
return stickerMats.get(colorHex);
}
/* ============================================================
* 构建 27 个小方块(Cubies)
* ============================================================ */
const cubies = [];
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const cubie = new THREE.Group();
cubie.userData.isCubie = true;
cubie.userData.homePosition = new THREE.Vector3(x, y, z); // 复原目标位
cubie.position.set(x, y, z);
cubie.scale.setScalar(CUBIE_SCALE);
const plastic = new THREE.Mesh(plasticGeo, plasticMat);
plastic.castShadow = true;
plastic.receiveShadow = true;
cubie.add(plastic);
// 仅给"当前位于魔方外表面"的面贴贴纸,其余面保持黑色塑料
for (const f of FACE_DEFS) {
if (cubie.userData.homePosition[f.axis] !== f.sign) continue;
const sticker = new THREE.Mesh(stickerGeo, stickerMaterial(f.color));
sticker.position[f.axis] = f.sign * STICKER_OFF;
if (f.axis === 'x') sticker.rotation.y = f.sign * Math.PI / 2;
if (f.axis === 'y') sticker.rotation.x = -f.sign * Math.PI / 2;
if (f.axis === 'z' && f.sign === -1) sticker.rotation.y = Math.PI;
sticker.castShadow = false; // 贴纸不投影,避免与塑料产生阴影痤疮
sticker.receiveShadow = false;
cubie.add(sticker);
}
cubies.push(cubie);
scene.add(cubie);
}
/* ============================================================
* 坐标清洗:每次旋转结束后取整,消除浮点累积误差
* 位置 → 整数格点;欧拉角 → 90° 倍数(三轴四分之一转的复合仍
* 属于立方体旋转群,逐轴吸附到 π/2 倍数即可精确恢复合法朝向)
* ============================================================ */
function roundTransform(obj) {
obj.position.set(
Math.round(obj.position.x),
Math.round(obj.position.y),
Math.round(obj.position.z),
);
obj.rotation.set(
Math.round(obj.rotation.x / QUARTER) * QUARTER,
Math.round(obj.rotation.y / QUARTER) * QUARTER,
Math.round(obj.rotation.z / QUARTER) * QUARTER,
);
}
/* ============================================================
* Pivot 挂载机制(核心考点)
* ------------------------------------------------------------
* attach() 会自动重写子对象的局部矩阵,使其"世界变换保持不变",
* 从而避免手写四元数/矩阵换算;动画结束后 scene.attach() 再原样
* 放回场景,同样保持世界变换,整个过程零误差。
* ============================================================ */
let pending = null; // 当前未完成的层旋转 { pivot, layer, tween }
let isAnimating = false; // 动画中(阻挡新的层操作)
// 选择某一层并挂载到新建的 Pivot(基于世界坐标 + EPS 动态筛选,不写死索引)
function attachLayerToPivot(axisChar, coord) {
const pivot = new THREE.Group();
scene.add(pivot);
const layer = [];
const tmp = new THREE.Vector3();
for (const c of cubies) {
c.getWorldPosition(tmp); // 世界坐标(不是局部坐标)
if (Math.abs(tmp[axisChar] - coord) < EPS) { // 阈值判断是否属于当前层
layer.push(c);
pivot.attach(c); // 挂载:世界变换自动保持
}
}
return { pivot, layer };
}
// 完成一次层旋转:方块放回场景 + Pivot 销毁 + 坐标清洗
function finishPending(round) {
if (!pending) return;
if (pending.tween) pending.tween.stop(); // 已自然完成时 stop 无副作用
for (const c of pending.layer) scene.attach(c); // 放回场景(保持世界变换)
scene.remove(pending.pivot);
if (round) pending.layer.forEach(roundTransform); // 坐标清洗
pending = null;
}
// 通用层旋转动画(拖拽磁吸与打乱算法共用)
function animateLayerMove(axisChar, coord, angle, duration, easing, onDone) {
const { pivot, layer } = attachLayerToPivot(axisChar, coord);
const tween = new TWEEN.Tween({ a: 0 })
.to({ a: angle }, duration)
.easing(easing)
.onUpdate(o => { pivot.rotation[axisChar] = o.a; })
.onComplete(() => { finishPending(true); onDone && onDone(); })
.start();
pending = { pivot, layer, tween };
return tween;
}
/* ============================================================
* 射线拾取 & 3D→2D 屏幕投影工具
* ============================================================ */
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const normalMat3 = new THREE.Matrix3();
const tmpA = new THREE.Vector3();
const tmpB = new THREE.Vector3();
function pickCubie(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
ndc.set(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(cubies, true);
for (const hit of hits) {
let obj = hit.object;
while (obj && !obj.userData.isCubie) obj = obj.parent; // 上溯找到所属方块
if (!obj) continue;
// 面法线:本地法线经 normalMatrix 变换到世界空间(非均匀缩放也正确)
const n = hit.face.normal.clone().applyMatrix3(normalMat3.getNormalMatrix(hit.object.matrixWorld));
return { cubie: obj, normal: n };
}
return null;
}
// 法线吸附到主分量轴:倒角面可能给出对角线法线,取其最大分量方向
function dominantAxis(n) {
const abs = [Math.abs(n.x), Math.abs(n.y), Math.abs(n.z)];
let i = 0;
if (abs[1] > abs[i]) i = 1;
if (abs[2] > abs[i]) i = 2;
const v = new THREE.Vector3();
v.setComponent(i, Math.sign(n.getComponent(i)));
return v;
}
// 把 3D 世界向量投影到 2D 屏幕空间(CSS 像素坐标系,y 向下)
function screenVector(worldDir) {
tmpA.copy(CENTER).add(worldDir);
tmpB.copy(CENTER);
tmpA.project(camera);
tmpB.project(camera);
const w = canvas.clientWidth;
const h = canvas.clientHeight;
return { x: (tmpA.x - tmpB.x) * w * 0.5, y: -(tmpA.y - tmpB.y) * h * 0.5 };
}
/* ============================================================
* 手势识别(交互核心)
* ------------------------------------------------------------
* 1) 射线检测点击面的世界法线 → 锁定两个潜在旋转轴
* (例如点中前面,潜在轴 = X 与 Y);
* 2) 把两个候选 3D 轴投影到 2D 屏幕空间;
* 3) 关键几何事实:绕轴 a 正旋转时,被点击面的表面沿 cross(a, normal)
* 的切向移动 —— 对近似正对相机的面,该方向在屏幕上恰好垂直于
* 轴 a 的投影。因此判断"哪根轴最贴合手指滑动"时,需把滑动向量
* 与 cross(a, normal) 的屏幕投影做点积(而非与轴本身),
* 绝对值最大者当选;否则向右滑动会误选竖列轴,出现"手指横划、
* 面却竖走"的错轴现象;
* 4) 方向符号修正:同一个点积的符号即旋转方向(叉乘符号修正),
* 保证无论从正面/背面/顶面操作,"往哪拖,面就往哪走",
* 向右划动始终对应向右转;
* 5) 像素位移 × 比例系数 = 实时角度,1:1 跟手;
* 6) 松手后 Tween 磁吸到最近的 90° 倍数(Back.Out 回弹手感)。
* ============================================================ */
const drag = {
active: false, locked: false,
startX: 0, startY: 0, lockX: 0, lockY: 0,
normal: null, candidates: null, pickCubieRef: null,
pivot: null, layer: null,
axisChar: 'x', coord: 0,
screenDir: null, k: 0, angle: 0, tween: null,
};
function onPointerDown(e) {
if (e.pointerType === 'touch') return; // 触屏交给 OrbitControls(双指缩放/单指视角)
if (e.button !== 0 || isAnimating || drag.active) return;
const pick = pickCubie(e.clientX, e.clientY);
if (!pick) return;
drag.active = true;
drag.locked = false;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.normal = dominantAxis(pick.normal); // 面法线(吸附到世界轴)
drag.candidates = AXES.filter(a => drag.normal[a.char] === 0); // 潜在两轴
drag.pickCubieRef = pick.cubie;
drag.angle = 0;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = 'grabbing';
}
function lockAxis(clientX, clientY, dx, dy) {
// —— 投影 + 点积选轴(手势投影算法核心)——
// 对每个候选轴 a:把"绕 a 正转时被点击面的切向运动方向"
// cross(a, normal) 投影到屏幕 2D 空间,再与滑动向量做点积,
// 绝对值最大者当选(该方向对正对相机的面恰垂直于轴 a 的屏幕投影)。
let best = null, bestScore = -1;
for (const a of drag.candidates) {
const cross = new THREE.Vector3().crossVectors(a.vec, drag.normal);
const s = screenVector(cross);
const len = Math.hypot(s.x, s.y);
if (len < 1e-3) continue; // 切向指向屏幕外(退化),跳过
const score = Math.abs((dx * s.x + dy * s.y) / len);
if (score > bestScore) { bestScore = score; best = { a, s, len }; }
}
if (!best) return; // 无可见候选轴 → 本次拖拽作废
drag.axisChar = best.a.char;
// —— 动态层级筛选:以点击方块的世界坐标定层,遍历全部方块按 EPS 判层 ——
const p = new THREE.Vector3();
drag.pickCubieRef.getWorldPosition(p);
drag.coord = Math.round(p[drag.axisChar]);
// —— Pivot 挂载:attach 自动保持世界变换,之后只需旋转 Pivot 本身 ——
const attached = attachLayerToPivot(drag.axisChar, drag.coord);
drag.pivot = attached.pivot;
drag.layer = attached.layer;
pending = { pivot: drag.pivot, layer: drag.layer, tween: null };
// —— 符号修正(叉乘):屏幕方向归一化后与滑动向量点积,
// 点积符号即旋转方向 —— 任意视角下"往哪拖,面就往哪走" ——
drag.screenDir = { x: best.s.x / best.len, y: best.s.y / best.len };
// 像素→弧度换算:拖过屏幕上该投影向量的长度 ≈ 转 90°(1:1 跟手)
drag.k = QUARTER / THREE.MathUtils.clamp(best.len, 90, 520);
drag.lockX = clientX;
drag.lockY = clientY;
drag.locked = true;
}
function onPointerMove(e) {
// 空闲态悬浮提示:检测是否悬停在方块上
if (!drag.active && !isAnimating && e.buttons === 0) {
canvas.style.cursor = pickCubie(e.clientX, e.clientY) ? 'grab' : 'default';
return;
}
if (!drag.active) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
// 位移超过阈值才锁定旋转轴(避免微抖动误触,锁死后轴不再变化)
if (!drag.locked) {
if (Math.hypot(dx, dy) < LOCK_PX) return;
lockAxis(e.clientX, e.clientY, dx, dy);
if (!drag.locked) return;
}
// 实时跟手:锁定点之后的滑动向量 × 修正方向 → 角度,逐帧 1:1
const lx = e.clientX - drag.lockX;
const ly = e.clientY - drag.lockY;
drag.angle = (lx * drag.screenDir.x + ly * drag.screenDir.y) * drag.k;
drag.pivot.rotation[drag.axisChar] = drag.angle;
}
function onPointerUp(e) {
if (e.button !== 0 || !drag.active) return;
drag.active = false;
canvas.style.cursor = 'default';
if (!drag.locked) return; // 未锁定:微小位移,视为误触
// —— 磁吸:吸附到最近 90° 倍数;角度过小则回弹归零 ——
let target = Math.round(drag.angle / QUARTER) * QUARTER;
if (Math.abs(drag.angle) < 0.05) target = 0;
isAnimating = true;
drag.tween = new TWEEN.Tween({ a: drag.angle })
.to({ a: target }, 280)
.easing(TWEEN.Easing.Back.Out) // 带回弹的磁吸手感
.onUpdate(o => { drag.pivot.rotation[drag.axisChar] = o.a; })
.onComplete(() => { finishPending(true); isAnimating = false; })
.start();
if (pending) pending.tween = drag.tween;
}
/* ============================================================
* Scramble(打乱)与 Reset(重置)
* ============================================================ */
const btnScramble = document.getElementById('btnScramble');
const btnReset = document.getElementById('btnReset');
let runId = 0; // 世代号:Reset 打断打乱链的令牌
function scramble() {
if (isAnimating) return;
isAnimating = true;
btnScramble.disabled = true;
// 生成 20 步随机转动,避免连续转动同一层
const moves = [];
let prev = null;
for (let i = 0; i < 20; i++) {
let m;
do {
m = {
axis: AXES[Math.floor(Math.random() * 3)].char,
coord: [-1, 0, 1][Math.floor(Math.random() * 3)],
dir: Math.random() < 0.5 ? -1 : 1,
};
} while (prev && m.axis === prev.axis && m.coord === prev.coord);
moves.push(m);
prev = m;
}
const id = ++runId;
let i = 0;
const next = () => {
if (id !== runId) return; // 被 Reset 打断 → 链条终止
if (i >= moves.length) {
isAnimating = false;
btnScramble.disabled = false;
return;
}
const m = moves[i++];
animateLayerMove(m.axis, m.coord, m.dir * QUARTER, 210, TWEEN.Easing.Cubic.InOut, next);
};
next();
}
function resetCube() {
runId++; // 打断进行中的打乱链
if (drag.active) { // 打断进行中的拖拽
drag.active = false;
canvas.style.cursor = 'default';
}
// 若存在未完成的层转:立即把方块挂回场景(不取整——Reset 直接从
// 当前世界变换插值到初始态,中途可能处于非格点位置)
finishPending(false);
isAnimating = true;
btnScramble.disabled = true;
btnReset.disabled = true;
const items = cubies.map(c => ({
c,
fromPos: c.position.clone(),
fromQuat: c.quaternion.clone(),
home: c.userData.homePosition,
// 按初始位置远近错峰启动,形成波浪式复位动画
delay: (c.userData.homePosition.x + c.userData.homePosition.y + c.userData.homePosition.z + 3) / 6 * 0.4,
}));
new TWEEN.Tween({ t: 0 })
.to({ t: 1 }, 1200)
.easing(TWEEN.Easing.Cubic.InOut)
.onUpdate(o => {
for (const it of items) {
const lt = THREE.MathUtils.clamp((o.t - it.delay) / Math.max(1e-6, 1 - it.delay), 0, 1);
const e = TWEEN.Easing.Cubic.InOut(lt);
it.c.position.lerpVectors(it.fromPos, it.home, e);
it.c.quaternion.slerpQuaternions(it.fromQuat, IDENTITY_Q, e); // 四元数球面插值,走最短路径
}
})
.onComplete(() => {
for (const it of items) {
it.c.position.copy(it.home);
it.c.quaternion.copy(IDENTITY_Q);
roundTransform(it.c); // 结束再做一次坐标清洗
}
isAnimating = false;
btnScramble.disabled = false;
btnReset.disabled = false;
})
.start();
}
btnScramble.addEventListener('click', scramble);
btnReset.addEventListener('click', resetCube);
/* ============================================================
* 事件绑定 & 渲染循环
* ============================================================ */
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerleave', () => { if (!drag.active) canvas.style.cursor = 'default'; });
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop(() => {
TWEEN.update();
controls.update();
renderer.render(scene, camera);
});
</script>
</body>
</html>
-
あ 楼主 08-18 02:142楼glm5.3的:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>3D 魔方 · Three.js 物理级交互</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%; overflow: hidden; background: #0a0c14; overscroll-behavior: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
user-select: none; -webkit-user-select: none;
}
canvas { display: block; }
#hud {
position: fixed; top: 20px; left: 20px; z-index: 10; width: 226px; padding: 16px 18px;
border-radius: 16px; background: rgba(15, 18, 29, .74);
border: 1px solid rgba(255, 255, 255, .09);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
color: #e8ecf6; box-shadow: 0 12px 40px rgba(0, 0, 0, .45);
}
#hud h1 { font-size: 16px; font-weight: 700; letter-spacing: .14em; }
#hud .sub { font-size: 10.5px; color: #7d8aa5; letter-spacing: .05em; margin-top: 4px; }
#hud .hint {
font-size: 11.5px; line-height: 1.9; color: #98a4bd; margin-top: 12px;
border-top: 1px solid rgba(255, 255, 255, .07); padding-top: 10px;
}
#hud .hint b { color: #d4dcf0; font-weight: 600; margin-right: 6px; }
.btns { display: flex; gap: 8px; margin-top: 14px; }
.btns button {
flex: 1; padding: 9px 0; border: 0; border-radius: 10px;
font-size: 13px; font-weight: 700; color: #fff; cursor: pointer;
transition: filter .15s, transform .05s;
background: linear-gradient(135deg, #4d7cfe, #7a5cff);
}
#btnReset { background: linear-gradient(135deg, #3a4360, #525f85); }
.btns button:hover { filter: brightness(1.15); }
.btns button:active { transform: translateY(1px); }
.btns button:disabled { opacity: .4; cursor: not-allowed; filter: none; }
#status { margin-top: 12px; font-size: 12px; color: #aeb9d6; }
#status b { color: #fff; font-size: 14px; }
#toast {
position: fixed; top: 28px; left: 50%; z-index: 20;
transform: translate(-50%, -18px); opacity: 0; pointer-events: none;
transition: opacity .35s, transform .35s;
background: linear-gradient(135deg, #35e08d, #17b06c); color: #05301c;
font-size: 14px; font-weight: 800; padding: 11px 24px; border-radius: 999px;
box-shadow: 0 10px 34px rgba(46, 213, 137, .35);
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }
#err {
position: fixed; left: 16px; bottom: 14px; z-index: 30; max-width: 70vw;
font-size: 12px; line-height: 1.5; color: #ff9c9c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
<!-- 依赖统一由 importmap 从 CDN 引入: Three.js / OrbitControls / Tween.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"@tweenjs/tween.js": "https://unpkg.com/@tweenjs/[email protected]/dist/tween.esm.js"
}
}
</script>
<script>
// CDN 加载失败等致命错误的兜底提示
window.addEventListener('error', e => {
const d = document.getElementById('err');
if (d) { d.hidden = false; d.textContent = '加载/运行错误: ' + (e.message || e.type); }
});
</script>
</head>
<body>
<div id="hud">
<h1>3D 魔方</h1>
<div class="sub">THREE.JS · CANVAS 程序化贴纸</div>
<div class="hint">
<b>左键拖拽</b>转动魔方层<br>
<b>右键拖拽</b>旋转视角<br>
<b>滚轮</b>缩放 · 松手磁吸对齐
</div>
<div class="btns">
<button id="btnScramble">打乱魔方</button>
<button id="btnReset">重置</button>
</div>
<div id="status">步数 <b id="moves">0</b></div>
</div>
<div id="toast"></div>
<div id="err" hidden></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { Tween, Easing, Group as TweenGroup } from '@tweenjs/tween.js';
/* ============================================================
* 一、常量与配色
* ============================================================ */
const CUBIE = 1; // 小方块边长
const GAP = 0.055; // 小方块之间的物理间隙(不可紧贴)
const STEP = CUBIE + GAP; // 网格步长(世界坐标单位)
const EPS = STEP * 0.5; // 层归属判定阈值(Epsilon)
const HALF_PI = Math.PI / 2;
const DRAG_THRESHOLD = 8; // 判定旋转意图的最小拖拽距离(px)
const AXIS_VECS = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1),
};
// 标准配色: +X 红 / -X 橙 / +Y 白 / -Y 黄 / +Z 绿 / -Z 蓝
const FACES = [
{ dir: new THREE.Vector3( 1, 0, 0), color: '#c62838' },
{ dir: new THREE.Vector3(-1, 0, 0), color: '#ff5d00' },
{ dir: new THREE.Vector3( 0, 1, 0), color: '#f3f3f1' },
{ dir: new THREE.Vector3( 0,-1, 0), color: '#ffd500' },
{ dir: new THREE.Vector3( 0, 0, 1), color: '#00a45c' },
{ dir: new THREE.Vector3( 0, 0,-1), color: '#0a56c8' },
];
/* ============================================================
* 二、渲染器 / 场景 / 相机 / 光影环境
* ============================================================ */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true; // 必须开启阴影贴图
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 软阴影
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = makeBackgroundTexture();
const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(4.6, 4.0, 6.6);
// 环境光 + 投影平行光, 保证立体感与阴影投射
scene.add(new THREE.AmbientLight(0xdfe8ff, 0.5));
const sun = new THREE.DirectionalLight(0xffffff, 2.6);
sun.position.set(5.5, 11, 7.5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -5;
sun.shadow.camera.right = 5;
sun.shadow.camera.top = 5;
sun.shadow.camera.bottom = -5;
sun.shadow.camera.near = 2;
sun.shadow.camera.far = 26;
sun.shadow.camera.updateProjectionMatrix();
sun.shadow.bias = -0.0004;
sun.shadow.normalBias = 0.02;
scene.add(sun);
// 程序化室内环境贴图(PMREM): 提供真实反射高光, 不依赖任何外部资源
{
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(renderer), 0.04).texture;
pmrem.dispose();
}
// 地面: 只承接阴影的隐形平面
const ground = new THREE.Mesh(
new THREE.CircleGeometry(14, 64).rotateX(-HALF_PI),
new THREE.ShadowMaterial({ opacity: 0.35 })
);
ground.position.y = -2.8;
ground.receiveShadow = true;
scene.add(ground);
// 视角控制: 左键(-1 = 禁用)让位给层旋转手势; 右键旋转视角; 滚轮缩放
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 5;
controls.maxDistance = 16;
controls.mouseButtons = { LEFT: -1, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
/* ============================================================
* 三、Canvas 程序化纹理(零外部素材)
* ============================================================ */
function roundedRectPath(g, x, y, w, h, r) {
if (g.roundRect) { g.roundRect(x, y, w, h, r); return; } // 新版 Canvas API
g.moveTo(x + r, y);
g.arcTo(x + w, y, x + w, y + h, r);
g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r);
g.arcTo(x, y, x + w, y, r);
g.closePath();
}
// 贴纸纹理: 圆角矩形 + 上亮下暗渐变高光, 四周透明露出黑色塑料底 → 模拟真实魔方贴纸
function makeStickerTexture(hex) {
const S = 256, M = 12, R = 42;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
roundedRectPath(g, M, M, S - 2 * M, S - 2 * M, R);
g.fillStyle = hex;
g.fill();
// 贴纸光泽: 顶部斜向高光 → 底部轻微压暗
const grad = g.createLinearGradient(0, M, 0, S - M);
grad.addColorStop(0, 'rgba(255,255,255,0.38)');
grad.addColorStop(0.42, 'rgba(255,255,255,0.06)');
grad.addColorStop(1, 'rgba(0,0,0,0.18)');
g.fillStyle = grad;
g.fill();
// 内描边增强边缘质感
g.lineWidth = 4;
g.strokeStyle = 'rgba(0,0,0,0.22)';
g.stroke();
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
// 页面背景: 纵向渐变
function makeBackgroundTexture() {
const c = document.createElement('canvas');
c.width = 32; c.height = 512;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, 512);
grad.addColorStop(0, '#2c3552');
grad.addColorStop(0.55, '#151b2d');
grad.addColorStop(1, '#07080f');
g.fillStyle = grad;
g.fillRect(0, 0, 32, 512);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
/* ============================================================
* 四、构建魔方: 27 个独立 cubie(倒角塑料体 + 贴纸)
* ============================================================ */
const bodyGeo = new RoundedBoxGeometry(CUBIE, CUBIE, CUBIE, 4, 0.09); // 倒角塑料外壳
const stickerGeo = new THREE.PlaneGeometry(CUBIE * 0.84, CUBIE * 0.84);
const bodyMat = new THREE.MeshPhysicalMaterial({
color: 0x0e0f13, roughness: 0.34, metalness: 0.05,
clearcoat: 0.6, clearcoatRoughness: 0.3, envMapIntensity: 0.65,
});
const stickerMats = FACES.map(f => new THREE.MeshPhysicalMaterial({
map: makeStickerTexture(f.color), transparent: true,
roughness: 0.2, metalness: 0,
clearcoat: 0.85, clearcoatRoughness: 0.12, envMapIntensity: 0.5,
}));
let cubeGroup = null; // 魔方根节点(恒为单位变换, 局部坐标 == 世界坐标)
let cubies = []; // 27 个 cubie 的引用
function buildCube() {
if (cubeGroup) scene.remove(cubeGroup);
cubeGroup = new THREE.Group();
scene.add(cubeGroup);
cubies = [];
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const cubie = new THREE.Group();
cubie.position.set(x * STEP, y * STEP, z * STEP); // 含物理间隙的网格坐标
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.castShadow = body.receiveShadow = true;
cubie.add(body);
const stickers = [];
FACES.forEach((f, i) => {
// 只有最外侧可见面贴贴纸: 方块在该方向处于 ±1 层 (dot == 1)
if (x * f.dir.x + y * f.dir.y + z * f.dir.z !== 1) return;
const s = new THREE.Mesh(stickerGeo, stickerMats[i]);
s.position.copy(f.dir).multiplyScalar(CUBIE / 2 + 0.006); // 微微悬浮避免 z-fighting
setStickerRotation(s, f.dir); // 平面法线朝向 f.dir
s.userData = { colorIndex: i, normal: f.dir.clone() };
cubie.add(s);
stickers.push(s);
});
cubie.userData.stickers = stickers;
cubeGroup.add(cubie);
cubies.push(cubie);
}
}
function setStickerRotation(mesh, dir) {
if (dir.x) mesh.rotation.y = dir.x * HALF_PI; // +X: π/2 / -X: -π/2
else if (dir.y) mesh.rotation.x = -dir.y * HALF_PI; // +Y: -π/2 / -Y: +π/2
else if (dir.z < 0) mesh.rotation.y = Math.PI; // -Z: 翻转 180°
}
/* ============================================================
* 五、Pivot 挂载式层旋转(核心变换机制)
* ------------------------------------------------------------
* 旋转某一层时创建临时 Pivot 轴心对象:
* pivot.attach(cubie) —— attach 自动完成世界矩阵换算, 把 cubie 挂到轴心下,
* 无需手写任何四元数乘法;
* 旋转 pivot —— 整层跟随转动;
* parent.attach(cubie) —— 动画结束后放回, 世界变换再次自动换算。
* ============================================================ */
const tweenGroup = new TweenGroup();
let animating = false; // 磁吸回弹 / 打乱播放中, 禁止新旋转手势
let pivot = null; // 当前旋转轴心
let rotAxisVec = null; // 当前旋转轴(世界空间单位向量)
// —— 动态层级筛选: 不维护任何 3D 状态数组, 每次旋转都按世界坐标 + Epsilon 现算 ——
function selectLayer(axisName, coord) {
return cubies.filter(c => Math.abs(c.position[axisName] - coord) < EPS);
}
function beginPivot(axisName, coord) {
rotAxisVec = AXIS_VECS[axisName].clone();
pivot = new THREE.Group();
cubeGroup.add(pivot); // pivot 挂在恒等变换的 cubeGroup 下, 坐标系一致
for (const c of selectLayer(axisName, coord)) {
pivot.attach(c); // 关键 API: 世界变换自动换算
}
}
function setPivotAngle(a) {
pivot.quaternion.setFromAxisAngle(rotAxisVec, a);
}
// 解除挂载 + 坐标清洗: 位置取整回网格、旋转矩阵逐元素 Math.round,
// 消除浮点累积误差, 防止魔方"散架"
function finishPivot() {
for (const c of [...pivot.children]) {
cubeGroup.attach(c); // 放回 cubeGroup, 世界变换自动换算
}
cubeGroup.remove(pivot);
pivot = null;
for (const c of cubies) {
c.position.set(
Math.round(c.position.x / STEP) * STEP,
Math.round(c.position.y / STEP) * STEP,
Math.round(c.position.z / STEP) * STEP
);
snapQuaternion(c.quaternion);
}
}
// 90° 整数倍旋转的旋转矩阵, 元素必为 -1/0/1, 逐元素取整即可精确吸附朝向
const _mat = new THREE.Matrix4();
function snapQuaternion(q) {
_mat.makeRotationFromQuaternion(q);
const e = _mat.elements;
for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]);
q.setFromRotationMatrix(_mat);
}
/* ============================================================
* 六、自然手势: 射线检测 + 基于投影向量的轴识别 + 1:1 跟手 + 磁吸
* ============================================================ */
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const _proj = new THREE.Vector3();
let moveCount = 0;
let drag = null; // { startX, startY, cubie, point, normal, basis?, angle }
function toScreenPixels(worldPoint) {
_proj.copy(worldPoint).project(camera);
return {
x: (_proj.x + 1) / 2 * window.innerWidth,
y: (1 - _proj.y) / 2 * window.innerHeight,
};
}
// 射线检测: 命中 cubie + 命中点 + 世界空间表面法线(吸附到最近坐标轴)
function raycastCubie(e) {
const r = renderer.domElement.getBoundingClientRect();
ndc.set(
((e.clientX - r.left) / r.width) * 2 - 1,
-((e.clientY - r.top) / r.height) * 2 + 1
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(cubies, true);
if (!hits.length) return null;
const h = hits[0];
let cubie = h.object;
while (cubie.parent && cubie.parent !== cubeGroup) cubie = cubie.parent; // 上溯到 cubie 根
const n = h.face.normal.clone().transformDirection(h.object.matrixWorld); // 物体空间 → 世界空间
snapToAxis(n);
return { cubie, point: h.point.clone(), normal: n };
}
function snapToAxis(n) {
const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
if (ax >= ay && ax >= az) n.set(Math.sign(n.x), 0, 0);
else if (ay >= az) n.set(0, Math.sign(n.y), 0);
else n.set(0, 0, Math.sign(n.z));
}
/* ------------------------------------------------------------
* 手势投影算法 —— 从 2D 拖拽向量反推 3D 旋转轴与角度:
* 1. 表面法线 n 排除自身方向, 剩下两个候选旋转轴
* (例如点击 +Z 前面 → 候选轴为 X / Y);
* 2. 对每个候选轴 A, 点击点随 +A 正向旋转的运动切线 t = A × r
* (r 为命中点到魔方中心的矢径, 即刚体运动学 v = ω × r, 叉乘);
* 3. 将 t 投影(Project)到屏幕: 分别投影 point 与 point+t,
* 两者像素差即"每弧度对应的像素位移向量 s";
* 4. 计算鼠标拖拽向量 d 与两个 s 的夹角余弦(|点积|/|模|),
* 匹配度(Dot Product)最高者为旋转轴;
* 5. 角度 = (d·s) / |s|²:
* - 分子点积自带符号 → 天然完成方向修正, 无论从正面/背面/顶面
* 操作, 鼠标向右划永远对应贴纸"向右转"的视觉逻辑;
* - 分母 |s|² 给出真实的 像素→弧度 换算 → 1:1 实时跟手。
* ------------------------------------------------------------ */
function chooseRotationAxis(g, dx, dy) {
const axisNames = ['x', 'y', 'z'].filter(n => Math.abs(g.normal[n]) < 0.5); // 排除法线轴
const r = g.point.clone(); // 魔方中心位于原点, 矢径即世界坐标
let best = null;
for (const name of axisNames) {
const t = new THREE.Vector3().crossVectors(AXIS_VECS[name], r); // 切线 = ω × r
const p0 = toScreenPixels(g.point);
const p1 = toScreenPixels(g.point.clone().add(t));
const sx = p1.x - p0.x, sy = p1.y - p0.y;
const len = Math.hypot(sx, sy);
if (len < 2) continue; // 切线几乎平行于视线, 该轴无法在屏幕上辨识
const dl = Math.hypot(dx, dy);
const score = Math.abs((dx * sx + dy * sy) / (dl * len)); // |cosθ| 匹配度
if (!best || score > best.score) best = { name, sx, sy, len2: len * len, score };
}
return best;
}
// —— 左键按下: 只记录, 不旋转 ——
renderer.domElement.addEventListener('pointerdown', e => {
if (e.button !== 0 || animating || drag) return;
const hit = raycastCubie(e);
if (!hit) return; // 点到空白处: 左键无操作(右键仍可旋转视角)
drag = { ...hit, startX: e.clientX, startY: e.clientY, basis: null, angle: 0 };
renderer.domElement.setPointerCapture(e.pointerId);
renderer.domElement.style.cursor = 'grabbing';
});
// —— 拖拽中: 先判定意图(选轴), 再 1:1 跟手 ——
window.addEventListener('pointermove', e => {
if (!drag) { updateHoverCursor(e); return; }
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
if (!drag.basis) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return; // 位移太小, 意图不明
const basis = chooseRotationAxis(drag, dx, dy);
if (!basis) return;
drag.basis = basis;
// 由被点击 cubie 的世界坐标确定层号, 挂载 Pivot 开始旋转
beginPivot(basis.name, Math.round(drag.cubie.position[basis.name] / STEP) * STEP);
}
// 1:1 跟手: 像素投影点积 / |s|² → 弧度(符号与幅度天然正确)
drag.angle = (dx * drag.basis.sx + dy * drag.basis.sy) / drag.basis.len2;
setPivotAngle(drag.angle);
});
// —— 松手: 磁吸回弹到最近的 90° 倍数 ——
function endDrag() {
if (!drag) return;
const { basis, angle } = drag;
drag = null;
renderer.domElement.style.cursor = 'default';
if (!basis || !pivot) return; // 未构成旋转意图(仅点击)
animating = true;
const target = Math.round(angle / HALF_PI) * HALF_PI; // 最近 90° 倍数
const state = { a: angle };
new Tween(state)
.group(tweenGroup)
.to({ a: target }, 340)
.easing(Easing.Back.Out) // 轻微过冲回弹, 模拟真实魔方的磁吸手感
.onUpdate(() => setPivotAngle(state.a))
.onComplete(() => {
setPivotAngle(target);
finishPivot();
animating = false;
const turns = Math.round(target / HALF_PI);
if (turns !== 0) {
moveCount += Math.abs(turns);
refreshHud();
checkSolved();
}
})
.start();
}
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
function updateHoverCursor(e) {
if (animating) { renderer.domElement.style.cursor = 'default'; return; }
renderer.domElement.style.cursor = raycastCubie(e) ? 'grab' : 'default';
}
/* ============================================================
* 七、程序化旋转 / 打乱 / 重置 / 复原检测
* ============================================================ */
function rotateLayer(axisName, coord, turns, duration = 110) {
return new Promise(resolve => {
beginPivot(axisName, coord);
const target = turns * HALF_PI;
const state = { a: 0 };
new Tween(state)
.group(tweenGroup)
.to({ a: target }, duration)
.easing(Easing.Cubic.Out)
.onUpdate(() => setPivotAngle(state.a))
.onComplete(() => { setPivotAngle(target); finishPivot(); resolve(); })
.start();
});
}
async function scramble() {
if (animating || drag) return;
animating = true;
btnScramble.disabled = btnReset.disabled = true;
let lastAxis = null;
for (let i = 0; i < 22; i++) {
let axis;
do { axis = 'xyz'[(Math.random() * 3) | 0]; } while (axis === lastAxis); // 避免同轴连转
lastAxis = axis;
const coord = STEP * [-1, 0, 1][(Math.random() * 3) | 0];
const turns = Math.random() < 0.5 ? -1 : 1;
await rotateLayer(axis, coord, turns);
}
animating = false;
btnScramble.disabled = btnReset.disabled = false;
moveCount = 0;
refreshHud();
}
function resetCube() {
if (animating || drag) return;
buildCube(); // 共享几何体与材质, 直接重建 27 个方块
moveCount = 0;
hideToast();
refreshHud();
}
// 复原检测: 逐世界方向收集贴纸颜色, 六个方向各自颜色唯一 → 已还原
// (支持"整体色向旋转"型解, 而非只认初始状态)
const _n = new THREE.Vector3();
function isSolved() {
const faceColor = new Map();
for (const c of cubies) {
for (const s of c.userData.stickers) {
_n.copy(s.userData.normal).applyQuaternion(c.quaternion); // 贴纸当前世界朝向
const key = axisKey(_n);
const color = s.userData.colorIndex;
if (faceColor.has(key)) {
if (faceColor.get(key) !== color) return false;
} else {
faceColor.set(key, color);
}
}
}
return true;
}
function axisKey(n) {
const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
if (ax >= ay && ax >= az) return 'x' + (n.x > 0 ? '+' : '-');
if (ay >= az) return 'y' + (n.y > 0 ? '+' : '-');
return 'z' + (n.z > 0 ? '+' : '-');
}
function checkSolved() {
if (moveCount > 0 && isSolved()) showToast('🎉 还原成功 · 共 ' + moveCount + ' 步');
}
/* ============================================================
* 八、HUD / 主循环
* ============================================================ */
const btnScramble = document.getElementById('btnScramble');
const btnReset = document.getElementById('btnReset');
const movesEl = document.getElementById('moves');
const toastEl = document.getElementById('toast');
let toastTimer = 0;
function refreshHud() { movesEl.textContent = moveCount; }
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 3200);
}
function hideToast() { toastEl.classList.remove('show'); clearTimeout(toastTimer); }
btnScramble.addEventListener('click', () => scramble());
btnReset.addEventListener('click', () => resetCube());
buildCube();
refreshHud();
// 供 E2E 调试断言使用
window.__cubeDebug = {
cubies: () => cubies,
isSolved,
moveCount: () => moveCount,
cameraPos: () => camera.position.toArray(),
};
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
renderer.setAnimationLoop(t => {
tweenGroup.update(t); // Tween.js 驱动磁吸/打乱动画
controls.update(); // 视角阻尼惯性
renderer.render(scene, camera);
});
</script>
</body>
</html>
-
あ 楼主 08-18 02:153楼Kimi k3的:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3×3 魔方 · Three.js 高保真手势交互</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%; overflow: hidden; background: #14161a;
font-family: -apple-system, "PingFang SC", "Segoe UI", "Microsoft YaHei", sans-serif;
}
canvas { display: block; }
#ui { position: fixed; top: 22px; left: 24px; z-index: 10; color: #e8eaf0; user-select: none; }
#ui h1 { font-size: 17px; font-weight: 600; letter-spacing: .5px; margin-bottom: 12px; opacity: .92; }
.btns { display: flex; gap: 10px; margin-bottom: 14px; }
.btns button {
padding: 8px 18px; font-size: 13px; color: #e8eaf0;
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.16);
border-radius: 8px; cursor: pointer; backdrop-filter: blur(6px);
transition: background .15s, transform .1s;
}
.btns button:hover { background: rgba(255,255,255,.16); }
.btns button:active { transform: scale(.96); }
.btns button:disabled { opacity: .35; cursor: default; }
.hint { font-size: 12px; line-height: 1.9; color: #8b909b; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"tween": "https://unpkg.com/@tweenjs/[email protected]/dist/tween.esm.js"
}
}
</script>
</head>
<body>
<div id="ui">
<h1>3×3 魔方 · Rubik's Cube</h1>
<div class="btns">
<button id="btn-scramble">打乱 Scramble</button>
<button id="btn-reset">重置 Reset</button>
</div>
<p class="hint">
左键拖动色块 — 旋转该层(实时跟手)<br>
右键拖动 — 旋转视角 · 滚轮 — 缩放
</p>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import * as TWEEN from 'tween';
/* ================================================================
* 常量
* ================================================================ */
const HALF_PI = Math.PI / 2; // 90°
const EPSILON = 0.5; // 动态层级筛选的世界坐标阈值
const CUBIE_SIZE = 0.94; // 小方块边长(槽位间距为 1,物理间隙 0.06)
const DRAG_THRESHOLD = 6; // 手势死区(像素),区分点击与拖拽
const MAX_DRAG_ANGLE = HALF_PI * 1.05; // 单次拖拽允许的最大转角(略超 90°)
/* ================================================================
* 渲染器 / 场景 / 相机
* ================================================================ */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true; // 开启 ShadowMap
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14161a);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4.8, 4.0, 6.4);
/* ================================================================
* 灯光:环境光 + 平行光(投影)
* ================================================================ */
scene.add(new THREE.AmbientLight(0xffffff, 0.85));
const dirLight = new THREE.DirectionalLight(0xffffff, 2.4);
dirLight.position.set(6, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
const SHADOW_RANGE = 6;
dirLight.shadow.camera.left = -SHADOW_RANGE;
dirLight.shadow.camera.right = SHADOW_RANGE;
dirLight.shadow.camera.top = SHADOW_RANGE;
dirLight.shadow.camera.bottom = -SHADOW_RANGE;
dirLight.shadow.camera.near = 2;
dirLight.shadow.camera.far = 30;
dirLight.shadow.bias = -0.0004;
dirLight.shadow.normalBias = 0.02; // 消除平面自阴影条纹
scene.add(dirLight);
// 只接收阴影的"隐形"地面
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(60, 60),
new THREE.ShadowMaterial({ opacity: 0.4 })
);
ground.rotation.x = -HALF_PI;
ground.position.y = -2.6;
ground.receiveShadow = true;
scene.add(ground);
/* ================================================================
* 程序化贴纸纹理(零外部素材)
* 黑色塑料底 + 圆角矩形贴纸 + 渐变高光 + 边缘亮线 → 倒角质感
* ================================================================ */
function roundedRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function makeStickerTexture(colorHex) {
const S = 256; // 画布尺寸
const M = 24; // 贴纸外边距(露出塑料黑边)
const R = 40; // 贴纸圆角半径
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
// 1) 塑料底座
ctx.fillStyle = '#101013';
ctx.fillRect(0, 0, S, S);
// 2) 圆角矩形贴纸:对角线渐变模拟受光
const base = new THREE.Color(colorHex);
const hi = '#' + base.clone().lerp(new THREE.Color(0xffffff), 0.30).getHexString();
const mid = '#' + base.getHexString();
const lo = '#' + base.clone().lerp(new THREE.Color(0x000000), 0.18).getHexString();
roundedRectPath(ctx, M, M, S - 2 * M, S - 2 * M, R);
const grad = ctx.createLinearGradient(M, M, S - M, S - M);
grad.addColorStop(0, hi);
grad.addColorStop(0.5, mid);
grad.addColorStop(1, lo);
ctx.fillStyle = grad;
ctx.fill();
// 3) 贴纸上半部分的柔和高光(模拟光滑塑料膜)
ctx.save();
roundedRectPath(ctx, M, M, S - 2 * M, S - 2 * M, R);
ctx.clip();
const gloss = ctx.createLinearGradient(0, M, 0, S * 0.6);
gloss.addColorStop(0, 'rgba(255,255,255,0.32)');
gloss.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gloss;
ctx.fillRect(M, M, S - 2 * M, S * 0.6);
ctx.restore();
// 4) 贴纸边缘一圈细亮线,强化"倒角"立体感
roundedRectPath(ctx, M + 2, M + 2, S - 2 * M - 4, S - 2 * M - 4, R - 2);
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 3;
ctx.stroke();
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
/* ================================================================
* 构建 27 个独立小方块(Cubies),槽位坐标为 -1/0/1
* ================================================================ */
const cubies = [];
const cubieGeo = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
const plasticMat = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, roughness: 0.45, metalness: 0.15 });
const stickerMats = new Map();
function getStickerMaterial(colorHex) {
if (!stickerMats.has(colorHex)) {
stickerMats.set(colorHex, new THREE.MeshStandardMaterial({
map: makeStickerTexture(colorHex), roughness: 0.28, metalness: 0.05
}));
}
return stickerMats.get(colorHex);
}
// 六个外表面的配色(BoxGeometry 材质数组顺序固定为 +x, -x, +y, -y, +z, -z)
const FACE_DEFS = [
{ axis: 'x', sign: 1, color: 0xc41e3a }, // 右:红
{ axis: 'x', sign: -1, color: 0xff7a00 }, // 左:橙
{ axis: 'y', sign: 1, color: 0xffffff }, // 上:白
{ axis: 'y', sign: -1, color: 0xffd500 }, // 下:黄
{ axis: 'z', sign: 1, color: 0x009e60 }, // 前:绿
{ axis: 'z', sign: -1, color: 0x0051ba }, // 后:蓝
];
(function buildCube() {
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const home = new THREE.Vector3(x, y, z);
// 只有魔方外表面的方格才贴贴纸,朝内的面是纯黑塑料
const mats = FACE_DEFS.map(f =>
home[f.axis] === f.sign ? getStickerMaterial(f.color) : plasticMat
);
const cubie = new THREE.Mesh(cubieGeo, mats);
cubie.position.copy(home);
cubie.castShadow = true;
cubie.receiveShadow = true;
cubie.userData.home = home; // 记录初始位置,供 Reset 使用
scene.add(cubie);
cubies.push(cubie);
}
})();
/* ================================================================
* Pivot 变换机制(核心考点)
* ----------------------------------------------------------------
* 旋转某一层时,不手动计算每个方块的矩阵,而是:
* 1. 创建一个位于原点、与场景对齐的临时 pivot 对象;
* 2. 用 pivot.attach(cubie) 把该层方块挂载进来 —— attach 会
* 根据两者当前的世界矩阵自动求出"局部变换",保证方块的
* 世界位置/朝向在挂载瞬间保持不变;
* 3. 只需旋转 pivot 一个对象,层内 9 个方块随之一同转动;
* 4. 结束后用 scene.attach(cubie) 逐一放回场景 —— 同样由
* attach 把旋转后的世界矩阵自动"烘焙"成方块的局部变换。
* 全程无需手写任何四元数乘法。
* ================================================================ */
let pivot = null;
function beginLayerRotation(axis, layerCoord) {
pivot = new THREE.Object3D();
scene.add(pivot);
pivot.updateMatrixWorld(true);
const v = new THREE.Vector3();
cubies.forEach(c => {
c.getWorldPosition(v); // 动态层级筛选:世界坐标 + 阈值判断归属
if (Math.abs(v[axis] - layerCoord) < EPSILON) pivot.attach(c);
});
}
function endLayerRotation() {
if (!pivot) return;
[...pivot.children].forEach(c => scene.attach(c)); // 放回场景,烘焙世界变换
scene.remove(pivot);
pivot = null;
snapCubiesToGrid();
}
/* 坐标清洗:位置与旋转取整,消除浮点累积误差,防止魔方"散架"。
* 所有合法朝向的欧拉角必为 90° 的整数倍,因此直接 round 到最近的 90°。 */
function snapCubiesToGrid() {
cubies.forEach(c => {
c.position.set(
Math.round(c.position.x),
Math.round(c.position.y),
Math.round(c.position.z)
);
c.rotation.set(
Math.round(c.rotation.x / HALF_PI) * HALF_PI,
Math.round(c.rotation.y / HALF_PI) * HALF_PI,
Math.round(c.rotation.z / HALF_PI) * HALF_PI
);
c.updateMatrixWorld(true);
});
}
/* ================================================================
* 交互系统:手势识别 + 1:1 跟手 + 磁吸回弹
* ================================================================ */
const raycaster = new THREE.Raycaster();
const AXES = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1)
};
let controls = null; // 稍后创建(必须先注册我们自己的 pointerdown,见下方说明)
let gesture = null; // 当前手势状态
let busy = false; // 动画(打乱/回弹)期间锁定输入
// 世界坐标 → 屏幕像素坐标
function worldToScreen(v3) {
const v = v3.clone().project(camera);
return new THREE.Vector2(
(v.x + 1) * 0.5 * innerWidth,
(1 - v.y) * 0.5 * innerHeight
);
}
/* 屏幕空间切线:点 p 绕轴 a 旋转 1 弧度时,p 在屏幕上的像素位移。
* 用"切线 t = a × p + 小步长差分"数值求屏幕投影的导数,比大步长
* 差分更精确(避免透视投影的非线性误差)。单位:px / rad。 */
function screenTangent(point, axisVec) {
const t = new THREE.Vector3().crossVectors(axisVec, point);
if (t.lengthSq() < 1e-6) return null;
const e = 0.01; // 小步长(世界单位)
return worldToScreen(point.clone().addScaledVector(t, e))
.sub(worldToScreen(point))
.divideScalar(e);
}
// 向量主导轴(取绝对值最大的分量)
function dominantAxis(v) {
const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z);
return ax > ay ? (ax > az ? 'x' : 'z') : (ay > az ? 'y' : 'z');
}
/* ----------------------------------------------------------------
* 手势投影算法(核心考点)
* ----------------------------------------------------------------
* 按下时通过射线检测拿到被点击面的世界法线 n,则旋转轴只可能是
* 与 n 垂直的另外两个轴。对候选轴 a:
* 1) 计算命中点 p 绕 a 旋转的切线方向 t = a × p(叉乘),
* 即该点在旋转时的瞬时运动方向;
* 2) 把 t 投影到 2D 屏幕空间得到向量 w —— 物理含义是
* "该层每转 1 弧度,被点击点在屏幕上移动 w 像素";
* 3) 计算鼠标拖动向量 drag 与 w 的点积:匹配度(|cos|)最大
* 的轴就是用户真正想转的轴。
*
* 方向修正:w 的方向由叉乘与投影共同决定,天然携带符号信息,
* 因此 drag·w 的正负直接给出旋转的正负方向 —— 无论相机处于魔方
* 正面、背面还是顶面,"鼠标向右划 = 视觉上向右转"始终成立,
* 从根上消除了手写符号对照表导致的方向反转 bug。
*
* 1:1 跟手:angle = (drag·w) / (w·w),即 drag 在 w 方向上的投影
* 长度换算回弧度,使被点击点恰好跟随鼠标移动。
* ---------------------------------------------------------------- */
function decideRotationAxis(drag) {
let best = null;
let bestScore = -Infinity;
for (const name of gesture.candidates) {
const w = screenTangent(gesture.hitPoint, AXES[name]); // 屏幕空间切线(px/rad)
if (!w || w.lengthSq() < 1e-3) continue;
const score = Math.abs(drag.dot(w)) / w.length(); // 匹配度
if (score > bestScore) { bestScore = score; best = { name, w }; }
}
if (!best) return false;
gesture.axis = best.name;
gesture.wPerRad = best.w;
gesture.status = 'rotating';
// 动态层级筛选:以被点击方块的世界坐标确定层号(-1 / 0 / 1)
const layerCoord = Math.round(gesture.cubie.getWorldPosition(new THREE.Vector3())[best.name]);
beginLayerRotation(best.name, layerCoord);
return true;
}
function onPointerDown(e) {
if (busy || gesture) return;
if (e.pointerType === 'mouse' && e.button !== 0) return; // 只接管左键/触摸;右键留给 OrbitControls
const ndc = new THREE.Vector2(
(e.clientX / innerWidth) * 2 - 1,
-(e.clientY / innerHeight) * 2 + 1
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObjects(cubies, false);
if (hits.length === 0) return; // 点在空白处 → 交给 OrbitControls
const hit = hits[0];
// 命中面的世界空间法线(face.normal 是物体空间,需用世界矩阵变换),吸附到最近坐标轴
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
gesture = {
cubie: hit.object,
hitPoint: hit.point.clone(), // 世界坐标命中点
normalAxis: dominantAxis(n), // 被点击面的朝向轴
candidates: null, // 两个候选旋转轴
start: new THREE.Vector2(e.clientX, e.clientY),
status: 'pending', // pending → rotating
axis: null,
wPerRad: null, // 屏幕空间"每弧度像素位移"
angle: 0
};
gesture.candidates = ['x', 'y', 'z'].filter(a => a !== gesture.normalAxis);
controls.enabled = false; // 手势期间禁用视角控制
}
function onPointerMove(e) {
if (!gesture || busy) return;
const drag = new THREE.Vector2(e.clientX - gesture.start.x, e.clientY - gesture.start.y);
if (gesture.status === 'pending') {
if (drag.length() < DRAG_THRESHOLD) return; // 死区内不判定,避免误触发
if (!decideRotationAxis(drag)) { gesture = null; controls.enabled = true; return; }
// 决策成功:把死区内累积的拖动也计入初始角度,避免"起步卡顿"
gesture.angle = drag.dot(gesture.wPerRad) / gesture.wPerRad.lengthSq();
gesture.lastDrag = drag.clone();
} else {
/* 1:1 实时跟手(增量式线性化):
* 切线方向随旋转不断变化,若沿用初始切线,转过大角度后被点击点
* 会偏离鼠标。因此每一帧都用"当前旋转角作用后的命中点"重新
* 计算切线投影 w,只把本帧的鼠标增量换算成弧度累加 ——
* 相当于沿圆弧逐帧重新线性化,90° 全程都能精准跟手。 */
const a = AXES[gesture.axis];
const pCur = gesture.hitPoint.clone().applyAxisAngle(a, gesture.angle);
const w = screenTangent(pCur, a);
if (w && w.lengthSq() > 1e-3) {
const delta = drag.clone().sub(gesture.lastDrag);
gesture.angle += delta.dot(w) / w.lengthSq();
}
gesture.lastDrag.copy(drag);
}
gesture.angle = THREE.MathUtils.clamp(gesture.angle, -MAX_DRAG_ANGLE, MAX_DRAG_ANGLE);
if (pivot) pivot.rotation[gesture.axis] = gesture.angle;
}
function onPointerUp() {
if (!gesture) return;
const g = gesture;
gesture = null;
controls.enabled = true;
if (g.status === 'rotating' && pivot) {
// 磁吸效果:吸附到最近的 90° 倍数,并用 Tween 播放回弹动画
const target = Math.round(g.angle / HALF_PI) * HALF_PI;
const duration = 140 + (Math.abs(target - g.angle) / HALF_PI) * 180;
setBusy(true);
const state = { a: g.angle };
new TWEEN.Tween(state)
.to({ a: target }, duration)
.easing(TWEEN.Easing.Quartic.Out)
.onUpdate(() => { if (pivot) pivot.rotation[g.axis] = state.a; })
.onComplete(() => { endLayerRotation(); setBusy(false); })
.start();
}
}
/* 注意:必须先注册我们自己的 pointerdown,再创建 OrbitControls。
* 这样触摸手势命中方块时,我们能在 OrbitControls 的处理器之前
* 把 controls.enabled 置为 false,使视角旋转与层旋转互不干扰。 */
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
renderer.domElement.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
/* ================================================================
* 视角控制:操作分离 —— 左键转层,右键转视角,滚轮缩放
* ================================================================ */
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 4;
controls.maxDistance = 16;
controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE };
/* ================================================================
* 打乱 / 重置
* ================================================================ */
const btnScramble = document.getElementById('btn-scramble');
const btnReset = document.getElementById('btn-reset');
function setBusy(v) {
busy = v;
btnScramble.disabled = v;
btnReset.disabled = v;
}
// 播放一步标准转动(轴、层号、方向 ±1)
function playMove(axis, layerCoord, direction, duration, onDone) {
beginLayerRotation(axis, layerCoord);
const state = { a: 0 };
new TWEEN.Tween(state)
.to({ a: direction * HALF_PI }, duration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(() => { if (pivot) pivot.rotation[axis] = state.a; })
.onComplete(() => { endLayerRotation(); onDone && onDone(); })
.start();
}
function scramble() {
if (busy) return;
setBusy(true);
const names = ['x', 'y', 'z'];
const layers = [-1, 0, 1];
const TOTAL = 20;
let done = 0, prevAxis = '';
const next = () => {
if (done++ >= TOTAL) { setBusy(false); return; }
let axis;
do { axis = names[(Math.random() * 3) | 0]; } while (axis === prevAxis); // 避免连续转同一轴
prevAxis = axis;
const layer = layers[(Math.random() * 3) | 0];
const dir = Math.random() < 0.5 ? 1 : -1;
playMove(axis, layer, dir, 130, next);
};
next();
}
function reset() {
if (busy) return;
cubies.forEach(c => {
c.position.copy(c.userData.home);
c.rotation.set(0, 0, 0);
c.updateMatrixWorld(true);
});
}
btnScramble.addEventListener('click', scramble);
btnReset.addEventListener('click', reset);
/* ================================================================
* 主循环与自适应
* ================================================================ */
function animate(time) {
requestAnimationFrame(animate);
TWEEN.update(time);
controls.update();
renderer.render(scene, camera);
}
requestAnimationFrame(animate);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
* 帖子来源Linux.do
附近帖子
- ↑感觉我把grok里面的伙伴给带跑偏了
- ↑Cerebras free tier 将加入 Qwen 3.8-27B,Gemma-4-31B 9月首周下线
- ↑有无中转推荐awsb源头的哇
- ↑hetzner不再提供 GLM-5.2、Kimi-K2.7-Code、DeepSeek-V4-Flash-0731
- ↑grok之前200+的bug heavy,依旧存活
- 📍 ds-v4-pro max、glm5.3、kimi k3魔方测试
- ↓Qwen3.8-27B 跑分已出,agentic 得分超过 Deepseek-V4-Flash
- ↓大家日常用什么IDE?
- ↓我看好多佬买了grok heavy年付,哪怕薅羊毛也得接近1000刀了,真舍得啊
- ↓关于newapi的service_tier问题
- ↓Cursor Origin上线