Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52edd18b3e | ||
|
|
299f47b1b3 | ||
|
|
d0e0e3274a | ||
|
|
d38ae7cbe6 | ||
|
|
7c94426848 | ||
|
|
1fbdd1317c | ||
|
|
1f41362338 | ||
|
|
2dfea24f42 | ||
|
|
039b64f9e5 | ||
|
|
6ed96533b0 | ||
|
|
8dcc964d04 | ||
|
|
226ad23f7b | ||
|
|
5f75752b26 | ||
|
|
5e57feb66f | ||
|
|
4e6e2fc9e2 | ||
|
|
e6e57a3cc0 | ||
|
|
0720bcc229 | ||
|
|
093b4ac46f | ||
|
|
3b02071fe7 | ||
|
|
728da46b10 | ||
|
|
328fe6b417 | ||
|
|
0aba79015d | ||
|
|
3d7bfd1797 | ||
|
|
debf3bbc68 | ||
|
|
1d87e4539b | ||
|
|
11a1b0cb41 | ||
|
|
83500d2d86 | ||
|
|
da311edec3 | ||
|
|
34606b50cc | ||
|
|
0a8475d245 | ||
|
|
52ce4ab42a | ||
|
|
ff02d54bab | ||
|
|
555d0b2005 | ||
|
|
bef651fa5d |
@@ -0,0 +1,140 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#171512">
|
||||
<title>Chess Party</title>
|
||||
<meta name="description" content="Chess Party — шахматы против ИИ и с друзьями.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ctext y='52' font-size='52'%3E♞%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">CHESS PARTY</div>
|
||||
<h1>Шахматы</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="newGameBtn" class="btn primary">Новая игра</button>
|
||||
<button id="flipBtn" class="btn">Повернуть</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="home" id="homeView">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="pill">Без лишних зависимостей</span>
|
||||
<h2>Играй спокойно.<br>Ходы всегда на виду.</h2>
|
||||
<p>Корректные подсказки фигур, последний ход, материал, история и стабильная ориентация доски.</p>
|
||||
</div>
|
||||
<div class="home-grid">
|
||||
<button class="mode-card" data-mode="ai">
|
||||
<strong>🤖 Против ИИ</strong>
|
||||
<span>Локальная партия без сервера.</span>
|
||||
</button>
|
||||
<button class="mode-card" data-mode="party">
|
||||
<strong>👥 Party</strong>
|
||||
<span>Создай комнату или подключись к другу.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel party-panel hidden" id="partySetup">
|
||||
<h3>Party</h3>
|
||||
<div class="form-row">
|
||||
<label>Имя<input id="nameInput" maxlength="24" placeholder="Guest"></label>
|
||||
<label>Контроль времени
|
||||
<select id="timeControl">
|
||||
<option value="none">Без часов</option>
|
||||
<option value="1+0">1 + 0</option>
|
||||
<option value="3+0">3 + 0</option>
|
||||
<option value="3+2">3 + 2</option>
|
||||
<option value="5+0">5 + 0</option>
|
||||
<option value="5+3">5 + 3</option>
|
||||
<option value="10+0" selected>10 + 0</option>
|
||||
<option value="15+10">15 + 10</option>
|
||||
<option value="30+0">30 + 0</option>
|
||||
<option value="30+20">30 + 20</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button id="createPartyBtn" class="btn primary">Создать комнату</button>
|
||||
<input id="partyCodeInput" maxlength="6" placeholder="Код комнаты">
|
||||
<button id="joinPartyBtn" class="btn">Войти</button>
|
||||
</div>
|
||||
<p id="setupError" class="error"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="game hidden" id="gameView">
|
||||
<div class="game-layout">
|
||||
<aside class="side-panel left-panel">
|
||||
<div class="player-row">
|
||||
<div class="avatar">♟</div>
|
||||
<div><strong id="blackName">Чёрные</strong><span id="blackMeta">—</span></div>
|
||||
<div id="blackClock" class="clock">—</div>
|
||||
</div>
|
||||
|
||||
<div class="board-wrap">
|
||||
<div id="board" class="board" aria-label="Шахматная доска"></div>
|
||||
</div>
|
||||
|
||||
<div class="player-row">
|
||||
<div class="avatar">♙</div>
|
||||
<div><strong id="whiteName">Белые</strong><span id="whiteMeta">—</span></div>
|
||||
<div id="whiteClock" class="clock">—</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="right-column">
|
||||
<div class="panel status-panel">
|
||||
<div class="panel-title"><span>Партия</span><span id="turnBadge" class="pill">Ход белых</span></div>
|
||||
<div id="gameStatus" class="status">Выберите фигуру.</div>
|
||||
<div id="material" class="material"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel controls">
|
||||
<button id="resignBtn" class="btn danger">Сдаться</button>
|
||||
<button id="drawBtn" class="btn">Предложить ничью</button>
|
||||
<button id="rematchBtn" class="btn primary">Реванш</button>
|
||||
<button id="backHomeBtn" class="btn">Выйти</button>
|
||||
</div>
|
||||
|
||||
<div class="panel moves-panel">
|
||||
<div class="panel-title"><span>Ходы</span><span id="moveCount">0</span></div>
|
||||
<div id="movesList" class="moves-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel chat-panel hidden" id="chatPanel">
|
||||
<div class="panel-title">Чат</div>
|
||||
<div id="chatMessages" class="chat-messages"></div>
|
||||
<form id="chatForm" class="chat-form">
|
||||
<input id="chatInput" maxlength="300" autocomplete="off" placeholder="Сообщение…">
|
||||
<button class="btn primary" type="submit">→</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="promotionModal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<h3>Превращение пешки</h3>
|
||||
<div class="promotion-grid">
|
||||
<button data-promotion="q">♛<span>Ферзь</span></button>
|
||||
<button data-promotion="r">♜<span>Ладья</span></button>
|
||||
<button data-promotion="b">♝<span>Слон</span></button>
|
||||
<button data-promotion="n">♞<span>Конь</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
const PIECE_VALUE = {
|
||||
P: 100,
|
||||
N: 320,
|
||||
B: 330,
|
||||
R: 500,
|
||||
Q: 900,
|
||||
K: 20_000,
|
||||
};
|
||||
|
||||
const LEVELS = {
|
||||
easy: {
|
||||
depth: 1,
|
||||
randomness: 0.35,
|
||||
},
|
||||
|
||||
intermediate: {
|
||||
depth: 2,
|
||||
randomness: 0.08,
|
||||
},
|
||||
|
||||
hard: {
|
||||
depth: 3,
|
||||
randomness: 0.01,
|
||||
},
|
||||
|
||||
expert: {
|
||||
depth: 4,
|
||||
randomness: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const CENTER = new Set([
|
||||
"d4",
|
||||
"e4",
|
||||
"d5",
|
||||
"e5",
|
||||
]);
|
||||
|
||||
const EXTENDED_CENTER = new Set([
|
||||
"c3",
|
||||
"d3",
|
||||
"e3",
|
||||
"f3",
|
||||
"c4",
|
||||
"d4",
|
||||
"e4",
|
||||
"f4",
|
||||
"c5",
|
||||
"d5",
|
||||
"e5",
|
||||
"f5",
|
||||
"c6",
|
||||
"d6",
|
||||
"e6",
|
||||
"f6",
|
||||
]);
|
||||
|
||||
function cloneEngine(engine) {
|
||||
return new engine.constructor(
|
||||
engine.getSnapshot(),
|
||||
);
|
||||
}
|
||||
|
||||
function opposite(color) {
|
||||
return color === "w"
|
||||
? "b"
|
||||
: "w";
|
||||
}
|
||||
|
||||
function squareName(x, y) {
|
||||
return String.fromCharCode(
|
||||
97 + x,
|
||||
) + (8 - y);
|
||||
}
|
||||
|
||||
function getAllMoves(engine, color) {
|
||||
const moves = [];
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const square =
|
||||
squareName(x, y);
|
||||
|
||||
const piece =
|
||||
engine.state.board[y][x];
|
||||
|
||||
if (
|
||||
!piece ||
|
||||
piece.color !== color
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pieceMoves =
|
||||
engine.getMovesFrom(
|
||||
square,
|
||||
);
|
||||
|
||||
for (const move of pieceMoves) {
|
||||
moves.push(move);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
function evaluateMaterial(engine, color) {
|
||||
let score = 0;
|
||||
|
||||
for (const row of engine.state.board) {
|
||||
for (const piece of row) {
|
||||
if (!piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value =
|
||||
PIECE_VALUE[piece.type] ||
|
||||
0;
|
||||
|
||||
score +=
|
||||
piece.color === color
|
||||
? value
|
||||
: -value;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function evaluatePosition(engine, color) {
|
||||
let score = 0;
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const piece =
|
||||
engine.state.board[y][x];
|
||||
|
||||
if (!piece) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const square =
|
||||
squareName(x, y);
|
||||
|
||||
const sign =
|
||||
piece.color === color
|
||||
? 1
|
||||
: -1;
|
||||
|
||||
if (
|
||||
CENTER.has(square)
|
||||
) {
|
||||
score +=
|
||||
sign *
|
||||
(piece.type === "P"
|
||||
? 30
|
||||
: 18);
|
||||
}
|
||||
|
||||
if (
|
||||
EXTENDED_CENTER.has(
|
||||
square,
|
||||
)
|
||||
) {
|
||||
score += sign * 6;
|
||||
}
|
||||
|
||||
if (
|
||||
piece.type === "N"
|
||||
) {
|
||||
const edgePenalty =
|
||||
Math.abs(3.5 - x) +
|
||||
Math.abs(3.5 - y);
|
||||
|
||||
score +=
|
||||
sign *
|
||||
Math.max(
|
||||
0,
|
||||
18 - edgePenalty * 4,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
piece.type === "P"
|
||||
) {
|
||||
const advancement =
|
||||
piece.color === "w"
|
||||
? 6 - y
|
||||
: y - 1;
|
||||
|
||||
score +=
|
||||
sign *
|
||||
advancement *
|
||||
8;
|
||||
}
|
||||
|
||||
if (
|
||||
piece.type === "B"
|
||||
) {
|
||||
score += sign * 4;
|
||||
}
|
||||
|
||||
if (
|
||||
piece.type === "R" &&
|
||||
(y === 0 ||
|
||||
y === 7)
|
||||
) {
|
||||
score += sign * 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function evaluateMobility(
|
||||
engine,
|
||||
color,
|
||||
) {
|
||||
const own =
|
||||
getAllMoves(
|
||||
engine,
|
||||
color,
|
||||
).length;
|
||||
|
||||
const enemy =
|
||||
getAllMoves(
|
||||
engine,
|
||||
opposite(color),
|
||||
).length;
|
||||
|
||||
return (own - enemy) * 3;
|
||||
}
|
||||
|
||||
function evaluateKings(engine, color) {
|
||||
let score = 0;
|
||||
|
||||
const status =
|
||||
engine.state.status;
|
||||
|
||||
if (!status) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (status.check) {
|
||||
const turn =
|
||||
engine.state.turn;
|
||||
|
||||
if (turn === color) {
|
||||
score -= 35;
|
||||
} else {
|
||||
score += 35;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function evaluate(engine, color) {
|
||||
const status =
|
||||
engine.state.status;
|
||||
|
||||
if (
|
||||
status?.phase ===
|
||||
"checkmate"
|
||||
) {
|
||||
if (
|
||||
status.winner === color
|
||||
) {
|
||||
return 1_000_000;
|
||||
}
|
||||
|
||||
return -1_000_000;
|
||||
}
|
||||
|
||||
if (
|
||||
status?.phase === "draw"
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
evaluateMaterial(
|
||||
engine,
|
||||
color,
|
||||
) +
|
||||
evaluatePosition(
|
||||
engine,
|
||||
color,
|
||||
) +
|
||||
evaluateMobility(
|
||||
engine,
|
||||
color,
|
||||
) +
|
||||
evaluateKings(
|
||||
engine,
|
||||
color,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function orderMoves(
|
||||
engine,
|
||||
moves,
|
||||
) {
|
||||
return [...moves].sort(
|
||||
(a, b) => {
|
||||
const captureA =
|
||||
a.capture ? 1 : 0;
|
||||
|
||||
const captureB =
|
||||
b.capture ? 1 : 0;
|
||||
|
||||
const promotionA =
|
||||
a.promotion ? 1 : 0;
|
||||
|
||||
const promotionB =
|
||||
b.promotion ? 1 : 0;
|
||||
|
||||
return (
|
||||
captureB -
|
||||
captureA ||
|
||||
promotionB -
|
||||
promotionA
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function minimax(
|
||||
engine,
|
||||
depth,
|
||||
alpha,
|
||||
beta,
|
||||
maximizingColor,
|
||||
) {
|
||||
const status =
|
||||
engine.state.status;
|
||||
|
||||
if (
|
||||
depth <= 0 ||
|
||||
status?.phase ===
|
||||
"checkmate" ||
|
||||
status?.phase === "draw"
|
||||
) {
|
||||
return {
|
||||
score: evaluate(
|
||||
engine,
|
||||
maximizingColor,
|
||||
),
|
||||
move: null,
|
||||
};
|
||||
}
|
||||
|
||||
const turn =
|
||||
engine.state.turn;
|
||||
|
||||
const moves =
|
||||
orderMoves(
|
||||
engine,
|
||||
getAllMoves(
|
||||
engine,
|
||||
turn,
|
||||
),
|
||||
);
|
||||
|
||||
if (!moves.length) {
|
||||
return {
|
||||
score: evaluate(
|
||||
engine,
|
||||
maximizingColor,
|
||||
),
|
||||
move: null,
|
||||
};
|
||||
}
|
||||
|
||||
const maximizing =
|
||||
turn === maximizingColor;
|
||||
|
||||
let bestScore = maximizing
|
||||
? -Infinity
|
||||
: Infinity;
|
||||
|
||||
let bestMove = null;
|
||||
|
||||
for (const move of moves) {
|
||||
const child =
|
||||
cloneEngine(engine);
|
||||
|
||||
const result =
|
||||
child.makeMove({
|
||||
from: move.from,
|
||||
to: move.to,
|
||||
promotion:
|
||||
move.promotion ||
|
||||
null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resultNode =
|
||||
minimax(
|
||||
child,
|
||||
depth - 1,
|
||||
alpha,
|
||||
beta,
|
||||
maximizingColor,
|
||||
);
|
||||
|
||||
const score =
|
||||
resultNode.score;
|
||||
|
||||
if (maximizing) {
|
||||
if (
|
||||
score >
|
||||
bestScore
|
||||
) {
|
||||
bestScore = score;
|
||||
bestMove = move;
|
||||
}
|
||||
|
||||
alpha = Math.max(
|
||||
alpha,
|
||||
bestScore,
|
||||
);
|
||||
} else {
|
||||
if (
|
||||
score <
|
||||
bestScore
|
||||
) {
|
||||
bestScore = score;
|
||||
bestMove = move;
|
||||
}
|
||||
|
||||
beta = Math.min(
|
||||
beta,
|
||||
bestScore,
|
||||
);
|
||||
}
|
||||
|
||||
if (beta <= alpha) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
score: bestScore,
|
||||
move: bestMove,
|
||||
};
|
||||
}
|
||||
|
||||
export const AI_LEVELS = LEVELS;
|
||||
|
||||
export function chooseComputerMove(
|
||||
engine,
|
||||
level = "intermediate",
|
||||
) {
|
||||
const config =
|
||||
LEVELS[level] ||
|
||||
LEVELS.intermediate;
|
||||
|
||||
const color =
|
||||
engine.state.turn;
|
||||
|
||||
const moves =
|
||||
getAllMoves(
|
||||
engine,
|
||||
color,
|
||||
);
|
||||
|
||||
if (!moves.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ranked = [];
|
||||
|
||||
for (const move of moves) {
|
||||
const child =
|
||||
cloneEngine(engine);
|
||||
|
||||
const result =
|
||||
child.makeMove({
|
||||
from: move.from,
|
||||
to: move.to,
|
||||
promotion:
|
||||
move.promotion ||
|
||||
null,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score =
|
||||
minimax(
|
||||
child,
|
||||
Math.max(
|
||||
0,
|
||||
config.depth - 1,
|
||||
),
|
||||
-Infinity,
|
||||
Infinity,
|
||||
color,
|
||||
).score;
|
||||
|
||||
ranked.push({
|
||||
move,
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
ranked.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score,
|
||||
);
|
||||
|
||||
if (!ranked.length) {
|
||||
return moves[0];
|
||||
}
|
||||
|
||||
if (
|
||||
Math.random() <
|
||||
config.randomness
|
||||
) {
|
||||
const count =
|
||||
Math.min(
|
||||
3,
|
||||
ranked.length,
|
||||
);
|
||||
|
||||
return ranked[
|
||||
Math.floor(
|
||||
Math.random() *
|
||||
count,
|
||||
)
|
||||
].move;
|
||||
}
|
||||
|
||||
return ranked[0].move;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { ChessEngine, VALUES } from "/shared/chess-engine.js";
|
||||
|
||||
const PIECES = {
|
||||
w: { k:"♔", q:"♕", r:"♖", b:"♗", n:"♘", p:"♙" },
|
||||
b: { k:"♚", q:"♛", r:"♜", b:"♝", n:"♞", p:"♟" }
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
mode: null,
|
||||
engine: new ChessEngine(),
|
||||
orientation: "w",
|
||||
selected: null,
|
||||
legal: [],
|
||||
party: null,
|
||||
clientId: localStorage.getItem("chess-client-id") || crypto.randomUUID(),
|
||||
eventSource: null,
|
||||
pendingPromotion: null,
|
||||
aiBusy: false,
|
||||
localGameOver: false
|
||||
};
|
||||
|
||||
localStorage.setItem("chess-client-id", state.clientId);
|
||||
|
||||
function show(view) {
|
||||
$("homeView").classList.toggle("hidden", view !== "home");
|
||||
$("gameView").classList.toggle("hidden", view !== "game");
|
||||
}
|
||||
|
||||
function toast(text) {
|
||||
const el = $("toast");
|
||||
el.textContent = text;
|
||||
el.classList.add("show");
|
||||
clearTimeout(toast.timer);
|
||||
toast.timer = setTimeout(() => el.classList.remove("show"), 2200);
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
state.selected = null;
|
||||
state.legal = [];
|
||||
}
|
||||
|
||||
function boardOrder() {
|
||||
const files = "abcdefgh".split("");
|
||||
const ranks = [8,7,6,5,4,3,2,1];
|
||||
if (state.orientation === "b") {
|
||||
files.reverse();
|
||||
ranks.reverse();
|
||||
}
|
||||
return { files, ranks };
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return state.mode === "party" ? state.party?.game : state.engine.getSnapshot();
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const snap = snapshot();
|
||||
if (!snap) return;
|
||||
|
||||
const board = $("board");
|
||||
board.innerHTML = "";
|
||||
const { files, ranks } = boardOrder();
|
||||
|
||||
const legalMap = new Map(state.legal.map(m => [m.to, m]));
|
||||
const last = snap.lastMove;
|
||||
|
||||
for (const rank of ranks) {
|
||||
for (const file of files) {
|
||||
const name = `${file}${rank}`;
|
||||
const f = "abcdefgh".indexOf(file);
|
||||
const r = rank - 1;
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = `square ${((f+r)%2===0) ? "light" : "dark"}`;
|
||||
el.dataset.square = name;
|
||||
|
||||
if (state.selected === name) el.classList.add("selected");
|
||||
if (state.selected && state.legal.some(m => m.to === name)) {
|
||||
el.classList.add(snap.board[name] ? "capture" : "legal");
|
||||
}
|
||||
if (last?.from === name) el.classList.add("last-from");
|
||||
if (last?.to === name) el.classList.add("last-to");
|
||||
if (state.selected && state.legal.some(m => m.to === name)) el.classList.add("trajectory");
|
||||
|
||||
const piece = snap.board[name];
|
||||
if (piece) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "piece";
|
||||
span.textContent = PIECES[piece.color][piece.type];
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement("span");
|
||||
fileLabel.className = "coord file";
|
||||
fileLabel.textContent = file;
|
||||
el.appendChild(fileLabel);
|
||||
|
||||
const rankLabel = document.createElement("span");
|
||||
rankLabel.className = "coord rank";
|
||||
rankLabel.textContent = rank;
|
||||
el.appendChild(rankLabel);
|
||||
|
||||
el.addEventListener("click", () => handleSquare(name));
|
||||
board.appendChild(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canUserMove() {
|
||||
if (state.mode === "ai") return state.engine.state.turn === "w" && !state.aiBusy && !state.localGameOver;
|
||||
if (state.mode === "party") {
|
||||
return state.party?.you?.role === (state.party.game.turn === "w" ? "white" : "black")
|
||||
&& !state.party.gameOverReason;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleSquare(name) {
|
||||
if (!canUserMove()) return;
|
||||
|
||||
const snap = snapshot();
|
||||
const piece = snap.board[name];
|
||||
|
||||
if (state.selected) {
|
||||
const candidate = state.legal.filter(m => m.to === name);
|
||||
if (candidate.length) {
|
||||
if (candidate.some(m => m.promotion)) {
|
||||
state.pendingPromotion = { from: state.selected, to: name };
|
||||
$("promotionModal").classList.remove("hidden");
|
||||
} else {
|
||||
playMove(state.selected, name, candidate[0].promotion || null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (piece && piece.color === snap.turn) {
|
||||
state.selected = name;
|
||||
if (state.mode === "ai") state.legal = state.engine.movesFrom(name);
|
||||
else state.legal = state.partyEngineMoves(name);
|
||||
} else {
|
||||
resetSelection();
|
||||
}
|
||||
renderBoard();
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function partyEngineMoves(from) {
|
||||
// The server is authoritative. The snapshot contains enough board state to
|
||||
// calculate normal movement hints locally; the server validates the final move.
|
||||
const temp = new ChessEngine();
|
||||
temp.state = JSON.parse(JSON.stringify({
|
||||
board: state.party.game.board,
|
||||
turn: state.party.game.turn,
|
||||
castling: state.party.game.castling,
|
||||
ep: state.party.game.ep,
|
||||
halfmove: state.party.game.halfmove,
|
||||
fullmove: state.party.game.fullmove,
|
||||
lastMove: state.party.game.lastMove
|
||||
}));
|
||||
return temp.movesFrom(from);
|
||||
}
|
||||
|
||||
async function playMove(from, to, promotion = null) {
|
||||
resetSelection();
|
||||
$("promotionModal").classList.add("hidden");
|
||||
|
||||
if (state.mode === "ai") {
|
||||
const result = state.engine.makeMove({ from, to, promotion: promotion || "q" });
|
||||
if (!result.ok) {
|
||||
toast(result.error);
|
||||
renderBoard();
|
||||
return;
|
||||
}
|
||||
renderAll();
|
||||
if (!state.engine.getStatus().phase.match(/checkmate|draw/)) {
|
||||
state.aiBusy = true;
|
||||
renderAll();
|
||||
setTimeout(aiMove, 220);
|
||||
}
|
||||
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.clientId,
|
||||
from, to, promotion: promotion || "q"
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok) toast(data.error || "Ход не выполнен.");
|
||||
if (data.party) applyParty(data.party);
|
||||
} catch {
|
||||
toast("Нет соединения с сервером.");
|
||||
}
|
||||
}
|
||||
|
||||
function aiMove() {
|
||||
const moves = state.engine.legalMoves("b");
|
||||
if (!moves.length) {
|
||||
state.aiBusy = false;
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Small deterministic evaluation: captures, promotion, checks, center.
|
||||
let best = moves[0];
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const move of moves) {
|
||||
const piece = state.engine.state.board[move.from];
|
||||
const captured = state.engine.state.board[move.to];
|
||||
let score = Math.random() * 0.15;
|
||||
if (captured) score += VALUES[captured.type] * 2.5;
|
||||
if (move.promotion) score += 9;
|
||||
if (["d4","e4","d5","e5"].includes(move.to)) score += .5;
|
||||
if (piece?.type === "p" && Math.abs(Number(move.to[1]) - Number(move.from[1])) === 2) score += .1;
|
||||
if (score > bestScore) { bestScore = score; best = move; }
|
||||
}
|
||||
|
||||
state.engine.makeMove({
|
||||
from: best.from,
|
||||
to: best.to,
|
||||
promotion: best.promotion || "q"
|
||||
});
|
||||
state.aiBusy = false;
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function renderMoves(snap) {
|
||||
const list = $("movesList");
|
||||
list.innerHTML = "";
|
||||
const moves = snap.moves || [];
|
||||
|
||||
for (let i = 0; i < moves.length; i += 2) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "move";
|
||||
row.innerHTML = `
|
||||
<span class="move-number">${Math.floor(i/2)+1}.</span>
|
||||
<span>${escapeHtml(moves[i]?.san || "")}</span>
|
||||
<span>${escapeHtml(moves[i+1]?.san || "")}</span>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
$("moveCount").textContent = moves.length;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
"&":"&","<":"<",">":">",'"':""","'":"'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function renderMaterial(snap) {
|
||||
const mat = snap.material || { captured:{w:[],b:[]},score:{w:0,b:0} };
|
||||
const icon = (color, type) => PIECES[color][type];
|
||||
$("material").innerHTML = `
|
||||
<span>${mat.captured.w.map(p => icon("w",p)).join("") || "—"}</span>
|
||||
<b>Δ ${mat.score.w - mat.score.b}</b>
|
||||
<span>${mat.captured.b.map(p => icon("b",p)).join("") || "—"}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
const snap = snapshot();
|
||||
if (!snap) return;
|
||||
|
||||
const status = snap.status || { phase:"playing", turn:snap.turn };
|
||||
let text = state.selected ? "Выберите клетку назначения." : "Выберите фигуру.";
|
||||
|
||||
if (status.phase === "check") text = "Шах.";
|
||||
if (status.phase === "checkmate") text = "Мат.";
|
||||
if (status.phase === "draw") text = `Ничья: ${status.reason || "правило"}.`;
|
||||
if (state.mode === "ai" && state.aiBusy) text = "ИИ думает…";
|
||||
if (state.mode === "ai" && state.localGameOver) text = "Партия завершена сдачей.";
|
||||
if (state.mode === "party" && state.party?.gameOverReason === "resign") text = "Партия завершена сдачей.";
|
||||
if (state.mode === "party" && state.party?.gameOverReason === "timeout") text = "Время закончилось.";
|
||||
if (state.mode === "party" && state.party?.drawOffer) text = `${state.party.drawOffer.name} предложил(а) ничью.`;
|
||||
|
||||
$("gameStatus").textContent = text;
|
||||
$("turnBadge").textContent = snap.turn === "w" ? "Ход белых" : "Ход чёрных";
|
||||
}
|
||||
|
||||
function renderPlayers() {
|
||||
if (state.mode === "ai") {
|
||||
$("whiteName").textContent = "Вы";
|
||||
$("blackName").textContent = "ИИ";
|
||||
$("whiteMeta").textContent = "Белые";
|
||||
$("blackMeta").textContent = "Чёрные";
|
||||
$("whiteClock").textContent = "—";
|
||||
$("blackClock").textContent = "—";
|
||||
return;
|
||||
}
|
||||
|
||||
const p = state.party;
|
||||
$("whiteName").textContent = p.players.white?.name || "Ожидание…";
|
||||
$("blackName").textContent = p.players.black?.name || "Ожидание…";
|
||||
$("whiteMeta").textContent = p.players.white?.connected ? "Белые • онлайн" : "Белые • офлайн";
|
||||
$("blackMeta").textContent = p.players.black?.connected ? "Чёрные • онлайн" : "Чёрные • офлайн";
|
||||
$("whiteClock").textContent = formatClock(p.clocks.white, p.timeControl.initial);
|
||||
$("blackClock").textContent = formatClock(p.clocks.black, p.timeControl.initial);
|
||||
}
|
||||
|
||||
function formatClock(seconds, enabled) {
|
||||
if (!enabled) return "—";
|
||||
const total = Math.max(0, Math.ceil(seconds));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderBoard();
|
||||
renderMoves(snapshot());
|
||||
renderMaterial(snapshot());
|
||||
renderPlayers();
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function startAI() {
|
||||
closeParty();
|
||||
state.mode = "ai";
|
||||
state.engine = new ChessEngine();
|
||||
state.orientation = "w";
|
||||
state.localGameOver = false;
|
||||
resetSelection();
|
||||
$("chatPanel").classList.add("hidden");
|
||||
show("game");
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function createParty() {
|
||||
const name = $("nameInput").value.trim() || "Guest";
|
||||
const timeControl = $("timeControl").value;
|
||||
await enterParty("/api/party/create", { name, timeControl });
|
||||
}
|
||||
|
||||
async function joinParty() {
|
||||
const name = $("nameInput").value.trim() || "Guest";
|
||||
const partyCode = $("partyCodeInput").value.trim().toUpperCase();
|
||||
if (!partyCode) {
|
||||
$("setupError").textContent = "Введите код комнаты.";
|
||||
return;
|
||||
}
|
||||
await enterParty("/api/party/join", { name, partyCode, clientId: state.clientId });
|
||||
}
|
||||
|
||||
async function enterParty(url, body) {
|
||||
$("setupError").textContent = "";
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok) {
|
||||
$("setupError").textContent = data.error || "Ошибка.";
|
||||
return;
|
||||
}
|
||||
|
||||
state.mode = "party";
|
||||
state.party = data.party;
|
||||
state.orientation = state.party.you?.role === "black" ? "b" : "w";
|
||||
resetSelection();
|
||||
$("chatPanel").classList.remove("hidden");
|
||||
show("game");
|
||||
connectEvents();
|
||||
renderAll();
|
||||
} catch {
|
||||
$("setupError").textContent = "Сервер недоступен.";
|
||||
}
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (state.eventSource) state.eventSource.close();
|
||||
state.eventSource = new EventSource(
|
||||
`/api/party/events?partyCode=${encodeURIComponent(state.party.code)}&clientId=${encodeURIComponent(state.clientId)}`
|
||||
);
|
||||
state.eventSource.addEventListener("party", e => {
|
||||
try { applyParty(JSON.parse(e.data)); } catch {}
|
||||
});
|
||||
state.eventSource.onerror = () => {};
|
||||
}
|
||||
|
||||
function applyParty(party) {
|
||||
state.party = party;
|
||||
// Important: orientation is never changed by a received move.
|
||||
// It only changes when the user presses "Повернуть".
|
||||
renderAll();
|
||||
renderChat();
|
||||
}
|
||||
|
||||
function renderChat() {
|
||||
if (state.mode !== "party") return;
|
||||
const box = $("chatMessages");
|
||||
box.innerHTML = "";
|
||||
for (const msg of state.party.chat || []) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "chat-message";
|
||||
row.innerHTML = `<b>${escapeHtml(msg.name)}</b> <span>${escapeHtml(msg.text)}</span>`;
|
||||
box.appendChild(row);
|
||||
}
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
async function partyAction(endpoint) {
|
||||
if (!state.party) return;
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ partyCode: state.party.code, clientId: state.clientId })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok) toast(data.error || "Операция не выполнена.");
|
||||
if (data.party) applyParty(data.party);
|
||||
} catch {
|
||||
toast("Нет соединения с сервером.");
|
||||
}
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
if (state.mode === "ai") {
|
||||
state.engine = new ChessEngine();
|
||||
state.aiBusy = false;
|
||||
state.localGameOver = false;
|
||||
state.orientation = "w";
|
||||
resetSelection();
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "party") {
|
||||
partyAction("/api/party/rematch");
|
||||
return;
|
||||
}
|
||||
|
||||
$("partySetup").classList.add("hidden");
|
||||
show("home");
|
||||
}
|
||||
|
||||
function closeParty() {
|
||||
state.eventSource?.close();
|
||||
state.eventSource = null;
|
||||
state.party = null;
|
||||
}
|
||||
|
||||
function backHome() {
|
||||
if (state.mode === "party") {
|
||||
partyAction("/api/party/leave");
|
||||
closeParty();
|
||||
}
|
||||
state.mode = null;
|
||||
state.engine = new ChessEngine();
|
||||
resetSelection();
|
||||
$("partySetup").classList.add("hidden");
|
||||
show("home");
|
||||
}
|
||||
|
||||
$("newGameBtn").addEventListener("click", newGame);
|
||||
$("flipBtn").addEventListener("click", () => {
|
||||
state.orientation = state.orientation === "w" ? "b" : "w";
|
||||
renderBoard();
|
||||
});
|
||||
$("backHomeBtn").addEventListener("click", backHome);
|
||||
$("resignBtn").addEventListener("click", () => {
|
||||
if (state.mode === "party") partyAction("/api/party/resign");
|
||||
else {
|
||||
const snap = state.engine.getSnapshot();
|
||||
if (!snap.status.phase.match(/checkmate|draw/)) {
|
||||
state.localGameOver = true;
|
||||
state.aiBusy = false;
|
||||
resetSelection();
|
||||
renderAll();
|
||||
toast("Вы сдались.");
|
||||
}
|
||||
}
|
||||
});
|
||||
$("drawBtn").addEventListener("click", () => {
|
||||
if (state.mode === "party") partyAction("/api/party/draw");
|
||||
else toast("Предложение ничьей доступно в Party.");
|
||||
});
|
||||
$("rematchBtn").addEventListener("click", newGame);
|
||||
|
||||
document.querySelectorAll(".mode-card").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
if (btn.dataset.mode === "ai") startAI();
|
||||
else $("partySetup").classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
$("createPartyBtn").addEventListener("click", createParty);
|
||||
$("joinPartyBtn").addEventListener("click", joinParty);
|
||||
$("partyCodeInput").addEventListener("input", e => {
|
||||
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g,"");
|
||||
});
|
||||
|
||||
$("chatForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
if (!state.party) return;
|
||||
const input = $("chatInput");
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = "";
|
||||
try {
|
||||
await fetch("/api/party/chat", {
|
||||
method:"POST",
|
||||
headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({
|
||||
partyCode:state.party.code,
|
||||
clientId:state.clientId,
|
||||
text
|
||||
})
|
||||
});
|
||||
} catch {
|
||||
toast("Не удалось отправить сообщение.");
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-promotion]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const p = state.pendingPromotion;
|
||||
if (!p) return;
|
||||
state.pendingPromotion = null;
|
||||
playMove(p.from, p.to, btn.dataset.promotion);
|
||||
});
|
||||
});
|
||||
|
||||
show("home");
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
:root{
|
||||
--bg:#171512;
|
||||
--panel:#211e1a;
|
||||
--panel2:#28231e;
|
||||
--text:#f2eee7;
|
||||
--muted:#a9a197;
|
||||
--line:#3b342d;
|
||||
--accent:#c79a5b;
|
||||
--accent2:#e0bc84;
|
||||
--light:#e7d8c0;
|
||||
--dark:#8d6847;
|
||||
--danger:#b9685e;
|
||||
--shadow:0 18px 60px rgba(0,0,0,.28);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,sans-serif}
|
||||
button,input,select{font:inherit}
|
||||
button{cursor:pointer}
|
||||
body{padding:20px}
|
||||
.app{max-width:1180px;margin:auto}
|
||||
.topbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:22px}
|
||||
.eyebrow{font-size:11px;letter-spacing:.2em;color:var(--accent2)}
|
||||
h1,h2,h3,p{margin:0}
|
||||
h1{font-size:25px;margin-top:4px}
|
||||
h2{font-size:clamp(36px,6vw,72px);line-height:.96;letter-spacing:-.045em;margin:20px 0}
|
||||
h3{font-size:20px}
|
||||
.top-actions,.controls,.form-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.btn{border:1px solid var(--line);background:var(--panel);color:var(--text);padding:10px 14px;border-radius:10px;min-height:42px}
|
||||
.btn:hover{border-color:#635548;background:var(--panel2)}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:#1a1510}
|
||||
.btn.danger{background:transparent;border-color:#70423e;color:#e3aaa3}
|
||||
.panel,.hero{border:1px solid var(--line);background:var(--panel);box-shadow:var(--shadow);border-radius:16px}
|
||||
.hero{padding:clamp(24px,5vw,54px);display:grid;grid-template-columns:1.1fr .9fr;gap:35px;align-items:end}
|
||||
.hero p{max-width:650px;color:var(--muted);font-size:16px;line-height:1.6}
|
||||
.pill{display:inline-flex;align-items:center;padding:5px 9px;border:1px solid var(--line);border-radius:999px;color:var(--accent2);font-size:12px}
|
||||
.home-grid{display:grid;gap:10px}
|
||||
.mode-card{display:flex;flex-direction:column;text-align:left;gap:6px;padding:18px;border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:12px}
|
||||
.mode-card:hover{border-color:var(--accent)}
|
||||
.mode-card span{color:var(--muted);font-size:13px}
|
||||
.party-panel{margin-top:14px;padding:20px}
|
||||
.form-row{align-items:end;margin-top:14px}
|
||||
label{display:grid;gap:6px;color:var(--muted);font-size:12px;min-width:180px}
|
||||
input,select{height:42px;padding:0 12px;border-radius:9px;border:1px solid var(--line);background:#171512;color:var(--text);outline:none}
|
||||
input:focus,select:focus{border-color:var(--accent)}
|
||||
.error{color:#df9d95;margin-top:10px;min-height:18px}
|
||||
.game-layout{display:grid;grid-template-columns:minmax(0,760px) 330px;gap:18px;align-items:start}
|
||||
.left-panel{padding:14px}
|
||||
.right-column{display:grid;gap:12px}
|
||||
.player-row{display:grid;grid-template-columns:42px 1fr auto;gap:10px;align-items:center;padding:7px 4px 12px}
|
||||
.player-row:last-child{padding:12px 4px 7px}
|
||||
.avatar{width:36px;height:36px;border:1px solid var(--line);display:grid;place-items:center;border-radius:9px;background:var(--panel2)}
|
||||
.player-row strong,.player-row span{display:block}
|
||||
.player-row span{font-size:12px;color:var(--muted);margin-top:2px}
|
||||
.clock{font-variant-numeric:tabular-nums;font-size:24px;font-weight:700}
|
||||
.board-wrap{width:100%;aspect-ratio:1;max-width:760px;margin:auto}
|
||||
.board{width:100%;height:100%;display:grid;grid-template-columns:repeat(8,1fr);grid-template-rows:repeat(8,1fr);overflow:hidden;border-radius:8px;user-select:none;touch-action:manipulation}
|
||||
.square{position:relative;display:grid;place-items:center;font-size:clamp(27px,7vw,70px);line-height:1}
|
||||
.square.light{background:var(--light);color:#251e18}
|
||||
.square.dark{background:var(--dark);color:#17120f}
|
||||
.square.last-from::after,.square.last-to::after{content:"";position:absolute;inset:0;background:rgba(230,184,92,.26);pointer-events:none}
|
||||
.square.selected{box-shadow:inset 0 0 0 4px rgba(255,239,202,.75)}
|
||||
.square.legal::before{content:"";position:absolute;width:18%;height:18%;border-radius:50%;background:rgba(40,30,20,.35);z-index:1}
|
||||
.square.capture::before{content:"";position:absolute;inset:8%;border:5px solid rgba(70,40,20,.42);border-radius:50%;z-index:1}
|
||||
.square.trajectory{box-shadow:inset 0 0 0 3px rgba(255,245,210,.28)}
|
||||
.piece{position:relative;z-index:2;font-family:"DejaVu Sans","Segoe UI Symbol",serif;text-shadow:0 1px 0 rgba(0,0,0,.15)}
|
||||
.coord{position:absolute;font-size:9px;font-weight:700;opacity:.7;z-index:3;pointer-events:none}
|
||||
.coord.file{right:4px;bottom:2px}
|
||||
.coord.rank{left:4px;top:2px}
|
||||
.panel{padding:14px}
|
||||
.panel-title{display:flex;justify-content:space-between;align-items:center;gap:10px;font-weight:700}
|
||||
.status{color:var(--muted);font-size:13px;line-height:1.5;margin-top:10px}
|
||||
.material{display:flex;justify-content:space-between;margin-top:12px;font-size:14px;color:var(--muted);min-height:23px}
|
||||
.material span{letter-spacing:2px}
|
||||
.controls{display:grid;grid-template-columns:1fr 1fr}
|
||||
.controls .btn{width:100%}
|
||||
.moves-panel{min-height:180px}
|
||||
.moves-list{display:grid;grid-template-columns:1fr 1fr;gap:2px 10px;margin-top:10px;max-height:260px;overflow:auto}
|
||||
.move{display:grid;grid-template-columns:32px 1fr 1fr;padding:6px 7px;border-radius:7px;color:var(--muted);font-size:13px}
|
||||
.move:nth-child(odd){background:#1a1714}
|
||||
.move-number{color:#70675e}
|
||||
.chat-messages{height:180px;overflow:auto;margin-top:10px;display:grid;align-content:start;gap:8px}
|
||||
.chat-message{font-size:13px}
|
||||
.chat-message b{color:var(--accent2)}
|
||||
.chat-message span{color:var(--muted)}
|
||||
.chat-form{display:flex;gap:7px;margin-top:10px}
|
||||
.chat-form input{min-width:0;flex:1}
|
||||
.hidden{display:none!important}
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.62);display:grid;place-items:center;padding:20px;z-index:20}
|
||||
.modal-card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:22px;width:min(430px,100%)}
|
||||
.promotion-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:16px}
|
||||
.promotion-grid button{border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:10px;padding:12px;font-size:34px}
|
||||
.promotion-grid span{display:block;font-size:11px;color:var(--muted)}
|
||||
.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;pointer-events:none;background:#2a251f;border:1px solid var(--line);padding:10px 14px;border-radius:9px;transition:.2s;z-index:30}
|
||||
.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||
@media(max-width:920px){
|
||||
body{padding:10px}
|
||||
.hero{grid-template-columns:1fr}
|
||||
.game-layout{grid-template-columns:1fr}
|
||||
.right-column{grid-template-columns:1fr 1fr}
|
||||
.moves-panel,.chat-panel{grid-column:span 2}
|
||||
}
|
||||
@media(max-width:560px){
|
||||
.topbar{align-items:flex-start}
|
||||
.top-actions{justify-content:flex-end}
|
||||
.top-actions .btn{padding:8px 10px}
|
||||
.game-layout{gap:10px}
|
||||
.left-panel{padding:7px}
|
||||
.right-column{grid-template-columns:1fr}
|
||||
.moves-panel,.chat-panel{grid-column:auto}
|
||||
.controls{grid-template-columns:1fr 1fr}
|
||||
.square{font-size:clamp(24px,10vw,46px)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
const FILES = "abcdefgh";
|
||||
const PIECES = ["p", "n", "b", "r", "q", "k"];
|
||||
const VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 };
|
||||
|
||||
function square(file, rank) {
|
||||
return `${FILES[file]}${rank + 1}`;
|
||||
}
|
||||
|
||||
function parseSquare(s) {
|
||||
if (!/^[a-h][1-8]$/.test(s || "")) return null;
|
||||
return { file: FILES.indexOf(s[0]), rank: Number(s[1]) - 1 };
|
||||
}
|
||||
|
||||
function other(color) {
|
||||
return color === "w" ? "b" : "w";
|
||||
}
|
||||
|
||||
function cloneBoard(board) {
|
||||
return Object.fromEntries(Object.entries(board).map(([s, p]) => [s, { ...p }]));
|
||||
}
|
||||
|
||||
function cloneState(state) {
|
||||
return {
|
||||
board: cloneBoard(state.board),
|
||||
turn: state.turn,
|
||||
castling: {
|
||||
w: { ...state.castling.w },
|
||||
b: { ...state.castling.b }
|
||||
},
|
||||
ep: state.ep,
|
||||
halfmove: state.halfmove,
|
||||
fullmove: state.fullmove,
|
||||
lastMove: state.lastMove ? { ...state.lastMove } : null
|
||||
};
|
||||
}
|
||||
|
||||
function initialBoard() {
|
||||
const board = {};
|
||||
const back = "rnbqkbnr";
|
||||
for (let f = 0; f < 8; f++) {
|
||||
board[square(f, 0)] = { color: "w", type: back[f] };
|
||||
board[square(f, 1)] = { color: "w", type: "p" };
|
||||
board[square(f, 6)] = { color: "b", type: "p" };
|
||||
board[square(f, 7)] = { color: "b", type: back[f] };
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
export class ChessEngine {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = {
|
||||
board: initialBoard(),
|
||||
turn: "w",
|
||||
castling: {
|
||||
w: { k: true, q: true },
|
||||
b: { k: true, q: true }
|
||||
},
|
||||
ep: null,
|
||||
halfmove: 0,
|
||||
fullmove: 1,
|
||||
lastMove: null
|
||||
};
|
||||
this.history = [];
|
||||
this.positions = new Map([[this.positionKey(this.state), 1]]);
|
||||
}
|
||||
|
||||
positionKey(state) {
|
||||
const board = Object.keys(state.board).sort().map(s => {
|
||||
const p = state.board[s];
|
||||
return `${s}${p.color}${p.type}`;
|
||||
}).join(",");
|
||||
return `${board}|${state.turn}|${state.castling.w.k ? "K" : ""}${state.castling.w.q ? "Q" : ""}${state.castling.b.k ? "k" : ""}${state.castling.b.q ? "q" : ""}|${state.ep || "-"}`;
|
||||
}
|
||||
|
||||
getPiece(s) {
|
||||
return this.state.board[s] || null;
|
||||
}
|
||||
|
||||
movesFrom(from) {
|
||||
const p = this.state.board[from];
|
||||
if (!p || p.color !== this.state.turn) return [];
|
||||
return this.legalMoves(this.state.turn).filter(m => m.from === from);
|
||||
}
|
||||
|
||||
legalMoves(color = this.state.turn) {
|
||||
const pseudo = this.pseudoMoves(this.state, color);
|
||||
const legal = [];
|
||||
|
||||
for (const move of pseudo) {
|
||||
const next = this.applyToClone(this.state, move);
|
||||
if (!this.inCheck(next, color)) legal.push(move);
|
||||
}
|
||||
return legal;
|
||||
}
|
||||
|
||||
pseudoMoves(state, color) {
|
||||
const result = [];
|
||||
|
||||
for (const [from, piece] of Object.entries(state.board)) {
|
||||
if (piece.color !== color) continue;
|
||||
const pos = parseSquare(from);
|
||||
if (!pos) continue;
|
||||
|
||||
if (piece.type === "p") this.pawnMoves(state, from, piece, pos, result);
|
||||
else if (piece.type === "n") this.knightMoves(state, from, piece, pos, result);
|
||||
else if (piece.type === "b") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1]]);
|
||||
else if (piece.type === "r") this.slideMoves(state, from, piece, pos, result, [[1,0],[-1,0],[0,1],[0,-1]]);
|
||||
else if (piece.type === "q") this.slideMoves(state, from, piece, pos, result, [[1,1],[1,-1],[-1,1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]]);
|
||||
else if (piece.type === "k") this.kingMoves(state, from, piece, pos, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
pushMove(state, result, from, to, promotion = null, special = null) {
|
||||
const target = state.board[to];
|
||||
if (target?.color === state.board[from]?.color) return;
|
||||
if (target?.type === "k") return;
|
||||
result.push({
|
||||
from, to,
|
||||
promotion,
|
||||
special,
|
||||
capture: Boolean(target) || special === "ep"
|
||||
});
|
||||
}
|
||||
|
||||
pawnMoves(state, from, piece, pos, result) {
|
||||
const dir = piece.color === "w" ? 1 : -1;
|
||||
const startRank = piece.color === "w" ? 1 : 6;
|
||||
const promotionRank = piece.color === "w" ? 7 : 0;
|
||||
|
||||
const oneRank = pos.rank + dir;
|
||||
if (oneRank >= 0 && oneRank <= 7) {
|
||||
const one = square(pos.file, oneRank);
|
||||
if (!state.board[one]) {
|
||||
if (oneRank === promotionRank) {
|
||||
for (const promotion of ["q","r","b","n"]) this.pushMove(state, result, from, one, promotion);
|
||||
} else {
|
||||
this.pushMove(state, result, from, one);
|
||||
if (pos.rank === startRank) {
|
||||
const two = square(pos.file, pos.rank + dir * 2);
|
||||
if (!state.board[two]) this.pushMove(state, result, from, two, null, "double");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const df of [-1, 1]) {
|
||||
const f = pos.file + df;
|
||||
const r = pos.rank + dir;
|
||||
if (f < 0 || f > 7 || r < 0 || r > 7) continue;
|
||||
const to = square(f, r);
|
||||
const target = state.board[to];
|
||||
|
||||
if (target && target.color !== piece.color && target.type !== "k") {
|
||||
if (r === promotionRank) {
|
||||
for (const promotion of ["q","r","b","n"]) this.pushMove(state, result, from, to, promotion);
|
||||
} else {
|
||||
this.pushMove(state, result, from, to);
|
||||
}
|
||||
} else if (state.ep === to) {
|
||||
this.pushMove(state, result, from, to, null, "ep");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
knightMoves(state, from, piece, pos, result) {
|
||||
const jumps = [[1,2],[2,1],[-1,2],[-2,1],[1,-2],[2,-1],[-1,-2],[-2,-1]];
|
||||
for (const [df, dr] of jumps) {
|
||||
const f = pos.file + df, r = pos.rank + dr;
|
||||
if (f >= 0 && f <= 7 && r >= 0 && r <= 7) this.pushMove(state, result, from, square(f,r));
|
||||
}
|
||||
}
|
||||
|
||||
slideMoves(state, from, piece, pos, result, dirs) {
|
||||
for (const [df, dr] of dirs) {
|
||||
let f = pos.file + df, r = pos.rank + dr;
|
||||
while (f >= 0 && f <= 7 && r >= 0 && r <= 7) {
|
||||
const to = square(f, r);
|
||||
const target = state.board[to];
|
||||
if (!target) this.pushMove(state, result, from, to);
|
||||
else {
|
||||
if (target.color !== piece.color && target.type !== "k") this.pushMove(state, result, from, to);
|
||||
break;
|
||||
}
|
||||
f += df; r += dr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kingMoves(state, from, piece, pos, result) {
|
||||
for (let df = -1; df <= 1; df++) {
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
if (!df && !dr) continue;
|
||||
const f = pos.file + df, r = pos.rank + dr;
|
||||
if (f >= 0 && f <= 7 && r >= 0 && r <= 7) {
|
||||
this.pushMove(state, result, from, square(f,r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rank = piece.color === "w" ? 0 : 7;
|
||||
const enemy = other(piece.color);
|
||||
|
||||
if (pos.file === 4 && pos.rank === rank && !this.inCheck(state, piece.color)) {
|
||||
if (state.castling[piece.color].k &&
|
||||
!state.board[square(5,rank)] &&
|
||||
!state.board[square(6,rank)] &&
|
||||
state.board[square(7,rank)]?.type === "r" &&
|
||||
state.board[square(7,rank)]?.color === piece.color) {
|
||||
const through = square(5,rank), to = square(6,rank);
|
||||
if (!this.isAttacked(state, through, enemy) && !this.isAttacked(state, to, enemy)) {
|
||||
this.pushMove(state, result, from, to, null, "castle-k");
|
||||
}
|
||||
}
|
||||
|
||||
if (state.castling[piece.color].q &&
|
||||
!state.board[square(1,rank)] &&
|
||||
!state.board[square(2,rank)] &&
|
||||
!state.board[square(3,rank)] &&
|
||||
state.board[square(0,rank)]?.type === "r" &&
|
||||
state.board[square(0,rank)]?.color === piece.color) {
|
||||
const through = square(3,rank), to = square(2,rank);
|
||||
if (!this.isAttacked(state, through, enemy) && !this.isAttacked(state, to, enemy)) {
|
||||
this.pushMove(state, result, from, to, null, "castle-q");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyToClone(state, move) {
|
||||
const next = cloneState(state);
|
||||
const piece = next.board[move.from];
|
||||
if (!piece) return next;
|
||||
|
||||
delete next.board[move.from];
|
||||
|
||||
if (move.special === "ep") {
|
||||
const to = parseSquare(move.to);
|
||||
const captured = square(to.file, to.rank + (piece.color === "w" ? -1 : 1));
|
||||
delete next.board[captured];
|
||||
}
|
||||
|
||||
const captured = next.board[move.to];
|
||||
delete next.board[move.to];
|
||||
|
||||
const moved = {
|
||||
color: piece.color,
|
||||
type: move.promotion || piece.type
|
||||
};
|
||||
next.board[move.to] = moved;
|
||||
|
||||
if (piece.type === "k") {
|
||||
next.castling[piece.color].k = false;
|
||||
next.castling[piece.color].q = false;
|
||||
if (move.special === "castle-k") {
|
||||
const rank = piece.color === "w" ? 0 : 7;
|
||||
delete next.board[square(7,rank)];
|
||||
next.board[square(5,rank)] = { color: piece.color, type: "r" };
|
||||
}
|
||||
if (move.special === "castle-q") {
|
||||
const rank = piece.color === "w" ? 0 : 7;
|
||||
delete next.board[square(0,rank)];
|
||||
next.board[square(3,rank)] = { color: piece.color, type: "r" };
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.type === "r") {
|
||||
if (move.from === "a1") next.castling.w.q = false;
|
||||
if (move.from === "h1") next.castling.w.k = false;
|
||||
if (move.from === "a8") next.castling.b.q = false;
|
||||
if (move.from === "h8") next.castling.b.k = false;
|
||||
}
|
||||
|
||||
if (captured?.type === "r") {
|
||||
if (move.to === "a1") next.castling.w.q = false;
|
||||
if (move.to === "h1") next.castling.w.k = false;
|
||||
if (move.to === "a8") next.castling.b.q = false;
|
||||
if (move.to === "h8") next.castling.b.k = false;
|
||||
}
|
||||
|
||||
next.ep = null;
|
||||
if (piece.type === "p") {
|
||||
const a = parseSquare(move.from), b = parseSquare(move.to);
|
||||
if (Math.abs(b.rank - a.rank) === 2) {
|
||||
next.ep = square(a.file, (a.rank + b.rank) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
next.halfmove = piece.type === "p" || move.capture ? 0 : next.halfmove + 1;
|
||||
if (piece.color === "b") next.fullmove += 1;
|
||||
next.turn = other(piece.color);
|
||||
return next;
|
||||
}
|
||||
|
||||
findKing(state, color) {
|
||||
for (const [s, p] of Object.entries(state.board)) {
|
||||
if (p.color === color && p.type === "k") return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
inCheck(state, color) {
|
||||
const king = this.findKing(state, color);
|
||||
return !king || this.isAttacked(state, king, other(color));
|
||||
}
|
||||
|
||||
isAttacked(state, target, byColor) {
|
||||
const t = parseSquare(target);
|
||||
if (!t) return false;
|
||||
|
||||
for (const [from, piece] of Object.entries(state.board)) {
|
||||
if (piece.color !== byColor) continue;
|
||||
const p = parseSquare(from);
|
||||
const df = t.file - p.file;
|
||||
const dr = t.rank - p.rank;
|
||||
|
||||
if (piece.type === "p") {
|
||||
const dir = byColor === "w" ? 1 : -1;
|
||||
if (dr === dir && Math.abs(df) === 1) return true;
|
||||
} else if (piece.type === "n") {
|
||||
if ((Math.abs(df) === 1 && Math.abs(dr) === 2) || (Math.abs(df) === 2 && Math.abs(dr) === 1)) return true;
|
||||
} else if (piece.type === "k") {
|
||||
if (Math.max(Math.abs(df), Math.abs(dr)) === 1) return true;
|
||||
} else {
|
||||
const diagonal = Math.abs(df) === Math.abs(dr);
|
||||
const straight = df === 0 || dr === 0;
|
||||
const allowed = piece.type === "b" ? diagonal : piece.type === "r" ? straight : diagonal || straight;
|
||||
if (!allowed) continue;
|
||||
|
||||
const sf = Math.sign(df), sr = Math.sign(dr);
|
||||
let f = p.file + sf, r = p.rank + sr;
|
||||
let clear = true;
|
||||
while (f !== t.file || r !== t.rank) {
|
||||
if (state.board[square(f,r)]) { clear = false; break; }
|
||||
f += sf; r += sr;
|
||||
}
|
||||
if (clear) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
sanForMove(move, legalBefore = null) {
|
||||
const piece = this.state.board[move.from];
|
||||
if (!piece) return "";
|
||||
if (move.special === "castle-k") return this.suffixAfter(move, "O-O");
|
||||
if (move.special === "castle-q") return this.suffixAfter(move, "O-O-O");
|
||||
|
||||
const legal = legalBefore || this.legalMoves(this.state);
|
||||
let san = piece.type === "p" ? "" : piece.type.toUpperCase();
|
||||
|
||||
const same = legal.filter(m =>
|
||||
m.to === move.to &&
|
||||
m.from !== move.from &&
|
||||
this.state.board[m.from]?.type === piece.type
|
||||
);
|
||||
|
||||
if (same.length) {
|
||||
const from = parseSquare(move.from);
|
||||
const fileConflict = same.some(m => parseSquare(m.from).file === from.file);
|
||||
san += fileConflict ? move.from : move.from[0];
|
||||
}
|
||||
|
||||
if (move.capture) {
|
||||
if (piece.type === "p") san += move.from[0];
|
||||
san += "x";
|
||||
}
|
||||
|
||||
san += move.to;
|
||||
if (move.promotion) san += `=${move.promotion.toUpperCase()}`;
|
||||
|
||||
return this.suffixAfter(move, san);
|
||||
}
|
||||
|
||||
suffixAfter(move, san) {
|
||||
const next = this.applyToClone(this.state, move);
|
||||
if (this.inCheck(next, next.turn)) {
|
||||
const replies = this.legalMovesFromState(next, next.turn);
|
||||
return san + (replies.length ? "+" : "#");
|
||||
}
|
||||
return san;
|
||||
}
|
||||
|
||||
legalMovesFromState(state, color) {
|
||||
return this.pseudoMoves(state, color).filter(m => !this.inCheck(this.applyToClone(state, m), color));
|
||||
}
|
||||
|
||||
makeMove(input) {
|
||||
const from = String(input?.from || "").toLowerCase();
|
||||
const to = String(input?.to || "").toLowerCase();
|
||||
const promotion = String(input?.promotion || "q").toLowerCase();
|
||||
|
||||
const legal = this.legalMoves(this.state);
|
||||
const move = legal.find(m =>
|
||||
m.from === from &&
|
||||
m.to === to &&
|
||||
(m.promotion ? m.promotion === promotion : !m.promotion)
|
||||
);
|
||||
|
||||
if (!move) return { ok: false, error: "Недопустимый ход." };
|
||||
|
||||
const san = this.sanForMove(move, legal);
|
||||
const piece = this.state.board[from];
|
||||
const capturedPiece =
|
||||
move.special === "ep"
|
||||
? { type: "p", color: other(piece.color) }
|
||||
: this.state.board[to] || null;
|
||||
|
||||
this.history.push({
|
||||
...move,
|
||||
san,
|
||||
piece: { ...piece },
|
||||
captured: capturedPiece ? { ...capturedPiece } : null,
|
||||
before: cloneState(this.state)
|
||||
});
|
||||
|
||||
this.state = this.applyToClone(this.state, move);
|
||||
this.state.lastMove = {
|
||||
from,
|
||||
to,
|
||||
san,
|
||||
piece: { ...piece },
|
||||
captured: capturedPiece ? { ...capturedPiece } : null
|
||||
};
|
||||
|
||||
const key = this.positionKey(this.state);
|
||||
this.positions.set(key, (this.positions.get(key) || 0) + 1);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
move: this.state.lastMove,
|
||||
status: this.getStatus()
|
||||
};
|
||||
}
|
||||
|
||||
undo() {
|
||||
const last = this.history.pop();
|
||||
if (!last) return false;
|
||||
this.state = last.before;
|
||||
this.positions = new Map([[this.positionKey(this.state), 1]]);
|
||||
for (const h of this.history) {
|
||||
const key = this.positionKey(h.before);
|
||||
this.positions.set(key, (this.positions.get(key) || 0) + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
const color = this.state.turn;
|
||||
const moves = this.legalMoves(color);
|
||||
const check = this.inCheck(this.state, color);
|
||||
|
||||
if (!moves.length && check) return { phase: "checkmate", turn: color, winner: other(color) };
|
||||
if (!moves.length) return { phase: "draw", reason: "stalemate", turn: color };
|
||||
if (this.state.halfmove >= 100) return { phase: "draw", reason: "50-move", turn: color };
|
||||
|
||||
const key = this.positionKey(this.state);
|
||||
if ((this.positions.get(key) || 0) >= 3) return { phase: "draw", reason: "threefold", turn: color };
|
||||
|
||||
if (this.insufficientMaterial()) return { phase: "draw", reason: "insufficient-material", turn: color };
|
||||
if (check) return { phase: "check", turn: color };
|
||||
return { phase: "playing", turn: color };
|
||||
}
|
||||
|
||||
insufficientMaterial() {
|
||||
const pieces = Object.values(this.state.board);
|
||||
const nonKings = pieces.filter(p => p.type !== "k");
|
||||
if (!nonKings.length) return true;
|
||||
if (nonKings.length === 1 && (nonKings[0].type === "b" || nonKings[0].type === "n")) return true;
|
||||
if (nonKings.length === 2 && nonKings.every(p => p.type === "b")) {
|
||||
const bishops = Object.entries(this.state.board)
|
||||
.filter(([, p]) => p.type === "b")
|
||||
.map(([s, p]) => ({ s, color: p.color }));
|
||||
if (bishops.length === 2) {
|
||||
const colors = bishops.map(({ s }) => {
|
||||
const p = parseSquare(s);
|
||||
return (p.file + p.rank) % 2;
|
||||
});
|
||||
if (colors[0] === colors[1]) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
material() {
|
||||
const captured = { w: [], b: [] };
|
||||
for (const h of this.history) {
|
||||
if (h.captured) captured[h.piece.color].push(h.captured.type);
|
||||
}
|
||||
|
||||
const sort = a => a.sort((x,y) => VALUES[y] - VALUES[x]);
|
||||
sort(captured.w); sort(captured.b);
|
||||
|
||||
const score = {
|
||||
w: captured.b.reduce((n,p) => n + VALUES[p], 0),
|
||||
b: captured.w.reduce((n,p) => n + VALUES[p], 0)
|
||||
};
|
||||
|
||||
return { captured, score };
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return {
|
||||
board: cloneBoard(this.state.board),
|
||||
turn: this.state.turn,
|
||||
castling: {
|
||||
w: { ...this.state.castling.w },
|
||||
b: { ...this.state.castling.b }
|
||||
},
|
||||
ep: this.state.ep,
|
||||
halfmove: this.state.halfmove,
|
||||
fullmove: this.state.fullmove,
|
||||
lastMove: this.state.lastMove ? { ...this.state.lastMove } : null,
|
||||
moves: this.history.map(h => ({
|
||||
number: this.history.indexOf(h) + 1,
|
||||
from: h.from,
|
||||
to: h.to,
|
||||
san: h.san,
|
||||
color: h.piece.color,
|
||||
piece: h.piece.type,
|
||||
capture: Boolean(h.captured)
|
||||
})),
|
||||
material: this.material(),
|
||||
status: this.getStatus()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { FILES, VALUES };
|
||||
|
||||
Binary file not shown.
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "college-chess-party",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "college-chess-party",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "chess-party",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Chess Party — AI and multiplayer chess",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
-518
@@ -1,518 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#171717">
|
||||
<title>Tempo Radio</title>
|
||||
<link rel="icon" type="image/x-icon" href="YOUR_FAVICON_URL_HERE">
|
||||
<style>
|
||||
/* =========================================================
|
||||
THEME & GLOBAL
|
||||
========================================================= */
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--glass: rgba(255,255,255,.75);
|
||||
--glass-strong: rgba(255,255,255,.85);
|
||||
--glass-hover: rgba(255,255,255,.95);
|
||||
--text: #1d1d1f;
|
||||
--muted: #86868b;
|
||||
--accent: #1d1d1f;
|
||||
--accent-text: #ffffff;
|
||||
--line: rgba(0,0,0,.08);
|
||||
--shadow: rgba(0,0,0,.08);
|
||||
--overlay: rgba(255,255,255,.20);
|
||||
--bg-image: url('YOUR_BACKGROUND_URL_HERE');
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--glass: rgba(28,28,30,.75);
|
||||
--glass-strong: rgba(44,44,46,.85);
|
||||
--glass-hover: rgba(58,58,60,.95);
|
||||
--text: #f5f5f7;
|
||||
--muted: #86868b;
|
||||
--accent: #f5f5f7;
|
||||
--accent-text: #000000;
|
||||
--line: rgba(255,255,255,.1);
|
||||
--shadow: rgba(0,0,0,.4);
|
||||
--overlay: rgba(0,0,0,.6);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; color: var(--text);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: linear-gradient(var(--overlay), var(--overlay)), var(--bg-image) no-repeat center center fixed;
|
||||
background-size: cover; background-color: var(--bg);
|
||||
user-select: none; -webkit-user-select: none; overscroll-behavior-y: none;
|
||||
transition: background-color .3s ease, color .3s ease;
|
||||
}
|
||||
::-webkit-scrollbar { display: none; }
|
||||
button { font: inherit; outline: none; }
|
||||
|
||||
/* =========================================================
|
||||
APP & TOP BAR
|
||||
========================================================= */
|
||||
.app { width: min(100%, 980px); min-height: 100vh; margin: auto; overflow-x: hidden; }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 50; display: flex; align-items: center;
|
||||
min-height: 72px; padding: max(16px, env(safe-area-inset-top)) 24px 16px;
|
||||
background: var(--glass); border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(30px) saturate(180%); -webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; font-size: 19px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.brand-logo { width: auto; height: 42px; object-fit: contain; border-radius: 8px; }
|
||||
.brand-text small { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; font-weight: 500; letter-spacing: normal; }
|
||||
|
||||
/* =========================================================
|
||||
TABS
|
||||
========================================================= */
|
||||
.tabs {
|
||||
position: sticky; top: 72px; z-index: 40; display: flex; justify-content: center; gap: 8px;
|
||||
padding: 12px 24px; background: var(--glass); border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 20px; border: 0; border-radius: 100px; background: transparent;
|
||||
color: var(--muted); cursor: pointer; font-size: 14px; font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.tab:hover { color: var(--text); background: rgba(128,128,128,.1); }
|
||||
.tab.active { color: var(--accent-text); background: var(--accent); }
|
||||
.tab:active { transform: scale(.96); }
|
||||
|
||||
/* =========================================================
|
||||
MAIN
|
||||
========================================================= */
|
||||
main { padding: 24px 24px calc(140px + env(safe-area-inset-bottom)); }
|
||||
.panel { display: none; animation: fadeIn 0.3s ease; }
|
||||
.panel.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* =========================================================
|
||||
NOW PLAYING (HERO)
|
||||
========================================================= */
|
||||
.hero {
|
||||
position: relative; min-height: 220px; display: flex; align-items: center;
|
||||
padding: 32px; overflow: hidden; border: 1px solid var(--line); margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, var(--glass-strong), var(--glass));
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(40px) saturate(180%); -webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow: 0 20px 40px var(--shadow);
|
||||
}
|
||||
.hero::before {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(circle at 100% 0%, rgba(255,255,255,.12), transparent 50%);
|
||||
}
|
||||
.hero-content { position: relative; z-index: 2; display: flex; align-items: center; gap: 24px; width: 100%; }
|
||||
.hero-art {
|
||||
width: 120px; height: 120px; flex: 0 0 120px; object-fit: cover;
|
||||
background: rgba(128,128,128,.10); border: 1px solid var(--line);
|
||||
border-radius: 20px; box-shadow: 0 16px 32px var(--shadow);
|
||||
}
|
||||
.hero h1 { margin: 0 0 6px; font-size: 32px; font-weight: 800; letter-spacing: -1.2px; line-height: 1.1; }
|
||||
.hero p { margin: 0; color: var(--muted); font-size: 15px; font-weight: 500; }
|
||||
.playing {
|
||||
display: inline-block; margin-top: 12px; padding: 6px 14px; color: var(--text);
|
||||
background: rgba(128,128,128,.12); border: 1px solid var(--line);
|
||||
border-radius: 100px; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.hero-controls { display: flex; margin-top: 20px; }
|
||||
.play {
|
||||
width: 54px; height: 54px; border: 0; border-radius: 50%; background: var(--accent);
|
||||
color: var(--accent-text); cursor: pointer; font-size: 20px;
|
||||
box-shadow: 0 10px 24px var(--shadow); transition: transform 0.2s ease, filter 0.2s ease;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.play:hover { filter: brightness(1.1); }
|
||||
.play:active { transform: scale(.92); }
|
||||
|
||||
/* =========================================================
|
||||
STATIONS HEADER & VIEW MANAGER
|
||||
========================================================= */
|
||||
.stations-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin: 32px 0 20px; }
|
||||
.section-head h2 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.6px; }
|
||||
|
||||
.view-controls {
|
||||
display: flex; align-items: center; gap: 4px; padding: 4px;
|
||||
background: rgba(128,128,128,.08); border: 1px solid var(--line);
|
||||
border-radius: 10px; backdrop-filter: blur(20px);
|
||||
}
|
||||
.view-btn {
|
||||
width: 32px; height: 32px; display: flex; justify-content: center; align-items: center;
|
||||
gap: 3px; padding: 5px; border: 0; border-radius: 6px; background: transparent; cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.view-btn span { display: block; width: 3px; height: 14px; background: var(--muted); border-radius: 2px; }
|
||||
.view-btn.active { background: var(--glass-strong); box-shadow: 0 2px 8px var(--shadow); }
|
||||
.view-btn.active span { background: var(--text); }
|
||||
.view-btn:active { transform: scale(.9); }
|
||||
|
||||
/* =========================================================
|
||||
RADIO GRID
|
||||
========================================================= */
|
||||
.grid { display: grid; gap: 20px; grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.grid[data-columns="2"] { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.grid[data-columns="3"] { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.grid[data-columns="4"] { grid-template-columns: repeat(4, minmax(0,1fr)); }
|
||||
|
||||
/* =========================================================
|
||||
STATION ITEM
|
||||
========================================================= */
|
||||
.station { min-width: 0; cursor: pointer; border-radius: 16px; transition: transform .2s ease; }
|
||||
@media (hover: hover) { .station:hover { transform: translateY(-4px); } }
|
||||
.station-art {
|
||||
display: block; width: 100%; aspect-ratio: 1; object-fit: cover;
|
||||
background: rgba(128,128,128,.08); border: 1px solid var(--line); border-radius: 16px;
|
||||
box-shadow: 0 10px 24px var(--shadow); transition: transform .2s ease, filter .2s ease;
|
||||
}
|
||||
.station:active .station-art { transform: scale(.95); filter: brightness(.85); }
|
||||
.station-info { padding: 12px 6px 4px; }
|
||||
.station-name { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 700; letter-spacing: -0.3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.station-desc { margin-top: 4px; color: var(--muted); font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.onair { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #32d74b; } /* Статичный индикатор, без пульсации */
|
||||
|
||||
/* =========================================================
|
||||
BOTTOM PLAYER
|
||||
========================================================= */
|
||||
.bottom-player {
|
||||
position: fixed; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); z-index: 100;
|
||||
width: min(calc(100% - 48px), 940px); min-height: 76px; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 16px; padding: 12px 16px;
|
||||
background: var(--glass-strong); border: 1px solid var(--line); border-radius: 24px;
|
||||
backdrop-filter: blur(40px) saturate(180%); -webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow: 0 24px 48px rgba(0,0,0,0.15); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
.bottom-player:active { transform: translateX(-50%) scale(0.98); }
|
||||
.mini-art { width: 52px; height: 52px; flex-shrink: 0; object-fit: cover; border-radius: 12px; border: 1px solid var(--line); }
|
||||
.mini-info { min-width: 0; flex: 1; }
|
||||
.mini-title { font-size: 15px; font-weight: 700; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mini-sub { margin-top: 3px; color: var(--muted); font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.progress { width: 100%; height: 4px; margin-top: 8px; overflow: hidden; background: rgba(128,128,128,.15); border-radius: 4px; }
|
||||
.progress i { display: block; width: 37%; height: 100%; background: var(--accent); border-radius: 4px; }
|
||||
.bottom-controls { display: flex; align-items: center; gap: 4px; padding-right: 4px; }
|
||||
.small-btn {
|
||||
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; padding: 0; border: 0;
|
||||
border-radius: 50%; background: transparent; color: var(--text); cursor: pointer; font-size: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.small-btn:hover { background: rgba(128,128,128,.10); }
|
||||
.small-btn:active { background: rgba(128,128,128,.16); transform: scale(.9); }
|
||||
.main-play { background: var(--accent); color: var(--accent-text); box-shadow: 0 8px 16px var(--shadow); }
|
||||
.main-play:hover { background: var(--accent); filter: brightness(1.1); }
|
||||
|
||||
/* =========================================================
|
||||
ABOUT & REPOSITORY CARD
|
||||
========================================================= */
|
||||
.about-card {
|
||||
margin-top: 12px; padding: 32px; background: var(--glass-strong); border: 1px solid var(--line);
|
||||
backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); box-shadow: 0 20px 40px var(--shadow);
|
||||
border-radius: 24px;
|
||||
}
|
||||
.about-card.repo-container {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px;
|
||||
}
|
||||
.about-card h3 { margin: 0 0 24px 0; font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.repo-link { display: inline-block; transition: transform 0.2s ease, filter 0.2s ease; }
|
||||
.repo-link:hover { filter: brightness(1.05); }
|
||||
.repo-link:active { transform: scale(0.92); }
|
||||
.repo-icon {
|
||||
width: 88px; height: 88px; object-fit: contain; border-radius: 20px;
|
||||
background: var(--bg); border: 1px solid var(--line);
|
||||
box-shadow: 0 12px 28px var(--shadow); padding: 12px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MOBILE RESPONSIVENESS
|
||||
========================================================= */
|
||||
@media(max-width:600px) {
|
||||
.topbar { padding: 12px 16px; min-height: 64px; }
|
||||
.brand-text small { display: none; }
|
||||
.tabs { top: 64px; padding: 10px 16px; }
|
||||
.tab { padding: 8px 16px; font-size: 13px; }
|
||||
main { padding: 16px 16px calc(130px + env(safe-area-inset-bottom)); }
|
||||
.hero { min-height: 190px; padding: 20px; border-radius: 20px; }
|
||||
.hero-content { gap: 16px; }
|
||||
.hero-art { width: 90px; height: 90px; flex-basis: 90px; border-radius: 16px; }
|
||||
.hero h1 { font-size: 24px; letter-spacing: -0.6px; }
|
||||
.hero p { font-size: 13px; }
|
||||
.stations-toolbar { flex-direction: column; align-items: stretch; gap: 16px; margin: 24px 0 16px; }
|
||||
.grid { gap: 16px; }
|
||||
.station-art { border-radius: 12px; }
|
||||
.bottom-player { width: calc(100% - 24px); bottom: max(12px, env(safe-area-inset-bottom)); padding: 8px 12px; min-height: 70px; border-radius: 20px; }
|
||||
.mini-art { width: 46px; height: 46px; border-radius: 10px; }
|
||||
.small-btn { width: 40px; height: 40px; font-size: 18px; }
|
||||
}
|
||||
@media(max-width:400px) {
|
||||
.hero-art { width: 80px; height: 80px; flex-basis: 80px; }
|
||||
.hero h1 { font-size: 22px; }
|
||||
.station-name { font-size: 14px; }
|
||||
.station-desc { font-size: 11px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app"> <!-- HEADER -->
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img src="YOUR_LOGO_URL_HERE" alt="Tempo Radio" class="brand-logo">
|
||||
<div class="brand-text">Tempo Radio <small>Internet Radio</small></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- NAVIGATION -->
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="#radio">Radio</button>
|
||||
<button class="tab" data-tab="#about">О программе</button>
|
||||
</nav>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main>
|
||||
<!-- RADIO -->
|
||||
<section id="radio" class="panel active">
|
||||
<!-- NOW PLAYING -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<img id="heroArt" class="hero-art" src="" alt="Station">
|
||||
<div>
|
||||
<h1 id="heroTitle">ZIP FM</h1>
|
||||
<p id="heroDesc">Hit music • Lithuania</p>
|
||||
<div id="heroStatus" class="playing">Готово к воспроизведению</div>
|
||||
<div class="hero-controls">
|
||||
<button id="heroPlay" class="play" onclick="togglePlay()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TOOLBAR -->
|
||||
<div class="stations-toolbar">
|
||||
<div class="section-head"><h2>Radio stations</h2></div>
|
||||
<div class="view-controls">
|
||||
<button class="view-btn" data-columns="2" title="2 в ряд">
|
||||
<span></span><span></span>
|
||||
</button>
|
||||
<button class="view-btn" data-columns="3" title="3 в ряд">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<button class="view-btn" data-columns="4" title="4 в ряд">
|
||||
<span></span><span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATIONS -->
|
||||
<div id="stationGrid" class="grid" data-columns="3"></div>
|
||||
</section>
|
||||
|
||||
<!-- ABOUT -->
|
||||
<section id="about" class="panel">
|
||||
<div class="about-card repo-container">
|
||||
<h3>Мой репозиторий</h3>
|
||||
<!-- Замените YOUR_REPOSITORY_URL_HERE на ссылку на ваш репозиторий (например GitHub) -->
|
||||
<!-- Замените YOUR_REPO_ICON_URL_HERE на ссылку с вашей иконкой или картинкой -->
|
||||
<a href="YOUR_REPOSITORY_URL_HERE" target="_blank" rel="noopener noreferrer" class="repo-link" title="Перейти в репозиторий">
|
||||
<img src="YOUR_REPO_ICON_URL_HERE" alt="Repository" class="repo-icon">
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM PLAYER -->
|
||||
<div id="bottomPlayer" class="bottom-player" onclick="window.scrollTo(0,0)">
|
||||
<img id="miniArt" class="mini-art" src="" alt="Station">
|
||||
<div class="mini-info">
|
||||
<div id="miniTitle" class="mini-title">ZIP FM</div>
|
||||
<div id="miniSub" class="mini-sub">Готово к воспроизведению</div>
|
||||
<div class="progress"><i></i></div>
|
||||
</div>
|
||||
<div class="bottom-controls">
|
||||
<button class="small-btn" onclick="event.stopPropagation(); previousStation();">‹</button>
|
||||
<button id="miniPlay" class="small-btn main-play" onclick="event.stopPropagation(); togglePlay();">▶</button>
|
||||
<button class="small-btn" onclick="event.stopPropagation(); nextStation();">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio" preload="none"></audio>
|
||||
|
||||
<script>
|
||||
/* =========================================================
|
||||
RADIO STATIONS
|
||||
========================================================= */
|
||||
const stations = [
|
||||
{ id: "zip", name: "ZIP FM", description: "Hit music • Lithuania", stream: "https://stream1.zipfm.lt/zipfm128.mp3", icon: "YOUR_SVG_URL_HERE_1" },
|
||||
{ id: "powerhit", name: "Power Hit Radio", description: "Dance • Pop • Top Hits • Lithuania", stream: "https://stream.powerhitradio.lt/PHR_AAC", icon: "YOUR_POWERHIT_SVG_URL_HERE" },
|
||||
{ id: "rock", name: "Rock FM", description: "Rock music • Lithuania", stream: "https://stream2.rockfm.lt/crf128.mp3", icon: "YOUR_SVG_URL_HERE_2" },
|
||||
{ id: "relax", name: "Relax FM", description: "Relax & easy listening", stream: "https://stream1.relaxfm.lt/relaxfm128.mp3", icon: "YOUR_SVG_URL_HERE_3" },
|
||||
{ id: "lietus", name: "Lietus", description: "Lithuanian music", stream: "https://radio.m-1.fm/LIETUS", icon: "YOUR_SVG_URL_HERE_5" },
|
||||
{ id: "m1", name: "M-1", description: "Popular music", stream: "https://n02a-eu.rcs.revma.com/f31w7e0fveuvv", icon: "YOUR_SVG_URL_HERE_6" },
|
||||
{ id: "plus", name: "M-1 Plius", description: "Music & classics", stream: "https://radio.m-1.fm/m1plius/mp3", icon: "YOUR_SVG_URL_HERE_7" }
|
||||
];
|
||||
|
||||
/* =========================================================
|
||||
APP LOGIC
|
||||
========================================================= */
|
||||
const audio = document.getElementById("audio");
|
||||
let currentIndex = 0;
|
||||
let isPlaying = false;
|
||||
const $ = id => document.getElementById(id);
|
||||
const current = () => stations[currentIndex];
|
||||
|
||||
function renderStations() {
|
||||
const grid = $("stationGrid");
|
||||
let columns = localStorage.getItem("tempoRadioColumns") || "3";
|
||||
if(columns === "5" || columns === "6") columns = "4";
|
||||
|
||||
grid.dataset.columns = columns;
|
||||
grid.innerHTML = stations.map((station, index) => {
|
||||
const active = index === currentIndex;
|
||||
return `
|
||||
<article class="station" onclick="selectStation(${index})">
|
||||
<img class="station-art" src="${station.icon}" alt="${station.name}">
|
||||
<div class="station-info">
|
||||
<div class="station-name">${active && isPlaying ? '<i class="onair"></i>' : ''}${station.name}</div>
|
||||
<div class="station-desc">${station.description}</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function updateMediaSession() {
|
||||
if ("mediaSession" in navigator) {
|
||||
try {
|
||||
const station = current();
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: station.name, artist: station.description, album: "Tempo Radio",
|
||||
artwork: [{ src: station.icon, sizes: "512x512", type: "image/png" }]
|
||||
});
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
|
||||
if ("mediaSession" in navigator) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler("play", togglePlay);
|
||||
navigator.mediaSession.setActionHandler("pause", togglePlay);
|
||||
navigator.mediaSession.setActionHandler("previoustrack", previousStation);
|
||||
navigator.mediaSession.setActionHandler("nexttrack", nextStation);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const station = current();
|
||||
$("heroArt").src = station.icon;
|
||||
$("heroTitle").textContent = station.name;
|
||||
$("heroDesc").textContent = station.description;
|
||||
$("miniArt").src = station.icon;
|
||||
$("miniTitle").textContent = station.name;
|
||||
|
||||
const statusTxt = isPlaying ? "Сейчас играет • LIVE" : "Готово к воспроизведению";
|
||||
$("miniSub").textContent = statusTxt;
|
||||
$("heroStatus").textContent = statusTxt;
|
||||
$("heroPlay").textContent = isPlaying ? "Ⅱ" : "▶";
|
||||
$("miniPlay").textContent = isPlaying ? "Ⅱ" : "▶";
|
||||
|
||||
renderStations();
|
||||
updateMediaSession();
|
||||
}
|
||||
|
||||
async function selectStation(index, autoplay = true) {
|
||||
currentIndex = index;
|
||||
isPlaying = false;
|
||||
$("heroStatus").textContent = "Подключение…";
|
||||
updateUI();
|
||||
|
||||
audio.src = current().stream;
|
||||
audio.load();
|
||||
|
||||
if (autoplay) {
|
||||
try {
|
||||
await audio.play();
|
||||
isPlaying = true;
|
||||
} catch (error) {
|
||||
isPlaying = false;
|
||||
$("heroStatus").textContent = "Нажмите Play для запуска";
|
||||
}
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function togglePlay() {
|
||||
if (!audio.src) return selectStation(currentIndex, true);
|
||||
if (audio.paused) {
|
||||
try {
|
||||
await audio.play();
|
||||
isPlaying = true;
|
||||
} catch (error) {
|
||||
isPlaying = false;
|
||||
$("heroStatus").textContent = "Нажмите Play для запуска";
|
||||
}
|
||||
} else {
|
||||
audio.pause();
|
||||
isPlaying = false;
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
function previousStation() {
|
||||
currentIndex = (currentIndex - 1 + stations.length) % stations.length;
|
||||
selectStation(currentIndex, true);
|
||||
}
|
||||
|
||||
function nextStation() {
|
||||
currentIndex = (currentIndex + 1) % stations.length;
|
||||
selectStation(currentIndex, true);
|
||||
}
|
||||
|
||||
audio.addEventListener("playing", () => { isPlaying = true; $("heroStatus").textContent = "Сейчас играет • LIVE"; updateUI(); });
|
||||
audio.addEventListener("pause", () => { isPlaying = false; $("heroStatus").textContent = "Готово к воспроизведению"; updateUI(); });
|
||||
audio.addEventListener("waiting", () => { if (!audio.paused) $("heroStatus").textContent = "Подключение…"; });
|
||||
audio.addEventListener("error", () => { isPlaying = false; $("heroStatus").textContent = "Поток недоступен"; updateUI(); });
|
||||
|
||||
/* =========================================================
|
||||
GRID VIEW
|
||||
========================================================= */
|
||||
const stationGrid = $("stationGrid");
|
||||
const viewButtons = document.querySelectorAll(".view-btn");
|
||||
let savedColumns = localStorage.getItem("tempoRadioColumns") || "3";
|
||||
if(savedColumns === "5" || savedColumns === "6") savedColumns = "4";
|
||||
|
||||
stationGrid.dataset.columns = savedColumns;
|
||||
|
||||
viewButtons.forEach(button => {
|
||||
if (button.dataset.columns === savedColumns) button.classList.add("active");
|
||||
button.addEventListener("click", () => {
|
||||
const cols = button.dataset.columns;
|
||||
stationGrid.dataset.columns = cols;
|
||||
localStorage.setItem("tempoRadioColumns", cols);
|
||||
viewButtons.forEach(item => item.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
/* =========================================================
|
||||
TABS
|
||||
========================================================= */
|
||||
document.querySelectorAll(".tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab, .panel").forEach(el => el.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.querySelector(tab.dataset.tab).classList.add("active");
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
/* =========================================================
|
||||
START
|
||||
========================================================= */
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express from "express";
|
||||
import { ChessEngine } from "./shared/chess-engine.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||
const SHARED_DIR = path.join(__dirname, "shared");
|
||||
const PORT = Number(process.env.PORT || 3015);
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "32kb" }));
|
||||
app.use(express.static(PUBLIC_DIR, { extensions: ["html"], maxAge: "1h" }));
|
||||
app.use("/shared", express.static(SHARED_DIR, { maxAge: "1h" }));
|
||||
|
||||
const MAX_SPECTATORS = 20;
|
||||
const MAX_CHAT = 100;
|
||||
|
||||
const TIME_CONTROLS = {
|
||||
none: { key: "none", label: "Без часов", initial: 0, increment: 0 },
|
||||
"1+0": { key: "1+0", label: "1 + 0 • Bullet", initial: 60, increment: 0 },
|
||||
"3+0": { key: "3+0", label: "3 + 0 • Blitz", initial: 180, increment: 0 },
|
||||
"3+2": { key: "3+2", label: "3 + 2 • Blitz", initial: 180, increment: 2 },
|
||||
"5+0": { key: "5+0", label: "5 + 0 • Blitz", initial: 300, increment: 0 },
|
||||
"5+3": { key: "5+3", label: "5 + 3 • Blitz", initial: 300, increment: 3 },
|
||||
"10+0": { key: "10+0", label: "10 + 0 • Rapid", initial: 600, increment: 0 },
|
||||
"15+10": { key: "15+10", label: "15 + 10 • Rapid", initial: 900, increment: 10 },
|
||||
"30+0": { key: "30+0", label: "30 + 0 • Classical", initial: 1800, increment: 0 },
|
||||
"30+20": { key: "30+20", label: "30 + 20 • Classical", initial: 1800, increment: 20 }
|
||||
};
|
||||
|
||||
const parties = new Map();
|
||||
|
||||
const safeName = (value) => {
|
||||
const name = String(value ?? "").trim().replace(/[<>]/g, "").slice(0, 24);
|
||||
return name || "Guest";
|
||||
};
|
||||
|
||||
const normalizeCode = (value) => String(value ?? "").trim().toUpperCase();
|
||||
|
||||
const clientId = () => crypto.randomUUID();
|
||||
|
||||
function partyCode() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code;
|
||||
do {
|
||||
code = Array.from({ length: 6 }, () =>
|
||||
alphabet[crypto.randomInt(0, alphabet.length)]
|
||||
).join("");
|
||||
} while (parties.has(code));
|
||||
return code;
|
||||
}
|
||||
|
||||
function getParty(code) {
|
||||
const party = parties.get(normalizeCode(code));
|
||||
if (!party) throw new Error("Комната не найдена.");
|
||||
return party;
|
||||
}
|
||||
|
||||
function participant(party, id) {
|
||||
return party.participants.get(String(id || "")) || null;
|
||||
}
|
||||
|
||||
function player(party, role) {
|
||||
for (const p of party.participants.values()) {
|
||||
if (p.role === role) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function roleColor(role) {
|
||||
return role === "white" ? "w" : "b";
|
||||
}
|
||||
|
||||
function colorRole(color) {
|
||||
return color === "w" ? "white" : "black";
|
||||
}
|
||||
|
||||
function makeClocks(tc) {
|
||||
return {
|
||||
white: tc.initial,
|
||||
black: tc.initial,
|
||||
running: false,
|
||||
lastTick: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function updateClock(party) {
|
||||
if (!party.clocks.running || !party.timeControl.initial || party.gameOverReason) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = Math.max(0, (now - party.clocks.lastTick) / 1000);
|
||||
const side = colorRole(party.engine.state.turn);
|
||||
|
||||
party.clocks[side] = Math.max(0, party.clocks[side] - elapsed);
|
||||
party.clocks.lastTick = now;
|
||||
|
||||
if (party.clocks[side] <= 0) {
|
||||
party.clocks.running = false;
|
||||
party.gameOverReason = "timeout";
|
||||
party.winner = side === "w" ? "b" : "w";
|
||||
}
|
||||
}
|
||||
|
||||
function startClockIfReady(party) {
|
||||
if (!party.timeControl.initial || party.gameOverReason) return;
|
||||
if (!player(party, "white") || !player(party, "black")) return;
|
||||
|
||||
party.clocks.running = true;
|
||||
party.clocks.lastTick = Date.now();
|
||||
}
|
||||
|
||||
function applyMoveClock(party, movingRole) {
|
||||
if (!party.timeControl.initial || party.gameOverReason) return;
|
||||
|
||||
updateClock(party);
|
||||
if (party.gameOverReason) return;
|
||||
|
||||
party.clocks[movingRole] += party.timeControl.increment;
|
||||
party.clocks.lastTick = Date.now();
|
||||
party.clocks.running = true;
|
||||
}
|
||||
|
||||
function resetGame(party) {
|
||||
party.engine = new ChessEngine();
|
||||
party.clocks = makeClocks(party.timeControl);
|
||||
party.gameOverReason = null;
|
||||
party.winner = null;
|
||||
party.drawOffer = null;
|
||||
party.gameNumber += 1;
|
||||
startClockIfReady(party);
|
||||
}
|
||||
|
||||
function serializeParticipant(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
role: p.role,
|
||||
connected: Boolean(p.connected)
|
||||
};
|
||||
}
|
||||
|
||||
function serializeParty(party, me = null) {
|
||||
updateClock(party);
|
||||
|
||||
const players = { white: null, black: null };
|
||||
const spectators = [];
|
||||
|
||||
for (const p of party.participants.values()) {
|
||||
if (p.role === "white") players.white = serializeParticipant(p);
|
||||
else if (p.role === "black") players.black = serializeParticipant(p);
|
||||
else spectators.push(serializeParticipant(p));
|
||||
}
|
||||
|
||||
const you = participant(party, me);
|
||||
|
||||
return {
|
||||
code: party.code,
|
||||
gameNumber: party.gameNumber,
|
||||
game: party.engine.getSnapshot(),
|
||||
players,
|
||||
spectators,
|
||||
spectatorCount: spectators.length,
|
||||
maxSpectators: MAX_SPECTATORS,
|
||||
timeControl: party.timeControl,
|
||||
clocks: {
|
||||
white: Math.max(0, party.clocks.white),
|
||||
black: Math.max(0, party.clocks.black),
|
||||
running: Boolean(party.clocks.running),
|
||||
turn: party.engine.state.turn
|
||||
},
|
||||
gameOverReason: party.gameOverReason || null,
|
||||
winner: party.winner || null,
|
||||
drawOffer: party.drawOffer
|
||||
? { from: party.drawOffer.from, name: party.drawOffer.name }
|
||||
: null,
|
||||
chat: party.chat.slice(-MAX_CHAT),
|
||||
you: you ? { id: you.id, name: you.name, role: you.role } : null
|
||||
};
|
||||
}
|
||||
|
||||
function send(res, event, data) {
|
||||
try {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function broadcast(party) {
|
||||
for (const c of party.sse) {
|
||||
send(c.res, "party", serializeParty(party, c.id));
|
||||
}
|
||||
}
|
||||
|
||||
function createParty(name, timeControl) {
|
||||
const tc = TIME_CONTROLS[timeControl] || TIME_CONTROLS["10+0"];
|
||||
const code = partyCode();
|
||||
const id = clientId();
|
||||
|
||||
const party = {
|
||||
code,
|
||||
engine: new ChessEngine(),
|
||||
timeControl: tc,
|
||||
clocks: makeClocks(tc),
|
||||
gameOverReason: null,
|
||||
winner: null,
|
||||
drawOffer: null,
|
||||
gameNumber: 1,
|
||||
participants: new Map(),
|
||||
sse: new Set(),
|
||||
chat: []
|
||||
};
|
||||
|
||||
party.participants.set(id, {
|
||||
id,
|
||||
name: safeName(name),
|
||||
role: "white",
|
||||
connected: true
|
||||
});
|
||||
|
||||
parties.set(code, party);
|
||||
return { party, id };
|
||||
}
|
||||
|
||||
function cleanup(party) {
|
||||
const active = [...party.participants.values()].some(p => p.connected);
|
||||
if (!active && party.sse.size === 0) parties.delete(party.code);
|
||||
}
|
||||
|
||||
app.post("/api/party/create", (req, res) => {
|
||||
try {
|
||||
const { party, id } = createParty(req.body?.name, req.body?.timeControl);
|
||||
res.json({ ok: true, party: serializeParty(party, id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/join", (req, res) => {
|
||||
try {
|
||||
const code = normalizeCode(req.body?.partyCode);
|
||||
const party = getParty(code);
|
||||
const name = safeName(req.body?.name);
|
||||
let id = String(req.body?.clientId || "").trim();
|
||||
let p = participant(party, id);
|
||||
|
||||
if (p) {
|
||||
p.name = name;
|
||||
p.connected = true;
|
||||
} else {
|
||||
id = clientId();
|
||||
let role = "spectator";
|
||||
if (!player(party, "white")) role = "white";
|
||||
else if (!player(party, "black")) role = "black";
|
||||
else if (party.participants.size >= MAX_SPECTATORS + 2) {
|
||||
return res.status(403).json({ ok: false, error: "Лимит зрителей достигнут." });
|
||||
}
|
||||
|
||||
p = { id, name, role, connected: true };
|
||||
party.participants.set(id, p);
|
||||
}
|
||||
|
||||
startClockIfReady(party);
|
||||
broadcast(party);
|
||||
res.json({ ok: true, party: serializeParty(party, id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/move", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
|
||||
if (!p || !["white", "black"].includes(p.role)) {
|
||||
return res.status(403).json({ ok: false, error: "Вы не игрок этой партии." });
|
||||
}
|
||||
|
||||
updateClock(party);
|
||||
if (party.gameOverReason) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Игра уже завершена.",
|
||||
party: serializeParty(party, p.id)
|
||||
});
|
||||
}
|
||||
|
||||
const color = roleColor(p.role);
|
||||
if (party.engine.state.turn !== color) {
|
||||
return res.status(400).json({ ok: false, error: "Сейчас ход соперника." });
|
||||
}
|
||||
|
||||
const result = party.engine.makeMove({
|
||||
from: req.body?.from,
|
||||
to: req.body?.to,
|
||||
promotion: req.body?.promotion || "q"
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
applyMoveClock(party, p.role);
|
||||
|
||||
const status = party.engine.getStatus();
|
||||
if (status.phase === "checkmate") {
|
||||
party.gameOverReason = "checkmate";
|
||||
party.winner = party.engine.state.turn === "w" ? "b" : "w";
|
||||
party.clocks.running = false;
|
||||
} else if (status.phase === "draw") {
|
||||
party.gameOverReason = "draw";
|
||||
party.winner = null;
|
||||
party.clocks.running = false;
|
||||
}
|
||||
|
||||
party.drawOffer = null;
|
||||
broadcast(party);
|
||||
res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/rematch", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
if (!p) return res.status(403).json({ ok: false, error: "Сессия не найдена." });
|
||||
|
||||
resetGame(party);
|
||||
broadcast(party);
|
||||
res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/resign", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
if (!p || !["white", "black"].includes(p.role)) {
|
||||
return res.status(403).json({ ok: false, error: "Вы не игрок." });
|
||||
}
|
||||
if (party.gameOverReason) return res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
|
||||
party.gameOverReason = "resign";
|
||||
party.winner = roleColor(p.role) === "w" ? "b" : "w";
|
||||
party.clocks.running = false;
|
||||
party.drawOffer = null;
|
||||
broadcast(party);
|
||||
res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/draw", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
if (!p || !["white", "black"].includes(p.role)) {
|
||||
return res.status(403).json({ ok: false, error: "Вы не игрок." });
|
||||
}
|
||||
if (party.gameOverReason) return res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
|
||||
if (party.drawOffer && party.drawOffer.from !== p.id) {
|
||||
party.gameOverReason = "draw";
|
||||
party.winner = null;
|
||||
party.drawOffer = null;
|
||||
party.clocks.running = false;
|
||||
} else {
|
||||
party.drawOffer = { from: p.id, name: p.name };
|
||||
}
|
||||
|
||||
broadcast(party);
|
||||
res.json({ ok: true, party: serializeParty(party, p.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/chat", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
if (!p) return res.status(403).json({ ok: false, error: "Сессия не найдена." });
|
||||
|
||||
const text = String(req.body?.text ?? "").trim().replace(/[<>]/g, "").slice(0, 300);
|
||||
if (!text) return res.status(400).json({ ok: false, error: "Пустое сообщение." });
|
||||
|
||||
party.chat.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: p.name,
|
||||
text,
|
||||
at: Date.now()
|
||||
});
|
||||
if (party.chat.length > MAX_CHAT) party.chat.splice(0, party.chat.length - MAX_CHAT);
|
||||
|
||||
broadcast(party);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/party/events", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.query?.partyCode);
|
||||
const id = String(req.query?.clientId || "").trim();
|
||||
if (!participant(party, id)) return res.status(403).end();
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
|
||||
const client = { id, res };
|
||||
party.sse.add(client);
|
||||
participant(party, id).connected = true;
|
||||
|
||||
send(res, "party", serializeParty(party, id));
|
||||
broadcast(party);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try { res.write(": ping\n\n"); } catch { clearInterval(heartbeat); }
|
||||
}, 15000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
party.sse.delete(client);
|
||||
const p = participant(party, id);
|
||||
if (p) p.connected = false;
|
||||
broadcast(party);
|
||||
cleanup(party);
|
||||
});
|
||||
} catch {
|
||||
res.status(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/party/leave", (req, res) => {
|
||||
try {
|
||||
const party = getParty(req.body?.partyCode);
|
||||
const p = participant(party, req.body?.clientId);
|
||||
if (p) {
|
||||
if (p.role === "spectator") party.participants.delete(p.id);
|
||||
else p.connected = false;
|
||||
}
|
||||
broadcast(party);
|
||||
cleanup(party);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
for (const party of parties.values()) {
|
||||
const before = party.gameOverReason;
|
||||
updateClock(party);
|
||||
if (!before && party.gameOverReason) broadcast(party);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Chess Party running on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user