2169 lines
46 KiB
JavaScript
2169 lines
46 KiB
JavaScript
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 =
|
||
"<li class='empty-state'>В режиме ИИ зрителей нет</li>";
|
||
}
|
||
|
||
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 =
|
||
"<li class='empty-state'>Пока никого нет</li>";
|
||
|
||
return;
|
||
}
|
||
|
||
elements.spectatorList.innerHTML =
|
||
spectators
|
||
.map(
|
||
spectator =>
|
||
`<li>${escapeHtml(
|
||
spectator.name
|
||
)}${
|
||
spectator.connected
|
||
? ""
|
||
: " · offline"
|
||
}</li>`
|
||
)
|
||
.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();
|
||
|