diff --git a/chess/public/js/app.js b/chess/public/js/app.js index 9c3bf74..4b9285e 100644 --- a/chess/public/js/app.js +++ b/chess/public/js/app.js @@ -90,8 +90,11 @@ const elements = { partyTimeControl: document.querySelector("#partyClockSelect"), startAiButton: document.querySelector("#startAiButton"), + newGameToolbarButton: document.querySelector("#newGameToolbarButton"), createPartyButton: document.querySelector("#createPartyButton"), + newPartyGameButton: document.querySelector("#newPartyGameButton"), + partyCodeInput: document.querySelector("#partyCodeInput"), joinPartyButton: document.querySelector("#joinPartyButton"), leavePartyButton: document.querySelector("#leavePartyButton"), @@ -113,6 +116,11 @@ const elements = { whiteClock: document.querySelector("#whiteClock"), blackClock: document.querySelector("#blackClock"), + capturedByWhite: document.querySelector("#capturedByWhite"), + capturedByBlack: document.querySelector("#capturedByBlack"), + + moveHistory: document.querySelector("#moveHistory"), + promotionDialog: document.querySelector("#promotionDialog"), promotionOptions: document.querySelector("#promotionOptions"), @@ -303,10 +311,9 @@ function showToast(message, type = "info") { elements.toastHost.appendChild(toast); - window.setTimeout( - () => toast.remove(), - 3200 - ); + window.setTimeout(() => { + toast.remove(); + }, 3200); } @@ -384,15 +391,13 @@ function renderBoard() { const selectedMoves = getLegalMovesForSelected(); - const moveTargets = new Map( - selectedMoves.map(move => [ - move.to, - move, - ]) - ); + const moveTargets = new Map(); - const orientation = - appState.orientation; + for (const move of selectedMoves) { + moveTargets.set(move.to, move); + } + + const orientation = appState.orientation; const xOrder = orientation === "w" @@ -426,16 +431,24 @@ function renderBoard() { document.createElement("button"); square.type = "button"; + + /* + * ВАЖНО: + * Теперь цвет клетки задаётся непосредственно + * через .light / .dark. + */ + const isDark = + (x + y) % 2 === 0; + square.className = `square ${ - (x + y) % 2 === 0 - ? "dark" - : "light" + isDark ? "dark" : "light" }`; square.dataset.square = squareName; + /* Selected */ if ( appState.selectedSquare === squareName @@ -445,6 +458,7 @@ function renderBoard() { ); } + /* Legal move */ const move = moveTargets.get(squareName); @@ -456,6 +470,7 @@ function renderBoard() { ); } + /* Last move */ if ( lastMove && ( @@ -468,6 +483,7 @@ function renderBoard() { ); } + /* Check */ if ( piece && piece.type === "K" && @@ -478,6 +494,7 @@ function renderBoard() { ); } + /* Piece */ if (piece) { const pieceNode = document.createElement("span"); @@ -494,15 +511,26 @@ function renderBoard() { piece.color ][piece.type]; - square.appendChild(pieceNode); + pieceNode.setAttribute( + "aria-hidden", + "true" + ); + + square.appendChild( + pieceNode + ); } square.addEventListener( "click", - () => handleSquareClick(squareName) + () => handleSquareClick( + squareName + ) ); - elements.board.appendChild(square); + elements.board.appendChild( + square + ); } } } @@ -520,22 +548,22 @@ function formatStatus() { const status = currentStatus(); if (status.phase === "checkmate") { - return "Checkmate"; + return "Мат"; } if (status.phase === "draw") { - return "Draw"; + return "Ничья"; } if (status.check) { return appState.engine.state.turn === "w" - ? "White is in check" - : "Black is in check"; + ? "Белые под шахом" + : "Чёрные под шахом"; } return appState.engine.state.turn === "w" - ? "White to move" - : "Black to move"; + ? "Ход белых" + : "Ход чёрных"; } @@ -546,7 +574,7 @@ function getGameOverMessage() { if (!snapshot) { return ( appState.gameOverMessage || - "Game over." + "Игра окончена." ); } @@ -559,21 +587,21 @@ function getGameOverMessage() { switch (reason) { case "timeout": return winner === "w" - ? "White wins on time." - : "Black wins on time."; + ? "Белые победили по времени." + : "Чёрные победили по времени."; case "checkmate": return winner === "w" - ? "White wins by checkmate." - : "Black wins by checkmate."; + ? "Белые победили матом." + : "Чёрные победили матом."; case "draw": - return "Draw."; + return "Ничья."; default: return ( appState.gameOverMessage || - "Game over." + "Игра окончена." ); } } @@ -586,17 +614,17 @@ function getGameOverMessage() { function renderRoster() { if (appState.mode === "ai") { const human = - appState.playerName || "You"; + appState.playerName || "Вы"; const whiteName = appState.playerColor === "w" ? human - : `Computer (${appState.aiLevel})`; + : `Компьютер (${appState.aiLevel})`; const blackName = appState.playerColor === "b" ? human - : `Computer (${appState.aiLevel})`; + : `Компьютер (${appState.aiLevel})`; if (elements.whitePlayerLabel) { elements.whitePlayerLabel.textContent = @@ -620,7 +648,7 @@ function renderRoster() { if (elements.spectatorList) { elements.spectatorList.innerHTML = - "
  • None in AI mode
  • "; + "
  • В режиме AI зрителей нет
  • "; } if (elements.spectatorCount) { @@ -642,34 +670,38 @@ function renderRoster() { ? `${white.name}${ white.connected ? "" - : " · offline" + : " · офлайн" }` - : "Open seat"; + : "Свободно"; const blackName = black ? `${black.name}${ black.connected ? "" - : " · offline" + : " · офлайн" }` - : "Open seat"; + : "Свободно"; - elements.whitePlayerLabel && - (elements.whitePlayerLabel.textContent = - whiteName); + if (elements.whitePlayerLabel) { + elements.whitePlayerLabel.textContent = + whiteName; + } - elements.blackPlayerLabel && - (elements.blackPlayerLabel.textContent = - blackName); + if (elements.blackPlayerLabel) { + elements.blackPlayerLabel.textContent = + blackName; + } - elements.rosterWhite && - (elements.rosterWhite.textContent = - whiteName); + if (elements.rosterWhite) { + elements.rosterWhite.textContent = + whiteName; + } - elements.rosterBlack && - (elements.rosterBlack.textContent = - blackName); + if (elements.rosterBlack) { + elements.rosterBlack.textContent = + blackName; + } const spectators = appState.partySnapshot?.spectators || []; @@ -685,7 +717,7 @@ function renderRoster() { if (!spectators.length) { elements.spectatorList.innerHTML = - "
  • None yet
  • "; + "
  • Пока никого нет
  • "; return; } @@ -699,13 +731,149 @@ function renderRoster() { )}${ spectator.connected ? "" - : " · offline" + : " · офлайн" }` ) .join(""); } +/* ========================================================= + CAPTURED PIECES +========================================================= */ + +function renderCaptured() { + if ( + !elements.capturedByWhite && + !elements.capturedByBlack + ) { + return; + } + + /* + * Поддерживаем несколько возможных форматов + * истории движка, если они присутствуют. + */ + const history = + appState.engine.state.moveHistory || + appState.engine.state.history || + []; + + const whiteCaptured = []; + const blackCaptured = []; + + for (const move of history) { + if (!move) { + continue; + } + + const captured = + move.captured || + move.capturedPiece || + null; + + if (!captured) { + continue; + } + + const color = + captured.color || + (captured.type + ? (move.color === "w" ? "b" : "w") + : null); + + const type = + captured.type || + ( + typeof captured === "string" + ? captured + : null + ); + + if (!type) { + continue; + } + + const glyph = + PIECE_GLYPHS[color]?.[type]; + + if (!glyph) { + continue; + } + + if (move.color === "w") { + whiteCaptured.push(glyph); + } else { + blackCaptured.push(glyph); + } + } + + if (elements.capturedByWhite) { + elements.capturedByWhite.textContent = + whiteCaptured.join(" "); + } + + if (elements.capturedByBlack) { + elements.capturedByBlack.textContent = + blackCaptured.join(" "); + } +} + + +/* ========================================================= + MOVE HISTORY +========================================================= */ + +function renderMoveHistory() { + if (!elements.moveHistory) { + return; + } + + const history = + appState.engine.state.moveHistory || + appState.engine.state.history || + []; + + if (!Array.isArray(history) || !history.length) { + elements.moveHistory.innerHTML = + "
  • Ходов пока нет
  • "; + + return; + } + + elements.moveHistory.innerHTML = + history + .map((move, index) => { + if (typeof move === "string") { + return `
  • ${escapeHtml(move)}
  • `; + } + + const from = + move?.from || ""; + + const to = + move?.to || ""; + + const promotion = + move?.promotion + ? `=${String(move.promotion).toUpperCase()}` + : ""; + + const text = + from && to + ? `${from} → ${to}${promotion}` + : JSON.stringify(move); + + return ( + `
  • ${escapeHtml( + `${index + 1}. ${text}` + )}
  • ` + ); + }) + .join(""); +} + + /* ========================================================= PARTY SUMMARY ========================================================= */ @@ -717,7 +885,7 @@ function renderPartySummary() { if (!appState.party) { elements.partySummary.textContent = - "Create a room to become White. The next player joins as Black. Up to 20 spectators can watch live."; + "Создайте комнату, чтобы играть белыми. Следующий игрок присоединится за чёрных. До 20 зрителей."; return; } @@ -727,10 +895,10 @@ function renderPartySummary() { const roleText = role === "white" - ? "playing as White" + ? "вы играете белыми" : role === "black" - ? "playing as Black" - : "watching as a spectator"; + ? "вы играете чёрными" + : "вы зритель"; const timeLabel = appState.partySnapshot @@ -738,7 +906,7 @@ function renderPartySummary() { "Без часов"; elements.partySummary.textContent = - `Party ${appState.party.code} · ${roleText}. ${timeLabel}.`; + `Комната ${appState.party.code} · ${roleText} · ${timeLabel}.`; } @@ -782,17 +950,21 @@ function renderClocks() { if (!enabled) { if (elements.whiteClock) { elements.whiteClock.textContent = "∞"; + elements.whiteClock.classList.remove( "active", - "low" + "low", + "danger" ); } if (elements.blackClock) { elements.blackClock.textContent = "∞"; + elements.blackClock.classList.remove( "active", - "low" + "low", + "danger" ); } @@ -819,6 +991,11 @@ function renderClocks() { "low", white <= 10 ); + + elements.whiteClock.classList.toggle( + "danger", + white <= 5 + ); } if (elements.blackClock) { @@ -835,6 +1012,11 @@ function renderClocks() { "low", black <= 10 ); + + elements.blackClock.classList.toggle( + "danger", + black <= 5 + ); } } @@ -851,28 +1033,61 @@ function renderConnectionBadge() { if (appState.mode === "ai") { elements.connectionBadge.textContent = appState.isAiThinking - ? "Computer thinking" - : `AI: ${appState.aiLevel}`; + ? "Компьютер думает" + : `AI · ${appState.aiLevel}`; + + elements.connectionBadge.className = + "status-chip connection-solo"; return; } if (!appState.party) { elements.connectionBadge.textContent = - "Party idle"; + "Party ожидание"; + + elements.connectionBadge.className = + "status-chip connection-solo"; return; } const role = appState.party.role === "white" - ? "White" + ? "Белые" : appState.party.role === "black" - ? "Black" - : "Spectator"; + ? "Чёрные" + : "Зритель"; elements.connectionBadge.textContent = - `Party ${appState.party.code} · ${role}`; + `${appState.party.code} · ${role}`; + + elements.connectionBadge.className = + "status-chip connection-party"; +} + + +/* ========================================================= + STATUS BADGE +========================================================= */ + +function renderTurnBadge() { + if (!elements.turnBadge) { + return; + } + + const turn = + appState.engine.state.turn; + + elements.turnBadge.classList.toggle( + "turn-white", + turn === "w" + ); + + elements.turnBadge.classList.toggle( + "turn-black", + turn === "b" + ); } @@ -886,8 +1101,12 @@ function render() { renderPartySummary(); renderClocks(); renderConnectionBadge(); + renderTurnBadge(); + renderCaptured(); + renderMoveHistory(); - const status = formatStatus(); + const status = + formatStatus(); if (elements.turnBadge) { elements.turnBadge.textContent = @@ -939,7 +1158,8 @@ function openPromotionDialog(moves) { button.textContent = PIECE_GLYPHS[ - move.color + move.color || + appState.engine.state.turn ][promotion]; button.addEventListener( @@ -1013,7 +1233,7 @@ async function submitMove(move) { showToast( response.error || - "Move rejected.", + "Ход отклонён.", "error" ); @@ -1039,7 +1259,7 @@ async function submitMove(move) { if (!result.ok) { showToast( result.error || - "Illegal move.", + "Недопустимый ход.", "error" ); @@ -1084,7 +1304,10 @@ function handleSquareClick(square) { ); if (matchingMoves.length === 1) { - submitMove(matchingMoves[0]); + submitMove( + matchingMoves[0] + ); + return; } @@ -1092,6 +1315,7 @@ function handleSquareClick(square) { openPromotionDialog( matchingMoves ); + return; } } @@ -1111,6 +1335,7 @@ function handleSquareClick(square) { : square; renderBoard(); + return; } @@ -1304,8 +1529,8 @@ function updateAiClock() { appState.gameOverMessage = color === "w" - ? "Black wins on time." - : "White wins on time."; + ? "Чёрные победили по времени." + : "Белые победили по времени."; stopAiClock(); @@ -1368,6 +1593,7 @@ function resetLocalGame() { new ChessEngine(); appState.partySnapshot = null; + appState.selectedSquare = null; appState.orientation = appState.playerColor; @@ -1416,7 +1642,7 @@ async function postJson(url, body) { ok: false, error: - `Server returned ${response.status} instead of JSON.`, + `Сервер вернул ${response.status} вместо JSON.`, }; } @@ -1428,7 +1654,7 @@ async function postJson(url, body) { error: error instanceof Error ? error.message - : "Network error.", + : "Ошибка сети.", }; } } @@ -1475,6 +1701,8 @@ function hydratePartyState( partyPayload.game ); + appState.selectedSquare = null; + appState.gameOver = Boolean( partyPayload.gameOverReason @@ -1581,7 +1809,7 @@ function connectPartyStream() { elements.connectionBadge ) { elements.connectionBadge.textContent = - `Party ${code} · reconnecting`; + `Party ${code} · переподключение`; } }; @@ -1624,7 +1852,7 @@ async function createParty() { if (!response.ok) { showToast( response.error || - "Unable to create party.", + "Не удалось создать комнату.", "error" ); @@ -1644,7 +1872,7 @@ async function createParty() { connectPartyStream(); showToast( - `Party ${response.party.code} created.`, + `Комната ${response.party.code} создана.`, "success" ); } @@ -1662,7 +1890,7 @@ async function joinParty(code) { if (!normalizedCode) { showToast( - "Enter a party code first.", + "Введите код комнаты.", "error" ); @@ -1674,7 +1902,7 @@ async function joinParty(code) { normalizedCode ) { showToast( - `You are already in party ${normalizedCode}.` + `Вы уже в комнате ${normalizedCode}.` ); return; @@ -1706,7 +1934,7 @@ async function joinParty(code) { if (!response.ok) { showToast( response.error || - "Unable to join party.", + "Не удалось войти в комнату.", "error" ); @@ -1726,7 +1954,7 @@ async function joinParty(code) { connectPartyStream(); showToast( - `Joined party ${response.party.code}.`, + `Вы вошли в комнату ${response.party.code}.`, "success" ); } @@ -1774,7 +2002,7 @@ async function leaveParty( if (!silent) { showToast( - "Left the party room." + "Вы покинули комнату." ); } } @@ -1802,12 +2030,12 @@ async function copyInviteLink() { ); showToast( - "Invite link copied.", + "Ссылка скопирована.", "success" ); } catch { showToast( - `Party code: ${appState.party.code}` + `Код комнаты: ${appState.party.code}` ); } } @@ -1960,7 +2188,28 @@ function bindEvents() { resetLocalGame(); showToast( - "New AI game ready.", + "Новая партия с ИИ готова.", + "success" + ); + } + ); + + + elements.newGameToolbarButton?.addEventListener( + "click", + async () => { + if (appState.mode === "party") { + showToast( + "Для новой партии создайте новую комнату." + ); + + return; + } + + resetLocalGame(); + + showToast( + "Новая партия.", "success" ); } @@ -1973,6 +2222,12 @@ function bindEvents() { ); + elements.newPartyGameButton?.addEventListener( + "click", + createParty + ); + + elements.joinPartyButton?.addEventListener( "click", () => @@ -2113,5 +2368,3 @@ async function init() { ========================================================= */ init(); - -