Forkei's picture
Upload folder using huggingface_hub
52a4f3c verified
/* ═══════════════════════════════════════════════════
METROPOLIS CHESS CLUB — Interactive Board Client
═══════════════════════════════════════════════════ */
const socket = io();
// ── Piece image map ──────────────────────────────────────────────────────────
const PIECE_IMG = {
K:'/static/pieces/wK.svg', Q:'/static/pieces/wQ.svg',
R:'/static/pieces/wR.svg', B:'/static/pieces/wB.svg',
N:'/static/pieces/wN.svg', P:'/static/pieces/wP.svg',
k:'/static/pieces/bK.svg', q:'/static/pieces/bQ.svg',
r:'/static/pieces/bR.svg', b:'/static/pieces/bB.svg',
n:'/static/pieces/bN.svg', p:'/static/pieces/bP.svg',
};
const FILES = 'abcdefgh';
const RANKS = '87654321'; // index 0 = rank 8 (top when unflipped)
// Piece sort order for captured pieces display (high value first)
const CAPTURED_ORDER_WHITE = ['Q','R','B','N','P'];
const CAPTURED_ORDER_BLACK = ['q','r','b','n','p'];
const STARTING_COUNTS = {K:1,Q:1,R:2,B:2,N:2,P:8,k:1,q:1,r:2,b:2,n:2,p:8};
// ── App state ────────────────────────────────────────────────────────────────
const G = {
mode: 'vs_viktor',
depth: 3,
playerName: '',
player2Name: 'Black',
fen: null,
legalMovesUci: [],
pieces: {},
selectedSq: null,
lastMove: null,
isCheck: false,
currentPlayerSide:'white',
boardFlipped: false,
isPlayerTurn: true,
gameOver: false,
waitingMove: false,
startTime: null,
moveCount: 0,
msgCount: 0,
};
// ── Drag state ───────────────────────────────────────────────────────────────
let potentialDrag = null; // { sq, x, y } — awaiting threshold
let drag = null; // { sq, ghost } — dragging in progress
/* ════════════════════════════════════════
SCREEN MANAGEMENT
════════════════════════════════════════ */
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
document.getElementById('mute-btn-global').classList.toggle('in-game', id === 'game-screen');
}
/* ════════════════════════════════════════
SETUP SCREEN
════════════════════════════════════════ */
document.querySelectorAll('.mode-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
G.mode = btn.dataset.mode;
const isViktor = G.mode === 'vs_viktor';
document.getElementById('difficulty-row').style.display = isViktor ? '' : 'none';
const p2 = document.getElementById('player2-name');
const p2lbl = document.getElementById('player2-label');
if (G.mode === 'vs_human') {
p2.style.display = p2lbl.style.display = '';
document.getElementById('name-label').textContent = 'White player name';
document.getElementById('player-name').placeholder = 'White';
document.getElementById('viktor-quote').style.display = 'none';
} else {
p2.style.display = p2lbl.style.display = 'none';
document.getElementById('name-label').textContent = 'Your name';
document.getElementById('player-name').placeholder = 'Enter your name';
document.getElementById('viktor-quote').style.display = '';
}
});
});
document.querySelectorAll('.diff-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.diff-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
G.depth = +btn.dataset.depth;
});
});
/* ════════════════════════════════════════
FEN PARSER
════════════════════════════════════════ */
function parseFen(fen) {
const pieces = {};
const rows = fen.split(' ')[0].split('/');
for (let ri = 0; ri < 8; ri++) {
let fi = 0;
for (const ch of rows[ri]) {
if (isNaN(ch)) { pieces[FILES[fi] + RANKS[ri]] = ch; fi++; }
else fi += +ch;
}
}
return pieces;
}
/* ════════════════════════════════════════
BOARD RENDERING
════════════════════════════════════════ */
function renderBoard() {
const boardEl = document.getElementById('chess-board');
boardEl.innerHTML = '';
// Always render from white's perspective. CSS rotateZ(180deg) on #chess-board
// handles the visual flip for black — no DOM reordering needed.
const flip = false;
const checkKing = G.isCheck
? (G.currentPlayerSide === 'white' ? 'K' : 'k')
: null;
for (let ri = 0; ri < 8; ri++) {
for (let fi = 0; fi < 8; fi++) {
const actualRi = flip ? 7 - ri : ri;
const actualFi = flip ? 7 - fi : fi;
const sq = FILES[actualFi] + RANKS[actualRi];
const isLight = (actualFi + actualRi) % 2 === 0;
const piece = G.pieces[sq];
const div = document.createElement('div');
div.className = 'sq ' + (isLight ? 'light' : 'dark');
div.dataset.sq = sq;
if (G.lastMove && (sq === G.lastMove.from || sq === G.lastMove.to))
div.classList.add('last-move');
if (sq === G.selectedSq)
div.classList.add('selected');
if (checkKing && piece === checkKing)
div.classList.add('in-check');
if (G.selectedSq) {
const legal = G.legalMovesUci.some(
m => m.startsWith(G.selectedSq) && m.slice(2,4) === sq
);
if (legal) div.classList.add(piece ? 'legal-cap' : 'legal-empty');
}
if (!G.gameOver && !G.waitingMove && isOwnPiece(piece))
div.classList.add('own-piece');
if (drag && drag.sq === sq)
div.classList.add('drag-source');
// Rank/file coord labels
if (actualFi === 0) {
const r = document.createElement('span');
r.className = 'sq-coord rank';
r.textContent = RANKS[actualRi];
div.appendChild(r);
}
if (actualRi === 7) {
const f = document.createElement('span');
f.className = 'sq-coord file';
f.textContent = FILES[actualFi];
div.appendChild(f);
}
if (piece) {
const img = document.createElement('img');
img.className = 'piece';
img.src = PIECE_IMG[piece];
img.alt = piece;
img.draggable = false;
div.appendChild(img);
}
boardEl.appendChild(div);
}
}
}
/* ════════════════════════════════════════
CAPTURED PIECES
════════════════════════════════════════ */
function computeCaptured(pieces) {
const current = {};
for (const p of Object.values(pieces)) current[p] = (current[p] || 0) + 1;
const captured = {};
for (const [p, count] of Object.entries(STARTING_COUNTS)) {
if (p === 'K' || p === 'k') continue;
const diff = count - (current[p] || 0);
if (diff > 0) captured[p] = diff;
}
return captured;
}
function updateCapturedDisplay() {
const captured = computeCaptured(G.pieces);
// Top bar shows white pieces captured (taken by black/Viktor)
const topEl = document.getElementById('top-captured');
const botEl = document.getElementById('bottom-captured');
if (!topEl || !botEl) return;
function renderCaptures(el, order) {
el.innerHTML = '';
for (const p of order) {
const count = captured[p] || 0;
for (let i = 0; i < count; i++) {
const img = document.createElement('img');
img.className = 'captured-piece';
img.src = PIECE_IMG[p];
img.alt = p;
el.appendChild(img);
}
}
}
renderCaptures(topEl, CAPTURED_ORDER_WHITE); // white pieces lost (black captured them)
renderCaptures(botEl, CAPTURED_ORDER_BLACK); // black pieces lost (white captured them)
}
/* ════════════════════════════════════════
MOVE FLASH
════════════════════════════════════════ */
function flashSquare(sq) {
const el = document.querySelector(`.sq[data-sq="${sq}"]`);
if (!el) return;
const overlay = document.createElement('div');
overlay.className = 'flash-overlay';
el.appendChild(overlay);
overlay.addEventListener('animationend', () => overlay.remove());
}
function flashMove(from, to) {
flashSquare(from);
flashSquare(to);
}
function isOwnPiece(piece) {
if (!piece) return false;
if (G.mode === 'vs_human')
return G.currentPlayerSide === 'white'
? piece === piece.toUpperCase()
: piece === piece.toLowerCase();
return piece === piece.toUpperCase(); // player is always white vs Viktor
}
function updateBoard(boardData, animateTo = null) {
G.fen = boardData.fen;
G.legalMovesUci = boardData.legal_moves_uci || [];
G.isCheck = boardData.is_check || false;
G.moveCount = boardData.move_count || 0;
G.pieces = parseFen(G.fen);
G.currentPlayerSide = G.fen.split(' ')[1] === 'w' ? 'white' : 'black';
if (G.mode === 'vs_human') {
G.boardFlipped = G.currentPlayerSide === 'black';
// Defer the class toggle one frame so renderBoard() finishes first.
// The transition then fires on already-existing elements, not fresh ones,
// preventing flicker on re-renders during black's turn.
const _bf = G.boardFlipped;
requestAnimationFrame(() =>
document.getElementById('chess-board').classList.toggle('board-black-turn', _bf)
);
}
if (boardData.last_move)
G.lastMove = { from: boardData.last_move.slice(0,2), to: boardData.last_move.slice(2,4) };
if (G.mode === 'vs_viktor')
G.isPlayerTurn = G.currentPlayerSide === 'white';
renderBoard();
updateCapturedDisplay();
if (animateTo) {
const pieceEl = document.querySelector(`.sq[data-sq="${animateTo}"] .piece`);
if (pieceEl) { pieceEl.classList.remove('land'); void pieceEl.offsetWidth; pieceEl.classList.add('land'); }
}
updateStatus(boardData);
updatePlayerBars(boardData);
updateMoveLog(boardData.move_history_san || []);
document.getElementById('move-counter').textContent = `Move ${G.moveCount}`;
document.getElementById('stat-moves').textContent = G.moveCount;
document.getElementById('phase-badge').textContent =
boardData.phase ? capitalize(boardData.phase) : 'Opening';
}
function updateStatus(boardData) {
const turnEl = document.getElementById('turn-status');
const checkEl = document.getElementById('check-badge');
if (boardData.is_checkmate) {
turnEl.textContent = 'Checkmate';
checkEl.textContent = 'CHECKMATE';
checkEl.className = 'check-badge is-checkmate';
checkEl.style.display = '';
} else if (boardData.is_stalemate) {
turnEl.textContent = 'Stalemate — Draw'; checkEl.style.display = 'none';
} else if (boardData.is_check) {
turnEl.textContent = `${capitalize(G.currentPlayerSide)} · CHECK!`;
checkEl.textContent = 'CHECK';
checkEl.className = 'check-badge';
checkEl.style.display = '';
Audio.playCheck();
} else {
checkEl.style.display = 'none';
checkEl.className = 'check-badge';
if (G.waitingMove) {
turnEl.textContent = G.mode === 'vs_viktor' ? 'Viktor is thinking...' : 'Processing...';
} else if (G.mode === 'vs_human') {
const name = G.currentPlayerSide === 'white' ? G.playerName : G.player2Name;
turnEl.textContent = `${name}'s turn`;
} else {
turnEl.textContent = G.isPlayerTurn ? 'White · Your turn' : 'Black · Viktor';
}
}
}
function updatePlayerBars(boardData) {
if (G.mode !== 'vs_human') return;
const top = document.getElementById('top-name');
const bottom = document.getElementById('bottom-name');
const topSub = document.getElementById('top-sub');
if (!G.boardFlipped) {
top.textContent = G.player2Name; bottom.textContent = G.playerName; topSub.textContent = 'Black';
} else {
top.textContent = G.playerName; bottom.textContent = G.player2Name; topSub.textContent = 'White';
}
}
function updateMoveLog(sanMoves) {
const log = document.getElementById('move-log');
log.innerHTML = '';
const recent = 4;
sanMoves.forEach((mv, i) => {
const pill = document.createElement('span');
pill.className = 'mpill' + (i >= sanMoves.length - recent ? ' recent' : '');
const moveNum = Math.floor(i / 2) + 1;
const prefix = i % 2 === 0 ? `${moveNum}.` : '';
pill.textContent = prefix ? `${prefix}${mv}` : mv;
log.appendChild(pill);
});
log.scrollLeft = log.scrollWidth;
}
/* ════════════════════════════════════════
SQUARE HIT-DETECTION
Uses elementFromPoint so hit boxes match what the user actually sees,
even with perspective/rotateX applied to the board.
Piece images have pointer-events:none so they're transparent to this.
════════════════════════════════════════ */
function squareAtPoint(clientX, clientY) {
const el = document.elementFromPoint(clientX, clientY);
if (el) {
const sqEl = el.closest('.sq[data-sq]');
if (sqEl) return sqEl.dataset.sq;
}
// Fallback: linear bounding-rect mapping (handles edge/border area)
const boardEl = document.getElementById('chess-board');
if (!boardEl) return null;
const rect = boardEl.getBoundingClientRect();
const x = (clientX - rect.left) / rect.width;
const y = (clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
const fi = Math.min(7, Math.floor(x * 8));
const ri = Math.min(7, Math.floor(y * 8));
const actualFi = G.boardFlipped ? 7 - fi : fi;
const actualRi = G.boardFlipped ? 7 - ri : ri;
return FILES[actualFi] + RANKS[actualRi];
}
/* ════════════════════════════════════════
DRAG-AND-DROP
════════════════════════════════════════ */
function startDrag(sq, clientX, clientY) {
const piece = G.pieces[sq];
if (!piece) return;
G.selectedSq = sq;
const ghost = document.createElement('img');
ghost.src = PIECE_IMG[piece];
ghost.className = 'piece-ghost';
ghost.style.left = clientX + 'px';
ghost.style.top = clientY + 'px';
document.body.appendChild(ghost);
drag = { sq, ghost };
renderBoard();
}
function moveDrag(clientX, clientY) {
if (!drag) return;
drag.ghost.style.left = clientX + 'px';
drag.ghost.style.top = clientY + 'px';
}
function endDrag(clientX, clientY) {
if (!drag) return;
const targetSq = squareAtPoint(clientX, clientY);
drag.ghost.remove();
const sourceSq = drag.sq;
drag = null;
if (targetSq && targetSq !== sourceSq) {
const prefix = sourceSq + targetSq;
const matches = G.legalMovesUci.filter(m => m.startsWith(prefix.slice(0,4)));
if (matches.length > 0) {
G.selectedSq = null;
if (isPromotionMove(sourceSq, targetSq)) {
showPromoDialog(sourceSq, targetSq, moveUci => executeMove(moveUci));
} else {
const move = matches.find(m => m.length === 4) || matches[0];
executeMove(move);
}
return;
}
}
G.selectedSq = null;
renderBoard();
}
// ── Mouse event wiring ──────────────────────────────────────────────────────
document.getElementById('chess-board').addEventListener('mousedown', e => {
if (e.button !== 0 || G.gameOver || G.waitingMove) return;
const sq = squareAtPoint(e.clientX, e.clientY);
if (!sq) return;
const piece = G.pieces[sq];
if (isOwnPiece(piece)) {
potentialDrag = { sq, x: e.clientX, y: e.clientY };
e.preventDefault();
}
});
document.getElementById('chess-board').addEventListener('touchstart', e => {
if (G.gameOver || G.waitingMove) return;
const t = e.touches[0];
const sq = squareAtPoint(t.clientX, t.clientY);
if (!sq) return;
const piece = G.pieces[sq];
if (isOwnPiece(piece)) {
potentialDrag = { sq, x: t.clientX, y: t.clientY };
e.preventDefault();
}
}, { passive: false });
window.addEventListener('mousemove', e => {
if (potentialDrag && !drag) {
const dx = e.clientX - potentialDrag.x;
const dy = e.clientY - potentialDrag.y;
if (dx * dx + dy * dy > 25) { // 5px threshold
startDrag(potentialDrag.sq, e.clientX, e.clientY);
potentialDrag = null;
}
return;
}
moveDrag(e.clientX, e.clientY);
});
window.addEventListener('touchmove', e => {
const t = e.touches[0];
if (potentialDrag && !drag) {
const dx = t.clientX - potentialDrag.x;
const dy = t.clientY - potentialDrag.y;
if (dx * dx + dy * dy > 25) {
startDrag(potentialDrag.sq, t.clientX, t.clientY);
potentialDrag = null;
}
return;
}
if (drag) { moveDrag(t.clientX, t.clientY); e.preventDefault(); }
}, { passive: false });
window.addEventListener('mouseup', e => {
if (potentialDrag && !drag) {
// Never became a drag — treat as a click
handleClick(potentialDrag.sq);
potentialDrag = null;
return;
}
if (drag) endDrag(e.clientX, e.clientY);
});
window.addEventListener('touchend', e => {
const t = e.changedTouches[0];
if (potentialDrag && !drag) {
handleClick(potentialDrag.sq);
potentialDrag = null;
return;
}
if (drag) endDrag(t.clientX, t.clientY);
});
// Click-to-select / click-to-move (shared between 2D and 3D modes)
function handleClick(sq) {
if (G.gameOver || G.waitingMove) return;
const piece = G.pieces[sq];
if (G.selectedSq) {
const prefix = G.selectedSq + sq;
const matches = G.legalMovesUci.filter(m => m.startsWith(prefix.slice(0,4)));
if (matches.length > 0) {
const fromSq = G.selectedSq;
G.selectedSq = null;
if (isPromotionMove(fromSq, sq)) {
showPromoDialog(fromSq, sq, moveUci => executeMove(moveUci));
} else {
const move = matches.find(m => m.length === 4) || matches[0];
executeMove(move);
}
} else if (isOwnPiece(piece)) {
G.selectedSq = sq; renderBoard();
} else {
G.selectedSq = null; renderBoard();
}
} else if (isOwnPiece(piece)) {
G.selectedSq = sq; renderBoard();
}
}
/* ════════════════════════════════════════
PROMOTION DIALOG
════════════════════════════════════════ */
function isPromotionMove(fromSq, toSq) {
const piece = G.pieces[fromSq];
if (!piece) return false;
if (piece === 'P' && toSq[1] === '8') return true;
if (piece === 'p' && toSq[1] === '1') return true;
return false;
}
function showPromoDialog(fromSq, toSq, callback) {
const overlay = document.getElementById('promo-overlay');
const choices = document.getElementById('promo-choices');
if (!overlay || !choices) { callback(fromSq + toSq + 'q'); return; }
const isWhite = G.pieces[fromSq] === 'P';
const pieces = isWhite
? [['q','Q'],['r','R'],['b','B'],['n','N']]
: [['q','q'],['r','r'],['b','b'],['n','n']];
const imgPrefix = isWhite ? 'w' : 'b';
choices.innerHTML = '';
for (const [letter, label] of pieces) {
const btn = document.createElement('button');
btn.className = 'promo-btn';
btn.dataset.piece = letter;
const img = document.createElement('img');
img.src = PIECE_IMG[isWhite ? label : label.toLowerCase()];
img.alt = label;
btn.appendChild(img);
btn.addEventListener('click', () => {
overlay.style.display = 'none';
callback(fromSq + toSq + letter);
});
choices.appendChild(btn);
}
overlay.style.display = 'flex';
}
function executeMove(moveUci) {
G.waitingMove = true;
G.selectedSq = null;
// Fade the board hint after first player move
const hint = document.getElementById('board-hint');
if (hint && hint.style.opacity !== '0') {
hint.style.transition = 'opacity 0.6s ease';
hint.style.opacity = '0';
}
const from = moveUci.slice(0,2);
const to = moveUci.slice(2,4);
const isCapture = !!G.pieces[to];
G.pieces[to] = G.pieces[from];
delete G.pieces[from];
G.lastMove = { from, to };
if (G.mode === 'vs_viktor') G.isPlayerTurn = false;
renderBoard();
updateCapturedDisplay();
flashMove(from, to);
isCapture ? Audio.playCapture() : Audio.playMove();
updateStatus({ is_check: false });
if (G.mode === 'vs_viktor') showThinking();
socket.emit('make_move', { move: moveUci });
}
/* ════════════════════════════════════════
CHAT
════════════════════════════════════════ */
function addMessage(role, content, tone = null, thinking = null, meta = {}) {
const box = document.getElementById('chat-messages');
const msg = document.createElement('div');
const toneClass = tone ? ` tone-${tone}` : '';
msg.className = `cmsg ${role}${toneClass}`;
// Memory surfaced pill — shows before the bubble, very subtle
if (role === 'agent' && meta.memories_surfaced > 0) {
const pill = document.createElement('div');
pill.className = 'cmsg-memory-pill';
pill.textContent = meta.memories_surfaced === 1
? '· memory surfaced'
: ${meta.memories_surfaced} memories surfaced`;
msg.appendChild(pill);
}
const bubble = document.createElement('div');
bubble.className = 'cmsg-bubble';
bubble.textContent = content;
msg.appendChild(bubble);
if (tone) {
const t = document.createElement('div');
t.className = 'cmsg-tone';
t.textContent = '· ' + tone;
msg.appendChild(t);
}
if (thinking && role === 'agent') {
const toggle = document.createElement('div');
toggle.className = 'cmsg-thinking-toggle';
toggle.textContent = '› show thinking';
const think = document.createElement('div');
think.className = 'cmsg-thinking';
think.textContent = thinking;
toggle.addEventListener('click', () => {
const v = think.classList.toggle('visible');
toggle.textContent = v ? '› hide thinking' : '› show thinking';
});
msg.appendChild(toggle);
msg.appendChild(think);
}
box.appendChild(msg);
// If Viktor saved a memory, add a small system note below
if (role === 'agent' && meta.memory_saved) {
const note = document.createElement('div');
note.className = 'cmsg system';
note.innerHTML = '<div class="cmsg-bubble cmsg-memory-note">· memory noted</div>';
box.appendChild(note);
}
box.scrollTop = box.scrollHeight;
G.msgCount++;
document.getElementById('stat-messages').textContent = G.msgCount;
}
function addSystemMsg(text) {
const box = document.getElementById('chat-messages');
const el = document.createElement('div');
el.className = 'cmsg system';
const bubble = document.createElement('div');
bubble.className = 'cmsg-bubble';
bubble.textContent = text;
el.appendChild(bubble);
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
function showThinking() {
removeThinking();
const box = document.getElementById('chat-messages');
const el = document.createElement('div');
el.className = 'thinking-row';
el.id = 'thinking-indicator';
el.innerHTML = `<div class="dots"><span></span><span></span><span></span></div>
<span class="thinking-lbl">Viktor is thinking...</span>`;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
function removeThinking() {
document.getElementById('thinking-indicator')?.remove();
}
/* ════════════════════════════════════════
TIMER
════════════════════════════════════════ */
G._timerInterval = setInterval(() => {
if (!G.startTime || G.gameOver) return;
const s = Math.floor((Date.now() - G.startTime) / 1000);
document.getElementById('stat-duration').textContent =
`${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;
}, 1000);
/* ════════════════════════════════════════
SOCKET EVENTS
════════════════════════════════════════ */
socket.on('connect', () => console.log('[MCC] connected'));
socket.on('game_started', data => {
G.startTime = Date.now();
G.playerName = data.player_name;
G.player2Name = data.player2_name || 'Black';
G.mode = data.mode || 'vs_viktor';
G.boardFlipped = false;
document.getElementById('bottom-name').textContent = G.playerName;
document.getElementById('stat-duration').textContent = '0:00';
const viktorAvatar = document.getElementById('viktor-avatar');
if (G.mode === 'vs_human') {
document.getElementById('top-name').textContent = G.player2Name;
document.getElementById('top-sub').textContent = 'Black';
document.getElementById('chat-hdr-title').textContent = 'Local Game';
document.getElementById('chat-input-row').style.display = 'none';
if (viktorAvatar) viktorAvatar.style.display = 'none';
addSystemMsg(`${G.playerName} (White) vs ${G.player2Name} (Black)`);
addSystemMsg("White's turn");
} else {
document.getElementById('top-name').textContent = 'Viktor Petrov';
document.getElementById('top-sub').textContent = 'Chess Master · Minsk';
document.getElementById('chat-hdr-title').textContent = 'Metropolis Chess Club';
if (viktorAvatar) viktorAvatar.style.display = '';
}
updateBoard(data.board);
Audio.dimAmbient();
Audio.playGameStart();
if (data.message)
addMessage('agent', data.message.content, data.message.tone, data.message.thinking, data.message);
showScreen('game-screen');
});
socket.on('move_made', data => {
removeThinking();
G.waitingMove = false;
// Capture Viktor's move info before the board update wipes the DOM
let viktorFrom = null, viktorTo = null, viktorIsCapture = false;
if (data.board?.last_move && G.mode === 'vs_viktor') {
viktorFrom = data.board.last_move.slice(0,2);
viktorTo = data.board.last_move.slice(2,4);
const lastSan = (data.board.move_history_san || []).slice(-1)[0] || '';
viktorIsCapture = lastSan.includes('x');
}
// vs_human: skip animateTo — the land animation conflicts with piece counter-rotation.
// vs_viktor: pass animateTo for the landing piece bounce.
updateBoard(data.board, G.mode !== 'vs_human' ? data.board?.last_move?.slice(2,4) : null);
if (viktorFrom) {
flashMove(viktorFrom, viktorTo);
viktorIsCapture ? Audio.playCapture() : Audio.playMove();
}
if (G.mode === 'vs_human') {
const mover = G.currentPlayerSide === 'white' ? G.playerName : G.player2Name;
addSystemMsg(`${mover}'s turn`);
}
if (data.player_message?.content)
addMessage('agent', data.player_message.content, data.player_message.tone, data.player_message.thinking, data.player_message);
if (data.game_over) {
if (data.agent_message?.content)
addMessage('agent', data.agent_message.content, data.agent_message.tone, data.agent_message.thinking, data.agent_message);
endGame(data.result);
}
});
socket.on('game_over', data => {
removeThinking(); G.waitingMove = false;
updateBoard(data.board);
if (data.player_message?.content)
addMessage('agent', data.player_message.content, data.player_message.tone, data.player_message.thinking, data.player_message);
if (data.agent_message?.content)
addMessage('agent', data.agent_message.content, data.agent_message.tone, data.agent_message.thinking, data.agent_message);
endGame(data.result);
});
socket.on('move_error', data => {
removeThinking(); G.waitingMove = false; G.isPlayerTurn = true;
renderBoard();
if (G.mode === 'vs_viktor') addMessage('agent', `Illegal move: ${data.error}`);
});
socket.on('message_sent', data => {
removeThinking();
if (data.agent_message?.content)
addMessage('agent', data.agent_message.content, data.agent_message.tone, data.agent_message.thinking, data.agent_message);
});
socket.on('idle_message', data => {
if (data.agent_message?.content)
addMessage('agent', data.agent_message.content, data.agent_message.tone, data.agent_message.thinking, data.agent_message);
});
socket.on('error', data => {
console.error('[MCC]', data);
removeThinking();
addMessage('agent', `Error: ${data.message}`);
});
/* ════════════════════════════════════════
GAME OVER
════════════════════════════════════════ */
function endGame(result) {
G.gameOver = true; G.waitingMove = false;
clearInterval(G._timerInterval);
renderBoard();
let icon = '♟', text = 'Game Over', outcome = 'draw';
if (result === '1-0') {
icon = '♔'; outcome = 'win';
text = G.mode === 'vs_viktor' ? 'You Win' : `${G.playerName} Wins`;
} else if (result === '0-1') {
icon = '♚'; outcome = G.mode === 'vs_viktor' ? 'lose' : 'win';
text = G.mode === 'vs_viktor' ? 'Viktor Wins' : `${G.player2Name} Wins`;
} else if (result === '1/2-1/2') {
icon = '½'; text = 'Draw';
}
Audio.playGameOver(outcome);
// Show inline result banner — stays on the game screen
const banner = document.getElementById('result-banner');
if (banner) {
document.getElementById('result-banner-icon').textContent = icon;
document.getElementById('result-banner-text').textContent = text;
banner.dataset.outcome = outcome;
banner.style.display = '';
}
// Hide resign button, hide chat input
document.getElementById('resign-btn').style.display = 'none';
document.getElementById('chat-input-row').style.display = 'none';
}
/* ════════════════════════════════════════
UI BINDINGS
════════════════════════════════════════ */
document.getElementById('start-btn').addEventListener('click', () => {
const name = document.getElementById('player-name').value.trim() || (G.mode === 'vs_human' ? 'White' : 'Player');
const name2 = document.getElementById('player2-name').value.trim() || 'Black';
G.playerName = name; G.player2Name = name2;
socket.emit('start_game', { player_name: name, player2_name: name2, mode: G.mode, depth: G.depth });
});
document.getElementById('player-name').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('start-btn').click();
});
document.getElementById('send-btn').addEventListener('click', () => {
const input = document.getElementById('message-input');
const text = input.value.trim();
if (!text) return;
addMessage('player', text);
socket.emit('send_message', { message: text });
input.value = '';
if (G.mode === 'vs_viktor') showThinking();
});
document.getElementById('message-input').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('send-btn').click();
});
document.getElementById('resign-btn').addEventListener('click', () => {
if (G.gameOver) return;
if (!confirm('Resign this game?')) return;
socket.emit('resign');
});
document.getElementById('play-again-inline').addEventListener('click', () => location.reload());
document.getElementById('mute-btn-global').addEventListener('click', () => {
const muted = Audio.toggleMute();
const btn = document.getElementById('mute-btn-global');
btn.textContent = muted ? '♪̶' : '♪';
btn.classList.toggle('muted', muted);
});
/* ════════════════════════════════════════
ISOMETRIC PARALLAX
Mouse movement gently shifts the perspective-origin — creates the
illusion of looking at a real 3D object from slightly different angles.
No rotation; just a subtle depth cue.
════════════════════════════════════════ */
(function setupParallax() {
const scene = document.getElementById('board-scene');
if (!scene) return;
const BASE_OX = 50, BASE_OY = -10; // default perspective-origin
const SWAY_X = 6, SWAY_Y = 4; // max shift in %
let cx = BASE_OX, cy = BASE_OY; // current (lerped)
let tx = BASE_OX, ty = BASE_OY; // target
function tick() {
cx += (tx - cx) * 0.07;
cy += (ty - cy) * 0.07;
scene.style.perspectiveOrigin = `${cx.toFixed(2)}% ${cy.toFixed(2)}%`;
requestAnimationFrame(tick);
}
document.addEventListener('mousemove', e => {
const nx = e.clientX / window.innerWidth;
const ny = e.clientY / window.innerHeight;
tx = BASE_OX + (nx - 0.5) * SWAY_X * 2;
ty = BASE_OY + (ny - 0.5) * SWAY_Y * 2;
});
document.addEventListener('mouseleave', () => { tx = BASE_OX; ty = BASE_OY; });
requestAnimationFrame(tick);
})();
/* ════════════════════════════════════════
UTILS
════════════════════════════════════════ */
function capitalize(s) { return s ? s[0].toUpperCase() + s.slice(1) : ''; }
showScreen('setup-screen');
// Attempt ambient immediately — Howler's autoUnlock will play it on the
// first user gesture (click, key, touch) without us needing to wait.
Audio.startAmbient(true);