Claude Sonnet 5 · Claude Code Web — Jogo de xadrez 3D em um unico HTML
src/index.html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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>nenhum arquivo aberto
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- Arquivo unico de 41 KB / 1143 linhas, o maior dos tres xadrezes. Pecas construidas por geometria procedural com silhuetas distintas por tipo, sombras projetadas e movimento interpolado. Verificado dentro do iframe de origem opaca: carrega sem erro de console e o lance e aplicado — o historico registra "1. h3" em notacao algebrica e o turno passa para as pretas. E a unica das tres que escreve o historico em SAN; as outras usam coordenada de origem-destino ou so a casa. Roque, en passant, promocao com overlay de escolha e deteccao de afogamento estao no codigo. Ressalva visual: com a camera inicial a fileira 8 fica parcialmente cortada no topo do enquadramento.