mirror of
https://github.com/MuRuLOSE/limoka.git
synced 2026-06-16 14:34:17 +02:00
1676 lines
49 KiB
JavaScript
1676 lines
49 KiB
JavaScript
const board_size = 8;
|
|
const block_size = 107;
|
|
const theme_config = window.nochess_theme || {};
|
|
const block_light = theme_config.block_light || '#D8E3E7';
|
|
const block_dark = theme_config.block_dark || '#7699AF';
|
|
const select_block = theme_config.select_block || '#FFDF5A';
|
|
const block_select_alpha_light = 0.34;
|
|
const block_select_alpha_dark = 0.58;
|
|
const move_pieces_color = theme_config.move_pieces_color || '#58B4FF';
|
|
const block_move_from_alpha = 0.56;
|
|
const block_move_to_alpha = 0.62;
|
|
const move_highlight_delay_ms = 25;
|
|
const result_overlay_duration_ms = 300;
|
|
const result_win = theme_config.result_win || '#00BE16';
|
|
const result_lose = theme_config.result_lose || '#BE0000';
|
|
const result_draw = theme_config.result_draw || '#434343';
|
|
const result_overlay_alpha = 0.64;
|
|
const arrow_color = theme_config.arrow_color || '#BD3667';
|
|
const asset_root = window.nochess_asset_root || 'https://raw.githubusercontent.com/SunnexGB/Heroku-Modules/main/Assets/NoChess';
|
|
const noise_src = `${asset_root}/other/noise.png`;
|
|
const start_fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
|
|
|
|
const chess_pieces = {
|
|
wP: `${asset_root}/pieces/white-pawn.png`,
|
|
wR: `${asset_root}/pieces/white-rook.png`,
|
|
wN: `${asset_root}/pieces/white-knight.png`,
|
|
wB: `${asset_root}/pieces/white-bishop.png`,
|
|
wQ: `${asset_root}/pieces/white-queen.png`,
|
|
wK: `${asset_root}/pieces/white-king.png`,
|
|
bP: `${asset_root}/pieces/black-pawn.png`,
|
|
bR: `${asset_root}/pieces/black-rook.png`,
|
|
bN: `${asset_root}/pieces/black-knight.png`,
|
|
bB: `${asset_root}/pieces/black-bishop.png`,
|
|
bQ: `${asset_root}/pieces/black-queen.png`,
|
|
bK: `${asset_root}/pieces/black-king.png`
|
|
};
|
|
|
|
const sound_urls = {
|
|
game_start: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/game-start.mp3',
|
|
game_end: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/game-end.mp3',
|
|
capture: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/capture.mp3',
|
|
castle: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/castle.mp3',
|
|
premove: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/premove.mp3',
|
|
move_self: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/move-self.mp3',
|
|
move_opponent: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/move-opponent.mp3',
|
|
move_check: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/move-check.mp3',
|
|
promote: 'https://images.chesscomfiles.com/chess-themes/sounds/_MP3_/default/promote.mp3'
|
|
};
|
|
|
|
const ui_text = {
|
|
no_history: 'No history',
|
|
unknown_date: 'Unknown date',
|
|
chess_library_not_ready: 'Chess library not ready',
|
|
pgn_empty: 'Paste PGN text first',
|
|
pgn_invalid: 'Invalid PGN format',
|
|
pgn_loaded: 'PGN loaded',
|
|
pgn_copied: 'PGN copied',
|
|
copy_failed: 'Copy failed'
|
|
};
|
|
|
|
const crown_icon_src = `${asset_root}/icons/result-crown.svg`;
|
|
const flag_icon_src = `${asset_root}/icons/result-flag.svg`;
|
|
const draw_icon_src = `${asset_root}/icons/result-draw.svg`;
|
|
|
|
const canvas = document.getElementById('chessBoard');
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = board_size * block_size;
|
|
canvas.height = board_size * block_size;
|
|
|
|
const moves_scroll = document.querySelector('.moves_list_mobile');
|
|
const pgn_moves_board = document.querySelector('.pgn_moves_board');
|
|
const timer_black = document.querySelector('.timer_black');
|
|
const timer_white = document.querySelector('.timer_white');
|
|
const player_name_white = document.querySelector('.player_name_white');
|
|
const player_name_black = document.querySelector('.player_name_black');
|
|
const avatar_white = document.querySelector('.avatar_white');
|
|
const avatar_black = document.querySelector('.avatar_black');
|
|
const history_btn = document.getElementById('history_btn');
|
|
const share_btn = document.getElementById('share_btn');
|
|
const more_btn = document.getElementById('more_btn');
|
|
const app_layout = document.querySelector('.web_chess');
|
|
const first_move_icon = document.querySelector('.first_move_btn img');
|
|
const submenu_overlay = document.getElementById('submenu_overlay');
|
|
const history_panel = document.getElementById('history_panel');
|
|
const share_panel = document.getElementById('share_panel');
|
|
const history_games = document.getElementById('history_games');
|
|
const share_pgn_text = document.getElementById('share_pgn_text');
|
|
const load_pgn_btn = document.getElementById('load_pgn_btn');
|
|
const copy_pgn_btn = document.getElementById('copy_pgn_btn');
|
|
const share_status = document.getElementById('share_status');
|
|
|
|
let board = [];
|
|
let marked_cells = new Set();
|
|
let arrows = [];
|
|
let piece_images = {};
|
|
let noise_image = null;
|
|
let right_drag_start = null;
|
|
let is_flipped = false;
|
|
let parsed_games = [];
|
|
let current_game_index = 0;
|
|
let current_ply = 0;
|
|
let share_status_timeout = null;
|
|
let chess_ready = false;
|
|
let highlighted_move_cells = null;
|
|
let piece_animation = null;
|
|
let highlight_delay_timeout = null;
|
|
let result_markers = null;
|
|
let result_animation = null;
|
|
let nochess_profile = window.nochess_profile || null;
|
|
const mobile_breakpoint = window.matchMedia('(max-width: 572px)');
|
|
let was_mobile_layout = false;
|
|
const crown_icon_image = new Image();
|
|
const flag_icon_image = new Image();
|
|
const draw_icon_image = new Image();
|
|
crown_icon_image.src = crown_icon_src;
|
|
flag_icon_image.src = flag_icon_src;
|
|
draw_icon_image.src = draw_icon_src;
|
|
|
|
function hexToRgba(hex, alpha) {
|
|
const value = hex.replace('#', '');
|
|
const full = value.length === 3
|
|
? value.split('').map((c) => c + c).join('')
|
|
: value;
|
|
const int = Number.parseInt(full, 16);
|
|
const r = (int >> 16) & 255;
|
|
const g = (int >> 8) & 255;
|
|
const b = int & 255;
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
function loadScript(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.async = true;
|
|
script.onload = () => resolve(true);
|
|
script.onerror = () => reject(new Error(`failed: ${src}`));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
async function loadChessLibrary() {
|
|
if (typeof window.Chess !== 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
const classic_sources = [
|
|
'https://cdn.jsdelivr.net/npm/chess.js@0.13.4/chess.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.13.4/chess.min.js'
|
|
];
|
|
|
|
for (const src of classic_sources) {
|
|
try {
|
|
await loadScript(src);
|
|
if (typeof window.Chess !== 'undefined') {
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
}
|
|
|
|
try {
|
|
const module = await import('https://cdn.jsdelivr.net/npm/chess.js@1.4.0/dist/esm/chess.js');
|
|
window.Chess = module.Chess || module.default;
|
|
return typeof window.Chess !== 'undefined';
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createChess() {
|
|
if (typeof Chess === 'undefined') {
|
|
return null;
|
|
}
|
|
return new Chess();
|
|
}
|
|
|
|
function loadPgnToChess(chess, pgn_text) {
|
|
if (!chess) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof chess.load_pgn === 'function') {
|
|
let loaded = chess.load_pgn(pgn_text, { newline_char: '\n', sloppy: true });
|
|
if (!loaded) {
|
|
loaded = chess.load_pgn(pgn_text, { sloppy: true });
|
|
}
|
|
if (!loaded) {
|
|
loaded = chess.load_pgn(pgn_text);
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
if (typeof chess.loadPgn === 'function') {
|
|
try {
|
|
chess.loadPgn(pgn_text, { strict: false });
|
|
return true;
|
|
} catch (error) {
|
|
try {
|
|
chess.loadPgn(pgn_text);
|
|
return true;
|
|
} catch (second_error) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function moveOnChess(chess, move_text) {
|
|
if (!chess || !move_text) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const sloppy_move = chess.move(move_text, { sloppy: true });
|
|
if (sloppy_move) {
|
|
return sloppy_move;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
|
|
try {
|
|
return chess.move(move_text);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const sound_players = Object.fromEntries(Object.entries(sound_urls).map(([name, url]) => {
|
|
const base = new Audio(url);
|
|
base.preload = 'auto';
|
|
return [name, () => {
|
|
const sound = base.cloneNode(true);
|
|
sound.volume = 0.9;
|
|
sound.play().catch(() => {});
|
|
}];
|
|
}));
|
|
|
|
function updateScrollShadow() {
|
|
const at_bottom = moves_scroll.scrollTop + moves_scroll.clientHeight >= moves_scroll.scrollHeight - 5;
|
|
const has_overflow = moves_scroll.scrollHeight > moves_scroll.clientHeight;
|
|
pgn_moves_board.classList.toggle('has_scroll', has_overflow && !at_bottom);
|
|
}
|
|
|
|
function parseHeaders(pgn_text) {
|
|
const headers = {};
|
|
const header_regex = /^\[(\w+)\s+"([^"]*)"\]$/gm;
|
|
let match = header_regex.exec(pgn_text);
|
|
while (match) {
|
|
headers[match[1]] = match[2];
|
|
match = header_regex.exec(pgn_text);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function normalizeClock(clock) {
|
|
if (!clock) {
|
|
return null;
|
|
}
|
|
const parts = clock.split(':');
|
|
if (parts.length === 3) {
|
|
const hours = Number(parts[0]);
|
|
const minutes = Number(parts[1]);
|
|
const seconds = Number(parts[2]);
|
|
if (!Number.isNaN(hours) && !Number.isNaN(minutes) && !Number.isNaN(seconds)) {
|
|
if (hours === 0) {
|
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
}
|
|
if (parts.length === 2) {
|
|
const minutes = Number(parts[0]);
|
|
const seconds = Number(parts[1]);
|
|
if (!Number.isNaN(minutes) && !Number.isNaN(seconds)) {
|
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
}
|
|
return clock;
|
|
}
|
|
|
|
function isMoveToken(token) {
|
|
return /^(O-O(-O)?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](=[QRBN])?[+#]?|[a-h]x[a-h][1-8](=[QRBN])?[+#]?|[a-h][1-8](=[QRBN])?[+#]?)$/.test(token);
|
|
}
|
|
|
|
function extractMoveClocks(pgn_text) {
|
|
const cleaned = pgn_text.replace(/^\[[^\]]*\]\s*$/gm, '');
|
|
const tokens = cleaned.match(/\{[^}]*\}|[^\s]+/g) || [];
|
|
const clocks_by_ply = {};
|
|
let ply = 0;
|
|
let last_move_ply = 0;
|
|
|
|
for (const token of tokens) {
|
|
if (!token) {
|
|
continue;
|
|
}
|
|
|
|
if (token.startsWith('{')) {
|
|
const clk_match = token.match(/\[%clk\s+([^\]]+)\]/i);
|
|
if (clk_match && last_move_ply > 0) {
|
|
clocks_by_ply[last_move_ply] = normalizeClock(clk_match[1].trim());
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (/^\d+\.{1,3}$/.test(token)) {
|
|
continue;
|
|
}
|
|
|
|
if (token === '1-0' || token === '0-1' || token === '1/2-1/2' || token === '*') {
|
|
continue;
|
|
}
|
|
|
|
if (isMoveToken(token)) {
|
|
ply += 1;
|
|
last_move_ply = ply;
|
|
}
|
|
}
|
|
|
|
return clocks_by_ply;
|
|
}
|
|
|
|
function fenCharToPiece(char) {
|
|
const is_white = char === char.toUpperCase();
|
|
const color = is_white ? 'w' : 'b';
|
|
const type = char.toUpperCase();
|
|
if (!'PRNBQK'.includes(type)) {
|
|
return null;
|
|
}
|
|
return `${color}${type}`;
|
|
}
|
|
|
|
function boardFromFen(fen) {
|
|
const rows = fen.split(' ')[0].split('/');
|
|
return rows.map((row) => {
|
|
const parsed = [];
|
|
for (const char of row) {
|
|
const count = Number(char);
|
|
if (!Number.isNaN(count) && count > 0) {
|
|
for (let i = 0; i < count; i += 1) {
|
|
parsed.push(null);
|
|
}
|
|
} else {
|
|
parsed.push(fenCharToPiece(char));
|
|
}
|
|
}
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
function squareToCoords(square) {
|
|
if (!square || square.length < 2) {
|
|
return null;
|
|
}
|
|
const file = square.charCodeAt(0) - 97;
|
|
const rank = Number(square[1]);
|
|
if (file < 0 || file > 7 || Number.isNaN(rank) || rank < 1 || rank > 8) {
|
|
return null;
|
|
}
|
|
return { row: 8 - rank, col: file };
|
|
}
|
|
|
|
function setHighlightedMove(from_square, to_square) {
|
|
const from = squareToCoords(from_square);
|
|
const to = squareToCoords(to_square);
|
|
highlighted_move_cells = from && to ? { from, to } : null;
|
|
}
|
|
|
|
function scheduleHighlightedMove(from_square, to_square) {
|
|
if (highlight_delay_timeout) {
|
|
clearTimeout(highlight_delay_timeout);
|
|
highlight_delay_timeout = null;
|
|
}
|
|
|
|
highlighted_move_cells = null;
|
|
|
|
if (!from_square || !to_square) {
|
|
return;
|
|
}
|
|
|
|
highlight_delay_timeout = setTimeout(() => {
|
|
setHighlightedMove(from_square, to_square);
|
|
highlight_delay_timeout = null;
|
|
render();
|
|
}, move_highlight_delay_ms);
|
|
}
|
|
|
|
window.highlightMoveSquares = function highlightMoveSquares(from_square, to_square) {
|
|
setHighlightedMove(from_square, to_square);
|
|
render();
|
|
};
|
|
|
|
function createAnimationState(from, to, piece_code) {
|
|
return {
|
|
from,
|
|
to,
|
|
piece_code,
|
|
start_time: performance.now(),
|
|
duration: 180
|
|
};
|
|
}
|
|
|
|
function animateMoveTransition(animation_state) {
|
|
piece_animation = animation_state;
|
|
|
|
function step() {
|
|
if (!piece_animation) {
|
|
return;
|
|
}
|
|
|
|
const elapsed = performance.now() - piece_animation.start_time;
|
|
if (elapsed >= piece_animation.duration) {
|
|
piece_animation = null;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
render();
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
function parsePgnToGameState(pgn_text) {
|
|
if (!chess_ready || typeof Chess === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const parser = createChess();
|
|
if (!parser) {
|
|
return null;
|
|
}
|
|
|
|
const loaded = loadPgnToChess(parser, pgn_text);
|
|
|
|
if (!loaded) {
|
|
const fallback = parseMovesWithSloppyParser(pgn_text);
|
|
if (!fallback) {
|
|
return null;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
const headers = parseHeaders(pgn_text);
|
|
const verbose_moves = parser.history({ verbose: true });
|
|
const clocks_by_ply = extractMoveClocks(pgn_text);
|
|
const simulator = createChess();
|
|
if (!simulator) {
|
|
return null;
|
|
}
|
|
const positions = [boardFromFen(simulator.fen())];
|
|
|
|
for (const move of verbose_moves) {
|
|
moveOnChess(simulator, move.san);
|
|
positions.push(boardFromFen(simulator.fen()));
|
|
}
|
|
|
|
const moves = verbose_moves.map((move, index) => ({
|
|
...move,
|
|
clock: clocks_by_ply[index + 1] || null,
|
|
is_capture: move.flags.includes('c') || move.flags.includes('e'),
|
|
is_castle: move.flags.includes('k') || move.flags.includes('q'),
|
|
is_promotion: move.flags.includes('p'),
|
|
is_check: move.san.includes('+'),
|
|
is_mate: move.san.includes('#')
|
|
}));
|
|
|
|
return {
|
|
pgn_text,
|
|
headers,
|
|
moves,
|
|
positions
|
|
};
|
|
}
|
|
|
|
function normalizeMoveToken(token) {
|
|
if (!token) {
|
|
return '';
|
|
}
|
|
|
|
let value = token.trim();
|
|
value = value.replace(/^\d+\.\.\./, '');
|
|
value = value.replace(/^\d+\./, '');
|
|
value = value.replace(/[!?]+$/g, '');
|
|
value = value.replace(/^0-0-0$/, 'O-O-O');
|
|
value = value.replace(/^0-0$/, 'O-O');
|
|
value = value.replace(/=([qrbn])/g, (_, p1) => `=${p1.toUpperCase()}`);
|
|
return value;
|
|
}
|
|
|
|
function extractSanTokens(movetext) {
|
|
const regex = /(?:O-O-O|O-O|0-0-0|0-0|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=[QRBNqrbn])?|[a-h]x[a-h][1-8](?:=[QRBNqrbn])?|[a-h][1-8](?:=[QRBNqrbn])?)(?:[+#])?|1-0|0-1|1\/2-1\/2|\*/g;
|
|
return movetext.match(regex) || [];
|
|
}
|
|
|
|
function tryApplySanMove(game, token) {
|
|
const first_try = moveOnChess(game, token);
|
|
if (first_try) {
|
|
return first_try;
|
|
}
|
|
|
|
const without_suffix = token.replace(/[+#]$/, '');
|
|
if (without_suffix !== token) {
|
|
const second_try = moveOnChess(game, without_suffix);
|
|
if (second_try) {
|
|
return second_try;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseMovesWithSloppyParser(pgn_text) {
|
|
if (!chess_ready || typeof Chess === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const headers = parseHeaders(pgn_text);
|
|
const clocks_by_ply = extractMoveClocks(pgn_text);
|
|
const move_section = pgn_text
|
|
.replace(/^\[[^\]]*\]\s*$/gm, '')
|
|
.replace(/\{[^}]*\}/g, ' ')
|
|
.replace(/\([^)]*\)/g, ' ')
|
|
.replace(/\$\d+/g, ' ')
|
|
.replace(/\r\n/g, '\n')
|
|
.trim();
|
|
|
|
const start_index = move_section.search(/\b\d+\./);
|
|
const mandatory_movetext = start_index >= 0 ? move_section.slice(start_index) : move_section;
|
|
const normalized_movetext = mandatory_movetext
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
const raw_tokens = extractSanTokens(normalized_movetext);
|
|
const game = createChess();
|
|
if (!game) {
|
|
return null;
|
|
}
|
|
const moves = [];
|
|
const positions = [boardFromFen(game.fen())];
|
|
let ply = 0;
|
|
|
|
for (const raw of raw_tokens) {
|
|
if (!raw || raw === '1-0' || raw === '0-1' || raw === '1/2-1/2' || raw === '*') {
|
|
continue;
|
|
}
|
|
|
|
const split_tokens = raw.includes('.') ? raw.split('.').filter(Boolean) : [raw];
|
|
for (const token of split_tokens) {
|
|
const clean_token = normalizeMoveToken(token);
|
|
if (!clean_token || /^\d+$/.test(clean_token)) {
|
|
continue;
|
|
}
|
|
|
|
if (clean_token === '...' || /^\.+$/.test(clean_token)) {
|
|
continue;
|
|
}
|
|
|
|
if (clean_token === '1-0' || clean_token === '0-1' || clean_token === '1/2-1/2' || clean_token === '*') {
|
|
continue;
|
|
}
|
|
|
|
const move = tryApplySanMove(game, clean_token);
|
|
if (!move) {
|
|
return null;
|
|
}
|
|
|
|
ply += 1;
|
|
moves.push({
|
|
...move,
|
|
clock: clocks_by_ply[ply] || null,
|
|
is_capture: move.flags.includes('c') || move.flags.includes('e'),
|
|
is_castle: move.flags.includes('k') || move.flags.includes('q'),
|
|
is_promotion: move.flags.includes('p'),
|
|
is_check: move.san.includes('+'),
|
|
is_mate: move.san.includes('#')
|
|
});
|
|
positions.push(boardFromFen(game.fen()));
|
|
}
|
|
}
|
|
|
|
if (moves.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
pgn_text,
|
|
headers,
|
|
moves,
|
|
positions
|
|
};
|
|
}
|
|
|
|
function sanitizePgnInput(raw_text) {
|
|
if (!raw_text) {
|
|
return '';
|
|
}
|
|
|
|
let text = raw_text.replace(/^\uFEFF/, '').trim();
|
|
const fenced = text.match(/^```(?:pgn)?\s*([\s\S]*?)\s*```$/i);
|
|
if (fenced) {
|
|
text = fenced[1].trim();
|
|
}
|
|
|
|
return text
|
|
.replace(/\u00A0/g, ' ')
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\t/g, ' ')
|
|
.replace(/\n{3,}/g, '\n\n');
|
|
}
|
|
|
|
function autoResizeShareTextarea() {
|
|
share_pgn_text.style.height = 'auto';
|
|
const next_height = Math.min(share_pgn_text.scrollHeight, Math.floor(window.innerHeight * 0.55));
|
|
share_pgn_text.style.height = `${Math.max(next_height, 170)}px`;
|
|
}
|
|
|
|
function setShareStatus(message, is_error) {
|
|
if (share_status_timeout) {
|
|
clearTimeout(share_status_timeout);
|
|
share_status_timeout = null;
|
|
}
|
|
|
|
share_status.textContent = message || '';
|
|
share_status.classList.remove('show', 'error');
|
|
if (!message) {
|
|
return;
|
|
}
|
|
if (is_error) {
|
|
share_status.classList.add('error');
|
|
}
|
|
requestAnimationFrame(() => {
|
|
share_status.classList.add('show');
|
|
});
|
|
|
|
share_status_timeout = setTimeout(() => {
|
|
share_status.classList.remove('show', 'error');
|
|
share_status.textContent = '';
|
|
share_status_timeout = null;
|
|
}, 2200);
|
|
}
|
|
|
|
function clearResultVisuals() {
|
|
result_markers = null;
|
|
result_animation = null;
|
|
}
|
|
|
|
function isSameResultMarkers(first, second) {
|
|
if (!first && !second) {
|
|
return true;
|
|
}
|
|
if (!first || !second) {
|
|
return false;
|
|
}
|
|
return first.winner.row === second.winner.row
|
|
&& first.winner.col === second.winner.col
|
|
&& first.loser.row === second.loser.row
|
|
&& first.loser.col === second.loser.col;
|
|
}
|
|
|
|
function getResultOverlayProgress() {
|
|
if (!result_animation) {
|
|
return 1;
|
|
}
|
|
return Math.min(1, (performance.now() - result_animation.start_time) / result_animation.duration);
|
|
}
|
|
|
|
function getResultIconScale(progress) {
|
|
const start = 0.52;
|
|
const overshoot = 1.08;
|
|
const ease_out_back = 1 + (2.1 * ((progress - 1) ** 3)) + (1.1 * ((progress - 1) ** 2));
|
|
|
|
if (progress < 0.78) {
|
|
return start + ((overshoot - start) * ease_out_back);
|
|
}
|
|
|
|
const settle_progress = (progress - 0.78) / 0.22;
|
|
return overshoot + ((1 - overshoot) * settle_progress);
|
|
}
|
|
|
|
function animateResultOverlay() {
|
|
function step() {
|
|
if (!result_animation) {
|
|
return;
|
|
}
|
|
|
|
const progress = getResultOverlayProgress();
|
|
render();
|
|
|
|
if (progress >= 1) {
|
|
result_animation = null;
|
|
return;
|
|
}
|
|
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
function getWinnerFromResult(result) {
|
|
if (result === '1-0') {
|
|
return 'w';
|
|
}
|
|
if (result === '0-1') {
|
|
return 'b';
|
|
}
|
|
if (result === '1/2-1/2') {
|
|
return 'draw';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateResultVisuals() {
|
|
const previous_markers = result_markers;
|
|
clearResultVisuals();
|
|
|
|
const game = getCurrentGame();
|
|
if (!game || current_ply !== game.moves.length) {
|
|
return;
|
|
}
|
|
|
|
const winner = getWinnerFromResult(game.headers.Result || '');
|
|
if (!winner) {
|
|
return;
|
|
}
|
|
|
|
let winner_king = null;
|
|
let loser_king = null;
|
|
for (let row = 0; row < board_size; row += 1) {
|
|
for (let col = 0; col < board_size; col += 1) {
|
|
const piece = board[row][col];
|
|
if (winner === 'draw') {
|
|
if (piece === 'wK') {
|
|
winner_king = { row, col };
|
|
} else if (piece === 'bK') {
|
|
loser_king = { row, col };
|
|
}
|
|
} else {
|
|
if (piece === (winner === 'w' ? 'wK' : 'bK')) {
|
|
winner_king = { row, col };
|
|
} else if (piece === (winner === 'w' ? 'bK' : 'wK')) {
|
|
loser_king = { row, col };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (winner_king && loser_king) {
|
|
const next_markers = {
|
|
type: winner === 'draw' ? 'draw' : 'winlose',
|
|
winner: winner_king,
|
|
loser: loser_king
|
|
};
|
|
|
|
result_markers = next_markers;
|
|
if (!isSameResultMarkers(previous_markers, next_markers)) {
|
|
result_animation = {
|
|
start_time: performance.now(),
|
|
duration: result_overlay_duration_ms
|
|
};
|
|
animateResultOverlay();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function copyTextToClipboard(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
const helper = document.createElement('textarea');
|
|
helper.value = text;
|
|
helper.setAttribute('readonly', '');
|
|
helper.style.position = 'fixed';
|
|
helper.style.top = '-1000px';
|
|
helper.style.left = '-1000px';
|
|
document.body.appendChild(helper);
|
|
helper.focus();
|
|
helper.select();
|
|
const success = document.execCommand('copy');
|
|
document.body.removeChild(helper);
|
|
if (!success) {
|
|
throw new Error('copy-failed');
|
|
}
|
|
}
|
|
|
|
function getCurrentGame() {
|
|
return parsed_games[current_game_index] || null;
|
|
}
|
|
|
|
function normalizePlayerName(value) {
|
|
return (value || '').toString().trim().toLowerCase();
|
|
}
|
|
|
|
function playerNameMatches(side_name, variants) {
|
|
const side = normalizePlayerName(side_name);
|
|
if (!side) {
|
|
return false;
|
|
}
|
|
|
|
return variants.some((name) => {
|
|
const candidate = normalizePlayerName(name);
|
|
if (!candidate) {
|
|
return false;
|
|
}
|
|
return side === candidate || side.includes(candidate) || candidate.includes(side);
|
|
});
|
|
}
|
|
|
|
function detectMyColor(game) {
|
|
if (!game || !nochess_profile || !game.headers) {
|
|
return null;
|
|
}
|
|
|
|
const names = [
|
|
nochess_profile.name,
|
|
nochess_profile.username,
|
|
nochess_profile.first_name,
|
|
nochess_profile.last_name
|
|
];
|
|
|
|
const white_match = playerNameMatches(game.headers.White, names);
|
|
const black_match = playerNameMatches(game.headers.Black, names);
|
|
|
|
if (white_match && !black_match) {
|
|
return 'w';
|
|
}
|
|
if (black_match && !white_match) {
|
|
return 'b';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function applyPlayerAvatars() {
|
|
if (!avatar_white || !avatar_black || !nochess_profile) {
|
|
return;
|
|
}
|
|
|
|
const me_photo = nochess_profile.photo || '';
|
|
const enemy_photo = nochess_profile.enemy_photo || '';
|
|
if (!me_photo && !enemy_photo) {
|
|
return;
|
|
}
|
|
|
|
const game = getCurrentGame();
|
|
const my_color = detectMyColor(game) || 'w';
|
|
const white_photo = my_color === 'b' ? enemy_photo : me_photo;
|
|
const black_photo = my_color === 'b' ? me_photo : enemy_photo;
|
|
|
|
if (white_photo) {
|
|
avatar_white.style.backgroundImage = `url('${white_photo}')`;
|
|
}
|
|
if (black_photo) {
|
|
avatar_black.style.backgroundImage = `url('${black_photo}')`;
|
|
}
|
|
}
|
|
|
|
function setNoChessProfile(profile) {
|
|
nochess_profile = profile || null;
|
|
applyPlayerAvatars();
|
|
}
|
|
|
|
window.setNoChessProfile = setNoChessProfile;
|
|
|
|
function getTimerTextForColor(color) {
|
|
const game = getCurrentGame();
|
|
if (!game) {
|
|
return '--:--';
|
|
}
|
|
|
|
let latest = null;
|
|
for (let i = 0; i < current_ply; i += 1) {
|
|
const move = game.moves[i];
|
|
if (move.color === color && move.clock) {
|
|
latest = move.clock;
|
|
}
|
|
}
|
|
return latest || '--:--';
|
|
}
|
|
|
|
function updateTimers() {
|
|
timer_white.textContent = getTimerTextForColor('w');
|
|
timer_black.textContent = getTimerTextForColor('b');
|
|
}
|
|
|
|
function updatePlayers() {
|
|
const game = getCurrentGame();
|
|
if (!game) {
|
|
applyPlayerAvatars();
|
|
return;
|
|
}
|
|
player_name_white.textContent = game.headers.White || 'White';
|
|
player_name_black.textContent = game.headers.Black || 'Black';
|
|
applyPlayerAvatars();
|
|
}
|
|
|
|
function renderMoves() {
|
|
const game = getCurrentGame();
|
|
moves_scroll.innerHTML = '';
|
|
|
|
if (!game) {
|
|
updateScrollShadow();
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < game.moves.length; i += 2) {
|
|
const move_number = Math.floor(i / 2) + 1;
|
|
const white = game.moves[i] || null;
|
|
const black = game.moves[i + 1] || null;
|
|
const row = document.createElement('div');
|
|
row.className = 'move_row';
|
|
|
|
const number = document.createElement('div');
|
|
number.className = 'move_number';
|
|
number.textContent = `${move_number}.`;
|
|
|
|
const white_cell = document.createElement('div');
|
|
white_cell.className = 'move_white';
|
|
white_cell.textContent = white ? white.san : '';
|
|
if (white) {
|
|
white_cell.dataset.ply = String(i + 1);
|
|
}
|
|
|
|
const black_cell = document.createElement('div');
|
|
black_cell.className = 'move_black';
|
|
black_cell.textContent = black ? black.san : '';
|
|
if (black) {
|
|
black_cell.dataset.ply = String(i + 2);
|
|
}
|
|
|
|
row.append(number, white_cell, black_cell);
|
|
moves_scroll.appendChild(row);
|
|
}
|
|
|
|
moves_scroll.querySelectorAll('.move_white[data-ply], .move_black[data-ply]').forEach((cell) => {
|
|
cell.addEventListener('click', () => {
|
|
const next_ply = Number(cell.dataset.ply || '0');
|
|
if (next_ply > 0) {
|
|
setPly(next_ply, true);
|
|
}
|
|
});
|
|
});
|
|
|
|
updateCurrentMoveHighlight();
|
|
updateScrollShadow();
|
|
}
|
|
|
|
function updateCurrentMoveHighlight() {
|
|
moves_scroll.querySelectorAll('.current_move').forEach((cell) => {
|
|
cell.classList.remove('current_move');
|
|
});
|
|
|
|
if (current_ply <= 0) {
|
|
return;
|
|
}
|
|
|
|
const active = moves_scroll.querySelector(`[data-ply="${current_ply}"]`);
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
active.classList.add('current_move');
|
|
active.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
function playSound(name) {
|
|
const player = sound_players[name];
|
|
if (player) {
|
|
player();
|
|
}
|
|
}
|
|
|
|
function playMoveSound(move) {
|
|
if (!move) {
|
|
return;
|
|
}
|
|
|
|
if (move.is_mate) {
|
|
playSound('move_check');
|
|
setTimeout(() => playSound('game_end'), 70);
|
|
return;
|
|
}
|
|
|
|
if (move.is_promotion) {
|
|
playSound('promote');
|
|
return;
|
|
}
|
|
|
|
if (move.is_castle) {
|
|
playSound('castle');
|
|
return;
|
|
}
|
|
|
|
if (move.is_capture) {
|
|
playSound('capture');
|
|
return;
|
|
}
|
|
|
|
if (move.is_check) {
|
|
playSound('move_check');
|
|
return;
|
|
}
|
|
|
|
playSound(move.color === 'w' ? 'move_self' : 'move_opponent');
|
|
}
|
|
|
|
function setPly(next_ply, with_sound) {
|
|
const game = getCurrentGame();
|
|
if (!game) {
|
|
return;
|
|
}
|
|
|
|
const previous_ply = current_ply;
|
|
const previous_board = board.map((row) => [...row]);
|
|
const clamped = Math.max(0, Math.min(next_ply, game.moves.length));
|
|
const delta = clamped - previous_ply;
|
|
const moved_forward_by_one = delta === 1;
|
|
current_ply = clamped;
|
|
|
|
if (current_ply > 0) {
|
|
const move = game.moves[current_ply - 1];
|
|
scheduleHighlightedMove(move.from, move.to);
|
|
} else {
|
|
scheduleHighlightedMove(null, null);
|
|
}
|
|
|
|
board = game.positions[current_ply].map((row) => [...row]);
|
|
|
|
if (Math.abs(delta) === 1) {
|
|
const move_index = delta > 0 ? current_ply - 1 : previous_ply - 1;
|
|
const move = game.moves[move_index];
|
|
if (move) {
|
|
const from_square = delta > 0 ? move.from : move.to;
|
|
const to_square = delta > 0 ? move.to : move.from;
|
|
const from = squareToCoords(from_square);
|
|
const to = squareToCoords(to_square);
|
|
const piece_code = from ? previous_board[from.row][from.col] : null;
|
|
if (from && to && piece_code) {
|
|
animateMoveTransition(createAnimationState(from, to, piece_code));
|
|
} else {
|
|
render();
|
|
}
|
|
} else {
|
|
render();
|
|
}
|
|
} else {
|
|
piece_animation = null;
|
|
render();
|
|
}
|
|
|
|
updateCurrentMoveHighlight();
|
|
updateTimers();
|
|
updateResultVisuals();
|
|
|
|
if (delta !== 0) {
|
|
marked_cells.clear();
|
|
arrows = [];
|
|
}
|
|
|
|
if (!with_sound || delta === 0) {
|
|
return;
|
|
}
|
|
|
|
if (moved_forward_by_one && current_ply > 0) {
|
|
playMoveSound(game.moves[current_ply - 1]);
|
|
return;
|
|
}
|
|
|
|
playSound('premove');
|
|
}
|
|
|
|
function loadGame(index) {
|
|
if (index < 0 || index >= parsed_games.length) {
|
|
return;
|
|
}
|
|
current_game_index = index;
|
|
current_ply = 0;
|
|
marked_cells.clear();
|
|
arrows = [];
|
|
updatePlayers();
|
|
renderMoves();
|
|
setPly(0, false);
|
|
share_pgn_text.value = parsed_games[current_game_index].pgn_text;
|
|
playSound('game_start');
|
|
}
|
|
|
|
function buildHistoryList() {
|
|
history_games.innerHTML = '';
|
|
|
|
if (parsed_games.length === 0) {
|
|
history_games.classList.add('empty');
|
|
const empty = document.createElement('div');
|
|
empty.className = 'history_empty';
|
|
empty.textContent = ui_text.no_history;
|
|
history_games.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
history_games.classList.remove('empty');
|
|
parsed_games.forEach((game, index) => {
|
|
const button = document.createElement('button');
|
|
button.className = 'history_game_btn';
|
|
button.type = 'button';
|
|
const white = game.headers.White || 'White';
|
|
const black = game.headers.Black || 'Black';
|
|
const date = game.headers.Date || ui_text.unknown_date;
|
|
const result = game.headers.Result || '*';
|
|
button.textContent = `${white} vs ${black} • ${result} • ${date}`;
|
|
button.addEventListener('click', () => {
|
|
closePanels();
|
|
loadGame(index);
|
|
});
|
|
history_games.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function openPanel(panel) {
|
|
submenu_overlay.classList.add('open');
|
|
history_panel.classList.remove('open');
|
|
share_panel.classList.remove('open');
|
|
panel.classList.add('open');
|
|
}
|
|
|
|
function closePanels() {
|
|
submenu_overlay.classList.remove('open');
|
|
history_panel.classList.remove('open');
|
|
share_panel.classList.remove('open');
|
|
}
|
|
|
|
function loadPieceImages(on_ready) {
|
|
let loaded = 0;
|
|
const total = Object.keys(chess_pieces).length;
|
|
|
|
for (const [piece, src] of Object.entries(chess_pieces)) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
piece_images[piece] = img;
|
|
loaded += 1;
|
|
if (loaded === total) {
|
|
on_ready();
|
|
}
|
|
};
|
|
img.onerror = () => {
|
|
loaded += 1;
|
|
if (loaded === total) {
|
|
on_ready();
|
|
}
|
|
};
|
|
img.src = src;
|
|
}
|
|
}
|
|
|
|
function loadNoise(on_ready) {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
noise_image = img;
|
|
on_ready();
|
|
};
|
|
img.onerror = () => on_ready();
|
|
img.src = noise_src;
|
|
}
|
|
|
|
function toVisual(row, col) {
|
|
if (is_flipped) {
|
|
return { vrow: board_size - 1 - row, vcol: board_size - 1 - col };
|
|
}
|
|
return { vrow: row, vcol: col };
|
|
}
|
|
|
|
function fromVisual(vrow, vcol) {
|
|
if (is_flipped) {
|
|
return { row: board_size - 1 - vrow, col: board_size - 1 - vcol };
|
|
}
|
|
return { row: vrow, col: vcol };
|
|
}
|
|
|
|
function drawBoard() {
|
|
for (let vrow = 0; vrow < board_size; vrow += 1) {
|
|
for (let vcol = 0; vcol < board_size; vcol += 1) {
|
|
const { row, col } = fromVisual(vrow, vcol);
|
|
const is_light = (row + col) % 2 === 0;
|
|
const is_marked = marked_cells.has(`${row},${col}`);
|
|
const is_move_from = highlighted_move_cells
|
|
&& highlighted_move_cells.from.row === row
|
|
&& highlighted_move_cells.from.col === col;
|
|
const is_move_to = highlighted_move_cells
|
|
&& highlighted_move_cells.to.row === row
|
|
&& highlighted_move_cells.to.col === col;
|
|
|
|
if (is_move_to) {
|
|
ctx.fillStyle = hexToRgba(move_pieces_color, block_move_to_alpha);
|
|
} else if (is_move_from) {
|
|
ctx.fillStyle = hexToRgba(move_pieces_color, block_move_from_alpha);
|
|
} else if (is_marked) {
|
|
const alpha = is_light ? block_select_alpha_light : block_select_alpha_dark;
|
|
ctx.fillStyle = hexToRgba(select_block, alpha);
|
|
} else {
|
|
ctx.fillStyle = is_light ? block_light : block_dark;
|
|
}
|
|
ctx.fillRect(vcol * block_size, vrow * block_size, block_size, block_size);
|
|
}
|
|
}
|
|
|
|
if (noise_image) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'soft-light';
|
|
ctx.drawImage(noise_image, 0, 0, canvas.width, canvas.height);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function drawResultOverlays() {
|
|
if (!result_markers) {
|
|
return;
|
|
}
|
|
|
|
const progress = getResultOverlayProgress();
|
|
const winner = toVisual(result_markers.winner.row, result_markers.winner.col);
|
|
const loser = toVisual(result_markers.loser.row, result_markers.loser.col);
|
|
|
|
if (result_markers.type === 'draw') {
|
|
ctx.fillStyle = hexToRgba(result_draw, result_overlay_alpha * progress);
|
|
ctx.fillRect(winner.vcol * block_size, winner.vrow * block_size, block_size, block_size);
|
|
ctx.fillRect(loser.vcol * block_size, loser.vrow * block_size, block_size, block_size);
|
|
return;
|
|
}
|
|
|
|
ctx.fillStyle = hexToRgba(result_win, result_overlay_alpha * progress);
|
|
ctx.fillRect(winner.vcol * block_size, winner.vrow * block_size, block_size, block_size);
|
|
|
|
ctx.fillStyle = hexToRgba(result_lose, result_overlay_alpha * progress);
|
|
ctx.fillRect(loser.vcol * block_size, loser.vrow * block_size, block_size, block_size);
|
|
}
|
|
|
|
function drawResultIcons() {
|
|
if (!result_markers) {
|
|
return;
|
|
}
|
|
|
|
const progress = getResultOverlayProgress();
|
|
const scale = getResultIconScale(progress);
|
|
|
|
const draw_icon = (cell, icon) => {
|
|
if (!cell || !icon || !icon.complete) {
|
|
return;
|
|
}
|
|
const { vrow, vcol } = toVisual(cell.row, cell.col);
|
|
const max_size = 62;
|
|
const icon_ratio = (icon.naturalWidth || 61) / (icon.naturalHeight || 54);
|
|
const base_w = icon_ratio >= 1 ? max_size : max_size * icon_ratio;
|
|
const base_h = icon_ratio >= 1 ? max_size / icon_ratio : max_size;
|
|
const icon_w = base_w * scale;
|
|
const icon_h = base_h * scale;
|
|
const center_x = vcol * block_size + (block_size / 2);
|
|
const center_y = vrow * block_size + (block_size / 2);
|
|
const x = center_x - (icon_w / 2);
|
|
const y = center_y - (icon_h / 2);
|
|
ctx.save();
|
|
ctx.globalAlpha = Math.min(1, progress * 1.15);
|
|
ctx.drawImage(icon, x, y, icon_w, icon_h);
|
|
ctx.restore();
|
|
};
|
|
|
|
if (result_markers.type === 'draw') {
|
|
draw_icon(result_markers.winner, draw_icon_image);
|
|
draw_icon(result_markers.loser, draw_icon_image);
|
|
return;
|
|
}
|
|
|
|
draw_icon(result_markers.winner, crown_icon_image);
|
|
draw_icon(result_markers.loser, flag_icon_image);
|
|
}
|
|
|
|
function drawPiece(img, vcol, vrow) {
|
|
const max_size = block_size * 0.82;
|
|
const ratio = img.naturalWidth / img.naturalHeight;
|
|
const draw_w = ratio >= 1 ? max_size : max_size * ratio;
|
|
const draw_h = ratio >= 1 ? max_size / ratio : max_size;
|
|
const x = vcol * block_size + (block_size - draw_w) / 2;
|
|
const y = vrow * block_size + (block_size - draw_h) / 2;
|
|
ctx.drawImage(img, x, y, draw_w, draw_h);
|
|
}
|
|
|
|
function drawPieces() {
|
|
const hide_row = piece_animation ? piece_animation.to.row : -1;
|
|
const hide_col = piece_animation ? piece_animation.to.col : -1;
|
|
|
|
for (let row = 0; row < board_size; row += 1) {
|
|
for (let col = 0; col < board_size; col += 1) {
|
|
if (row === hide_row && col === hide_col) {
|
|
continue;
|
|
}
|
|
|
|
const piece = board[row][col];
|
|
if (!piece || !piece_images[piece]) {
|
|
continue;
|
|
}
|
|
const { vrow, vcol } = toVisual(row, col);
|
|
drawPiece(piece_images[piece], vcol, vrow);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawAnimatedPiece() {
|
|
if (!piece_animation || !piece_images[piece_animation.piece_code]) {
|
|
return;
|
|
}
|
|
|
|
const progress = Math.min(1, (performance.now() - piece_animation.start_time) / piece_animation.duration);
|
|
const eased = 1 - ((1 - progress) * (1 - progress));
|
|
const row = piece_animation.from.row + ((piece_animation.to.row - piece_animation.from.row) * eased);
|
|
const col = piece_animation.from.col + ((piece_animation.to.col - piece_animation.from.col) * eased);
|
|
const visual = toVisual(row, col);
|
|
drawPiece(piece_images[piece_animation.piece_code], visual.vcol, visual.vrow);
|
|
}
|
|
|
|
function drawArrow(from_col, from_row, to_col, to_row) {
|
|
const from = toVisual(from_row, from_col);
|
|
const to = toVisual(to_row, to_col);
|
|
const from_x = from.vcol * block_size + block_size / 2;
|
|
const from_y = from.vrow * block_size + block_size / 2;
|
|
const to_x = to.vcol * block_size + block_size / 2;
|
|
const to_y = to.vrow * block_size + block_size / 2;
|
|
const angle = Math.atan2(to_y - from_y, to_x - from_x);
|
|
const head_size = 28;
|
|
const line_end_x = to_x - Math.cos(angle) * (head_size * 0.6);
|
|
const line_end_y = to_y - Math.sin(angle) * (head_size * 0.6);
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = arrow_color;
|
|
ctx.fillStyle = arrow_color;
|
|
ctx.lineWidth = 10;
|
|
ctx.lineCap = 'round';
|
|
ctx.globalAlpha = 0.85;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(from_x, from_y);
|
|
ctx.lineTo(line_end_x, line_end_y);
|
|
ctx.stroke();
|
|
|
|
ctx.translate(to_x, to_y);
|
|
ctx.rotate(angle);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(-head_size, -head_size / 2.2);
|
|
ctx.lineTo(-head_size, head_size / 2.2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
function isMobileLayout() {
|
|
return mobile_breakpoint.matches;
|
|
}
|
|
|
|
function setMobileMovesOpen(is_open) {
|
|
if (!app_layout) {
|
|
return;
|
|
}
|
|
app_layout.classList.toggle('mobile_moves_open', is_open);
|
|
}
|
|
|
|
function syncFirstMoveButtonIcon() {
|
|
if (!first_move_icon) {
|
|
return;
|
|
}
|
|
first_move_icon.src = isMobileLayout()
|
|
? `${asset_root}/icons/pgn_moves.svg`
|
|
: `${asset_root}/icons/first.png`;
|
|
}
|
|
|
|
function setMobileMenuOpen(is_open) {
|
|
if (!app_layout) {
|
|
return;
|
|
}
|
|
app_layout.classList.toggle('mobile_menu_open', is_open);
|
|
if (more_btn) {
|
|
more_btn.classList.toggle('active', is_open);
|
|
}
|
|
}
|
|
|
|
function syncMobileUiState() {
|
|
const is_mobile = isMobileLayout();
|
|
syncFirstMoveButtonIcon();
|
|
if (!is_mobile) {
|
|
setMobileMovesOpen(false);
|
|
setMobileMenuOpen(false);
|
|
was_mobile_layout = false;
|
|
return;
|
|
}
|
|
|
|
if (!was_mobile_layout) {
|
|
setMobileMovesOpen(true);
|
|
setMobileMenuOpen(false);
|
|
was_mobile_layout = true;
|
|
}
|
|
}
|
|
|
|
function drawArrows() {
|
|
for (const arrow of arrows) {
|
|
drawArrow(arrow.from_col, arrow.from_row, arrow.to_col, arrow.to_row);
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawBoard();
|
|
drawPieces();
|
|
drawAnimatedPiece();
|
|
drawResultOverlays();
|
|
drawResultIcons();
|
|
drawArrows();
|
|
}
|
|
|
|
function flipBoard() {
|
|
is_flipped = !is_flipped;
|
|
app_layout.classList.toggle('board_flipped', is_flipped);
|
|
applyPlayerAvatars();
|
|
render();
|
|
}
|
|
|
|
function getCellFromEvent(event) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scale_x = canvas.width / rect.width;
|
|
const scale_y = canvas.height / rect.height;
|
|
const canvas_x = (event.clientX - rect.left) * scale_x;
|
|
const canvas_y = (event.clientY - rect.top) * scale_y;
|
|
const vcol = Math.floor(canvas_x / block_size);
|
|
const vrow = Math.floor(canvas_y / block_size);
|
|
return fromVisual(vrow, vcol);
|
|
}
|
|
|
|
function setupNavigation() {
|
|
document.querySelector('.first_move_btn').addEventListener('click', () => {
|
|
if (isMobileLayout()) {
|
|
setMobileMovesOpen(!app_layout.classList.contains('mobile_moves_open'));
|
|
return;
|
|
}
|
|
setPly(0, true);
|
|
});
|
|
document.querySelector('.prev_move_btn').addEventListener('click', () => setPly(current_ply - 1, true));
|
|
document.querySelector('.next_move_btn').addEventListener('click', () => setPly(current_ply + 1, true));
|
|
document.querySelector('.last_move_btn').addEventListener('click', () => {
|
|
const game = getCurrentGame();
|
|
if (game) {
|
|
setPly(game.moves.length, true);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'ArrowLeft') {
|
|
setPly(current_ply - 1, true);
|
|
} else if (event.key === 'ArrowRight') {
|
|
setPly(current_ply + 1, true);
|
|
} else if (event.key === 'Home') {
|
|
setPly(0, true);
|
|
} else if (event.key === 'End') {
|
|
const game = getCurrentGame();
|
|
if (game) {
|
|
setPly(game.moves.length, true);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupMobileButtons() {
|
|
if (!more_btn) {
|
|
return;
|
|
}
|
|
|
|
more_btn.addEventListener('click', () => {
|
|
if (!isMobileLayout()) {
|
|
return;
|
|
}
|
|
setMobileMenuOpen(!app_layout.classList.contains('mobile_menu_open'));
|
|
});
|
|
|
|
const mobile_menu_buttons = [
|
|
document.getElementById('flip_board_btn'),
|
|
history_btn,
|
|
share_btn
|
|
];
|
|
|
|
for (const button of mobile_menu_buttons) {
|
|
if (!button) {
|
|
continue;
|
|
}
|
|
button.addEventListener('click', () => {
|
|
if (isMobileLayout()) {
|
|
setMobileMenuOpen(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (typeof mobile_breakpoint.addEventListener === 'function') {
|
|
mobile_breakpoint.addEventListener('change', syncMobileUiState);
|
|
} else {
|
|
mobile_breakpoint.addListener(syncMobileUiState);
|
|
}
|
|
|
|
window.addEventListener('resize', syncMobileUiState);
|
|
syncMobileUiState();
|
|
}
|
|
|
|
function requestFullscreenIfPossible() {
|
|
if (document.fullscreenElement) {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
const request = root.requestFullscreen
|
|
|| root.webkitRequestFullscreen
|
|
|| root.msRequestFullscreen;
|
|
|
|
if (typeof request === 'function') {
|
|
Promise.resolve(request.call(root)).catch(() => {});
|
|
}
|
|
}
|
|
|
|
function setupAutoFullscreen() {
|
|
requestFullscreenIfPossible();
|
|
|
|
const on_first_interaction = () => {
|
|
requestFullscreenIfPossible();
|
|
window.removeEventListener('pointerdown', on_first_interaction);
|
|
window.removeEventListener('touchstart', on_first_interaction);
|
|
window.removeEventListener('click', on_first_interaction);
|
|
};
|
|
|
|
window.addEventListener('pointerdown', on_first_interaction, { once: true });
|
|
window.addEventListener('touchstart', on_first_interaction, { once: true });
|
|
window.addEventListener('click', on_first_interaction, { once: true });
|
|
}
|
|
|
|
function setupPanels() {
|
|
history_btn.addEventListener('click', () => openPanel(history_panel));
|
|
share_btn.addEventListener('click', () => {
|
|
const game = getCurrentGame();
|
|
share_pgn_text.value = game ? game.pgn_text : '';
|
|
autoResizeShareTextarea();
|
|
setShareStatus('', false);
|
|
openPanel(share_panel);
|
|
});
|
|
submenu_overlay.addEventListener('click', closePanels);
|
|
|
|
load_pgn_btn.addEventListener('click', () => {
|
|
if (!chess_ready) {
|
|
setShareStatus(ui_text.chess_library_not_ready, true);
|
|
return;
|
|
}
|
|
|
|
const pgn_text = sanitizePgnInput(share_pgn_text.value);
|
|
if (!pgn_text) {
|
|
setShareStatus(ui_text.pgn_empty, true);
|
|
return;
|
|
}
|
|
|
|
const parsed_game = parsePgnToGameState(pgn_text);
|
|
if (!parsed_game) {
|
|
setShareStatus(ui_text.pgn_invalid, true);
|
|
return;
|
|
}
|
|
|
|
setShareStatus(ui_text.pgn_loaded, false);
|
|
share_pgn_text.value = pgn_text;
|
|
autoResizeShareTextarea();
|
|
parsed_games.unshift(parsed_game);
|
|
buildHistoryList();
|
|
closePanels();
|
|
loadGame(0);
|
|
});
|
|
|
|
share_pgn_text.addEventListener('input', autoResizeShareTextarea);
|
|
window.addEventListener('resize', autoResizeShareTextarea);
|
|
|
|
copy_pgn_btn.addEventListener('click', async () => {
|
|
try {
|
|
await copyTextToClipboard(share_pgn_text.value);
|
|
setShareStatus(ui_text.pgn_copied, false);
|
|
} catch (error) {
|
|
setShareStatus(ui_text.copy_failed, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
function initializeGames() {
|
|
parsed_games = [];
|
|
current_game_index = 0;
|
|
current_ply = 0;
|
|
highlighted_move_cells = null;
|
|
piece_animation = null;
|
|
clearResultVisuals();
|
|
buildHistoryList();
|
|
board = boardFromFen(start_fen);
|
|
moves_scroll.innerHTML = '';
|
|
render();
|
|
updateTimers();
|
|
app_layout.classList.remove('board_flipped');
|
|
applyPlayerAvatars();
|
|
}
|
|
|
|
document.getElementById('flip_board_btn').addEventListener('click', flipBoard);
|
|
moves_scroll.addEventListener('scroll', updateScrollShadow);
|
|
new MutationObserver(updateScrollShadow).observe(moves_scroll, { childList: true, subtree: true });
|
|
window.addEventListener('resize', updateScrollShadow);
|
|
|
|
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
|
|
canvas.addEventListener('mousedown', (event) => {
|
|
if (event.button !== 2) {
|
|
return;
|
|
}
|
|
right_drag_start = getCellFromEvent(event);
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', (event) => {
|
|
if (event.button !== 2 || !right_drag_start) {
|
|
return;
|
|
}
|
|
|
|
const { row, col } = getCellFromEvent(event);
|
|
|
|
if (row === right_drag_start.row && col === right_drag_start.col) {
|
|
const key = `${row},${col}`;
|
|
if (marked_cells.has(key)) {
|
|
marked_cells.delete(key);
|
|
} else {
|
|
marked_cells.add(key);
|
|
}
|
|
} else {
|
|
const existing = arrows.findIndex((arrow) => (
|
|
arrow.from_row === right_drag_start.row
|
|
&& arrow.from_col === right_drag_start.col
|
|
&& arrow.to_row === row
|
|
&& arrow.to_col === col
|
|
));
|
|
|
|
if (existing >= 0) {
|
|
arrows.splice(existing, 1);
|
|
} else {
|
|
arrows.push({
|
|
from_row: right_drag_start.row,
|
|
from_col: right_drag_start.col,
|
|
to_row: row,
|
|
to_col: col
|
|
});
|
|
}
|
|
}
|
|
|
|
right_drag_start = null;
|
|
render();
|
|
});
|
|
|
|
canvas.addEventListener('click', () => {
|
|
marked_cells.clear();
|
|
arrows = [];
|
|
render();
|
|
});
|
|
|
|
loadNoise(() => {
|
|
loadPieceImages(async () => {
|
|
chess_ready = await loadChessLibrary();
|
|
setupNavigation();
|
|
setupMobileButtons();
|
|
setupAutoFullscreen();
|
|
setupPanels();
|
|
initializeGames();
|
|
updateScrollShadow();
|
|
});
|
|
});
|