User01110's picture
Keep cube context visible during turns
641bbe9
Raw History Blame Contribute Delete
45.5 kB
"use strict";
const FACES = ["U", "R", "F", "D", "L", "B"];
const FACE_NAMES = {U: "Top", R: "Right", F: "Front", D: "Bottom", L: "Left", B: "Back"};
const FACE_AXES = {U: "Y", D: "Y", R: "X", L: "X", F: "Z", B: "Z"};
const SCRAMBLE_SUFFIXES = ["", "'", "2"];
const DEMO_SCRAMBLE_LENGTH = 20;
const COLOR_PRESETS = [
{name: "white", label: "White", hex: "#f4f4f5"},
{name: "yellow", label: "Yellow", hex: "#facc15"},
{name: "red", label: "Red", hex: "#ef4444"},
{name: "orange", label: "Orange", hex: "#f97316"},
{name: "blue", label: "Blue", hex: "#3b82f6"},
{name: "green", label: "Green", hex: "#22c55e"},
{name: "pink", label: "Pink", hex: "#ec4899"},
{name: "purple", label: "Purple", hex: "#a855f7"},
{name: "cyan", label: "Cyan", hex: "#06b6d4"},
{name: "teal", label: "Teal", hex: "#14b8a6"},
{name: "lime", label: "Lime", hex: "#84cc16"},
{name: "brown", label: "Brown", hex: "#92400e"},
{name: "black", label: "Black", hex: "#111827"},
{name: "gray", label: "Gray", hex: "#6b7280"},
{name: "magenta", label: "Magenta", hex: "#d946ef"},
{name: "maroon", label: "Maroon", hex: "#881337"},
{name: "navy", label: "Navy", hex: "#1e3a8a"},
{name: "gold", label: "Gold", hex: "#eab308"},
{name: "coral", label: "Coral", hex: "#fb7185"},
{name: "turquoise", label: "Turquoise", hex: "#2dd4bf"},
];
const PRESET_MAP = Object.fromEntries(COLOR_PRESETS.map(color => [color.name, color]));
let COLORS = [
{name: "blue", label: "Blue", hex: "#3b82f6"},
{name: "green", label: "Green", hex: "#22c55e"},
{name: "white", label: "White", hex: "#f4f4f5"},
{name: "pink", label: "Pink", hex: "#ec4899"},
{name: "yellow", label: "Yellow", hex: "#facc15"},
{name: "orange", label: "Orange", hex: "#f97316"},
];
let COLOR_MAP = Object.fromEntries(COLORS.map(color => [color.name, color]));
let SOLVED_COLORS = {U: "yellow", R: "orange", F: "white", D: "green", L: "blue", B: "pink"};
const TOPOLOGY_FACES = ["U", "R", "D", "L", "B"];
const CAMERA_PRESETS = {
HOME: {x: -24, y: -36, label: "Free view"},
F: {x: -14, y: -8, label: "Front face"},
R: {x: -14, y: -82, label: "Right face"},
B: {x: -14, y: 172, label: "Back face"},
L: {x: -14, y: 82, label: "Left face"},
U: {x: -68, y: -10, label: "Top face"},
D: {x: 68, y: 10, label: "Bottom face"},
};
const CAMERA_UP_FACE = {F: "U", R: "U", B: "U", L: "U", U: "B", D: "F"};
const TURN_SPECS = {
F: {axis: "Z", sign: 1, select: cubie => cubie.dataset.z === "1"},
B: {axis: "Z", sign: -1, select: cubie => cubie.dataset.z === "-1"},
R: {axis: "X", sign: 1, select: cubie => cubie.dataset.x === "1"},
L: {axis: "X", sign: -1, select: cubie => cubie.dataset.x === "-1"},
U: {axis: "Y", sign: -1, select: cubie => cubie.dataset.y === "1"},
D: {axis: "Y", sign: 1, select: cubie => cubie.dataset.y === "-1"},
};
const MOVE_TIMING = {
announce: 650,
cameraMin: 520,
cameraMax: 1100,
cameraPerDegree: 10,
sameFaceCamera: 420,
faceHold: 600,
quarterTurn: 1350,
halfTurn: 1800,
settle: 420,
betweenMoves: 520,
};
const cube = Object.fromEntries(FACES.map(face => [face, Array(9).fill(null)]));
function resetCubeToCenters() {
FACES.forEach(face => {
cube[face].fill(null);
cube[face][4] = SOLVED_COLORS[face];
});
}
resetCubeToCenters();
let inputMode = true;
let solution = null;
let frameIndex = 0;
let selectedSticker = null;
let camera = {...CAMERA_PRESETS.HOME};
let pointerStart = null;
let dragging = false;
let suppressStickerClick = false;
let animating = false;
let playing = false;
let jumpVersion = 0;
let menuGlobalListenersBound = false;
let solverReady = false;
let solverFailure = null;
let solverWorker = null;
let solverRequestId = 0;
const solverPending = new Map();
let validationReturnFocus = null;
const CUBE_INPUT_ERROR_CODES = new Set([
"input_type", "face_shape", "empty_sticker", "duplicate_centers",
"color_multiplicity", "unknown_color", "piece_set", "corner_orientation",
"edge_orientation", "corner_twist", "edge_flip", "parity",
]);
const $ = id => document.getElementById(id);
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
const nextPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function randomInteger(maximum) {
const values = new Uint32Array(1);
const limit = 0x100000000 - (0x100000000 % maximum);
do window.crypto.getRandomValues(values); while (values[0] >= limit);
return values[0] % maximum;
}
function randomScramble(length = DEMO_SCRAMBLE_LENGTH) {
const scramble = [];
let previousAxis = null;
for (let index = 0; index < length; index++) {
const availableFaces = FACES.filter(face => FACE_AXES[face] !== previousAxis);
const face = availableFaces[randomInteger(availableFaces.length)];
const suffix = SCRAMBLE_SUFFIXES[randomInteger(SCRAMBLE_SUFFIXES.length)];
scramble.push(`${face}${suffix}`);
previousAxis = FACE_AXES[face];
}
return scramble;
}
async function transitionTransform(element, transform, duration, easing = "cubic-bezier(.4,.01,.18,1)") {
element.style.transition = `transform ${Math.round(duration)}ms ${easing}`;
await nextPaint();
element.style.transform = transform;
await sleep(Math.round(duration) + 24);
}
function colorInfo(value) {
return COLOR_MAP[String(value || "").toLowerCase()] || {name: value, label: String(value), hex: "#94a3b8"};
}
function safeColorHex(value) {
return /^#[0-9a-f]{6}$/i.test(String(value)) ? String(value) : "#94a3b8";
}
function axisOffset(value) {
if (value === 0) return "0px";
return value > 0 ? "var(--step)" : "calc(var(--step) * -1)";
}
function faceletIndex(face, x, y, z) {
if (face === "U") return (z + 1) * 3 + (x + 1);
if (face === "D") return (1 - z) * 3 + (x + 1);
if (face === "F") return (1 - y) * 3 + (x + 1);
if (face === "B") return (1 - y) * 3 + (1 - x);
if (face === "R") return (1 - y) * 3 + (1 - z);
return (1 - y) * 3 + (z + 1);
}
const FACE_OFFSETS = Object.fromEntries(FACES.map((face, index) => [face, index * 9]));
const FACE_NORMALS = {
U: [0, 1, 0], R: [1, 0, 0], F: [0, 0, 1],
D: [0, -1, 0], L: [-1, 0, 0], B: [0, 0, -1],
};
function stickerGeometry(face, index) {
const row = Math.floor(index / 3);
const column = index % 3;
if (face === "U") return {position: [column - 1, 1, row - 1], normal: FACE_NORMALS.U};
if (face === "D") return {position: [column - 1, -1, 1 - row], normal: FACE_NORMALS.D};
if (face === "F") return {position: [column - 1, 1 - row, 1], normal: FACE_NORMALS.F};
if (face === "B") return {position: [1 - column, 1 - row, -1], normal: FACE_NORMALS.B};
if (face === "R") return {position: [1, 1 - row, 1 - column], normal: FACE_NORMALS.R};
return {position: [-1, 1 - row, column - 1], normal: FACE_NORMALS.L};
}
function geometryKey(position, normal) {
return `${position.join(",")}|${normal.join(",")}`;
}
const FACELET_LOOKUP = new Map();
FACES.forEach(face => {
for (let index = 0; index < 9; index++) {
const geometry = stickerGeometry(face, index);
FACELET_LOOKUP.set(geometryKey(geometry.position, geometry.normal), FACE_OFFSETS[face] + index);
}
});
function rotateClockwise(vector, face) {
const [x, y, z] = vector;
if (face === "F") return [y, -x, z];
if (face === "B") return [-y, x, z];
if (face === "R") return [x, z, -y];
if (face === "L") return [x, -z, y];
if (face === "U") return [-z, y, x];
return [z, y, -x];
}
const QUARTER_TURN_PERMUTATIONS = Object.fromEntries(FACES.map(face => {
const normal = FACE_NORMALS[face];
const permutation = Array.from({length: 54}, (_, index) => index);
FACES.forEach(sourceFace => {
for (let sourceIndex = 0; sourceIndex < 9; sourceIndex++) {
const source = FACE_OFFSETS[sourceFace] + sourceIndex;
const geometry = stickerGeometry(sourceFace, sourceIndex);
const onLayer = geometry.position[0] * normal[0]
+ geometry.position[1] * normal[1]
+ geometry.position[2] * normal[2] === 1;
if (!onLayer) continue;
const targetPosition = rotateClockwise(geometry.position, face);
const targetNormal = rotateClockwise(geometry.normal, face);
const target = FACELET_LOOKUP.get(geometryKey(targetPosition, targetNormal));
if (target === undefined) throw new Error(`Internal facelet mapping failed for ${face}.`);
permutation[source] = target;
}
});
return [face, permutation];
}));
function applyCanonicalMove(canonical, move) {
if (typeof canonical !== "string" || canonical.length !== 54 || !/^[URFDLB](?:2|')?$/.test(move)) {
throw new Error("The solution contained invalid cube data.");
}
const face = move[0];
const turns = move.endsWith("2") ? 2 : move.endsWith("'") ? 3 : 1;
let state = canonical.split("");
const permutation = QUARTER_TURN_PERMUTATIONS[face];
for (let turn = 0; turn < turns; turn++) {
const next = Array(54);
for (let source = 0; source < 54; source++) next[permutation[source]] = state[source];
state = next;
}
return state.join("");
}
function coloredFacesFromCanonical(canonical, faceToColor) {
return Object.fromEntries(FACES.map((face, faceIndex) => [
face,
[...canonical.slice(faceIndex * 9, faceIndex * 9 + 9)].map(value => faceToColor[value]),
]));
}
function moveInstruction(index, move) {
const face = move[0];
const turns = move.endsWith("2") ? 2 : move.endsWith("'") ? -1 : 1;
const text = Math.abs(turns) === 2
? `Turn the ${FACE_NAMES[face]} face 180 degrees.`
: `Turn the ${FACE_NAMES[face]} face ${turns > 0 ? "clockwise" : "counterclockwise"} 90 degrees.`;
return {
index,
move,
text,
camera: {transition: "rotate_cube_to_face", front_face: face, up_face: CAMERA_UP_FACE[face], center_identity: face},
turn: {layer: face, reference: "clockwise_when_viewed_directly_at_the_face", clockwise_quarter_turns: turns, absolute_degrees: Math.abs(turns) === 2 ? 180 : 90},
before_frame: index - 1,
after_frame: index,
};
}
function buildVerifiedSolution(workerResult) {
const solved = FACES.map(face => face.repeat(9)).join("");
let canonical = workerResult.canonical;
const frames = [{index: 0, last_move: null, canonical_facelets: canonical, faces: coloredFacesFromCanonical(canonical, workerResult.faceToColor), solved: canonical === solved}];
const instructions = [];
workerResult.moves.forEach((move, moveIndex) => {
canonical = applyCanonicalMove(canonical, move);
frames.push({index: moveIndex + 1, last_move: move, canonical_facelets: canonical, faces: coloredFacesFromCanonical(canonical, workerResult.faceToColor), solved: canonical === solved});
instructions.push(moveInstruction(moveIndex + 1, move));
});
if (canonical !== solved) {
throw new Error("Safety check failed: the proposed moves did not solve the entered cube. No steps were shown.");
}
return {
ok: true,
valid: true,
solver: {algorithm: "deterministic_two_phase_worker", trained: false, deterministic_search: true, optimality_guaranteed: false, search_seconds: workerResult.searchSeconds},
moves: workerResult.moves,
move_count: workerResult.moves.length,
instructions,
frames,
final_canonical_facelets: canonical,
solved: true,
};
}
function setRuntimeStatus(status, text) {
const pill = $("runtimePill");
pill.classList.remove("ready", "error");
if (status === "ready") pill.classList.add("ready");
if (status === "error") pill.classList.add("error");
$("runtimeText").textContent = text;
pill.title = status === "error" && solverFailure ? solverFailure : text;
}
function rejectPendingSolverRequests(message) {
solverPending.forEach(({reject, timeout}) => {
clearTimeout(timeout);
reject(new Error(message));
});
solverPending.clear();
}
function initializeSolver() {
if (!("Worker" in window)) {
solverFailure = "This browser does not support Web Workers. Please use a current Chrome, Edge, Firefox, or Safari.";
setRuntimeStatus("error", "Browser unsupported");
updateEntryStatus();
return;
}
setRuntimeStatus("preparing", "Preparing browser solver…");
const workerUrl = new URL("./solver-worker.js", window.location.href);
workerUrl.searchParams.set("v", "20260810-cube-v2");
const privateSignature = new URLSearchParams(window.location.search).get("__sign");
if (privateSignature) workerUrl.searchParams.set("__sign", privateSignature);
solverWorker = new Worker(workerUrl);
solverWorker.addEventListener("message", event => {
const message = event.data || {};
if (message.type === "status") {
if (message.status === "ready") {
solverReady = true;
solverFailure = null;
setRuntimeStatus("ready", "Solver ready · on-device");
} else if (message.status === "error") {
solverReady = false;
solverFailure = message.message || "The browser solver could not initialize.";
console.error(solverFailure);
setRuntimeStatus("error", "Solver unavailable");
rejectPendingSolverRequests(solverFailure);
} else {
setRuntimeStatus("preparing", message.message || "Building solve tables…");
}
updateEntryStatus();
return;
}
if (message.type !== "result") return;
const pending = solverPending.get(message.id);
if (!pending) return;
clearTimeout(pending.timeout);
solverPending.delete(message.id);
if (message.ok) pending.resolve(message);
else pending.reject(Object.assign(new Error(message.error?.message || "The cube could not be solved."), {code: message.error?.code}));
});
solverWorker.addEventListener("error", event => {
solverReady = false;
const detail = event && event.message ? ` (${event.message})` : "";
solverFailure = `The browser solver stopped unexpectedly${detail}. Reload the page to try again.`;
console.error(solverFailure);
setRuntimeStatus("error", "Solver unavailable");
rejectPendingSolverRequests(solverFailure);
updateEntryStatus();
});
solverWorker.postMessage({type: "init"});
}
function requestWorkerSolve(cubeInput) {
return new Promise((resolve, reject) => {
if (!solverReady || !solverWorker) {
reject(new Error(solverFailure || "The browser solver is still preparing. Please wait a moment."));
return;
}
const id = ++solverRequestId;
const timeout = setTimeout(() => {
solverPending.delete(id);
reject(new Error("This solve took too long. Please reload and recheck the entered cube."));
}, 60000);
solverPending.set(id, {resolve, reject, timeout});
solverWorker.postMessage({type: "solve", id, cube: cubeInput});
});
}
function stickerPositionName(index) {
if (index === 4) return "center";
const rows = ["top", "middle", "bottom"];
const columns = ["left", "middle", "right"];
return `${rows[Math.floor(index / 3)]} ${columns[index % 3]}`;
}
function addPlasticFaces(cubie) {
FACES.forEach(face => {
const side = document.createElement("div");
side.className = `plastic-face surface-${face}`;
cubie.append(side);
});
}
function setStickerAppearance(sticker, value) {
if (!value) {
sticker.style.background = "linear-gradient(145deg, #343947, #242833)";
sticker.dataset.color = "";
return;
}
const color = colorInfo(value);
sticker.style.background = `linear-gradient(145deg, color-mix(in srgb, ${color.hex}, white 14%), ${color.hex} 58%, color-mix(in srgb, ${color.hex}, black 12%))`;
sticker.dataset.color = color.name;
}
function addSticker(cubie, face, index, state, interactive) {
const sticker = document.createElement("button");
const value = state[face][index];
const editable = interactive && index !== 4;
sticker.className = `cube-sticker surface-${face}${editable ? "" : " locked"}${interactive && index === 4 ? " center-fixed" : ""}`;
sticker.type = "button";
sticker.dataset.face = face;
sticker.dataset.index = String(index);
sticker.setAttribute("aria-label", `${FACE_NAMES[face]} face, ${stickerPositionName(index)} sticker${value ? `, ${colorInfo(value).label}` : ", uncolored"}${interactive && index === 4 ? ", fixed by cube setup" : ""}`);
setStickerAppearance(sticker, value);
if (editable) {
sticker.addEventListener("click", event => {
if (suppressStickerClick || dragging) return;
openColorMenu(face, index, event);
});
}
cubie.append(sticker);
}
function renderCube(state = cube, interactive = inputMode) {
const root = $("cube3d");
root.replaceChildren();
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
const cubie = document.createElement("div");
cubie.className = "cubie";
cubie.dataset.x = String(x);
cubie.dataset.y = String(y);
cubie.dataset.z = String(z);
cubie.style.transform = `translate3d(${axisOffset(x)}, ${axisOffset(-y)}, ${axisOffset(z)})`;
addPlasticFaces(cubie);
if (y === 1) addSticker(cubie, "U", faceletIndex("U", x, y, z), state, interactive);
if (y === -1) addSticker(cubie, "D", faceletIndex("D", x, y, z), state, interactive);
if (z === 1) addSticker(cubie, "F", faceletIndex("F", x, y, z), state, interactive);
if (z === -1) addSticker(cubie, "B", faceletIndex("B", x, y, z), state, interactive);
if (x === 1) addSticker(cubie, "R", faceletIndex("R", x, y, z), state, interactive);
if (x === -1) addSticker(cubie, "L", faceletIndex("L", x, y, z), state, interactive);
root.append(cubie);
}
}
}
}
function applyCamera(animated = true) {
const rig = $("cubeRig");
rig.classList.toggle("no-transition", !animated);
rig.style.transform = `rotateX(${camera.x}deg) rotateY(${camera.y}deg)`;
}
function chooseView(face, animated = true) {
const preset = CAMERA_PRESETS[face] || CAMERA_PRESETS.HOME;
camera = {...preset};
applyCamera(animated);
$("viewLabel").textContent = preset.label;
document.querySelectorAll("[data-view]").forEach(button => button.classList.toggle("active", button.dataset.view === face));
}
function nearestAngle(current, target) {
let candidate = target;
while (candidate - current > 180) candidate -= 360;
while (candidate - current < -180) candidate += 360;
return candidate;
}
function cameraTransform(value) {
return `rotateX(${value.x}deg) rotateY(${value.y}deg)`;
}
function cameraSegmentDuration(start, target) {
const distance = Math.hypot(target.x - start.x, target.y - start.y);
return Math.round(Math.max(MOVE_TIMING.cameraMin, Math.min(MOVE_TIMING.cameraMax, distance * MOVE_TIMING.cameraPerDegree)));
}
function sameCameraPosition(left, right) {
return Math.hypot(left.x - right.x, left.y - right.y) < .6;
}
function cameraTravelStages(start, target) {
const stages = [];
let current = {...start};
const sidePitch = -14;
const startIsVertical = Math.abs(current.x) > 48;
const targetIsVertical = Math.abs(target.x) > 48;
// Human hands reorient a cube around one principal axis at a time. First
// return a top/bottom view to a side view, then yaw to the requested face,
// and only then pitch into a new top/bottom view.
if (startIsVertical) {
current = {x: sidePitch, y: current.y};
stages.push(current);
}
if (Math.abs(target.y - current.y) >= .6) {
current = {x: targetIsVertical ? sidePitch : target.x, y: target.y};
stages.push(current);
}
if (Math.abs(target.x - current.x) >= .6) stages.push({...target});
if (!stages.length && !sameCameraPosition(start, target)) stages.push({...target});
return stages;
}
async function animateCameraToFace(face) {
const preset = CAMERA_PRESETS[face] || CAMERA_PRESETS.HOME;
const start = {...camera};
const target = {
...preset,
x: nearestAngle(start.x, preset.x),
y: nearestAngle(start.y, preset.y),
};
camera = target;
$("viewLabel").textContent = preset.label;
document.querySelectorAll("[data-view]").forEach(button => button.classList.toggle("active", button.dataset.view === face));
const rig = $("cubeRig");
rig.classList.add("no-transition");
const vertical = face === "U" || face === "D";
$("cubeViewport").dataset.travelAxis = vertical ? "vertical" : "horizontal";
rig.style.transform = cameraTransform(start);
await nextPaint();
if (sameCameraPosition(start, target)) {
await sleep(MOVE_TIMING.sameFaceCamera);
} else {
let segmentStart = start;
for (const stage of cameraTravelStages(start, target)) {
await transitionTransform(rig, cameraTransform(stage), cameraSegmentDuration(segmentStart, stage), "cubic-bezier(.25,.72,.28,1)");
segmentStart = stage;
}
}
rig.style.transform = cameraTransform(target);
rig.style.transition = "";
}
function initializeCameraControls() {
const viewport = $("cubeViewport");
viewport.addEventListener("pointerdown", event => {
if (animating || event.button !== 0) return;
pointerStart = {x: event.clientX, y: event.clientY, cameraX: camera.x, cameraY: camera.y};
dragging = false;
});
viewport.addEventListener("pointermove", event => {
if (!pointerStart || animating) return;
const dx = event.clientX - pointerStart.x;
const dy = event.clientY - pointerStart.y;
if (Math.hypot(dx, dy) < 5 && !dragging) return;
dragging = true;
if (!viewport.hasPointerCapture(event.pointerId)) viewport.setPointerCapture(event.pointerId);
suppressStickerClick = true;
viewport.classList.add("dragging");
camera.x = Math.max(-88, Math.min(88, pointerStart.cameraX - dy * .42));
camera.y = pointerStart.cameraY + dx * .46;
camera.label = "Free view";
$("viewLabel").textContent = "Free view";
document.querySelectorAll("[data-view]").forEach(button => button.classList.remove("active"));
applyCamera(false);
});
const finishDrag = event => {
if (!pointerStart) return;
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
pointerStart = null;
viewport.classList.remove("dragging");
if (dragging) setTimeout(() => suppressStickerClick = false, 80);
dragging = false;
};
viewport.addEventListener("pointerup", finishDrag);
viewport.addEventListener("pointercancel", finishDrag);
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
if (!animating) chooseView(button.dataset.view);
}));
}
function buildColorMenu() {
const options = $("colorOptions");
options.replaceChildren();
COLORS.forEach(color => {
const button = document.createElement("button");
button.className = "color-option";
button.dataset.color = color.name;
const swatch = document.createElement("i");
swatch.style.background = safeColorHex(color.hex);
const label = document.createElement("span");
label.textContent = color.label;
const count = document.createElement("b");
count.className = "color-option-count";
button.append(swatch, label, count);
options.append(button);
});
$("colorOptions").querySelectorAll(".color-option").forEach(button => button.addEventListener("click", () => {
if (!selectedSticker || button.disabled) return;
cube[selectedSticker.face][selectedSticker.index] = button.dataset.color;
closeColorMenu();
renderCube(cube, true);
updateEntryStatus();
}));
if (!menuGlobalListenersBound) {
menuGlobalListenersBound = true;
$("removeColor").addEventListener("click", () => {
if (!selectedSticker) return;
cube[selectedSticker.face][selectedSticker.index] = null;
closeColorMenu();
renderCube(cube, true);
updateEntryStatus();
});
$("closeColorMenu").addEventListener("click", closeColorMenu);
document.addEventListener("pointerdown", event => {
if (!$("colorMenu").hidden && !$("colorMenu").contains(event.target) && !event.target.closest(".cube-sticker")) closeColorMenu();
});
document.addEventListener("keydown", event => {
if (event.key === "Escape" && !$("validationModal").hidden) {
closeValidationModal();
return;
}
if (event.key === "Escape") closeColorMenu();
if (!solution) return;
if (event.key === "ArrowRight") { event.preventDefault(); stepSolution(1); }
if (event.key === "ArrowLeft") { event.preventDefault(); stepSolution(-1); }
});
}
}
function buildSchemeEditor() {
const palette = $("paletteEditor");
palette.replaceChildren();
COLORS.forEach(color => {
const row = document.createElement("label");
row.className = "palette-slot";
row.dataset.color = color.name;
const swatch = document.createElement("i");
const select = document.createElement("select");
select.dataset.paletteChoice = color.name;
select.setAttribute("aria-label", `${color.label} palette color`);
const currentPreset = COLOR_PRESETS.find(preset => preset.label.toLowerCase() === color.label.toLowerCase()) || PRESET_MAP[color.name] || COLOR_PRESETS[0];
const choices = color.name === "white" ? [PRESET_MAP.white] : COLOR_PRESETS.filter(preset => preset.name !== "white");
choices.forEach(preset => {
const option = document.createElement("option");
option.value = preset.name;
option.textContent = preset.label;
option.selected = preset.name === currentPreset.name;
select.append(option);
});
if (color.name === "white") {
select.disabled = true;
select.title = "White is the fixed front reference.";
}
row.append(swatch, select);
palette.append(row);
});
const topology = $("topologyGrid");
topology.replaceChildren();
TOPOLOGY_FACES.forEach(face => {
const field = document.createElement("label");
field.innerHTML = `<span>${FACE_NAMES[face]} center</span>`;
const select = document.createElement("select");
select.dataset.topologyFace = face;
select.setAttribute("aria-label", `${FACE_NAMES[face]} center color`);
COLORS.filter(color => color.name !== "white").forEach(color => {
const option = document.createElement("option");
option.value = color.name;
option.textContent = color.label;
option.selected = SOLVED_COLORS[face] === color.name;
select.append(option);
});
field.append(select);
topology.append(field);
});
palette.querySelectorAll("select").forEach(select => select.addEventListener("change", previewSchemeEditor));
previewSchemeEditor();
}
function previewSchemeEditor() {
const draftColors = Object.fromEntries(COLORS.map(color => {
const select = document.querySelector(`[data-palette-choice="${color.name}"]`);
const preset = PRESET_MAP[select?.value] || color;
const swatch = document.querySelector(`.palette-slot[data-color="${color.name}"] > i`);
if (swatch) swatch.style.background = safeColorHex(preset.hex);
return [color.name, preset];
}));
document.querySelectorAll("[data-topology-face]").forEach(select => {
[...select.options].forEach(option => option.textContent = draftColors[option.value]?.label || option.value);
});
$("referenceSwatch").style.background = safeColorHex(draftColors.white?.hex || COLOR_MAP.white.hex);
}
function schemeDescription() {
return `${colorInfo(SOLVED_COLORS.F).label} front · ${colorInfo(SOLVED_COLORS.U).label} top · ${colorInfo(SOLVED_COLORS.R).label} right`;
}
function applyScheme() {
const selectedPresets = {};
COLORS.forEach(color => {
const select = document.querySelector(`[data-palette-choice="${color.name}"]`);
selectedPresets[color.name] = PRESET_MAP[select.value];
});
if (new Set(Object.values(selectedPresets).map(preset => preset.name)).size !== 6) {
$("schemeError").textContent = "Choose six different colors—each color can be used only once.";
return;
}
const nextTopology = {F: "white"};
document.querySelectorAll("[data-topology-face]").forEach(select => nextTopology[select.dataset.topologyFace] = select.value);
if (new Set(Object.values(nextTopology)).size !== 6) {
$("schemeError").textContent = "Each center color must be used exactly once in the topology.";
return;
}
COLORS = COLORS.map(color => ({...color, label: selectedPresets[color.name].label, hex: selectedPresets[color.name].hex}));
COLOR_MAP = Object.fromEntries(COLORS.map(color => [color.name, color]));
SOLVED_COLORS = nextTopology;
$("schemeError").textContent = "";
$("schemeSummary").textContent = schemeDescription();
buildColorMenu();
resetCubeToCenters();
closeColorMenu();
renderCube(cube, true);
updateEntryStatus();
hideEntryMessage();
chooseView("HOME");
$("schemeCard").open = false;
showEntryMessage("Cube setup applied. Keep White facing you while copying stickers.", "success");
}
function openColorMenu(face, index, event) {
if (!inputMode) return;
selectedSticker = {face, index};
updateColorOptionAvailability();
document.querySelectorAll(".cube-sticker").forEach(sticker => sticker.classList.toggle("selected", sticker.dataset.face === face && Number(sticker.dataset.index) === index));
$("colorMenuTitle").textContent = `${FACE_NAMES[face]} · ${stickerPositionName(index)}`;
const menu = $("colorMenu");
menu.hidden = false;
const width = 285;
const approximateHeight = 220;
menu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, event.clientX + 14))}px`;
menu.style.top = `${Math.max(12, Math.min(window.innerHeight - approximateHeight - 12, event.clientY - 40))}px`;
const firstOption = menu.querySelector(".color-option");
if (firstOption) firstOption.focus({preventScroll: true});
}
function closeColorMenu() {
$("colorMenu").hidden = true;
selectedSticker = null;
document.querySelectorAll(".cube-sticker.selected").forEach(sticker => sticker.classList.remove("selected"));
}
function colorCounts() {
const counts = Object.fromEntries(COLORS.map(color => [color.name, 0]));
let filled = 0;
FACES.forEach(face => cube[face].forEach(value => {
if (!value) return;
filled++;
counts[value] = (counts[value] || 0) + 1;
}));
return {counts, filled};
}
function updateColorOptionAvailability() {
const {counts} = colorCounts();
const currentColor = selectedSticker ? cube[selectedSticker.face][selectedSticker.index] : null;
$("colorOptions").querySelectorAll(".color-option").forEach(button => {
const color = button.dataset.color;
const isCurrent = color === currentColor;
const isFull = counts[color] >= 9;
const unavailable = isFull && !isCurrent;
button.disabled = unavailable;
button.classList.toggle("current", isCurrent);
button.classList.toggle("exhausted", unavailable);
button.setAttribute("aria-disabled", String(unavailable));
button.querySelector(".color-option-count").textContent = `${counts[color]}/9`;
button.title = unavailable
? `${colorInfo(color).label} is already used on all nine stickers.`
: isCurrent ? `${colorInfo(color).label} is the current sticker color.` : "";
});
}
function updateEntryStatus() {
const {counts, filled} = colorCounts();
const percent = Math.round(filled / 54 * 100);
$("progressLabel").textContent = `${filled} of 54 stickers`;
$("progressPercent").textContent = `${percent}%`;
$("entryProgress").style.width = `${percent}%`;
const centers = FACES.map(face => cube[face][4]).filter(Boolean);
const distinctCenters = centers.length === 6 && new Set(centers).size === 6;
const balanced = COLORS.every(color => counts[color.name] === 9);
const ready = filled === 54 && balanced && distinctCenters;
$("solveBtn").disabled = !ready || !solverReady;
const message = $("guideMessage").querySelector("p");
if (centers.length < 6) message.textContent = `Start with the six center stickers. ${6 - centers.length} center${6 - centers.length === 1 ? "" : "s"} still need a color.`;
else if (!distinctCenters) message.textContent = "Each center needs a different color. Click a repeated center to change it.";
else if (filled < 54) message.textContent = `Centers are done—great. Keep clicking stickers; ${54 - filled} remain.`;
else if (!balanced) message.textContent = "Almost there. Open a sticker and check the color counts inside the picker.";
else if (!solverReady) message.textContent = "Everything is filled correctly. The on-device solver is still preparing its lookup tables.";
else message.textContent = "Everything is filled correctly. Click Solve my cube for the physical check and solution.";
}
function showEntryMessage(text, kind = "error") {
const element = $("entryMessage");
element.textContent = text;
element.className = `status-message show ${kind}`;
}
function hideEntryMessage() {
$("entryMessage").className = "status-message";
}
function showValidationModal(message) {
closeColorMenu();
validationReturnFocus = document.activeElement;
$("validationMessage").textContent = message;
$("validationModal").hidden = false;
document.body.classList.add("modal-open");
requestAnimationFrame(() => $("validationReviewBtn").focus({preventScroll: true}));
}
function closeValidationModal() {
if ($("validationModal").hidden) return;
$("validationModal").hidden = true;
document.body.classList.remove("modal-open");
if (validationReturnFocus && typeof validationReturnFocus.focus === "function") {
validationReturnFocus.focus({preventScroll: true});
}
validationReturnFocus = null;
}
function replaceCubeState(nextState) {
FACES.forEach(face => cube[face] = [...nextState[face]].flat(Infinity));
renderCube(cube, true);
updateEntryStatus();
}
function loadSolvedCube() {
const state = Object.fromEntries(FACES.map(face => [face, Array(9).fill(SOLVED_COLORS[face])]));
replaceCubeState(state);
hideEntryMessage();
chooseView("HOME");
}
function loadRandomDemoCube() {
hideEntryMessage();
try {
const scramble = randomScramble();
let canonical = FACES.map(face => face.repeat(9)).join("");
scramble.forEach(move => canonical = applyCanonicalMove(canonical, move));
replaceCubeState(coloredFacesFromCanonical(canonical, SOLVED_COLORS));
showEntryMessage(`Random demo loaded: ${scramble.join(" ")}`, "success");
chooseView("HOME");
} catch (error) {
showEntryMessage(error.message || "A random demo cube could not be loaded.");
}
}
function clearCube() {
resetCubeToCenters();
closeColorMenu();
renderCube(cube, true);
updateEntryStatus();
hideEntryMessage();
chooseView("HOME");
}
async function solveCube() {
hideEntryMessage();
const button = $("solveBtn");
if (button.disabled) return;
button.disabled = true;
button.classList.add("busy");
try {
const workerResult = await requestWorkerSolve(Object.fromEntries(FACES.map(face => [face, [...cube[face]]])));
const result = buildVerifiedSolution(workerResult);
enterSolutionMode(result);
} catch (error) {
const message = error.message || "The solver request failed. Please try again.";
if (CUBE_INPUT_ERROR_CODES.has(error.code)) showValidationModal(message);
else showEntryMessage(message);
} finally {
button.classList.remove("busy");
updateEntryStatus();
}
}
function frameState(frame) {
return Object.fromEntries(FACES.map(face => [face, [...frame.faces[face]].flat(Infinity)]));
}
function enterSolutionMode(result) {
solution = result;
frameIndex = 0;
inputMode = false;
playing = false;
closeColorMenu();
$("entryPanel").hidden = true;
$("solutionPanel").hidden = false;
$("timelineCard").hidden = false;
$("stageTitle").textContent = "Watch the cube solve";
$("dragHint").innerHTML = "<span>↔</span> Drag to inspect between turns";
$("solutionSummary").textContent = result.move_count ? `${result.move_count}-move solution ready` : "Already solved";
$("moveCount").textContent = String(result.move_count);
$("searchTime").textContent = `${Number(result.solver.search_seconds).toFixed(3)}s`;
$("timeline").innerHTML = result.moves.length ? result.moves.map((move, index) => `<button class="move-chip" data-step="${index + 1}" title="Play through step ${index + 1}">${move}</button>`).join("") : "<span>No turns needed</span>";
$("timeline").querySelectorAll(".move-chip").forEach(chip => chip.addEventListener("click", () => playToStep(Number(chip.dataset.step))));
renderCube(frameState(result.frames[0]), false);
clearMoveCoach();
chooseView("HOME");
updateSolutionUI();
$("solutionPanel").scrollIntoView({behavior: "smooth", block: "nearest"});
}
function exitSolutionMode() {
stopPlayback();
solution = null;
frameIndex = 0;
inputMode = true;
resetCubeToCenters();
$("entryPanel").hidden = false;
$("solutionPanel").hidden = true;
$("timelineCard").hidden = true;
$("stageTitle").textContent = "Enter your cube";
clearMoveCoach();
$("dragHint").innerHTML = "<span>↔</span> Drag anywhere to rotate";
renderCube(cube, true);
chooseView("HOME");
updateEntryStatus();
}
function updateSolutionUI() {
if (!solution) return;
const total = solution.move_count;
$("stepCount").textContent = `${frameIndex} / ${total}`;
$("playerProgress").style.width = `${total ? frameIndex / total * 100 : 100}%`;
$("prevBtn").disabled = animating || frameIndex === 0;
$("nextBtn").disabled = animating || frameIndex === total;
$("playBtn").disabled = animating || total === 0;
$("playBtn").textContent = playing ? "Ⅱ" : "▶";
$("playBtn").setAttribute("aria-label", playing ? "Pause solution" : "Play solution");
if (frameIndex === 0) {
$("stepNumber").textContent = total ? "Starting position" : "Solved cube";
$("moveSymbol").textContent = total ? "Ready" : "Solved";
$("moveDirection").textContent = total ? "Your cube before the first move." : "No turns are needed.";
$("humanInstruction").textContent = total ? "Press Next or Play. The cube will face the correct side, then turn that layer." : "This cube is already solved.";
} else {
const instruction = solution.instructions[frameIndex - 1];
const turns = instruction.turn.clockwise_quarter_turns;
$("stepNumber").textContent = `Step ${frameIndex} of ${total}`;
$("moveSymbol").textContent = instruction.move;
$("moveDirection").textContent = `${FACE_NAMES[instruction.camera.front_face]} face · ${Math.abs(turns) === 2 ? "half turn" : turns > 0 ? "clockwise" : "counterclockwise"}`;
$("humanInstruction").textContent = instruction.text;
}
$("timeline").querySelectorAll(".move-chip").forEach((chip, index) => {
chip.classList.toggle("active", index + 1 === frameIndex);
chip.classList.toggle("done", index + 1 < frameIndex);
if (index + 1 === frameIndex) chip.scrollIntoView({behavior: "smooth", block: "nearest", inline: "center"});
});
}
function setMotionPhase(phase, label) {
$("cubeViewport").dataset.motionPhase = phase;
$("coachPhase").textContent = label;
}
function clearMoveCoach() {
$("cubeViewport").dataset.motionPhase = "idle";
delete $("cubeViewport").dataset.travelAxis;
$("moveCoach").hidden = true;
$("turnGuide").hidden = true;
document.querySelectorAll(".cube-sticker.active-turn-face").forEach(sticker => sticker.classList.remove("active-turn-face"));
}
function effectiveTurns(instruction, direction) {
return instruction.turn.clockwise_quarter_turns * direction;
}
function turnDescription(turns) {
if (Math.abs(turns) === 2) return {short: "180° HALF TURN", direction: "a half turn (180°)", arrow: "180°"};
if (turns > 0) return {short: "CLOCKWISE", direction: "clockwise by 90°", arrow: "↻"};
return {short: "COUNTERCLOCKWISE", direction: "counterclockwise by 90°", arrow: "↺"};
}
function announceMove(instruction, direction, target) {
const face = instruction.turn.layer;
const detail = turnDescription(effectiveTurns(instruction, direction));
const action = direction > 0 ? "Next move" : "Undo move";
const text = `${action}: turn the ${FACE_NAMES[face]} face ${detail.direction}.`;
$("moveCoach").hidden = false;
$("coachFace").textContent = `${FACE_NAMES[face]} FACE`;
$("coachInstruction").textContent = text;
$("turnArrow").textContent = detail.arrow;
$("turnGuideLabel").textContent = detail.short;
$("stepNumber").textContent = `${action} · ${Math.max(frameIndex, target)} of ${solution.move_count}`;
$("moveSymbol").textContent = instruction.move;
$("moveDirection").textContent = `${FACE_NAMES[face]} face · ${detail.direction}`;
$("humanInstruction").textContent = text;
setMotionPhase("announce", `${instruction.move} · watch first`);
}
function highlightTurnFace(face, enabled = true) {
document.querySelectorAll(`.cube-sticker[data-face="${face}"]`).forEach(sticker => sticker.classList.toggle("active-turn-face", enabled));
}
async function animateLayer(instruction, direction, duration) {
const face = instruction.turn.layer;
const spec = TURN_SPECS[face];
const layer = document.createElement("div");
layer.className = "turn-layer";
layer.dataset.face = face;
layer.dataset.axis = spec.axis;
const cubies = [...$("cube3d").querySelectorAll(":scope > .cubie")].filter(spec.select);
cubies.forEach(cubie => layer.append(cubie));
$("cube3d").append(layer);
const quarters = instruction.turn.clockwise_quarter_turns * direction;
const degrees = spec.sign * quarters * 90;
const targetTransform = `rotate${spec.axis}(${degrees}deg)`;
layer.style.transform = `rotate${spec.axis}(0deg)`;
await transitionTransform(layer, targetTransform, duration, "cubic-bezier(.42,0,.18,1)");
layer.style.transform = targetTransform;
}
async function stepSolution(direction) {
if (!solution || animating) return false;
const target = frameIndex + direction;
if (target < 0 || target > solution.move_count) return false;
animating = true;
updateSolutionUI();
const instructionIndex = direction > 0 ? frameIndex : target;
const instruction = solution.instructions[instructionIndex];
const turns = effectiveTurns(instruction, direction);
const motionScale = reduceMotion ? .82 : 1;
const turnDuration = Math.round((Math.abs(turns) === 2 ? MOVE_TIMING.halfTurn : MOVE_TIMING.quarterTurn) * motionScale);
try {
announceMove(instruction, direction, target);
await sleep(Math.round(MOVE_TIMING.announce * motionScale));
setMotionPhase("camera", `Slowly moving to the ${FACE_NAMES[instruction.camera.front_face]} face`);
await animateCameraToFace(instruction.camera.front_face);
highlightTurnFace(instruction.turn.layer, true);
$("turnGuide").hidden = false;
setMotionPhase("ready", "This highlighted layer will turn");
await sleep(Math.round(MOVE_TIMING.faceHold * motionScale));
setMotionPhase("turning", `Now turn ${turnDescription(turns).direction}`);
await animateLayer(instruction, direction, turnDuration);
setMotionPhase("settling", "Turn complete · hold this position");
await sleep(Math.round(MOVE_TIMING.settle * motionScale));
frameIndex = target;
renderCube(frameState(solution.frames[frameIndex]), false);
highlightTurnFace(instruction.turn.layer, true);
await sleep(180);
} finally {
animating = false;
clearMoveCoach();
updateSolutionUI();
}
return true;
}
function stopPlayback() {
playing = false;
jumpVersion++;
updateSolutionUI();
}
async function togglePlayback() {
if (!solution || animating) return;
if (playing) { stopPlayback(); return; }
if (frameIndex >= solution.move_count) {
frameIndex = 0;
renderCube(frameState(solution.frames[0]), false);
}
playing = true;
const version = ++jumpVersion;
updateSolutionUI();
while (playing && version === jumpVersion && frameIndex < solution.move_count) {
await stepSolution(1);
if (playing) await sleep(Math.round(MOVE_TIMING.betweenMoves * (reduceMotion ? .82 : 1)));
}
if (version === jumpVersion) {
playing = false;
updateSolutionUI();
}
}
async function playToStep(target) {
if (!solution || animating || target === frameIndex) return;
playing = false;
const version = ++jumpVersion;
const direction = target > frameIndex ? 1 : -1;
while (version === jumpVersion && frameIndex !== target) {
await stepSolution(direction);
if (frameIndex !== target) await sleep(Math.round(MOVE_TIMING.betweenMoves * .7));
}
}
function initialize() {
resetCubeToCenters();
document.body.dataset.appVersion = "cube-solver-v2";
buildSchemeEditor();
buildColorMenu();
initializeCameraControls();
$("clearBtn").addEventListener("click", clearCube);
$("solvedBtn").addEventListener("click", loadSolvedCube);
$("demoBtn").addEventListener("click", loadRandomDemoCube);
$("solveBtn").addEventListener("click", solveCube);
$("prevBtn").addEventListener("click", () => { stopPlayback(); stepSolution(-1); });
$("nextBtn").addEventListener("click", () => { stopPlayback(); stepSolution(1); });
$("playBtn").addEventListener("click", togglePlayback);
$("startOverBtn").addEventListener("click", exitSolutionMode);
$("applySchemeBtn").addEventListener("click", applyScheme);
$("validationCloseBtn").addEventListener("click", closeValidationModal);
$("validationReviewBtn").addEventListener("click", closeValidationModal);
$("validationBackdrop").addEventListener("click", closeValidationModal);
renderCube(cube, true);
chooseView("HOME", false);
updateEntryStatus();
initializeSolver();
}
initialize();