魔方
2026-09-05 14:15:01 · 更新 2026-09-14
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
/* ================= 配色(取自设计稿实测) ================= */
const PALETTE = {
U: '#F4C243', // 黄(顶)
D: '#F2F4EF', // 白(底)
F: '#F16041', // 红(前)
B: '#FF9716', // 橙(后)
R: '#306CCC', // 蓝(右)
L: '#2C9A69', // 绿(左)
}
const BODY = '#FBF8F1' // 魔方块体(浅色分格缝)
const INNER = '#E7E2D6' // 内面
const RING = '#CECFCA' // 圆环细线
const ACCENT = '#F08626' // 拧动高亮
const DOT_R = 6 // 色点半径
const DOT_RIM = '#A8ABA4' // 白面色点描边
/* ================= 3D 魔方:位置 + 朝向矩阵 ================= */
type Axis = 'x' | 'y' | 'z'
type FaceKey = 'U' | 'D' | 'F' | 'B' | 'R' | 'L'
interface Cubie {
x: number
y: number
z: number
m: number[][] // 朝向矩阵:世界坐标 = m · 局部坐标
colors: Record<string, string | null>
tf: string // 当前 transform
ov: Record<string, string> // 各局部面的世界光照着色
}
const S = 50 // 单块边长 px
// 六个局部面法线 -> 颜色(仅初始位于表面时着色)
function makeColors(
x: number,
y: number,
z: number,
): Record<string, string | null> {
return {
'1,0,0': x === 1 ? PALETTE.R : null,
'-1,0,0': x === -1 ? PALETTE.L : null,
'0,-1,0': y === -1 ? PALETTE.U : null,
'0,1,0': y === 1 ? PALETTE.D : null,
'0,0,1': z === 1 ? PALETTE.F : null,
'0,0,-1': z === -1 ? PALETTE.B : null,
}
}
// 六个面片在块内的摆放(法线朝外)
const FACES6 = [
{ k: '0,0,1', t: `translateZ(${S / 2}px)` },
{ k: '0,0,-1', t: `rotateY(180deg) translateZ(${S / 2}px)` },
{ k: '1,0,0', t: `rotateY(90deg) translateZ(${S / 2}px)` },
{ k: '-1,0,0', t: `rotateY(-90deg) translateZ(${S / 2}px)` },
{ k: '0,-1,0', t: `rotateX(90deg) translateZ(${S / 2}px)` },
{ k: '0,1,0', t: `rotateX(-90deg) translateZ(${S / 2}px)` },
]
// 矩阵工具
function matVec(m: number[][], v: number[]): number[] {
return [
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
function matMul(a: number[][], b: number[][]): number[][] {
const out: number[][] = []
for (let i = 0; i < 3; i++) {
out.push([])
for (let j = 0; j < 3; j++)
out[i].push(a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j])
}
return out
}
function transpose(m: number[][]): number[][] {
return [
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
]
}
// m^T · v
function transposeVec(m: number[][], v: number[]): number[] {
return [
m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2],
m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2],
m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2],
]
}
// 行优先 3x3 -> CSS matrix3d(列优先)
function matrix3d(m: number[][]): string {
return `matrix3d(${m[0][0]},${m[1][0]},${m[2][0]},0,${m[0][1]},${m[1][1]},${m[2][1]},0,${m[0][2]},${m[1][2]},${m[2][2]},0,0,0,0,1)`
}
// 各动作:轴 + 层 + 方向(角度 = 90deg * dir,正方向 = 从该面看顺时针)
const MOVES: Record<string, { axis: Axis; layer: number; dir: 1 | -1 }> = {
U: { axis: 'y', layer: -1, dir: -1 },
D: { axis: 'y', layer: 1, dir: 1 },
R: { axis: 'x', layer: 1, dir: 1 },
L: { axis: 'x', layer: -1, dir: -1 },
F: { axis: 'z', layer: 1, dir: 1 },
B: { axis: 'z', layer: -1, dir: -1 },
}
// 对应 90° 整数旋转矩阵(CSS 坐标系)
const ROT: Record<string, number[][]> = {
x1: [
[1, 0, 0],
[0, 0, -1],
[0, 1, 0],
],
'x-1': [
[1, 0, 0],
[0, 0, 1],
[0, -1, 0],
],
y1: [
[0, 0, 1],
[0, 1, 0],
[-1, 0, 0],
],
'y-1': [
[0, 0, -1],
[0, 1, 0],
[1, 0, 0],
],
z1: [
[0, -1, 0],
[1, 0, 0],
[0, 0, 1],
],
'z-1': [
[0, 1, 0],
[-1, 0, 0],
[0, 0, 1],
],
}
// 解析基础动作与逆动作(带 ' 表示反方向)
function getMove(key: string) {
const prime = key.endsWith("'")
const base = prime ? key.slice(0, -1) : key
const mv = MOVES[base]
return prime ? { ...mv, dir: (mv.dir * -1) as 1 | -1 } : mv
}
function restTf(c: Cubie): string {
return `translate3d(${c.x * S}px, ${c.y * S}px, ${c.z * S}px) ${matrix3d(c.m)}`
}
/* 面向世界方向的柔和着色(平涂设计稿的体积感) */
const SHADES: Record<string, string> = {
'0,-1,0':
'linear-gradient(175deg, rgba(255,255,255,0.20), rgba(255,255,255,0.02))', // 顶:受光
'0,1,0': 'linear-gradient(175deg, rgba(0,0,0,0.22), rgba(0,0,0,0.34))', // 底:最暗
'0,0,1': 'linear-gradient(200deg, rgba(255,255,255,0.10), rgba(0,0,0,0.10))', // 前
'0,0,-1': 'linear-gradient(160deg, rgba(0,0,0,0.24), rgba(0,0,0,0.18))', // 后
'1,0,0': 'linear-gradient(155deg, rgba(255,255,255,0.08), rgba(0,0,0,0.16))', // 右
'-1,0,0': 'linear-gradient(160deg, rgba(0,0,0,0.06), rgba(0,0,0,0.24))', // 左
}
// 局部面 k 在朝向矩阵 m 下的世界受光
function shadeFor(m: number[][], k: string): string {
const n = k.split(',').map(Number)
const w = matVec(m, n)
const ax = [0, 1, 2].reduce((a, b) =>
Math.abs(w[a]) >= Math.abs(w[b]) ? a : b,
)
const key = [0, 0, 0]
key[ax] = Math.sign(w[ax]) || 1
return SHADES[key.join(',')]
}
function refreshShades(c: Cubie) {
for (const f of FACES6) c.ov[f.k] = shadeFor(c.m, f.k)
}
function makeCubies(): Cubie[] {
const out: Cubie[] = []
for (let x = -1; x <= 1; x++)
for (let y = -1; y <= 1; y++)
for (let z = -1; z <= 1; z++) {
const cu: Cubie = {
x,
y,
z,
m: [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
],
colors: makeColors(x, y, z),
tf: '',
ov: {},
}
cu.tf = restTf(cu)
refreshShades(cu)
out.push(cu)
}
return out
}
const cubies = reactive<Cubie[]>([])
let cubieMap = new Map<string, Cubie>()
function rebuildMap() {
cubieMap = new Map()
for (const cu of cubies) cubieMap.set(`${cu.x},${cu.y},${cu.z}`, cu)
}
// 逻辑提交:整数矩阵旋转,保证永不漂移
function commitMove(key: string) {
const mv = getMove(key)
const R = ROT[`${mv.axis}${mv.dir}`]
for (const cu of cubies) {
const v = mv.axis === 'x' ? cu.x : mv.axis === 'y' ? cu.y : cu.z
if (v !== mv.layer) continue
const p = matVec(R, [cu.x, cu.y, cu.z])
cu.x = p[0]
cu.y = p[1]
cu.z = p[2]
cu.m = matMul(R, cu.m)
}
rebuildMap()
for (const cu of cubies) cu.tf = restTf(cu)
}
/* ================= 二维轨道图(几何取自设计稿实测) ================= */
const W = 660
const H = 676
const VIEW = 0.64 // 圆环图整体缩放(绘制坐标不变,仅缩小呈现)
const K = W / 1206 // 设计稿(宽 1206)→ 画布比例
const D2 = (v: number) => v * K
// 三个大圆:圆心构成等边三角形(顶 / 左下 / 右下),互相交叠成花朵
const CIRCLES = [
{ cx: D2(602.5), cy: D2(568) }, // 顶圆
{ cx: D2(411), cy: D2(900) }, // 左圆
{ cx: D2(794), cy: D2(900) }, // 右圆
]
const RINGS = [D2(242), D2(278), D2(314)] // 每圆 3 圈同心环(内 / 中 / 外,等距)
// 六面簇 = 相邻两圆「中环」的交点;簇内 3×3 点阵沿所在环切线倾斜 ±18°
const FACE_CENTER: Record<FaceKey, { cx: number; cy: number }> = {
U: { cx: D2(602.5), cy: D2(698.5) }, // 左∩右 上交点(顶心,黄)
D: { cx: D2(602.5), cy: D2(1101.5) }, // 左∩右 下交点(底尖,白)
B: { cx: D2(332), cy: D2(633) }, // 顶∩左 上交点(橙)
L: { cx: D2(873), cy: D2(633) }, // 顶∩右 上交点(绿)
F: { cx: D2(681), cy: D2(835) }, // 顶∩左 下交点(红)
R: { cx: D2(524), cy: D2(835) }, // 顶∩右 下交点(蓝)
}
// 轨道:每个转动层对应一条环 —— y 轴(U/D)绕顶圆、x 轴(L/R)绕左圆、z 轴(F/B)
// 绕右圆;近层(U/L/F)用最内环,远层(D/R/B)用最外环,中环留白。
// 每条轨道环上常驻该层的全部 12 个跨面色点,拧动时它们沿环滑动换位
const TRACK: Record<FaceKey, { ci: number; ring: number }> = {
U: { ci: 0, ring: RINGS[0] },
D: { ci: 0, ring: RINGS[2] },
L: { ci: 1, ring: RINGS[0] },
R: { ci: 1, ring: RINGS[2] },
F: { ci: 2, ring: RINGS[0] },
B: { ci: 2, ring: RINGS[2] },
}
// 槽位 -> 所属转动层(最多两条):行/列贴各自的轨道,
// 角槽(行列皆非中)= 两条轨道环的交点,边槽 = 环上角点之间的中点
function tracksOf(f: FaceKey, r: number, c: number): FaceKey[] {
const t: FaceKey[] = []
if (f === 'U' || f === 'D') {
if (f === 'U') {
if (r === 0) t.push('B')
if (r === 2) t.push('F')
} else {
if (r === 0) t.push('F')
if (r === 2) t.push('B')
}
if (c === 0) t.push('L')
if (c === 2) t.push('R')
} else {
if (r === 0) t.push('U')
if (r === 2) t.push('D')
if (f === 'F' || f === 'B') {
const left = f === 'F' ? 'L' : 'R' // B 面列的左右与 F 相反
if (c === 0) t.push(left)
if (c === 2) t.push(left === 'L' ? 'R' : 'L')
} else {
const front = f === 'L' ? 'F' : 'B' // R 面列的前后与 L 相反
if (c === 0) t.push(front === 'F' ? 'B' : 'F')
if (c === 2) t.push(front)
}
}
return t
}
// 两轨道环的交点(取距簇心近的那个)
function trackCross(
a: FaceKey,
b: FaceKey,
ctr: { cx: number; cy: number },
): [number, number] {
const c1 = CIRCLES[TRACK[a].ci]
const c2 = CIRCLES[TRACK[b].ci]
const r1 = TRACK[a].ring
const r2 = TRACK[b].ring
const dx = c2.cx - c1.cx
const dy = c2.cy - c1.cy
const d = Math.hypot(dx, dy)
const aa = (r1 * r1 - r2 * r2 + d * d) / (2 * d)
const h = Math.sqrt(Math.max(0, r1 * r1 - aa * aa))
const px = c1.cx + (aa * dx) / d
const py = c1.cy + (aa * dy) / d
const p1: [number, number] = [px - (h * dy) / d, py + (h * dx) / d]
const p2: [number, number] = [px + (h * dy) / d, py - (h * dx) / d]
const d1 = Math.hypot(p1[0] - ctr.cx, p1[1] - ctr.cy)
const d2 = Math.hypot(p2[0] - ctr.cx, p2[1] - ctr.cy)
return d1 <= d2 ? p1 : p2
}
// 簇内 3×3 槽位:中心贴纸坐簇心;角贴纸坐两条轨道环的交点;
// 边贴纸坐轨道环上、同簇两角点之间的中点 —— 保证拧任意层时该层
// 12 个跨面点起止都精确落在被高亮的轨道环上
function grid2d(f: FaceKey, r: number, c: number): [number, number] {
const ctr = FACE_CENTER[f]
const ts = tracksOf(f, r, c)
if (ts.length === 0) return [ctr.cx, ctr.cy]
if (ts.length === 2) return trackCross(ts[0], ts[1], ctr)
// 单轨道边槽:环上同簇两角点的中点角
const tk = TRACK[ts[0]]
const cc = CIRCLES[tk.ci]
let sum = 0
let n = 0
for (let r2 = 0; r2 < 3; r2++)
for (let c2 = 0; c2 < 3; c2++) {
const ts2 = tracksOf(f, r2, c2)
if (ts2.length !== 2 || !ts2.includes(ts[0])) continue
const [px, py] = trackCross(ts2[0], ts2[1], ctr)
sum += Math.atan2(py - cc.cy, px - cc.cx)
n++
}
const ang = sum / n
return [cc.cx + Math.cos(ang) * tk.ring, cc.cy + Math.sin(ang) * tk.ring]
}
// 每个面在 3D 中的位置映射与法线,用于从魔方状态反推颜色
const FACE3D: Record<
FaceKey,
{
n: [number, number, number]
pos: (r: number, c: number) => [number, number, number]
}
> = {
U: { n: [0, -1, 0], pos: (r, c) => [c - 1, -1, r - 1] },
D: { n: [0, 1, 0], pos: (r, c) => [c - 1, 1, 1 - r] },
F: { n: [0, 0, 1], pos: (r, c) => [c - 1, r - 1, 1] },
B: { n: [0, 0, -1], pos: (r, c) => [1 - c, r - 1, -1] },
R: { n: [1, 0, 0], pos: (r, c) => [1, r - 1, 1 - c] },
L: { n: [-1, 0, 0], pos: (r, c) => [-1, r - 1, c - 1] },
}
const FACE_KEYS: FaceKey[] = ['U', 'D', 'F', 'B', 'R', 'L']
interface Facelet {
f: FaceKey
r: number
c: number
key: string
color: string
px: number
py: number
}
// 从 3D 状态反推所有六个面各 9 个色块的颜色与 2D 位置
function visibleFacelets(): Facelet[] {
const out: Facelet[] = []
for (const f of FACE_KEYS) {
const d = FACE3D[f]
for (let r = 0; r < 3; r++)
for (let c = 0; c < 3; c++) {
const [x, y, z] = d.pos(r, c)
const cu = cubieMap.get(`${x},${y},${z}`)
if (!cu) continue
const local = transposeVec(cu.m, d.n)
const color = cu.colors[local.join(',')]
if (!color) continue
const [px, py] = grid2d(f, r, c)
out.push({ f, r, c, key: `${f}:${r}:${c}`, color, px, py })
}
}
return out
}
/* ================= 动画驱动 ================= */
/* 一颗色点的滑行:绕某圆心的极坐标插值。
跨面换位的点起止槽位都精确落在轨道环上(布局保证),即纯环上滑动;
被拧面自身换位(打乱后才可见)绕簇心直接过渡 */
interface Ride {
color: string
cx: number
cy: number
a0: number // 起点角
da: number // 总转角(跨面点带环流方向,绝不横穿图心)
r0: number // 起点半径
r1: number // 终点半径
}
interface Part {
c: Cubie
px: number
py: number
pz: number
pm: number[][]
}
interface MoveAnim {
ax: number
ay: number
az: number
dir: number
t0: number
dur: number
parts: Part[]
rides: Ride[]
excluded: Set<string>
hl: FaceKey | null
}
let current: MoveAnim | null = null
let highlight: { face: FaceKey; t0: number } | null = null
const queue: { key: string; dur: number }[] = []
function shortestDelta(a: number): number {
while (a > Math.PI) a -= Math.PI * 2
while (a < -Math.PI) a += Math.PI * 2
return a
}
function easeInOutCubic(p: number): number {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2
}
function startMove(job: { key: string; dur: number }) {
const mv = getMove(job.key)
const face = job.key.replace("'", '') as FaceKey
const R = ROT[`${mv.axis}${mv.dir}`]
const Rt = transpose(R)
// 拧动前快照
const old = visibleFacelets()
const oldByWorld = new Map<string, Facelet>()
for (const fl of old) {
const d = FACE3D[fl.f]
const [x, y, z] = d.pos(fl.r, fl.c)
oldByWorld.set(`${x},${y},${z}|${d.n.join(',')}`, fl)
}
// 快照受影响块,并立即提交逻辑状态
const parts: Part[] = []
for (const cu of cubies) {
const v = mv.axis === 'x' ? cu.x : mv.axis === 'y' ? cu.y : cu.z
if (v !== mv.layer) continue
parts.push({ c: cu, px: cu.x * S, py: cu.y * S, pz: cu.z * S, pm: cu.m })
}
commitMove(job.key)
const nw = visibleFacelets()
// ===== 生成 2D 滑行计划 =====
// 布局保证:每层的 12 个跨面槽位起止都精确落在该层轨道环上,
// 因此跨面换位就是沿轨道环的纯角度滑动,全程不离环;
// 被拧面自身换位(打乱后才可见)绕簇心直接过渡
const rides: Ride[] = []
const excluded = new Set<string>()
const ac = CIRCLES[TRACK[face].ci]
const tc = FACE_CENTER[face]
// 跨面点绕轨道环的环流方向(由各轴四段跨面循环在图上的几何排布推得):
// 整圈色点同向流转、沿环走外侧弧线,不会抄近道横穿图心
const flowSign = mv.axis === 'y' ? mv.dir : -mv.dir
const inLayer = (x: number, y: number, z: number) => {
const v = mv.axis === 'x' ? x : mv.axis === 'y' ? y : z
return v === mv.layer
}
for (const fl of nw) {
const d = FACE3D[fl.f]
const [x, y, z] = d.pos(fl.r, fl.c)
const moving = inLayer(x, y, z)
const sp = moving ? matVec(Rt, [x, y, z]) : [x, y, z]
const sn = moving ? matVec(Rt, d.n) : d.n
const src = oldByWorld.get(`${sp.join(',')}|${sn.join(',')}`)
if (!src || src.key === fl.key) continue
// 本面内换位:若颜色未变(如同色面自转)则肉眼无变化,直接落位,
// 保证拧动时只有「轨道环上真正变色的点」在滑行
const sameFace = src.f === fl.f
if (sameFace) {
const occ = oldByWorld.get(`${x},${y},${z}|${d.n.join(',')}`)
if (occ && occ.color === src.color) continue
}
excluded.add(fl.key)
const cc = sameFace ? tc : ac // 本面换位 → 簇心;跨面交换 → 轨道圆心
const a0 = Math.atan2(src.py - cc.cy, src.px - cc.cx)
const a1 = Math.atan2(fl.py - cc.cy, fl.px - cc.cx)
let da = shortestDelta(a1 - a0)
if (!sameFace) {
// 强制同向环流:宁可绕外侧长弧,也不横穿图心
if (flowSign > 0 && da < 0) da += Math.PI * 2
else if (flowSign < 0 && da > 0) da -= Math.PI * 2
}
rides.push({
color: src.color,
cx: cc.cx,
cy: cc.cy,
a0,
da,
r0: Math.hypot(src.px - cc.cx, src.py - cc.cy),
r1: Math.hypot(fl.px - cc.cx, fl.py - cc.cy),
})
}
current = {
ax: mv.axis === 'x' ? 1 : 0,
ay: mv.axis === 'y' ? 1 : 0,
az: mv.axis === 'z' ? 1 : 0,
dir: mv.dir,
t0: performance.now(),
dur: job.dur,
parts,
rides,
excluded,
hl: face,
}
}
function finishMove(now: number) {
const cur = current
if (!cur) return
for (const pt of cur.parts) {
pt.c.tf = restTf(pt.c)
refreshShades(pt.c) // 落定后按新朝向重新着色
}
if (cur.hl) highlight = { face: cur.hl, t0: now }
current = null
nextAutoAt = now + 420
}
/* ================= 渲染循环 ================= */
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let rafId = 0
let nextAutoAt = 0
const auto = ref(false)
function drawDot(x: number, y: number, color: string, alpha: number) {
if (!ctx || alpha <= 0.01) return
ctx.globalAlpha = alpha
ctx.beginPath()
ctx.arc(x, y, DOT_R, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
if (color === PALETTE.D) {
// 白面色点加浅灰描边(设计稿样式)
ctx.lineWidth = 1.4
ctx.strokeStyle = DOT_RIM
ctx.stroke()
}
ctx.globalAlpha = 1
}
function draw2D(e: number, now: number) {
if (!ctx) return
ctx.save()
ctx.clearRect(0, 0, W, H)
// 三个大圆 × 3 圈同心环
ctx.strokeStyle = RING
ctx.lineWidth = 2
for (const bc of CIRCLES) {
for (const rr of RINGS) {
ctx.beginPath()
ctx.arc(bc.cx, bc.cy, rr, 0, Math.PI * 2)
ctx.stroke()
}
}
// 拧动高亮:该层轨道环变橙
const hlFace: FaceKey | null = current?.hl ?? highlight?.face ?? null
let hlAlpha = 0
if (current?.hl) hlAlpha = 1
else if (highlight) hlAlpha = Math.max(0, 1 - (now - highlight.t0) / 900)
if (hlFace && hlAlpha > 0) {
const c = CIRCLES[TRACK[hlFace].ci]
ctx.strokeStyle = ACCENT
ctx.lineWidth = 3.5
ctx.globalAlpha = hlAlpha
ctx.beginPath()
ctx.arc(c.cx, c.cy, TRACK[hlFace].ring, 0, Math.PI * 2)
ctx.stroke()
ctx.globalAlpha = 1
}
// 静态色点(排除正在被动画接管的格子)
for (const fl of visibleFacelets()) {
if (current?.excluded.has(fl.key)) continue
drawDot(fl.px, fl.py, fl.color, 1)
}
// 动画色点:跨面点沿轨道环做纯角度滑行(起止槽位都在环上,全程不离环);
// 同面换位绕簇心过渡
if (current) {
for (const rd of current.rides) {
const a = rd.a0 + rd.da * e
const r = rd.r0 + (rd.r1 - rd.r0) * e
drawDot(rd.cx + Math.cos(a) * r, rd.cy + Math.sin(a) * r, rd.color, 1)
}
}
ctx.restore()
}
function loop(now: number) {
rafId = requestAnimationFrame(loop)
if (!current && queue.length) startMove(queue.shift()!)
if (current) {
const p = Math.min(1, (now - current.t0) / current.dur)
const e = easeInOutCubic(p)
const deg = 90 * current.dir * e
for (const pt of current.parts) {
pt.c.tf = `rotate3d(${current.ax},${current.ay},${current.az},${deg}deg) translate3d(${pt.px}px,${pt.py}px,${pt.pz}px) ${matrix3d(pt.pm)}`
}
if (p >= 1) {
finishMove(now)
draw2D(1, now)
} else {
draw2D(e, now)
}
} else {
if (auto.value && now >= nextAutoAt) nextAuto()
draw2D(1, now)
}
}
/* ================= 自动演示与交互 ================= */
const AUTO_FACES = ['U', 'D', 'L', 'R', 'F', 'B']
let lastAutoFace = ''
/* 自动演示:每步从 12 个动作里随机取一个,避免与上一步同面,持续拧动不重样 */
function nextAuto() {
let f = AUTO_FACES[(Math.random() * AUTO_FACES.length) | 0]
if (f === lastAutoFace)
f = AUTO_FACES[(Math.random() * AUTO_FACES.length) | 0]
lastAutoFace = f
enqueue(Math.random() < 0.5 ? f : `${f}'`, 520)
}
function enqueue(key: string, dur: number) {
if (queue.length > 24) return
queue.push({ key, dur })
}
function toggleAuto() {
auto.value = !auto.value
if (auto.value) nextAutoAt = performance.now() + 250
}
/* 手动拧动:12 个基础动作(带 ' 表示逆时针),点击后魔方与色块圆环同步转动 */
const MOVE_KEYS = [
'U',
"U'",
'D',
"D'",
'L',
"L'",
'R',
"R'",
'F',
"F'",
'B',
"B'",
]
function manual(k: string) {
auto.value = false // 手动操作时退出自动演示
enqueue(k, 480)
}
function reset() {
queue.length = 0
if (current) {
for (const pt of current.parts) pt.c.tf = restTf(pt.c)
current = null
}
cubies.splice(0, cubies.length, ...makeCubies())
rebuildMap()
highlight = null
if (auto.value) nextAutoAt = performance.now() + 700
}
/* ================= 挂载 ================= */
onMounted(() => {
const cv = canvasRef.value
if (!cv) return
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1))
cv.width = W * dpr * VIEW
cv.height = H * dpr * VIEW
ctx = cv.getContext('2d')
ctx?.scale(dpr * VIEW, dpr * VIEW)
cubies.splice(0, cubies.length, ...makeCubies()) // 复原态起步
rebuildMap()
// 先同步画一帧,避免首帧 rAF 到来前画布空白
draw2D(1, performance.now())
nextAutoAt = performance.now() + 1000
rafId = requestAnimationFrame(loop)
})
onBeforeUnmount(() => cancelAnimationFrame(rafId))
</script>
<template>
<div class="rubik">
<canvas ref="canvasRef" class="board" />
<div class="stage">
<div class="cube">
<div
v-for="(cu, i) in cubies"
:key="i"
class="cubie"
:style="{ transform: cu.tf }"
>
<div
v-for="f in FACES6"
:key="f.k"
class="face"
:style="{
transform: f.t,
background: cu.colors[f.k] ? BODY : INNER,
}"
>
<div
v-if="cu.colors[f.k]"
class="sticker"
:style="{ background: cu.colors[f.k] || '' }"
/>
<div class="shade" :style="{ background: cu.ov[f.k] }" />
</div>
</div>
</div>
</div>
<div class="ctrl">
<button
v-for="k in MOVE_KEYS"
:key="k"
class="m-btn"
:title="`拧动 ${k} 层`"
@click="manual(k)"
>
{{ k }}
</button>
</div>
<div class="bar">
<button class="m-btn" @click="reset">↺ 复原</button>
<button
class="m-btn"
:class="{ on: auto }"
:title="auto ? '暂停' : '自动演示'"
@click="toggleAuto"
>
<span class="tri">{{ auto ? '暂停' : '自动演示' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.rubik {
display: flex;
flex-direction: column;
background: transparent; /* 背景透明,透出页面底色 */
}
.board {
width: 422px;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.stage {
position: relative;
width: 230px;
max-width: 100%;
height: 250px;
margin: 36px auto 0;
}
.cube {
position: absolute;
left: 50%;
top: 44%;
width: 0;
height: 0;
transform-style: preserve-3d;
transform: rotateX(-30deg) rotateY(36deg); /* 正交投影:黄顶 / 绿左 / 红右 */
}
.cubie {
position: absolute;
left: -25px;
top: -25px;
width: 50px;
height: 50px;
transform-style: preserve-3d;
}
.face {
position: absolute;
inset: 0;
border-radius: 5px;
backface-visibility: hidden;
}
.sticker {
position: absolute;
inset: 3px;
border-radius: 6px;
box-shadow: inset 0 0 0 1.2px rgba(70, 58, 45, 0.45);
}
.shade {
position: absolute;
inset: 0;
border-radius: 5px;
box-shadow: inset 0 0 0 1px rgba(70, 58, 45, 0.4);
pointer-events: none;
}
.ctrl {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 7px;
max-width: 640px; /* 12 个按钮一行放下,避免 B' 单独换行 */
margin: 22px auto 0;
}
.m-btn {
min-width: 44px;
border: 1px solid #cfc6b4;
background: rgba(255, 255, 255, 0.72);
border-radius: 8px;
padding: 4px 10px;
font-size: 14px;
font-weight: 600;
color: #5c4a38;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.m-btn:hover {
background: #fff;
}
.m-btn:active {
transform: translateY(1px);
}
.bar {
position: relative;
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-top: 12px;
}
.m-btn.reset {
margin-left: 42px;
background: none;
border: none;
color: #fff;
font-size: 22px;
letter-spacing: 2px;
cursor: pointer;
opacity: 0.92;
}
.reset:hover {
opacity: 1;
}
.play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.28);
cursor: pointer;
}
.play:hover {
background: rgba(255, 255, 255, 0.4);
}
.play .tri {
width: 0;
height: 0;
margin-left: 4px;
border-left: 14px solid #fff;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
}
.play.on .tri {
display: none;
}
.play.on::before,
.play.on::after {
content: '';
width: 4px;
height: 16px;
border-radius: 1px;
background: #fff;
margin: 0 2.5px;
}
</style>效果拆解
示意图分上下两部分:
- 下半部分:一个 CSS 3D 魔方,点击按钮时某一层绕轴转动 90°。
- 上半部分:三个互相相交的大圆("花朵"骨架),每个大圆内含 3 圈同心环(内/中/外)。六个面 U/D/F/B/R/L 各对应一个 3×3 色点簇,颜色与 3D 魔方各面严格一致。点击转动按钮时:
- 该层对应的轨道环变橙色高亮;
- 高亮环上常驻的 12 个色点沿环滑动换位,全程不离环;
- 其他色点纹丝不动(复原态下同色面自转无视觉变化,直接落位)。
所以"二维平面示意"本质是把魔方的一次拧动,画成「层 → 轨道环、色块 → 环上圆点、循环置换 → 圆环上的环流」。
核心设计
单一数据源,两个视图,同一时钟。
魔方的真实状态(27 个 cubie 的位置 + 朝向)只存一份;Canvas 二维图和 CSS 3D 魔方都从这份状态反推渲染,并在同一个 requestAnimationFrame 里以相同的 easeInOutCubic 和时长推进。上下两部分天然同步,不存在两个动画各跑各的漂移。
3D 魔方:位置 + 朝向矩阵
每个 cubie 保存两个量:
- 位置
(x, y, z),取值-1 / 0 / 1; - 朝向矩阵
m(3×3 整数矩阵,世界 = m · 局部)。
块的最终 transform 是:
text
translate3d(x·S, y·S, z·S) matrix3d(m)1
拧动 = 整数矩阵旋转。转某层时,先用对应的 ±90° 旋转矩阵把该层 9 块的位置和朝向一起乘一遍(逻辑提交);视觉上再用 rotate3d(轴, θ) 从 0° 补间到 90°,最后落回整数矩阵。每一帧渲染的都是「上一状态 × 补间角度」,数值不累积浮点误差,可无限连续拧动而不变形。
二维轨道图:三层轨道环模型
这是整个实现的几何核心:
- 三条轨道轴:三个大圆各绑定一条魔方转轴 —— 顶圆 ↔ y 轴(U/D)、左圆 ↔ x 轴(L/R)、右圆 ↔ z 轴(F/B)。
- 每轴双环:同轴的两个转动层各占一条环 —— 近层(U/L/F)用最内环,远层(D/R/B)用最外环,中环留白。六条轨道环由
TRACK表统一描述(圆心索引 + 环半径),高亮与滑行共用这一份数据源。 - 槽位由轨道环反推(
grid2d):每个 3×3 簇内,格点按它属于哪些转动层来落位——- 角格属于两层 → 坐在两条轨道环的交点上(两圆交点精确求解,取距簇心近者);
- 边格属于一层 → 坐在该层轨道环上、本簇两个角点夹角的中点角处;
- 中心格不属于任何层 → 坐在簇心。
- 由此每条轨道环上常驻该层全部 12 个跨面色点,静止时就骑在环上;拧动时高亮的就是这条环,点沿环流过,落定后仍是同一批槽位、颜色换了一轮。
颜色始终从 3D 状态反推:对面格 (r, c) 找到所在 cubie,再算「哪个局部面法线现在朝外」取对应颜色(visibleFacelets)。
一次拧动 = 轨道环上的环流
拧动前快照所有面格(按 世界坐标|法线 建索引),逻辑提交后重建,按「每个色块从哪来、到哪去」生成滑行计划:
| 类型 | 场景 | 表现 |
|---|---|---|
| 跨面交换 | 转动层 12 个跨面点 | 绕轨道圆心做纯角度滑动:起止槽位都已精确在轨道环上(布局保证),半径恒为环半径,全程不离环 |
| 本面换位 | 被拧面自身换位(打乱后才可见) | 绕簇心极坐标直接过渡;若换位前后颜色相同(同色面自转)则跳过动画直接落位 |
两个关键约束:
- 同向环流:跨面点强制沿轨道环同一方向滑(
flowSign),宁可绕外侧长弧也绝不横穿图心——四个侧簇的顶行/底行/前列/后列在环上像列车一样同向流转; - 只动该动的点:不在转动层内的格、以及颜色无变化的格都不参与补间,避免闪烁和无意义运动。
同一置换在 2D 里是环上滑动,在 3D 里是层绕轴转 90°——两套视图描述的是同一个循环。
踩坑记录
迭代过程中试错数次,记录如下,避免重蹈:
- 跨面点抄近道横穿图心:最初按"最短角差"补间,L↔B 这类对侧交换的点直接穿过图形中心。修复:按各轴四段跨面循环的图上几何排布推定环流方向,跨面点强制同向走外侧长弧。
- "垂直于圆环的三点组"旋转:每行三点各自沿不同半径绕轴圆心滑行,飞行中整组呈径向"辐条"状旋转,视觉上像四组垂直于环的点列在转。教训:同组点必须共享同一条环线轨道,否则点阵在飞行中会"散开"。
- 骑环窗口写反:用
|2e−1| > 0.92做起止过渡窗口,结果权重变成"两端为 1、中段为 0",点在中段乱飞、起止反而被拉上环再瞬跳回槽位。修复:反转窗口方向(0.92 − |2e−1|)。这类"权重方向"错误靠对帧截图很容易发现——落定帧错位说明插值剖面写反了。 - 轨道环选错 / 贴环不彻底:先按"中环"做轨道,与"拧 U 应转最内环、拧 D 应转最外环"的预期不符;随后又只把侧簇的"行"贴上环,U/D 簇的前后行仍不在环上。最终结论:必须让槽位精确落在轨道环上(角=交点、边=中点角),而不是让动画中途去"贴"环——轨道环上常驻 12 点后,"点击按钮 → 环高亮 → 环上的点滑动"三者才严格一致。
- 按钮换行:12 个按钮在 560px 容器里挤成两行,B' 单独占一行。
.ctrl放宽到 640px 解决。
交互
- 自动演示:每步从 12 个动作中随机选择(避免与上一步同面),持续拧动不重样,覆盖各面的轨道高亮。
- 12 个手动按钮:单独拧某一层(带
'表示逆时针),点击后手动模式会关闭自动演示。 - 复原:任意时刻回到复原态。