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:14
    1

    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">左键拖拽 · 旋转层 &nbsp;|&nbsp; 右键拖拽 · 旋转视角 &nbsp;|&nbsp; 滚轮 · 缩放 &nbsp;|&nbsp; 松开自动磁吸对齐</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:14
    2

    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:15
    3

    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
返回