想问问各位佬怎么如果新开对话的话,怎么把之前的对话内容简要告知新对话中呢?这个是让旧对话的ai自己简要概括一下吗 10 个帖子 - 10 位参与者 阅读完整话题
早上看到官方发的一个帖子: 简要看了一下,大概意思是,以前你要用 curl 写一堆 JSON,现在直接用 ant 命令就行。也可以理解成,ant = Claude 的官方 API 命令行版 安装: brew install anthropics/tap/ant 安装后可以这样用: ant --version ant messages create --model claude-sonnet-4-5 --message "你好" ant beta:agents create ... # 创建托管 Agent 文档是在这里感兴趣可以看下: CLI - Claude API Docs 下面是让gemini整理了一个cheatsheet: 7 个帖子 - 5 位参与者 阅读完整话题
如题,本文是我回顾过去一年对自己使用过的AI模型进行的一个简要回顾,欢迎大家阅读探讨。 GPT 5.x GPT系列堪称是我的启蒙系列了,最早使用国外模型就是它。最早注册免费号的时候感觉还不错,当时并没有重度使用,就偶尔上传点图片,偶尔触发限额,后来越来越频繁直到三句话就限额,我才冒出了上Plus的想法,可惜当时没有购买渠道,于是作罢。后来接触了中转才继续使用GPT系列的,再到现在基本上就是找首月免费或者其他渠道开Plus了。 最初体验不错,或许是因为我是萌新,亦或是因为当时还是GPT-5系列刚出,体感还不错,之后我开始使用中转站的时期,也还是以GPT为主力(依稀记得当时带着电脑去管科楼复习我的力学,就是用的中转站的GPT一问一答),到12月,1月我就开始有点不对的感觉了,回答问题开始人机化,频繁出现列表式的结构,在之后接触了Gemini,GPT就开始忽略了。 现在,主力是Claude和各类agent/IDE,GPT已经基本被我抛在脑后,虽然有时候听说GPT数理逻辑推理能力强,Pro模型可以连续思考几十分钟甚至以小时为单位,我也没有再返回GPT,甚至在Perplexity里面还是会避开GPT的模型——因为回复实在是人机,我都有种过敏的反应了。 Gemini 3.x Gemini是我在1月到2月开始接触的模型,当时加入了谷歌家庭组,就开始大量使用Gemini,这是一个完全崭新的领域——Gemini以多模态的能力著称,有很多独特的功能(如后来的创建音乐,生成图片,以及canvas模式等等),我还花了很多时间探索谷歌的各种子产品,如NoteBookLM,生成音乐等等,2月到3月算是我和谷歌的一个“蜜月期”,当时很喜欢Gemini,平时用用Flash,偶尔上Thinking回答数理问题,再不行才Pro,感觉用起来得心应手。直到后来频繁开始“降智”,发现连很多简单问题都会出错,而且开始变得奉承,我才转入Claude。 现在,Gemini前段时间刚刚发布了3.5Flash的更新,速度变快了但依旧没什么长进,我除了偶尔需要前端处理以外,基本上不怎么用Gemini了,有点可惜,希望六月的3.5Pro能让Gemini再次发力吧。 Claude Sonnet 4.6 应该是Claude系列我最最常用的模型,一来满足我对Claude的几乎所有需要:理性客观的分析,有独立看法,遇到不会的知识不会乱编而是自己知道去联网搜索,二来价格额度消耗算挺不错,我的Pro还从来没有出现额度限制。 Claude系列我一直很喜欢,语言风格克制,有时候真的就说一句话,不像一些AI没事就输出上百行无意义分点(特指GPT)。同时真的给我一种是真人的感觉,有自己的思考模式,客观分析,可以指出我的一些问题,我不止一次和它探讨一些学习,生活态度,一些发展的看法,感觉很有品味。加上coding能力又强,算是我最喜欢的模型了,很多时候我上来就看有没有Claude系列模型使用(比如Perpelxity,Notion和Kiro)。我经常会听取他的看法和理解,是我现在的主力模型,也是最愿意为之全款付费的模型。 Claude Opus 4.6 平时用的不多,感觉消耗额度有点大了。一个印象就是Opus可以一次性输出一段内容,连着说一段话。 Opus4.6至今仍是口碑最好的Opus模型,比后来的两代口碑还好。我在ClaudeCode里短暂用过一两回,感觉确实厉害些,提的要求sonnet要轮换几次Opus一两次就搞定了。 一直到最近的Kiro时期几乎无限制使用,我才锚定了Opus4.6开始蹬,感觉确实能力强,只需要很少的几次交互就可以满足我的要求,不过有时还是略微有一点不足,当然可能是我太过于随意使用,没有注重上下文等等。 总体而言印象是很好的,个人认为应该是目前coding最出色的模型,可惜额度消耗太大除非不限制,不然我还是会以sonnet为主。 Claude Opus 4.7 & 4.8 这两用的少,算是Opus口碑下滑的两代了,虽然coding能力在benchmark上是有提升,但口碑不如Opus4.6,说话风格有明显不同,经常以“我”自称,这一点和Codex类似,不太理想。即使是在Kiro时期,我用的也不多。 Grok Grok系列用的一般,偶尔需要搜索的时候会试试,但我还是更相信Perplexity。 挺灵巧的,没什么限制,Expert模型给出的结果确实结果稍微好一点,但用的也不太多,Grok4.3没什么显著提升,一般般吧。 说来好笑,我对Grok的印象是大物实验——因为手机上没怎么登录AI的会员,只有Grok当时玩玩所以登录了,大物实验的时候手机上没一个好用的,就用Grok凑合分析一下,感觉还不错,能勉强指导我处理数据。 最近上新的Grok Build我终于登录上了,我可能主要还是看看Composer2.5吧,Grok可能coding比Gemini还差些,毕竟不是专攻这个方向的。 Composer 2.5 新出没多久的模型,源自Kimi,前段时间在Grok Build上面上架了,等下去品鉴一下。 简单了解了一下,说是一个基于Kimi2.5进行了RL微调的模型,可以理解为一个coding优化过的kimi2.5,基于我对kimi印象不错,估计这个体验应该还行。 Kimi2.6 Kimi是我在国内印象最好的模型了,会自己调用工具分析问题,有一点Claude的味道,遇到不知道的信息会主动联网搜索。 一个深刻的印象是当时做大物实验,手机上没几个AI,元宝,千问,Deepseek处理一份简单的数据都失败了,只有Kimi知道去写python代码计算并绘制曲线给我拟合。 印象不错,是我认为国内最好的模型了,可惜免费版容易限流,用不太了,而要付费又略差于国外几个模型。 Qwen3.x 千问系列用的不太多,千问手机端现在是主力(手机端没有像电脑这么多AI,毕竟学习还是以电脑为主),说是比Deepseek和元宝好一点,但没看出什么优秀之处,Qwen的最新几款说是coding能力不赖,但也没看到什么显著优势的。 我感觉Qwen系列主要专攻开源,似乎提到开源大家都会想到qwen的各种系列——从8B模型到数百B的模型都有,覆盖面广,国内外开源口碑都很好。 Deepseek v4 早期我使用的元宝,背后就是Deepseek v3系列。deepseek是中国第一个出圈的模型,当时第一个和GPT对峙的。比起一个商业公司,我感觉Deepseek更像是一个大型有投资的实验室,他专攻各种大参数模型,这方面走的路线和Qwen完全不同,但又发明了如MLA等架构,在数学逻辑推理这一块分数极高,在数学方面的得分是开源模型里面最高的,同时又追求用极有限的算力做出超额收益,做到同等效果下最低成本,性价比一块做到了最好,科大周围很多同学(包括我寒假简单玩玩)试手都是用的Deepseek API去研究,去接入各种简单的AI模型,包括QQ群里的猫娘。 我对AI的要求更多是偏向于“你可以不会,但要诚实说明,要拟人风格,有客观分析问题能力,同时智商也要在线,能跟我探讨学习问题,给我一些观点的分析与建议”,因此我不太喜欢GPT系列,目前主力用的是Claude。上文仅是我个人对这些模型的看法,或许和佬友看法,口味并不一致,欢迎佬友分享自己对这些模型的看法。 2 个帖子 - 2 位参与者 阅读完整话题
GPT-5.5 简要介绍(免费使用方法) GPT-5.5 免费使用方法 一、概述 GPT-5.5 是由 OpenAI 2026 年 4 月 24 日 推出的新一代大语言模型,相比 GPT-5.4 在推理能力、多模态理解、工具调用与稳定性方面都有明显提升,进一步向“通用智能助手( Agent )”演进。 二、核心能力 1. 更强的推理能力 提升复杂问题分解与多步推理能力 在编程、数学、系统设计等场景表现更稳定 支持更高“思考深度”的推理模式 2. 多模态能力增强 支持文本、图像、代码等多种输入形式 图像理解更加精细( UI 、图表、结构化内容) 更好支持跨模态推理(图 + 文结合分析) 3. Agent 化能力(工具使用) 原生支持工具调用(函数调用 / API 调用) 更强的任务规划与执行能力 可结合外部系统(搜索、数据库、业务系统)完成复杂任务 4. 长上下文能力 支持超长上下文(数十万至百万级 tokens ) 更适合处理长文档、代码库、知识库问答( RAG ) 5. 稳定性与成本优化 更低幻觉率( Hallucination ) 更高缓存命中率(尤其在 Agent 场景) 在性能与成本之间更优平衡 三、典型应用场景 1. 企业级应用 ITSM / 工单自动化(智能填单、自动流转) 企业知识库问答( RAG + 重排) 数据分析与报表生成 2. 开发与技术 代码生成与重构 系统设计与架构评审 DevOps / 运维自动化 3. 内容生产 PPT / 文档自动生成 营销文案与内容创作 多语言翻译与本地化 四、与 GPT-5.4 对比(简要) 能力维度 GPT-5.4 GPT-5.5 推理能力 强 更强(更稳定) 多模态 基础支持 增强支持 Agent 能力 初步 更成熟 上下文长度 长 更长 成本/性能 较优 更优 五、一句话总结 GPT-5.5 是一个更强推理 + 更成熟 Agent 能力 + 更适合企业落地的大模型版本。
GPT-5.5 简要介绍(免费使用方法) GPT-5.5 免费使用方法 一、概述 GPT-5.5 是由 OpenAI 2026 年 4 月 24 日 推出的新一代大语言模型,相比 GPT-5.4 在推理能力、多模态理解、工具调用与稳定性方面都有明显提升,进一步向“通用智能助手( Agent )”演进。 二、核心能力 1. 更强的推理能力 提升复杂问题分解与多步推理能力 在编程、数学、系统设计等场景表现更稳定 支持更高“思考深度”的推理模式 2. 多模态能力增强 支持文本、图像、代码等多种输入形式 图像理解更加精细( UI 、图表、结构化内容) 更好支持跨模态推理(图 + 文结合分析) 3. Agent 化能力(工具使用) 原生支持工具调用(函数调用 / API 调用) 更强的任务规划与执行能力 可结合外部系统(搜索、数据库、业务系统)完成复杂任务 4. 长上下文能力 支持超长上下文(数十万至百万级 tokens ) 更适合处理长文档、代码库、知识库问答( RAG ) 5. 稳定性与成本优化 更低幻觉率( Hallucination ) 更高缓存命中率(尤其在 Agent 场景) 在性能与成本之间更优平衡 三、典型应用场景 1. 企业级应用 ITSM / 工单自动化(智能填单、自动流转) 企业知识库问答( RAG + 重排) 数据分析与报表生成 2. 开发与技术 代码生成与重构 系统设计与架构评审 DevOps / 运维自动化 3. 内容生产 PPT / 文档自动生成 营销文案与内容创作 多语言翻译与本地化 四、与 GPT-5.4 对比(简要) 能力维度 GPT-5.4 GPT-5.5 推理能力 强 更强(更稳定) 多模态 基础支持 增强支持 Agent 能力 初步 更成熟 上下文长度 长 更长 成本/性能 较优 更优 五、一句话总结 GPT-5.5 是一个更强推理 + 更成熟 Agent 能力 + 更适合企业落地的大模型版本。
从 Deepseek v4 pro 3d魔方简要测试 帖子继续 原本测试 只在cherry studio 里面使用auto模式测试 在cherry studio 不知道如何改用max思考模式 现在使用claude + max 思考等级测试 api 耗费 4.39元 思考加首次交付时间: 28m12s https://imgbed.snemc.cn/i/10a7ac09545f.gif (图片大于 4 MB) 测试结果: 非常丝滑 问题: 当视角转到背面的时候鼠标操作垂直方向拖动魔方,旋转角度是反的 在claude中看到deepseek完成一次写入后没有立马交付,而是又自己读了文件进行审阅, 又自己写了python脚本测试不知道怎样用python来测试html的 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>3x3 魔方</title> <style> *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0f0f1a; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; user-select: none; -webkit-user-select: none; height: 100vh; width: 100vw; } #container { position: fixed; inset: 0; cursor: grab; } #container.grabbing { cursor: grabbing; } #container.orbiting { cursor: move; } #ui { position: fixed; bottom: 32px; left: 50%; transform: translateX(-50%); display: flex; gap: 14px; z-index: 10; } #ui button { padding: 12px 28px; border: 1px solid rgba(255,255,255,0.16); border-radius: 10px; background: rgba(255,255,255,0.06); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); color: #ccc; font-size: 15px; font-weight: 500; letter-spacing: 0.03em; cursor: pointer; transition: background 0.2s, border-color 0.2s, transform 0.15s; } #ui button:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.28); } #ui button:active { transform: scale(0.96); } #hint { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); color: rgba(255,255,255,0.38); font-size: 12.5px; letter-spacing: 0.04em; pointer-events: none; z-index: 10; } </style> </head> <body> <div id="container"></div> <div id="hint">左键拖拽旋转层面 · 右键拖拽旋转视角 · 滚轮缩放</div> <div id="ui"> <button id="scramble">Scramble</button> <button id="reset">Reset</button> </div> <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 * as TWEEN from '@tweenjs/tween.js'; // ============================================================ // 常量 // ============================================================ const CUBIE_SIZE = 0.85; const EPSILON = 0.35; const SENSITIVITY = 0.007; const AXIS_THRESHOLD = 4; const SNAP_MS = 180; const SCRAMBLE_MS = 75; const SCRAMBLE_N = 22; // Rubik 标准配色 (白顶绿前) const COLORS = { right: '#B71234', left: '#FF5800', up: '#FFFFFF', down: '#FFD500', front: '#009B48', back: '#0046AD', }; const AXES = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1), }; const AXIS_NAMES = ['x', 'y', 'z']; // ============================================================ // DOM // ============================================================ const container = document.getElementById('container'); const btnScramble = document.getElementById('scramble'); const btnReset = document.getElementById('reset'); // ============================================================ // Three.js 基础设施 // ============================================================ const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.15; container.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color('#0f0f1a'); scene.fog = new THREE.Fog('#0f0f1a', 9, 32); const camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 0.5, 40 ); camera.position.set(4.8, 3.0, 5.4); camera.lookAt(0, 0, 0); // ---- OrbitControls: 仅右键旋转视角, 滚轮缩放 ---- const orbitControls = new OrbitControls(camera, renderer.domElement); orbitControls.target.set(0, 0, 0); orbitControls.enableDamping = true; orbitControls.dampingFactor = 0.07; orbitControls.minDistance = 3.5; orbitControls.maxDistance = 12; orbitControls.maxPolarAngle = Math.PI * 0.75; orbitControls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE, }; orbitControls.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN, }; orbitControls.update(); // ============================================================ // 光照与阴影 // ============================================================ scene.add(new THREE.AmbientLight('#8899bb', 0.8)); scene.add(new THREE.HemisphereLight('#ffffff', '#334455', 0.45)); const dirLight = new THREE.DirectionalLight('#ffffff', 1.7); dirLight.position.set(5, 12, 6); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 40; dirLight.shadow.camera.left = -8; dirLight.shadow.camera.right = 8; dirLight.shadow.camera.top = 8; dirLight.shadow.camera.bottom = -8; dirLight.shadow.bias = -0.0002; dirLight.shadow.normalBias = 0.015; scene.add(dirLight); // 阴影接收面 const ground = new THREE.Mesh( new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: 0.25 }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = -2.3; ground.receiveShadow = true; scene.add(ground); // ============================================================ // Canvas 纹理 — 圆角贴纸 + 塑料黑边 + 高光 // ============================================================ function createStickerTexture(hexColor) { const S = 256; const cv = document.createElement('canvas'); cv.width = S; cv.height = S; const ctx = cv.getContext('2d'); // 塑料黑底 ctx.fillStyle = '#141414'; ctx.fillRect(0, 0, S, S); // 圆角矩形 const m = 26; const r = 15; const x = m, y = m, w = S - 2 * m, h = S - 2 * m; function roundRect(cx, cy, cw, ch, cr) { ctx.beginPath(); ctx.moveTo(cx + cr, cy); ctx.arcTo(cx + cw, cy, cx + cw, cy + cr, cr); ctx.arcTo(cx + cw, cy + ch, cx + cw - cr, cy + ch, cr); ctx.arcTo(cx, cy + ch, cx, cy + ch - cr, cr); ctx.arcTo(cx, cy, cx + cr, cy, cr); ctx.closePath(); } roundRect(x, y, w, h, r); ctx.fillStyle = hexColor; ctx.fill(); // 对角线高光渐变 (模拟贴纸光泽) const grad = ctx.createLinearGradient(x, y, x + w, y + h); grad.addColorStop(0, 'rgba(255,255,255,0.24)'); grad.addColorStop(0.3, 'rgba(255,255,255,0.05)'); grad.addColorStop(0.55, 'rgba(0,0,0,0)'); grad.addColorStop(1, 'rgba(0,0,0,0.14)'); roundRect(x, y, w, h, r); ctx.fillStyle = grad; ctx.fill(); const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.minFilter = THREE.LinearMipmapLinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = true; return tex; } const stickerTextures = {}; for (const [key, color] of Object.entries(COLORS)) { stickerTextures[key] = createStickerTexture(color); } // 贴纸材质 function stickerMat(tex) { return new THREE.MeshStandardMaterial({ map: tex, roughness: 0.30, metalness: 0.02 }); } const sMat = { right: stickerMat(stickerTextures.right), left: stickerMat(stickerTextures.left), up: stickerMat(stickerTextures.up), down: stickerMat(stickerTextures.down), front: stickerMat(stickerTextures.front), back: stickerMat(stickerTextures.back), }; // 不可见面的黑色塑料 const blackMat = new THREE.MeshStandardMaterial({ color: '#181818', roughness: 0.55, metalness: 0.05, }); // ============================================================ // 方块构建 — 3×3×3 = 27 // ============================================================ const cubies = []; const geo = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); // 材质数组顺序: [+X, -X, +Y, -Y, +Z, -Z] function buildMaterials(lx, ly, lz) { return [ lx === 1 ? sMat.right : blackMat, lx === -1 ? sMat.left : blackMat, ly === 1 ? sMat.up : blackMat, ly === -1 ? sMat.down : blackMat, lz === 1 ? sMat.front : blackMat, lz === -1 ? sMat.back : blackMat, ]; } for (let lx = -1; lx <= 1; lx++) { for (let ly = -1; ly <= 1; ly++) { for (let lz = -1; lz <= 1; lz++) { const mesh = new THREE.Mesh(geo, buildMaterials(lx, ly, lz)); mesh.position.set(lx, ly, lz); mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData.origin = { x: lx, y: ly, z: lz }; scene.add(mesh); cubies.push(mesh); } } } // ============================================================ // 交互状态机 // ============================================================ const IDLE = 'idle'; const AXIS_PENDING = 'axis_pending'; const ROTATING = 'rotating'; const ANIMATING = 'animating'; let state = IDLE; let clickedCubie = null; let clickedNormal = null; // 世界空间面法线 let candidateAxes = []; // [{ name, worldAxis, layer, screenDir, perpDir }] let dragStart = new THREE.Vector2(); let chosenAxis = null; // 确定后的旋转轴信息 (含 perpDir) let pivot = null; let totalAngle = 0; let animTween = null; const raycaster = new THREE.Raycaster(); raycaster.far = 20; // ============================================================ // 工具 // ============================================================ /** 3D 世界轴 → 2D 屏幕单位方向 */ function projectAxisToScreen(axis3D) { const o = new THREE.Vector3(0, 0, 0); const t = axis3D.clone(); o.project(camera); t.project(camera); const W = renderer.domElement.clientWidth; const H = renderer.domElement.clientHeight; const sO = new THREE.Vector2((o.x + 1) / 2 * W, (1 - o.y) / 2 * H); const sT = new THREE.Vector2((t.x + 1) / 2 * W, (1 - t.y) / 2 * H); const d = sT.clone().sub(sO); return d.length() < 1e-8 ? d : d.normalize(); } function getMouse(e) { const r = renderer.domElement.getBoundingClientRect(); return new THREE.Vector2(e.clientX - r.left, e.clientY - r.top); } /** 射线检测 — 返回命中的方块及其世界空间面法线 */ function raycastCubie(mouse) { const ndc = new THREE.Vector2( (mouse.x / renderer.domElement.clientWidth) * 2 - 1, -(mouse.y / renderer.domElement.clientHeight) * 2 + 1, ); raycaster.setFromCamera(ndc, camera); const hits = raycaster.intersectObjects(cubies, false); if (hits.length === 0) return null; const hit = hits[0]; const n = hit.face.normal.clone(); n.transformDirection(hit.object.matrixWorld); return { cubie: hit.object, normal: n }; } /** * 根据面法线确定 2 个候选旋转轴。 * 逻辑: 法线沿某主轴的 → 排除该轴 → 候选为其余两轴。 */ function getCandidateAxes(faceNormal) { const a = [Math.abs(faceNormal.x), Math.abs(faceNormal.y), Math.abs(faceNormal.z)]; let dom = 'z'; if (a[0] >= a[1] && a[0] >= a[2]) dom = 'x'; else if (a[1] >= a[0] && a[1] >= a[2]) dom = 'y'; const others = AXIS_NAMES.filter(n => n !== dom); return others.map(name => { const comp = { x: 0, y: 1, z: 2 }[name]; const wp = new THREE.Vector3(); clickedCubie.getWorldPosition(wp); return { name, worldAxis: AXES[name].clone(), layer: Math.round(wp.getComponent(comp)), screenDir: null, perpDir: null, }; }); } /** 返回属于指定层的所有方块 (基于世界坐标) */ function findLayerCubies(axisName, layerValue) { const ci = { x: 0, y: 1, z: 2 }[axisName]; return cubies.filter(c => { const wp = new THREE.Vector3(); c.getWorldPosition(wp); return Math.abs(wp.getComponent(ci) - layerValue) < EPSILON; }); } /** 创建临时轴心并将选中方块挂载上去 */ function createPivot(layerCubies) { const p = new THREE.Group(); scene.add(p); for (const c of layerCubies) p.attach(c); return p; } /** 归还方块到场景, 消除浮点累积误差, 移除轴心 */ function cleanupPivot() { if (!pivot) return; const children = [...pivot.children]; for (const c of children) scene.attach(c); for (const c of children) { c.position.x = Math.round(c.position.x); c.position.y = Math.round(c.position.y); c.position.z = Math.round(c.position.z); c.rotation.x = Math.round(c.rotation.x / (Math.PI / 2)) * (Math.PI / 2); c.rotation.y = Math.round(c.rotation.y / (Math.PI / 2)) * (Math.PI / 2); c.rotation.z = Math.round(c.rotation.z / (Math.PI / 2)) * (Math.PI / 2); } scene.remove(pivot); pivot = null; } /** 绝对设置轴心旋转 (非增量, 避免误差累积) */ function setPivotRotation(axis, rad) { pivot.rotation.set(0, 0, 0); pivot.rotateOnWorldAxis(axis, rad); } /** 重置交互状态 */ function resetInteraction() { state = IDLE; clickedCubie = null; clickedNormal = null; candidateAxes = []; chosenAxis = null; totalAngle = 0; container.classList.remove('grabbing'); } // ============================================================ // 程序化旋转 (供 Scramble 使用) // ============================================================ function executeRotation(axisName, layerValue, targetAngle, duration, cb) { if (state !== IDLE && state !== ANIMATING) { if (cb) cb(); return; } state = ANIMATING; const axis = AXES[axisName].clone(); const layerCubies = findLayerCubies(axisName, layerValue); if (layerCubies.length === 0) { state = IDLE; if (cb) cb(); return; } const p = createPivot(layerCubies); const t = { angle: 0 }; animTween = new TWEEN.Tween(t) .to({ angle: targetAngle }, duration) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(({ angle }) => { p.rotation.set(0, 0, 0); p.rotateOnWorldAxis(axis, angle); }) .onComplete(() => { const kids = [...p.children]; for (const k of kids) scene.attach(k); for (const k of kids) { k.position.x = Math.round(k.position.x); k.position.y = Math.round(k.position.y); k.position.z = Math.round(k.position.z); k.rotation.x = Math.round(k.rotation.x / (Math.PI / 2)) * (Math.PI / 2); k.rotation.y = Math.round(k.rotation.y / (Math.PI / 2)) * (Math.PI / 2); k.rotation.z = Math.round(k.rotation.z / (Math.PI / 2)) * (Math.PI / 2); } scene.remove(p); animTween = null; state = IDLE; if (cb) cb(); }) .start(); } // ============================================================ // 指针事件 — 手势交互核心 // ============================================================ function onPointerDown(e) { if (e.button !== 0) return; if (state !== IDLE) return; if (animTween) { animTween.stop(); animTween = null; } const m = getMouse(e); const hit = raycastCubie(m); if (!hit) return; clickedCubie = hit.cubie; clickedNormal = hit.normal.clone(); candidateAxes = getCandidateAxes(clickedNormal); dragStart.copy(m); state = AXIS_PENDING; container.classList.add('grabbing'); } function onPointerMove(e) { const m = getMouse(e); if (state === AXIS_PENDING) { const delta = m.clone().sub(dragStart); if (delta.length() < AXIS_THRESHOLD) return; // ---------------------------------------------------- // 手势投影算法 — 选择旋转轴 // 将候选 3D 轴投影到 2D 屏幕空间, 计算拖拽方向 // 与各投影轴**垂直方向**的点积, 取最大者。 // 这确保无论视角如何, 拖拽方向总是匹配最自然 // 的旋转轴 (旋转在屏幕上表现为垂直于旋转轴的运动)。 // ---------------------------------------------------- const dragDir = delta.clone().normalize(); let bestAxis = null; let bestDot = -Infinity; for (const cand of candidateAxes) { cand.screenDir = projectAxisToScreen(cand.worldAxis); // 垂直于投影轴的方向 = 旋转在屏幕上的运动方向 cand.perpDir = new THREE.Vector2(-cand.screenDir.y, cand.screenDir.x); const dot = Math.abs(dragDir.dot(cand.perpDir)); if (dot > bestDot) { bestDot = dot; bestAxis = cand; } } if (!bestAxis) return; chosenAxis = bestAxis; // 找层并挂载到临时轴心 const layerCubies = findLayerCubies(chosenAxis.name, chosenAxis.layer); if (layerCubies.length === 0) { resetInteraction(); return; } pivot = createPivot(layerCubies); totalAngle = 0; state = ROTATING; } if (state === ROTATING) { // ---------------------------------------------------- // 1:1 跟手旋转 // - 将拖拽向量投影到 screenDir 的垂直方向 // - 摄像头位置修正符号, 保证各角度操作一致 // ---------------------------------------------------- const screenAxis = projectAxisToScreen(chosenAxis.worldAxis); const perpDir = new THREE.Vector2(-screenAxis.y, screenAxis.x); const dragTotal = m.clone().sub(dragStart); const dragAmount = dragTotal.dot(perpDir); // 带符号的拖拽分量 // 符号修正: 摄像头位于旋转轴哪一侧决定旋转方向 const camDir = camera.position.clone().normalize(); const camSign = chosenAxis.worldAxis.dot(camDir) > 0 ? 1 : -1; const angle = dragAmount * SENSITIVITY * camSign; setPivotRotation(chosenAxis.worldAxis, angle); totalAngle = angle; } } function onPointerUp(_e) { if (state === AXIS_PENDING) { resetInteraction(); return; } if (state === ROTATING) { // 磁吸到最近 90° 倍数 const snapTarget = Math.round(totalAngle / (Math.PI / 2)) * (Math.PI / 2); if (Math.abs(snapTarget - totalAngle) < 0.0005) { cleanupPivot(); resetInteraction(); return; } state = ANIMATING; const axis = chosenAxis.worldAxis.clone(); const start = totalAngle; const t = { angle: start }; animTween = new TWEEN.Tween(t) .to({ angle: snapTarget }, SNAP_MS) .easing(TWEEN.Easing.Quadratic.Out) .onUpdate(({ angle }) => setPivotRotation(axis, angle)) .onComplete(() => { cleanupPivot(); animTween = null; resetInteraction(); }) .start(); } } // ============================================================ // 右键/中键视觉效果 // ============================================================ window.addEventListener('contextmenu', e => e.preventDefault()); window.addEventListener('pointerdown', e => { if (e.button === 2 || e.button === 1) container.classList.add('orbiting'); }); window.addEventListener('pointerup', () => container.classList.remove('orbiting')); // ============================================================ // Scramble / Reset // ============================================================ function scramble() { if (state !== IDLE) return; const moves = []; let prevAxis = null, prevLayer = null; for (let i = 0; i < SCRAMBLE_N; i++) { let ax, la; do { ax = AXIS_NAMES[Math.floor(Math.random() * 3)]; la = [-1, 0, 1][Math.floor(Math.random() * 3)]; } while (ax === prevAxis && la === prevLayer); prevAxis = ax; prevLayer = la; moves.push({ axis: ax, layer: la, angle: (Math.random() < 0.5 ? 1 : -1) * Math.PI / 2 }); } function run(idx) { if (idx >= moves.length) { state = IDLE; return; } const mv = moves[idx]; executeRotation(mv.axis, mv.layer, mv.angle, SCRAMBLE_MS, () => run(idx + 1)); } state = ANIMATING; run(0); } function resetCube() { if (state === ANIMATING && animTween) { animTween.stop(); animTween = null; } // 若有活跃 pivot, 先归还方块但不取整 (避免跳变) if (pivot) { const kids = [...pivot.children]; for (const k of kids) scene.attach(k); scene.remove(pivot); pivot = null; } resetInteraction(); state = ANIMATING; let done = 0; const N = cubies.length; cubies.forEach(c => { const o = c.userData.origin; const sp = c.position.clone(); const sr = c.rotation.clone(); const tw = { px: sp.x, py: sp.y, pz: sp.z, rx: sr.x, ry: sr.y, rz: sr.z }; new TWEEN.Tween(tw) .to({ px: o.x, py: o.y, pz: o.z, rx: 0, ry: 0, rz: 0 }, 520) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(v => { c.position.set(v.px, v.py, v.pz); c.rotation.set(v.rx, v.ry, v.rz); }) .onComplete(() => { c.position.set(o.x, o.y, o.z); c.rotation.set(0, 0, 0); done++; if (done >= N) state = IDLE; }) .start(); }); } btnScramble.addEventListener('click', scramble); btnReset.addEventListener('click', resetCube); // ============================================================ // 事件绑定 // ============================================================ renderer.domElement.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // ============================================================ // 渲染循环 // ============================================================ function animate(time) { requestAnimationFrame(animate); TWEEN.update(time); orbitControls.update(); renderer.render(scene, camera); } requestAnimationFrame(animate); </script> </body> </html> 3 个帖子 - 3 位参与者 阅读完整话题
从上面两篇帖子继续 Deepseek v4 pro 3d魔方简要测试 国产替代 使用 cherry studio 客户端, 未设置系统提示词, 思考等级:auto 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 … 小米mimo-v2.5-pro 3d 魔方简要测试 开发调优 从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif(图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不… 测试环境 cherry studio 思考等级 : auto 上游 opencode-go 思考时间149.7s 提示词 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。 https://imgbed.snemc.cn/i/76632ecccb36.gif (图片大于 4 MB) 概述: 没有任何问题,相当丝滑 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>高保真 3x3 魔方模拟器</title> <style> :root { --bg: #1a1a1a; --panel-bg: rgba(30, 30, 30, 0.85); --accent: #4fd1c5; --text: #e2e8f0; --btn-hover: #38b2ac; } body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text); } #canvas-container { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; } #ui-panel { position: absolute; top: 20px; left: 20px; z-index: 10; background: var(--panel-bg); padding: 15px 25px; border-radius: 12px; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.4); display: flex; flex-direction: column; gap: 10px; } h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: var(--accent); letter-spacing: 1px; } p { margin: 0; font-size: 0.85rem; color: #a0aec0; } .btn-group { display: flex; gap: 10px; margin-top: 5px; } button { background-color: var(--accent); color: #1a202c; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; } button:hover { background-color: var(--btn-hover); transform: translateY(-1px); box-shadow: 0 2px 10px rgba(79, 209, 197, 0.4); } button:active { transform: translateY(0); } #toast { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(100px); background: rgba(79, 209, 197, 0.9); color: #1a202c; padding: 10px 25px; border-radius: 20px; font-weight: 600; opacity: 0; transition: all 0.3s ease; z-index: 20; } #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } </style> <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> </head> <body> <div id="ui-panel"> <h1>3x3 魔方模拟器</h1> <p>左键拖拽旋转层 | 右键拖拽旋转视角</p> <div class="btn-group"> <button id="btn-scramble">Scramble</button> <button id="btn-reset">Reset</button> </div> </div> <div id="toast"></div> <div id="canvas-container"></div> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import * as TWEEN from '@tweenjs/tween.js'; // ========================================== // 全局状态与配置 // ========================================== let cubies = []; // 存储所有 27 个小方块 let isAnimating = false; // 是否正在播放动画(打乱或磁吸回弹) let isDragging = false; // 是否正在拖拽旋转层 let hasChosenAxis = false; // 是否已经通过算法确定了旋转轴 let startMouse = new THREE.Vector2(); // 鼠标按下时的 NDC 坐标 let currentMouse = new THREE.Vector2(); // 当前鼠标的 NDC 坐标 let selectedCubie = null; // 射线检测到的初始方块 let faceNormal = new THREE.Vector3(); // 初始点击面的世界法线 let intersectPoint = new THREE.Vector3(); // 初始点击的交点 let currentAxis = ''; // 当前旋转的轴 'x', 'y', 'z' let targetLayerIndex = 0; // 当前旋转的层级 -1, 0, 1 let pivot; // 临时轴心对象 let projDelta = new THREE.Vector2(); // 投影算法计算出的 2D 方向单位向量 let startRotation = 0; // 拖拽开始时 pivot 的旋转角度 // 魔方尺寸配置 const SPACING = 1.02; // 物理间隙 const CUBIE_SIZE = 0.95; // 小方块尺寸 // ========================================== // 场景初始化 // ========================================== const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; document.getElementById('canvas-container').appendChild(renderer.domElement); // 右键拖拽旋转视角配置 const controls = new OrbitControls(camera, renderer.domElement); controls.mouseButtons = { LEFT: null, // 左键由我们自己的手势算法接管 MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; controls.enablePan = false; controls.minDistance = 5; controls.maxDistance = 15; // ========================================== // 光影环境 // ========================================== const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 2048; dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.far = 50; dirLight.shadow.camera.left = -5; dirLight.shadow.camera.right = 5; dirLight.shadow.camera.top = 5; dirLight.shadow.camera.bottom = -5; dirLight.shadow.bias = -0.001; scene.add(dirLight); // ========================================== // Canvas API 程序化生成贴图 // ========================================== const FACE_COLORS = { right: '#B71234', // 红 left: '#FF5800', // 橙 up: '#FFFFFF', // 白 down: '#FFD500', // 黄 front: '#0046AD', // 蓝 back: '#009B48', // 绿 inside: '#111111' // 内部黑 }; function createFaceTexture(color) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 绘制黑色塑料底色(带微小圆角模拟边缘) ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, size, size); // 如果不是内部面,绘制带圆角的贴纸和高光 if (color !== FACE_COLORS.inside) { const margin = 16; const radius = 30; const sx = margin, sy = margin, sw = size - margin * 2, sh = size - margin * 2; // 绘制圆角矩形贴纸路径 ctx.beginPath(); ctx.moveTo(sx + radius, sy); ctx.lineTo(sx + sw - radius, sy); ctx.quadraticCurveTo(sx + sw, sy, sx + sw, sy + radius); ctx.lineTo(sx + sw, sy + sh - radius); ctx.quadraticCurveTo(sx + sw, sy + sh, sx + sw - radius, sy + sh); ctx.lineTo(sx + radius, sy + sh); ctx.quadraticCurveTo(sx, sy + sh, sx, sy + sh - radius); ctx.lineTo(sx, sy + radius); ctx.quadraticCurveTo(sx, sy, sx + radius, sy); ctx.closePath(); // 填充贴纸底色 ctx.fillStyle = color; ctx.fill(); // 添加从左上到右下的高光渐变,模拟塑料质感 const gradient = ctx.createLinearGradient(sx, sy, sx + sw, sy + sh); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.25)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); ctx.fillStyle = gradient; ctx.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } // 预生成所有纹理 const textures = { right: createFaceTexture(FACE_COLORS.right), left: createFaceTexture(FACE_COLORS.left), up: createFaceTexture(FACE_COLORS.up), down: createFaceTexture(FACE_COLORS.down), front: createFaceTexture(FACE_COLORS.front), back: createFaceTexture(FACE_COLORS.back), inside: createFaceTexture(FACE_COLORS.inside) }; // ========================================== // 模型构建 & 动态层级 // ========================================== function buildCube() { // 清空旧方块 cubies.forEach(c => scene.remove(c)); cubies = []; const geometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); for (let x = -1; x <= 1; x++) { for (let y = -1; y <= 1; y++) { for (let z = -1; z <= 1; z++) { // 根据方块所在空间位置决定6个面的贴纸 const materials = [ new THREE.MeshStandardMaterial({ map: x === 1 ? textures.right : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: x === -1 ? textures.left : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === 1 ? textures.up : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: y === -1 ? textures.down : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === 1 ? textures.front : textures.inside, roughness: 0.4, metalness: 0.1 }), new THREE.MeshStandardMaterial({ map: z === -1 ? textures.back : textures.inside, roughness: 0.4, metalness: 0.1 }), ]; const mesh = new THREE.Mesh(geometry, materials); mesh.position.set(x * SPACING, y * SPACING, z * SPACING); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } } } } buildCube(); // ========================================== // 核心交互算法:手势投影与旋转轴确定 // ========================================== const raycaster = new THREE.Raycaster(); // 获取某3D轴投影到2D屏幕后的向量,以及对应的物理旋转方向 function getProjectionData(axisStr) { const axisVec = new THREE.Vector3(); axisVec[axisStr] = 1; // 构造 3D 轴单位向量 (1,0,0) 等 // 算法核心:3D旋转映射到2D屏幕 // 假设绕 axisStr 轴发生正向微小旋转,被点击面的法线方向将发生偏移。 // 偏移向量 deltaPos3D = cross(axisVec, faceNormal) const deltaPos3D = new THREE.Vector3().crossVectors(axisVec, faceNormal); // 将交点和偏移后的交点投影到屏幕空间,求差得到 2D 投影方向向量 const p0 = intersectPoint.clone().project(camera); const p1 = intersectPoint.clone().add(deltaPos3D).project(camera); // 返回 2D 屏幕空间中的方向向量 (NDC坐标系) return new THREE.Vector2(p1.x - p0.x, p1.y - p0.y); } function onPointerDown(event) { if (event.button !== 0 || isAnimating) return; // 仅处理左键,且不在动画中 startMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); raycaster.setFromCamera(startMouse, camera); const intersects = raycaster.intersectObjects(cubies); if (intersects.length > 0) { isDragging = true; controls.enabled = false; // 禁用视角控制,防止冲突 selectedCubie = intersects[0].object; intersectPoint = intersects[0].point.clone(); // 计算世界法线:将局部法线转换到世界空间,并四舍五入消除浮点误差 faceNormal = intersects[0].face.normal.clone() .transformDirection(selectedCubie.matrixWorld) .round(); hasChosenAxis = false; currentAxis = ''; startRotation = 0; } } function onPointerMove(event) { if (!isDragging || isAnimating) return; currentMouse.set( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); const NDC_delta2D = currentMouse.clone().sub(startMouse); // 若还未确定旋转轴(手势刚开始),需要达到一定阈值才锁定 if (!hasChosenAxis) { if (NDC_delta2D.length() < 0.01) return; // 死区防抖 let axis1, axis2; // 排除法线方向的轴,锁定两个潜在旋转轴 if (Math.abs(faceNormal.x) > 0.5) { axis1 = 'y'; axis2 = 'z'; } else if (Math.abs(faceNormal.y) > 0.5) { axis1 = 'x'; axis2 = 'z'; } else { axis1 = 'x'; axis2 = 'y'; } // 基于投影向量的手势识别算法核心: // 将3D潜在轴的旋转效果投影到2D,与用户的2D滑动向量计算点积 const proj1 = getProjectionData(axis1); const proj2 = getProjectionData(axis2); const dot1 = NDC_delta2D.dot(proj1); const dot2 = NDC_delta2D.dot(proj2); // 选择匹配度最高的轴 if (Math.abs(dot1) > Math.abs(dot2)) { currentAxis = axis1; projDelta = proj1; } else { currentAxis = axis2; projDelta = proj2; } // 动态层级筛选:根据点击方块在当前轴的世界坐标确定层级 targetLayerIndex = Math.round(selectedCubie.position[currentAxis] / SPACING); // 创建临时 Pivot 并挂载相应层级的方块 pivot = new THREE.Object3D(); pivot.position[currentAxis] = targetLayerIndex * SPACING; scene.add(pivot); // Pivot 变换机制核心:使用 attach 保持世界矩阵 cubies.forEach(cubie => { if (Math.round(cubie.position[currentAxis] / SPACING) === targetLayerIndex) { pivot.attach(cubie); } }); hasChosenAxis = true; } // 实时跟手:1:1 计算旋转角度 // 公式:deltaAngle = dot(2D滑动距离, 2D投影方向) / dot(投影方向, 投影方向) // 这完美解决了方向反转的Bug,且从任何视角操作都直观! const deltaAngle = NDC_delta2D.dot(projDelta) / projDelta.dot(projDelta); pivot.rotation[currentAxis] = deltaAngle; } function onPointerUp(event) { if (!isDragging) return; isDragging = false; if (!hasChosenAxis) { controls.enabled = true; return; } // 磁吸效果:松开鼠标后,自动对齐到最近的 90 度倍数 const currentAngle = pivot.rotation[currentAxis]; const targetAngle = Math.round(currentAngle / (Math.PI / 2)) * (Math.PI / 2); isAnimating = true; // 使用 Tween.js 播放回弹动画 const tweenObj = { r: currentAngle }; new TWEEN.Tween(tweenObj) .to({ r: targetAngle }, 250) .easing(TWEEN.Easing.Back.Out) .onUpdate(() => { pivot.rotation[currentAxis] = tweenObj.r; }) .onComplete(() => { finishRotation(targetAngle); }) .start(); } function finishRotation(finalAngle) { pivot.rotation[currentAxis] = finalAngle; // 坐标清洗:消除浮点累积误差,防止魔方“散架” // 将方块从 pivot 放回 scene,利用 scene.attach 自动处理矩阵 const children = [...pivot.children]; children.forEach(child => { scene.attach(child); // 位置清洗:四舍五入到 Spacing 的整数倍 child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; // 旋转清洗:四舍五入到 90 度倍数 child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); // 清理临时轴心对象 scene.remove(pivot); isAnimating = false; controls.enabled = true; } // ========================================== // 打乱与重置功能 // ========================================== function scrambleCube() { if (isAnimating || isDragging) return; isAnimating = true; showToast("Scrambling..."); const axes = ['x', 'y', 'z']; const layers = [-1, 0, 1]; const moves = 20; let moveList = []; for (let i = 0; i < moves; i++) { moveList.push({ axis: axes[Math.floor(Math.random() * 3)], layer: layers[Math.floor(Math.random() * 3)], dir: Math.random() > 0.5 ? 1 : -1 }); } let index = 0; function doMove() { if (index >= moveList.length) { isAnimating = false; showToast("Ready!"); return; } const move = moveList[index]; const tempPivot = new THREE.Object3D(); tempPivot.position[move.axis] = move.layer * SPACING; scene.add(tempPivot); cubies.forEach(cubie => { if (Math.round(cubie.position[move.axis] / SPACING) === move.layer) { tempPivot.attach(cubie); } }); const targetAngle = move.dir * (Math.PI / 2); const tempTweenObj = { r: 0 }; new TWEEN.Tween(tempTweenObj) .to({ r: targetAngle }, 100) .easing(TWEEN.Easing.Quadratic.InOut) .onUpdate(() => { tempPivot.rotation[move.axis] = tempTweenObj.r; }) .onComplete(() => { finishRotationForScramble(tempPivot, targetAngle); index++; doMove(); }) .start(); } doMove(); } function finishRotationForScramble(pivotObj) { const children = [...pivotObj.children]; children.forEach(child => { scene.attach(child); child.position.x = Math.round(child.position.x / SPACING) * SPACING; child.position.y = Math.round(child.position.y / SPACING) * SPACING; child.position.z = Math.round(child.position.z / SPACING) * SPACING; child.rotation.x = Math.round(child.rotation.x / (Math.PI / 2)) * (Math.PI / 2); child.rotation.y = Math.round(child.rotation.y / (Math.PI / 2)) * (Math.PI / 2); child.rotation.z = Math.round(child.rotation.z / (Math.PI / 2)) * (Math.PI / 2); }); scene.remove(pivotObj); } function resetCube() { if (isAnimating || isDragging) return; buildCube(); showToast("Reset Done!"); } // 提示弹窗 function showToast(msg) { const toast = document.getElementById('toast'); toast.innerText = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1500); } // ========================================== // 事件绑定与渲染循环 // ========================================== window.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); document.getElementById('btn-scramble').addEventListener('click', scrambleCube); document.getElementById('btn-reset').addEventListener('click', resetCube); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); function animate() { requestAnimationFrame(animate); TWEEN.update(); controls.update(); renderer.render(scene, camera); } animate(); </script> </body> </html> 1 个帖子 - 1 位参与者 阅读完整话题
从上篇帖子继续 Deepseek v4 pro 3d魔方简要测试 测试环境 cherry studio, 上游是opencode go, 思考模式默认 思考时间 937.6s 测试结果 https://imgbed.snemc.cn/i/f5400f9b7e02.gif (图片大于 4 MB) 概要: 相当完美,鼠标操作底部那一排似乎有些转不过来 提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。 <!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 Physical-Grade Rubik's Cube</title> <style> *{margin:0;padding:0;box-sizing:border-box} body{ overflow:hidden; background:#0d0d1a; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; } canvas{display:block} #ui{ position:fixed;bottom:30px;left:50%;transform:translateX(-50%); display:flex;gap:14px;z-index:10; } .btn{ padding:12px 30px;font-size:15px;font-weight:600; border:2px solid rgba(255,255,255,.15);border-radius:14px; cursor:pointer;color:#fff; backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); background:rgba(255,255,255,.08); transition:all .3s cubic-bezier(.4,0,.2,1); letter-spacing:.5px;user-select:none; } .btn:hover{ background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.35); transform:translateY(-2px);box-shadow:0 10px 30px rgba(0,0,0,.4); } .btn:active{transform:translateY(0);box-shadow:0 4px 12px rgba(0,0,0,.3)} .btn.scramble{border-color:rgba(245,87,108,.5)} .btn.scramble:hover{background:rgba(245,87,108,.22)} .btn.reset{border-color:rgba(78,205,196,.5)} .btn.reset:hover{background:rgba(78,205,196,.22)} .btn:disabled{opacity:.4;pointer-events:none} #info{ position:fixed;top:20px;width:100%;text-align:center; color:rgba(255,255,255,.45);font-size:13px;z-index:10;pointer-events:none; } </style> </head> <body> <div id="info">🖱️ 左键拖拽旋转层 · 右键拖拽旋转视角 · 滚轮缩放</div> <div id="ui"> <button class="btn scramble" id="btnScramble">🎲 打乱</button> <button class="btn reset" id="btnReset">🔄 重置</button> </div> <!-- ═══════════ Import Map: Three.js + OrbitControls ═══════════ --> <script type="importmap"> { "imports": { "three": "https://unpkg.com/[email protected]/build/three.module.js", "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; /* ════════════════════════════════════════════════════════════════ Section 1 · 轻量 Tween 引擎(模拟 Tween.js API) ════════════════════════════════════════════════════════════════ */ const _tweens = []; class Tween { constructor(obj) { this._o = obj; // 被驱动的对象 this._s = {}; // 起始值快照 this._e = {}; // 目标值 this._d = 300; // 持续时间 ms this._ef = t => t; // 缓动函数 this._uc = null; // onUpdate 回调 this._cc = null; // onComplete 回调 this._t0 = 0; // 起始时间戳 this._r = false; // 是否运行中 } to(target, dur) { this._e = target; if (dur != null) this._d = dur; return this; } easing(fn) { this._ef = fn; return this; } onUpdate(fn) { this._uc = fn; return this; } onComplete(fn) { this._cc = fn; return this; } start() { for (const k in this._e) this._s[k] = this._o[k]; this._t0 = performance.now(); this._r = true; _tweens.push(this); return this; } _tick(now) { if (!this._r) return false; const t = Math.min((now - this._t0) / this._d, 1); const e = this._ef(t); for (const k in this._e) this._o[k] = this._s[k] + (this._e[k] - this._s[k]) * e; if (this._uc) this._uc(this._o); if (t >= 1) { this._r = false; if (this._cc) this._cc(); return false; } return true; } } function tickTweens() { const n = performance.now(); for (let i = _tweens.length - 1; i >= 0; i--) if (!_tweens[i]._tick(n)) _tweens.splice(i, 1); } const easeOutCubic = t => 1 - Math.pow(1 - t, 3); const easeInOutCubic = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; /* ════════════════════════════════════════════════════════════════ Section 2 · 常量 & 配色 ════════════════════════════════════════════════════════════════ */ const GAP = 1; // 方块中心距 const SPACING = 0.08; // 物理间隙 const SIZE = GAP - SPACING; // 小方块视觉边长 const EPS = 0.1; // 层判断容差 const HPI = Math.PI / 2; // 90° const FACE_COLORS = { R: '#B71234', // +X 红 L: '#FF5800', // -X 橙 U: '#FFFFFF', // +Y 白 D: '#FFD500', // -Y 黄 F: '#009B48', // +Z 绿 B: '#0046AD', // -Z 蓝 }; /* ════════════════════════════════════════════════════════════════ Section 3 · Canvas API 程序化纹理(零外部图片) ════════════════════════════════════════════════════════════════ */ /** 绘制圆角矩形路径 */ function roundRect(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 makeSticker(color) { const S = 256; const cvs = document.createElement('canvas'); cvs.width = cvs.height = S; const c = cvs.getContext('2d'); // ① 黑色塑料底座 c.fillStyle = '#111'; c.fillRect(0, 0, S, S); const m = 18, r = 24; // ② 贴纸外缘阴影(模拟凹槽) roundRect(c, m - 3, m - 3, S - 2*m + 6, S - 2*m + 6, r + 3); c.fillStyle = 'rgba(0,0,0,0.35)'; c.fill(); // ③ 彩色圆角贴纸 roundRect(c, m, m, S - 2*m, S - 2*m, r); c.fillStyle = color; c.fill(); // ④ 对角线渐变高光(模拟真实光泽) roundRect(c, m, m, S - 2*m, S - 2*m, r); const g1 = c.createLinearGradient(m, m, S - m, S - m); g1.addColorStop(0, 'rgba(255,255,255,0.30)'); g1.addColorStop(0.4, 'rgba(255,255,255,0.06)'); g1.addColorStop(0.6, 'rgba(0,0,0,0)'); g1.addColorStop(1, 'rgba(0,0,0,0.20)'); c.fillStyle = g1; c.fill(); // ⑤ 顶部高光条(模拟边缘反光) roundRect(c, m, m, S - 2*m, S - 2*m, r); const g2 = c.createLinearGradient(m, m, m, m + 55); g2.addColorStop(0, 'rgba(255,255,255,0.28)'); g2.addColorStop(1, 'rgba(255,255,255,0)'); c.fillStyle = g2; c.fill(); const tex = new THREE.CanvasTexture(cvs); tex.colorSpace = THREE.SRGBColorSpace; return tex; } /** 纯黑内部纹理 */ function makeBlackTex() { const c = document.createElement('canvas'); c.width = c.height = 8; const ctx = c.getContext('2d'); ctx.fillStyle = '#111'; ctx.fillRect(0, 0, 8, 8); return new THREE.CanvasTexture(c); } // 预生成所有纹理 const TEX = {}; for (const [k, v] of Object.entries(FACE_COLORS)) TEX[k] = makeSticker(v); const TEX_BLACK = makeBlackTex(); /* ════════════════════════════════════════════════════════════════ Section 4 · 场景 / 相机 / 渲染器 / 光照 ════════════════════════════════════════════════════════════════ */ const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a2e); const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100); camera.position.set(5.5, 4.5, 5.5); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; document.body.appendChild(renderer.domElement); // ── 光照:环境光 + 主平行光(ShadowMap)+ 补光 ── scene.add(new THREE.AmbientLight(0xffffff, 0.5)); const sun = new THREE.DirectionalLight(0xffffff, 1.5); sun.position.set(5, 8, 6); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 30; sun.shadow.camera.left = -5; sun.shadow.camera.right = 5; sun.shadow.camera.top = 5; sun.shadow.camera.bottom = -5; sun.shadow.bias = -0.0001; scene.add(sun); const fill = new THREE.DirectionalLight(0x8888ff, 0.3); fill.position.set(-4, 3, -5); scene.add(fill); const rim = new THREE.DirectionalLight(0xffffff, 0.2); rim.position.set(0, -3, -6); scene.add(rim); // ── 地面(ShadowMaterial 接收阴影)── const ground = new THREE.Mesh( new THREE.PlaneGeometry(20, 20), new THREE.ShadowMaterial({ opacity: 0.25 }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = -2.2; ground.receiveShadow = true; scene.add(ground); /* ════════════════════════════════════════════════════════════════ Section 5 · OrbitControls(右键旋转视角) ════════════════════════════════════════════════════════════════ */ const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; orbit.dampingFactor = 0.08; orbit.enablePan = false; orbit.minDistance = 4; orbit.maxDistance = 15; // 左键 → undefined(交给魔方交互),中键 → 缩放,右键 → 轨道旋转 orbit.mouseButtons = { LEFT: undefined, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.ROTATE }; renderer.domElement.addEventListener('contextmenu', e => e.preventDefault()); /* ════════════════════════════════════════════════════════════════ Section 6 · 构建 27 个独立 Cubies ════════════════════════════════════════════════════════════════ */ const cubies = []; const cubieGeo = new THREE.BoxGeometry(SIZE, SIZE, SIZE); // 共享几何体 /** * 为指定 (x,y,z) 位置的 cubie 生成六面材质 * BoxGeometry 面序:[+X, -X, +Y, -Y, +Z, -Z] */ function cubieMaterials(x, y, z) { return [ x === 1 ? TEX.R : TEX_BLACK, x === -1 ? TEX.L : TEX_BLACK, y === 1 ? TEX.U : TEX_BLACK, y === -1 ? TEX.D : TEX_BLACK, z === 1 ? TEX.F : TEX_BLACK, z === -1 ? TEX.B : TEX_BLACK, ].map(t => new THREE.MeshStandardMaterial({ map: t, roughness: 0.35, metalness: 0 })); } // 生成 3×3×3 = 27 个方块 for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) { const mesh = new THREE.Mesh(cubieGeo, cubieMaterials(x, y, z)); mesh.position.set(x * GAP, y * GAP, z * GAP); mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); cubies.push(mesh); } /* ════════════════════════════════════════════════════════════════ Section 7 · 坐标清洗 — 消除浮点累积误差 ════════════════════════════════════════════════════════════════ */ /** * 每次旋转结束后调用: * 1. 位置四舍五入到整数(GAP=1) * 2. 旋转矩阵元素四舍五入到 {-1, 0, 1} * → 保证后续层判断精确匹配 */ function cleanCubies() { const _mat = new THREE.Matrix4(); for (const c of cubies) { // 位置取整 c.position.set( Math.round(c.position.x), Math.round(c.position.y), Math.round(c.position.z) ); // 旋转矩阵取整 c.updateMatrix(); _mat.copy(c.matrix); const e = _mat.elements; for (let i = 0; i < 16; i++) e[i] = Math.round(e[i]); c.quaternion.setFromRotationMatrix(_mat); } } /* ════════════════════════════════════════════════════════════════ Section 8 · 工具函数 ════════════════════════════════════════════════════════════════ */ /** 将方向向量吸附到最近的主轴 (±X, ±Y, ±Z) */ function snapToAxis(v) { const ax = Math.abs(v.x), ay = Math.abs(v.y), az = Math.abs(v.z); if (ax >= ay && ax >= az) return new THREE.Vector3(Math.sign(v.x), 0, 0); if (ay >= ax && ay >= az) return new THREE.Vector3(0, Math.sign(v.y), 0); return new THREE.Vector3(0, 0, Math.sign(v.z)); } /** 给定面法线 → 返回与其垂直的两个主轴 */ function perpAxes(n) { if (Math.abs(n.x) > 0.5) return [new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)]; if (Math.abs(n.y) > 0.5) return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,0,1)]; return [new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0)]; } /** 3D 轴向量 → 轴名字符 */ function axName(v) { if (Math.abs(v.x) > 0.5) return 'x'; if (Math.abs(v.y) > 0.5) return 'y'; return 'z'; } /** 3D 世界点 → 屏幕像素坐标 */ function toScreen(p) { const v = p.clone().project(camera); return new THREE.Vector2( (v.x + 1) * 0.5 * innerWidth, (1 - v.y) * 0.5 * innerHeight ); } /** 将 3D 方向向量投影为屏幕像素方向 */ function screenDir(dir3, origin3) { const a = toScreen(origin3); const b = toScreen(origin3.clone().add(dir3)); return new THREE.Vector2(b.x - a.x, b.y - a.y); } /* ════════════════════════════════════════════════════════════════ Section 9 · 旋转状态机 ════════════════════════════════════════════════════════════════ */ let interacting = false; // 鼠标左键按下追踪中 let animating = false; // 补间动画播放中 let scrambling = false; // 打乱序列执行中 let pivot = null; // 临时轴心 Object3D let rotAx = null; // 当前旋转轴 'x'|'y'|'z' let rotLayer = 0; // 当前层号 -1|0|1 let angle = 0; // 累计旋转弧度 let mouse0 = { x: 0, y: 0 }; // ── 手势预计算缓存(初始化旋转时计算一次)── let gDir = null; // 屏幕空间旋转方向(归一化 2D 向量) let gRate = 1; // 灵敏度:弧度 / 像素 // ── 点击信息缓存 ── let clickNormal = null; // 点击面法线(已吸附主轴) let clickPoint = null; // 点击的世界坐标 let clickCubie = null; // 被点击的 cubie Mesh const rc = new THREE.Raycaster(); /* ════════════════════════════════════════════════════════════════ Section 10 · 交互事件处理 ════════════════════════════════════════════════════════════════ */ function ndc(e) { return new THREE.Vector2( (e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1 ); } // ─── pointerdown ─── renderer.domElement.addEventListener('pointerdown', e => { if (animating || scrambling || e.button !== 0) return; rc.setFromCamera(ndc(e), camera); const hits = rc.intersectObjects(cubies); if (!hits.length) return; const hit = hits[0]; clickCubie = hit.object; // 将局部法线转换到世界空间并吸附到主轴 clickNormal = snapToAxis(hit.face.normal.clone().transformDirection(clickCubie.matrixWorld)); clickPoint = hit.point.clone(); mouse0 = { x: e.clientX, y: e.clientY }; angle = 0; pivot = null; rotAx = null; interacting = true; orbit.enabled = false; // 暂时禁用轨道控制,避免冲突 }); // ─── pointermove ─── renderer.domElement.addEventListener('pointermove', e => { if (!interacting || animating) return; const dx = e.clientX - mouse0.x; const dy = e.clientY - mouse0.y; // 阈值防误触:拖动不足 10px 不触发旋转 if (!pivot && dx * dx + dy * dy < 100) return; // 首次超过阈值 → 初始化层旋转 if (!pivot) initLayerRotation(dx, dy); if (!pivot) return; // ── 实时跟手:将鼠标总位移投影到旋转屏幕方向 ── const proj = dx * gDir.x + dy * gDir.y; // 像素分量 angle = proj * gRate; // 转为弧度 pivot.rotation[rotAx] = angle; }); // ─── pointerup ─── renderer.domElement.addEventListener('pointerup', e => { if (e.button !== 0) return; interacting = false; if (pivot) { // 磁吸效果:计算最近的 90° 倍数 const target = Math.round(angle / HPI) * HPI; animating = true; new Tween({ a: angle }) .to({ a: target }, 200) .easing(easeOutCubic) .onUpdate(o => { pivot.rotation[rotAx] = o.a; }) .onComplete(() => releasePivot()) .start(); } else { orbit.enabled = true; // 未触发旋转,恢复轨道控制 } }); /* ════════════════════════════════════════════════════════════════ Section 11 · 核心手势识别算法 & Pivot 变换机制 ┌─────────────────────────────────────────────────────────────┐ │ 算法概览: │ │ 1. 由面法线确定两个潜在旋转轴(均与法线垂直) │ │ 2. 计算每个轴的「正旋转切线方向」 = axis × normal │ │ 3. 将两条切线投影到 2D 屏幕空间 │ │ 4. 鼠标拖拽方向与两条投影做点积 → 选匹配度最高的轴 │ │ 5. 用数值微分法计算该轴的屏幕灵敏度(像素/弧度) │ │ 6. 创建 Pivot → attach() 挂载层内方块 → 旋转 → detach │ └─────────────────────────────────────────────────────────────┘ ════════════════════════════════════════════════════════════════ */ /** * 初始化层旋转 * 在首次拖动超过阈值时调用,一次性完成: * - 选择旋转轴和层 * - 预计算屏幕灵敏度 * - 创建 Pivot 并挂载 cubies */ function initLayerRotation(dx, dy) { const [ax1, ax2] = perpAxes(clickNormal); // 正旋转切线方向 = axis × faceNormal // 物理含义:该方向是「正角速度旋转时,面表面的运动方向」 const tan1 = new THREE.Vector3().crossVectors(ax1, clickNormal); const tan2 = new THREE.Vector3().crossVectors(ax2, clickNormal); // 投影到屏幕像素空间 const sTan1 = screenDir(tan1, clickPoint); const sTan2 = screenDir(tan2, clickPoint); // 拖拽方向(归一化) const drag = new THREE.Vector2(dx, dy).normalize(); // 点积比较:选择与拖拽方向更匹配的轴 const dot1 = Math.abs(drag.dot(sTan1.clone().normalize())); const dot2 = Math.abs(drag.dot(sTan2.clone().normalize())); const chosen = dot1 >= dot2 ? ax1 : ax2; rotAx = axName(chosen); // 动态计算层号:根据被点击 cubie 在旋转轴上的世界坐标 rotLayer = Math.round(clickCubie.position[rotAx] / GAP); // ── 数值微分法:计算屏幕灵敏度(像素/弧度)── // 对点击点做微小旋转,观察屏幕位移 const eps = 0.01; const s0 = toScreen(clickPoint); const sp = toScreen(clickPoint.clone().applyAxisAngle(chosen, eps)); const deriv = new THREE.Vector2(sp.x - s0.x, sp.y - s0.y).divideScalar(eps); // deriv 的方向 = 正旋转在屏幕上的运动方向 // deriv 的长度 = 像素/弧度 gDir = deriv.clone().normalize(); gRate = 1 / deriv.length(); // 弧度/像素 // 轴几乎垂直于屏幕(看不到旋转)→ 不触发 if (deriv.length() < 0.5) return; // ── Pivot 变换机制(关键考点)── pivot = new THREE.Object3D(); // 临时轴心,位于世界原点 scene.add(pivot); for (const c of cubies) { // 基于世界坐标动态筛选层,不依赖固定索引 if (Math.abs(c.position[rotAx] / GAP - rotLayer) < EPS) { pivot.attach(c); // attach() 自动计算并保持 cubie 的世界变换 // cubie 成为 pivot 的子节点,后续随 pivot 旋转 } } } /** 释放 Pivot:将方块归还场景 + 坐标清洗 */ function releasePivot() { if (!pivot) return; // 将所有子对象挂回场景(自动保持旋转后的世界变换) const kids = [...pivot.children]; // 复制数组,因为 attach 会修改 children for (const c of kids) scene.attach(c); cleanCubies(); // 坐标清洗:消除浮点误差 scene.remove(pivot); pivot = null; rotAx = null; animating = false; orbit.enabled = true; } /* ════════════════════════════════════════════════════════════════ Section 12 · 打乱(Scramble)& 重置(Reset) ════════════════════════════════════════════════════════════════ */ /** 通用层旋转(供 scramble 调用) */ function doRotation(ax, layer, ang, dur) { return new Promise(resolve => { const p = new THREE.Object3D(); scene.add(p); for (const c of cubies) if (Math.abs(c.position[ax] / GAP - layer) < EPS) p.attach(c); animating = true; new Tween({ a: 0 }) .to({ a: ang }, dur) .easing(easeInOutCubic) .onUpdate(o => { p.rotation[ax] = o.a; }) .onComplete(() => { for (const c of [...p.children]) scene.attach(c); cleanCubies(); scene.remove(p); animating = false; resolve(); }) .start(); }); } async function scrambleCube() { if (animating || scrambling || interacting) return; scrambling = true; orbit.enabled = false; document.getElementById('btnScramble').disabled = true; document.getElementById('btnReset').disabled = true; const AXES = ['x', 'y', 'z']; const LAYERS = [-1, 0, 1]; let lastA = '', lastL = -99; for (let i = 0; i < 20; i++) { let a, l; // 避免连续旋转同一层(无意义) do { a = AXES[Math.random() * 3 | 0]; l = LAYERS[Math.random() * 3 | 0]; } while (a === lastA && l === lastL); const dir = (Math.random() < 0.5 ? 1 : -1) * HPI; await doRotation(a, l, dir, Math.max(40, 100 - 3 * i)); lastA = a; lastL = l; } scrambling = false; orbit.enabled = true; document.getElementById('btnScramble').disabled = false; document.getElementById('btnReset').disabled = false; } function resetCube() { if (animating || scrambling || interacting) return; let i = 0; for (let x = -1; x <= 1; x++) for (let y = -1; y <= 1; y++) for (let z = -1; z <= 1; z++) { const c = cubies[i++]; c.position.set(x * GAP, y * GAP, z * GAP); c.quaternion.identity(); } } document.getElementById('btnScramble').addEventListener('click', scrambleCube); document.getElementById('btnReset').addEventListener('click', resetCube); /* ════════════════════════════════════════════════════════════════ Section 13 · 窗口自适应 & 渲染循环 ════════════════════════════════════════════════════════════════ */ addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); }); (function loop() { requestAnimationFrame(loop); tickTweens(); orbit.update(); renderer.render(scene, camera); })(); </script> </body> </html> 模型的额外输出 1 个帖子 - 1 位参与者 阅读完整话题
提示词: 你是一名精通图形学算法与 Web 交互的前端专家。请仅用一个 HTML 文件,基于 Three.js (ES Modules) 实现一个物理级高保真、支持自然手势的 3x3 魔方。 一、 交付规范 单文件架构:HTML/CSS/JS 必须合并在一个文件中。 依赖管理:必须通过 importmap 从 unpkg 或 cdn.skypack 引入 Three.js 及其 OrbitControls、Tween.js。 零素材依赖:禁止加载任何外部图片/贴图,所有材质纹理必须使用 HTML5 Canvas API 程序化动态生成。 二、 视觉与物理标准 模型构建: 场景需包含 27 个独立的小方块(Cubies)。 物理间隙:小方块之间必须保留微小的物理间距(Spacing),不可紧贴。 倒角质感:通过 Canvas 绘制带有圆角矩形(Rounded Rect)的贴纸纹理,模拟真实魔方的塑料黑边与贴纸高光效果。 光影环境: 必须开启 ShadowMap。 配置环境光(Ambient)与平行光(Directional),确保魔方有清晰的立体感和阴影投射。 三、 核心逻辑考点(数据结构与变换) 禁止维护复杂的 3D 状态数组,请使用基于“空间位置”的动态计算方案: 动态层级筛选: 不要写死索引。当需要旋转某一层时,遍历所有方块,根据其在世界坐标系(World Position)下的 x, y, z 值与阈值(Epsilon)来判断它是否属于当前旋转层。 Pivot 变换机制(关键考点): 实现旋转时,必须创建一个临时的 Pivot(轴心对象)。 核心API:使用 pivot.attach(object) 将选中的方块挂载到轴心,旋转轴心,动画结束后使用 scene.attach(object) 将方块放回场景。 作用:利用 attach 自动计算世界矩阵变换,避免手动处理复杂的四元数乘法。 坐标清洗: 每次旋转结束后,必须对所有方块的位置(Position)和旋转(Rotation)进行 Math.round() 取整处理,消除浮点数累积误差,防止魔方“散架”。 四、 交互系统考点(算法重难点) 这是区分初级与高级开发者的核心点,请实现类似原生 App 的自然手势体验: 操作分离: 左键拖拽:旋转魔方的某一层。 右键拖拽:旋转视角(OrbitControls)。 基于投影向量的手势识别算法: 射线检测:点击时获取被点击方块的“表面法线(Face Normal)”。 意图判断: 根据法线,锁定潜在的两个旋转轴(例如点击前面,潜在轴为 X 或 Y)。 将这两个 3D 轴的向量**投影(Project)**到 2D 屏幕空间。 计算用户鼠标滑动的 2D 向量与这两个投影向量的点积(Dot Product),选择匹配度最高的轴作为旋转轴。 方向修正与实时跟随: 实现1:1 实时跟手:鼠标移动多少像素,魔方层转动对应角度。 符号修正:通过叉乘(Cross Product)或投影符号判断,确保无论从魔方正面、背面还是顶面操作,鼠标向右划动始终对应“向右转”的视觉逻辑(解决方向反转 bug)。 磁吸效果: 松开鼠标后,自动计算最近的 90 度倍数,使用 Tween.js 播放回弹动画并对齐网格。 五、 代码质量 代码需包含清晰的注释,解释“手势投影算法”和“Pivot 挂载逻辑”。 具备 Scramble(打乱)和 Reset(重置)功能按钮。 测试结果 https://imgbed.snemc.cn/i/2ba0883a5353.gif (图片大于 4 MB) 概述: 有颜色错乱和转动轴向错乱问题 打乱按钮无效 测试花费 0.6元 思考时间(不含输出答案时间) 346.7s 测试轮数: 1 轮 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>3x3 魔方 - 物理级高保真手势交互</title> <style> :root { --bg: #1a1a2e; --panel-bg: #16213e; --text: #e0e0e0; --accent: #e94560; --accent2: #0f3460; --gold: #d4a574; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg); font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; overflow: hidden; height: 100vh; width: 100vw; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: default; } #canvas-container { position: fixed; inset: 0; z-index: 1; } #canvas-container canvas { display: block; } #canvas-container.grabbing canvas { cursor: grabbing; } #canvas-container.right-drag canvas { cursor: grab; } /* 底部控制面板 */ .control-panel { position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%); z-index: 10; display: flex; gap: 16px; align-items: center; } .btn { padding: 12px 28px; border: none; border-radius: 30px; font-size: 15px; font-weight: 600; letter-spacing: 0.5px; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); outline: none; position: relative; overflow: hidden; } .btn-scramble { background: rgba(233, 68, 96, 0.85); color: #fff; box-shadow: 0 8px 24px rgba(233, 68, 96, 0.35); } .btn-scramble:hover { background: rgba(245, 80, 108, 0.95); box-shadow: 0 12px 32px rgba(233, 68, 96, 0.5); transform: translateY(-3px); } .btn-scramble:active { transform: translateY(0); box-shadow: 0 4px 12px rgba(233, 68, 96, 0.3); } .btn-reset { background: rgba(255, 255, 255, 0.12); color: #e0e0e0; border: 1.5px solid rgba(255, 255, 255, 0.25); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); } .btn-reset:hover { background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.45); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); transform: translateY(-3px); } .btn-reset:active { transform: translateY(0); } .btn:disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; transform: none !important; } /* 提示标签 */ .hint-tag { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); z-index: 10; background: rgba(0, 0, 0, 0.55); color: #ccc; font-size: 13px; padding: 8px 20px; border-radius: 20px; letter-spacing: 0.4px; pointer-events: none; transition: opacity 0.5s; } /* 响应式 */ @media (max-width: 600px) { .btn { padding: 10px 20px; font-size: 13px; border-radius: 24px; } .control-panel { gap: 10px; bottom: 18px; } .hint-tag { font-size: 11px; top: 12px; padding: 6px 14px; } } </style> </head> <body> <div id="canvas-container"></div> <div class="hint-tag" id="hint-tag">🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放</div> <div class="control-panel"> <button class="btn btn-scramble" id="btn-scramble" title="随机打乱魔方">🔀 打乱</button> <button class="btn btn-reset" id="btn-reset" title="重置魔方到初始状态">↺ 重置</button> </div> <!-- Import Map:ES Modules 依赖 --> <script type="importmap"> { "imports": { "three": "https://unpkg.com/[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 * as TWEEN from '@tweenjs/tween.js'; // ==================== DOM 元素 ==================== const container = document.getElementById('canvas-container'); const hintTag = document.getElementById('hint-tag'); const btnScramble = document.getElementById('btn-scramble'); const btnReset = document.getElementById('btn-reset'); // ==================== 常量与配置 ==================== const CUBIE_SIZE = 0.9; // 小方块边长 const GRID_SPACING = 1.0; // 格点间距 const GAP = GRID_SPACING - CUBIE_SIZE; // 物理间隙 ≈ 0.1 const EPSILON = 0.15; // 层级筛选阈值 const ANGLE_PER_NDC = Math.PI; // NDC距离→弧度映射系数(1 NDC ≈ 180°) const MAGNETIC_DURATION = 280; // 磁吸动画时长(ms) const SCRAMBLE_STEPS = 20; // 打乱步数 const SCRAMBLE_STEP_DURATION = 160; // 打乱每步动画时长(ms) // 魔方标准颜色(贴纸颜色) const STICKER_COLORS = { right: '#E53935', // +X 红 left: '#FB8C00', // -X 橙 top: '#FAFAFA', // +Y 白 bottom: '#FDD835', // -Y 黄 front: '#43A047', // +Z 绿 back: '#1E88E5', // -Z 蓝 }; const PLASTIC_COLOR = '#1a1a1a'; // 塑料黑边颜色 // ==================== Three.js 初始化 ==================== const scene = new THREE.Scene(); scene.background = new THREE.Color('#1a1a2e'); scene.fog = new THREE.Fog('#1a1a2e', 6, 22); // 渲染器 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement); const canvas = renderer.domElement; // 相机 const camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.5, 40); camera.position.set(5.5, 3.8, 6.5); camera.lookAt(0, 0, 0); // OrbitControls —— 仅响应右键旋转 & 中键缩放 const controls = new OrbitControls(camera, canvas); controls.target.set(0, 0, 0); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.minDistance = 4; controls.maxDistance = 14; controls.maxPolarAngle = Math.PI * 0.75; // 关键:左键=null(留给魔方操作),中键=缩放,右键=旋转 controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE ? THREE.MOUSE.DOLLY : 1, RIGHT: THREE.MOUSE ? THREE.MOUSE.ROTATE : 0, }; controls.touches = { ONE: THREE.TOUCH ? THREE.TOUCH.ROTATE : 0, TWO: THREE.TOUCH ? THREE.TOUCH.DOLLY_PAN : 1, }; controls.update(); // ==================== 光照与阴影 ==================== // 环境光 —— 提供基础照明 const ambientLight = new THREE.AmbientLight('#8899bb', 1.6); scene.add(ambientLight); // 半球光 —— 模拟天空/地面散射 const hemiLight = new THREE.HemisphereLight('#bcc8e8', '#3a3040', 1.0); scene.add(hemiLight); // 主平行光 —— 投射阴影 const sunLight = new THREE.DirectionalLight('#fffef5', 5.5); sunLight.position.set(8, 14, 6); sunLight.castShadow = true; sunLight.shadow.mapSize.width = 2048; sunLight.shadow.mapSize.height = 2048; sunLight.shadow.camera.near = 0.5; sunLight.shadow.camera.far = 50; sunLight.shadow.camera.left = -10; sunLight.shadow.camera.right = 10; sunLight.shadow.camera.top = 10; sunLight.shadow.camera.bottom = -10; sunLight.shadow.bias = -0.00025; sunLight.shadow.normalBias = 0.02; scene.add(sunLight); // 补光 —— 减少暗面过黑 const fillLight = new THREE.DirectionalLight('#c8d0ff', 2.2); fillLight.position.set(-3, 2, -4); scene.add(fillLight); // ==================== 地面(接收阴影) ==================== const groundGeo = new THREE.PlaneGeometry(20, 20); const groundMat = new THREE.MeshStandardMaterial({ color: '#2a2a35', roughness: 0.65, metalness: 0.05, }); const ground = new THREE.Mesh(groundGeo, groundMat); ground.position.y = -3.5; ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; ground.name = 'ground'; scene.add(ground); // 微妙的网格辅助线(装饰) const gridHelper = new THREE.PolarGridHelper(9, 48, 32, 64, '#333344', '#222233'); gridHelper.position.y = -3.49; scene.add(gridHelper); // ==================== 贴纸纹理生成(Canvas API) ==================== /** * 使用 HTML5 Canvas 程序化生成带圆角矩形的高光贴纸纹理 * @param {string} colorHex - 贴纸颜色 * @returns {THREE.CanvasTexture} */ function createStickerTexture(colorHex) { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // 1. 塑料黑底(模拟魔方塑料边框) ctx.fillStyle = PLASTIC_COLOR; ctx.fillRect(0, 0, size, size); // 2. 圆角矩形贴纸 const margin = 22; const radius = 34; const x = margin; const y = margin; const w = size - margin * 2; const h = size - margin * 2; ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + w - radius, y); ctx.quadraticCurveTo(x + w, y, x + w, y + radius); ctx.lineTo(x + w, y + h - radius); ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); ctx.lineTo(x + radius, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); // 填充贴纸颜色 ctx.fillStyle = colorHex; ctx.fill(); // 3. 高光渐变(模拟贴纸表面的微妙光泽) const highlightGrad = ctx.createLinearGradient(x, y, x, y + h); highlightGrad.addColorStop(0, 'rgba(255,255,255,0.22)'); highlightGrad.addColorStop(0.25, 'rgba(255,255,255,0.06)'); highlightGrad.addColorStop(0.55, 'rgba(0,0,0,0.0)'); highlightGrad.addColorStop(0.8, 'rgba(0,0,0,0.04)'); highlightGrad.addColorStop(1, 'rgba(0,0,0,0.14)'); ctx.fillStyle = highlightGrad; ctx.fill(); // 4. 贴纸边缘微妙的暗线(模拟贴纸厚度) ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + w - radius, y); ctx.quadraticCurveTo(x + w, y, x + w, y + radius); ctx.lineTo(x + w, y + h - radius); ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); ctx.lineTo(x + radius, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); ctx.strokeStyle = 'rgba(0,0,0,0.18)'; ctx.lineWidth = 2.5; ctx.stroke(); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = true; return texture; } // 预生成6种颜色的贴纸纹理(缓存复用) const stickerTextureCache = {}; for (const [key, colorHex] of Object.entries(STICKER_COLORS)) { stickerTextureCache[key] = createStickerTexture(colorHex); } // 塑料面材质(无贴纸,纯黑) const plasticMaterial = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.55, metalness: 0.08, }); // ==================== 构建魔方 ==================== /** * 所有27个小方块的引用数组 * @type {THREE.Mesh[]} */ const allCubies = []; /** * 每个cubie的初始状态(用于Reset) * @type {Map<THREE.Mesh, {position: THREE.Vector3, rotation: THREE.Euler}>} */ const initialStates = new Map(); /** * 根据cubie的初始格点位置判断某个面是否外露 * @param {number} gridX - 格点X坐标 (-1, 0, 1) * @param {number} gridY - 格点Y坐标 (-1, 0, 1) * @param {number} gridZ - 格点Z坐标 (-1, 0, 1) * @returns {Object} 6个面是否外露的布尔映射 */ function getExposedFaces(gridX, gridY, gridZ) { return { posX: Math.abs(gridX - 1) < 0.01, // +X面外露(右面,红) negX: Math.abs(gridX + 1) < 0.01, // -X面外露(左面,橙) posY: Math.abs(gridY - 1) < 0.01, // +Y面外露(顶面,白) negY: Math.abs(gridY + 1) < 0.01, // -Y面外露(底面,黄) posZ: Math.abs(gridZ - 1) < 0.01, // +Z面外露(前面,绿) negZ: Math.abs(gridZ + 1) < 0.01, // -Z面外露(后面,蓝) }; } /** * 为单个cubie创建6个面的材质数组 * BoxGeometry的面顺序:+X(0), -X(1), +Y(2), -Y(3), +Z(4), -Z(5) */ function createCubieMaterials(gridX, gridY, gridZ) { const exposed = getExposedFaces(gridX, gridY, gridZ); const faceConfigs = [ { exposed: exposed.posX, stickerKey: 'right' }, // +X → 右面红 { exposed: exposed.negX, stickerKey: 'left' }, // -X → 左面橙 { exposed: exposed.posY, stickerKey: 'top' }, // +Y → 顶面白 { exposed: exposed.negY, stickerKey: 'bottom' }, // -Y → 底面黄 { exposed: exposed.posZ, stickerKey: 'front' }, // +Z → 前面绿 { exposed: exposed.negZ, stickerKey: 'back' }, // -Z → 后面蓝 ]; return faceConfigs.map((config) => { if (config.exposed) { const tex = stickerTextureCache[config.stickerKey]; return new THREE.MeshStandardMaterial({ map: tex, roughness: 0.32, metalness: 0.02, color: 0xffffff, }); } else { return plasticMaterial.clone(); } }); } // 创建27个cubie const cubieGroup = new THREE.Group(); // 用于组织所有cubie cubieGroup.name = 'cubieGroup'; scene.add(cubieGroup); for (let gx = -1; gx <= 1; gx++) { for (let gy = -1; gy <= 1; gy++) { for (let gz = -1; gz <= 1; gz++) { const geo = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE); const materials = createCubieMaterials(gx, gy, gz); const mesh = new THREE.Mesh(geo, materials); mesh.position.set(gx * GRID_SPACING, gy * GRID_SPACING, gz * GRID_SPACING); mesh.rotation.set(0, 0, 0); mesh.castShadow = true; mesh.receiveShadow = true; mesh.name = `cubie_${gx}_${gy}_${gz}`; // 存储初始格点坐标(用于调试和初始状态判断) mesh.userData = { initialGrid: { x: gx, y: gy, z: gz }, }; cubieGroup.add(mesh); allCubies.push(mesh); initialStates.set(mesh, { position: mesh.position.clone(), rotation: mesh.rotation.clone(), }); } } } // ==================== 交互状态机 ==================== const InteractionState = Object.freeze({ IDLE: 'idle', DRAGGING: 'dragging', ANIMATING: 'animating', SCRAMBLING: 'scrambling', }); let interactionState = InteractionState.IDLE; let activePivot = null; // 当前旋转轴心(THREE.Group) let activeAxisWorld = null; // 当前旋转轴(世界空间Vector3) let activeLayerValue = null; // 当前旋转层的坐标值 let activeAxisIndex = null; // 轴索引:0=X, 1=Y, 2=Z let dragStartNDC = null; // 鼠标按下时的NDC坐标 let projectedAxisScreen = null; // 投影到屏幕空间的轴方向(归一化Vector2) let currentDragAngle = 0; // 当前拖拽累积角度 let selectedCubies = []; // 当前选中的cubie列表 // ==================== 工具函数 ==================== /** * 将3D世界坐标投影到NDC空间 */ function worldToNDC(worldPos) { const ndc = worldPos.clone().project(camera); return new THREE.Vector2(ndc.x, ndc.y); } /** * 获取鼠标事件的NDC坐标 */ function mouseToNDC(event) { return new THREE.Vector2( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); } /** * 将3D世界轴向量投影到屏幕空间,返回归一化的2D方向 * @param {THREE.Vector3} worldAxis - 世界空间中的轴向量(如(1,0,0)) * @param {THREE.Vector3} referencePoint - 参考点(用于投影) * @returns {THREE.Vector2} 屏幕空间中的归一化方向 */ function projectAxisToScreen(worldAxis, referencePoint) { const p1 = worldToNDC(referencePoint); const p2 = worldToNDC(referencePoint.clone().add(worldAxis)); const dir = new THREE.Vector2(p2.x - p1.x, p2.y - p1.y); const len = dir.length(); if (len < 1e-9) return new THREE.Vector2(1, 0); // 退化情况 return dir.normalize(); } /** * 坐标清洗:消除浮点数累积误差 * - position 四舍五入到整数 * - rotation 欧拉角四舍五入到最接近的 π/2 倍数 */ function snapCubieTransform(cubie) { // 清洗位置 cubie.position.x = Math.round(cubie.position.x); cubie.position.y = Math.round(cubie.position.y); cubie.position.z = Math.round(cubie.position.z); // 清洗旋转(欧拉角 → 最接近的 π/2 倍数) const halfPi = Math.PI / 2; cubie.rotation.x = Math.round(cubie.rotation.x / halfPi) * halfPi; cubie.rotation.y = Math.round(cubie.rotation.y / halfPi) * halfPi; cubie.rotation.z = Math.round(cubie.rotation.z / halfPi) * halfPi; } /** * 对所有cubie执行坐标清洗 */ function snapAllCubies() { allCubies.forEach(snapCubieTransform); } /** * 动态层级筛选:根据世界坐标和轴索引,找出属于同一旋转层的所有cubie * @param {number} axisIndex - 轴索引 0=X, 1=Y, 2=Z * @param {number} layerValue - 该层的坐标值 * @returns {THREE.Mesh[]} */ function getCubiesInLayer(axisIndex, layerValue) { const result = []; const axisKey = ['x', 'y', 'z'][axisIndex]; for (const cubie of allCubies) { // 获取cubie在世界空间中的位置 const worldPos = new THREE.Vector3(); cubie.getWorldPosition(worldPos); if (Math.abs(worldPos[axisKey] - layerValue) < EPSILON) { result.push(cubie); } } return result; } /** * 获取cubie当前世界坐标在指定轴上的值 */ function getCubieWorldAxisValue(cubie, axisIndex) { const worldPos = new THREE.Vector3(); cubie.getWorldPosition(worldPos); return ['x', 'y', 'z'][axisIndex] === 'x' ? worldPos.x : ['x', 'y', 'z'][axisIndex] === 'y' ? worldPos.y : worldPos.z; } // ==================== Pivot 旋转机制 ==================== /** * 创建轴心对象并挂载选中的cubie * @param {THREE.Mesh[]} cubies - 要挂载的cubie列表 * @returns {THREE.Group} pivot */ function createPivotAndAttach(cubies) { const pivot = new THREE.Group(); pivot.name = 'rotationPivot'; // Pivot放在世界原点,旋转轴通过原点 scene.add(pivot); for (const cubie of cubies) { // pivot.attach() 保持世界变换,将cubie挂载到pivot下 pivot.attach(cubie); } return pivot; } /** * 释放pivot:将所有cubie放回场景,然后移除pivot * @param {THREE.Group} pivot */ function releasePivot(pivot) { const children = [...pivot.children]; // 复制数组(遍历时会被修改) for (const cubie of children) { // scene.attach() 保持世界变换,将cubie放回场景 scene.attach(cubie); // 放回cubieGroup以保持组织结构 cubieGroup.attach(cubie); } scene.remove(pivot); if (pivot.geometry) pivot.geometry.dispose(); if (pivot.material) { if (Array.isArray(pivot.material)) { pivot.material.forEach(m => m.dispose()); } else { pivot.material.dispose(); } } } /** * 执行完整的层旋转(带磁吸动画) * @param {THREE.Group} pivot * @param {THREE.Vector3} axisWorld - 世界空间旋转轴 * @param {number} targetAngle - 目标角度(弧度) * @param {number} duration - 动画时长(ms) * @returns {Promise<void>} */ function animatePivotToTarget(pivot, axisWorld, targetAngle, duration) { return new Promise((resolve) => { // 获取pivot当前绕该轴的角度 const axisKey = ['x', 'y', 'z'][activeAxisIndex]; const startAngle = pivot.rotation[axisKey]; // 使用Tween.js const tweenData = { angle: startAngle }; const tween = new TWEEN.Tween(tweenData) .to({ angle: targetAngle }, duration) .easing(TWEEN.Easing.Cubic.Out) .onUpdate(() => { pivot.rotation[axisKey] = tweenData.angle; }) .onComplete(() => { pivot.rotation[axisKey] = targetAngle; resolve(); }) .start(); }); } // ==================== 手势投影算法(核心考点) ==================== /** * 根据面法线确定两个潜在旋转轴 * 原理:用户可以绕与法线垂直的任意轴旋转 * @param {THREE.Vector3} worldNormal - 世界空间中的面法线 * @returns {{axisA: THREE.Vector3, axisB: THREE.Vector3, indexA: number, indexB: number}} */ function getPotentialAxes(worldNormal) { const absX = Math.abs(worldNormal.x); const absY = Math.abs(worldNormal.y); const absZ = Math.abs(worldNormal.z); // 法线的主要分量决定了两个潜在旋转轴 if (absX >= absY && absX >= absZ) { // 法线接近X轴 → 潜在旋转轴为Y和Z return { axisA: new THREE.Vector3(0, 1, 0), axisB: new THREE.Vector3(0, 0, 1), indexA: 1, indexB: 2, }; } else if (absY >= absX && absY >= absZ) { // 法线接近Y轴 → 潜在旋转轴为X和Z return { axisA: new THREE.Vector3(1, 0, 0), axisB: new THREE.Vector3(0, 0, 1), indexA: 0, indexB: 2, }; } else { // 法线接近Z轴 → 潜在旋转轴为X和Y return { axisA: new THREE.Vector3(1, 0, 0), axisB: new THREE.Vector3(0, 1, 0), indexA: 0, indexB: 1, }; } } /** * 计算鼠标滑动向量与两个投影轴的点积,选择匹配度最高的轴 * @param {THREE.Vector2} dragVector - 鼠标滑动2D向量(NDC空间) * @param {THREE.Vector2} projA - 轴A在屏幕空间的投影方向 * @param {THREE.Vector2} projB - 轴B在屏幕空间的投影方向 * @returns {{chosenIndex: number, dotProduct: number, chosenProj: THREE.Vector2}} */ function selectBestAxis(dragVector, projA, projB, indexA, indexB) { const dotA = Math.abs(dragVector.dot(projA)); const dotB = Math.abs(dragVector.dot(projB)); if (dotA >= dotB) { return { chosenIndex: indexA, dotProduct: dragVector.dot(projA), chosenProj: projA, }; } else { return { chosenIndex: indexB, dotProduct: dragVector.dot(projB), chosenProj: projB, }; } } // ==================== 鼠标事件处理 ==================== let mouseDownWorldPoint = null; // 鼠标按下时的3D交点 let hasMovedSignificantly = false; // 是否产生了有效拖拽 const MIN_DRAG_THRESHOLD_NDC = 0.015; // 最小拖拽阈值(NDC单位) canvas.addEventListener('pointerdown', (event) => { // 仅处理左键(button === 0) if (event.button !== 0) return; // 非空闲状态不响应 if (interactionState !== InteractionState.IDLE) return; // 射线检测 const ndc = mouseToNDC(event); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(ndc.x, ndc.y), camera); const intersects = raycaster.intersectObjects(allCubies, false); if (intersects.length === 0) return; // 未命中魔方 const intersect = intersects[0]; const clickedCubie = intersect.object; const faceNormalLocal = intersect.face.normal.clone(); // 将面法线转换到世界空间 const worldNormal = faceNormalLocal.transformDirection(clickedCubie.matrixWorld).round(); // 获取交点世界位置 mouseDownWorldPoint = intersect.point.clone(); // 【手势投影算法】根据法线确定两个潜在旋转轴 const { axisA, axisB, indexA, indexB } = getPotentialAxes(worldNormal); // 将两个3D轴投影到屏幕空间 const projA = projectAxisToScreen(axisA, mouseDownWorldPoint); const projB = projectAxisToScreen(axisB, mouseDownWorldPoint); // 存储以备mousemove使用(此时尚未决定选哪个轴) dragStartNDC = ndc; hasMovedSignificantly = false; currentDragAngle = 0; selectedCubies = []; activePivot = null; activeAxisWorld = null; activeLayerValue = null; activeAxisIndex = null; projectedAxisScreen = null; // 暂存潜在轴信息 canvas._gestureData = { axisA, axisB, indexA, indexB, projA, projB, clickedCubie, worldNormal, }; interactionState = InteractionState.DRAGGING; container.classList.add('grabbing'); controls.enabled = false; // 拖拽期间禁用OrbitControls hintTag.style.opacity = '0.4'; }); canvas.addEventListener('pointermove', (event) => { if (interactionState !== InteractionState.DRAGGING) return; if (!canvas._gestureData) return; const currentNDC = mouseToNDC(event); const dragVector = new THREE.Vector2( currentNDC.x - dragStartNDC.x, currentNDC.y - dragStartNDC.y ); const dragLength = dragVector.length(); // 检查是否超过最小阈值 if (!hasMovedSignificantly && dragLength < MIN_DRAG_THRESHOLD_NDC) { return; // 微小移动,忽略 } const gd = canvas._gestureData; if (!hasMovedSignificantly) { // 首次显著移动:选择最佳旋转轴 hasMovedSignificantly = true; const selection = selectBestAxis(dragVector, gd.projA, gd.projB, gd.indexA, gd.indexB); activeAxisIndex = selection.chosenIndex; const rawDot = selection.dotProduct; // 确定世界旋转轴 const axisVectors = [ new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1), ]; activeAxisWorld = axisVectors[activeAxisIndex].clone(); projectedAxisScreen = selection.chosenProj.clone(); // 动态层级筛选:根据被点击cubie的世界坐标确定旋转层 const clickedCubie = gd.clickedCubie; const worldPos = new THREE.Vector3(); clickedCubie.getWorldPosition(worldPos); const axisKey = ['x', 'y', 'z'][activeAxisIndex]; activeLayerValue = Math.round(worldPos[axisKey]); // 筛选同一层的所有cubie selectedCubies = getCubiesInLayer(activeAxisIndex, activeLayerValue); // 创建Pivot并挂载选中的cubie activePivot = createPivotAndAttach(selectedCubies); activePivot.rotation.set(0, 0, 0); currentDragAngle = 0; // 更新gestureData gd._rawDotSign = rawDot; } // 计算旋转角度:鼠标滑动向量在投影轴上的投影(带符号) const projectionOnAxis = dragVector.dot(projectedAxisScreen); // 转换为弧度(1 NDC ≈ π 弧度 ≈ 180°) const angle = projectionOnAxis * ANGLE_PER_NDC; // 符号修正:确保"向右滑=向右转"的视觉逻辑 // 使用叉乘判断:dragVector × projectedAxisScreen 的符号决定旋转方向 const crossZ = dragVector.x * projectedAxisScreen.y - dragVector.y * projectedAxisScreen.x; const signCorrection = crossZ >= 0 ? 1 : -1; const correctedAngle = Math.abs(angle) * signCorrection; currentDragAngle = correctedAngle; // 实时更新pivot旋转(1:1跟手) const axisKey = ['x', 'y', 'z'][activeAxisIndex]; activePivot.rotation.set(0, 0, 0); activePivot.rotation[axisKey] = correctedAngle; }); window.addEventListener('pointerup', async (event) => { if (interactionState !== InteractionState.DRAGGING) return; container.classList.remove('grabbing'); hintTag.style.opacity = '1'; if (!hasMovedSignificantly || !activePivot || selectedCubies.length === 0) { // 无有效拖拽:清理 if (activePivot) { releasePivot(activePivot); } cleanupDragState(); controls.enabled = true; return; } // 【磁吸效果】:计算最近的90度倍数 const halfPi = Math.PI / 2; const rawAngle = currentDragAngle; const targetAngle = Math.round(rawAngle / halfPi) * halfPi; // 如果目标角度为0(即回到原位),直接释放 if (Math.abs(targetAngle) < 0.001) { releasePivot(activePivot); snapAllCubies(); cleanupDragState(); controls.enabled = true; return; } // 执行磁吸动画 interactionState = InteractionState.ANIMATING; btnScramble.disabled = true; btnReset.disabled = true; const pivotRef = activePivot; const axisRef = activeAxisWorld; const cubiesRef = selectedCubies; await animatePivotToTarget(pivotRef, axisRef, targetAngle, MAGNETIC_DURATION); // 动画完成:释放cubie回场景 releasePivot(pivotRef); // 【坐标清洗】:消除浮点数累积误差 snapAllCubies(); cleanupDragState(); controls.enabled = true; btnScramble.disabled = false; btnReset.disabled = false; interactionState = InteractionState.IDLE; }); /** * 清理拖拽状态 */ function cleanupDragState() { activePivot = null; activeAxisWorld = null; activeLayerValue = null; activeAxisIndex = null; dragStartNDC = null; projectedAxisScreen = null; currentDragAngle = 0; selectedCubies = []; hasMovedSignificantly = false; mouseDownWorldPoint = null; delete canvas._gestureData; if (interactionState === InteractionState.DRAGGING) { interactionState = InteractionState.IDLE; } container.classList.remove('grabbing'); } // 防止右键菜单弹出(在canvas上) canvas.addEventListener('contextmenu', (event) => { event.preventDefault(); }); // ==================== Scramble(打乱) ==================== /** * 执行打乱序列 * 随机选择层和轴,执行一系列90度旋转 */ async function scramble() { if (interactionState !== InteractionState.IDLE) return; interactionState = InteractionState.SCRAMBLING; btnScramble.disabled = true; btnReset.disabled = true; controls.enabled = false; hintTag.textContent = '🔀 正在打乱...'; hintTag.style.opacity = '1'; const axisVectors = [ new THREE.Vector3(1, 0, 0), // X轴 new THREE.Vector3(0, 1, 0), // Y轴 new THREE.Vector3(0, 0, 1), // Z轴 ]; const layerValues = [-1, 0, 1]; // 三层 const angleOptions = [-Math.PI / 2, Math.PI / 2]; // ±90° // 生成随机打乱序列(避免连续同轴旋转) const steps = []; let lastAxisIndex = -1; for (let i = 0; i < SCRAMBLE_STEPS; i++) { let axisIndex; do { axisIndex = Math.floor(Math.random() * 3); } while (axisIndex === lastAxisIndex && SCRAMBLE_STEPS > 3); lastAxisIndex = axisIndex; const layerValue = layerValues[Math.floor(Math.random() * 3)]; const angle = angleOptions[Math.floor(Math.random() * 2)]; steps.push({ axisIndex, layerValue, angle }); } for (let i = 0; i < steps.length; i++) { const { axisIndex, layerValue, angle } = steps[i]; const cubies = getCubiesInLayer(axisIndex, layerValue); if (cubies.length === 0) continue; const pivot = createPivotAndAttach(cubies); const axisKey = ['x', 'y', 'z'][axisIndex]; pivot.rotation.set(0, 0, 0); await animatePivotToTarget(pivot, axisVectors[axisIndex], angle, SCRAMBLE_STEP_DURATION); releasePivot(pivot); snapAllCubies(); // 小延迟让动画呼吸 if (i < steps.length - 1) { await new Promise(r => setTimeout(r, 40)); } } snapAllCubies(); controls.enabled = true; btnScramble.disabled = false; btnReset.disabled = false; interactionState = InteractionState.IDLE; hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放'; hintTag.style.opacity = '1'; } // ==================== Reset(重置) ==================== /** * 重置所有cubie到初始状态(使用动画平滑过渡) */ async function resetCube() { if (interactionState !== InteractionState.IDLE) return; interactionState = InteractionState.ANIMATING; btnScramble.disabled = true; btnReset.disabled = true; controls.enabled = false; hintTag.textContent = '↺ 正在重置...'; hintTag.style.opacity = '1'; // 收集所有需要动画的cubie及其目标状态 const animations = []; for (const cubie of allCubies) { const initState = initialStates.get(cubie); if (!initState) continue; const targetPos = initState.position.clone(); const targetRot = initState.rotation.clone(); // 如果当前位置/旋转与目标相同,跳过 const posDist = cubie.position.distanceTo(targetPos); const rotDist = Math.abs(cubie.rotation.x - targetRot.x) + Math.abs(cubie.rotation.y - targetRot.y) + Math.abs(cubie.rotation.z - targetRot.z); if (posDist < 0.001 && rotDist < 0.001) continue; animations.push({ cubie, startPos: cubie.position.clone(), targetPos, startRot: { x: cubie.rotation.x, y: cubie.rotation.y, z: cubie.rotation.z, }, targetRot: { x: targetRot.x, y: targetRot.y, z: targetRot.z, }, }); } if (animations.length === 0) { // 无需动画 snapAllCubies(); finishReset(); return; } // 使用Tween并行动画所有cubie const tweenData = { progress: 0 }; const duration = 400; const tween = new TWEEN.Tween(tweenData) .to({ progress: 1 }, duration) .easing(TWEEN.Easing.Cubic.InOut) .onUpdate(() => { const t = tweenData.progress; for (const anim of animations) { anim.cubie.position.lerpVectors(anim.startPos, anim.targetPos, t); anim.cubie.rotation.x = anim.startRot.x + (anim.targetRot.x - anim.startRot.x) * t; anim.cubie.rotation.y = anim.startRot.y + (anim.targetRot.y - anim.startRot.y) * t; anim.cubie.rotation.z = anim.startRot.z + (anim.targetRot.z - anim.startRot.z) * t; } }) .onComplete(() => { for (const anim of animations) { anim.cubie.position.copy(anim.targetPos); anim.cubie.rotation.set(anim.targetRot.x, anim.targetRot.y, anim.targetRot.z); } snapAllCubies(); finishReset(); }) .start(); // 等待tween完成 await new Promise(resolve => { const check = setInterval(() => { if (tweenData.progress >= 1) { clearInterval(check); resolve(); } }, 50); // 安全超时 setTimeout(() => { clearInterval(check); resolve(); }, duration + 200); }); } function finishReset() { snapAllCubies(); controls.enabled = true; btnScramble.disabled = false; btnReset.disabled = false; interactionState = InteractionState.IDLE; hintTag.textContent = '🖱️ 左键拖拽旋转层 | 右键拖拽旋转视角 | 滚轮缩放'; hintTag.style.opacity = '1'; } // ==================== 按钮事件 ==================== btnScramble.addEventListener('click', () => { if (interactionState === InteractionState.IDLE) { scramble(); } }); btnReset.addEventListener('click', () => { if (interactionState === InteractionState.IDLE) { resetCube(); } }); // 键盘快捷键 window.addEventListener('keydown', (event) => { if (event.key === 's' || event.key === 'S') { if (interactionState === InteractionState.IDLE) scramble(); } if (event.key === 'r' || event.key === 'R') { if (interactionState === InteractionState.IDLE) resetCube(); } }); // ==================== 窗口大小适配 ==================== window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); // ==================== 渲染循环 ==================== function animate(timestamp) { requestAnimationFrame(animate); // 更新Tween动画 TWEEN.update(timestamp); // 更新OrbitControls(仅在非拖拽/非动画状态) if (interactionState === InteractionState.IDLE) { controls.enabled = true; controls.update(); } else if (interactionState === InteractionState.DRAGGING) { controls.enabled = false; } // 渲染 renderer.render(scene, camera); } // ==================== 启动 ==================== console.log('🧊 3x3 魔方已就绪'); console.log(' - 左键拖拽:手势旋转层(投影向量匹配算法)'); console.log(' - 右键拖拽:OrbitControls 旋转视角'); console.log(' - 滚轮:缩放'); console.log(' - 按钮/快捷键 S=打乱 R=重置'); console.log(' - Pivot挂载机制 + 坐标清洗 + 磁吸效果'); console.log(' - 物理间隙:', GAP.toFixed(2), '单位'); console.log(' - 倒角贴纸:Canvas程序化生成圆角矩形纹理'); requestAnimationFrame(animate); </script> </body> </html> 1 个帖子 - 1 位参与者 阅读完整话题