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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from 'react'
|
|
2
|
+
import { Canvas } from '@react-three/fiber'
|
|
3
|
+
import { Lights } from './primitives.jsx'
|
|
4
|
+
import { AutoCubeScene } from './AutoCubeScene.jsx'
|
|
5
|
+
import { CubeOverlay } from './CubeOverlay.jsx'
|
|
6
|
+
import { resolvePalette } from './palette.js'
|
|
7
|
+
|
|
8
|
+
// ══════════════════════════════════════════════════════════
|
|
9
|
+
// <RubiksCube /> — the drop-in component.
|
|
10
|
+
//
|
|
11
|
+
// Renders a self-contained auto-solving 3D cube. Optionally click it to open a
|
|
12
|
+
// fullscreen interactive overlay (drag a face to turn, or use the buttons).
|
|
13
|
+
// ══════════════════════════════════════════════════════════
|
|
14
|
+
export function RubiksCube({
|
|
15
|
+
size = 400,
|
|
16
|
+
height,
|
|
17
|
+
palette: paletteProp,
|
|
18
|
+
accent = '#E05C28',
|
|
19
|
+
autoplay = true,
|
|
20
|
+
tumble = true,
|
|
21
|
+
interactive = true,
|
|
22
|
+
scrollTilt = false,
|
|
23
|
+
scale = 0.72,
|
|
24
|
+
cameraPosition = [7, 6, 7],
|
|
25
|
+
fov = 24,
|
|
26
|
+
background = 'transparent',
|
|
27
|
+
ambient = 0.22,
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
onExpand,
|
|
31
|
+
...rest
|
|
32
|
+
}) {
|
|
33
|
+
const scrollRef = useRef(0)
|
|
34
|
+
const wrapRef = useRef(null)
|
|
35
|
+
const [expanded, setExpanded] = useState(false)
|
|
36
|
+
|
|
37
|
+
const palette = resolvePalette(paletteProp)
|
|
38
|
+
const w = typeof size === 'number' ? `${size}px` : size
|
|
39
|
+
const h = height != null ? (typeof height === 'number' ? `${height}px` : height) : w
|
|
40
|
+
|
|
41
|
+
// Optional scroll-driven tilt: 0..1 progress as the element scrolls up.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!scrollTilt) return
|
|
44
|
+
const el = wrapRef.current
|
|
45
|
+
const update = () => {
|
|
46
|
+
if (!el) return
|
|
47
|
+
const rect = el.getBoundingClientRect()
|
|
48
|
+
const vh = window.innerHeight || 1
|
|
49
|
+
scrollRef.current = Math.max(0, Math.min(1, (vh - rect.top) / (vh + rect.height) - 0.1))
|
|
50
|
+
}
|
|
51
|
+
update()
|
|
52
|
+
window.addEventListener('scroll', update, { passive: true })
|
|
53
|
+
window.addEventListener('resize', update)
|
|
54
|
+
return () => {
|
|
55
|
+
window.removeEventListener('scroll', update)
|
|
56
|
+
window.removeEventListener('resize', update)
|
|
57
|
+
}
|
|
58
|
+
}, [scrollTilt])
|
|
59
|
+
|
|
60
|
+
// Lock body scroll while the overlay is open + close on Escape.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!expanded) return
|
|
63
|
+
const scrollY = window.scrollY
|
|
64
|
+
document.body.style.position = 'fixed'
|
|
65
|
+
document.body.style.top = `-${scrollY}px`
|
|
66
|
+
document.body.style.width = '100%'
|
|
67
|
+
const onKey = e => e.key === 'Escape' && setExpanded(false)
|
|
68
|
+
window.addEventListener('keydown', onKey)
|
|
69
|
+
return () => {
|
|
70
|
+
document.body.style.position = ''
|
|
71
|
+
document.body.style.top = ''
|
|
72
|
+
document.body.style.width = ''
|
|
73
|
+
window.scrollTo(0, scrollY)
|
|
74
|
+
window.removeEventListener('keydown', onKey)
|
|
75
|
+
}
|
|
76
|
+
}, [expanded])
|
|
77
|
+
|
|
78
|
+
const open = useCallback(() => {
|
|
79
|
+
setExpanded(true)
|
|
80
|
+
onExpand?.()
|
|
81
|
+
}, [onExpand])
|
|
82
|
+
const close = useCallback(() => setExpanded(false), [])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<div ref={wrapRef} className={className} style={{ position: 'relative', width: w, height: h, flexShrink: 0, ...style }} {...rest}>
|
|
87
|
+
<Canvas flat camera={{ position: cameraPosition, fov }} gl={{ antialias: true, alpha: background === 'transparent' }} style={{ background }}>
|
|
88
|
+
<Lights ambient={ambient} />
|
|
89
|
+
<AutoCubeScene palette={palette} scrollRef={scrollTilt ? scrollRef : null} scale={scale} tumble={tumble} autoplay={autoplay} />
|
|
90
|
+
</Canvas>
|
|
91
|
+
|
|
92
|
+
{interactive && (
|
|
93
|
+
<div
|
|
94
|
+
style={{ position: 'absolute', inset: 0, cursor: 'pointer', zIndex: 1 }}
|
|
95
|
+
onClick={open}
|
|
96
|
+
role="button"
|
|
97
|
+
aria-label="Explore cube"
|
|
98
|
+
tabIndex={0}
|
|
99
|
+
onKeyDown={e => e.key === 'Enter' && open()}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{interactive && expanded && <CubeOverlay onClose={close} palette={palette} accent={accent} />}
|
|
105
|
+
</>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default RubiksCube
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FaceMaterial {
|
|
4
|
+
color: string
|
|
5
|
+
roughness: number
|
|
6
|
+
metalness: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Palette {
|
|
10
|
+
right: FaceMaterial
|
|
11
|
+
left: FaceMaterial
|
|
12
|
+
up: FaceMaterial
|
|
13
|
+
down: FaceMaterial
|
|
14
|
+
front: FaceMaterial
|
|
15
|
+
back: FaceMaterial
|
|
16
|
+
inner: FaceMaterial
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A partial palette. `base` selects which built-in palette to merge onto. */
|
|
20
|
+
export type PaletteInput = Partial<{
|
|
21
|
+
base: 'brand' | 'classic'
|
|
22
|
+
right: Partial<FaceMaterial>
|
|
23
|
+
left: Partial<FaceMaterial>
|
|
24
|
+
up: Partial<FaceMaterial>
|
|
25
|
+
down: Partial<FaceMaterial>
|
|
26
|
+
front: Partial<FaceMaterial>
|
|
27
|
+
back: Partial<FaceMaterial>
|
|
28
|
+
inner: Partial<FaceMaterial>
|
|
29
|
+
}>
|
|
30
|
+
|
|
31
|
+
export interface RubiksCubeProps extends Omit<HTMLAttributes<HTMLDivElement>, 'style'> {
|
|
32
|
+
/** Width in px (number) or any CSS size (string). Default 400. */
|
|
33
|
+
size?: number | string
|
|
34
|
+
/** Height. Defaults to `size` (square). */
|
|
35
|
+
height?: number | string
|
|
36
|
+
/** Palette override, merged onto the `brand` (default) or `classic` base. */
|
|
37
|
+
palette?: PaletteInput
|
|
38
|
+
/** Accent colour for the overlay's active mode toggle. Default '#E05C28'. */
|
|
39
|
+
accent?: string
|
|
40
|
+
/** Run the scramble → solve loop. Default true. */
|
|
41
|
+
autoplay?: boolean
|
|
42
|
+
/** Slowly tumble the whole cube. Default true. */
|
|
43
|
+
tumble?: boolean
|
|
44
|
+
/** Click to open the fullscreen interactive overlay. Default true. */
|
|
45
|
+
interactive?: boolean
|
|
46
|
+
/** Tilt the cube as the element scrolls through the viewport. Default false. */
|
|
47
|
+
scrollTilt?: boolean
|
|
48
|
+
/** Cube scale inside the canvas. Default 0.72. */
|
|
49
|
+
scale?: number
|
|
50
|
+
/** Camera position [x, y, z]. Default [7, 6, 7]. */
|
|
51
|
+
cameraPosition?: [number, number, number]
|
|
52
|
+
/** Camera field of view. Default 24. */
|
|
53
|
+
fov?: number
|
|
54
|
+
/** Canvas background. Default 'transparent'. */
|
|
55
|
+
background?: string
|
|
56
|
+
/** Ambient light intensity. Default 0.22. */
|
|
57
|
+
ambient?: number
|
|
58
|
+
style?: CSSProperties
|
|
59
|
+
/** Called when the interactive overlay opens. */
|
|
60
|
+
onExpand?: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export declare function RubiksCube(props: RubiksCubeProps): JSX.Element
|
|
64
|
+
export default RubiksCube
|
|
65
|
+
|
|
66
|
+
export interface Move {
|
|
67
|
+
axis: 'x' | 'y' | 'z'
|
|
68
|
+
/** -1, 0, 1 for a layer; null for a whole-cube rotation. */
|
|
69
|
+
layer: -1 | 0 | 1 | null
|
|
70
|
+
dir: 1 | -1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AutoCubeSceneProps {
|
|
74
|
+
palette: Palette
|
|
75
|
+
scrollRef?: { current: number } | null
|
|
76
|
+
scale?: number
|
|
77
|
+
tumble?: boolean
|
|
78
|
+
autoplay?: boolean
|
|
79
|
+
}
|
|
80
|
+
export declare function AutoCubeScene(props: AutoCubeSceneProps): JSX.Element
|
|
81
|
+
|
|
82
|
+
export interface InteractiveCubeSceneProps {
|
|
83
|
+
palette: Palette
|
|
84
|
+
queueRef: { current: Move[] }
|
|
85
|
+
modeRef?: { current: 'orbit' | 'move' }
|
|
86
|
+
autoReturnAfter?: number
|
|
87
|
+
idleSpin?: number
|
|
88
|
+
}
|
|
89
|
+
export declare function InteractiveCubeScene(props: InteractiveCubeSceneProps): JSX.Element
|
|
90
|
+
|
|
91
|
+
export interface CubeOverlayProps {
|
|
92
|
+
onClose: () => void
|
|
93
|
+
palette: Palette
|
|
94
|
+
accent?: string
|
|
95
|
+
cameraPosition?: [number, number, number]
|
|
96
|
+
fov?: number
|
|
97
|
+
}
|
|
98
|
+
export declare function CubeOverlay(props: CubeOverlayProps): JSX.Element
|
|
99
|
+
|
|
100
|
+
export declare const MOVE_GROUPS: Array<{
|
|
101
|
+
id: string
|
|
102
|
+
label: string
|
|
103
|
+
accent: string
|
|
104
|
+
pairs: Array<Array<{ n: string } & Move>>
|
|
105
|
+
}>
|
|
106
|
+
|
|
107
|
+
export declare function Cubelet(props: { px: number; py: number; pz: number; palette: Palette }): JSX.Element
|
|
108
|
+
export declare function Lights(props: { ambient?: number }): JSX.Element
|
|
109
|
+
export declare function useCubePositions(): [number, number, number][]
|
|
110
|
+
export declare function smoother(t: number): number
|
|
111
|
+
export declare const CUBELET_GEO: import('three').BufferGeometry
|
|
112
|
+
|
|
113
|
+
export declare const BRAND_PALETTE: Palette
|
|
114
|
+
export declare const CLASSIC_PALETTE: Palette
|
|
115
|
+
export declare const FACE_ORDER: Array<keyof Palette>
|
|
116
|
+
export declare function resolvePalette(palette?: PaletteInput): Palette
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public API
|
|
2
|
+
export { RubiksCube, default } from './RubiksCube.jsx'
|
|
3
|
+
|
|
4
|
+
// Advanced building blocks — bring your own <Canvas>.
|
|
5
|
+
export { AutoCubeScene } from './AutoCubeScene.jsx'
|
|
6
|
+
export { InteractiveCubeScene, MOVE_GROUPS } from './InteractiveCubeScene.jsx'
|
|
7
|
+
export { CubeOverlay } from './CubeOverlay.jsx'
|
|
8
|
+
export { Cubelet, Lights, CUBELET_GEO, smoother, useCubePositions } from './primitives.jsx'
|
|
9
|
+
|
|
10
|
+
// Theming
|
|
11
|
+
export { BRAND_PALETTE, CLASSIC_PALETTE, FACE_ORDER, resolvePalette } from './palette.js'
|
package/src/palette.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ── Palettes ──────────────────────────────────────────────
|
|
2
|
+
// A palette describes the six sticker faces plus the inner body.
|
|
3
|
+
// Faces map to axes: right (+x), left (-x), up (+y), down (-y),
|
|
4
|
+
// front (+z), back (-z). Each entry is { color, roughness, metalness }.
|
|
5
|
+
// metalness stays 0 by default — there is no environment map, so any
|
|
6
|
+
// metalness only deadens the diffuse colour. The plastic sheen comes from
|
|
7
|
+
// crisp specular highlights of the lights at low roughness.
|
|
8
|
+
|
|
9
|
+
// Warm editorial brand palette (the original cubex look): a "warm side /
|
|
10
|
+
// cool side" read rather than a random rainbow. Opposite faces hold the
|
|
11
|
+
// strongest contrast pairs.
|
|
12
|
+
export const BRAND_PALETTE = {
|
|
13
|
+
right: { color: '#E05C28', roughness: 0.34, metalness: 0 }, // accent orange
|
|
14
|
+
left: { color: '#00C9A7', roughness: 0.34, metalness: 0 }, // mint teal
|
|
15
|
+
up: { color: '#EDE3D1', roughness: 0.44, metalness: 0 }, // ink cream
|
|
16
|
+
down: { color: '#C8963E', roughness: 0.36, metalness: 0 }, // gold
|
|
17
|
+
front: { color: '#B23E22', roughness: 0.36, metalness: 0 }, // deep terracotta
|
|
18
|
+
back: { color: '#0B7A6E', roughness: 0.38, metalness: 0 }, // deep teal
|
|
19
|
+
inner: { color: '#1C1A17', roughness: 0.82, metalness: 0 }, // dark body
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Classic Rubik's colours for a traditional look.
|
|
23
|
+
export const CLASSIC_PALETTE = {
|
|
24
|
+
right: { color: '#B71234', roughness: 0.3, metalness: 0 }, // red
|
|
25
|
+
left: { color: '#FF5800', roughness: 0.3, metalness: 0 }, // orange
|
|
26
|
+
up: { color: '#FFFFFF', roughness: 0.4, metalness: 0 }, // white
|
|
27
|
+
down: { color: '#FFD500', roughness: 0.32, metalness: 0 }, // yellow
|
|
28
|
+
front: { color: '#009B48', roughness: 0.32, metalness: 0 }, // green
|
|
29
|
+
back: { color: '#0046AD', roughness: 0.32, metalness: 0 }, // blue
|
|
30
|
+
inner: { color: '#111111', roughness: 0.85, metalness: 0 }, // black body
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Face order used everywhere geometry is built. This is the order
|
|
34
|
+
// meshStandardMaterial slots map to on a BoxGeometry:
|
|
35
|
+
// 0 = +x (right), 1 = -x (left), 2 = +y (up),
|
|
36
|
+
// 3 = -y (down), 4 = +z (front), 5 = -z (back)
|
|
37
|
+
export const FACE_ORDER = ['right', 'left', 'up', 'down', 'front', 'back']
|
|
38
|
+
|
|
39
|
+
// Merge a user-supplied partial palette over a base, per-face.
|
|
40
|
+
export function resolvePalette(palette) {
|
|
41
|
+
if (!palette) return BRAND_PALETTE
|
|
42
|
+
const base = palette.base === 'classic' ? CLASSIC_PALETTE : BRAND_PALETTE
|
|
43
|
+
const out = {}
|
|
44
|
+
for (const key of [...FACE_ORDER, 'inner']) {
|
|
45
|
+
out[key] = { ...base[key], ...(palette[key] || {}) }
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
|
|
3
|
+
import { FACE_ORDER } from './palette.js'
|
|
4
|
+
|
|
5
|
+
// ── Shared geometry ───────────────────────────────────────
|
|
6
|
+
// One rounded-box geometry reused by all 27 cubelets. Module-level so it is
|
|
7
|
+
// created once per bundle, never per mount.
|
|
8
|
+
export const CUBELET_GEO = new RoundedBoxGeometry(0.95, 0.95, 0.95, 4, 0.045)
|
|
9
|
+
|
|
10
|
+
// Smootherstep (Perlin) — zero velocity AND acceleration at both ends, so
|
|
11
|
+
// every turn starts and stops with no perceptible jerk.
|
|
12
|
+
export const smoother = t => {
|
|
13
|
+
t = Math.max(0, Math.min(1, t))
|
|
14
|
+
return t * t * t * (t * (t * 6 - 15) + 10)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// One cubelet: outer faces get sticker materials, inner faces get the body.
|
|
18
|
+
export function Cubelet({ px, py, pz, palette }) {
|
|
19
|
+
// Which of the 6 BoxGeometry face-slots are on the cube's outer surface.
|
|
20
|
+
const outer = useMemo(
|
|
21
|
+
() => [px === 1, px === -1, py === 1, py === -1, pz === 1, pz === -1],
|
|
22
|
+
[px, py, pz],
|
|
23
|
+
)
|
|
24
|
+
return (
|
|
25
|
+
<mesh position={[px, py, pz]} geometry={CUBELET_GEO}>
|
|
26
|
+
{outer.map((isOuter, i) => {
|
|
27
|
+
const m = isOuter ? palette[FACE_ORDER[i]] : palette.inner
|
|
28
|
+
return (
|
|
29
|
+
<meshStandardMaterial
|
|
30
|
+
key={i}
|
|
31
|
+
attach={`material-${i}`}
|
|
32
|
+
color={m.color}
|
|
33
|
+
roughness={m.roughness}
|
|
34
|
+
metalness={m.metalness}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
})}
|
|
38
|
+
</mesh>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Studio lighting tuned for flat (no tone-mapping) rendering: a warm key, a
|
|
43
|
+
// soft warm fill, a cool rim to carve edges out of a dark background, an
|
|
44
|
+
// ambient floor, and a warm front specular for the plastic sheen.
|
|
45
|
+
export function Lights({ ambient = 0.22 }) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<directionalLight position={[5, 8, 4]} intensity={2.4} color="#fff6ee" />
|
|
49
|
+
<directionalLight position={[-5, -3, -4]} intensity={0.6} color="#ffe0cf" />
|
|
50
|
+
<directionalLight position={[-4, 6, -6]} intensity={1.5} color="#cfe4ff" />
|
|
51
|
+
<ambientLight intensity={ambient} />
|
|
52
|
+
<pointLight position={[-1, 1, 5]} intensity={0.85} color="#fff5ee" distance={18} />
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The 27 integer grid positions of a 3×3×3 cube.
|
|
58
|
+
export function useCubePositions() {
|
|
59
|
+
return useMemo(() => {
|
|
60
|
+
const list = []
|
|
61
|
+
for (let x = -1; x <= 1; x++)
|
|
62
|
+
for (let y = -1; y <= 1; y++)
|
|
63
|
+
for (let z = -1; z <= 1; z++) list.push([x, y, z])
|
|
64
|
+
return list
|
|
65
|
+
}, [])
|
|
66
|
+
}
|