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();