comparativos

Claude Opus 5 · Claude Code Web — Simulador de cubo magico 3D em um unico HTML

~/testes/cubo-magico/claude-opus-5-claude-code-web  — Simulador de cubo magico 3D em um unico HTML

abrir em nova aba
Claude Opus 5 Anthropic Claude Code Web
  • Leonardo
  • ~9min
carregando artefato

src/index.html

html787 linhas27 KB
<!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>
claude-opus-5-claude-code-web 1 arquivo 788 linhas 27 KB atalhos p aba preview c aba codigo [ arquivo anterior ] proximo arquivo esc sair do artefato

prompt desta execucao

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
notas do autor
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".