Files
limoka/SunnexGB/Heroku-Modules/Assets/NoChess/raw_assets/javascript.js
2026-06-12 08:36:40 +00:00

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();
});
});