2048 lines
32 KiB
JavaScript
2048 lines
32 KiB
JavaScript
import { ChessEngine } from "/shared/chess-engine.js";
|
|
|
|
|
|
const PIECES = {
|
|
|
|
w: {
|
|
k: "♔",
|
|
q: "♕",
|
|
r: "♖",
|
|
b: "♗",
|
|
n: "♘",
|
|
p: "♙"
|
|
},
|
|
|
|
b: {
|
|
k: "♚",
|
|
q: "♛",
|
|
r: "♜",
|
|
b: "♝",
|
|
n: "♞",
|
|
p: "♟"
|
|
}
|
|
};
|
|
|
|
|
|
const state = {
|
|
|
|
mode: "ai",
|
|
|
|
engine: new ChessEngine(),
|
|
|
|
orientation: "w",
|
|
|
|
selected: null,
|
|
|
|
legalMoves: [],
|
|
|
|
aiColor: "b",
|
|
|
|
aiThinking: false,
|
|
|
|
aiTimer: null,
|
|
|
|
gameStarted: false,
|
|
|
|
party: null,
|
|
|
|
partyClientId: null,
|
|
|
|
partyEvents: null,
|
|
|
|
promotionResolver: null,
|
|
|
|
clocks: {
|
|
white: 600,
|
|
black: 600,
|
|
running: false,
|
|
turn: "w"
|
|
},
|
|
|
|
clockTimer: null
|
|
};
|
|
|
|
|
|
const $ = id =>
|
|
document.getElementById(id);
|
|
|
|
|
|
const boardElement =
|
|
$("board");
|
|
|
|
const statusMessage =
|
|
$("statusMessage");
|
|
|
|
const turnBadge =
|
|
$("turnBadge");
|
|
|
|
const connectionBadge =
|
|
$("connectionBadge");
|
|
|
|
const modeBadge =
|
|
$("modeBadge");
|
|
|
|
const aiControls =
|
|
$("aiControls");
|
|
|
|
const partyControls =
|
|
$("partyControls");
|
|
|
|
const whitePlayerLabel =
|
|
$("whitePlayerLabel");
|
|
|
|
const blackPlayerLabel =
|
|
$("blackPlayerLabel");
|
|
|
|
const whiteClock =
|
|
$("whiteClock");
|
|
|
|
const blackClock =
|
|
$("blackClock");
|
|
|
|
const spectatorCount =
|
|
$("spectatorCount");
|
|
|
|
const spectatorList =
|
|
$("spectatorList");
|
|
|
|
const toastHost =
|
|
$("toastHost");
|
|
|
|
const promotionDialog =
|
|
$("promotionDialog");
|
|
|
|
const promotionOptions =
|
|
$("promotionOptions");
|
|
|
|
|
|
const files =
|
|
"abcdefgh";
|
|
|
|
|
|
function showToast(
|
|
message,
|
|
type = ""
|
|
) {
|
|
|
|
const element =
|
|
document.createElement("div");
|
|
|
|
element.className =
|
|
`toast ${type}`;
|
|
|
|
element.textContent =
|
|
message;
|
|
|
|
toastHost.appendChild(
|
|
element
|
|
);
|
|
|
|
setTimeout(
|
|
() => element.remove(),
|
|
3200
|
|
);
|
|
}
|
|
|
|
|
|
function setStatus(message) {
|
|
|
|
statusMessage.textContent =
|
|
message;
|
|
}
|
|
|
|
|
|
function colorName(color) {
|
|
|
|
return color === "w"
|
|
? "белых"
|
|
: "черных";
|
|
}
|
|
|
|
|
|
function getSquare(file, rank) {
|
|
|
|
return `${files[file]}${rank + 1}`;
|
|
}
|
|
|
|
|
|
function getCoordinates(
|
|
index
|
|
) {
|
|
|
|
if (state.orientation === "w") {
|
|
|
|
return {
|
|
file: index % 8,
|
|
rank: 7 - Math.floor(index / 8)
|
|
};
|
|
}
|
|
|
|
return {
|
|
file: 7 - index % 8,
|
|
rank: Math.floor(index / 8)
|
|
};
|
|
}
|
|
|
|
|
|
function renderBoard() {
|
|
|
|
boardElement.innerHTML = "";
|
|
|
|
const snapshot =
|
|
state.engine.state;
|
|
|
|
const status =
|
|
state.engine.getStatus();
|
|
|
|
let checkedKing = null;
|
|
|
|
if (
|
|
status.phase === "check"
|
|
) {
|
|
|
|
for (
|
|
const [squareName, piece]
|
|
of Object.entries(
|
|
snapshot.board
|
|
)
|
|
) {
|
|
|
|
if (
|
|
piece.type === "k" &&
|
|
piece.color === snapshot.turn
|
|
) {
|
|
checkedKing = squareName;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const legalMap =
|
|
new Map();
|
|
|
|
for (
|
|
const move
|
|
of state.legalMoves
|
|
) {
|
|
|
|
legalMap.set(
|
|
move.to,
|
|
move
|
|
);
|
|
}
|
|
|
|
|
|
for (
|
|
let index = 0;
|
|
index < 64;
|
|
index++
|
|
) {
|
|
|
|
const {
|
|
file,
|
|
rank
|
|
} = getCoordinates(index);
|
|
|
|
const squareName =
|
|
getSquare(file, rank);
|
|
|
|
const element =
|
|
document.createElement("button");
|
|
|
|
element.type =
|
|
"button";
|
|
|
|
element.className =
|
|
"square";
|
|
|
|
if (
|
|
(file + rank) % 2 === 0
|
|
) {
|
|
element.classList.add(
|
|
"light"
|
|
);
|
|
} else {
|
|
element.classList.add(
|
|
"dark"
|
|
);
|
|
}
|
|
|
|
if (
|
|
state.selected === squareName
|
|
) {
|
|
element.classList.add(
|
|
"selected"
|
|
);
|
|
}
|
|
|
|
if (
|
|
snapshot.lastMove &&
|
|
(
|
|
snapshot.lastMove.from === squareName ||
|
|
snapshot.lastMove.to === squareName
|
|
)
|
|
) {
|
|
element.classList.add(
|
|
"last-move"
|
|
);
|
|
}
|
|
|
|
if (
|
|
checkedKing === squareName
|
|
) {
|
|
element.classList.add(
|
|
"check-square"
|
|
);
|
|
}
|
|
|
|
const legal =
|
|
legalMap.get(
|
|
squareName
|
|
);
|
|
|
|
if (legal) {
|
|
|
|
if (legal.capture) {
|
|
|
|
element.classList.add(
|
|
"capture-target"
|
|
);
|
|
|
|
} else {
|
|
|
|
element.classList.add(
|
|
"legal-target"
|
|
);
|
|
}
|
|
}
|
|
|
|
const piece =
|
|
snapshot.board[squareName];
|
|
|
|
if (piece) {
|
|
|
|
const span =
|
|
document.createElement("span");
|
|
|
|
span.className =
|
|
`piece ${
|
|
piece.color === "w"
|
|
? "white"
|
|
: "black"
|
|
}`;
|
|
|
|
span.textContent =
|
|
PIECES[
|
|
piece.color
|
|
][
|
|
piece.type
|
|
];
|
|
|
|
element.appendChild(
|
|
span
|
|
);
|
|
}
|
|
|
|
element.dataset.square =
|
|
squareName;
|
|
|
|
element.addEventListener(
|
|
"click",
|
|
() => handleSquareClick(squareName)
|
|
);
|
|
|
|
boardElement.appendChild(
|
|
element
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function canUserMove() {
|
|
|
|
if (
|
|
!state.gameStarted
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const status =
|
|
state.engine.getStatus();
|
|
|
|
if (
|
|
status.phase === "checkmate" ||
|
|
status.phase === "draw"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
state.aiThinking
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
state.mode === "party"
|
|
) {
|
|
|
|
if (!state.party) {
|
|
return false;
|
|
}
|
|
|
|
const role =
|
|
state.party.you?.role;
|
|
|
|
if (
|
|
role !== "white" &&
|
|
role !== "black"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
state.engine.state.turn ===
|
|
(
|
|
role === "white"
|
|
? "w"
|
|
: "b"
|
|
)
|
|
);
|
|
}
|
|
|
|
return (
|
|
state.engine.state.turn !==
|
|
state.aiColor
|
|
);
|
|
}
|
|
|
|
|
|
function handleSquareClick(
|
|
squareName
|
|
) {
|
|
|
|
if (!canUserMove()) {
|
|
return;
|
|
}
|
|
|
|
const piece =
|
|
state.engine.getPiece(
|
|
squareName
|
|
);
|
|
|
|
if (!state.selected) {
|
|
|
|
if (
|
|
piece &&
|
|
piece.color ===
|
|
state.engine.state.turn
|
|
) {
|
|
|
|
selectSquare(
|
|
squareName
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
if (
|
|
state.selected === squareName
|
|
) {
|
|
|
|
clearSelection();
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
const move =
|
|
state.legalMoves.find(
|
|
item =>
|
|
item.to === squareName
|
|
);
|
|
|
|
if (move) {
|
|
|
|
if (
|
|
move.promotion
|
|
) {
|
|
|
|
openPromotion(
|
|
move,
|
|
promotion =>
|
|
performMove(
|
|
move.from,
|
|
move.to,
|
|
promotion
|
|
)
|
|
);
|
|
|
|
} else {
|
|
|
|
performMove(
|
|
move.from,
|
|
move.to,
|
|
null
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
if (
|
|
piece &&
|
|
piece.color ===
|
|
state.engine.state.turn
|
|
) {
|
|
|
|
selectSquare(
|
|
squareName
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
clearSelection();
|
|
}
|
|
|
|
|
|
function selectSquare(
|
|
squareName
|
|
) {
|
|
|
|
state.selected =
|
|
squareName;
|
|
|
|
state.legalMoves =
|
|
state.engine.movesFrom(
|
|
squareName
|
|
);
|
|
|
|
renderBoard();
|
|
}
|
|
|
|
|
|
function clearSelection() {
|
|
|
|
state.selected =
|
|
null;
|
|
|
|
state.legalMoves =
|
|
[];
|
|
|
|
renderBoard();
|
|
}
|
|
|
|
|
|
function openPromotion(
|
|
move,
|
|
callback
|
|
) {
|
|
|
|
promotionOptions.innerHTML =
|
|
"";
|
|
|
|
const color =
|
|
state.engine.state.turn;
|
|
|
|
for (
|
|
const type
|
|
of ["q", "r", "b", "n"]
|
|
) {
|
|
|
|
const button =
|
|
document.createElement("button");
|
|
|
|
button.type =
|
|
"button";
|
|
|
|
button.textContent =
|
|
PIECES[color][type];
|
|
|
|
button.addEventListener(
|
|
"click",
|
|
() => {
|
|
|
|
promotionDialog.classList.add(
|
|
"hidden"
|
|
);
|
|
|
|
state.promotionResolver =
|
|
null;
|
|
|
|
callback(type);
|
|
}
|
|
);
|
|
|
|
promotionOptions.appendChild(
|
|
button
|
|
);
|
|
}
|
|
|
|
promotionDialog.classList.remove(
|
|
"hidden"
|
|
);
|
|
}
|
|
|
|
|
|
function performMove(
|
|
from,
|
|
to,
|
|
promotion
|
|
) {
|
|
|
|
if (
|
|
state.mode === "party"
|
|
) {
|
|
|
|
performPartyMove(
|
|
from,
|
|
to,
|
|
promotion
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
const result =
|
|
state.engine.makeMove({
|
|
from,
|
|
to,
|
|
promotion
|
|
});
|
|
|
|
if (!result.ok) {
|
|
|
|
showToast(
|
|
result.error,
|
|
"error"
|
|
);
|
|
|
|
clearSelection();
|
|
|
|
return;
|
|
}
|
|
|
|
clearSelection();
|
|
|
|
state.gameStarted =
|
|
true;
|
|
|
|
updateGameUI();
|
|
|
|
if (
|
|
result.status.phase ===
|
|
"checkmate" ||
|
|
result.status.phase ===
|
|
"draw"
|
|
) {
|
|
|
|
finishGame();
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
if (
|
|
state.engine.state.turn ===
|
|
state.aiColor
|
|
) {
|
|
|
|
scheduleAiMove();
|
|
}
|
|
}
|
|
|
|
|
|
function scheduleAiMove() {
|
|
|
|
if (
|
|
state.aiThinking
|
|
) {
|
|
return;
|
|
}
|
|
|
|
state.aiThinking =
|
|
true;
|
|
|
|
setStatus(
|
|
"Компьютер думает..."
|
|
);
|
|
|
|
renderBoard();
|
|
|
|
clearTimeout(
|
|
state.aiTimer
|
|
);
|
|
|
|
state.aiTimer =
|
|
setTimeout(
|
|
() => {
|
|
|
|
try {
|
|
makeAiMove();
|
|
} finally {
|
|
state.aiThinking =
|
|
false;
|
|
}
|
|
|
|
},
|
|
getAiDelay()
|
|
);
|
|
}
|
|
|
|
|
|
function getAiDelay() {
|
|
|
|
const level =
|
|
$("aiLevelSelect").value;
|
|
|
|
if (level === "easy") {
|
|
return 350;
|
|
}
|
|
|
|
if (level === "hard") {
|
|
return 800;
|
|
}
|
|
|
|
return 550;
|
|
}
|
|
|
|
|
|
function makeAiMove() {
|
|
|
|
if (
|
|
state.mode !== "ai"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const status =
|
|
state.engine.getStatus();
|
|
|
|
if (
|
|
status.phase === "checkmate" ||
|
|
status.phase === "draw"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const legal =
|
|
state.engine.legalMoves(
|
|
state.aiColor
|
|
);
|
|
|
|
if (!legal.length) {
|
|
updateGameUI();
|
|
return;
|
|
}
|
|
|
|
const level =
|
|
$("aiLevelSelect").value;
|
|
|
|
let move;
|
|
|
|
if (level === "easy") {
|
|
|
|
move =
|
|
legal[
|
|
Math.floor(
|
|
Math.random() *
|
|
legal.length
|
|
)
|
|
];
|
|
|
|
} else {
|
|
|
|
move =
|
|
chooseAiMove(
|
|
legal
|
|
);
|
|
}
|
|
|
|
const result =
|
|
state.engine.makeMove({
|
|
from: move.from,
|
|
to: move.to,
|
|
promotion:
|
|
move.promotion || null
|
|
});
|
|
|
|
if (!result.ok) {
|
|
|
|
showToast(
|
|
"ИИ не смог выполнить ход.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
updateGameUI();
|
|
|
|
if (
|
|
result.status.phase ===
|
|
"checkmate" ||
|
|
result.status.phase ===
|
|
"draw"
|
|
) {
|
|
|
|
finishGame();
|
|
}
|
|
}
|
|
|
|
|
|
function chooseAiMove(
|
|
moves
|
|
) {
|
|
|
|
let bestScore =
|
|
-Infinity;
|
|
|
|
let bestMoves = [];
|
|
|
|
|
|
for (const move of moves) {
|
|
|
|
let score =
|
|
Math.random() * 0.4;
|
|
|
|
if (move.capture) {
|
|
score += 3;
|
|
}
|
|
|
|
if (move.promotion) {
|
|
score += 8;
|
|
}
|
|
|
|
if (
|
|
move.to[1] === "4" ||
|
|
move.to[1] === "5"
|
|
) {
|
|
score += .15;
|
|
}
|
|
|
|
if (score > bestScore) {
|
|
|
|
bestScore =
|
|
score;
|
|
|
|
bestMoves = [
|
|
move
|
|
];
|
|
|
|
} else if (
|
|
Math.abs(
|
|
score - bestScore
|
|
) < .1
|
|
) {
|
|
|
|
bestMoves.push(
|
|
move
|
|
);
|
|
}
|
|
}
|
|
|
|
return bestMoves[
|
|
Math.floor(
|
|
Math.random() *
|
|
bestMoves.length
|
|
)
|
|
];
|
|
}
|
|
|
|
|
|
function finishGame() {
|
|
|
|
state.aiThinking =
|
|
false;
|
|
|
|
clearTimeout(
|
|
state.aiTimer
|
|
);
|
|
|
|
updateGameUI();
|
|
}
|
|
|
|
|
|
function updateGameUI() {
|
|
|
|
const status =
|
|
state.engine.getStatus();
|
|
|
|
const turn =
|
|
state.engine.state.turn;
|
|
|
|
turnBadge.textContent =
|
|
`Ход ${colorName(turn)}`;
|
|
|
|
turnBadge.className =
|
|
`status-chip ${
|
|
turn === "w"
|
|
? "turn-white"
|
|
: "turn-black"
|
|
}`;
|
|
|
|
|
|
if (
|
|
status.phase === "checkmate"
|
|
) {
|
|
|
|
setStatus(
|
|
`Мат. Победили ${colorName(status.winner)}.`
|
|
);
|
|
|
|
} else if (
|
|
status.phase === "draw"
|
|
) {
|
|
|
|
setStatus(
|
|
drawMessage(
|
|
status.reason
|
|
)
|
|
);
|
|
|
|
} else if (
|
|
status.phase === "check"
|
|
) {
|
|
|
|
setStatus(
|
|
`Шах — ход ${colorName(turn)}.`
|
|
);
|
|
|
|
} else if (
|
|
state.mode === "party"
|
|
) {
|
|
|
|
const role =
|
|
state.party?.you?.role;
|
|
|
|
if (
|
|
role === "spectator"
|
|
) {
|
|
|
|
setStatus(
|
|
`Ход ${colorName(turn)}. Вы зритель.`
|
|
);
|
|
|
|
} else {
|
|
|
|
const myColor =
|
|
role === "white"
|
|
? "w"
|
|
: "b";
|
|
|
|
setStatus(
|
|
myColor === turn
|
|
? "Ваш ход."
|
|
: "Ход соперника."
|
|
);
|
|
}
|
|
|
|
} else if (
|
|
state.gameStarted
|
|
) {
|
|
|
|
setStatus(
|
|
turn === state.aiColor
|
|
? "Ход компьютера."
|
|
: "Ваш ход."
|
|
);
|
|
|
|
} else {
|
|
|
|
setStatus(
|
|
"Новая игра готова."
|
|
);
|
|
}
|
|
|
|
renderBoard();
|
|
|
|
updateClocks();
|
|
}
|
|
|
|
|
|
function drawMessage(
|
|
reason
|
|
) {
|
|
|
|
switch (reason) {
|
|
|
|
case "stalemate":
|
|
return "Ничья — пат.";
|
|
|
|
case "50-move":
|
|
return "Ничья — правило 50 ходов.";
|
|
|
|
case "threefold":
|
|
return "Ничья — троекратное повторение.";
|
|
|
|
case "insufficient-material":
|
|
return "Ничья — недостаточно материала.";
|
|
|
|
default:
|
|
return "Ничья.";
|
|
}
|
|
}
|
|
|
|
|
|
function startNewAiGame() {
|
|
|
|
state.mode =
|
|
"ai";
|
|
|
|
state.engine =
|
|
new ChessEngine();
|
|
|
|
state.selected =
|
|
null;
|
|
|
|
state.legalMoves =
|
|
[];
|
|
|
|
state.gameStarted =
|
|
true;
|
|
|
|
state.aiThinking =
|
|
false;
|
|
|
|
clearTimeout(
|
|
state.aiTimer
|
|
);
|
|
|
|
const selectedColor =
|
|
$("playerColorSelect").value;
|
|
|
|
state.aiColor =
|
|
selectedColor === "w"
|
|
? "b"
|
|
: "w";
|
|
|
|
state.orientation =
|
|
selectedColor;
|
|
|
|
setupAiPlayers();
|
|
|
|
resetClockFromSelect(
|
|
$("aiClockSelect").value
|
|
);
|
|
|
|
updateGameUI();
|
|
|
|
if (
|
|
state.aiColor === "w"
|
|
) {
|
|
|
|
scheduleAiMove();
|
|
}
|
|
}
|
|
|
|
|
|
function setupAiPlayers() {
|
|
|
|
const name =
|
|
getPlayerName();
|
|
|
|
if (
|
|
state.aiColor === "b"
|
|
) {
|
|
|
|
whitePlayerLabel.textContent =
|
|
name;
|
|
|
|
blackPlayerLabel.textContent =
|
|
"Компьютер";
|
|
|
|
} else {
|
|
|
|
whitePlayerLabel.textContent =
|
|
"Компьютер";
|
|
|
|
blackPlayerLabel.textContent =
|
|
name;
|
|
}
|
|
}
|
|
|
|
|
|
function getPlayerName() {
|
|
|
|
const value =
|
|
$("playerNameInput")
|
|
.value
|
|
.trim();
|
|
|
|
return value || "Вы";
|
|
}
|
|
|
|
|
|
function resetClockFromSelect(
|
|
value
|
|
) {
|
|
|
|
const seconds =
|
|
parseClock(
|
|
value
|
|
);
|
|
|
|
state.clocks = {
|
|
white: seconds,
|
|
black: seconds,
|
|
running:
|
|
seconds > 0,
|
|
turn: "w"
|
|
};
|
|
|
|
updateClocks();
|
|
}
|
|
|
|
|
|
function parseClock(
|
|
value
|
|
) {
|
|
|
|
if (
|
|
value === "none"
|
|
) {
|
|
return 0;
|
|
}
|
|
|
|
const [minutes] =
|
|
value.split("+");
|
|
|
|
return (
|
|
Number(minutes) *
|
|
60
|
|
);
|
|
}
|
|
|
|
|
|
function updateClocks() {
|
|
|
|
const white =
|
|
state.clocks.white;
|
|
|
|
const black =
|
|
state.clocks.black;
|
|
|
|
whiteClock.textContent =
|
|
formatTime(white);
|
|
|
|
blackClock.textContent =
|
|
formatTime(black);
|
|
|
|
whiteClock.classList.toggle(
|
|
"active",
|
|
state.clocks.running &&
|
|
state.clocks.turn === "w"
|
|
);
|
|
|
|
blackClock.classList.toggle(
|
|
"active",
|
|
state.clocks.running &&
|
|
state.clocks.turn === "b"
|
|
);
|
|
|
|
whiteClock.classList.toggle(
|
|
"low",
|
|
white > 0 &&
|
|
white < 30
|
|
);
|
|
|
|
blackClock.classList.toggle(
|
|
"low",
|
|
black > 0 &&
|
|
black < 30
|
|
);
|
|
}
|
|
|
|
|
|
function formatTime(
|
|
seconds
|
|
) {
|
|
|
|
if (!seconds) {
|
|
return "—";
|
|
}
|
|
|
|
const value =
|
|
Math.max(
|
|
0,
|
|
Math.ceil(seconds)
|
|
);
|
|
|
|
const minutes =
|
|
Math.floor(
|
|
value / 60
|
|
);
|
|
|
|
const remaining =
|
|
value % 60;
|
|
|
|
return (
|
|
String(minutes)
|
|
.padStart(2, "0") +
|
|
":" +
|
|
String(remaining)
|
|
.padStart(2, "0")
|
|
);
|
|
}
|
|
|
|
|
|
function startClockTimer() {
|
|
|
|
clearInterval(
|
|
state.clockTimer
|
|
);
|
|
|
|
state.clockTimer =
|
|
setInterval(
|
|
() => {
|
|
|
|
if (
|
|
!state.clocks.running
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
state.clocks.white <= 0 ||
|
|
state.clocks.black <= 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const key =
|
|
state.clocks.turn === "w"
|
|
? "white"
|
|
: "black";
|
|
|
|
state.clocks[key] =
|
|
Math.max(
|
|
0,
|
|
state.clocks[key] -
|
|
1
|
|
);
|
|
|
|
updateClocks();
|
|
|
|
},
|
|
1000
|
|
);
|
|
}
|
|
|
|
|
|
function switchMode(
|
|
mode
|
|
) {
|
|
|
|
if (
|
|
mode === "ai"
|
|
) {
|
|
|
|
state.mode =
|
|
"ai";
|
|
|
|
modeAiButton.classList.add(
|
|
"active"
|
|
);
|
|
|
|
modePartyButton.classList.remove(
|
|
"active"
|
|
);
|
|
|
|
aiControls.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
partyControls.classList.add(
|
|
"hidden"
|
|
);
|
|
|
|
modeBadge.textContent =
|
|
"AI Арена";
|
|
|
|
connectionBadge.textContent =
|
|
"Соло";
|
|
|
|
connectionBadge.className =
|
|
"status-chip connection-solo";
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
state.mode =
|
|
"party";
|
|
|
|
modePartyButton.classList.add(
|
|
"active"
|
|
);
|
|
|
|
modeAiButton.classList.remove(
|
|
"active"
|
|
);
|
|
|
|
aiControls.classList.add(
|
|
"hidden"
|
|
);
|
|
|
|
partyControls.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
modeBadge.textContent =
|
|
"Party";
|
|
|
|
connectionBadge.textContent =
|
|
"Онлайн";
|
|
|
|
connectionBadge.className =
|
|
"status-chip connection-party";
|
|
|
|
state.gameStarted =
|
|
false;
|
|
|
|
state.engine =
|
|
new ChessEngine();
|
|
|
|
state.selected =
|
|
null;
|
|
|
|
state.legalMoves =
|
|
[];
|
|
|
|
updateGameUI();
|
|
}
|
|
|
|
|
|
async function createParty() {
|
|
|
|
const name =
|
|
getPlayerName();
|
|
|
|
try {
|
|
|
|
const response =
|
|
await fetch(
|
|
"/api/party/create",
|
|
{
|
|
method: "POST",
|
|
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json"
|
|
},
|
|
|
|
body: JSON.stringify({
|
|
name,
|
|
timeControl:
|
|
$("partyClockSelect")
|
|
.value
|
|
})
|
|
}
|
|
);
|
|
|
|
const data =
|
|
await response.json();
|
|
|
|
if (!data.ok) {
|
|
throw new Error(
|
|
data.error
|
|
);
|
|
}
|
|
|
|
state.party =
|
|
data.party;
|
|
|
|
state.partyClientId =
|
|
data.party.you?.id ||
|
|
null;
|
|
|
|
state.engine =
|
|
createEngineFromSnapshot(
|
|
data.party.game
|
|
);
|
|
|
|
state.gameStarted =
|
|
true;
|
|
|
|
state.orientation =
|
|
"w";
|
|
|
|
$("partyCodeInput").value =
|
|
data.party.code;
|
|
|
|
$("leavePartyButton")
|
|
.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
$("copyInviteButton")
|
|
.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
$("partySummary")
|
|
.textContent =
|
|
`Комната ${data.party.code}. Вы играете белыми.`;
|
|
|
|
applyPartyState(
|
|
data.party
|
|
);
|
|
|
|
connectPartyEvents();
|
|
|
|
showToast(
|
|
`Комната ${data.party.code} создана.`,
|
|
"success"
|
|
);
|
|
|
|
} catch (error) {
|
|
|
|
showToast(
|
|
error.message ||
|
|
"Не удалось создать комнату.",
|
|
"error"
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
async function joinParty() {
|
|
|
|
const code =
|
|
$("partyCodeInput")
|
|
.value
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
if (
|
|
!/^[A-Z0-9]{6}$/.test(code)
|
|
) {
|
|
|
|
showToast(
|
|
"Введите 6-значный код комнаты.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response =
|
|
await fetch(
|
|
"/api/party/join",
|
|
{
|
|
method: "POST",
|
|
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json"
|
|
},
|
|
|
|
body: JSON.stringify({
|
|
partyCode: code,
|
|
name:
|
|
getPlayerName(),
|
|
clientId:
|
|
state.partyClientId
|
|
})
|
|
}
|
|
);
|
|
|
|
const data =
|
|
await response.json();
|
|
|
|
if (!data.ok) {
|
|
throw new Error(
|
|
data.error
|
|
);
|
|
}
|
|
|
|
state.party =
|
|
data.party;
|
|
|
|
state.partyClientId =
|
|
data.party.you?.id ||
|
|
null;
|
|
|
|
state.gameStarted =
|
|
true;
|
|
|
|
applyPartyState(
|
|
data.party
|
|
);
|
|
|
|
$("leavePartyButton")
|
|
.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
$("copyInviteButton")
|
|
.classList.remove(
|
|
"hidden"
|
|
);
|
|
|
|
connectPartyEvents();
|
|
|
|
showToast(
|
|
`Вы вошли в комнату ${code}.`,
|
|
"success"
|
|
);
|
|
|
|
} catch (error) {
|
|
|
|
showToast(
|
|
error.message ||
|
|
"Не удалось войти в комнату.",
|
|
"error"
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function createEngineFromSnapshot(
|
|
snapshot
|
|
) {
|
|
|
|
const engine =
|
|
new ChessEngine(
|
|
snapshot.fen
|
|
);
|
|
|
|
engine.state.lastMove =
|
|
snapshot.lastMove
|
|
? {
|
|
...snapshot.lastMove
|
|
}
|
|
: null;
|
|
|
|
return engine;
|
|
}
|
|
|
|
|
|
function applyPartyState(
|
|
party
|
|
) {
|
|
|
|
state.party =
|
|
party;
|
|
|
|
state.engine =
|
|
createEngineFromSnapshot(
|
|
party.game
|
|
);
|
|
|
|
state.clocks = {
|
|
...party.clocks
|
|
};
|
|
|
|
state.selected =
|
|
null;
|
|
|
|
state.legalMoves =
|
|
[];
|
|
|
|
state.gameStarted =
|
|
true;
|
|
|
|
updatePartyPlayers();
|
|
|
|
updateSpectators();
|
|
|
|
updateGameUI();
|
|
}
|
|
|
|
|
|
function updatePartyPlayers() {
|
|
|
|
const players =
|
|
state.party?.players || {};
|
|
|
|
whitePlayerLabel.textContent =
|
|
players.white?.name ||
|
|
"Ожидание игрока";
|
|
|
|
blackPlayerLabel.textContent =
|
|
players.black?.name ||
|
|
"Ожидание игрока";
|
|
|
|
if (
|
|
state.party?.you?.role ===
|
|
"white"
|
|
) {
|
|
|
|
state.orientation =
|
|
"w";
|
|
|
|
} else if (
|
|
state.party?.you?.role ===
|
|
"black"
|
|
) {
|
|
|
|
state.orientation =
|
|
"b";
|
|
}
|
|
|
|
$("partySummary")
|
|
.textContent =
|
|
state.party?.code
|
|
? `Комната ${state.party.code}`
|
|
: "Создайте комнату.";
|
|
}
|
|
|
|
|
|
function updateSpectators() {
|
|
|
|
const spectators =
|
|
state.party?.spectators ||
|
|
[];
|
|
|
|
spectatorCount.textContent =
|
|
`${spectators.length} / ${
|
|
state.party?.maxSpectators || 20
|
|
}`;
|
|
|
|
spectatorList.innerHTML =
|
|
"";
|
|
|
|
if (!spectators.length) {
|
|
|
|
const li =
|
|
document.createElement("li");
|
|
|
|
li.className =
|
|
"empty-state";
|
|
|
|
li.textContent =
|
|
"Пока никого нет";
|
|
|
|
spectatorList.appendChild(
|
|
li
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
for (
|
|
const spectator
|
|
of spectators
|
|
) {
|
|
|
|
const li =
|
|
document.createElement("li");
|
|
|
|
li.textContent =
|
|
spectator.name;
|
|
|
|
spectatorList.appendChild(
|
|
li
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function connectPartyEvents() {
|
|
|
|
if (
|
|
state.partyEvents
|
|
) {
|
|
state.partyEvents.close();
|
|
}
|
|
|
|
if (
|
|
!state.party?.code ||
|
|
!state.partyClientId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const params =
|
|
new URLSearchParams({
|
|
partyCode:
|
|
state.party.code,
|
|
|
|
clientId:
|
|
state.partyClientId
|
|
});
|
|
|
|
const source =
|
|
new EventSource(
|
|
`/api/party/events?${params}`
|
|
);
|
|
|
|
state.partyEvents =
|
|
source;
|
|
|
|
source.addEventListener(
|
|
"party",
|
|
event => {
|
|
|
|
try {
|
|
|
|
const party =
|
|
JSON.parse(
|
|
event.data
|
|
);
|
|
|
|
applyPartyState(
|
|
party
|
|
);
|
|
|
|
} catch {
|
|
showToast(
|
|
"Ошибка обновления игры.",
|
|
"error"
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
source.onerror =
|
|
() => {
|
|
connectionBadge.textContent =
|
|
"Переподключение...";
|
|
|
|
connectionBadge.className =
|
|
"status-chip connection-solo";
|
|
};
|
|
}
|
|
|
|
|
|
async function performPartyMove(
|
|
from,
|
|
to,
|
|
promotion
|
|
) {
|
|
|
|
if (
|
|
!state.party ||
|
|
!state.partyClientId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response =
|
|
await fetch(
|
|
"/api/party/move",
|
|
{
|
|
method: "POST",
|
|
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json"
|
|
},
|
|
|
|
body: JSON.stringify({
|
|
|
|
partyCode:
|
|
state.party.code,
|
|
|
|
clientId:
|
|
state.partyClientId,
|
|
|
|
from,
|
|
to,
|
|
|
|
promotion:
|
|
promotion || null
|
|
})
|
|
}
|
|
);
|
|
|
|
const data =
|
|
await response.json();
|
|
|
|
if (
|
|
data.party
|
|
) {
|
|
applyPartyState(
|
|
data.party
|
|
);
|
|
}
|
|
|
|
if (!data.ok) {
|
|
|
|
showToast(
|
|
data.error ||
|
|
"Ход отклонён.",
|
|
"error"
|
|
);
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
showToast(
|
|
error.message ||
|
|
"Ошибка соединения.",
|
|
"error"
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
async function leaveParty() {
|
|
|
|
if (
|
|
!state.party ||
|
|
!state.partyClientId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
await fetch(
|
|
"/api/party/leave",
|
|
{
|
|
method: "POST",
|
|
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json"
|
|
},
|
|
|
|
body: JSON.stringify({
|
|
partyCode:
|
|
state.party.code,
|
|
|
|
clientId:
|
|
state.partyClientId
|
|
})
|
|
}
|
|
);
|
|
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
if (
|
|
state.partyEvents
|
|
) {
|
|
|
|
state.partyEvents.close();
|
|
|
|
state.partyEvents =
|
|
null;
|
|
}
|
|
|
|
state.party =
|
|
null;
|
|
|
|
state.partyClientId =
|
|
null;
|
|
|
|
$("leavePartyButton")
|
|
.classList.add(
|
|
"hidden"
|
|
);
|
|
|
|
$("copyInviteButton")
|
|
.classList.add(
|
|
"hidden"
|
|
);
|
|
|
|
$("partySummary")
|
|
.textContent =
|
|
"Создайте комнату, чтобы играть белыми.";
|
|
|
|
state.engine =
|
|
new ChessEngine();
|
|
|
|
state.gameStarted =
|
|
false;
|
|
|
|
state.selected =
|
|
null;
|
|
|
|
state.legalMoves =
|
|
[];
|
|
|
|
switchMode("party");
|
|
|
|
updateGameUI();
|
|
}
|
|
|
|
|
|
async function copyInvite() {
|
|
|
|
if (
|
|
!state.party?.code
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const url =
|
|
`${location.origin}${location.pathname}?party=${state.party.code}`;
|
|
|
|
try {
|
|
|
|
await navigator.clipboard.writeText(
|
|
url
|
|
);
|
|
|
|
showToast(
|
|
"Ссылка скопирована.",
|
|
"success"
|
|
);
|
|
|
|
} catch {
|
|
|
|
showToast(
|
|
`Код комнаты: ${state.party.code}`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function loadPartyFromUrl() {
|
|
|
|
const code =
|
|
new URLSearchParams(
|
|
location.search
|
|
).get("party");
|
|
|
|
if (code) {
|
|
|
|
switchMode("party");
|
|
|
|
$("partyCodeInput").value =
|
|
code.toUpperCase();
|
|
}
|
|
}
|
|
|
|
|
|
function flipBoard() {
|
|
|
|
state.orientation =
|
|
state.orientation === "w"
|
|
? "b"
|
|
: "w";
|
|
|
|
renderBoard();
|
|
}
|
|
|
|
|
|
function bindEvents() {
|
|
|
|
$("modeAiButton")
|
|
.addEventListener(
|
|
"click",
|
|
() => switchMode("ai")
|
|
);
|
|
|
|
$("modePartyButton")
|
|
.addEventListener(
|
|
"click",
|
|
() => switchMode("party")
|
|
);
|
|
|
|
$("startAiButton")
|
|
.addEventListener(
|
|
"click",
|
|
startNewAiGame
|
|
);
|
|
|
|
$("newGameToolbarButton")
|
|
.addEventListener(
|
|
"click",
|
|
() => {
|
|
|
|
if (
|
|
state.mode === "party"
|
|
) {
|
|
|
|
showToast(
|
|
"В Party новую игру создаёт владелец комнаты."
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
startNewAiGame();
|
|
}
|
|
);
|
|
|
|
$("flipBoardButton")
|
|
.addEventListener(
|
|
"click",
|
|
flipBoard
|
|
);
|
|
|
|
$("createPartyButton")
|
|
.addEventListener(
|
|
"click",
|
|
createParty
|
|
);
|
|
|
|
$("joinPartyButton")
|
|
.addEventListener(
|
|
"click",
|
|
joinParty
|
|
);
|
|
|
|
$("leavePartyButton")
|
|
.addEventListener(
|
|
"click",
|
|
leaveParty
|
|
);
|
|
|
|
$("copyInviteButton")
|
|
.addEventListener(
|
|
"click",
|
|
copyInvite
|
|
);
|
|
|
|
promotionDialog.addEventListener(
|
|
"click",
|
|
event => {
|
|
|
|
if (
|
|
event.target ===
|
|
promotionDialog
|
|
) {
|
|
|
|
promotionDialog.classList.add(
|
|
"hidden"
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
function initialize() {
|
|
|
|
bindEvents();
|
|
|
|
state.engine =
|
|
new ChessEngine();
|
|
|
|
state.orientation =
|
|
"w";
|
|
|
|
state.gameStarted =
|
|
false;
|
|
|
|
resetClockFromSelect(
|
|
$("aiClockSelect").value
|
|
);
|
|
|
|
setupAiPlayers();
|
|
|
|
updateGameUI();
|
|
|
|
startClockTimer();
|
|
|
|
loadPartyFromUrl();
|
|
}
|
|
|
|
|
|
initialize();
|