react-rubiks-cube 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cubex.js +586 -0
- package/dist/cubex.umd.cjs +1 -0
- package/package.json +70 -0
- package/src/AutoCubeScene.jsx +190 -0
- package/src/CubeOverlay.jsx +211 -0
- package/src/InteractiveCubeScene.jsx +196 -0
- package/src/RubiksCube.jsx +109 -0
- package/src/index.d.ts +116 -0
- package/src/index.js +11 -0
- package/src/palette.js +48 -0
- package/src/primitives.jsx +66 -0
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-rubiks-cube",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A beautiful, self-solving 3D Rubik's Cube React component — auto scramble/solve loop, drag-to-turn interaction, and a fully themeable palette. Built on react-three-fiber.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "chahe-dridi",
|
|
8
|
+
"homepage": "https://github.com/chahe-dridi/cubex#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/chahe-dridi/cubex.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/chahe-dridi/cubex/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"rubiks-cube",
|
|
19
|
+
"rubik",
|
|
20
|
+
"cube",
|
|
21
|
+
"three",
|
|
22
|
+
"three-js",
|
|
23
|
+
"react-three-fiber",
|
|
24
|
+
"3d",
|
|
25
|
+
"animation",
|
|
26
|
+
"hero",
|
|
27
|
+
"component"
|
|
28
|
+
],
|
|
29
|
+
"main": "./dist/cubex.umd.cjs",
|
|
30
|
+
"module": "./dist/cubex.js",
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./src/index.d.ts",
|
|
35
|
+
"import": "./dist/cubex.js",
|
|
36
|
+
"require": "./dist/cubex.umd.cjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"scripts": {
|
|
47
|
+
"dev": "vite",
|
|
48
|
+
"build": "vite build",
|
|
49
|
+
"preview": "vite preview --outDir example-dist",
|
|
50
|
+
"prepare": "vite build"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@react-three/drei": ">=9.0.0",
|
|
54
|
+
"@react-three/fiber": ">=8.0.0",
|
|
55
|
+
"framer-motion": ">=10.0.0",
|
|
56
|
+
"react": ">=18.0.0",
|
|
57
|
+
"react-dom": ">=18.0.0",
|
|
58
|
+
"three": ">=0.150.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@react-three/drei": "^9.122.0",
|
|
62
|
+
"@react-three/fiber": "^8.18.0",
|
|
63
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
64
|
+
"framer-motion": "^11.3.0",
|
|
65
|
+
"react": "^18.3.1",
|
|
66
|
+
"react-dom": "^18.3.1",
|
|
67
|
+
"three": "^0.186.1",
|
|
68
|
+
"vite": "^5.3.4"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import { useFrame } from '@react-three/fiber'
|
|
3
|
+
import * as THREE from 'three'
|
|
4
|
+
import { Cubelet, smoother, useCubePositions } from './primitives.jsx'
|
|
5
|
+
|
|
6
|
+
// ── Move helpers ──────────────────────────────────────────
|
|
7
|
+
const AXES = ['x', 'y', 'z']
|
|
8
|
+
const randDir = () => (Math.random() < 0.5 ? 1 : -1)
|
|
9
|
+
|
|
10
|
+
// One random legal move, avoiding an immediate inverse of the last move (which
|
|
11
|
+
// would read like an undo). Mostly quarter turns, occasionally a half turn.
|
|
12
|
+
function randMove(last) {
|
|
13
|
+
for (let i = 0; i < 8; i++) {
|
|
14
|
+
const axis = AXES[(Math.random() * 3) | 0]
|
|
15
|
+
const layer = [-1, 0, 1][(Math.random() * 3) | 0]
|
|
16
|
+
const dir = randDir()
|
|
17
|
+
const quarter = Math.random() < 0.82 ? 1 : 2
|
|
18
|
+
if (last && last.axis === axis && last.layer === layer && last.dir === -dir && last.quarter === quarter) continue
|
|
19
|
+
return { axis, layer, dir, quarter }
|
|
20
|
+
}
|
|
21
|
+
return { axis: 'y', layer: 1, dir: 1, quarter: 1 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Cubelets in the given layer of the given axis (null layer = all cubelets).
|
|
25
|
+
function layerChildren(children, axis, layer) {
|
|
26
|
+
if (layer === null) return children
|
|
27
|
+
return children.filter(c => {
|
|
28
|
+
const v = c.position[axis]
|
|
29
|
+
return layer === 0 ? Math.abs(v) < 0.5 : layer > 0 ? v > 0.5 : v < -0.5
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Reusable quaternions — never re-allocated per frame.
|
|
34
|
+
const _dq = new THREE.Quaternion()
|
|
35
|
+
const _pq1 = new THREE.Quaternion()
|
|
36
|
+
const _pq2 = new THREE.Quaternion()
|
|
37
|
+
const _pq3 = new THREE.Quaternion()
|
|
38
|
+
|
|
39
|
+
// Three fixed precession axes, chosen incommensurate so the whole-cube tumble
|
|
40
|
+
// never visibly repeats within a practical viewing window.
|
|
41
|
+
const PREC1 = new THREE.Vector3(0.58, 0.77, -0.27).normalize()
|
|
42
|
+
const PREC2 = new THREE.Vector3(-0.41, 0.35, 0.84).normalize()
|
|
43
|
+
const PREC3 = new THREE.Vector3(0.22, -0.62, 0.75).normalize()
|
|
44
|
+
|
|
45
|
+
// Build one scramble "step":
|
|
46
|
+
// • ~16% a 360° double-spin flourish (identity → not recorded)
|
|
47
|
+
// • ~38% two parallel layers of the SAME axis turning together
|
|
48
|
+
// • else a single quarter/half turn
|
|
49
|
+
// `record` is what must be reversed later to solve (null for the flourish).
|
|
50
|
+
function buildScrambleStep(prev) {
|
|
51
|
+
const roll = Math.random()
|
|
52
|
+
if (roll < 0.16) {
|
|
53
|
+
const move = { axis: AXES[(Math.random() * 3) | 0], layer: [-1, 0, 1][(Math.random() * 3) | 0], dir: randDir(), quarter: 4 }
|
|
54
|
+
return { moves: [move], record: null }
|
|
55
|
+
}
|
|
56
|
+
if (roll < 0.54) {
|
|
57
|
+
const axis = AXES[(Math.random() * 3) | 0]
|
|
58
|
+
const ls = [-1, 0, 1]
|
|
59
|
+
const i1 = (Math.random() * 3) | 0
|
|
60
|
+
let i2 = (Math.random() * 3) | 0
|
|
61
|
+
while (i2 === i1) i2 = (Math.random() * 3) | 0
|
|
62
|
+
const moves = [i1, i2].map(i => ({ axis, layer: ls[i], dir: randDir(), quarter: Math.random() < 0.25 ? 2 : 1 }))
|
|
63
|
+
return { moves, record: moves }
|
|
64
|
+
}
|
|
65
|
+
const move = randMove(prev)
|
|
66
|
+
return { moves: [move], record: [move] }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Re-parent a layer's cubelets onto a rotation pivot and arm its tween. Each
|
|
70
|
+
// slice gets its own duration so two concurrent turns finish at different times.
|
|
71
|
+
function startSlot(slot, rotEl, cube, move) {
|
|
72
|
+
layerChildren(cube.children.slice(), move.axis, move.layer).forEach(c => rotEl.attach(c))
|
|
73
|
+
slot.ref = rotEl
|
|
74
|
+
slot.axis = move.axis
|
|
75
|
+
slot.target = (Math.PI / 2) * move.dir * move.quarter
|
|
76
|
+
slot.elapsed = 0
|
|
77
|
+
slot.duration = 0.42 * move.quarter + 0.16 + Math.random() * 0.3
|
|
78
|
+
slot.active = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ══════════════════════════════════════════════════════════
|
|
82
|
+
// Auto-playing cube: whole-cube tumble + scramble→solve loop.
|
|
83
|
+
// Renders inside a parent <Canvas>. `scrollRef` is an optional ref holding a
|
|
84
|
+
// 0..1 scroll progress used to tilt the cube.
|
|
85
|
+
// ══════════════════════════════════════════════════════════
|
|
86
|
+
export function AutoCubeScene({ palette, scrollRef, scale = 0.72, tumble = true, autoplay = true }) {
|
|
87
|
+
const tiltRef = useRef() // outermost: scroll tilt only
|
|
88
|
+
const pivotRef = useRef() // whole-cube tumble + bob + scale
|
|
89
|
+
const cubeRef = useRef() // holds the 27 cubelets at integer grid positions
|
|
90
|
+
const rotRefA = useRef() // rotation pivot for slice A
|
|
91
|
+
const rotRefB = useRef() // rotation pivot for slice B (concurrent turns)
|
|
92
|
+
|
|
93
|
+
const spinDir = useRef(new THREE.Vector3(0.3, 0.88, 0.36).normalize())
|
|
94
|
+
const slots = useRef([
|
|
95
|
+
{ active: false, ref: null, axis: 'y', target: 0, elapsed: 0, duration: 0 },
|
|
96
|
+
{ active: false, ref: null, axis: 'y', target: 0, elapsed: 0, duration: 0 },
|
|
97
|
+
])
|
|
98
|
+
// Player state machine: scramble a handful of moves, then solve back, forever.
|
|
99
|
+
const P = useRef({ phase: 'scramble', history: [], remaining: 7, idle: 1.0, prevMove: null })
|
|
100
|
+
|
|
101
|
+
const positions = useCubePositions()
|
|
102
|
+
|
|
103
|
+
useFrame((state, delta) => {
|
|
104
|
+
if (!pivotRef.current || !tiltRef.current || !cubeRef.current || !rotRefA.current || !rotRefB.current) return
|
|
105
|
+
const t = state.clock.elapsedTime
|
|
106
|
+
const cube = cubeRef.current
|
|
107
|
+
const m = P.current
|
|
108
|
+
const S = slots.current
|
|
109
|
+
|
|
110
|
+
// ── Whole-cube tumble on the PARENT pivot (cancels out of slice maths) ──
|
|
111
|
+
if (tumble) {
|
|
112
|
+
_pq1.setFromAxisAngle(PREC1, 0.2 * delta)
|
|
113
|
+
_pq2.setFromAxisAngle(PREC2, 0.125 * delta)
|
|
114
|
+
_pq3.setFromAxisAngle(PREC3, 0.075 * delta)
|
|
115
|
+
spinDir.current.applyQuaternion(_pq1).applyQuaternion(_pq2).applyQuaternion(_pq3).normalize()
|
|
116
|
+
|
|
117
|
+
const swell = Math.sin(t * 0.11) * 0.5 + Math.sin(t * 0.047 + 1.3) * 0.3 + Math.sin(t * 0.19 + 0.6) * 0.18
|
|
118
|
+
const speed = 0.72 + swell * 0.34
|
|
119
|
+
_dq.setFromAxisAngle(spinDir.current, speed * delta)
|
|
120
|
+
pivotRef.current.quaternion.premultiply(_dq)
|
|
121
|
+
pivotRef.current.position.y = Math.sin(t * 0.6) * 0.09
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Scroll tilt on the outer wrapper ──
|
|
125
|
+
if (scrollRef) {
|
|
126
|
+
const sp = scrollRef.current || 0
|
|
127
|
+
const tiltK = 1 - Math.pow(0.05, delta)
|
|
128
|
+
tiltRef.current.rotation.x = THREE.MathUtils.lerp(tiltRef.current.rotation.x, sp * -0.36, tiltK)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Auto slice player: scramble → solve loop, 1 or 2 turns at a time ──
|
|
132
|
+
const busy = S[0].active || S[1].active
|
|
133
|
+
if (autoplay && !busy) {
|
|
134
|
+
m.idle -= delta
|
|
135
|
+
if (m.idle <= 0) {
|
|
136
|
+
let moves
|
|
137
|
+
if (m.phase === 'scramble') {
|
|
138
|
+
const step = buildScrambleStep(m.prevMove)
|
|
139
|
+
moves = step.moves
|
|
140
|
+
if (step.record) m.history.push(step.record)
|
|
141
|
+
if (m.history.length >= m.remaining) m.phase = 'solve'
|
|
142
|
+
} else {
|
|
143
|
+
const step = m.history.pop()
|
|
144
|
+
moves = step.map(mm => ({ axis: mm.axis, layer: mm.layer, dir: -mm.dir, quarter: mm.quarter }))
|
|
145
|
+
}
|
|
146
|
+
m.prevMove = moves[moves.length - 1]
|
|
147
|
+
startSlot(S[0], rotRefA.current, cube, moves[0])
|
|
148
|
+
if (moves[1]) startSlot(S[1], rotRefB.current, cube, moves[1])
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Advance each active slot independently (desynced finish).
|
|
153
|
+
for (const s of S) {
|
|
154
|
+
if (!s.active) continue
|
|
155
|
+
s.elapsed = Math.min(s.elapsed + delta, s.duration)
|
|
156
|
+
s.ref.rotation[s.axis] = s.target * smoother(s.elapsed / s.duration)
|
|
157
|
+
if (s.elapsed >= s.duration) {
|
|
158
|
+
s.ref.rotation[s.axis] = s.target
|
|
159
|
+
s.ref.children.slice().forEach(c => cube.attach(c))
|
|
160
|
+
s.ref.rotation.set(0, 0, 0)
|
|
161
|
+
s.active = false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step just completed → schedule the next.
|
|
166
|
+
if (busy && !S[0].active && !S[1].active) {
|
|
167
|
+
if (m.phase === 'solve' && m.history.length === 0) {
|
|
168
|
+
m.phase = 'scramble'
|
|
169
|
+
m.remaining = 7 + ((Math.random() * 6) | 0)
|
|
170
|
+
m.idle = 1.8 + Math.random() * 1.5 // linger on the solved cube
|
|
171
|
+
} else {
|
|
172
|
+
m.idle = 0.12 + Math.random() * 0.4 // brief pause between turns
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<group ref={tiltRef}>
|
|
179
|
+
<group ref={pivotRef} scale={[scale, scale, scale]}>
|
|
180
|
+
<group ref={cubeRef}>
|
|
181
|
+
{positions.map(([x, y, z]) => (
|
|
182
|
+
<Cubelet key={`${x + 1}${y + 1}${z + 1}`} px={x} py={y} pz={z} palette={palette} />
|
|
183
|
+
))}
|
|
184
|
+
</group>
|
|
185
|
+
<group ref={rotRefA} />
|
|
186
|
+
<group ref={rotRefB} />
|
|
187
|
+
</group>
|
|
188
|
+
</group>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Canvas } from '@react-three/fiber'
|
|
4
|
+
import { OrbitControls } from '@react-three/drei'
|
|
5
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
6
|
+
import { Lights } from './primitives.jsx'
|
|
7
|
+
import { InteractiveCubeScene, MOVE_GROUPS } from './InteractiveCubeScene.jsx'
|
|
8
|
+
|
|
9
|
+
// Small reusable move button with a hover colour accent.
|
|
10
|
+
function MoveBtn({ label, accent, onClick }) {
|
|
11
|
+
const [hov, setHov] = useState(false)
|
|
12
|
+
const isPrime = label.includes("'")
|
|
13
|
+
return (
|
|
14
|
+
<motion.button
|
|
15
|
+
whileTap={{ scale: 0.9 }}
|
|
16
|
+
onClick={onClick}
|
|
17
|
+
onMouseEnter={() => setHov(true)}
|
|
18
|
+
onMouseLeave={() => setHov(false)}
|
|
19
|
+
style={{
|
|
20
|
+
flex: 1,
|
|
21
|
+
border: `1px solid ${hov ? accent : 'rgba(255,255,255,0.08)'}`,
|
|
22
|
+
background: hov ? `${accent}22` : 'rgba(255,255,255,0.03)',
|
|
23
|
+
color: hov ? '#fff' : 'rgba(240,235,226,0.65)',
|
|
24
|
+
borderRadius: 6,
|
|
25
|
+
padding: '5px 0',
|
|
26
|
+
fontSize: 11,
|
|
27
|
+
fontWeight: 700,
|
|
28
|
+
cursor: 'pointer',
|
|
29
|
+
fontFamily: "'DM Mono', monospace",
|
|
30
|
+
transition: 'all 0.12s',
|
|
31
|
+
textAlign: 'center',
|
|
32
|
+
letterSpacing: isPrime ? '-0.02em' : '0.04em',
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
</motion.button>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ══════════════════════════════════════════════════════════
|
|
41
|
+
// Fullscreen interactive overlay: a large drag-to-turn cube plus a control
|
|
42
|
+
// panel of Rubik's-notation buttons. Rendered into document.body via a portal.
|
|
43
|
+
// ══════════════════════════════════════════════════════════
|
|
44
|
+
export function CubeOverlay({ onClose, palette, accent = '#E05C28', cameraPosition = [5, 4, 5], fov = 34 }) {
|
|
45
|
+
const [mode, setMode] = useState('orbit')
|
|
46
|
+
const modeRef = useRef('orbit')
|
|
47
|
+
const queueRef = useRef([])
|
|
48
|
+
|
|
49
|
+
const changeMode = useCallback(m => {
|
|
50
|
+
modeRef.current = m
|
|
51
|
+
setMode(m)
|
|
52
|
+
}, [])
|
|
53
|
+
const pushMove = useCallback(move => queueRef.current.push(move), [])
|
|
54
|
+
|
|
55
|
+
return createPortal(
|
|
56
|
+
<AnimatePresence>
|
|
57
|
+
<motion.div
|
|
58
|
+
key="cube-overlay"
|
|
59
|
+
initial={{ opacity: 0 }}
|
|
60
|
+
animate={{ opacity: 1 }}
|
|
61
|
+
exit={{ opacity: 0 }}
|
|
62
|
+
transition={{ duration: 0.2 }}
|
|
63
|
+
style={{
|
|
64
|
+
position: 'fixed',
|
|
65
|
+
inset: 0,
|
|
66
|
+
zIndex: 9999,
|
|
67
|
+
background: 'rgba(7, 6, 5, 0.65)',
|
|
68
|
+
backdropFilter: 'blur(20px)',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
gap: 28,
|
|
73
|
+
fontFamily: 'system-ui, sans-serif',
|
|
74
|
+
}}
|
|
75
|
+
onClick={e => {
|
|
76
|
+
if (e.target === e.currentTarget) onClose()
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<button
|
|
80
|
+
onClick={onClose}
|
|
81
|
+
aria-label="Close"
|
|
82
|
+
style={{
|
|
83
|
+
position: 'absolute',
|
|
84
|
+
top: 20,
|
|
85
|
+
right: 22,
|
|
86
|
+
background: 'rgba(255,255,255,0.04)',
|
|
87
|
+
border: '1px solid rgba(255,255,255,0.09)',
|
|
88
|
+
color: 'rgba(255,255,255,0.35)',
|
|
89
|
+
borderRadius: 9,
|
|
90
|
+
width: 36,
|
|
91
|
+
height: 36,
|
|
92
|
+
fontSize: 18,
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
display: 'flex',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
transition: 'all 0.15s',
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
×
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
{/* 3-D canvas */}
|
|
104
|
+
<motion.div
|
|
105
|
+
initial={{ scale: 0.6, opacity: 0 }}
|
|
106
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
107
|
+
exit={{ scale: 0.6, opacity: 0 }}
|
|
108
|
+
transition={{ type: 'spring', damping: 20, stiffness: 180 }}
|
|
109
|
+
style={{
|
|
110
|
+
width: 'min(460px, 78vw)',
|
|
111
|
+
height: 'min(460px, 78vw)',
|
|
112
|
+
flexShrink: 0,
|
|
113
|
+
borderRadius: 20,
|
|
114
|
+
cursor: mode === 'move' ? 'crosshair' : 'grab',
|
|
115
|
+
overflow: 'hidden',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<Canvas flat camera={{ position: cameraPosition, fov }} gl={{ antialias: true, alpha: true }} style={{ background: 'transparent' }}>
|
|
119
|
+
<Lights ambient={0.4} />
|
|
120
|
+
<InteractiveCubeScene palette={palette} queueRef={queueRef} modeRef={modeRef} />
|
|
121
|
+
<OrbitControls enabled={mode === 'orbit'} autoRotate={false} enablePan={false} enableZoom={false} dampingFactor={0.08} enableDamping rotateSpeed={0.65} />
|
|
122
|
+
</Canvas>
|
|
123
|
+
</motion.div>
|
|
124
|
+
|
|
125
|
+
{/* Control panel */}
|
|
126
|
+
<motion.div
|
|
127
|
+
initial={{ x: 44, opacity: 0 }}
|
|
128
|
+
animate={{ x: 0, opacity: 1 }}
|
|
129
|
+
exit={{ x: 44, opacity: 0 }}
|
|
130
|
+
transition={{ type: 'spring', damping: 22, stiffness: 180, delay: 0.06 }}
|
|
131
|
+
style={{
|
|
132
|
+
width: 196,
|
|
133
|
+
background: 'rgba(255,255,255,0.025)',
|
|
134
|
+
border: '1px solid rgba(255,255,255,0.07)',
|
|
135
|
+
borderRadius: 16,
|
|
136
|
+
overflow: 'hidden',
|
|
137
|
+
flexShrink: 0,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<div style={{ padding: '14px 14px 10px', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
141
|
+
<p style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.22)', marginBottom: 10 }}>Controls</p>
|
|
142
|
+
<div style={{ display: 'flex', background: 'rgba(0,0,0,0.35)', borderRadius: 9, padding: 3, gap: 2 }}>
|
|
143
|
+
{[
|
|
144
|
+
{ id: 'orbit', icon: '⟳', label: 'Orbit' },
|
|
145
|
+
{ id: 'move', icon: '✦', label: 'Move' },
|
|
146
|
+
].map(({ id, icon, label }) => (
|
|
147
|
+
<button
|
|
148
|
+
key={id}
|
|
149
|
+
onClick={() => changeMode(id)}
|
|
150
|
+
style={{
|
|
151
|
+
flex: 1,
|
|
152
|
+
border: 'none',
|
|
153
|
+
borderRadius: 7,
|
|
154
|
+
padding: '6px 0',
|
|
155
|
+
fontSize: 10.5,
|
|
156
|
+
fontWeight: 700,
|
|
157
|
+
cursor: 'pointer',
|
|
158
|
+
display: 'flex',
|
|
159
|
+
alignItems: 'center',
|
|
160
|
+
justifyContent: 'center',
|
|
161
|
+
gap: 4,
|
|
162
|
+
transition: 'all 0.15s',
|
|
163
|
+
background: mode === id ? accent : 'transparent',
|
|
164
|
+
color: mode === id ? '#fff' : 'rgba(255,255,255,0.35)',
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<span style={{ fontSize: 11 }}>{icon}</span>
|
|
168
|
+
{label}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
<p style={{ marginTop: 8, fontSize: 9.5, lineHeight: 1.45, color: 'rgba(255,255,255,0.2)' }}>
|
|
173
|
+
{mode === 'orbit' ? 'Drag to orbit · buttons to move' : 'Swipe a face to rotate · or use buttons'}
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{MOVE_GROUPS.map(({ id, label, accent: groupAccent, pairs }, gi) => (
|
|
178
|
+
<div key={id} style={{ borderBottom: gi < MOVE_GROUPS.length - 1 ? '1px solid rgba(255,255,255,0.045)' : 'none', padding: '8px 10px 9px' }}>
|
|
179
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
|
|
180
|
+
<span style={{ width: 5, height: 5, borderRadius: '50%', background: groupAccent, flexShrink: 0, opacity: 0.85 }} />
|
|
181
|
+
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.2)' }}>{label}</span>
|
|
182
|
+
</div>
|
|
183
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
184
|
+
{pairs.map(([cw, ccw], pi) => (
|
|
185
|
+
<div key={pi} style={{ display: 'flex', gap: 3 }}>
|
|
186
|
+
{[cw, ccw].map(({ n, axis, layer, dir }) => (
|
|
187
|
+
<MoveBtn key={n} label={n} accent={groupAccent} onClick={() => pushMove({ axis, layer, dir })} />
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
|
|
195
|
+
<div style={{ padding: '8px 14px 12px', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
|
196
|
+
<p style={{ fontSize: 9, color: 'rgba(255,255,255,0.15)', lineHeight: 1.5, letterSpacing: '0.04em' }}>
|
|
197
|
+
Notation: prime (′) = counter-clockwise
|
|
198
|
+
<br />
|
|
199
|
+
U=Up D=Down R=Right L=Left
|
|
200
|
+
<br />
|
|
201
|
+
F=Front B=Back M=Mid E=Eq S=Std
|
|
202
|
+
<br />
|
|
203
|
+
x/y/z = whole-cube rotation
|
|
204
|
+
</p>
|
|
205
|
+
</div>
|
|
206
|
+
</motion.div>
|
|
207
|
+
</motion.div>
|
|
208
|
+
</AnimatePresence>,
|
|
209
|
+
document.body,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react'
|
|
2
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
3
|
+
import * as THREE from 'three'
|
|
4
|
+
import { Cubelet, smoother, useCubePositions } from './primitives.jsx'
|
|
5
|
+
|
|
6
|
+
// Which cubelet layer a given axis value sits in.
|
|
7
|
+
function layerOf(v) {
|
|
8
|
+
return v > 0.5 ? 1 : v < -0.5 ? -1 : 0
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Map (faceNormal, cubelet position, screen drag delta) → { axis, layer, dir }.
|
|
12
|
+
// Picks whichever in-face rotation axis best matches the drag direction on
|
|
13
|
+
// screen, then the layer that cubelet belongs to.
|
|
14
|
+
function computeSlideMove(faceNormal, cubeletPos, dragDx, dragDy, camera) {
|
|
15
|
+
const axes = ['x', 'y', 'z']
|
|
16
|
+
const faceAxis = axes.reduce((best, ax) => (Math.abs(faceNormal[ax]) > Math.abs(faceNormal[best]) ? ax : best), 'x')
|
|
17
|
+
|
|
18
|
+
const candidates = axes.filter(a => a !== faceAxis)
|
|
19
|
+
const len = Math.sqrt(dragDx * dragDx + dragDy * dragDy)
|
|
20
|
+
if (len < 1) return null
|
|
21
|
+
|
|
22
|
+
const results = candidates.map(rotAxis => {
|
|
23
|
+
const A = new THREE.Vector3(rotAxis === 'x' ? 1 : 0, rotAxis === 'y' ? 1 : 0, rotAxis === 'z' ? 1 : 0)
|
|
24
|
+
const tangent = new THREE.Vector3().crossVectors(A, faceNormal.clone().normalize())
|
|
25
|
+
const camT = tangent.clone().applyMatrix4(camera.matrixWorldInverse).normalize()
|
|
26
|
+
const dot = (camT.x * dragDx + -camT.y * dragDy) / len
|
|
27
|
+
return { rotAxis, dot }
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const winner = results.reduce((a, b) => (Math.abs(a.dot) > Math.abs(b.dot) ? a : b))
|
|
31
|
+
const layer = layerOf(cubeletPos[winner.rotAxis])
|
|
32
|
+
return { axis: winner.rotAxis, layer, dir: winner.dot > 0 ? 1 : -1 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ══════════════════════════════════════════════════════════
|
|
36
|
+
// Interactive cube: drag a face to turn a slice, or feed moves through a
|
|
37
|
+
// queue ref (from external buttons). Auto-solves back to the start after
|
|
38
|
+
// `autoReturnAfter` seconds of idle. Renders inside a parent <Canvas>.
|
|
39
|
+
// ══════════════════════════════════════════════════════════
|
|
40
|
+
export function InteractiveCubeScene({ palette, queueRef, modeRef, autoReturnAfter = 4, idleSpin = 0.28 }) {
|
|
41
|
+
const orbitRef = useRef() // outer wrapper; only the idle auto-spin touches this
|
|
42
|
+
const cubeRef = useRef()
|
|
43
|
+
const rotRef = useRef()
|
|
44
|
+
const anim = useRef({ active: false, axis: 'y', start: 0, target: 0, elapsed: 0, duration: 0.3 })
|
|
45
|
+
const dragRef = useRef(null)
|
|
46
|
+
const spinAngle = useRef(0)
|
|
47
|
+
const history = useRef([]) // moves applied so far (for auto-return)
|
|
48
|
+
const idleTimer = useRef(0)
|
|
49
|
+
const isReturning = useRef(false)
|
|
50
|
+
const { camera } = useThree()
|
|
51
|
+
|
|
52
|
+
const positions = useCubePositions()
|
|
53
|
+
|
|
54
|
+
useFrame((_, delta) => {
|
|
55
|
+
const a = anim.current
|
|
56
|
+
const cube = cubeRef.current
|
|
57
|
+
const rot = rotRef.current
|
|
58
|
+
if (!cube || !rot) return
|
|
59
|
+
|
|
60
|
+
// Dequeue next move.
|
|
61
|
+
if (!a.active && queueRef.current.length > 0) {
|
|
62
|
+
const move = queueRef.current.shift()
|
|
63
|
+
const { axis, layer, dir } = move
|
|
64
|
+
if (!isReturning.current) {
|
|
65
|
+
history.current.push(move)
|
|
66
|
+
idleTimer.current = 0
|
|
67
|
+
}
|
|
68
|
+
const all = cube.children.slice()
|
|
69
|
+
const toRot =
|
|
70
|
+
layer === null
|
|
71
|
+
? all
|
|
72
|
+
: all.filter(c => {
|
|
73
|
+
const v = c.position[axis]
|
|
74
|
+
return layer === 0 ? Math.abs(v) < 0.5 : layer > 0 ? v > 0.5 : v < -0.5
|
|
75
|
+
})
|
|
76
|
+
toRot.forEach(c => rot.attach(c))
|
|
77
|
+
anim.current = { active: true, axis, start: 0, target: (Math.PI / 2) * dir, elapsed: 0, duration: 0.42 }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tween the active move — smootherstep for a silky, jerk-free turn.
|
|
81
|
+
if (a.active) {
|
|
82
|
+
a.elapsed = Math.min(a.elapsed + delta, a.duration)
|
|
83
|
+
const ease = smoother(a.elapsed / a.duration)
|
|
84
|
+
rot.rotation[a.axis] = a.start + (a.target - a.start) * ease
|
|
85
|
+
if (a.elapsed >= a.duration) {
|
|
86
|
+
rot.rotation[a.axis] = a.target
|
|
87
|
+
rot.children.slice().forEach(c => cube.attach(c))
|
|
88
|
+
rot.rotation.set(0, 0, 0)
|
|
89
|
+
a.active = false
|
|
90
|
+
if (isReturning.current && queueRef.current.length === 0) {
|
|
91
|
+
isReturning.current = false
|
|
92
|
+
history.current = []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Auto-return: after idle with scramble history, reverse every move.
|
|
98
|
+
if (autoReturnAfter > 0 && !a.active && queueRef.current.length === 0 && history.current.length > 0 && !isReturning.current) {
|
|
99
|
+
idleTimer.current += delta
|
|
100
|
+
if (idleTimer.current >= autoReturnAfter) {
|
|
101
|
+
isReturning.current = true
|
|
102
|
+
idleTimer.current = 0
|
|
103
|
+
const reversed = history.current
|
|
104
|
+
.slice()
|
|
105
|
+
.reverse()
|
|
106
|
+
.map(m => ({ ...m, dir: -m.dir }))
|
|
107
|
+
queueRef.current.push(...reversed)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Idle auto-spin — on the OUTER wrapper only, never on cubeRef.
|
|
112
|
+
if (idleSpin > 0 && !a.active && queueRef.current.length === 0 && orbitRef.current) {
|
|
113
|
+
spinAngle.current += delta * idleSpin
|
|
114
|
+
orbitRef.current.rotation.y = spinAngle.current
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const onPointerDown = useCallback(
|
|
119
|
+
e => {
|
|
120
|
+
if (modeRef && modeRef.current !== 'move') return
|
|
121
|
+
e.stopPropagation()
|
|
122
|
+
const normalWorld = e.face.normal.clone().transformDirection(e.object.matrixWorld)
|
|
123
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, normalWorld, cubeletPos: e.object.position.clone() }
|
|
124
|
+
},
|
|
125
|
+
[modeRef],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const onPointerUp = useCallback(
|
|
129
|
+
e => {
|
|
130
|
+
const drag = dragRef.current
|
|
131
|
+
if (!drag) return
|
|
132
|
+
dragRef.current = null
|
|
133
|
+
const dx = e.clientX - drag.startX
|
|
134
|
+
const dy = e.clientY - drag.startY
|
|
135
|
+
if (Math.sqrt(dx * dx + dy * dy) < 12) return
|
|
136
|
+
const move = computeSlideMove(drag.normalWorld, drag.cubeletPos, dx, dy, camera)
|
|
137
|
+
if (move) queueRef.current.push(move)
|
|
138
|
+
},
|
|
139
|
+
[camera, queueRef],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<group ref={orbitRef}>
|
|
144
|
+
<group ref={cubeRef} onPointerDown={onPointerDown} onPointerUp={onPointerUp}>
|
|
145
|
+
{positions.map(([x, y, z]) => (
|
|
146
|
+
<Cubelet key={`${x + 1}${y + 1}${z + 1}`} px={x} py={y} pz={z} palette={palette} />
|
|
147
|
+
))}
|
|
148
|
+
</group>
|
|
149
|
+
<group ref={rotRef} />
|
|
150
|
+
</group>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Rubik's-notation move groups, exported so consumers can build their own UI.
|
|
155
|
+
export const MOVE_GROUPS = [
|
|
156
|
+
{
|
|
157
|
+
id: 'y',
|
|
158
|
+
label: 'Y · Layer',
|
|
159
|
+
accent: '#f59e0b',
|
|
160
|
+
pairs: [
|
|
161
|
+
[{ n: 'U', axis: 'y', layer: 1, dir: -1 }, { n: "U'", axis: 'y', layer: 1, dir: 1 }],
|
|
162
|
+
[{ n: 'E', axis: 'y', layer: 0, dir: 1 }, { n: "E'", axis: 'y', layer: 0, dir: -1 }],
|
|
163
|
+
[{ n: 'D', axis: 'y', layer: -1, dir: 1 }, { n: "D'", axis: 'y', layer: -1, dir: -1 }],
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'x',
|
|
168
|
+
label: 'X · Column',
|
|
169
|
+
accent: '#ef4444',
|
|
170
|
+
pairs: [
|
|
171
|
+
[{ n: 'R', axis: 'x', layer: 1, dir: -1 }, { n: "R'", axis: 'x', layer: 1, dir: 1 }],
|
|
172
|
+
[{ n: 'M', axis: 'x', layer: 0, dir: 1 }, { n: "M'", axis: 'x', layer: 0, dir: -1 }],
|
|
173
|
+
[{ n: 'L', axis: 'x', layer: -1, dir: 1 }, { n: "L'", axis: 'x', layer: -1, dir: -1 }],
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'z',
|
|
178
|
+
label: 'Z · Depth',
|
|
179
|
+
accent: '#22c55e',
|
|
180
|
+
pairs: [
|
|
181
|
+
[{ n: 'F', axis: 'z', layer: 1, dir: -1 }, { n: "F'", axis: 'z', layer: 1, dir: 1 }],
|
|
182
|
+
[{ n: 'S', axis: 'z', layer: 0, dir: -1 }, { n: "S'", axis: 'z', layer: 0, dir: 1 }],
|
|
183
|
+
[{ n: 'B', axis: 'z', layer: -1, dir: 1 }, { n: "B'", axis: 'z', layer: -1, dir: -1 }],
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'rot',
|
|
188
|
+
label: 'Rotate · Whole',
|
|
189
|
+
accent: '#a78bfa',
|
|
190
|
+
pairs: [
|
|
191
|
+
[{ n: 'x', axis: 'x', layer: null, dir: -1 }, { n: "x'", axis: 'x', layer: null, dir: 1 }],
|
|
192
|
+
[{ n: 'y', axis: 'y', layer: null, dir: -1 }, { n: "y'", axis: 'y', layer: null, dir: 1 }],
|
|
193
|
+
[{ n: 'z', axis: 'z', layer: null, dir: -1 }, { n: "z'", axis: 'z', layer: null, dir: 1 }],
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
]
|