Claude Opus 5 · Claude Code Web — Simulador de cubo magico 3D em um unico HTML
src/index.html
<!DOCTYPE html>
<html lang="pt-BR" class="theme-dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cubo Mágico 3×3×3 — Simulador</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #07090c;
--bg-2: #0c1015;
--panel: #10151c;
--panel-2: #151b24;
--line: #222b36;
--line-2: #2e3947;
--txt: #e6ecf3;
--txt-dim: #8894a4;
--txt-mute: #5b6675;
--accent: #4ad9ff;
--accent-2: #b6ff4a;
--warn: #ffb84a;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DM Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, "Segoe UI", Inter, Roboto, sans-serif;
}
html, body { height: 100%; }
body {
margin: 0;
background:
radial-gradient(1100px 700px at 30% 8%, #16202b 0%, transparent 62%),
radial-gradient(900px 620px at 88% 92%, #131a26 0%, transparent 60%),
var(--bg);
color: var(--txt);
font-family: var(--sans);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
#app {
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 336px;
grid-template-areas: "head head" "stage side";
}
/* ---------- header ---------- */
header {
grid-area: head;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(20,26,34,.9), rgba(10,13,18,.6));
backdrop-filter: blur(6px);
}
.brand { display: flex; align-items: baseline; gap: 10px; }
.brand h1 {
margin: 0; font-size: 15px; font-weight: 650; letter-spacing: .14em; text-transform: uppercase;
}
.brand .sub { font-family: var(--mono); font-size: 11px; color: var(--txt-mute); letter-spacing: .06em; }
.hud { margin-left: auto; display: flex; align-items: stretch; gap: 10px; }
.stat {
min-width: 108px;
padding: 6px 12px 7px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
display: flex; flex-direction: column; gap: 2px;
}
.stat .k {
font-family: var(--mono); font-size: 9.5px; letter-spacing: .16em;
text-transform: uppercase; color: var(--txt-mute);
}
.stat .v {
font-family: var(--mono); font-size: 20px; line-height: 1.05; font-variant-numeric: tabular-nums;
color: var(--txt);
}
.stat.is-live .v { color: var(--accent); }
.stat.is-solved .v { color: var(--accent-2); }
#status {
display: flex; align-items: center; gap: 8px;
padding: 0 14px;
border: 1px solid var(--line); border-radius: 8px;
background: var(--panel);
font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
color: var(--txt-dim);
min-width: 156px;
}
#status .dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--txt-mute); flex: none;
}
#status[data-s="solved"] { color: var(--accent-2); border-color: #33501f; }
#status[data-s="solved"] .dot { background: var(--accent-2); box-shadow: 0 0 10px var(--accent-2); }
#status[data-s="scramble"] { color: var(--warn); border-color: #543f18; }
#status[data-s="scramble"] .dot { background: var(--warn); }
#status[data-s="solving"] .dot { background: var(--accent); }
/* ---------- stage ---------- */
#stage { grid-area: stage; position: relative; min-width: 0; min-height: 0; }
#stage canvas { display: block; width: 100%; height: 100%; }
#hint {
position: absolute; left: 18px; bottom: 16px;
font-family: var(--mono); font-size: 10.5px; color: var(--txt-mute);
letter-spacing: .05em; line-height: 1.7; pointer-events: none;
}
#banner {
position: absolute; inset: auto 0 auto 0; top: 28px;
display: flex; justify-content: center; pointer-events: none;
opacity: 0; transform: translateY(-8px); transition: opacity .35s ease, transform .35s ease;
}
#banner.on { opacity: 1; transform: none; }
#banner span {
font-family: var(--mono); font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
color: #0a0f06; background: var(--accent-2);
padding: 8px 20px; border-radius: 999px; font-weight: 600;
box-shadow: 0 0 40px rgba(182,255,74,.35);
}
/* ---------- sidebar ---------- */
aside {
grid-area: side;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(16,21,28,.85), rgba(9,12,17,.85));
overflow-y: auto;
padding: 16px 16px 24px;
display: flex; flex-direction: column; gap: 16px;
}
aside::-webkit-scrollbar { width: 8px; }
aside::-webkit-scrollbar-thumb { background: #232c38; border-radius: 8px; }
.sec-head {
display: flex; align-items: center; gap: 10px;
font-family: var(--mono); font-size: 9.5px; letter-spacing: .18em;
text-transform: uppercase; color: var(--txt-mute); margin-bottom: 9px;
}
.sec-head::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.moves { display: grid; grid-template-columns: 34px repeat(3, 1fr); gap: 6px; align-items: center; }
.moves .lbl {
font-family: var(--mono); font-size: 11px; color: var(--txt-dim);
display: flex; align-items: center; gap: 6px;
}
.moves .swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
button {
font-family: var(--mono);
color: var(--txt);
background: var(--panel-2);
border: 1px solid var(--line-2);
border-radius: 7px;
cursor: pointer;
transition: background .12s ease, border-color .12s ease, transform .06s ease, color .12s ease;
user-select: none;
}
button:hover:not(:disabled) { background: #1e2734; border-color: #3d4a5c; }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: .38; cursor: not-allowed; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.moves button { padding: 8px 0; font-size: 12.5px; letter-spacing: .04em; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.actions button {
padding: 11px 8px; font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
display: flex; flex-direction: column; align-items: center; gap: 3px;
}
.actions button .kbd { font-size: 9px; color: var(--txt-mute); letter-spacing: .08em; }
.actions button.wide { grid-column: 1 / -1; }
#btnScramble { border-color: #4a3a17; color: var(--warn); }
#btnScramble:hover:not(:disabled) { background: #241d0e; border-color: #6b5420; }
.log {
min-height: 62px; max-height: 84px; overflow-y: auto;
border: 1px solid var(--line); border-radius: 8px; background: #0a0e13;
padding: 8px 10px;
font-family: var(--mono); font-size: 11.5px; line-height: 1.9; color: var(--txt-dim);
word-spacing: 3px;
}
.log .last { color: var(--accent); }
.log .empty { color: var(--txt-mute); font-style: italic; }
.log::-webkit-scrollbar { width: 6px; }
.log::-webkit-scrollbar-thumb { background: #232c38; border-radius: 6px; }
.legend { font-family: var(--mono); font-size: 10.5px; color: var(--txt-mute); line-height: 1.95; }
.legend b { color: var(--txt-dim); font-weight: 500; }
kbd {
font-family: var(--mono); font-size: 10px;
background: #171e27; border: 1px solid var(--line-2); border-bottom-width: 2px;
border-radius: 4px; padding: 1px 5px; color: var(--txt); margin: 0 1px;
}
#modChip {
display: inline-flex; align-items: center; gap: 6px;
margin-left: auto; padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--line-2); background: #131a23;
font-size: 9px; letter-spacing: .12em; color: var(--txt-mute);
text-transform: none;
}
#modChip.on { color: var(--warn); border-color: #6b5420; background: #241d0e; }
@media (max-height: 780px) {
.actions button { padding: 9px 6px; }
.moves button { padding: 7px 0; }
}
</style>
<script type="importmap">
{"imports":{
"three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}}
</script>
</head>
<body>
<div id="app">
<header>
<div class="brand">
<h1>Cubo Mágico 3×3×3</h1>
<span class="sub">three.js · webgl</span>
</div>
<div class="hud">
<div id="status" data-s="idle"><span class="dot"></span><span id="statusTxt">Resolvido</span></div>
<div class="stat" id="statTime"><span class="k">Tempo</span><span class="v" id="timer">00:00.0</span></div>
<div class="stat" id="statMoves"><span class="k">Movimentos</span><span class="v" id="moveCount">0</span></div>
</div>
</header>
<div id="stage">
<div id="banner"><span>Cubo resolvido</span></div>
<div id="hint">arraste para girar a câmera · scroll para zoom</div>
</div>
<aside>
<section>
<div class="sec-head">Notação<span id="modChip">2× off</span></div>
<div class="moves" id="moveGrid"></div>
</section>
<section>
<div class="sec-head">Ações</div>
<div class="actions">
<button id="btnScramble" class="wide">Embaralhar<span class="kbd">espaço</span></button>
<button id="btnUndo">Desfazer<span class="kbd">backspace</span></button>
<button id="btnReset">Resetar<span class="kbd">esc</span></button>
</div>
</section>
<section>
<div class="sec-head">Histórico</div>
<div class="log" id="log"><span class="empty">nenhum movimento</span></div>
</section>
<section>
<div class="sec-head">Teclado</div>
<div class="legend">
<b>U D L R F B</b> — giro horário<br>
<kbd>Shift</kbd>+letra — anti-horário <b>′</b><br>
<kbd>Alt</kbd>+letra — meia volta <b>2</b><br>
<kbd>2</kbd> e depois a letra — meia volta <b>2</b><br>
<kbd>Espaço</kbd> embaralhar<br>
<kbd>⌫</kbd> desfazer · <kbd>Esc</kbd> resetar
</div>
</section>
</aside>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/* ══════════════════════════════════════════════════════════════
1. Modelo do cubo
══════════════════════════════════════════════════════════════ */
const COLORS = {
U: 0xf4f6f8, // branco
D: 0xffd23f, // amarelo
F: 0x1fbf5a, // verde
B: 0x2b7bff, // azul
R: 0xe8462f, // vermelho
L: 0xff8a1f, // laranja
};
const FACES = {
U: { axis: 'y', val: 1, sign: -1 },
D: { axis: 'y', val: -1, sign: 1 },
R: { axis: 'x', val: 1, sign: -1 },
L: { axis: 'x', val: -1, sign: 1 },
F: { axis: 'z', val: 1, sign: -1 },
B: { axis: 'z', val: -1, sign: 1 },
};
const FACE_ORDER = ['U', 'D', 'L', 'R', 'F', 'B'];
const TURN_MS = 165; // giro do usuário
const SCRAMB_MS = 78; // giro durante o embaralhamento
/* ══════════════════════════════════════════════════════════════
2. Cena
══════════════════════════════════════════════════════════════ */
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.set(5.0, 4.4, 6.4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.075;
controls.enablePan = false;
controls.rotateSpeed = 0.85;
controls.zoomSpeed = 0.7;
controls.minDistance = 5;
controls.maxDistance = 16;
controls.target.set(0, 0, 0);
scene.add(new THREE.HemisphereLight(0x9fc4ff, 0x0a0d12, 0.85));
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const key = new THREE.DirectionalLight(0xffffff, 1.5);
key.position.set(6, 9, 7);
scene.add(key);
const fill = new THREE.DirectionalLight(0x8fb6ff, 0.55);
fill.position.set(-7, 2, -5);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.4);
rim.position.set(0, -6, 3);
scene.add(rim);
/* ══════════════════════════════════════════════════════════════
3. Geometria dos cubinhos
══════════════════════════════════════════════════════════════ */
function roundedSquare(size, r) {
const s = new THREE.Shape();
const h = size / 2;
s.moveTo(-h + r, -h);
s.lineTo( h - r, -h);
s.quadraticCurveTo( h, -h, h, -h + r);
s.lineTo( h, h - r);
s.quadraticCurveTo( h, h, h - r, h);
s.lineTo(-h + r, h);
s.quadraticCurveTo(-h, h, -h, h - r);
s.lineTo(-h, -h + r);
s.quadraticCurveTo(-h, -h, -h + r, -h);
return s;
}
const stickerGeo = new THREE.ShapeGeometry(roundedSquare(0.84, 0.14), 6);
const bodyGeo = new THREE.BoxGeometry(0.97, 0.97, 0.97);
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x0b0e13, roughness: 0.55, metalness: 0.12 });
const stickerMats = {};
for (const [f, hex] of Object.entries(COLORS)) {
stickerMats[f] = new THREE.MeshStandardMaterial({
color: hex, roughness: 0.38, metalness: 0.03,
side: THREE.DoubleSide,
});
}
// posição / rotação de cada adesivo em relação ao centro do cubinho
const STICKER_XFORM = {
R: { pos: [ 0.4915, 0, 0 ], rot: [ 0, Math.PI / 2, 0 ] },
L: { pos: [-0.4915, 0, 0 ], rot: [ 0, -Math.PI / 2, 0 ] },
U: { pos: [ 0, 0.4915, 0 ], rot: [-Math.PI / 2, 0, 0 ] },
D: { pos: [ 0, -0.4915, 0 ], rot: [ Math.PI / 2, 0, 0 ] },
F: { pos: [ 0, 0, 0.4915 ], rot: [ 0, 0, 0 ] },
B: { pos: [ 0, 0, -0.4915 ], rot: [ 0, Math.PI, 0 ] },
};
const cube = new THREE.Group();
const cubies = [];
scene.add(cube);
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
for (let z = -1; z <= 1; z++) {
if (x === 0 && y === 0 && z === 0) continue; // miolo invisível
const c = new THREE.Mesh(bodyGeo, bodyMat);
c.position.set(x, y, z);
const on = { R: x === 1, L: x === -1, U: y === 1, D: y === -1, F: z === 1, B: z === -1 };
for (const f of FACE_ORDER) {
if (!on[f]) continue;
const s = new THREE.Mesh(stickerGeo, stickerMats[f]);
const t = STICKER_XFORM[f];
s.position.set(...t.pos);
s.rotation.set(...t.rot);
s.userData.color = f;
c.add(s);
}
cube.add(c);
cubies.push(c);
}
}
}
// arestas sutis, dão volume ao contorno
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(3.02, 3.02, 3.02)),
new THREE.LineBasicMaterial({ color: 0x2c3644, transparent: true, opacity: 0.55 })
);
cube.add(outline);
/* ══════════════════════════════════════════════════════════════
4. Rotação de camada
══════════════════════════════════════════════════════════════ */
const pivot = new THREE.Object3D();
cube.add(pivot);
let anim = null; // { axis, target, elapsed, duration, group, resolve }
const queue = []; // fila de movimentos pendentes
let busy = false; // travado durante embaralhamento
function parseMove(mv) {
const face = mv[0];
const mod = mv.slice(1);
return { face, turns: mod === '2' ? 2 : 1, dir: mod === "'" ? -1 : 1, label: mv };
}
function invertMove(mv) {
if (mv.endsWith('2')) return mv;
return mv.endsWith("'") ? mv[0] : mv + "'";
}
function snap(obj) {
obj.position.set(
Math.round(obj.position.x),
Math.round(obj.position.y),
Math.round(obj.position.z)
);
// todas as rotações são múltiplas de 90° → a matriz só tem 0, 1 e -1
const m = new THREE.Matrix4().makeRotationFromQuaternion(obj.quaternion);
for (let i = 0; i < 16; i++) m.elements[i] = Math.round(m.elements[i]);
obj.quaternion.setFromRotationMatrix(m);
}
function startMove(job) {
const { face, turns, dir } = parseMove(job.move);
const spec = FACES[face];
if (!spec) return finishMove(job);
pivot.rotation.set(0, 0, 0);
pivot.updateMatrixWorld(true);
const group = cubies.filter(c => Math.round(c.position[spec.axis]) === spec.val);
for (const c of group) pivot.attach(c);
anim = {
axis: spec.axis,
target: spec.sign * dir * turns * (Math.PI / 2),
elapsed: 0,
duration: job.duration ?? TURN_MS,
group,
job,
};
}
function finishAnim() {
pivot.rotation[anim.axis] = anim.target;
pivot.updateMatrixWorld(true);
for (const c of anim.group) { cube.attach(c); snap(c); }
pivot.rotation.set(0, 0, 0);
const job = anim.job;
anim = null;
finishMove(job);
}
function finishMove(job) {
if (job.onDone) job.onDone();
pump();
}
function pump() {
if (anim || !queue.length) return;
startMove(queue.shift());
}
/** Enfileira um movimento. opts: { count, record, duration, onDone } */
function doMove(move, opts = {}) {
const { count = true, record = true, duration, onDone } = opts;
if (record) history.push(move);
if (count) {
moves++;
if (!timerRunning) startTimer();
renderCount();
}
renderLog();
queue.push({ move, duration, onDone });
pump();
}
const easeInOut = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/* ══════════════════════════════════════════════════════════════
5. Estado, cronômetro, contador
══════════════════════════════════════════════════════════════ */
let history = [];
let moves = 0;
let timerRunning = false;
let t0 = 0;
let tAccum = 0;
let solvedState = true;
const elTimer = document.getElementById('timer');
const elCount = document.getElementById('moveCount');
const elLog = document.getElementById('log');
const elStatus = document.getElementById('status');
const elStatusTxt = document.getElementById('statusTxt');
const elBanner = document.getElementById('banner');
const statTime = document.getElementById('statTime');
function fmt(ms) {
const cs = Math.floor(ms / 100) % 10;
const s = Math.floor(ms / 1000) % 60;
const m = Math.floor(ms / 60000);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${cs}`;
}
function startTimer() { timerRunning = true; t0 = performance.now(); statTime.classList.add('is-live'); statTime.classList.remove('is-solved'); }
function stopTimer() { if (timerRunning) { tAccum += performance.now() - t0; timerRunning = false; } statTime.classList.remove('is-live'); }
function resetTimer() { timerRunning = false; tAccum = 0; elTimer.textContent = fmt(0); statTime.classList.remove('is-live', 'is-solved'); }
function elapsed() { return tAccum + (timerRunning ? performance.now() - t0 : 0); }
function renderCount() { elCount.textContent = String(moves); }
function renderLog() {
if (!history.length) { elLog.innerHTML = '<span class="empty">nenhum movimento</span>'; return; }
const tail = history.slice(-40);
elLog.innerHTML = tail
.map((m, i) => (i === tail.length - 1 ? `<span class="last">${m}</span>` : m))
.join(' ');
elLog.scrollTop = elLog.scrollHeight;
}
function setStatus(kind, text) {
elStatus.dataset.s = kind;
elStatusTxt.textContent = text;
}
const _wp = new THREE.Vector3();
function isSolved() {
cube.updateMatrixWorld(true);
const faces = {};
for (const c of cubies) {
for (const s of c.children) {
if (!s.userData.color) continue;
s.getWorldPosition(_wp);
let k = null;
if (Math.abs(_wp.x) > 1.4) k = _wp.x > 0 ? 'R' : 'L';
else if (Math.abs(_wp.y) > 1.4) k = _wp.y > 0 ? 'U' : 'D';
else if (Math.abs(_wp.z) > 1.4) k = _wp.z > 0 ? 'F' : 'B';
if (!k) continue;
(faces[k] ||= []).push(s.userData.color);
}
}
const vals = Object.values(faces);
return vals.length === 6 && vals.every(a => a.length === 9 && a.every(v => v === a[0]));
}
function evaluate() {
if (busy || anim || queue.length) return;
const now = isSolved();
if (now && !solvedState) {
stopTimer();
statTime.classList.add('is-solved');
setStatus('solved', 'Resolvido');
elBanner.classList.add('on');
setTimeout(() => elBanner.classList.remove('on'), 2600);
} else if (!now) {
setStatus('solving', 'Embaralhado');
elBanner.classList.remove('on');
} else {
setStatus('solved', 'Resolvido');
}
solvedState = now;
}
/* ══════════════════════════════════════════════════════════════
6. Ações
══════════════════════════════════════════════════════════════ */
function userMove(move) {
if (busy) return;
if (queue.length > 6) return; // evita fila infinita em teclado repetido
doMove(move, { onDone: evaluate });
}
function undo() {
if (busy || !history.length) return;
const last = history.pop();
moves = Math.max(0, moves - 1);
renderCount();
renderLog();
queue.push({ move: invertMove(last), onDone: evaluate });
pump();
}
function scramble(n = 22) {
if (busy) return;
busy = true;
queue.length = 0;
history = [];
moves = 0;
renderCount(); renderLog();
resetTimer();
solvedState = true;
setStatus('scramble', 'Embaralhando…');
syncButtons();
const seq = [];
const mods = ['', "'", '2'];
let prev = '', prev2 = '';
const OPPOSITE = { U: 'D', D: 'U', L: 'R', R: 'L', F: 'B', B: 'F' };
while (seq.length < n) {
const f = FACE_ORDER[(Math.random() * 6) | 0];
if (f === prev) continue;
if (f === prev2 && OPPOSITE[f] === prev) continue; // evita R L R
prev2 = prev; prev = f;
seq.push(f + mods[(Math.random() * 3) | 0]);
}
seq.forEach((mv, i) => {
queue.push({
move: mv,
duration: SCRAMB_MS,
onDone: i === seq.length - 1
? () => {
busy = false;
solvedState = false;
setStatus('solving', 'Embaralhado');
syncButtons();
evaluate();
}
: undefined,
});
});
pump();
}
function reset() {
if (busy) return;
queue.length = 0;
if (anim) { finishAnim(); queue.length = 0; }
for (const c of cubies) {
cube.attach(c);
c.quaternion.identity();
c.position.copy(c.userData.home);
}
history = [];
moves = 0;
renderCount(); renderLog();
resetTimer();
solvedState = true;
setStatus('solved', 'Resolvido');
elBanner.classList.remove('on');
}
for (const c of cubies) c.userData.home = c.position.clone();
function syncButtons() {
for (const b of document.querySelectorAll('#moveGrid button, #btnUndo, #btnReset, #btnScramble')) {
b.disabled = busy;
}
document.getElementById('btnUndo').disabled = busy || history.length === 0;
}
/* ══════════════════════════════════════════════════════════════
7. Interface
══════════════════════════════════════════════════════════════ */
const grid = document.getElementById('moveGrid');
const FACE_NAMES = { U: 'Cima', D: 'Baixo', L: 'Esquerda', R: 'Direita', F: 'Frente', B: 'Trás' };
for (const f of FACE_ORDER) {
const lbl = document.createElement('div');
lbl.className = 'lbl';
lbl.innerHTML = `<span class="swatch" style="background:#${COLORS[f].toString(16).padStart(6, '0')}"></span>${f}`;
lbl.title = FACE_NAMES[f];
grid.appendChild(lbl);
for (const mod of ['', "'", '2']) {
const b = document.createElement('button');
b.textContent = f + mod;
b.title = `${FACE_NAMES[f]} — ${mod === '' ? 'horário' : mod === "'" ? 'anti-horário' : '180°'}`;
b.addEventListener('click', () => userMove(f + mod));
grid.appendChild(b);
}
}
document.getElementById('btnScramble').addEventListener('click', () => scramble());
document.getElementById('btnUndo').addEventListener('click', undo);
document.getElementById('btnReset').addEventListener('click', reset);
// modificador "2" pegajoso, para quem prefere digitar 2 antes da letra
let stickyDouble = false;
const chip = document.getElementById('modChip');
function setSticky(v) {
stickyDouble = v;
chip.classList.toggle('on', v);
chip.textContent = v ? '2× ligado' : '2× off';
}
window.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey) return;
const k = e.key;
if (k === '2') { e.preventDefault(); setSticky(!stickyDouble); return; }
if (k === ' ') { e.preventDefault(); scramble(); return; }
if (k === 'Backspace') { e.preventDefault(); undo(); return; }
if (k === 'Escape') { e.preventDefault(); reset(); return; }
const f = k.toUpperCase();
if (!FACES[f]) return;
e.preventDefault();
let mod = '';
if (e.altKey || stickyDouble) mod = '2';
else if (e.shiftKey) mod = "'";
if (stickyDouble) setSticky(false);
userMove(f + mod);
const btn = [...grid.querySelectorAll('button')].find(b => b.textContent === f + mod);
if (btn) { btn.style.borderColor = 'var(--accent)'; setTimeout(() => (btn.style.borderColor = ''), 160); }
});
/* ══════════════════════════════════════════════════════════════
8. Loop
══════════════════════════════════════════════════════════════ */
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
// mantém o cubo inteiro visível em telas baixas
camera.fov = h < 640 ? 48 : 40;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
let prev = performance.now();
renderer.setAnimationLoop((now) => {
const dt = Math.min(now - prev, 60);
prev = now;
if (anim) {
anim.elapsed += dt;
const t = Math.min(anim.elapsed / anim.duration, 1);
pivot.rotation[anim.axis] = anim.target * easeInOut(t);
if (t >= 1) finishAnim();
}
if (timerRunning) elTimer.textContent = fmt(elapsed());
controls.update();
renderer.render(scene, camera);
});
/* estado inicial */
renderCount();
renderLog();
resetTimer();
setStatus('solved', 'Resolvido');
syncButtons();
setInterval(syncButtons, 220);
</script>
</body>
</html>nenhum arquivo aberto
Crie um simulador de cubo magico 3D em um unico arquivo HTML.
Requisitos funcionais:
- Cubo 3x3x3 renderizado em 3D com Three.js, com as seis cores nas faces
- Rotacao animada de camadas, uma camada por vez, sem quebrar o estado do cubo
- Notacao padrao (U, D, L, R, F, B) com as variantes ' (anti-horario) e 2 (meia volta)
- Botoes de embaralhar, desfazer o ultimo movimento e resetar
- Contador de movimentos e cronometro
- Controle por teclado e por botoes na tela
- Camera orbital com o mouse (arrastar para girar, scroll para zoom)
Restricoes tecnicas (obrigatorias):
- UM unico arquivo index.html. Todo o CSS e todo o JavaScript inline. Nao crie nenhum outro arquivo
- Three.js vem do CDN, declarado num <script type="importmap"> inline e consumido por um <script type="module"> tambem inline:
<script type="importmap">
{"imports":{
"three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
Atencao: importar https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/controls/OrbitControls.js pela URL direta NAO funciona — esse arquivo faz `import ... from 'three'`, e o especificador bare so resolve pelo importmap
- NAO use <script type="module" src="..."> apontando para arquivo local
- NAO use localStorage nem sessionStorage — eles lancam SecurityError no ambiente onde a pagina roda
- A pagina roda dentro de um iframe sandbox de origem opaca: sem cookies, sem acesso a janela pai
- Deve caber e ficar legivel em 1440x900- Arquivo unico de 27 KB / 785 linhas. Cubo com adesivos de cantos arredondados sobre corpo escuro, grade de notacao completa na lateral (as tres variantes de cada face, incluindo as meias-voltas 2), cronometro, contador, historico e um selo de estado que alterna entre RESOLVIDO e EMBARALHADO. Verificado dentro do iframe de origem opaca com interacao real por teclado: a sequencia (R U R' U') repetida seis vezes — que tem ordem 6 e portanto devolve um cubo integro ao estado resolvido — deixa o selo em RESOLVIDO ao fim dos 24 movimentos, sem nenhum erro de console. Um unico R muda o selo para EMBARALHADO, o que confirma que a deteccao nao esta presa em "resolvido".