comparisons

Claude Opus 5 · Claude Code Web — 3D chess game in a single HTML file

~/tests/xadrez/claude-opus-5-claude-code-web  — 3D chess game in a single HTML file

open in new tab
Claude Opus 5 Anthropic Claude Code Web
  • Leonardo
  • ~27min
loading artifact

src/index.html

html1107 lines39 KB
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Xadrez 3D</title>
<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>
<style>
  :root{
    --bg:#0e1116;
    --bg2:#161b23;
    --panel:#141922;
    --line:#252c38;
    --txt:#e6e9ef;
    --dim:#8e99ab;
    --accent:#7fb2ff;
    --warn:#ffb454;
    --ok:#7fd08a;
    --danger:#ff6b6b;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{height:100%}
  body{
    background:var(--bg);
    color:var(--txt);
    font-family:"Segoe UI",Roboto,system-ui,-apple-system,sans-serif;
    overflow:hidden;
  }
  #app{display:flex;height:100vh;width:100vw}
  #stage{position:relative;flex:1 1 auto;min-width:0;background:#0b0e13}
  #stage canvas{display:block;width:100%;height:100%}
  #hint{
    position:absolute;left:14px;bottom:12px;
    font-size:11.5px;color:#6f7a8c;letter-spacing:.02em;
    background:rgba(10,13,18,.55);padding:6px 10px;border-radius:6px;
    border:1px solid rgba(255,255,255,.05);pointer-events:none;
  }
  #panel{
    flex:0 0 336px;width:336px;height:100%;
    background:var(--panel);border-left:1px solid var(--line);
    display:flex;flex-direction:column;
  }
  .ph{padding:14px 16px 12px;border-bottom:1px solid var(--line)}
  .ph h1{font-size:17px;font-weight:600;letter-spacing:.06em;text-transform:uppercase}
  .ph p{font-size:11.5px;color:var(--dim);margin-top:3px}
  .turnbox{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line)}
  .disc{width:20px;height:20px;border-radius:50%;border:2px solid #4b5568;flex:0 0 auto}
  .disc.w{background:#eee5d3}
  .disc.b{background:#22262e}
  #turnLabel{font-size:14px;font-weight:600}
  #statusLabel{font-size:11.5px;color:var(--dim);margin-top:2px}
  #statusLabel.warn{color:var(--warn)}
  #statusLabel.end{color:var(--danger)}
  .sec{border-bottom:1px solid var(--line);padding:10px 16px}
  .sec h2{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-bottom:7px}
  .caprow{display:flex;align-items:center;gap:6px;min-height:26px}
  .capglyphs{font-size:21px;line-height:1.1;letter-spacing:-1px;word-break:break-all}
  .capglyphs.w{color:#efe7d6;text-shadow:0 1px 2px rgba(0,0,0,.8)}
  .capglyphs.b{color:#7d879a}
  .adv{margin-left:auto;font-size:11.5px;color:var(--ok);font-variant-numeric:tabular-nums}
  #movesWrap{flex:1 1 auto;overflow-y:auto;padding:10px 10px 10px 16px;min-height:80px}
  #movesWrap h2{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:0 0 8px}
  table.moves{width:100%;border-collapse:collapse;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
  table.moves td{padding:2.5px 4px;vertical-align:top}
  table.moves td.n{color:#5f6a7c;width:32px;text-align:right;padding-right:8px}
  table.moves td.m{width:calc(50% - 16px)}
  table.moves tr:nth-child(odd){background:rgba(255,255,255,.02)}
  .mv.last{color:var(--accent);font-weight:600}
  #movesEmpty{font-size:11.5px;color:#5f6a7c;font-style:italic}
  .btns{padding:12px 16px;border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:8px}
  button.b{
    font:inherit;font-size:12.5px;padding:8px 6px;border-radius:6px;cursor:pointer;
    background:var(--bg2);color:var(--txt);border:1px solid var(--line);
    transition:background .15s,border-color .15s;
  }
  button.b:hover{background:#1e2531;border-color:#39435a}
  button.b:disabled{opacity:.4;cursor:not-allowed}
  .chk{grid-column:1 / -1;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);cursor:pointer}
  .chk input{accent-color:var(--accent);width:14px;height:14px}
  #promo{
    position:absolute;inset:0;display:none;align-items:center;justify-content:center;
    background:rgba(6,9,14,.72);z-index:20;
  }
  #promo.on{display:flex}
  .pbox{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px 22px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6)}
  .pbox h3{font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;margin-bottom:14px;color:var(--dim)}
  .pbtns{display:flex;gap:10px}
  .pbtn{
    width:64px;height:64px;font-size:40px;line-height:1;cursor:pointer;
    background:var(--bg2);border:1px solid var(--line);border-radius:10px;color:#efe7d6;
    display:flex;align-items:center;justify-content:center;transition:transform .12s,border-color .12s;
  }
  .pbtn:hover{transform:translateY(-2px);border-color:var(--accent)}
  .pbtn.dark{color:#98a2b4}
  #banner{
    position:absolute;top:18px;left:50%;transform:translateX(-50%);
    padding:9px 20px;border-radius:8px;font-size:14px;font-weight:600;letter-spacing:.03em;
    background:rgba(20,25,34,.94);border:1px solid var(--line);color:#ffd7a0;
    box-shadow:0 8px 30px rgba(0,0,0,.5);display:none;z-index:15;white-space:nowrap;
  }
  #banner.on{display:block}
  #movesWrap::-webkit-scrollbar{width:8px}
  #movesWrap::-webkit-scrollbar-thumb{background:#2a3241;border-radius:4px}
  @media (max-width:1180px){#panel{flex-basis:290px;width:290px}}
</style>
</head>
<body>
<div id="app">
  <div id="stage">
    <div id="banner"></div>
    <div id="hint">Arraste para girar &middot; scroll para zoom &middot; clique na peca e depois na casa destacada</div>
    <div id="promo">
      <div class="pbox">
        <h3>Promocao de peao</h3>
        <div class="pbtns" id="pbtns"></div>
      </div>
    </div>
  </div>

  <aside id="panel">
    <div class="ph">
      <h1>Xadrez 3D</h1>
      <p>Dois jogadores no mesmo dispositivo</p>
    </div>

    <div class="turnbox">
      <div class="disc w" id="turnDisc"></div>
      <div>
        <div id="turnLabel">Vez das brancas</div>
        <div id="statusLabel">Lance 1</div>
      </div>
    </div>

    <div class="sec">
      <h2>Capturadas pelas brancas</h2>
      <div class="caprow"><div class="capglyphs b" id="capB"></div><div class="adv" id="advW"></div></div>
    </div>
    <div class="sec">
      <h2>Capturadas pelas pretas</h2>
      <div class="caprow"><div class="capglyphs w" id="capW"></div><div class="adv" id="advB"></div></div>
    </div>

    <div id="movesWrap">
      <h2>Historico de lances</h2>
      <div id="movesEmpty">Nenhum lance ainda.</div>
      <table class="moves"><tbody id="movesBody"></tbody></table>
    </div>

    <div class="btns">
      <button class="b" id="btnNew">Novo jogo</button>
      <button class="b" id="btnUndo">Desfazer</button>
      <button class="b" id="btnFlip">Girar camera</button>
      <button class="b" id="btnReset">Camera padrao</button>
      <label class="chk"><input type="checkbox" id="autoFlip"> Girar a camera a cada lance</label>
    </div>
  </aside>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

/* ============================================================
   1. MOTOR DE XADREZ
   ------------------------------------------------------------
   Casa   = indice 0..63, indice = fileira * 8 + coluna
   coluna 0 = 'a' ... 7 = 'h'   |   fileira 0 = '1' (brancas) ... 7 = '8'
   Peca   = string de 2 chars: cor ('w'|'b') + tipo ('p','n','b','r','q','k')
   ============================================================ */

const FILES = ['a','b','c','d','e','f','g','h'];
const idx = (f, r) => r * 8 + f;
const fileOf = i => i & 7;
const rankOf = i => i >> 3;
const sqName = i => FILES[fileOf(i)] + (rankOf(i) + 1);
const other = c => (c === 'w' ? 'b' : 'w');

const KN   = [[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]];
const KG   = [[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]];
const DIAG = [[1,1],[1,-1],[-1,1],[-1,-1]];
const ORTH = [[1,0],[-1,0],[0,1],[0,-1]];
const VALUE = { p:1, n:3, b:3, r:5, q:9, k:0 };

function initialState() {
  const board = new Array(64).fill(null);
  const back = ['r','n','b','q','k','b','n','r'];
  for (let f = 0; f < 8; f++) {
    board[idx(f, 0)] = 'w' + back[f];
    board[idx(f, 1)] = 'wp';
    board[idx(f, 6)] = 'bp';
    board[idx(f, 7)] = 'b' + back[f];
  }
  return {
    board,
    turn: 'w',
    castle: { wk:true, wq:true, bk:true, bq:true },
    ep: -1,        // casa de destino do en passant, ou -1
    half: 0,       // meio-lances desde captura ou lance de peao
    full: 1,
    captured: []
  };
}

function cloneState(st) {
  return {
    board: st.board.slice(),
    turn: st.turn,
    castle: { wk:st.castle.wk, wq:st.castle.wq, bk:st.castle.bk, bq:st.castle.bq },
    ep: st.ep,
    half: st.half,
    full: st.full,
    captured: st.captured.slice()
  };
}

function findKing(board, color) {
  const k = color + 'k';
  for (let i = 0; i < 64; i++) if (board[i] === k) return i;
  return -1;
}

/* A casa `sq` esta atacada por alguma peca da cor `by`? */
function isAttacked(board, sq, by) {
  const f = fileOf(sq), r = rankOf(sq);

  const pd = by === 'w' ? -1 : 1;      // fileira de onde o peao atacante viria
  for (const df of [-1, 1]) {
    const nf = f + df, nr = r + pd;
    if (nf >= 0 && nf < 8 && nr >= 0 && nr < 8 && board[idx(nf, nr)] === by + 'p') return true;
  }
  for (const off of KN) {
    const nf = f + off[0], nr = r + off[1];
    if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
    if (board[idx(nf, nr)] === by + 'n') return true;
  }
  for (const off of KG) {
    const nf = f + off[0], nr = r + off[1];
    if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
    if (board[idx(nf, nr)] === by + 'k') return true;
  }
  const slide = (dirs, types) => {
    for (const d of dirs) {
      let nf = f + d[0], nr = r + d[1];
      while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8) {
        const p = board[idx(nf, nr)];
        if (p) {
          if (p[0] === by && types.indexOf(p[1]) >= 0) return true;
          break;
        }
        nf += d[0]; nr += d[1];
      }
    }
    return false;
  };
  if (slide(DIAG, ['b','q'])) return true;
  if (slide(ORTH, ['r','q'])) return true;
  return false;
}

function inCheck(st, color) {
  const k = findKing(st.board, color);
  return k >= 0 && isAttacked(st.board, k, other(color));
}

function mk(from, to, piece, cap, capSq, extra) {
  const m = {
    from, to, piece,
    cap: cap || null,
    capSq: cap ? (capSq === undefined ? to : capSq) : -1,
    promo: null, double: false, ep: false, castle: null
  };
  if (extra) for (const k in extra) m[k] = extra[k];
  return m;
}

function genPseudo(st, color) {
  const c = color || st.turn;
  const opp = other(c);
  const b = st.board;
  const moves = [];

  for (let i = 0; i < 64; i++) {
    const p = b[i];
    if (!p || p[0] !== c) continue;
    const t = p[1], f = fileOf(i), r = rankOf(i);

    if (t === 'p') {
      const dir = c === 'w' ? 1 : -1;
      const promoRank = c === 'w' ? 7 : 0;
      const startRank = c === 'w' ? 1 : 6;
      const nr = r + dir;
      if (nr >= 0 && nr < 8) {
        const one = idx(f, nr);
        if (!b[one]) {
          if (nr === promoRank) {
            for (const q of ['q','r','b','n']) moves.push(mk(i, one, p, null, -1, { promo:q }));
          } else {
            moves.push(mk(i, one, p, null, -1));
            if (r === startRank) {
              const two = idx(f, r + 2 * dir);
              if (!b[two]) moves.push(mk(i, two, p, null, -1, { double:true }));
            }
          }
        }
        for (const df of [-1, 1]) {
          const nf = f + df;
          if (nf < 0 || nf > 7) continue;
          const to = idx(nf, nr);
          const tgt = b[to];
          if (tgt && tgt[0] === opp) {
            if (nr === promoRank) {
              for (const q of ['q','r','b','n']) moves.push(mk(i, to, p, tgt, to, { promo:q }));
            } else moves.push(mk(i, to, p, tgt, to));
          } else if (!tgt && to === st.ep) {
            const vic = idx(nf, r);
            moves.push(mk(i, to, p, b[vic], vic, { ep:true }));
          }
        }
      }
    } else if (t === 'n' || t === 'k') {
      const offs = t === 'n' ? KN : KG;
      for (const off of offs) {
        const nf = f + off[0], nr = r + off[1];
        if (nf < 0 || nf > 7 || nr < 0 || nr > 7) continue;
        const to = idx(nf, nr), tgt = b[to];
        if (tgt && tgt[0] === c) continue;
        moves.push(mk(i, to, p, tgt, to));
      }
      if (t === 'k') {
        const homeR = c === 'w' ? 0 : 7;
        if (r === homeR && f === 4 && !isAttacked(b, i, opp)) {
          const canK = c === 'w' ? st.castle.wk : st.castle.bk;
          const canQ = c === 'w' ? st.castle.wq : st.castle.bq;
          if (canK && !b[idx(5,homeR)] && !b[idx(6,homeR)] && b[idx(7,homeR)] === c + 'r'
              && !isAttacked(b, idx(5,homeR), opp) && !isAttacked(b, idx(6,homeR), opp)) {
            moves.push(mk(i, idx(6,homeR), p, null, -1, { castle:'k' }));
          }
          if (canQ && !b[idx(3,homeR)] && !b[idx(2,homeR)] && !b[idx(1,homeR)] && b[idx(0,homeR)] === c + 'r'
              && !isAttacked(b, idx(3,homeR), opp) && !isAttacked(b, idx(2,homeR), opp)) {
            moves.push(mk(i, idx(2,homeR), p, null, -1, { castle:'q' }));
          }
        }
      }
    } else {
      const dirs = t === 'b' ? DIAG : t === 'r' ? ORTH : DIAG.concat(ORTH);
      for (const d of dirs) {
        let nf = f + d[0], nr = r + d[1];
        while (nf >= 0 && nf < 8 && nr >= 0 && nr < 8) {
          const to = idx(nf, nr), tgt = b[to];
          if (tgt) {
            if (tgt[0] === opp) moves.push(mk(i, to, p, tgt, to));
            break;
          }
          moves.push(mk(i, to, p, null, -1));
          nf += d[0]; nr += d[1];
        }
      }
    }
  }
  return moves;
}

function applyMove(st, m) {
  const n = cloneState(st);
  const b = n.board;
  const c = m.piece[0];

  if (m.cap) { b[m.capSq] = null; n.captured.push(m.cap); }
  b[m.from] = null;
  b[m.to] = m.promo ? c + m.promo : m.piece;

  if (m.castle) {
    const homeR = c === 'w' ? 0 : 7;
    if (m.castle === 'k') { b[idx(7,homeR)] = null; b[idx(5,homeR)] = c + 'r'; }
    else                  { b[idx(0,homeR)] = null; b[idx(3,homeR)] = c + 'r'; }
  }

  if (m.piece[1] === 'k') {
    if (c === 'w') { n.castle.wk = false; n.castle.wq = false; }
    else           { n.castle.bk = false; n.castle.bq = false; }
  }
  const dropRook = sq => {
    if (sq === idx(0,0)) n.castle.wq = false;
    if (sq === idx(7,0)) n.castle.wk = false;
    if (sq === idx(0,7)) n.castle.bq = false;
    if (sq === idx(7,7)) n.castle.bk = false;
  };
  dropRook(m.from);
  if (m.cap) dropRook(m.capSq);

  n.ep = m.double ? idx(fileOf(m.from), (rankOf(m.from) + rankOf(m.to)) / 2) : -1;
  n.half = (m.piece[1] === 'p' || m.cap) ? 0 : st.half + 1;
  if (c === 'b') n.full = st.full + 1;
  n.turn = other(c);
  return n;
}

function legalMoves(st, color) {
  const c = color || st.turn;
  const out = [];
  for (const m of genPseudo(st, c)) {
    const nx = applyMove(st, m);
    if (!inCheck(nx, c)) out.push(m);
  }
  return out;
}

function positionKey(st) {
  let s = '';
  for (let i = 0; i < 64; i++) s += st.board[i] || '.';
  s += '|' + st.turn + '|';
  s += (st.castle.wk?'K':'') + (st.castle.wq?'Q':'') + (st.castle.bk?'k':'') + (st.castle.bq?'q':'');
  return s + '|' + st.ep;
}

function insufficientMaterial(board) {
  const rest = [];
  for (let i = 0; i < 64; i++) if (board[i] && board[i][1] !== 'k') rest.push({ p: board[i], i });
  if (rest.length === 0) return true;                                    // R x R
  if (rest.length === 1 && 'nb'.indexOf(rest[0].p[1]) >= 0) return true;  // R+B ou R+C x R
  if (rest.length === 2 && rest[0].p[1] === 'b' && rest[1].p[1] === 'b'
      && rest[0].p[0] !== rest[1].p[0]) {
    const col = x => (fileOf(x.i) + rankOf(x.i)) & 1;
    if (col(rest[0]) === col(rest[1])) return true;                       // bispos da mesma cor de casa
  }
  return false;
}

/* SAN — recebe a lista de lances legais da posicao ANTES do lance */
function toSAN(st, m, legal) {
  let base;
  if (m.castle) {
    base = m.castle === 'k' ? 'O-O' : 'O-O-O';
  } else {
    const t = m.piece[1];
    let s = '';
    if (t === 'p') {
      if (m.cap) s += FILES[fileOf(m.from)] + 'x';
      s += sqName(m.to);
      if (m.promo) s += '=' + m.promo.toUpperCase();
    } else {
      s += t.toUpperCase();
      const rivals = legal.filter(o => o.to === m.to && o.piece === m.piece && o.from !== m.from);
      if (rivals.length) {
        const sameFile = rivals.some(o => fileOf(o.from) === fileOf(m.from));
        const sameRank = rivals.some(o => rankOf(o.from) === rankOf(m.from));
        if (!sameFile) s += FILES[fileOf(m.from)];
        else if (!sameRank) s += String(rankOf(m.from) + 1);
        else s += sqName(m.from);
      }
      if (m.cap) s += 'x';
      s += sqName(m.to);
    }
    base = s;
  }
  const nx = applyMove(st, m);
  if (inCheck(nx, nx.turn)) base += legalMoves(nx, nx.turn).length ? '+' : '#';
  return base;
}

/* ============================================================
   2. ESTADO DA PARTIDA
   ============================================================ */

let state = initialState();
let history = [];                       // estados anteriores (desfazer)
let sanList = [];
let repKeys = [positionKey(state)];
let legalCache = legalMoves(state);
let selected = -1;
let lastMove = null;
let gameOver = null;                    // { text, kind }
let busy = false;                       // travado durante animacao / promocao

/* ============================================================
   3. CENA THREE.JS
   ============================================================ */

const stage = document.getElementById('stage');

const scene = new THREE.Scene();
scene.background = new THREE.Color('#0b0e13');

const camera = new THREE.PerspectiveCamera(42, stage.clientWidth / stage.clientHeight, 0.1, 200);
camera.position.set(0, 10.4, 8.7);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(stage.clientWidth, stage.clientHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
stage.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.3, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 6.5;
controls.maxDistance = 22;
controls.minPolarAngle = 0.12;
controls.maxPolarAngle = 1.44;
controls.update();

scene.add(new THREE.HemisphereLight('#9fb4d4', '#1a1d24', 0.85));
const keyLight = new THREE.DirectionalLight('#fff4e2', 1.6);
keyLight.position.set(5.5, 11, 6.5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.camera.left = -7.5;  keyLight.shadow.camera.right = 7.5;
keyLight.shadow.camera.top = 7.5;    keyLight.shadow.camera.bottom = -7.5;
keyLight.shadow.camera.near = 1;     keyLight.shadow.camera.far = 34;
keyLight.shadow.bias = -0.0006;
keyLight.shadow.normalBias = 0.02;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight('#8fb0ff', 0.5);
fillLight.position.set(-7, 6, -6);
scene.add(fillLight);
const rimLight = new THREE.PointLight('#ffd9a0', 16, 26, 2);
rimLight.position.set(0, 5.5, -6.5);
scene.add(rimLight);

const worldPos = sq => new THREE.Vector3(fileOf(sq) - 3.5, 0, 3.5 - rankOf(sq));

/* ---------- tabuleiro ---------- */
const boardGroup = new THREE.Group();
scene.add(boardGroup);

const baseMesh = new THREE.Mesh(
  new THREE.BoxGeometry(9.4, 0.5, 9.4),
  new THREE.MeshStandardMaterial({ color:'#2a2118', roughness:0.75, metalness:0.08 })
);
baseMesh.position.y = -0.31;
baseMesh.receiveShadow = true;
boardGroup.add(baseMesh);

// moldura: 4 faixas EM VOLTA das 64 casas (nunca por cima delas)
const frameMat = new THREE.MeshStandardMaterial({ color:'#3b2f22', roughness:0.6, metalness:0.15 });
const frameLong = new THREE.BoxGeometry(9.4, 0.12, 0.7);
const frameSide = new THREE.BoxGeometry(0.7, 0.12, 9.4);
for (const [geo, x, z] of [[frameLong, 0, 4.35], [frameLong, 0, -4.35], [frameSide, 4.35, 0], [frameSide, -4.35, 0]]) {
  const m = new THREE.Mesh(geo, frameMat);
  m.position.set(x, -0.06, z);
  m.receiveShadow = true;
  boardGroup.add(m);
}

const lightSqMat = new THREE.MeshStandardMaterial({ color:'#d9cdb4', roughness:0.55, metalness:0.05 });
const darkSqMat  = new THREE.MeshStandardMaterial({ color:'#4a5a70', roughness:0.55, metalness:0.05 });
const sqGeo = new THREE.BoxGeometry(1, 0.12, 1);
const squareMeshes = [];
for (let i = 0; i < 64; i++) {
  const isLight = (fileOf(i) + rankOf(i)) % 2 === 1;
  const m = new THREE.Mesh(sqGeo, isLight ? lightSqMat : darkSqMat);
  m.position.copy(worldPos(i)).setY(-0.06);
  m.receiveShadow = true;
  m.userData.sq = i;
  squareMeshes.push(m);
  boardGroup.add(m);
}

function makeLabel(text) {
  const c = document.createElement('canvas');
  c.width = 64; c.height = 64;
  const g = c.getContext('2d');
  g.clearRect(0, 0, 64, 64);
  g.fillStyle = '#cbbfa8';
  g.font = 'bold 42px ui-monospace, monospace';
  g.textAlign = 'center';
  g.textBaseline = 'middle';
  g.fillText(text, 32, 34);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry(0.44, 0.44),
    new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false })
  );
  mesh.rotation.x = -Math.PI / 2;
  return mesh;
}
for (let f = 0; f < 8; f++) {
  const a = makeLabel(FILES[f]); a.position.set(f - 3.5, 0.015, 4.33); boardGroup.add(a);
  const b = makeLabel(FILES[f]); b.position.set(f - 3.5, 0.015, -4.33); b.rotation.z = Math.PI; boardGroup.add(b);
}
for (let r = 0; r < 8; r++) {
  const a = makeLabel(String(r + 1)); a.position.set(-4.33, 0.015, 3.5 - r); boardGroup.add(a);
  const b = makeLabel(String(r + 1)); b.position.set(4.33, 0.015, 3.5 - r);  boardGroup.add(b);
}

/* ---------- geometria das pecas ---------- */
function lathe(profile, seg) {
  const pts = profile.map(p => new THREE.Vector2(p[0], p[1]));
  const g = new THREE.LatheGeometry(pts, seg || 40);
  g.computeVertexNormals();
  return g;
}

/* perfis de revolucao: [raio, altura]. Alturas em unidades de casa —
   peao ~0.82, rei ~1.30, como num jogo real em relacao ao tabuleiro. */
const PROFILES = {
  p: [[0,0],[0.275,0],[0.275,0.075],[0.24,0.122],[0.163,0.172],[0.133,0.232],[0.103,0.382],[0.143,0.432],[0.143,0.477],[0.098,0.522],[0,0.53]],
  r: [[0,0],[0.30,0],[0.30,0.082],[0.263,0.135],[0.188,0.202],[0.178,0.548],[0.233,0.60],[0.238,0.80],[0,0.80]],
  n: [[0,0],[0.29,0],[0.29,0.082],[0.25,0.135],[0.185,0.202],[0.175,0.328],[0.20,0.372],[0,0.38]],
  b: [[0,0],[0.283,0],[0.283,0.078],[0.243,0.132],[0.168,0.195],[0.143,0.472],[0.213,0.525],[0.226,0.578],[0.183,0.622],[0.148,0.675],[0.113,0.795],[0.065,0.90],[0,0.975]],
  q: [[0,0],[0.31,0],[0.31,0.087],[0.268,0.142],[0.19,0.21],[0.153,0.54],[0.20,0.63],[0.232,0.705],[0.265,0.818],[0.265,0.90],[0.21,0.938],[0,0.945]],
  k: [[0,0],[0.315,0],[0.315,0.09],[0.273,0.147],[0.195,0.218],[0.158,0.60],[0.205,0.69],[0.237,0.765],[0.27,0.878],[0.27,0.968],[0.218,1.005],[0,1.01]]
};

function knightHeadGeometry() {
  const pts = [
    [-0.175,0.135],[-0.215,0.30],[-0.175,0.44],[-0.075,0.505],
    [-0.035,0.60],[0.02,0.505],[0.10,0.545],[0.135,0.455],
    [0.215,0.365],[0.245,0.275],[0.165,0.24],[0.065,0.265],
    [0.02,0.185],[0.145,0.135]
  ];
  const K = 1.32;
  const s = new THREE.Shape();
  s.moveTo(pts[0][0] * K, pts[0][1] * K);
  for (let i = 1; i < pts.length; i++) s.lineTo(pts[i][0] * K, pts[i][1] * K);
  s.closePath();
  const g = new THREE.ExtrudeGeometry(s, {
    depth: 0.24, bevelEnabled: true, bevelThickness: 0.04,
    bevelSize: 0.04, bevelSegments: 3, curveSegments: 6
  });
  g.translate(0, 0, -0.155);
  g.computeVertexNormals();
  return g;
}

const GEO = {
  pawnBody:   lathe(PROFILES.p),
  pawnHead:   new THREE.SphereGeometry(0.152, 24, 16),
  rookBody:   lathe(PROFILES.r),
  rookCrenel: new THREE.BoxGeometry(0.105, 0.17, 0.135),
  knightBody: lathe(PROFILES.n),
  knightHead: knightHeadGeometry(),
  bishopBody: lathe(PROFILES.b),
  bishopTop:  new THREE.SphereGeometry(0.072, 20, 14),
  queenBody:  lathe(PROFILES.q),
  queenBall:  new THREE.SphereGeometry(0.062, 16, 12),
  queenTop:   new THREE.SphereGeometry(0.085, 20, 14),
  kingBody:   lathe(PROFILES.k),
  kingCrossV: new THREE.BoxGeometry(0.08, 0.32, 0.08),
  kingCrossH: new THREE.BoxGeometry(0.24, 0.08, 0.08)
};

const MAT = {
  w: new THREE.MeshStandardMaterial({ color:'#e9dfca', roughness:0.5, metalness:0.06 }),
  b: new THREE.MeshStandardMaterial({ color:'#252932', roughness:0.46, metalness:0.16 })
};

function addPart(group, geo, mat, x, y, z) {
  const m = new THREE.Mesh(geo, mat);
  m.position.set(x || 0, y || 0, z || 0);
  m.castShadow = true;
  m.receiveShadow = true;
  group.add(m);
  return m;
}

function createPiece(piece) {
  const color = piece[0], type = piece[1];
  const mat = MAT[color];
  const g = new THREE.Group();

  if (type === 'p') {
    addPart(g, GEO.pawnBody, mat);
    addPart(g, GEO.pawnHead, mat, 0, 0.665);
  } else if (type === 'r') {
    addPart(g, GEO.rookBody, mat);
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
      const m = addPart(g, GEO.rookCrenel, mat, Math.cos(a) * 0.19, 0.865, Math.sin(a) * 0.19);
      m.rotation.y = -a;
    }
  } else if (type === 'n') {
    addPart(g, GEO.knightBody, mat);
    // cavalo de perfil: os dois jogadores enxergam a silhueta, nunca a nuca
    const h = addPart(g, GEO.knightHead, mat, 0, 0.205, 0);
    h.rotation.y = color === 'w' ? 0 : Math.PI;
  } else if (type === 'b') {
    addPart(g, GEO.bishopBody, mat);
    addPart(g, GEO.bishopTop, mat, 0, 1.025);
  } else if (type === 'q') {
    addPart(g, GEO.queenBody, mat);
    for (let i = 0; i < 8; i++) {
      const a = (i / 8) * Math.PI * 2;
      addPart(g, GEO.queenBall, mat, Math.cos(a) * 0.23, 0.985, Math.sin(a) * 0.23);
    }
    addPart(g, GEO.queenTop, mat, 0, 1.045);
  } else if (type === 'k') {
    addPart(g, GEO.kingBody, mat);
    addPart(g, GEO.kingCrossV, mat, 0, 1.16);
    addPart(g, GEO.kingCrossH, mat, 0, 1.20);
  }
  g.userData.piece = piece;
  return g;
}

/* ---------- pecas na cena ---------- */
const piecesGroup = new THREE.Group();
scene.add(piecesGroup);
let meshBySq = {};

function syncPieces() {
  const kids = piecesGroup.children.slice();
  for (const c of kids) piecesGroup.remove(c);
  meshBySq = {};
  for (let i = 0; i < 64; i++) {
    const p = state.board[i];
    if (!p) continue;
    const g = createPiece(p);
    g.position.copy(worldPos(i));
    g.userData.sq = i;
    piecesGroup.add(g);
    meshBySq[i] = g;
  }
}

/* ---------- destaques ---------- */
const hlGroup = new THREE.Group();
scene.add(hlGroup);

const discGeo = new THREE.CircleGeometry(0.19, 28);
const ringGeo = new THREE.RingGeometry(0.36, 0.46, 32);
const fullGeo = new THREE.PlaneGeometry(0.98, 0.98);

const HL = {
  sel:   new THREE.MeshBasicMaterial({ color:'#7fd08a', transparent:true, opacity:0.40, depthWrite:false }),
  move:  new THREE.MeshBasicMaterial({ color:'#7fb2ff', transparent:true, opacity:0.65, depthWrite:false }),
  cap:   new THREE.MeshBasicMaterial({ color:'#ff8a5c', transparent:true, opacity:0.78, depthWrite:false, side:THREE.DoubleSide }),
  check: new THREE.MeshBasicMaterial({ color:'#ff5252', transparent:true, opacity:0.45, depthWrite:false }),
  last:  new THREE.MeshBasicMaterial({ color:'#ffc46b', transparent:true, opacity:0.20, depthWrite:false })
};

function hlAdd(geo, mat, sq, y) {
  const m = new THREE.Mesh(geo, mat);
  m.rotation.x = -Math.PI / 2;
  m.position.copy(worldPos(sq)).setY(y);
  hlGroup.add(m);
}

function clearHighlights() {
  const kids = hlGroup.children.slice();
  for (const c of kids) hlGroup.remove(c);
}

function refreshHighlights() {
  clearHighlights();

  if (lastMove) {
    hlAdd(fullGeo, HL.last, lastMove.from, 0.011);
    hlAdd(fullGeo, HL.last, lastMove.to, 0.011);
  }
  if (inCheck(state, state.turn)) {
    const k = findKing(state.board, state.turn);
    if (k >= 0) hlAdd(fullGeo, HL.check, k, 0.013);
  }
  if (selected >= 0) {
    hlAdd(fullGeo, HL.sel, selected, 0.014);
    const seen = {};
    for (const m of legalCache) {
      if (m.from !== selected || seen[m.to]) continue;
      seen[m.to] = true;
      if (m.cap) hlAdd(ringGeo, HL.cap, m.to, 0.016);
      else hlAdd(discGeo, HL.move, m.to, 0.016);
    }
  }
}

/* ============================================================
   4. INTERACAO
   ============================================================ */

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let downX = 0, downY = 0, downT = 0;

renderer.domElement.addEventListener('pointerdown', e => {
  downX = e.clientX; downY = e.clientY; downT = performance.now();
});
renderer.domElement.addEventListener('pointerup', e => {
  const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
  if (dist > 6 || performance.now() - downT > 700) return;   // foi arrasto de camera
  handleClick(e);
});

function pickSquare(e) {
  const rect = renderer.domElement.getBoundingClientRect();
  pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
  raycaster.setFromCamera(pointer, camera);
  const targets = squareMeshes.concat(piecesGroup.children);
  const hits = raycaster.intersectObjects(targets, true);
  for (const h of hits) {
    let o = h.object;
    while (o) {
      if (o.userData && o.userData.sq !== undefined) return o.userData.sq;
      o = o.parent;
    }
  }
  return -1;
}

function handleClick(e) {
  if (busy || gameOver) return;
  const sq = pickSquare(e);
  if (sq < 0) return;
  const p = state.board[sq];

  if (selected >= 0) {
    const opts = legalCache.filter(m => m.from === selected && m.to === sq);
    if (opts.length) {
      if (opts.length > 1 && opts[0].promo) { askPromotion(opts); return; }
      doMove(opts[0]);
      return;
    }
  }
  selected = (p && p[0] === state.turn && selected !== sq) ? sq : -1;
  refreshHighlights();
}

/* ---------- promocao ---------- */
const GLYPH = {
  w: { p:'♙', n:'♘', b:'♗', r:'♖', q:'♕', k:'♔' },
  b: { p:'♟', n:'♞', b:'♝', r:'♜', q:'♛', k:'♚' }
};
const promoEl = document.getElementById('promo');
const pbtnsEl = document.getElementById('pbtns');
const PROMO_NAME = { q:'Dama', r:'Torre', b:'Bispo', n:'Cavalo' };

function askPromotion(opts) {
  busy = true;
  const color = opts[0].piece[0];
  pbtnsEl.innerHTML = '';
  for (const t of ['q','r','b','n']) {
    const btn = document.createElement('button');
    btn.className = 'pbtn' + (color === 'b' ? ' dark' : '');
    btn.textContent = GLYPH[color][t];
    btn.title = PROMO_NAME[t];
    btn.onclick = () => {
      promoEl.classList.remove('on');
      busy = false;
      doMove(opts.find(o => o.promo === t) || opts[0]);
    };
    pbtnsEl.appendChild(btn);
  }
  promoEl.classList.add('on');
}

/* ---------- execucao de um lance ---------- */
const anims = [];

function animateTo(obj, target, dur, arc) {
  return new Promise(res => {
    anims.push({ obj, from: obj.position.clone(), to: target, t: 0, dur, arc, done: res });
  });
}
function shrinkOut(obj) {
  return new Promise(res => {
    anims.push({ obj, shrink: true, t: 0, dur: 300, done: res });
  });
}

function doMove(m) {
  busy = true;
  selected = -1;

  const san = toSAN(state, m, legalCache);
  const mesh = meshBySq[m.from];
  const victim = m.cap ? meshBySq[m.capSq] : null;

  history.push(state);
  state = applyMove(state, m);
  lastMove = m;
  sanList.push(san);
  repKeys.push(positionKey(state));
  legalCache = legalMoves(state);
  evaluateEnd();
  clearHighlights();

  const jobs = [];
  if (victim) jobs.push(shrinkOut(victim));
  if (mesh) {
    jobs.push(animateTo(mesh, worldPos(m.to), 320, m.piece[1] === 'n' ? 0.95 : 0.30));
    if (m.castle) {
      const homeR = m.piece[0] === 'w' ? 0 : 7;
      const rf = m.castle === 'k' ? idx(7, homeR) : idx(0, homeR);
      const rt = m.castle === 'k' ? idx(5, homeR) : idx(3, homeR);
      const rook = meshBySq[rf];
      if (rook) jobs.push(animateTo(rook, worldPos(rt), 320, 0.15));
    }
  }

  renderUI();
  Promise.all(jobs).then(() => {
    syncPieces();
    refreshHighlights();
    busy = false;
    if (autoFlipEl.checked && !gameOver) flipCameraTo(state.turn);
    renderUI();
  });
}

function evaluateEnd() {
  gameOver = null;
  if (legalCache.length === 0) {
    if (inCheck(state, state.turn)) {
      const winner = other(state.turn) === 'w' ? 'brancas' : 'pretas';
      gameOver = { text: 'Xeque-mate — vitoria das ' + winner, kind: 'mate' };
    } else {
      gameOver = { text: 'Empate por afogamento', kind: 'draw' };
    }
    return;
  }
  if (state.half >= 100) { gameOver = { text: 'Empate pela regra dos 50 lances', kind: 'draw' }; return; }
  if (insufficientMaterial(state.board)) { gameOver = { text: 'Empate por material insuficiente', kind: 'draw' }; return; }
  const k = repKeys[repKeys.length - 1];
  let n = 0;
  for (const x of repKeys) if (x === k) n++;
  if (n >= 3) gameOver = { text: 'Empate por repeticao tripla', kind: 'draw' };
}

/* ============================================================
   5. CAMERA
   ============================================================ */
let camTween = null;

function flipCameraTo(side) {
  const target = side === 'w' ? 0 : Math.PI;
  const sph = new THREE.Spherical().setFromVector3(camera.position.clone().sub(controls.target));
  let d = target - sph.theta;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  if (Math.abs(d) < 0.02) return;
  camTween = { from: sph.theta, delta: d, phi: sph.phi, radius: sph.radius, t: 0, dur: 620 };
}
function flipCamera() {
  const sph = new THREE.Spherical().setFromVector3(camera.position.clone().sub(controls.target));
  const th = ((sph.theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
  flipCameraTo(th < Math.PI / 2 || th > Math.PI * 1.5 ? 'b' : 'w');
}
function resetCamera() {
  camTween = null;
  camera.position.set(0, 10.4, 8.7);
  controls.target.set(0, 0.3, 0);
  controls.update();
}

/* ============================================================
   6. PAINEL
   ============================================================ */
const turnDisc   = document.getElementById('turnDisc');
const turnLabel  = document.getElementById('turnLabel');
const statusLabel= document.getElementById('statusLabel');
const capWEl     = document.getElementById('capW');
const capBEl     = document.getElementById('capB');
const advWEl     = document.getElementById('advW');
const advBEl     = document.getElementById('advB');
const movesBody  = document.getElementById('movesBody');
const movesEmpty = document.getElementById('movesEmpty');
const movesWrap  = document.getElementById('movesWrap');
const bannerEl   = document.getElementById('banner');
const autoFlipEl = document.getElementById('autoFlip');
const btnUndo    = document.getElementById('btnUndo');

const ORDER = { q:0, r:1, b:2, n:3, p:4 };

function renderUI() {
  turnDisc.className = 'disc ' + state.turn;
  turnLabel.textContent = state.turn === 'w' ? 'Vez das brancas' : 'Vez das pretas';
  statusLabel.className = '';

  if (gameOver) {
    turnLabel.textContent = 'Partida encerrada';
    statusLabel.textContent = gameOver.text;
    statusLabel.className = 'end';
    bannerEl.textContent = gameOver.text;
    bannerEl.classList.add('on');
  } else {
    bannerEl.classList.remove('on');
    if (inCheck(state, state.turn)) {
      statusLabel.textContent = 'Xeque! O rei precisa sair.';
      statusLabel.className = 'warn';
    } else {
      statusLabel.textContent = 'Lance ' + state.full + ' · ' + legalCache.length + ' lances legais';
    }
  }

  const byW = [], byB = [];
  for (const p of state.captured) (p[0] === 'b' ? byW : byB).push(p);
  const sortP = arr => arr.slice().sort((a, b) => ORDER[a[1]] - ORDER[b[1]]);
  capBEl.textContent = sortP(byW).map(p => GLYPH[p[0]][p[1]]).join('');
  capWEl.textContent = sortP(byB).map(p => GLYPH[p[0]][p[1]]).join('');

  let score = 0;
  for (let i = 0; i < 64; i++) {
    const p = state.board[i];
    if (p) score += (p[0] === 'w' ? 1 : -1) * VALUE[p[1]];
  }
  advWEl.textContent = score > 0 ? '+' + score : '';
  advBEl.textContent = score < 0 ? '+' + (-score) : '';

  movesEmpty.style.display = sanList.length ? 'none' : 'block';
  movesBody.innerHTML = '';
  for (let i = 0; i < sanList.length; i += 2) {
    const tr = document.createElement('tr');
    const n = document.createElement('td'); n.className = 'n'; n.textContent = (i / 2 + 1) + '.';
    const a = document.createElement('td'); a.className = 'm';
    const b = document.createElement('td'); b.className = 'm';
    const sw = document.createElement('span');
    sw.textContent = sanList[i];
    sw.className = 'mv' + (i === sanList.length - 1 ? ' last' : '');
    a.appendChild(sw);
    if (sanList[i + 1] !== undefined) {
      const sb = document.createElement('span');
      sb.textContent = sanList[i + 1];
      sb.className = 'mv' + (i + 1 === sanList.length - 1 ? ' last' : '');
      b.appendChild(sb);
    }
    tr.appendChild(n); tr.appendChild(a); tr.appendChild(b);
    movesBody.appendChild(tr);
  }
  movesWrap.scrollTop = movesWrap.scrollHeight;
  btnUndo.disabled = history.length === 0;
}

document.getElementById('btnNew').onclick = () => {
  if (busy) return;
  state = initialState();
  history = []; sanList = []; repKeys = [positionKey(state)];
  selected = -1; lastMove = null; gameOver = null;
  legalCache = legalMoves(state);
  syncPieces(); refreshHighlights(); renderUI();
};
document.getElementById('btnUndo').onclick = () => {
  if (busy || !history.length) return;
  state = history.pop();
  sanList.pop(); repKeys.pop();
  selected = -1; lastMove = null; gameOver = null;
  legalCache = legalMoves(state);
  syncPieces(); refreshHighlights(); renderUI();
};
document.getElementById('btnFlip').onclick = flipCamera;
document.getElementById('btnReset').onclick = resetCamera;

/* ============================================================
   7. LOOP
   ============================================================ */
const easeInOut = t => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
let prev = performance.now();

function tick(now) {
  requestAnimationFrame(tick);
  const dt = Math.min(now - prev, 120);   // teto evita salto apos aba em segundo plano
  prev = now;

  for (let i = anims.length - 1; i >= 0; i--) {
    const a = anims[i];
    a.t = Math.min(1, a.t + dt / a.dur);
    if (a.shrink) {
      a.obj.scale.setScalar(Math.max(0.001, 1 - a.t));
      a.obj.position.y = a.t * 0.35;
      a.obj.rotation.y = a.t * 1.6;
    } else {
      a.obj.position.lerpVectors(a.from, a.to, easeInOut(a.t));
      a.obj.position.y = Math.sin(Math.PI * a.t) * (a.arc || 0);
    }
    if (a.t >= 1) { anims.splice(i, 1); a.done(); }
  }

  if (camTween) {
    camTween.t = Math.min(1, camTween.t + dt / camTween.dur);
    const th = camTween.from + camTween.delta * easeInOut(camTween.t);
    camera.position.setFromSpherical(new THREE.Spherical(camTween.radius, camTween.phi, th)).add(controls.target);
    camera.lookAt(controls.target);
    if (camTween.t >= 1) camTween = null;
  }

  controls.update();
  renderer.render(scene, camera);
}

function onResize() {
  const w = stage.clientWidth, h = stage.clientHeight;
  if (!w || !h) return;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
}
window.addEventListener('resize', onResize);

syncPieces();
refreshHighlights();
renderUI();
onResize();
requestAnimationFrame(tick);
</script>
</body>
</html>
claude-opus-5-claude-code-web 1 file 1108 lines 39 KB shortcuts p preview tab c code tab [ previous file ] next file esc leave the artifact

prompt used in this run

Crie um jogo de xadrez 3D jogavel em um unico arquivo HTML.

Requisitos funcionais:
- Tabuleiro 8x8 e as 32 pecas renderizadas em 3D com Three.js
- Regras completas: movimentos legais de cada peca, roque, en passant, promocao de peao, xeque, xeque-mate e empate por afogamento
- Dois jogadores no mesmo dispositivo: clicar na peca destaca as casas legais, clicar na casa de destino executa o lance
- Camera orbital com o mouse (arrastar para girar, scroll para zoom)
- Painel com o turno atual, historico de lances e pecas capturadas

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
author's notes
A single 39 KB / 1,100-line file. It renders the board with a-h and 1-8 coordinates engraved on the rim, plus a panel showing the turn, the legal-move count for the position, captures, history and undo / rotate-camera / new-game buttons. Verified inside the opaque-origin iframe using the viewer's real sandbox: the scene loads with no console errors, clicking selects a piece and the move is applied — after White's first move the panel reads "Black to move" and "20 legal moves", which is the correct number of legal replies in that position. The engine tracks castling state, an en passant target square (the `ep` field) and promotion with piece choice in a modal. Pieces are modelled with `LatheGeometry` (a revolved profile), and the panel also computes the material advantage for the position.