34 Commits
Author SHA1 Message Date
admin01 52edd18b3e Update chess/public/index.html 2026-08-22 19:00:53 +00:00
admin01 299f47b1b3 Update chess/public/styles.css 2026-08-22 19:00:17 +00:00
admin01 d0e0e3274a Update chess/public/js/app.js 2026-08-22 18:59:37 +00:00
admin01 d38ae7cbe6 Update chess/shared/chess-engine.js 2026-08-22 18:57:39 +00:00
admin01 7c94426848 Update package.json 2026-08-22 18:56:58 +00:00
admin01 1fbdd1317c Update server.js 2026-08-22 18:56:03 +00:00
admin01 1f41362338 Upload files to "/" 2026-08-22 18:54:41 +00:00
admin01 2dfea24f42 Update chess/public/styles.css 2026-08-22 18:21:04 +00:00
admin01 039b64f9e5 Update chess/public/js/app.js 2026-08-22 18:20:15 +00:00
admin01 6ed96533b0 Update chess/public/js/app.js 2026-08-22 18:14:17 +00:00
admin01 8dcc964d04 Update chess/public/js/app.js 2026-08-22 18:08:35 +00:00
admin01 226ad23f7b Update chess/public/js/app.js 2026-08-22 17:51:45 +00:00
admin01 5f75752b26 Update server.js 2026-08-22 17:49:23 +00:00
admin01 5e57feb66f Update chess/public/index.html 2026-08-22 17:47:08 +00:00
admin01 4e6e2fc9e2 Update chess/public/styles.css 2026-08-22 17:46:20 +00:00
admin01 e6e57a3cc0 Add server.js 2026-08-22 17:41:22 +00:00
admin01 0720bcc229 Add package.json 2026-08-22 17:40:44 +00:00
admin01 093b4ac46f Add package-lock.json 2026-08-22 17:40:02 +00:00
admin01 3b02071fe7 Add chess/shared/chess-engine.js 2026-08-22 17:39:10 +00:00
admin01 728da46b10 Add chess/public/js/app.js 2026-08-22 17:37:25 +00:00
admin01 328fe6b417 Add chess/public/js/ai.js 2026-08-22 17:36:51 +00:00
admin01 0aba79015d Delete chess/public/js 2026-08-22 17:36:17 +00:00
admin01 3d7bfd1797 Add chess/public/js 2026-08-22 17:36:04 +00:00
admin01 debf3bbc68 Add chess/public/styles.css 2026-08-22 17:35:26 +00:00
admin01 1d87e4539b Add chess/public/index.html 2026-08-22 17:34:34 +00:00
admin01 11a1b0cb41 Delete chess/public 2026-08-22 17:32:52 +00:00
admin01 83500d2d86 Add chess/public 2026-08-22 17:30:18 +00:00
admin01 da311edec3 Delete batch.zip 2026-08-22 17:03:06 +00:00
admin01 34606b50cc Upload files to "/" 2026-08-22 17:02:45 +00:00
admin01 0a8475d245 Delete directory 'public' 2026-08-22 17:00:37 +00:00
admin01 52ce4ab42a Add public/404.html 2026-08-22 16:55:44 +00:00
admin01 ff02d54bab Delete radiowebview.html 2026-08-22 16:53:45 +00:00
admin01 555d0b2005 Update radiowebview.html 2026-08-22 16:45:58 +00:00
admin01 bef651fa5d Merge pull request 'Radio managment' (#1) from radio into main
Reviewed-on: #1
2026-08-22 16:37:48 +00:00
10 changed files with 2375 additions and 518 deletions
+140
View File
@@ -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>
+543
View File
@@ -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;
}
+538
View File
@@ -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 => ({
"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"
}[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");
+113
View File
@@ -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)}
}
+535
View File
@@ -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.
+12
View File
@@ -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"
}
}
}
+17
View File
@@ -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
View File
@@ -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>
+477
View File
@@ -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}`);
});