import { ChessEngine, ChessRules, } from "/shared/chess-engine.js"; import { chooseComputerMove, } from "/js/ai.js"; /* ========================================================= PIECES ========================================================= */ const PIECE_GLYPHS = { w: { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘", P: "♙", }, b: { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞", P: "♟", }, }; /* ========================================================= STORAGE ========================================================= */ const STORAGE_KEYS = { name: "chess-party:name", partyIds: "chess-party:party-ids", }; /* ========================================================= TIME CONTROLS ========================================================= */ const TIME_CONTROLS = { none: 0, "1+0": 60, "3+0": 180, "3+2": 180, "5+0": 300, "5+3": 300, "10+0": 600, "15+10": 900, "30+0": 1800, "30+20": 1800, }; /* ========================================================= ELEMENTS ========================================================= */ const elements = { modeBadge: document.querySelector("#modeBadge"), connectionBadge: document.querySelector("#connectionBadge"), board: document.querySelector("#board"), statusMessage: document.querySelector("#statusMessage"), turnBadge: document.querySelector("#turnBadge"), modeAiButton: document.querySelector("#modeAiButton"), modePartyButton: document.querySelector("#modePartyButton"), playerNameInput: document.querySelector("#playerNameInput"), aiControls: document.querySelector("#aiControls"), partyControls: document.querySelector("#partyControls"), aiLevelSelect: document.querySelector("#aiLevelSelect"), playerColorSelect: document.querySelector("#playerColorSelect"), aiTimeControl: document.querySelector("#aiClockSelect"), partyTimeControl: document.querySelector("#partyClockSelect"), startAiButton: document.querySelector("#startAiButton"), createPartyButton: document.querySelector("#createPartyButton"), partyCodeInput: document.querySelector("#partyCodeInput"), joinPartyButton: document.querySelector("#joinPartyButton"), leavePartyButton: document.querySelector("#leavePartyButton"), partySummary: document.querySelector("#partySummary"), copyInviteButton: document.querySelector("#copyInviteButton"), // ДОБАВЛЕНЫ КНОПКИ "НОВАЯ ИГРА" newGameToolbarButton: document.querySelector("#newGameToolbarButton"), newPartyGameButton: document.querySelector("#newPartyGameButton"), flipBoardButton: document.querySelector("#flipBoardButton"), whitePlayerLabel: document.querySelector("#whitePlayerLabel"), blackPlayerLabel: document.querySelector("#blackPlayerLabel"), rosterWhite: document.querySelector("#rosterWhite"), rosterBlack: document.querySelector("#rosterBlack"), spectatorList: document.querySelector("#spectatorList"), spectatorCount: document.querySelector("#spectatorCount"), whiteClock: document.querySelector("#whiteClock"), blackClock: document.querySelector("#blackClock"), promotionDialog: document.querySelector("#promotionDialog"), promotionOptions: document.querySelector("#promotionOptions"), toastHost: document.querySelector("#toastHost"), }; /* ========================================================= STATE ========================================================= */ const appState = { mode: "ai", engine: new ChessEngine(), orientation: "w", selectedSquare: null, aiLevel: "intermediate", playerColor: "w", aiTimeControl: "10+0", isAiThinking: false, aiMoveTimer: null, aiClockTimer: null, pendingPromotionMoves: null, playerName: "Guest", party: null, partySnapshot: null, partyStream: null, gameOver: false, gameOverMessage: null, localClocks: { white: 0, black: 0, running: false, turn: "w", lastTick: 0, }, }; /* ========================================================= HELPERS ========================================================= */ function currentStatus() { return ( appState.engine.state.status || ChessEngine.evaluateStatus( appState.engine.state ) ); } function currentHumanColor() { if (appState.mode === "ai") { return appState.playerColor; } if (!appState.party) { return null; } if (appState.party.role === "white") { return "w"; } if (appState.party.role === "black") { return "b"; } return null; } function isHumanTurn() { const color = currentHumanColor(); const status = currentStatus(); return Boolean( color && status.phase === "playing" && appState.engine.state.turn === color && !appState.isAiThinking && !appState.gameOver ); } function isFinished() { const status = currentStatus(); return Boolean( appState.gameOver || status.phase === "checkmate" || status.phase === "draw" ); } function getLegalMovesForSelected() { if (!appState.selectedSquare) { return []; } return appState.engine.getMovesFrom( appState.selectedSquare ); } function clearSelection() { appState.selectedSquare = null; appState.pendingPromotionMoves = null; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function formatClock(seconds) { const total = Math.max( 0, Math.ceil(Number(seconds) || 0) ); const minutes = Math.floor(total / 60); const secs = total % 60; if (minutes >= 60) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return ( `${String(hours).padStart(2, "0")}:` + `${String(mins).padStart(2, "0")}:` + `${String(secs).padStart(2, "0")}` ); } return ( `${String(minutes).padStart(2, "0")}:` + `${String(secs).padStart(2, "0")}` ); } function getIncrementForTimeControl(value) { const parts = String(value || "").split("+"); return Number(parts[1] || 0); } function getTimeControlSeconds(value) { return TIME_CONTROLS[value] || 0; } /* ========================================================= TOAST ========================================================= */ function showToast(message, type = "info") { if (!elements.toastHost) { console.log(message); return; } const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; elements.toastHost.appendChild(toast); window.setTimeout( () => toast.remove(), 3200 ); } /* ========================================================= MODE ========================================================= */ function setMode(mode) { appState.mode = mode; const isAi = mode === "ai"; const isParty = mode === "party"; elements.modeAiButton?.classList.toggle( "active", isAi ); elements.modePartyButton?.classList.toggle( "active", isParty ); elements.aiControls?.classList.toggle( "hidden", !isAi ); elements.partyControls?.classList.toggle( "hidden", !isParty ); if (elements.modeBadge) { elements.modeBadge.textContent = isAi ? "AI Arena" : "Party Lounge"; } } /* ========================================================= URL ========================================================= */ function updatePartyUrl(code = null) { const url = new URL(window.location.href); if (code) { url.searchParams.set("party", code); } else { url.searchParams.delete("party"); } window.history.replaceState( {}, "", url ); } /* ========================================================= BOARD ========================================================= */ function renderBoard() { if (!elements.board) { return; } elements.board.innerHTML = ""; const selectedMoves = getLegalMovesForSelected(); const moveTargets = new Map( selectedMoves.map(move => [ move.to, move, ]) ); const orientation = appState.orientation; const xOrder = orientation === "w" ? [0, 1, 2, 3, 4, 5, 6, 7] : [7, 6, 5, 4, 3, 2, 1, 0]; const yOrder = orientation === "w" ? [7, 6, 5, 4, 3, 2, 1, 0] : [0, 1, 2, 3, 4, 5, 6, 7]; const status = currentStatus(); const checkColor = status.check ? appState.engine.state.turn : null; const lastMove = appState.engine.state.lastMove; for (const y of yOrder) { for (const x of xOrder) { const squareName = ChessRules.toSquare(x, y); const piece = appState.engine.state.board[y][x]; const square = document.createElement("button"); square.type = "button"; square.className = `square ${ (x + y) % 2 === 0 ? "dark" : "light" }`; square.dataset.square = squareName; if ( appState.selectedSquare === squareName ) { square.classList.add( "selected" ); } const move = moveTargets.get(squareName); if (move) { square.classList.add( move.capture ? "capture-target" : "legal-target" ); } if ( lastMove && ( lastMove.from === squareName || lastMove.to === squareName ) ) { square.classList.add( "last-move" ); } if ( piece && piece.type === "K" && piece.color === checkColor ) { square.classList.add( "check-square" ); } if (piece) { const pieceNode = document.createElement("span"); pieceNode.className = `piece ${ piece.color === "w" ? "white" : "black" }`; pieceNode.textContent = PIECE_GLYPHS[ piece.color ][piece.type]; square.appendChild(pieceNode); } square.addEventListener( "click", () => handleSquareClick(squareName) ); elements.board.appendChild(square); } } } /* ========================================================= GAME STATUS ========================================================= */ function formatStatus() { if (appState.gameOver) { return getGameOverMessage(); } const status = currentStatus(); if (status.phase === "checkmate") { return "Мат!"; } if (status.phase === "draw") { return "Ничья"; } if (status.check) { return appState.engine.state.turn === "w" ? "Шах белым" : "Шах черным"; } return appState.engine.state.turn === "w" ? "Ход белых" : "Ход черных"; } function getGameOverMessage() { const snapshot = appState.partySnapshot; if (!snapshot) { return ( appState.gameOverMessage || "Игра окончена." ); } const reason = snapshot.gameOverReason; const winner = snapshot.winner; switch (reason) { case "timeout": return winner === "w" ? "Белые выиграли по времени." : "Черные выиграли по времени."; case "checkmate": return winner === "w" ? "Белые выиграли матом." : "Черные выиграли матом."; case "draw": return "Ничья."; default: return ( appState.gameOverMessage || "Игра окончена." ); } } /* ========================================================= ROSTER ========================================================= */ function renderRoster() { if (appState.mode === "ai") { const human = appState.playerName || "Вы"; const whiteName = appState.playerColor === "w" ? human : `Компьютер (${appState.aiLevel})`; const blackName = appState.playerColor === "b" ? human : `Компьютер (${appState.aiLevel})`; if (elements.whitePlayerLabel) { elements.whitePlayerLabel.textContent = whiteName; } if (elements.blackPlayerLabel) { elements.blackPlayerLabel.textContent = blackName; } if (elements.rosterWhite) { elements.rosterWhite.textContent = whiteName; } if (elements.rosterBlack) { elements.rosterBlack.textContent = blackName; } if (elements.spectatorList) { elements.spectatorList.innerHTML = "
  • В режиме ИИ зрителей нет
  • "; } if (elements.spectatorCount) { elements.spectatorCount.textContent = "0 / 20"; } return; } const white = appState.partySnapshot?.players?.white; const black = appState.partySnapshot?.players?.black; const whiteName = white ? `${white.name}${ white.connected ? "" : " · offline" }` : "Свободно"; const blackName = black ? `${black.name}${ black.connected ? "" : " · offline" }` : "Свободно"; elements.whitePlayerLabel && (elements.whitePlayerLabel.textContent = whiteName); elements.blackPlayerLabel && (elements.blackPlayerLabel.textContent = blackName); elements.rosterWhite && (elements.rosterWhite.textContent = whiteName); elements.rosterBlack && (elements.rosterBlack.textContent = blackName); const spectators = appState.partySnapshot?.spectators || []; if (elements.spectatorCount) { elements.spectatorCount.textContent = `${spectators.length} / 20`; } if (!elements.spectatorList) { return; } if (!spectators.length) { elements.spectatorList.innerHTML = "
  • Пока никого нет
  • "; return; } elements.spectatorList.innerHTML = spectators .map( spectator => `
  • ${escapeHtml( spectator.name )}${ spectator.connected ? "" : " · offline" }
  • ` ) .join(""); } /* ========================================================= PARTY SUMMARY ========================================================= */ function renderPartySummary() { if (!elements.partySummary) { return; } if (!appState.party) { elements.partySummary.textContent = "Создайте комнату, чтобы играть белыми. Следующий игрок присоединится за черных."; return; } const role = appState.party.role; const roleText = role === "white" ? "Играете за Белых" : role === "black" ? "Играете за Черных" : "Зритель"; const timeLabel = appState.partySnapshot ?.timeControl?.label || "Без лимита"; elements.partySummary.textContent = `Комната ${appState.party.code} · ${roleText}. ${timeLabel}.`; } /* ========================================================= CLOCKS ========================================================= */ function renderClocks() { let clocks; let timeControl; if (appState.mode === "party") { clocks = appState.partySnapshot?.clocks; timeControl = appState.partySnapshot?.timeControl; } else { clocks = appState.localClocks; timeControl = { initial: getTimeControlSeconds( appState.aiTimeControl ), label: elements.aiTimeControl ?.selectedOptions?.[0] ?.textContent || "Без лимита", }; } const enabled = Boolean( timeControl?.initial > 0 ); if (!enabled) { if (elements.whiteClock) { elements.whiteClock.textContent = "∞"; elements.whiteClock.classList.remove( "active", "danger" ); } if (elements.blackClock) { elements.blackClock.textContent = "∞"; elements.blackClock.classList.remove( "active", "danger" ); } return; } const white = clocks?.white ?? 0; const black = clocks?.black ?? 0; if (elements.whiteClock) { elements.whiteClock.textContent = formatClock(white); elements.whiteClock.classList.toggle( "active", clocks?.turn === "w" && clocks?.running ); elements.whiteClock.classList.toggle( "danger", white <= 10 && white > 0 ); } if (elements.blackClock) { elements.blackClock.textContent = formatClock(black); elements.blackClock.classList.toggle( "active", clocks?.turn === "b" && clocks?.running ); elements.blackClock.classList.toggle( "danger", black <= 10 && black > 0 ); } } /* ========================================================= CONNECTION BADGE ========================================================= */ function renderConnectionBadge() { if (!elements.connectionBadge) { return; } // Сбросим классы перед установкой elements.connectionBadge.className = "status-chip"; if (appState.mode === "ai") { elements.connectionBadge.classList.add("connection-solo"); elements.connectionBadge.textContent = appState.isAiThinking ? "Компьютер думает..." : `Соло (${appState.aiLevel})`; return; } if (!appState.party) { elements.connectionBadge.classList.add("connection-solo"); elements.connectionBadge.textContent = "Вне комнаты"; return; } elements.connectionBadge.classList.add("connection-party"); const role = appState.party.role === "white" ? "Белые" : appState.party.role === "black" ? "Черные" : "Зритель"; elements.connectionBadge.textContent = `Комната ${appState.party.code} · ${role}`; } /* ========================================================= RENDER ========================================================= */ function render() { renderBoard(); renderRoster(); renderPartySummary(); renderClocks(); renderConnectionBadge(); const status = formatStatus(); if (elements.turnBadge) { elements.turnBadge.textContent = appState.engine.state.turn === "w" ? "Ход белых" : "Ход черных"; // Меняем класс цвета бейджа elements.turnBadge.className = appState.engine.state.turn === "w" ? "status-chip turn-white" : "status-chip turn-black"; } if (elements.statusMessage) { elements.statusMessage.textContent = status; } elements.copyInviteButton?.classList.toggle( "hidden", !appState.party ); elements.leavePartyButton?.classList.toggle( "hidden", !appState.party ); // Показываем кнопку "Новая игра" в мультиплеере только если мы в комнате (и опционально, если игра окончена) elements.newPartyGameButton?.classList.toggle( "hidden", !appState.party ); } /* ========================================================= PROMOTION ========================================================= */ function openPromotionDialog(moves) { appState.pendingPromotionMoves = moves; if (!elements.promotionOptions) { return; } elements.promotionOptions.innerHTML = ""; for (const move of moves) { const button = document.createElement("button"); button.type = "button"; button.className = "promotion-button"; const promotion = String(move.promotion || "Q") .toUpperCase(); button.textContent = PIECE_GLYPHS[ move.color ][promotion]; button.addEventListener( "click", () => { closePromotionDialog(); submitMove(move); } ); elements.promotionOptions.appendChild( button ); } elements.promotionDialog?.classList.remove( "hidden" ); } function closePromotionDialog() { appState.pendingPromotionMoves = null; elements.promotionDialog?.classList.add( "hidden" ); } /* ========================================================= MOVE ========================================================= */ async function submitMove(move) { if (appState.gameOver) { return; } clearSelection(); if ( appState.mode === "party" && appState.party ) { const response = await postJson( "/api/party/move", { partyCode: appState.party.code, clientId: appState.party.clientId, from: move.from, to: move.to, promotion: move.promotion || null, } ); if (!response.ok) { if (response.party) { hydratePartyState( response.party, false ); } showToast( response.error || "Ход отклонен.", "error" ); return; } hydratePartyState( response.party, false ); return; } const result = appState.engine.makeMove({ from: move.from, to: move.to, promotion: move.promotion || null, }); if (!result.ok) { showToast( result.error || "Недопустимый ход.", "error" ); return; } updateAiClockAfterMove(); render(); scheduleAiTurn(); } /* ========================================================= BOARD INPUT ========================================================= */ function handleSquareClick(square) { if ( appState.pendingPromotionMoves || appState.gameOver ) { return; } if (!isHumanTurn()) { clearSelection(); renderBoard(); return; } const piece = appState.engine.getPiece(square); if (appState.selectedSquare) { const matchingMoves = getLegalMovesForSelected() .filter( move => move.to === square ); if (matchingMoves.length === 1) { submitMove(matchingMoves[0]); return; } if (matchingMoves.length > 1) { openPromotionDialog( matchingMoves ); return; } } const humanColor = currentHumanColor(); if ( piece && piece.color === humanColor && piece.color === appState.engine.state.turn ) { appState.selectedSquare = appState.selectedSquare === square ? null : square; renderBoard(); return; } clearSelection(); renderBoard(); } /* ========================================================= AI ========================================================= */ function scheduleAiTurn() { stopAiMoveTimer(); if (appState.mode !== "ai") { return; } const status = currentStatus(); if ( !status || status.phase !== "playing" || appState.gameOver ) { appState.isAiThinking = false; render(); return; } if ( appState.engine.state.turn === appState.playerColor ) { appState.isAiThinking = false; render(); return; } appState.isAiThinking = true; render(); appState.aiMoveTimer = window.setTimeout( () => { appState.aiMoveTimer = null; if ( appState.mode !== "ai" || appState.gameOver ) { appState.isAiThinking = false; render(); return; } const move = chooseComputerMove( appState.engine, appState.aiLevel ); appState.isAiThinking = false; if (!move) { render(); return; } submitMove(move); }, 400 ); } function stopAiMoveTimer() { if (appState.aiMoveTimer) { window.clearTimeout( appState.aiMoveTimer ); appState.aiMoveTimer = null; } appState.isAiThinking = false; } /* ========================================================= AI CLOCK ========================================================= */ function startAiClock() { stopAiClock(); const seconds = getTimeControlSeconds( appState.aiTimeControl ); appState.localClocks = { white: seconds, black: seconds, running: seconds > 0, turn: appState.engine.state.turn, lastTick: Date.now(), }; render(); if (!seconds) { return; } appState.aiClockTimer = window.setInterval( () => { updateAiClock(); renderClocks(); }, 250 ); } function stopAiClock() { if (appState.aiClockTimer) { window.clearInterval( appState.aiClockTimer ); appState.aiClockTimer = null; } } function updateAiClock() { if ( appState.mode !== "ai" || appState.gameOver ) { return; } const clocks = appState.localClocks; if (!clocks.running) { return; } const now = Date.now(); const elapsed = Math.max( 0, (now - clocks.lastTick) / 1000 ); if (!elapsed) { return; } clocks.lastTick = now; const color = clocks.turn; const key = color === "w" ? "white" : "black"; clocks[key] = Math.max( 0, clocks[key] - elapsed ); if (clocks[key] <= 0) { clocks.running = false; appState.gameOver = true; appState.gameOverMessage = color === "w" ? "Черные выиграли по времени." : "Белые выиграли по времени."; stopAiClock(); showToast( appState.gameOverMessage, "error" ); render(); } } function updateAiClockAfterMove() { const clocks = appState.localClocks; if (!clocks.running) { return; } updateAiClock(); if (appState.gameOver) { return; } const previousTurn = clocks.turn; const key = previousTurn === "w" ? "white" : "black"; clocks[key] += getIncrementForTimeControl( appState.aiTimeControl ); clocks.turn = appState.engine.state.turn; clocks.lastTick = Date.now(); } /* ========================================================= LOCAL GAME ========================================================= */ function resetLocalGame() { stopAiMoveTimer(); stopAiClock(); closePromotionDialog(); appState.engine = new ChessEngine(); appState.partySnapshot = null; appState.orientation = appState.playerColor; appState.gameOver = false; appState.gameOverMessage = null; startAiClock(); scheduleAiTurn(); render(); } /* ========================================================= NEW GAME HANDLER (Добавлено) ========================================================= */ async function startNewGame() { if (appState.mode === "ai") { resetLocalGame(); showToast("Новая игра с ИИ начата!", "success"); } else if (appState.mode === "party" && appState.party) { // Если вы играете с другом, мы отправляем запрос на сервер, // чтобы он сбросил состояние комнаты. (Предполагается, что такой API существует) const response = await postJson("/api/party/restart", { partyCode: appState.party.code, clientId: appState.party.clientId }); if (!response.ok) { showToast(response.error || "Не удалось перезапустить игру на сервере.", "error"); return; } showToast("Новая игра в комнате началась!", "success"); } } /* ========================================================= API ========================================================= */ async function postJson(url, body) { try { const response = await fetch( url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), } ); const contentType = response.headers.get( "content-type" ) || ""; if ( !contentType.includes( "application/json" ) ) { return { ok: false, error: `Server returned ${response.status} instead of JSON.`, }; } return await response.json(); } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Network error.", }; } } /* ========================================================= PARTY STATE ========================================================= */ function hydratePartyState( partyPayload, announce = true ) { if (!partyPayload) { return; } const previousCode = appState.party?.code; const previousClientId = appState.party?.clientId; appState.party = { code: partyPayload.code, clientId: partyPayload.you?.id || previousClientId || null, role: partyPayload.you?.role || appState.party?.role || "spectator", }; appState.partySnapshot = partyPayload; appState.engine = new ChessEngine( partyPayload.game ); appState.gameOver = Boolean( partyPayload.gameOverReason ); appState.gameOverMessage = appState.gameOver ? getGameOverMessage() : null; if ( appState.party.role === "white" ) { appState.orientation = "w"; } else if ( appState.party.role === "black" ) { appState.orientation = "b"; } setMode("party"); updatePartyUrl( partyPayload.code ); render(); if ( announce && partyPayload.gameOverReason ) { showToast( getGameOverMessage(), "success" ); } if ( previousCode && previousCode !== partyPayload.code ) { console.info( "Party changed:", previousCode, "→", partyPayload.code ); } } /* ========================================================= PARTY SSE ========================================================= */ function connectPartyStream() { if (!appState.party) { return; } closePartyStream(); const { code, clientId, } = appState.party; const url = `/api/party/events?partyCode=${ encodeURIComponent(code) }&clientId=${ encodeURIComponent(clientId) }`; const stream = new EventSource(url); stream.addEventListener( "party", event => { try { const party = JSON.parse( event.data ); hydratePartyState( party, false ); } catch (error) { console.error( "Party SSE error:", error ); } } ); stream.onerror = () => { if ( appState.mode === "party" && elements.connectionBadge ) { elements.connectionBadge.textContent = `Комната ${code} · переподключение...`; } }; appState.partyStream = stream; } function closePartyStream() { if (appState.partyStream) { appState.partyStream.close(); appState.partyStream = null; } } /* ========================================================= CREATE PARTY ========================================================= */ async function createParty() { if (appState.party) { await leaveParty(true); } const timeControl = elements.partyTimeControl?.value || "10+0"; const response = await postJson( "/api/party/create", { name: appState.playerName, timeControl, } ); if (!response.ok) { showToast( response.error || "Не удалось создать комнату.", "error" ); return; } setStoredPartyId( response.party.code, response.clientId ); hydratePartyState( response.party, false ); connectPartyStream(); showToast( `Комната ${response.party.code} создана.`, "success" ); } /* ========================================================= JOIN PARTY ========================================================= */ async function joinParty(code) { const normalizedCode = String(code || "") .trim() .toUpperCase(); if (!normalizedCode) { showToast( "Сначала введите код комнаты.", "error" ); return; } if ( appState.party?.code === normalizedCode ) { showToast( `Вы уже находитесь в комнате ${normalizedCode}.` ); return; } if (appState.party) { await leaveParty(true); } const storedIds = getStoredPartyIds(); const response = await postJson( "/api/party/join", { name: appState.playerName, partyCode: normalizedCode, clientId: storedIds[normalizedCode] || null, } ); if (!response.ok) { showToast( response.error || "Не удалось войти в комнату.", "error" ); return; } setStoredPartyId( response.party.code, response.clientId ); hydratePartyState( response.party, false ); connectPartyStream(); showToast( `Вы присоединились к комнате ${response.party.code}.`, "success" ); } /* ========================================================= LEAVE PARTY ========================================================= */ async function leaveParty( silent = false ) { if (!appState.party) { return; } const party = appState.party; closePartyStream(); await postJson( "/api/party/leave", { partyCode: party.code, clientId: party.clientId, } ); removeStoredPartyId( party.code ); appState.party = null; appState.partySnapshot = null; updatePartyUrl(null); setMode("ai"); resetLocalGame(); if (!silent) { showToast( "Вы покинули комнату." ); } } /* ========================================================= INVITE ========================================================= */ async function copyInviteLink() { if (!appState.party) { return; } const inviteUrl = `${window.location.origin}` + `${window.location.pathname}` + `?party=${encodeURIComponent( appState.party.code )}`; try { await navigator.clipboard.writeText( inviteUrl ); showToast( "Ссылка-инвайт скопирована!", "success" ); } catch { showToast( `Код комнаты: ${appState.party.code}` ); } } /* ========================================================= STORAGE ========================================================= */ function getStoredPartyIds() { try { return JSON.parse( localStorage.getItem( STORAGE_KEYS.partyIds ) || "{}" ); } catch { return {}; } } function setStoredPartyId( code, clientId ) { if (!code || !clientId) { return; } const ids = getStoredPartyIds(); ids[code] = clientId; localStorage.setItem( STORAGE_KEYS.partyIds, JSON.stringify(ids) ); } function removeStoredPartyId(code) { if (!code) { return; } const ids = getStoredPartyIds(); delete ids[code]; localStorage.setItem( STORAGE_KEYS.partyIds, JSON.stringify(ids) ); } /* ========================================================= EVENTS ========================================================= */ function bindEvents() { // ПРИВЯЗКА КНОПОК НОВОЙ ИГРЫ elements.newGameToolbarButton?.addEventListener("click", startNewGame); elements.newPartyGameButton?.addEventListener("click", startNewGame); elements.playerNameInput?.addEventListener( "input", event => { appState.playerName = event.target.value.trim() || "Гость"; localStorage.setItem( STORAGE_KEYS.name, appState.playerName ); renderRoster(); } ); elements.aiLevelSelect?.addEventListener( "change", event => { appState.aiLevel = event.target.value; render(); } ); elements.playerColorSelect?.addEventListener( "change", event => { appState.playerColor = event.target.value; if (appState.mode === "ai") { resetLocalGame(); } } ); elements.aiTimeControl?.addEventListener( "change", event => { appState.aiTimeControl = event.target.value; if (appState.mode === "ai") { resetLocalGame(); } } ); elements.modeAiButton?.addEventListener( "click", async () => { if (appState.party) { await leaveParty(true); return; } setMode("ai"); resetLocalGame(); } ); elements.modePartyButton?.addEventListener( "click", () => { setMode("party"); render(); } ); elements.startAiButton?.addEventListener( "click", async () => { if (appState.party) { await leaveParty(true); } setMode("ai"); resetLocalGame(); showToast( "Новая игра с ИИ началась.", "success" ); } ); elements.createPartyButton?.addEventListener( "click", createParty ); elements.joinPartyButton?.addEventListener( "click", () => joinParty( elements.partyCodeInput?.value ) ); elements.leavePartyButton?.addEventListener( "click", () => leaveParty(false) ); elements.copyInviteButton?.addEventListener( "click", copyInviteLink ); elements.flipBoardButton?.addEventListener( "click", () => { appState.orientation = appState.orientation === "w" ? "b" : "w"; renderBoard(); } ); elements.partyCodeInput?.addEventListener( "input", event => { event.target.value = event.target.value .toUpperCase() .replace( /[^A-Z0-9]/g, "" ); } ); elements.partyCodeInput?.addEventListener( "keydown", event => { if (event.key !== "Enter") { return; } event.preventDefault(); joinParty( elements.partyCodeInput.value ); } ); elements.promotionDialog?.addEventListener( "click", event => { if ( event.target === elements.promotionDialog ) { closePromotionDialog(); } } ); window.addEventListener( "beforeunload", () => { stopAiMoveTimer(); stopAiClock(); closePartyStream(); } ); } /* ========================================================= INIT ========================================================= */ async function init() { bindEvents(); const savedName = localStorage.getItem( STORAGE_KEYS.name ); if (savedName) { appState.playerName = savedName; if (elements.playerNameInput) { elements.playerNameInput.value = savedName; } } const params = new URLSearchParams( window.location.search ); const partyCode = params.get("party"); if (partyCode) { setMode("party"); if (elements.partyCodeInput) { elements.partyCodeInput.value = partyCode.toUpperCase(); } await joinParty(partyCode); return; } setMode("ai"); resetLocalGame(); } /* ========================================================= START ========================================================= */ init();