Claude Opus 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>
<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 · scroll para zoom · 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>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 39 KB / 1100 linhas. Renderiza o tabuleiro com as coordenadas a-h e 1-8 gravadas na borda e um painel com turno, contagem de lances legais da posicao, capturadas, historico e os botoes desfazer / girar camera / nova partida. Verificado dentro do iframe de origem opaca, com o sandbox real do viewer: a cena carrega sem nenhum erro de console, o clique seleciona a peca e o lance e aplicado — depois do primeiro lance das brancas o painel anuncia "Vez das pretas" e "20 lances legais", que e o numero correto de respostas legais naquela posicao. O motor tem estado de roque, casa-alvo de en passant (campo `ep`) e promocao com escolha de peca em modal. As pecas sao modeladas por `LatheGeometry` (perfil de revolucao), e o painel ainda calcula a vantagem material da posicao.