comparisons

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

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

open in new tab
Claude Sonnet 5 Anthropic Claude Code Web
  • Leonardo
  • ~11min
loading artifact

src/index.html

html1143 lines40 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>
<style>
  * { box-sizing: border-box; }
  html, body { height: 100%; margin: 0; padding: 0; overflow: hidden; }
  body {
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background: #05060a;
    color: #e8e6e1;
  }

  #app { height: 100vh; display: flex; flex-direction: column; }

  header#topbar {
    flex: 0 0 auto;
    height: 56px;
    display: flex;
    align-items: center;
    gap: 18px;
    padding: 0 20px;
    background: #12151d;
    border-bottom: 1px solid #232838;
  }
  header#topbar h1 {
    font-size: 17px;
    margin: 0;
    font-weight: 700;
    letter-spacing: 0.02em;
    white-space: nowrap;
  }
  header#topbar h1 span { color: #7ee0ff; }
  #statusMsg {
    flex: 1;
    font-size: 14px;
    color: #cfd2dc;
    font-weight: 500;
  }
  #statusMsg.check { color: #ff8a8a; }

  button.btn {
    background: #1d2333;
    color: #e8e6e1;
    border: 1px solid #333c54;
    padding: 8px 16px;
    border-radius: 8px;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: background .15s, border-color .15s;
    white-space: nowrap;
  }
  button.btn:hover { background: #2a3348; border-color: #4a5a86; }

  main#main { flex: 1; display: flex; min-height: 0; }

  #viewport { flex: 1; position: relative; background: #05060a; min-width: 0; }
  #board3d { display: block; width: 100%; height: 100%; touch-action: none; }
  #loading {
    position: absolute; inset: 0;
    display: flex; align-items: center; justify-content: center;
    color: #8890a3; font-size: 14px; background: #05060a;
    flex-direction: column; gap: 10px;
    transition: opacity .3s ease;
  }
  #loading.hidden { opacity: 0; pointer-events: none; }
  .spinner {
    width: 26px; height: 26px; border-radius: 50%;
    border: 3px solid #232838; border-top-color: #7ee0ff;
    animation: spin 0.9s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  aside#panel {
    width: 340px; flex: 0 0 340px;
    background: #12151d;
    border-left: 1px solid #232838;
    overflow-y: auto;
    padding: 16px;
    display: flex; flex-direction: column; gap: 18px;
  }
  .panel-block h2 {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: #8890a3;
    margin: 0 0 10px;
    font-weight: 700;
  }
  #turnBlock .turn-row {
    display: flex; align-items: center; gap: 10px;
    background: #171b28; border: 1px solid #232838;
    border-radius: 10px; padding: 12px 14px;
  }
  #turnDot { width: 14px; height: 14px; border-radius: 50%; background: #f2ead9; border: 2px solid #444; flex: 0 0 auto; }
  #turnDot.black { background: #2a2622; border-color: #666; }
  #turnText { font-size: 14px; font-weight: 600; }

  .captured-row { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 30px; font-size: 21px; line-height: 1; }
  .captured-row .label { font-size: 11px; color: #6b7385; margin-right: 6px; }
  .captured-row .empty-hint { font-size: 11px; color: #4d5568; font-style: italic; }
  .captured-block-inner { display: flex; flex-direction: column; gap: 8px; }

  #historyList { list-style: none; margin: 0; padding: 0; font-family: "SFMono-Regular", Consolas, "DM Mono", monospace; font-size: 12.5px; }
  #historyList li {
    display: flex; gap: 8px; padding: 4px 2px;
    border-bottom: 1px solid #1b2030;
  }
  #historyList li .num { width: 24px; color: #5a6478; flex: 0 0 auto; }
  #historyList li .mv-w { flex: 1; color: #e8e6e1; }
  #historyList li .mv-b { flex: 1; color: #b7bccb; }
  #historyList .empty-hint { color: #4d5568; font-size: 12px; font-style: italic; padding: 4px 2px; }

  #helpBlock p { font-size: 12px; color: #8890a3; line-height: 1.6; margin: 0; }

  .overlay {
    position: fixed; inset: 0; z-index: 50;
    background: rgba(5,6,10,0.78);
    display: flex; align-items: center; justify-content: center;
  }
  .overlay.hidden { display: none; }
  .overlay-card {
    background: #12151d; border: 1px solid #2a3348; border-radius: 14px;
    padding: 26px 30px; min-width: 300px; text-align: center;
    box-shadow: 0 20px 60px rgba(0,0,0,0.55);
  }
  .overlay-card h3 { margin: 0 0 8px; font-size: 19px; }
  .overlay-card p { margin: 0 0 18px; color: #a7adba; font-size: 13px; line-height: 1.5; }
  #promoChoices { display: flex; gap: 10px; justify-content: center; }
  .promo-btn {
    width: 66px; height: 66px; border-radius: 10px;
    background: #1d2333; border: 1px solid #333c54; color: #e8e6e1;
    font-size: 32px; cursor: pointer; display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 2px;
  }
  .promo-btn small { font-size: 9px; color: #8890a3; }
  .promo-btn:hover { background: #2a3348; border-color: #6fa8ff; }

  #endOverlay .overlay-card { min-width: 320px; }

  aside#panel::-webkit-scrollbar { width: 8px; }
  aside#panel::-webkit-scrollbar-thumb { background: #232838; border-radius: 4px; }
</style>
</head>
<body>
<div id="app">
  <header id="topbar">
    <h1>Xadrez <span>3D</span></h1>
    <div id="statusMsg">Carregando…</div>
    <button class="btn" id="btnNewGame">Novo Jogo</button>
  </header>
  <main id="main">
    <div id="viewport">
      <canvas id="board3d"></canvas>
      <div id="loading"><div class="spinner"></div><div>Carregando cena 3D…</div></div>
    </div>
    <aside id="panel">
      <section class="panel-block" id="turnBlock">
        <h2>Turno</h2>
        <div class="turn-row"><div id="turnDot"></div><div id="turnText">Vez das Brancas</div></div>
      </section>
      <section class="panel-block" id="capturedBlock">
        <h2>Peças capturadas</h2>
        <div class="captured-block-inner">
          <div class="captured-row"><span class="label">Brancas capturaram</span></div>
          <div class="captured-row" id="capturedByWhite"><span class="empty-hint">nenhuma</span></div>
          <div class="captured-row"><span class="label">Pretas capturaram</span></div>
          <div class="captured-row" id="capturedByBlack"><span class="empty-hint">nenhuma</span></div>
        </div>
      </section>
      <section class="panel-block" id="historyBlock">
        <h2>Histórico de lances</h2>
        <ol id="historyList"><li class="empty-hint">Nenhum lance ainda.</li></ol>
      </section>
      <section class="panel-block" id="helpBlock">
        <h2>Controles</h2>
        <p>Arraste o mouse para girar a câmera · Scroll para zoom · Clique numa peça para ver os lances legais e clique numa casa marcada para jogar.</p>
      </section>
    </aside>
  </main>
</div>

<div class="overlay hidden" id="promoOverlay">
  <div class="overlay-card">
    <h3>Promoção de peão</h3>
    <p>Escolha a nova peça:</p>
    <div id="promoChoices">
      <button class="promo-btn" data-type="q"><span class="glyph">♕</span><small>Dama</small></button>
      <button class="promo-btn" data-type="r"><span class="glyph">♖</span><small>Torre</small></button>
      <button class="promo-btn" data-type="b"><span class="glyph">♗</span><small>Bispo</small></button>
      <button class="promo-btn" data-type="n"><span class="glyph">♘</span><small>Cavalo</small></button>
    </div>
  </div>
</div>

<div class="overlay hidden" id="endOverlay">
  <div class="overlay-card">
    <h3 id="endTitle">Fim de jogo</h3>
    <p id="endDesc"></p>
    <button class="btn" id="btnEndNewGame">Novo Jogo</button>
  </div>
</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';

/* =========================================================================
   MOTOR DE XADREZ (lógica pura, sem dependência de Three.js)
   Tabuleiro: board[row][col], row 0..7 = rank 1..8, col 0..7 = file a..h
   Peça: { type: 'p'|'n'|'b'|'r'|'q'|'k', color: 'w'|'b', moved: bool }
   ========================================================================= */

const FILES = 'abcdefgh';
const ROOK_DIRS = [[1,0],[-1,0],[0,1],[0,-1]];
const BISHOP_DIRS = [[1,1],[1,-1],[-1,1],[-1,-1]];
const QUEEN_DIRS = ROOK_DIRS.concat(BISHOP_DIRS);
const KNIGHT_OFFSETS = [[1,2],[1,-2],[-1,2],[-1,-2],[2,1],[2,-1],[-2,1],[-2,-1]];
const PIECE_VALUE = { p:1, n:3, b:3, r:5, q:9, k:0 };
const PIECE_LETTER = { n:'N', b:'B', r:'R', q:'Q', k:'K' };

function inBounds(r,c){ return r>=0 && r<8 && c>=0 && c<8; }
function opp(color){ return color === 'w' ? 'b' : 'w'; }
function squareName(r,c){ return FILES[c] + (r+1); }

function createInitialBoard(){
  const board = Array.from({ length: 8 }, () => Array(8).fill(null));
  const back = ['r','n','b','q','k','b','n','r'];
  for (let c = 0; c < 8; c++){
    board[0][c] = { type: back[c], color: 'w', moved: false };
    board[1][c] = { type: 'p', color: 'w', moved: false };
    board[6][c] = { type: 'p', color: 'b', moved: false };
    board[7][c] = { type: back[c], color: 'b', moved: false };
  }
  return board;
}

function cloneBoard(board){
  return board.map(row => row.map(p => p ? { type: p.type, color: p.color, moved: p.moved } : null));
}

function findKing(board, color){
  for (let r = 0; r < 8; r++) for (let c = 0; c < 8; c++){
    const p = board[r][c];
    if (p && p.type === 'k' && p.color === color) return [r,c];
  }
  return null;
}

function isSquareAttacked(board, tr, tc, byColor){
  // peões
  const pawnDir = byColor === 'w' ? 1 : -1;
  for (const dc of [-1,1]){
    const r = tr - pawnDir, c = tc - dc;
    if (inBounds(r,c)){
      const p = board[r][c];
      if (p && p.color === byColor && p.type === 'p') return true;
    }
  }
  // cavalos
  for (const [dr,dc] of KNIGHT_OFFSETS){
    const r = tr+dr, c = tc+dc;
    if (inBounds(r,c)){
      const p = board[r][c];
      if (p && p.color === byColor && p.type === 'n') return true;
    }
  }
  // rei
  for (const [dr,dc] of QUEEN_DIRS){
    const r = tr+dr, c = tc+dc;
    if (inBounds(r,c)){
      const p = board[r][c];
      if (p && p.color === byColor && p.type === 'k') return true;
    }
  }
  // deslizantes: bispo/dama nas diagonais, torre/dama nas retas
  for (const [dr,dc] of BISHOP_DIRS){
    let r = tr+dr, c = tc+dc;
    while (inBounds(r,c)){
      const p = board[r][c];
      if (p){ if (p.color === byColor && (p.type === 'b' || p.type === 'q')) return true; break; }
      r += dr; c += dc;
    }
  }
  for (const [dr,dc] of ROOK_DIRS){
    let r = tr+dr, c = tc+dc;
    while (inBounds(r,c)){
      const p = board[r][c];
      if (p){ if (p.color === byColor && (p.type === 'r' || p.type === 'q')) return true; break; }
      r += dr; c += dc;
    }
  }
  return false;
}

function isInCheck(board, color){
  const k = findKing(board, color);
  if (!k) return false;
  return isSquareAttacked(board, k[0], k[1], opp(color));
}

function slideMoves(board, r, c, color, dirs, moves){
  for (const [dr,dc] of dirs){
    let nr = r+dr, nc = c+dc;
    while (inBounds(nr,nc)){
      const t = board[nr][nc];
      if (!t){ moves.push({ from:[r,c], to:[nr,nc], flags:{} }); }
      else { if (t.color !== color) moves.push({ from:[r,c], to:[nr,nc], flags:{ capture:true } }); break; }
      nr += dr; nc += dc;
    }
  }
}

function stepMoves(board, r, c, color, offsets, moves){
  for (const [dr,dc] of offsets){
    const nr = r+dr, nc = c+dc;
    if (!inBounds(nr,nc)) continue;
    const t = board[nr][nc];
    if (!t || t.color !== color) moves.push({ from:[r,c], to:[nr,nc], flags: t ? { capture:true } : {} });
  }
}

function pseudoMovesForPiece(board, r, c, state){
  const piece = board[r][c];
  const moves = [];
  const color = piece.color;

  if (piece.type === 'p'){
    const dir = color === 'w' ? 1 : -1;
    const startRow = color === 'w' ? 1 : 6;
    const promoRow = color === 'w' ? 7 : 0;

    const addForward = (nr, nc) => {
      if (nr === promoRow) moves.push({ from:[r,c], to:[nr,nc], flags:{ promotion:true } });
      else moves.push({ from:[r,c], to:[nr,nc], flags:{} });
    };
    if (inBounds(r+dir,c) && !board[r+dir][c]){
      addForward(r+dir, c);
      if (r === startRow && !board[r+2*dir][c]){
        moves.push({ from:[r,c], to:[r+2*dir,c], flags:{ doublePawn:true } });
      }
    }
    for (const dc of [-1,1]){
      const nr = r+dir, nc = c+dc;
      if (!inBounds(nr,nc)) continue;
      const target = board[nr][nc];
      if (target && target.color !== color){
        if (nr === promoRow) moves.push({ from:[r,c], to:[nr,nc], flags:{ promotion:true, capture:true } });
        else moves.push({ from:[r,c], to:[nr,nc], flags:{ capture:true } });
      } else if (!target && state.enPassantTarget && state.enPassantTarget[0] === nr && state.enPassantTarget[1] === nc){
        moves.push({ from:[r,c], to:[nr,nc], flags:{ enPassant:true, capture:true } });
      }
    }
  } else if (piece.type === 'n'){
    stepMoves(board, r, c, color, KNIGHT_OFFSETS, moves);
  } else if (piece.type === 'b'){
    slideMoves(board, r, c, color, BISHOP_DIRS, moves);
  } else if (piece.type === 'r'){
    slideMoves(board, r, c, color, ROOK_DIRS, moves);
  } else if (piece.type === 'q'){
    slideMoves(board, r, c, color, QUEEN_DIRS, moves);
  } else if (piece.type === 'k'){
    stepMoves(board, r, c, color, QUEEN_DIRS, moves);
    const oppColor = opp(color);
    if (!piece.moved && !isSquareAttacked(board, r, c, oppColor)){
      const rookK = board[r][7];
      if (rookK && rookK.type === 'r' && rookK.color === color && !rookK.moved &&
          !board[r][5] && !board[r][6] &&
          !isSquareAttacked(board, r, 5, oppColor) && !isSquareAttacked(board, r, 6, oppColor)){
        moves.push({ from:[r,c], to:[r,6], flags:{ castle:'K' } });
      }
      const rookQ = board[r][0];
      if (rookQ && rookQ.type === 'r' && rookQ.color === color && !rookQ.moved &&
          !board[r][1] && !board[r][2] && !board[r][3] &&
          !isSquareAttacked(board, r, 3, oppColor) && !isSquareAttacked(board, r, 2, oppColor)){
        moves.push({ from:[r,c], to:[r,2], flags:{ castle:'Q' } });
      }
    }
  }
  return moves;
}

// aplica um lance mutando board/state; usado tanto para simulação (clones)
// quanto para a execução real do jogo.
function applyMove(board, state, move, promotionType){
  const [fr,fc] = move.from, [tr,tc] = move.to;
  const piece = board[fr][fc];
  let captured = board[tr][tc] || null;
  let capturedSquare = captured ? [tr,tc] : null;

  state.enPassantTarget = null;

  if (move.flags && move.flags.enPassant){
    capturedSquare = [fr, tc];
    captured = board[fr][tc];
    board[fr][tc] = null;
  }

  board[tr][tc] = piece;
  board[fr][fc] = null;
  piece.moved = true;

  if (move.flags && move.flags.promotion){
    piece.type = promotionType || 'q';
  }

  if (move.flags && move.flags.castle === 'K'){
    const rook = board[fr][7];
    board[fr][5] = rook; board[fr][7] = null;
    if (rook) rook.moved = true;
  } else if (move.flags && move.flags.castle === 'Q'){
    const rook = board[fr][0];
    board[fr][3] = rook; board[fr][0] = null;
    if (rook) rook.moved = true;
  }

  if (move.flags && move.flags.doublePawn){
    state.enPassantTarget = [(fr+tr)/2, fc];
  }

  return { captured, capturedSquare };
}

function legalMovesForColor(board, state, color){
  const legal = [];
  for (let r = 0; r < 8; r++){
    for (let c = 0; c < 8; c++){
      const p = board[r][c];
      if (!p || p.color !== color) continue;
      const pseudo = pseudoMovesForPiece(board, r, c, state);
      for (const mv of pseudo){
        const b2 = cloneBoard(board);
        const st2 = { enPassantTarget: state.enPassantTarget };
        applyMove(b2, st2, mv, 'q');
        if (!isInCheck(b2, color)) legal.push(mv);
      }
    }
  }
  return legal;
}

function buildSANBase(move, promotionType, board, legalMovesThisTurn){
  const [fr,fc] = move.from, [tr,tc] = move.to;
  const piece = board[fr][fc];
  const isCapture = !!board[tr][tc] || (move.flags && move.flags.enPassant);

  if (move.flags && move.flags.castle === 'K') return 'O-O';
  if (move.flags && move.flags.castle === 'Q') return 'O-O-O';

  if (piece.type === 'p'){
    let s = isCapture ? (FILES[fc] + 'x' + squareName(tr,tc)) : squareName(tr,tc);
    if (move.flags && move.flags.promotion) s += '=' + PIECE_LETTER[promotionType || 'q'];
    return s;
  }

  let disambig = '';
  if (piece.type !== 'k'){
    const others = legalMovesThisTurn.filter(m => {
      if (m.to[0] !== tr || m.to[1] !== tc) return false;
      if (m.from[0] === fr && m.from[1] === fc) return false;
      const op = board[m.from[0]][m.from[1]];
      return op && op.type === piece.type;
    });
    if (others.length){
      const sameFile = others.some(m => m.from[1] === fc);
      const sameRank = others.some(m => m.from[0] === fr);
      if (!sameFile) disambig = FILES[fc];
      else if (!sameRank) disambig = String(fr+1);
      else disambig = FILES[fc] + String(fr+1);
    }
  }
  return PIECE_LETTER[piece.type] + disambig + (isCapture ? 'x' : '') + squareName(tr,tc);
}

/* =========================================================================
   ESTADO DO JOGO
   ========================================================================= */

let board, state, game;

function newGameState(){
  board = createInitialBoard();
  state = { enPassantTarget: null };
  game = {
    turn: 'w',
    over: false,
    selected: null,
    selectedMoves: [],
    currentLegalMoves: legalMovesForColor(board, state, 'w'),
    history: [],
    capturedByWhite: [],
    capturedByBlack: []
  };
}

/* =========================================================================
   THREE.JS — CENA, TABULEIRO E PEÇAS
   ========================================================================= */

const viewportEl = document.getElementById('viewport');
const canvas = document.getElementById('board3d');
const loadingEl = document.getElementById('loading');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060a);
scene.fog = new THREE.Fog(0x05060a, 16, 30);

const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 7.6, -9.4);

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.outputColorSpace = THREE.SRGBColorSpace;

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 = 4.8;
controls.maxDistance = 15;
controls.minPolarAngle = 0.25;
controls.maxPolarAngle = 1.35;
controls.update();

// luzes
const hemi = new THREE.HemisphereLight(0xbfd4ff, 0x2a2116, 0.65);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2df, 1.15);
sun.position.set(5.5, 10, -5);
sun.castShadow = true;
sun.shadow.mapSize.set(1536, 1536);
sun.shadow.camera.left = -7; sun.shadow.camera.right = 7;
sun.shadow.camera.top = 7; sun.shadow.camera.bottom = -7;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 25;
sun.shadow.bias = -0.0015;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x88a9ff, 0.25);
fill.position.set(-6, 5, 6);
scene.add(fill);

// base do tabuleiro e piso decorativo
const slab = new THREE.Mesh(
  new THREE.BoxGeometry(8.7, 0.14, 8.7),
  new THREE.MeshStandardMaterial({ color: 0x2c1e14, roughness: 0.85, metalness: 0.05 })
);
slab.position.y = -0.16;
slab.receiveShadow = true;
scene.add(slab);

const floor = new THREE.Mesh(
  new THREE.CircleGeometry(13, 48),
  new THREE.MeshStandardMaterial({ color: 0x0a0c12, roughness: 1 })
);
floor.rotation.x = -Math.PI/2;
floor.position.y = -0.24;
floor.receiveShadow = true;
scene.add(floor);

function worldX(c){ return c - 3.5; }
function worldZ(r){ return r - 3.5; }
function tilePos(r,c){ return new THREE.Vector3(worldX(c), 0.09, worldZ(r)); }

const tilesGroup = new THREE.Group(); scene.add(tilesGroup);
const piecesGroup = new THREE.Group(); scene.add(piecesGroup);
const selectionMarkersGroup = new THREE.Group(); scene.add(selectionMarkersGroup);
const checkMarkerGroup = new THREE.Group(); scene.add(checkMarkerGroup);
const labelsGroup = new THREE.Group(); scene.add(labelsGroup);

const tileGeo = new THREE.BoxGeometry(0.98, 0.18, 0.98);
const tileLightMat = new THREE.MeshStandardMaterial({ color: 0xe6d3ae, roughness: 0.65 });
const tileDarkMat = new THREE.MeshStandardMaterial({ color: 0x7a4f30, roughness: 0.65 });

for (let r = 0; r < 8; r++){
  for (let c = 0; c < 8; c++){
    const mat = (r+c) % 2 === 0 ? tileDarkMat : tileLightMat;
    const mesh = new THREE.Mesh(tileGeo, mat);
    mesh.position.set(worldX(c), 0, worldZ(r));
    mesh.receiveShadow = true;
    mesh.userData = { type: 'tile', row: r, col: c };
    tilesGroup.add(mesh);
  }
}

// rótulos de arquivo (a-h) e fileira (1-8)
function makeLabelSprite(text){
  const cnv = document.createElement('canvas');
  cnv.width = 64; cnv.height = 64;
  const ctx = cnv.getContext('2d');
  ctx.clearRect(0,0,64,64);
  ctx.font = '700 40px system-ui, sans-serif';
  ctx.fillStyle = 'rgba(232,230,225,0.55)';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(text, 32, 34);
  const tex = new THREE.CanvasTexture(cnv);
  tex.colorSpace = THREE.SRGBColorSpace;
  const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
  const sprite = new THREE.Sprite(mat);
  sprite.scale.set(0.45, 0.45, 1);
  return sprite;
}
for (let c = 0; c < 8; c++){
  const s = makeLabelSprite(FILES[c]);
  s.position.set(worldX(c), 0.06, worldZ(-1) + 0.65);
  labelsGroup.add(s);
}
for (let r = 0; r < 8; r++){
  const s = makeLabelSprite(String(r+1));
  s.position.set(worldX(-1) + 0.65, 0.06, worldZ(r));
  labelsGroup.add(s);
}

// materiais de peças
const whiteMat = new THREE.MeshStandardMaterial({ color: 0xf1e7d3, roughness: 0.35, metalness: 0.06 });
const blackMat = new THREE.MeshStandardMaterial({ color: 0x27231f, roughness: 0.32, metalness: 0.12 });
const accentMat = new THREE.MeshStandardMaterial({ color: 0xc9a227, roughness: 0.3, metalness: 0.6 });

function mkMesh(geo, mat){
  const m = new THREE.Mesh(geo, mat);
  m.castShadow = true;
  m.receiveShadow = true;
  return m;
}

function buildPawn(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.25,0.29,0.09,20), mat); base.position.y = y+0.045; y += 0.09;
  const stem = mkMesh(new THREE.CylinderGeometry(0.11,0.17,0.28,20), mat); stem.position.y = y+0.14; y += 0.28;
  const collar = mkMesh(new THREE.CylinderGeometry(0.18,0.13,0.05,20), mat); collar.position.y = y+0.025; y += 0.05;
  const head = mkMesh(new THREE.SphereGeometry(0.16,18,14), mat); head.position.y = y+0.16; y += 0.32;
  g.add(base, stem, collar, head);
  return g;
}

function buildRook(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.32,0.36,0.10,22), mat); base.position.y = y+0.05; y += 0.10;
  const body = mkMesh(new THREE.CylinderGeometry(0.27,0.31,0.42,22), mat); body.position.y = y+0.21; y += 0.42;
  const top = mkMesh(new THREE.CylinderGeometry(0.33,0.28,0.10,22), mat); top.position.y = y+0.05; y += 0.10;
  g.add(base, body, top);
  const n = 8;
  for (let i = 0; i < n; i++){
    if (i % 2 === 0) continue; // ameias alternadas
    const a = (i / n) * Math.PI * 2;
    const cren = mkMesh(new THREE.BoxGeometry(0.11,0.13,0.11), mat);
    cren.position.set(Math.cos(a)*0.27, y+0.065, Math.sin(a)*0.27);
    g.add(cren);
  }
  y += 0.13;
  g.userData.top = y;
  return g;
}

function buildBishop(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.25,0.30,0.09,20), mat); base.position.y = y+0.045; y += 0.09;
  const body = mkMesh(new THREE.CylinderGeometry(0.07,0.22,0.50,20), mat); body.position.y = y+0.25; y += 0.50;
  const collar = mkMesh(new THREE.CylinderGeometry(0.15,0.09,0.05,18), mat); collar.position.y = y+0.025; y += 0.05;
  const head = mkMesh(new THREE.SphereGeometry(0.135,16,12), mat); head.position.y = y+0.135; y += 0.20;
  const ball = mkMesh(new THREE.SphereGeometry(0.05,10,8), accentMat); ball.position.y = y+0.10; y += 0.06;
  g.add(base, body, collar, head, ball);
  return g;
}

function buildKnight(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.27,0.31,0.10,20), mat); base.position.y = y+0.05; y += 0.10;
  const body = mkMesh(new THREE.CylinderGeometry(0.15,0.23,0.28,20), mat); body.position.y = y+0.14; y += 0.28;
  const neckY = y;
  const neck = mkMesh(new THREE.BoxGeometry(0.17,0.34,0.20), mat);
  neck.position.set(0, neckY+0.16, 0.03);
  neck.rotation.x = -0.32;
  g.add(neck);
  const head = mkMesh(new THREE.BoxGeometry(0.15,0.17,0.32), mat);
  head.position.set(0, neckY+0.33, 0.16);
  head.rotation.x = -0.55;
  g.add(head);
  const snout = mkMesh(new THREE.BoxGeometry(0.11,0.10,0.12), mat);
  snout.position.set(0, neckY+0.24, 0.31);
  snout.rotation.x = -0.55;
  g.add(snout);
  for (const side of [-1,1]){
    const ear = mkMesh(new THREE.ConeGeometry(0.035,0.11,8), mat);
    ear.position.set(side*0.055, neckY+0.47, 0.02);
    ear.rotation.x = -0.2;
    g.add(ear);
  }
  g.add(base, body);
  return g;
}

function buildQueen(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.30,0.35,0.10,22), mat); base.position.y = y+0.05; y += 0.10;
  const body = mkMesh(new THREE.CylinderGeometry(0.11,0.27,0.55,22), mat); body.position.y = y+0.275; y += 0.55;
  const collar = mkMesh(new THREE.CylinderGeometry(0.20,0.14,0.06,20), mat); collar.position.y = y+0.03; y += 0.06;
  const crownBase = mkMesh(new THREE.SphereGeometry(0.155,18,14), mat); crownBase.position.y = y+0.155;
  const ring = mkMesh(new THREE.TorusGeometry(0.155,0.045,8,20), accentMat); ring.rotation.x = Math.PI/2; ring.position.y = y+0.155;
  y += 0.31;
  const ball = mkMesh(new THREE.SphereGeometry(0.075,14,10), accentMat); ball.position.y = y+0.075; y += 0.12;
  g.add(base, body, collar, crownBase, ring, ball);
  return g;
}

function buildKing(mat){
  const g = new THREE.Group();
  let y = 0;
  const base = mkMesh(new THREE.CylinderGeometry(0.30,0.35,0.10,22), mat); base.position.y = y+0.05; y += 0.10;
  const body = mkMesh(new THREE.CylinderGeometry(0.12,0.27,0.62,22), mat); body.position.y = y+0.31; y += 0.62;
  const collar = mkMesh(new THREE.CylinderGeometry(0.21,0.15,0.06,20), mat); collar.position.y = y+0.03; y += 0.06;
  const headBall = mkMesh(new THREE.SphereGeometry(0.16,18,14), mat); headBall.position.y = y+0.16; y += 0.30;
  g.add(base, body, collar, headBall);
  const crossV = mkMesh(new THREE.BoxGeometry(0.055,0.24,0.055), accentMat); crossV.position.y = y+0.12;
  const crossH = mkMesh(new THREE.BoxGeometry(0.17,0.055,0.055), accentMat); crossH.position.y = y+0.16;
  g.add(crossV, crossH);
  return g;
}

const BUILDERS = { p: buildPawn, r: buildRook, n: buildKnight, b: buildBishop, q: buildQueen, k: buildKing };

function buildPieceMesh(type, color){
  const mat = color === 'w' ? whiteMat : blackMat;
  const mesh = BUILDERS[type](mat);
  if (type === 'n') mesh.rotation.y = color === 'w' ? 0 : Math.PI;
  return mesh;
}

let meshBoard;
function placeAllPieces(){
  piecesGroup.clear();
  meshBoard = Array.from({ length: 8 }, () => Array(8).fill(null));
  for (let r = 0; r < 8; r++){
    for (let c = 0; c < 8; c++){
      const p = board[r][c];
      if (!p) continue;
      const mesh = buildPieceMesh(p.type, p.color);
      mesh.position.copy(tilePos(r,c));
      mesh.userData = { type: 'piece', row: r, col: c, color: p.color, pieceType: p.type };
      piecesGroup.add(mesh);
      meshBoard[r][c] = mesh;
    }
  }
}

function removeMeshAt(r,c){
  const m = meshBoard[r][c];
  if (m){ piecesGroup.remove(m); meshBoard[r][c] = null; }
}
function moveMeshTile(fr,fc,tr,tc){
  const m = meshBoard[fr][fc];
  meshBoard[fr][fc] = null;
  meshBoard[tr][tc] = m;
  if (m){
    m.userData.row = tr; m.userData.col = tc;
    startTween(m, tilePos(tr,tc));
  }
}

function syncMeshesForMove(move, promotionType, result){
  const [fr,fc] = move.from, [tr,tc] = move.to;
  if (move.flags && move.flags.enPassant){
    removeMeshAt(result.capturedSquare[0], result.capturedSquare[1]);
  } else if (result.captured){
    removeMeshAt(tr,tc);
  }
  moveMeshTile(fr,fc,tr,tc);
  if (move.flags && move.flags.castle === 'K') moveMeshTile(fr,7,fr,5);
  else if (move.flags && move.flags.castle === 'Q') moveMeshTile(fr,0,fr,3);

  if (move.flags && move.flags.promotion){
    removeMeshAt(tr,tc);
    const p = board[tr][tc];
    const mesh = buildPieceMesh(p.type, p.color);
    mesh.position.copy(tilePos(tr,tc));
    mesh.userData = { type: 'piece', row: tr, col: tc, color: p.color, pieceType: p.type };
    piecesGroup.add(mesh);
    meshBoard[tr][tc] = mesh;
  }
}

// tweens de movimento
let tweens = [];
function startTween(mesh, targetPos, duration = 280){
  tweens.push({ mesh, start: mesh.position.clone(), end: targetPos.clone(), t0: performance.now(), duration });
}
function updateTweens(){
  const now = performance.now();
  for (let i = tweens.length - 1; i >= 0; i--){
    const tw = tweens[i];
    const t = (now - tw.t0) / tw.duration;
    if (t >= 1){ tw.mesh.position.copy(tw.end); tweens.splice(i,1); continue; }
    const e = t < 0.5 ? 2*t*t : -1 + (4-2*t)*t;
    const pos = tw.start.clone().lerp(tw.end, e);
    pos.y += Math.sin(Math.min(Math.max(t,0),1) * Math.PI) * 0.35;
    tw.mesh.position.copy(pos);
  }
}

// marcadores de seleção / lances legais / xeque
const DS = THREE.DoubleSide;
const selectHighlightMat = new THREE.MeshBasicMaterial({ color: 0xffd84a, transparent: true, opacity: 0.5, side: DS });
const moveMarkerMat = new THREE.MeshBasicMaterial({ color: 0x7cff6b, transparent: true, opacity: 0.65, side: DS });
const captureMarkerMat = new THREE.MeshBasicMaterial({ color: 0xff5d5d, transparent: true, opacity: 0.75, side: DS });
const checkHighlightMat = new THREE.MeshBasicMaterial({ color: 0xff3333, transparent: true, opacity: 0.55, side: DS });

function showSelectionMarkers(r, c, moves){
  selectionMarkersGroup.clear();
  const sel = new THREE.Mesh(new THREE.BoxGeometry(0.96,0.02,0.96), selectHighlightMat);
  sel.position.copy(tilePos(r,c)); sel.position.y += 0.006;
  sel.userData = { type: 'tile', row: r, col: c };
  selectionMarkersGroup.add(sel);

  for (const mv of moves){
    const [tr,tc] = mv.to;
    const isCap = !!board[tr][tc] || (mv.flags && mv.flags.enPassant);
    let marker;
    if (isCap){
      marker = new THREE.Mesh(new THREE.RingGeometry(0.33,0.44,28), captureMarkerMat);
    } else {
      marker = new THREE.Mesh(new THREE.CircleGeometry(0.15,24), moveMarkerMat);
    }
    marker.rotation.x = -Math.PI/2;
    marker.position.copy(tilePos(tr,tc)); marker.position.y += 0.03;
    marker.userData = { type: 'tile', row: tr, col: tc };
    selectionMarkersGroup.add(marker);
  }
}

function highlightCheck(color){
  checkMarkerGroup.clear();
  const kp = findKing(board, color);
  if (!kp) return;
  const m = new THREE.Mesh(new THREE.BoxGeometry(0.96,0.03,0.96), checkHighlightMat);
  m.position.copy(tilePos(kp[0], kp[1])); m.position.y += 0.016;
  m.userData = { type: 'tile', row: kp[0], col: kp[1] };
  checkMarkerGroup.add(m);
}
function clearCheckHighlight(){ checkMarkerGroup.clear(); }

/* =========================================================================
   INTERAÇÃO — seleção de peça, execução de lance, promoção
   ========================================================================= */

function clearSelection(){
  game.selected = null;
  game.selectedMoves = [];
  selectionMarkersGroup.clear();
}

function selectPiece(r,c){
  game.selected = [r,c];
  game.selectedMoves = game.currentLegalMoves.filter(m => m.from[0] === r && m.from[1] === c);
  showSelectionMarkers(r,c, game.selectedMoves);
}

let pendingPromotion = null;

function handleBoardClick(r,c){
  if (game.over || pendingPromotion) return;
  const piece = board[r][c];
  if (game.selected){
    const mv = game.selectedMoves.find(m => m.to[0] === r && m.to[1] === c);
    if (mv){
      if (mv.flags && mv.flags.promotion){
        pendingPromotion = mv;
        openPromotionOverlay(board[mv.from[0]][mv.from[1]].color);
      } else {
        executeMove(mv, null);
      }
      return;
    }
    if (piece && piece.color === game.turn) selectPiece(r,c);
    else clearSelection();
  } else {
    if (piece && piece.color === game.turn) selectPiece(r,c);
  }
}

function addCaptured(captured){
  if (!captured) return;
  if (captured.color === 'b') game.capturedByWhite.push(captured.type);
  else game.capturedByBlack.push(captured.type);
}

function executeMove(move, promotionType){
  const moverColor = board[move.from[0]][move.from[1]].color;
  const sanBase = buildSANBase(move, promotionType, board, game.currentLegalMoves);
  const result = applyMove(board, state, move, promotionType);
  syncMeshesForMove(move, promotionType, result);
  if (result.captured) addCaptured(result.captured);

  const nextColor = opp(moverColor);
  const nextLegal = legalMovesForColor(board, state, nextColor);
  const nextInCheck = isInCheck(board, nextColor);

  let san = sanBase;
  let ended = false, endType = null;
  if (nextLegal.length === 0){
    san += nextInCheck ? '#' : '';
    ended = true;
    endType = nextInCheck ? 'checkmate' : 'stalemate';
  } else if (nextInCheck){
    san += '+';
  }

  pushHistoryEntry(moverColor, san);

  game.turn = nextColor;
  game.currentLegalMoves = nextLegal;
  game.over = ended;
  clearSelection();

  if (nextInCheck) highlightCheck(nextColor);
  else clearCheckHighlight();

  renderHistoryPanel();
  renderCapturedPanel();

  if (ended) endGame(endType, moverColor);
  else updateStatusBar(nextInCheck);
}

function pushHistoryEntry(moverColor, san){
  if (moverColor === 'w'){
    game.history.push({ num: game.history.length + 1, white: san, black: null });
  } else {
    const last = game.history[game.history.length - 1];
    if (last) last.black = san;
    else game.history.push({ num: 1, white: '…', black: san });
  }
}

/* promoção */
const promoOverlay = document.getElementById('promoOverlay');
const promoChoicesEl = document.getElementById('promoChoices');
const WHITE_GLYPH = { p:'♙', n:'♘', b:'♗', r:'♖', q:'♕', k:'♔' };
const BLACK_GLYPH = { p:'♟', n:'♞', b:'♝', r:'♜', q:'♛', k:'♚' };

function openPromotionOverlay(color){
  const glyphs = color === 'w' ? WHITE_GLYPH : BLACK_GLYPH;
  promoChoicesEl.querySelectorAll('.promo-btn').forEach(btn => {
    const t = btn.getAttribute('data-type');
    btn.querySelector('.glyph').textContent = glyphs[t];
  });
  promoOverlay.classList.remove('hidden');
}
function hidePromotionOverlay(){ promoOverlay.classList.add('hidden'); }

promoChoicesEl.querySelectorAll('.promo-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const type = btn.getAttribute('data-type');
    const mv = pendingPromotion;
    pendingPromotion = null;
    hidePromotionOverlay();
    if (mv) executeMove(mv, type);
  });
});

/* fim de jogo */
const endOverlay = document.getElementById('endOverlay');
const endTitleEl = document.getElementById('endTitle');
const endDescEl = document.getElementById('endDesc');

function endGame(type, moverColor){
  if (type === 'checkmate'){
    const winner = moverColor === 'w' ? 'Brancas' : 'Pretas';
    endTitleEl.textContent = 'Xeque-mate!';
    endDescEl.textContent = winner + ' vencem a partida.';
    document.getElementById('statusMsg').textContent = 'Xeque-mate — ' + winner + ' vencem!';
  } else {
    endTitleEl.textContent = 'Empate';
    endDescEl.textContent = 'Afogamento (stalemate): o jogador da vez não está em xeque, mas não tem nenhum lance legal.';
    document.getElementById('statusMsg').textContent = 'Empate por afogamento.';
  }
  endOverlay.classList.remove('hidden');
}

/* =========================================================================
   PAINÉIS DE UI
   ========================================================================= */

const statusMsgEl = document.getElementById('statusMsg');
const turnDotEl = document.getElementById('turnDot');
const turnTextEl = document.getElementById('turnText');
const historyListEl = document.getElementById('historyList');
const capturedByWhiteEl = document.getElementById('capturedByWhite');
const capturedByBlackEl = document.getElementById('capturedByBlack');

function updateStatusBar(inCheck){
  const turnLabel = game.turn === 'w' ? 'Brancas' : 'Pretas';
  statusMsgEl.textContent = 'Vez das ' + turnLabel + (inCheck ? ' — Xeque!' : '');
  statusMsgEl.classList.toggle('check', !!inCheck);
  turnDotEl.classList.toggle('black', game.turn === 'b');
  turnTextEl.textContent = 'Vez das ' + turnLabel;
}

function renderHistoryPanel(){
  if (!game.history.length){
    historyListEl.innerHTML = '<li class="empty-hint">Nenhum lance ainda.</li>';
    return;
  }
  historyListEl.innerHTML = game.history.map(h =>
    '<li><span class="num">' + h.num + '.</span>' +
    '<span class="mv-w">' + (h.white || '') + '</span>' +
    '<span class="mv-b">' + (h.black || '') + '</span></li>'
  ).join('');
  historyListEl.parentElement.scrollTop = historyListEl.parentElement.scrollHeight;
}

function renderCapturedList(el, list, glyphMap){
  if (!list.length){ el.innerHTML = '<span class="empty-hint">nenhuma</span>'; return; }
  const sorted = list.slice().sort((a,b) => PIECE_VALUE[a] - PIECE_VALUE[b]);
  el.innerHTML = sorted.map(t => '<span>' + glyphMap[t] + '</span>').join('');
}
function renderCapturedPanel(){
  renderCapturedList(capturedByWhiteEl, game.capturedByWhite, BLACK_GLYPH);
  renderCapturedList(capturedByBlackEl, game.capturedByBlack, WHITE_GLYPH);
}

/* =========================================================================
   ENTRADA — clique vs. arraste da câmera
   ========================================================================= */

const raycaster = new THREE.Raycaster();
const pointerNDC = new THREE.Vector2();
let downState = { active: false, x: 0, y: 0, t: 0 };

function findAncestorData(obj){
  let o = obj;
  while (o){
    if (o.userData && (o.userData.type === 'piece' || o.userData.type === 'tile')) return o;
    o = o.parent;
  }
  return null;
}

canvas.addEventListener('pointerdown', (e) => {
  if (e.button !== 0) return;
  downState = { active: true, x: e.clientX, y: e.clientY, t: performance.now() };
});

canvas.addEventListener('pointerup', (e) => {
  if (!downState.active) return;
  downState.active = false;
  if (e.button !== 0) return;
  const dx = Math.abs(e.clientX - downState.x);
  const dy = Math.abs(e.clientY - downState.y);
  const dt = performance.now() - downState.t;
  if (dx < 6 && dy < 6 && dt < 500) onBoardClickAt(e.clientX, e.clientY);
});

function onBoardClickAt(clientX, clientY){
  const rect = canvas.getBoundingClientRect();
  pointerNDC.set(
    ((clientX - rect.left) / rect.width) * 2 - 1,
    -((clientY - rect.top) / rect.height) * 2 + 1
  );
  raycaster.setFromCamera(pointerNDC, camera);
  const hits = raycaster.intersectObjects([piecesGroup, tilesGroup], true);
  if (!hits.length) return;
  const found = findAncestorData(hits[0].object);
  if (!found) return;
  handleBoardClick(found.userData.row, found.userData.col);
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') clearSelection();
});

/* =========================================================================
   NOVO JOGO / RESET
   ========================================================================= */

function resetGame(){
  newGameState();
  placeAllPieces();
  tweens = [];
  clearSelection();
  clearCheckHighlight();
  hidePromotionOverlay();
  pendingPromotion = null;
  endOverlay.classList.add('hidden');
  renderHistoryPanel();
  renderCapturedPanel();
  updateStatusBar(false);
}

document.getElementById('btnNewGame').addEventListener('click', resetGame);
document.getElementById('btnEndNewGame').addEventListener('click', resetGame);

/* =========================================================================
   LOOP DE RENDERIZAÇÃO E REDIMENSIONAMENTO
   ========================================================================= */

function onResize(){
  const w = viewportEl.clientWidth || 1;
  const h = viewportEl.clientHeight || 1;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h, false);
}
window.addEventListener('resize', onResize);
if (window.ResizeObserver){
  new ResizeObserver(onResize).observe(viewportEl);
}

function animate(){
  requestAnimationFrame(animate);
  controls.update();
  updateTweens();
  renderer.render(scene, camera);
}

function init(){
  onResize();
  resetGame();
  animate();
  requestAnimationFrame(() => loadingEl.classList.add('hidden'));
}

init();
</script>
</body>
</html>
claude-sonnet-5-claude-code-web 1 file 1144 lines 40 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 41 KB / 1,143-line file, the largest of the three chess runs. Pieces are built from procedural geometry with a distinct silhouette per type, cast shadows and tweened movement. Verified inside the opaque-origin iframe: it loads with no console errors and the move is applied — the history logs "1. h3" in algebraic notation and the turn passes to Black. It is the only one of the three that writes history in SAN; the others use from-to coordinates or just the destination square. Castling, en passant, promotion with a choice overlay and stalemate detection are all present in the code. Visual caveat: with the default camera, rank 8 is partly cropped at the top of the frame.