feat: add inertia and smooth snap animation, fix layer rotation direction
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
All checks were successful
Deploy to Production / deploy (push) Successful in 10s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rubic-cube",
|
"name": "rubic-cube",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,20 +2,9 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useCube } from '../../composables/useCube'
|
import { useCube } from '../../composables/useCube'
|
||||||
|
|
||||||
const { cubeState, initCube, rotateLayer, COLOR_MAP } = useCube()
|
const { cubeState, initCube, rotateLayer, COLOR_MAP, FACES } = useCube()
|
||||||
|
|
||||||
const getFaceColors = (faceName) => {
|
|
||||||
if (!cubeState.value || !cubeState.value[faceName]) {
|
|
||||||
return Array(9).fill('#333');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return cubeState.value[faceName].flat().map(c => COLOR_MAP[c] || 'gray');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error mapping face colors', e);
|
|
||||||
return Array(9).fill('red');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
const rx = ref(25)
|
const rx = ref(25)
|
||||||
const ry = ref(25)
|
const ry = ref(25)
|
||||||
const rz = ref(0)
|
const rz = ref(0)
|
||||||
@@ -26,227 +15,439 @@ const startMouseX = ref(0)
|
|||||||
const startMouseY = ref(0)
|
const startMouseY = ref(0)
|
||||||
const lastMouseX = ref(0)
|
const lastMouseX = ref(0)
|
||||||
const lastMouseY = ref(0)
|
const lastMouseY = ref(0)
|
||||||
const selectedFace = ref(null)
|
const selectedCubieId = ref(null) // ID of the cubie where drag started
|
||||||
const selectedStickerIndex = ref(null)
|
const selectedFaceNormal = ref(null) // Normal vector of the face clicked
|
||||||
|
|
||||||
// Animation state
|
// Animation state
|
||||||
const activeLayer = ref(null)
|
const activeLayer = ref(null) // { axis: 'x'|'y'|'z', index: -1|0|1 }
|
||||||
const layerRotation = ref(0)
|
const layerRotation = ref(0)
|
||||||
const isSnapping = ref(false)
|
const isSnapping = ref(false)
|
||||||
|
const velocity = ref(0)
|
||||||
|
const lastTime = ref(0)
|
||||||
|
const rafId = ref(null)
|
||||||
|
|
||||||
// Layer definitions (which stickers belong to which layer)
|
// Cubies Model
|
||||||
const LAYER_MAP = {
|
// We represent the cube as 27 independent cubies.
|
||||||
top: {
|
// Each cubie has a current position (x, y, z) in grid coordinates [-1, 0, 1].
|
||||||
up: [0,1,2,3,4,5,6,7,8],
|
// And a rotation matrix (or simplified orientation).
|
||||||
front: [0,1,2],
|
// Actually, for CSS rendering, we can just keep track of their current (x,y,z) and applying transforms.
|
||||||
right: [0,1,2],
|
// But to match `useCube` state (which is color based), we need to map colors to cubies.
|
||||||
back: [0,1,2],
|
//
|
||||||
left: [0,1,2]
|
// Alternative:
|
||||||
},
|
// `useCube` maintains the logical state of colors on faces.
|
||||||
bottom: {
|
// To render 27 cubies that MOVE, we need to know which color belongs to which face of which cubie.
|
||||||
down: [0,1,2,3,4,5,6,7,8],
|
//
|
||||||
front: [6,7,8],
|
// Mapping:
|
||||||
right: [6,7,8],
|
// 27 Cubies. ID: 0..26.
|
||||||
back: [6,7,8],
|
// Position: x,y,z in {-1, 0, 1}.
|
||||||
left: [6,7,8]
|
//
|
||||||
},
|
// Colors:
|
||||||
left: {
|
// A cubie at (x,y,z) exposes faces if x/y/z is +/- 1.
|
||||||
left: [0,1,2,3,4,5,6,7,8],
|
// e.g. (1, 1, 1) is Right-Top-Front corner.
|
||||||
front: [0,3,6],
|
// It has 3 colored faces: Right, Top, Front.
|
||||||
up: [0,3,6],
|
// We need to fetch the color from `cubeState` at the correct indices.
|
||||||
back: [2,5,8], // Opposite column on back face
|
//
|
||||||
down: [0,3,6]
|
// `cubeState` is organized by Faces.
|
||||||
},
|
// Front Face is a 3x3 matrix.
|
||||||
right: {
|
// (0,0) is Top-Left of Front Face.
|
||||||
right: [0,1,2,3,4,5,6,7,8],
|
// Front Face covers z=1 plane.
|
||||||
front: [2,5,8],
|
// x goes -1 (Left) to 1 (Right).
|
||||||
up: [2,5,8],
|
// y goes 1 (Top) to -1 (Bottom).
|
||||||
back: [0,3,6], // Opposite column on back face
|
//
|
||||||
down: [2,5,8]
|
// Let's define the 27 cubies.
|
||||||
},
|
const cubies = ref([])
|
||||||
front: {
|
|
||||||
front: [0,1,2,3,4,5,6,7,8],
|
const initCubies = () => {
|
||||||
up: [6,7,8],
|
const newCubies = []
|
||||||
right: [0,3,6],
|
let id = 0
|
||||||
down: [0,1,2],
|
for (let x = -1; x <= 1; x++) {
|
||||||
left: [2,5,8]
|
for (let y = -1; y <= 1; y++) {
|
||||||
},
|
for (let z = -1; z <= 1; z++) {
|
||||||
back: {
|
newCubies.push({
|
||||||
back: [0,1,2,3,4,5,6,7,8],
|
id: id++,
|
||||||
up: [0,1,2],
|
x, y, z, // Current grid position
|
||||||
right: [2,5,8],
|
// Store initial rotation or accumulate transform?
|
||||||
down: [6,7,8],
|
// Simplest is to accumulate rotation transforms for the cubie div.
|
||||||
left: [0,3,6]
|
// But for logic, we update x,y,z after snap.
|
||||||
|
transform: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
cubies.value = newCubies
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStickerInLayer = (layer, face, index) => {
|
// Map logical face colors to cubie faces
|
||||||
if (!layer || !LAYER_MAP[layer]) return false
|
// We need a function that given a cubie (x,y,z) returns the colors of its 6 faces.
|
||||||
const indices = LAYER_MAP[layer][face]
|
// If a face is internal, color is black (or null).
|
||||||
return indices && indices.includes(index)
|
const getCubieFaces = (cubie) => {
|
||||||
|
const { x, y, z } = cubie
|
||||||
|
const faces = {}
|
||||||
|
|
||||||
|
// Helper to map grid (x,y) to Matrix indices (row, col)
|
||||||
|
// Grid: x (-1..1), y (-1..1).
|
||||||
|
// Matrix: row (0..2), col (0..2).
|
||||||
|
//
|
||||||
|
// Face UP (y=1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
|
||||||
|
// Up Matrix: row 0 is Back, row 2 is Front. col 0 is Left, col 2 is Right.
|
||||||
|
// So: row = z + 1? No.
|
||||||
|
// z=-1 -> row 0. z=0 -> row 1. z=1 -> row 2. Yes.
|
||||||
|
// x=-1 -> col 0. x=0 -> col 1. x=1 -> col 2. Yes.
|
||||||
|
if (y === 1) {
|
||||||
|
const row = z + 1
|
||||||
|
const col = x + 1
|
||||||
|
faces.up = getColor(FACES.UP, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face DOWN (y=-1). z: Back(-1)..Front(1). x: Left(-1)..Right(1).
|
||||||
|
// Down Matrix: row 0 is Front, row 2 is Back. col 0 is Left, col 2 is Right.
|
||||||
|
// Wait, check standard mapping in `Cube.js` or standard rubik.
|
||||||
|
// Usually unfolding:
|
||||||
|
// Up: Back row is top.
|
||||||
|
// Down: Front row is top?
|
||||||
|
// Let's assume standard intuitive mapping:
|
||||||
|
// Down Face viewed from bottom.
|
||||||
|
// Row 0 is Front (top of view).
|
||||||
|
// z=1 -> row 0. z=-1 -> row 2.
|
||||||
|
// So row = 1 - z.
|
||||||
|
// x=-1 -> col 0. x=1 -> col 2.
|
||||||
|
if (y === -1) {
|
||||||
|
const row = 1 - z
|
||||||
|
const col = x + 1
|
||||||
|
faces.down = getColor(FACES.DOWN, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face FRONT (z=1). y: Top(1)..Bottom(-1). x: Left(-1)..Right(1).
|
||||||
|
// Matrix: row 0 is Top.
|
||||||
|
// y=1 -> row 0. y=-1 -> row 2.
|
||||||
|
// row = 1 - y.
|
||||||
|
// x=-1 -> col 0.
|
||||||
|
if (z === 1) {
|
||||||
|
const row = 1 - y
|
||||||
|
const col = x + 1
|
||||||
|
faces.front = getColor(FACES.FRONT, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face BACK (z=-1). y: Top(1)..Bottom(-1). x: Right(1)..Left(-1)?
|
||||||
|
// Back Face viewed from Back.
|
||||||
|
// Left side of view is Cube Right (x=1).
|
||||||
|
// Right side of view is Cube Left (x=-1).
|
||||||
|
// Matrix: row 0 is Top.
|
||||||
|
// y=1 -> row 0.
|
||||||
|
// col 0 (Left of view) -> x=1.
|
||||||
|
// col 2 (Right of view) -> x=-1.
|
||||||
|
// col = 1 - x.
|
||||||
|
if (z === -1) {
|
||||||
|
const row = 1 - y
|
||||||
|
const col = 1 - x
|
||||||
|
faces.back = getColor(FACES.BACK, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face RIGHT (x=1). y: Top(1)..Bottom(-1). z: Front(1)..Back(-1).
|
||||||
|
// Right Face viewed from Right.
|
||||||
|
// Left side of view is Front (z=1).
|
||||||
|
// Right side of view is Back (z=-1).
|
||||||
|
// Matrix: row 0 is Top.
|
||||||
|
// y=1 -> row 0.
|
||||||
|
// col 0 -> z=1.
|
||||||
|
// col 2 -> z=-1.
|
||||||
|
// col = 1 - z.
|
||||||
|
if (x === 1) {
|
||||||
|
const row = 1 - y
|
||||||
|
const col = 1 - z
|
||||||
|
faces.right = getColor(FACES.RIGHT, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Face LEFT (x=-1). y: Top(1)..Bottom(-1). z: Back(-1)..Front(1).
|
||||||
|
// Left Face viewed from Left.
|
||||||
|
// Left side of view is Back (z=-1).
|
||||||
|
// Right side of view is Front (z=1).
|
||||||
|
// Matrix: row 0 is Top.
|
||||||
|
// y=1 -> row 0.
|
||||||
|
// col 0 -> z=-1.
|
||||||
|
// col 2 -> z=1.
|
||||||
|
// col = z + 1.
|
||||||
|
if (x === -1) {
|
||||||
|
const row = 1 - y
|
||||||
|
const col = z + 1
|
||||||
|
faces.left = getColor(FACES.LEFT, row, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getColor = (face, row, col) => {
|
||||||
|
if (!cubeState.value || !cubeState.value[face]) return 'black'
|
||||||
|
const colorIndex = cubeState.value[face][row][col]
|
||||||
|
return COLOR_MAP[colorIndex] || 'black'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse Interaction
|
||||||
const onMouseDown = (event) => {
|
const onMouseDown = (event) => {
|
||||||
if (isSnapping.value) return // Prevent interaction during snap
|
if (isSnapping.value) return
|
||||||
|
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
startMouseX.value = event.clientX
|
startMouseX.value = event.clientX
|
||||||
startMouseY.value = event.clientY
|
startMouseY.value = event.clientY
|
||||||
lastMouseX.value = event.clientX
|
lastMouseX.value = event.clientX
|
||||||
lastMouseY.value = event.clientY
|
lastMouseY.value = event.clientY
|
||||||
|
lastTime.value = performance.now()
|
||||||
|
velocity.value = 0 // Reset velocity
|
||||||
|
|
||||||
const target = event.target
|
const target = event.target
|
||||||
const faceEl = target.closest('.face')
|
const stickerEl = target.closest('.sticker-face')
|
||||||
const stickerEl = target.closest('.sticker')
|
|
||||||
|
|
||||||
if (faceEl && stickerEl) {
|
if (stickerEl) {
|
||||||
const face = Array.from(faceEl.classList).find(c => ['top','bottom','left','right','front','back'].includes(c))
|
// Clicked on a cubie face
|
||||||
const index = parseInt(stickerEl.dataset.index)
|
const cubieId = parseInt(stickerEl.dataset.cubieId)
|
||||||
|
const faceName = stickerEl.dataset.face
|
||||||
|
|
||||||
selectedFace.value = face
|
selectedCubieId.value = cubieId
|
||||||
selectedStickerIndex.value = index
|
selectedFaceNormal.value = faceName // 'up', 'down', etc.
|
||||||
|
|
||||||
if (index === 4) {
|
// Check if it's a center face (implies View Drag)?
|
||||||
|
// User wants drag to rotate layers if grabbing edge/corner.
|
||||||
|
// Center face of the whole cube? No, center face of a side.
|
||||||
|
// If I grab the center sticker of Front Face, I might want to rotate View OR Front Face?
|
||||||
|
// User said: "jedynie centralny element kostki dragowany, bedzie ja po prostu obracal"
|
||||||
|
// So Center Sticker -> View Drag.
|
||||||
|
// Center Sticker is when x,y,z has two zeros? No.
|
||||||
|
// Center of Front Face: (0,0,1).
|
||||||
|
// Edge: (1,0,1). Corner: (1,1,1).
|
||||||
|
|
||||||
|
const cubie = cubies.value.find(c => c.id === cubieId)
|
||||||
|
const isCenter = (Math.abs(cubie.x) + Math.abs(cubie.y) + Math.abs(cubie.z)) === 1
|
||||||
|
|
||||||
|
if (isCenter) {
|
||||||
dragMode.value = 'view'
|
dragMode.value = 'view'
|
||||||
|
document.body.style.cursor = 'move'
|
||||||
} else {
|
} else {
|
||||||
dragMode.value = 'layer'
|
dragMode.value = 'layer'
|
||||||
|
document.body.style.cursor = 'grab'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dragMode.value = 'view'
|
dragMode.value = 'view'
|
||||||
selectedFace.value = null
|
selectedCubieId.value = null
|
||||||
selectedStickerIndex.value = null
|
document.body.style.cursor = 'move'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseMove = (event) => {
|
const onMouseMove = (event) => {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
if (dragMode.value === 'layer') {
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
}
|
||||||
|
|
||||||
const deltaX = event.clientX - lastMouseX.value
|
const deltaX = event.clientX - lastMouseX.value
|
||||||
const deltaY = event.clientY - lastMouseY.value
|
const deltaY = event.clientY - lastMouseY.value
|
||||||
|
|
||||||
if (dragMode.value === 'view') {
|
if (dragMode.value === 'view') {
|
||||||
ry.value += deltaX * 0.5
|
ry.value += deltaX * 0.5
|
||||||
rx.value -= deltaY * 0.5
|
rx.value -= deltaY * 0.5
|
||||||
} else if (dragMode.value === 'layer' && selectedFace.value) {
|
velocity.value = 0 // Reset velocity for view drag (or track it separately if needed)
|
||||||
|
} else if (dragMode.value === 'layer' && selectedCubieId.value !== null) {
|
||||||
const totalDeltaX = event.clientX - startMouseX.value
|
const totalDeltaX = event.clientX - startMouseX.value
|
||||||
const totalDeltaY = event.clientY - startMouseY.value
|
const totalDeltaY = event.clientY - startMouseY.value
|
||||||
|
|
||||||
updateLayerDrag(totalDeltaX, totalDeltaY)
|
// Calculate velocity
|
||||||
|
const now = performance.now()
|
||||||
|
const dt = now - lastTime.value
|
||||||
|
lastTime.value = now
|
||||||
|
|
||||||
|
// We only care about velocity of rotation, so we calculate it inside updateLayerDrag?
|
||||||
|
// Or we track mouse velocity here.
|
||||||
|
// Let's track rotation velocity in updateLayerDrag to be accurate with axis mapping.
|
||||||
|
updateLayerDrag(totalDeltaX, totalDeltaY, dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastMouseX.value = event.clientX
|
lastMouseX.value = event.clientX
|
||||||
lastMouseY.value = event.clientY
|
lastMouseY.value = event.clientY
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLayerDrag = (dx, dy) => {
|
const updateLayerDrag = (dx, dy, dt) => {
|
||||||
if (!selectedFace.value || selectedStickerIndex.value === null) return
|
// Determine rotation axis and direction based on drag vector and clicked face
|
||||||
|
const cubie = cubies.value.find(c => c.id === selectedCubieId.value)
|
||||||
|
if (!cubie) return
|
||||||
|
|
||||||
|
// Need to map 2D drag to 3D axis.
|
||||||
|
// Face Normals:
|
||||||
|
// Front: Z. Right: X. Up: Y.
|
||||||
|
|
||||||
// Determine direction if not yet determined (or update continuously)
|
const face = selectedFaceNormal.value
|
||||||
// Logic from previous handleLayerDrag, but adapted for continuous angle
|
let axis = null
|
||||||
|
let sign = 1
|
||||||
|
|
||||||
const face = selectedFace.value
|
const absDx = Math.abs(dx)
|
||||||
const idx = selectedStickerIndex.value
|
const absDy = Math.abs(dy)
|
||||||
|
const isHorizontal = absDx > absDy
|
||||||
|
|
||||||
const row = Math.floor(idx / 3)
|
// Logic:
|
||||||
const col = idx % 3
|
if (face === 'front' || face === 'back') {
|
||||||
|
if (isHorizontal) axis = 'y'; else axis = 'x';
|
||||||
// Determine dominant axis for the drag
|
} else if (face === 'right' || face === 'left') {
|
||||||
const isHorizontal = Math.abs(dx) > Math.abs(dy)
|
if (isHorizontal) axis = 'y'; else axis = 'z';
|
||||||
|
} else if (face === 'up' || face === 'down') {
|
||||||
// Determine target layer based on drag start position and direction
|
if (isHorizontal) axis = 'y';
|
||||||
let targetLayer = null
|
else axis = 'x';
|
||||||
let rotSign = 1
|
|
||||||
|
|
||||||
if (isHorizontal) {
|
|
||||||
// Horizontal drag rotates Top or Bottom layers
|
|
||||||
if (row === 0) targetLayer = 'top'
|
|
||||||
if (row === 2) targetLayer = 'bottom'
|
|
||||||
// Middle row horizontal drag -> rotates Middle layer (skip for now or map to 'middle')
|
|
||||||
|
|
||||||
// Determine rotation sign
|
|
||||||
// If dragging right (dx > 0):
|
|
||||||
// On Front face: Top moves Left (CW? No).
|
|
||||||
// Let's use standard:
|
|
||||||
// Front Face, Top Row, Drag Right -> Face moves Right.
|
|
||||||
// That is Anti-Clockwise Top Rotation (Top Face turns Left).
|
|
||||||
// So dx > 0 -> angle < 0.
|
|
||||||
|
|
||||||
if (face === 'back' || face === 'bottom') rotSign = 1 // Inverted logic for back/bottom
|
|
||||||
else rotSign = -1
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Vertical drag rotates Left or Right layers
|
|
||||||
if (col === 0) targetLayer = 'left'
|
|
||||||
if (col === 2) targetLayer = 'right'
|
|
||||||
|
|
||||||
// Sign logic
|
|
||||||
// Front Face, Right Col, Drag Down (dy > 0).
|
|
||||||
// Face moves Down.
|
|
||||||
// Right Face rotates ... towards user?
|
|
||||||
// Right Face CW: Front moves Up.
|
|
||||||
// So Drag Down = Anti-Clockwise (-1).
|
|
||||||
|
|
||||||
rotSign = -1
|
|
||||||
if (face === 'left' || face === 'back') rotSign = 1 // Inverted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetLayer) {
|
if (!axis) return
|
||||||
activeLayer.value = targetLayer
|
|
||||||
// Sensitivity: 1px = 0.5 degree
|
// Determine layer index
|
||||||
const rawDelta = isHorizontal ? dx : dy
|
let index = 0
|
||||||
layerRotation.value = rawDelta * rotSign * 0.5
|
if (axis === 'x') index = cubie.x
|
||||||
|
if (axis === 'y') index = cubie.y
|
||||||
|
if (axis === 'z') index = cubie.z
|
||||||
|
|
||||||
|
activeLayer.value = { axis, index }
|
||||||
|
|
||||||
|
// Determine Sign (Visual mapping)
|
||||||
|
const delta = isHorizontal ? dx : dy
|
||||||
|
const baseSign = isHorizontal ? 1 : -1
|
||||||
|
|
||||||
|
const newRotation = delta * baseSign * 0.5
|
||||||
|
|
||||||
|
// Calculate velocity (deg/ms)
|
||||||
|
if (dt > 0) {
|
||||||
|
const dRot = newRotation - layerRotation.value
|
||||||
|
// Simple low-pass filter for smoothing
|
||||||
|
velocity.value = 0.6 * velocity.value + 0.4 * (dRot / dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layerRotation.value = newRotation
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMouseUp = async () => {
|
const onMouseUp = async () => {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
|
||||||
if (dragMode.value === 'layer' && activeLayer.value) {
|
if (dragMode.value === 'layer' && activeLayer.value) {
|
||||||
isSnapping.value = true
|
isSnapping.value = true
|
||||||
|
|
||||||
|
// Inertia calculation
|
||||||
|
// Project final position based on velocity
|
||||||
|
// 200ms projection is reasonable for "throw" feel
|
||||||
|
const projection = velocity.value * 200
|
||||||
|
const projectedRot = layerRotation.value + projection
|
||||||
|
|
||||||
// Snap to nearest 90 degrees
|
// Snap to nearest 90 degrees
|
||||||
const currentRot = layerRotation.value
|
const steps = Math.round(projectedRot / 90)
|
||||||
const steps = Math.round(currentRot / 90)
|
|
||||||
const targetRot = steps * 90
|
const targetRot = steps * 90
|
||||||
|
|
||||||
// Animate to target
|
// Animation Loop
|
||||||
layerRotation.value = targetRot
|
const startRot = layerRotation.value
|
||||||
|
const startTime = performance.now()
|
||||||
|
const duration = 300 // ms
|
||||||
|
|
||||||
// Wait for transition
|
// Ease out cubic function
|
||||||
await new Promise(resolve => setTimeout(resolve, 200)) // Match CSS transition time
|
const easeOut = (t) => 1 - Math.pow(1 - t, 3)
|
||||||
|
|
||||||
// Commit changes to model
|
return new Promise(resolve => {
|
||||||
if (steps !== 0) {
|
const animate = (time) => {
|
||||||
// Rotate layer N times
|
const elapsed = time - startTime
|
||||||
const direction = steps > 0 ? 1 : -1
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
const count = Math.abs(steps)
|
const ease = easeOut(progress)
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
rotateLayer(activeLayer.value, direction)
|
layerRotation.value = startRot + (targetRot - startRot) * ease
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
rafId.value = requestAnimationFrame(animate)
|
||||||
|
} else {
|
||||||
|
finishRotation(steps)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
rafId.value = requestAnimationFrame(animate)
|
||||||
|
|
||||||
// Reset animation state silently
|
|
||||||
// We need to disable transition temporarily to prevent "rewind" animation
|
|
||||||
const movingCube = document.querySelector('.cube.moving')
|
|
||||||
if (movingCube) movingCube.style.transition = 'none'
|
|
||||||
|
|
||||||
activeLayer.value = null
|
|
||||||
layerRotation.value = 0
|
|
||||||
|
|
||||||
// Re-enable transition next tick
|
|
||||||
nextTick(() => {
|
|
||||||
if (movingCube) movingCube.style.transition = ''
|
|
||||||
isSnapping.value = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
selectedFace.value = null
|
selectedCubieId.value = null
|
||||||
selectedStickerIndex.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finishRotation = (steps) => {
|
||||||
|
if (steps !== 0) {
|
||||||
|
// Update logical state
|
||||||
|
const { axis, index } = activeLayer.value
|
||||||
|
let layerName = null
|
||||||
|
|
||||||
|
if (axis === 'x') {
|
||||||
|
if (index === -1) layerName = 'left'
|
||||||
|
if (index === 1) layerName = 'right'
|
||||||
|
} else if (axis === 'y') {
|
||||||
|
if (index === 1) layerName = 'top'
|
||||||
|
if (index === -1) layerName = 'bottom'
|
||||||
|
} else if (axis === 'z') {
|
||||||
|
if (index === 1) layerName = 'front'
|
||||||
|
if (index === -1) layerName = 'back'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerName) {
|
||||||
|
// Apply rotation to logical cube
|
||||||
|
let direction = steps > 0 ? 1 : -1
|
||||||
|
|
||||||
|
// Invert direction for specific layers where Visual and Logical rotations are opposite
|
||||||
|
if (layerName === 'top' || layerName === 'back' || layerName === 'right') {
|
||||||
|
direction = -direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.abs(steps)
|
||||||
|
|
||||||
|
// 1. Update Logical State (Colors)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
rotateLayer(layerName, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update Visual State (Cubies Position)
|
||||||
|
// We must rotate the (x,y,z) coordinates of the cubies that were in the active layer.
|
||||||
|
const visualSteps = steps // + means +90deg along axis
|
||||||
|
|
||||||
|
// Apply N times
|
||||||
|
const rotations = Math.abs(visualSteps)
|
||||||
|
const sign = Math.sign(visualSteps)
|
||||||
|
|
||||||
|
for (let r = 0; r < rotations; r++) {
|
||||||
|
cubies.value.forEach(cubie => {
|
||||||
|
// Check if cubie is in the rotating layer
|
||||||
|
let inLayer = false
|
||||||
|
if (axis === 'x' && cubie.x === index) inLayer = true
|
||||||
|
if (axis === 'y' && cubie.y === index) inLayer = true
|
||||||
|
if (axis === 'z' && cubie.z === index) inLayer = true
|
||||||
|
|
||||||
|
if (inLayer) {
|
||||||
|
const { x, y, z } = cubie
|
||||||
|
let nx = x, ny = y, nz = z
|
||||||
|
|
||||||
|
if (axis === 'x') {
|
||||||
|
if (sign > 0) { ny = -z; nz = y; } // (x, -z, y)
|
||||||
|
else { ny = z; nz = -y; } // (x, z, -y)
|
||||||
|
} else if (axis === 'y') {
|
||||||
|
if (sign > 0) { nx = z; nz = -x; } // (z, y, -x)
|
||||||
|
else { nx = -z; nz = x; } // (-z, y, x)
|
||||||
|
} else if (axis === 'z') {
|
||||||
|
if (sign > 0) { nx = -y; ny = x; } // (-y, x, z)
|
||||||
|
else { nx = y; ny = -x; } // (y, -x, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
cubie.x = nx
|
||||||
|
cubie.y = ny
|
||||||
|
cubie.z = nz
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeLayer.value = null
|
||||||
|
layerRotation.value = 0
|
||||||
|
isSnapping.value = false
|
||||||
|
velocity.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
initCubies()
|
||||||
initCube()
|
initCube()
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
@@ -255,89 +456,79 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
if (rafId.value) cancelAnimationFrame(rafId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Styles
|
||||||
const cubeStyle = computed(() => ({
|
const cubeStyle = computed(() => ({
|
||||||
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
|
transform: `rotateX(${rx.value}deg) rotateY(${ry.value}deg) rotateZ(${rz.value}deg)`
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const movingGroupStyle = computed(() => {
|
const getCubieStyle = (cubie) => {
|
||||||
if (!activeLayer.value) return {}
|
// Base position
|
||||||
|
// scale 300px total. 100px per cubie.
|
||||||
|
// x,y,z in -1..1.
|
||||||
|
// translate(x*100, -y*100, z*100).
|
||||||
|
// Y is inverted in CSS (down is positive)?
|
||||||
|
// Usually in 3D CSS:
|
||||||
|
// X right, Y down, Z towards viewer.
|
||||||
|
// My Grid: Y=1 is Top.
|
||||||
|
// So Y=1 -> translateY(-100px).
|
||||||
|
|
||||||
// Determine axis of rotation
|
const tx = cubie.x * 100
|
||||||
let axis = 'Y'
|
const ty = cubie.y * -100
|
||||||
if (['top', 'bottom'].includes(activeLayer.value)) axis = 'Y'
|
const tz = cubie.z * 100
|
||||||
if (['left', 'right'].includes(activeLayer.value)) axis = 'X'
|
|
||||||
if (['front', 'back'].includes(activeLayer.value)) axis = 'Z'
|
|
||||||
|
|
||||||
// Correction for axis orientation to match standard rotation
|
let transform = `translate3d(${tx}px, ${ty}px, ${tz}px)`
|
||||||
// Up/Down rotate around Y axis? No.
|
|
||||||
// Standard CSS 3D:
|
|
||||||
// rotateY -> horizontal rotation (Left/Right)
|
|
||||||
// rotateX -> vertical rotation (Up/Down)
|
|
||||||
// rotateZ -> in-plane rotation (Front/Back)
|
|
||||||
|
|
||||||
// Map layer to axis:
|
// Apply rotation if active layer
|
||||||
// Top/Bottom -> rotate around Y axis (vertical axis of the cube)
|
if (activeLayer.value) {
|
||||||
// Left/Right -> rotate around X axis (horizontal axis)
|
const { axis, index } = activeLayer.value
|
||||||
// Front/Back -> rotate around Z axis (depth axis)
|
let match = false
|
||||||
|
if (axis === 'x' && cubie.x === index) match = true
|
||||||
if (['top', 'bottom'].includes(activeLayer.value)) axis = 'Y'
|
if (axis === 'y' && cubie.y === index) match = true
|
||||||
else if (['left', 'right'].includes(activeLayer.value)) axis = 'X'
|
if (axis === 'z' && cubie.z === index) match = true
|
||||||
else axis = 'Z'
|
|
||||||
|
if (match) {
|
||||||
// We must concatenate the transforms because Vue style array merging overrides properties with same name.
|
// Rotation origin is center of cube (0,0,0).
|
||||||
// cubeStyle has 'transform' (view rotation).
|
// But we are translating the cubie.
|
||||||
// We want to append the layer rotation to it.
|
// To rotate around global axis, we should rotate THEN translate?
|
||||||
const viewTransform = cubeStyle.value.transform
|
// No, the Group rotates.
|
||||||
const layerTransform = `rotate${axis}(${layerRotation.value}deg)`
|
// But here we rotate individual cubies.
|
||||||
|
// A cubie at (100,0,0) rotating around Y axis:
|
||||||
return {
|
// Needs to move in arc.
|
||||||
transform: `${viewTransform} ${layerTransform}`
|
// `rotateY(angle) translate(...)` -> Rotates axis then moves.
|
||||||
|
// Yes. `rotateY` first puts it on the rotated axis.
|
||||||
|
// So `rotate` then `translate`.
|
||||||
|
transform = `rotate${axis.toUpperCase()}(${layerRotation.value}deg) ${transform}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
return { transform }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="scene" @mousedown="onMouseDown">
|
<div class="scene" @mousedown="onMouseDown">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="cube-group" :style="cubeStyle">
|
||||||
<!-- Static Group (Non-moving parts) -->
|
|
||||||
<div class="cube static" :style="cubeStyle">
|
<div v-for="cubie in cubies" :key="cubie.id" class="cubie" :style="getCubieStyle(cubie)">
|
||||||
<div v-for="faceName in ['top', 'bottom', 'left', 'right', 'front', 'back']"
|
<!-- Render 6 faces for each cubie -->
|
||||||
:key="'static-'+faceName"
|
<!-- Only render if color is not black? Optimization. -->
|
||||||
class="face"
|
|
||||||
:class="faceName">
|
<div v-for="(color, face) in getCubieFaces(cubie)" :key="face"
|
||||||
<div class="stickers">
|
class="sticker-face"
|
||||||
<div v-for="(color, i) in getFaceColors(faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName)"
|
:class="face"
|
||||||
:key="'s-'+i"
|
:data-cubie-id="cubie.id"
|
||||||
class="sticker-wrapper"
|
:data-face="face"
|
||||||
:data-index="i"
|
:style="{ backgroundColor: color }">
|
||||||
:style="{ visibility: activeLayer && isStickerInLayer(activeLayer, faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName, i) ? 'hidden' : 'visible' }">
|
<div class="sticker-border"></div>
|
||||||
<div class="sticker" :style="{ backgroundColor: color }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Moving Group (Active layer) -->
|
|
||||||
<div v-if="activeLayer" class="cube moving" :style="movingGroupStyle">
|
|
||||||
<div v-for="faceName in ['top', 'bottom', 'left', 'right', 'front', 'back']"
|
|
||||||
:key="'moving-'+faceName"
|
|
||||||
class="face"
|
|
||||||
:class="faceName">
|
|
||||||
<div class="stickers">
|
|
||||||
<div v-for="(color, i) in getFaceColors(faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName)"
|
|
||||||
:key="'m-'+i"
|
|
||||||
class="sticker-wrapper"
|
|
||||||
:data-index="i"
|
|
||||||
:style="{ visibility: isStickerInLayer(activeLayer, faceName === 'top' ? 'up' : faceName === 'bottom' ? 'down' : faceName, i) ? 'visible' : 'hidden' }">
|
|
||||||
<div class="sticker" :style="{ backgroundColor: color }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -356,65 +547,57 @@ const movingGroupStyle = computed(() => {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
perspective: 900px;
|
perspective: 900px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-group {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
.cube {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute; /* Stack them */
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: transform 0.1s; /* View rotation transition */
|
transition: transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cube.moving {
|
.cubie {
|
||||||
transition: transform 0.2s ease-out; /* Layer snap transition */
|
|
||||||
}
|
|
||||||
|
|
||||||
.face {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 300px;
|
width: 100px;
|
||||||
height: 300px;
|
height: 100px;
|
||||||
border: 2px solid rgba(0,0,0,0.1);
|
top: 100px; /* Center it: 300/2 - 100/2 = 100 */
|
||||||
|
left: 100px;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.8); /* Plastic edge */
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
justify-content: center;
|
||||||
/* Transparent background to allow seeing through split parts */
|
align-items: center;
|
||||||
background: transparent;
|
backface-visibility: hidden; /* Optimization? Or we want to see inside? */
|
||||||
|
/* We want solid cubies. So we need backfaces or 6 faces. */
|
||||||
|
/* We are rendering 6 faces. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.face.front { transform: rotateY(0deg) translateZ(150px); }
|
.sticker-border {
|
||||||
.face.right { transform: rotateY(90deg) translateZ(150px); }
|
width: 92%;
|
||||||
.face.back { transform: rotateY(180deg) translateZ(150px); }
|
height: 92%;
|
||||||
.face.left { transform: rotateY(-90deg) translateZ(150px); }
|
border: 2px solid rgba(0,0,0,0.5);
|
||||||
.face.top { transform: rotateX(90deg) translateZ(150px); }
|
border-radius: 8px; /* Rounded sticker */
|
||||||
.face.bottom { transform: rotateX(-90deg) translateZ(150px); }
|
background: inherit; /* Sticker color */
|
||||||
|
/* The face bg is the plastic color (black usually). */
|
||||||
.stickers {
|
/* Here we set face bg to color directly. */
|
||||||
display: grid;
|
/* Let's adjust: face bg = black. sticker-border bg = color. */
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
grid-template-rows: repeat(3, 1fr);
|
|
||||||
gap: 4px; /* Gap between stickers */
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticker-wrapper {
|
/* Face transforms relative to Cubie Center */
|
||||||
width: 100%;
|
.sticker-face.front { transform: rotateY(0deg) translateZ(50px); }
|
||||||
height: 100%;
|
.sticker-face.back { transform: rotateY(180deg) translateZ(50px); }
|
||||||
background: black; /* The black plastic look */
|
.sticker-face.right { transform: rotateY(90deg) translateZ(50px); }
|
||||||
padding: 2px; /* Inner padding for sticker */
|
.sticker-face.left { transform: rotateY(-90deg) translateZ(50px); }
|
||||||
box-sizing: border-box;
|
.sticker-face.up { transform: rotateX(90deg) translateZ(50px); }
|
||||||
}
|
.sticker-face.down { transform: rotateX(-90deg) translateZ(50px); }
|
||||||
|
|
||||||
.sticker {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: inset 0 0 5px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user