Claude Sonnet 5 · Claude Code Web — Simulador de cubo magico 3D em um unico HTML
src/index.html
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cubo Magico 3D</title>
<style>
:root{
--bg-0:#0a0c11;
--bg-1:#12151d;
--bg-2:#181c26;
--panel-border:#262c3a;
--text-0:#eef1f7;
--text-1:#9aa3b8;
--accent:#5b8cff;
--u-color:#ffffff;
--d-color:#ffd500;
--l-color:#ff5800;
--r-color:#b71234;
--f-color:#009e60;
--b-color:#0051ba;
}
*{box-sizing:border-box;}
html,body{
margin:0;
padding:0;
height:100%;
overflow:hidden;
background:var(--bg-0);
color:var(--text-0);
font-family:"Segoe UI",Roboto,-apple-system,BlinkMacSystemFont,sans-serif;
}
#app{
display:flex;
flex-direction:column;
height:100vh;
width:100vw;
}
header{
display:flex;
align-items:center;
justify-content:space-between;
padding:10px 20px;
background:linear-gradient(180deg,var(--bg-2),var(--bg-1));
border-bottom:1px solid var(--panel-border);
flex:0 0 auto;
z-index:5;
}
header h1{
font-size:17px;
margin:0;
font-weight:600;
letter-spacing:.02em;
display:flex;
align-items:center;
gap:8px;
}
header h1 .dot{
width:10px;height:10px;border-radius:2px;
background:conic-gradient(from 0deg,#fff 0 60deg,#ffd500 60deg 120deg,#009e60 120deg 180deg,#0051ba 180deg 240deg,#b71234 240deg 300deg,#ff5800 300deg 360deg);
display:inline-block;
}
.stats{
display:flex;
gap:22px;
font-family:"Consolas","DM Mono",monospace;
}
.stat{
display:flex;
flex-direction:column;
align-items:flex-end;
line-height:1.15;
}
.stat .label{
font-size:10px;
text-transform:uppercase;
letter-spacing:.08em;
color:var(--text-1);
}
.stat .value{
font-size:19px;
font-weight:600;
color:var(--text-0);
}
main{
flex:1 1 auto;
display:flex;
min-height:0;
}
#canvas-wrap{
flex:1 1 auto;
position:relative;
min-width:0;
}
#canvas-wrap canvas{
display:block;
width:100%;
height:100%;
}
#hint{
position:absolute;
left:16px;
bottom:12px;
font-size:11.5px;
color:var(--text-1);
background:rgba(10,12,17,.55);
padding:6px 10px;
border-radius:6px;
pointer-events:none;
border:1px solid var(--panel-border);
}
#toast{
position:absolute;
top:16px;
left:50%;
transform:translateX(-50%) translateY(-12px);
background:rgba(20,24,33,.92);
border:1px solid var(--accent);
color:var(--text-0);
padding:10px 18px;
border-radius:8px;
font-size:13.5px;
opacity:0;
transition:opacity .25s ease, transform .25s ease;
pointer-events:none;
white-space:nowrap;
box-shadow:0 8px 24px rgba(0,0,0,.4);
}
#toast.show{
opacity:1;
transform:translateX(-50%) translateY(0);
}
#panel{
flex:0 0 300px;
background:var(--bg-1);
border-left:1px solid var(--panel-border);
padding:16px;
overflow-y:auto;
display:flex;
flex-direction:column;
gap:16px;
}
.section-title{
font-size:11px;
text-transform:uppercase;
letter-spacing:.09em;
color:var(--text-1);
margin:0 0 8px 0;
font-weight:600;
}
.move-row{
display:flex;
align-items:center;
gap:8px;
margin-bottom:6px;
}
.move-row .face-tag{
width:26px;
height:26px;
border-radius:6px;
display:flex;
align-items:center;
justify-content:center;
font-weight:700;
font-size:12px;
color:#111;
flex:0 0 auto;
border:1px solid rgba(0,0,0,.25);
}
.move-row .btns{
display:flex;
gap:6px;
flex:1 1 auto;
}
.move-btn{
flex:1 1 0;
background:var(--bg-2);
border:1px solid var(--panel-border);
color:var(--text-0);
border-radius:6px;
padding:6px 0;
font-size:12.5px;
font-family:"Consolas","DM Mono",monospace;
cursor:pointer;
transition:background .12s ease, transform .06s ease, border-color .12s ease;
}
.move-btn:hover{
background:#222838;
border-color:var(--accent);
}
.move-btn:active{
transform:scale(.94);
}
.actions{
display:flex;
gap:8px;
}
.action-btn{
flex:1 1 0;
padding:10px 6px;
border-radius:8px;
border:1px solid var(--panel-border);
background:var(--bg-2);
color:var(--text-0);
font-size:12.5px;
font-weight:600;
cursor:pointer;
display:flex;
flex-direction:column;
align-items:center;
gap:2px;
transition:background .12s ease, border-color .12s ease, transform .06s ease;
}
.action-btn:hover{
border-color:var(--accent);
background:#222838;
}
.action-btn:active{
transform:scale(.96);
}
.action-btn.primary{
background:linear-gradient(135deg,#5b8cff,#7f5bff);
border-color:transparent;
color:#fff;
}
.action-btn kbd{
font-size:9.5px;
color:var(--text-1);
font-family:"Consolas",monospace;
}
.action-btn.primary kbd{
color:rgba(255,255,255,.75);
}
.legend{
font-size:11.5px;
color:var(--text-1);
line-height:1.65;
border-top:1px solid var(--panel-border);
padding-top:12px;
}
.legend b{
color:var(--text-0);
}
.legend kbd{
background:var(--bg-2);
border:1px solid var(--panel-border);
border-radius:4px;
padding:1px 5px;
font-family:"Consolas",monospace;
font-size:10.5px;
color:var(--text-0);
}
footer{
flex:0 0 auto;
padding:5px 20px;
font-size:10.5px;
color:var(--text-1);
border-top:1px solid var(--panel-border);
background:var(--bg-1);
}
</style>
</head>
<body>
<div id="app">
<header>
<h1><span class="dot"></span>Cubo Magico 3D</h1>
<div class="stats">
<div class="stat">
<span class="label">Movimentos</span>
<span class="value" id="moveCount">0</span>
</div>
<div class="stat">
<span class="label">Tempo</span>
<span class="value" id="timer">00:00.0</span>
</div>
</div>
</header>
<main>
<div id="canvas-wrap">
<div id="hint">Arraste para girar a camera · Scroll para zoom</div>
<div id="toast"></div>
</div>
<div id="panel">
<div>
<p class="section-title">Movimentos de camada</p>
<div id="move-grid"></div>
</div>
<div>
<p class="section-title">Acoes</p>
<div class="actions">
<button class="action-btn primary" id="btn-scramble">
<span>Embaralhar</span>
<kbd>Enter</kbd>
</button>
<button class="action-btn" id="btn-undo">
<span>Desfazer</span>
<kbd>Z</kbd>
</button>
<button class="action-btn" id="btn-reset">
<span>Resetar</span>
<kbd>Esc</kbd>
</button>
</div>
</div>
<div class="legend">
<b>Teclado</b><br/>
<kbd>U D L R F B</kbd> giro horario da face<br/>
<kbd>Shift</kbd> + face = anti-horario ( ' )<br/>
<kbd>Ctrl</kbd> + face = meia volta ( 2 )<br/>
<kbd>Z</kbd> desfazer · <kbd>Enter</kbd> embaralhar · <kbd>Esc</kbd> resetar
</div>
</div>
</main>
<footer>U = topo · D = base · L = esquerda · R = direita · F = frente · B = fundo</footer>
</div>
<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';
/* ---------------------------------------------------------------------- */
/* Setup basico: cena, camera, renderer, luzes, controles */
/* ---------------------------------------------------------------------- */
const wrap = document.getElementById('canvas-wrap');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1017);
const camera = new THREE.PerspectiveCamera(45, wrap.clientWidth / wrap.clientHeight, 0.1, 100);
camera.position.set(4.6, 4.2, 5.8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(wrap.clientWidth, wrap.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
wrap.appendChild(renderer.domElement);
const hemi = new THREE.HemisphereLight(0xffffff, 0x30313a, 1.15);
scene.add(hemi);
const dir1 = new THREE.DirectionalLight(0xffffff, 1.05);
dir1.position.set(6, 9, 7);
scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.45);
dir2.position.set(-6, -4, -5);
scene.add(dir2);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 5;
controls.maxDistance = 13;
controls.target.set(0, 0, 0);
new ResizeObserver(() => {
const w = wrap.clientWidth, h = wrap.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}).observe(wrap);
/* ---------------------------------------------------------------------- */
/* Construcao do cubo 3x3x3 */
/* ---------------------------------------------------------------------- */
const CUBIE_SIZE = 0.92;
const boxGeometry = new THREE.BoxGeometry(CUBIE_SIZE, CUBIE_SIZE, CUBIE_SIZE);
const edgesGeometry = new THREE.EdgesGeometry(boxGeometry);
const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x05060a, linewidth: 1 });
function mat(hex) {
return new THREE.MeshStandardMaterial({ color: hex, roughness: 0.45, metalness: 0.05 });
}
const colorMat = {
red: mat(0xb71234), // R (+x)
orange: mat(0xff5800), // L (-x)
white: mat(0xf5f5f5), // U (+y)
yellow: mat(0xffd500), // D (-y)
green: mat(0x009e60), // F (+z)
blue: mat(0x0051ba), // B (-z)
black: mat(0x0c0d10),
};
let cubies = [];
function createCubie(x, y, z) {
const materials = [
x === 1 ? colorMat.red : colorMat.black, // +x
x === -1 ? colorMat.orange : colorMat.black, // -x
y === 1 ? colorMat.white : colorMat.black, // +y
y === -1 ? colorMat.yellow : colorMat.black, // -y
z === 1 ? colorMat.green : colorMat.black, // +z
z === -1 ? colorMat.blue : colorMat.black, // -z
];
const mesh = new THREE.Mesh(boxGeometry, materials);
mesh.castShadow = false;
const edges = new THREE.LineSegments(edgesGeometry, edgeMaterial);
const group = new THREE.Group();
group.add(mesh);
group.add(edges);
group.position.set(x, y, z);
group.userData.home = { x, y, z };
return group;
}
function buildCube() {
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; // nucleo invisivel, omitido
const cubie = createCubie(x, y, z);
scene.add(cubie);
cubies.push(cubie);
}
}
}
}
function destroyCube() {
cubies.forEach((c) => scene.remove(c));
cubies = [];
}
buildCube();
/* ---------------------------------------------------------------------- */
/* Logica de movimentos (notacao U D L R F B, ' e 2) */
/* ---------------------------------------------------------------------- */
const FACE_INFO = {
U: { axis: 'y', layer: 1 },
D: { axis: 'y', layer: -1 },
L: { axis: 'x', layer: -1 },
R: { axis: 'x', layer: 1 },
F: { axis: 'z', layer: 1 },
B: { axis: 'z', layer: -1 },
};
function cwAngle(axis, layer) {
return -layer * (Math.PI / 2);
}
function angleFor(face, modifier) {
const { axis, layer } = FACE_INFO[face];
const cw = cwAngle(axis, layer);
if (modifier === "'") return -cw;
if (modifier === '2') return cw * 2;
return cw;
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function snapObject(obj) {
obj.position.x = Math.round(obj.position.x);
obj.position.y = Math.round(obj.position.y);
obj.position.z = Math.round(obj.position.z);
const m = new THREE.Matrix4().makeRotationFromQuaternion(obj.quaternion);
const e = m.elements;
[0, 1, 2, 4, 5, 6, 8, 9, 10].forEach((i) => {
e[i] = Math.round(e[i]);
});
obj.quaternion.setFromRotationMatrix(m);
}
let epoch = 0; // incrementado no reset para invalidar animacoes em voo
function animateMove(face, modifier, fast) {
const myEpoch = epoch;
const { axis, layer } = FACE_INFO[face];
const targetAngle = angleFor(face, modifier);
const selected = cubies.filter((c) => Math.round(c.position[axis]) === layer);
const pivot = new THREE.Group();
scene.add(pivot);
selected.forEach((c) => pivot.attach(c));
const duration = fast ? 110 : 240;
return new Promise((resolve) => {
const start = performance.now();
function step(now) {
if (myEpoch !== epoch) {
// cubo foi resetado durante a animacao: aborta silenciosamente
resolve();
return;
}
const t = Math.min(1, (now - start) / duration);
const eased = easeInOutCubic(t);
pivot.rotation[axis] = targetAngle * eased;
if (t < 1) {
requestAnimationFrame(step);
} else {
pivot.rotation[axis] = targetAngle;
pivot.updateMatrixWorld(true);
selected.forEach((c) => {
scene.attach(c);
snapObject(c);
});
scene.remove(pivot);
resolve();
}
}
requestAnimationFrame(step);
});
}
/* ---------------------------------------------------------------------- */
/* Fila de movimentos, historico, contador, cronometro */
/* ---------------------------------------------------------------------- */
const queue = [];
let processing = false;
let moveCount = 0;
const history = [];
const moveCountEl = document.getElementById('moveCount');
const timerEl = document.getElementById('timer');
const toastEl = document.getElementById('toast');
function updateMoveCount() {
moveCountEl.textContent = String(moveCount);
}
async function processQueue() {
if (processing) return;
processing = true;
while (queue.length) {
const task = queue.shift();
if (task.type === 'move') {
await animateMove(task.face, task.modifier, task.fast);
if (myEpochValid(task)) {
if (task.record) {
history.push({ face: task.face, modifier: task.modifier });
moveCount++;
} else {
moveCount = Math.max(0, moveCount - 1);
}
updateMoveCount();
startTimerIfNeeded();
checkSolved();
}
}
}
processing = false;
}
function myEpochValid(task) {
return task.epoch === epoch;
}
function enqueueMove(face, modifier, record = true, fast = false) {
queue.push({ type: 'move', face, modifier, record, fast, epoch });
processQueue();
}
function enqueueUndo() {
if (history.length === 0) return;
const last = history.pop();
const inv = last.modifier === "'" ? '' : last.modifier === '' ? "'" : '2';
queue.push({ type: 'move', face: last.face, modifier: inv, record: false, fast: false, epoch });
processQueue();
}
function scramble(n = 22) {
let lastFace = null;
for (let i = 0; i < n; i++) {
const faces = Object.keys(FACE_INFO).filter((f) => f !== lastFace);
const face = faces[Math.floor(Math.random() * faces.length)];
const mods = ['', "'", '2'];
const modifier = mods[Math.floor(Math.random() * mods.length)];
queue.push({ type: 'move', face, modifier, record: true, fast: true, epoch });
lastFace = face;
}
processQueue();
}
function resetCube() {
epoch++;
queue.length = 0;
history.length = 0;
moveCount = 0;
updateMoveCount();
resetTimer();
destroyCube();
buildCube();
hideToast();
}
/* ---------------------------------------------------------------------- */
/* Cronometro */
/* ---------------------------------------------------------------------- */
let timerRunning = false;
let timerStartTs = null;
let elapsedMs = 0;
let timerHandle = null;
function startTimerIfNeeded() {
if (!timerRunning && history.length >= 1) {
timerRunning = true;
timerStartTs = performance.now() - elapsedMs;
timerHandle = setInterval(updateTimerDisplay, 100);
}
}
function stopTimer() {
if (timerRunning) {
timerRunning = false;
clearInterval(timerHandle);
elapsedMs = performance.now() - timerStartTs;
updateTimerDisplay();
}
}
function resetTimer() {
timerRunning = false;
clearInterval(timerHandle);
elapsedMs = 0;
timerStartTs = null;
updateTimerDisplay();
}
function formatTime(ms) {
const totalDs = Math.floor(ms / 100);
const min = Math.floor(totalDs / 600);
const sec = Math.floor((totalDs % 600) / 10);
const ds = totalDs % 10;
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${ds}`;
}
function updateTimerDisplay() {
const ms = timerRunning ? performance.now() - timerStartTs : elapsedMs;
timerEl.textContent = formatTime(ms);
}
/* ---------------------------------------------------------------------- */
/* Deteccao de cubo resolvido (bonus informativo) */
/* ---------------------------------------------------------------------- */
function isSolved() {
return cubies.every((c) => {
const h = c.userData.home;
const px = Math.round(c.position.x), py = Math.round(c.position.y), pz = Math.round(c.position.z);
if (px !== h.x || py !== h.y || pz !== h.z) return false;
return Math.abs(c.quaternion.w) > 0.999;
});
}
let toastTimeout = null;
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimeout);
toastTimeout = setTimeout(hideToast, 3200);
}
function hideToast() {
toastEl.classList.remove('show');
}
function checkSolved() {
if (moveCount > 0 && isSolved()) {
stopTimer();
showToast(`Resolvido! ${moveCount} movimentos em ${formatTime(elapsedMs)}`);
}
}
/* ---------------------------------------------------------------------- */
/* Interface: botoes de movimento */
/* ---------------------------------------------------------------------- */
const FACE_COLORS = { U: '#f5f5f5', D: '#ffd500', L: '#ff5800', R: '#b71234', F: '#009e60', B: '#0051ba' };
const FACE_LABELS = { U: 'U', D: 'D', L: 'L', R: 'R', F: 'F', B: 'B' };
const moveGrid = document.getElementById('move-grid');
Object.keys(FACE_INFO).forEach((face) => {
const row = document.createElement('div');
row.className = 'move-row';
const tag = document.createElement('div');
tag.className = 'face-tag';
tag.style.background = FACE_COLORS[face];
tag.textContent = FACE_LABELS[face];
row.appendChild(tag);
const btns = document.createElement('div');
btns.className = 'btns';
[['', face], ["'", face + "'"], ['2', face + '2']].forEach(([modifier, label]) => {
const b = document.createElement('button');
b.className = 'move-btn';
b.textContent = label;
b.addEventListener('click', () => enqueueMove(face, modifier, true, false));
btns.appendChild(b);
});
row.appendChild(btns);
moveGrid.appendChild(row);
});
document.getElementById('btn-scramble').addEventListener('click', () => scramble());
document.getElementById('btn-undo').addEventListener('click', () => enqueueUndo());
document.getElementById('btn-reset').addEventListener('click', () => resetCube());
/* ---------------------------------------------------------------------- */
/* Controle por teclado */
/* ---------------------------------------------------------------------- */
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (['u', 'd', 'l', 'r', 'f', 'b'].includes(key)) {
e.preventDefault();
const face = key.toUpperCase();
const modifier = e.ctrlKey || e.metaKey ? '2' : e.shiftKey ? "'" : '';
enqueueMove(face, modifier, true, false);
return;
}
if (key === 'z' || e.key === 'Backspace') {
e.preventDefault();
enqueueUndo();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
scramble();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
resetCube();
return;
}
});
/* ---------------------------------------------------------------------- */
/* Loop de renderizacao */
/* ---------------------------------------------------------------------- */
function tick() {
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
tick();
</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 21 KB / 758 linhas. Cubo com as seis cores corretas, grade de notacao completa (incluindo as meias-voltas 2), contador, cronometro e legenda de teclado. Verificado dentro do iframe de origem opaca com interacao real por teclado: a sequencia (R U R' U') repetida seis vezes devolve o cubo ao estado resolvido e a propria execucao anuncia "Resolvido! 24 movimentos", sem nenhum erro de console — ou seja, o giro de camada preserva o estado logico ao longo de 24 movimentos encadeados. Rotaciona a camada reparentando os cubies num pivot e devolvendo-os a cena com snap de posicao e quaternion, que e o que evita a deriva de ponto flutuante.