920 lines
27 KiB
Svelte
920 lines
27 KiB
Svelte
|
|
<script>
|
|||
|
|
import { onMount, onDestroy } from 'svelte';
|
|||
|
|
import { browser } from '$app/environment';
|
|||
|
|
import { gamesOverlay, hasGame, currentGame, gameMode } from '$lib/stores/gamesOverlay';
|
|||
|
|
import { nakama, ChessOpCode } from '$lib/stores/nakama';
|
|||
|
|
import { auth } from '$lib/stores/auth';
|
|||
|
|
|
|||
|
|
// Chess state
|
|||
|
|
let game = null;
|
|||
|
|
let Chess = null;
|
|||
|
|
let boardSquares = [];
|
|||
|
|
let selectedSquare = null;
|
|||
|
|
let legalMoves = [];
|
|||
|
|
let moveHistory = [];
|
|||
|
|
|
|||
|
|
// Match state from overlay store
|
|||
|
|
let myColor = null;
|
|||
|
|
|
|||
|
|
// Drag state
|
|||
|
|
let isDragging = false;
|
|||
|
|
let dragOffset = { x: 0, y: 0 };
|
|||
|
|
let position = { x: null, y: null };
|
|||
|
|
let isResizing = false;
|
|||
|
|
let resizeDirection = '';
|
|||
|
|
let dimensions = { width: 450, height: 520 };
|
|||
|
|
let startDimensions = { width: 0, height: 0 };
|
|||
|
|
let startPos = { x: 0, y: 0 };
|
|||
|
|
|
|||
|
|
// Callbacks cleanup
|
|||
|
|
let unsubscribeMatch = null;
|
|||
|
|
let isDestroyed = false;
|
|||
|
|
|
|||
|
|
// Load/save position
|
|||
|
|
function loadPosition() {
|
|||
|
|
if (!browser) return;
|
|||
|
|
try {
|
|||
|
|
const saved = localStorage.getItem('chessGameOverlayPosition');
|
|||
|
|
if (saved) {
|
|||
|
|
const parsed = JSON.parse(saved);
|
|||
|
|
position = parsed.position || { x: null, y: null };
|
|||
|
|
dimensions = parsed.dimensions || { width: 450, height: 520 };
|
|||
|
|
}
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function savePosition() {
|
|||
|
|
if (!browser) return;
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem('chessGameOverlayPosition', JSON.stringify({
|
|||
|
|
position,
|
|||
|
|
dimensions
|
|||
|
|
}));
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Computed position (default to center-right if not set)
|
|||
|
|
$: computedPosition = {
|
|||
|
|
right: position.x === null ? '1rem' : 'auto',
|
|||
|
|
top: position.y === null ? '50%' : 'auto',
|
|||
|
|
left: position.x !== null ? `${position.x}px` : 'auto',
|
|||
|
|
bottom: position.y !== null ? `${position.y}px` : 'auto',
|
|||
|
|
transform: position.y === null ? 'translateY(-50%)' : 'none'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Calculate chess board cell size based on container dimensions
|
|||
|
|
// Account for header (~45px), footer (~45px), board border/padding (~20px)
|
|||
|
|
$: cellSize = Math.floor(Math.min(dimensions.width - 30, dimensions.height - 120) / 8);
|
|||
|
|
|
|||
|
|
async function initChess() {
|
|||
|
|
console.log('[ChessOverlay] initChess called, matchId:', $gamesOverlay.matchId);
|
|||
|
|
if (!browser) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const chessModule = await import('chess.js');
|
|||
|
|
Chess = chessModule.Chess;
|
|||
|
|
game = new Chess();
|
|||
|
|
|
|||
|
|
// Set up match event handler
|
|||
|
|
console.log('[ChessOverlay] Registering match event handler...');
|
|||
|
|
unsubscribeMatch = nakama.onMatchEvent(handleMatchEvent);
|
|||
|
|
console.log('[ChessOverlay] Handler registered');
|
|||
|
|
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[ChessOverlay] Failed to initialize chess:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleMatchEvent(type, data) {
|
|||
|
|
if (isDestroyed) return;
|
|||
|
|
|
|||
|
|
console.log('[Chess] handleMatchEvent:', type, 'op_code:', data?.op_code);
|
|||
|
|
|
|||
|
|
if (type === 'disconnect') {
|
|||
|
|
gamesOverlay.setMode('finished');
|
|||
|
|
gamesOverlay.updateState({ result: 'disconnect', reason: 'Disconnected from server' });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type !== 'matchdata') return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const payload = JSON.parse(new TextDecoder().decode(data.data));
|
|||
|
|
// Convert op_code to number for safe comparison (Nakama SDK uses snake_case)
|
|||
|
|
const opCode = Number(data.op_code);
|
|||
|
|
|
|||
|
|
console.log('[Chess] Received op_code:', opCode, 'payload:', payload);
|
|||
|
|
|
|||
|
|
if (opCode === ChessOpCode.GAME_STATE) {
|
|||
|
|
console.log('[Chess] Processing GAME_STATE with status:', payload.status);
|
|||
|
|
handleGameState(payload);
|
|||
|
|
} else if (opCode === ChessOpCode.MOVE) {
|
|||
|
|
handleOpponentMove(payload);
|
|||
|
|
} else if (opCode === ChessOpCode.GAME_OVER) {
|
|||
|
|
handleGameOver(payload);
|
|||
|
|
} else {
|
|||
|
|
console.log('[Chess] Unknown opCode:', opCode);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Error handling match event:', e, data);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleGameState(payload) {
|
|||
|
|
if (payload.status === 'waiting') {
|
|||
|
|
myColor = payload.yourColor;
|
|||
|
|
gamesOverlay.setMode('waiting');
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
positionId: payload.positionId,
|
|||
|
|
whiteName: payload.whiteName,
|
|||
|
|
blackName: payload.blackName
|
|||
|
|
});
|
|||
|
|
} else if (payload.status === 'playing') {
|
|||
|
|
const session = nakama.getSession();
|
|||
|
|
myColor = payload.whiteId === session?.user_id ? 'w' : 'b';
|
|||
|
|
|
|||
|
|
if (game) {
|
|||
|
|
game.load(payload.fen);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
gamesOverlay.setMode('playing');
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
positionId: payload.positionId,
|
|||
|
|
fen: payload.fen,
|
|||
|
|
turn: payload.turn || game?.turn(),
|
|||
|
|
whiteId: payload.whiteId,
|
|||
|
|
blackId: payload.blackId,
|
|||
|
|
whiteName: payload.whiteName,
|
|||
|
|
blackName: payload.blackName,
|
|||
|
|
myColor
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
} else if (payload.status === 'spectating') {
|
|||
|
|
myColor = null; // Spectator has no color
|
|||
|
|
|
|||
|
|
if (game) {
|
|||
|
|
game.load(payload.fen);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
gamesOverlay.setMode('spectating');
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
positionId: payload.positionId,
|
|||
|
|
fen: payload.fen,
|
|||
|
|
turn: payload.turn || game?.turn(),
|
|||
|
|
whiteId: payload.whiteId,
|
|||
|
|
blackId: payload.blackId,
|
|||
|
|
whiteName: payload.whiteName,
|
|||
|
|
blackName: payload.blackName
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleOpponentMove(payload) {
|
|||
|
|
if (game) {
|
|||
|
|
game.load(payload.fen);
|
|||
|
|
}
|
|||
|
|
moveHistory = [...moveHistory, payload.move];
|
|||
|
|
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
fen: payload.fen,
|
|||
|
|
turn: game?.turn()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleGameOver(payload) {
|
|||
|
|
gamesOverlay.setMode('finished');
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
result: payload.result,
|
|||
|
|
reason: payload.reason
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateBoardDisplay() {
|
|||
|
|
if (!game) return;
|
|||
|
|
|
|||
|
|
const board2d = [];
|
|||
|
|
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
|||
|
|
// Spectators see from white's perspective, players see from their own side
|
|||
|
|
const viewColor = myColor || 'w';
|
|||
|
|
const ranks = viewColor === 'b' ? ['1', '2', '3', '4', '5', '6', '7', '8'] : ['8', '7', '6', '5', '4', '3', '2', '1'];
|
|||
|
|
|
|||
|
|
for (const rank of ranks) {
|
|||
|
|
const row = [];
|
|||
|
|
for (const file of files) {
|
|||
|
|
const square = file + rank;
|
|||
|
|
const piece = game.get(square);
|
|||
|
|
row.push({
|
|||
|
|
square,
|
|||
|
|
piece: piece ? getPieceSymbol(piece) : null,
|
|||
|
|
color: piece?.color || null,
|
|||
|
|
isLight: (files.indexOf(file) + parseInt(rank)) % 2 === 1
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
board2d.push(row);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
boardSquares = board2d;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getPieceSymbol(piece) {
|
|||
|
|
const symbols = {
|
|||
|
|
'wk': '\u2654', 'wq': '\u2655', 'wr': '\u2656', 'wb': '\u2657', 'wn': '\u2658', 'wp': '\u2659',
|
|||
|
|
'bk': '\u265A', 'bq': '\u265B', 'br': '\u265C', 'bb': '\u265D', 'bn': '\u265E', 'bp': '\u265F'
|
|||
|
|
};
|
|||
|
|
return symbols[piece.color + piece.type] || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleSquareClick(square) {
|
|||
|
|
if ($gameMode !== 'playing') return;
|
|||
|
|
if (!game || game.turn() !== myColor) return;
|
|||
|
|
|
|||
|
|
const piece = game.get(square);
|
|||
|
|
|
|||
|
|
if (selectedSquare) {
|
|||
|
|
if (legalMoves.includes(square)) {
|
|||
|
|
makeMove(selectedSquare, square);
|
|||
|
|
}
|
|||
|
|
selectedSquare = null;
|
|||
|
|
legalMoves = [];
|
|||
|
|
} else if (piece && piece.color === myColor) {
|
|||
|
|
selectedSquare = square;
|
|||
|
|
const moves = game.moves({ square, verbose: true });
|
|||
|
|
legalMoves = moves.map(m => m.to);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function makeMove(from, to) {
|
|||
|
|
if (!game) return;
|
|||
|
|
|
|||
|
|
const piece = game.get(from);
|
|||
|
|
const isPromotion = piece.type === 'p' &&
|
|||
|
|
((piece.color === 'w' && to[1] === '8') || (piece.color === 'b' && to[1] === '1'));
|
|||
|
|
|
|||
|
|
const moveData = { from, to };
|
|||
|
|
if (isPromotion) {
|
|||
|
|
moveData.promotion = 'q';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = game.move(moveData);
|
|||
|
|
if (!result) {
|
|||
|
|
console.error('Invalid move');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await nakama.sendMatchData($gamesOverlay.matchId, ChessOpCode.MOVE, moveData);
|
|||
|
|
|
|||
|
|
moveHistory = [...moveHistory, result];
|
|||
|
|
gamesOverlay.updateState({
|
|||
|
|
fen: game.fen(),
|
|||
|
|
turn: game.turn()
|
|||
|
|
});
|
|||
|
|
updateBoardDisplay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function resign() {
|
|||
|
|
if ($gameMode !== 'playing') return;
|
|||
|
|
if (confirm('Are you sure you want to resign?')) {
|
|||
|
|
await nakama.sendMatchData($gamesOverlay.matchId, ChessOpCode.RESIGN, {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function copyInviteLink() {
|
|||
|
|
const url = `${window.location.origin}/games/chess?match=${$gamesOverlay.matchId}`;
|
|||
|
|
navigator.clipboard.writeText(url).then(() => {
|
|||
|
|
alert('Invite link copied!');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleClose() {
|
|||
|
|
if ($gamesOverlay.matchId) {
|
|||
|
|
nakama.leaveMatch($gamesOverlay.matchId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (unsubscribeMatch) {
|
|||
|
|
unsubscribeMatch();
|
|||
|
|
unsubscribeMatch = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
game = null;
|
|||
|
|
boardSquares = [];
|
|||
|
|
selectedSquare = null;
|
|||
|
|
legalMoves = [];
|
|||
|
|
moveHistory = [];
|
|||
|
|
myColor = null;
|
|||
|
|
|
|||
|
|
gamesOverlay.closeGame();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleMinimize() {
|
|||
|
|
gamesOverlay.toggleMinimized();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Drag handling
|
|||
|
|
function startDrag(e) {
|
|||
|
|
if (e.target.closest('button')) return;
|
|||
|
|
|
|||
|
|
isDragging = true;
|
|||
|
|
const rect = e.currentTarget.closest('.chess-overlay').getBoundingClientRect();
|
|||
|
|
dragOffset = {
|
|||
|
|
x: e.clientX - rect.left,
|
|||
|
|
y: e.clientY - rect.top
|
|||
|
|
};
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleMouseMove(e) {
|
|||
|
|
if (isDragging) {
|
|||
|
|
position = {
|
|||
|
|
x: e.clientX - dragOffset.x,
|
|||
|
|
y: window.innerHeight - (e.clientY - dragOffset.y) - dimensions.height
|
|||
|
|
};
|
|||
|
|
} else if (isResizing) {
|
|||
|
|
const dx = e.clientX - startPos.x;
|
|||
|
|
const dy = e.clientY - startPos.y;
|
|||
|
|
|
|||
|
|
let newWidth = startDimensions.width;
|
|||
|
|
let newHeight = startDimensions.height;
|
|||
|
|
|
|||
|
|
if (resizeDirection.includes('e')) {
|
|||
|
|
newWidth = Math.max(400, startDimensions.width + dx);
|
|||
|
|
}
|
|||
|
|
if (resizeDirection.includes('w')) {
|
|||
|
|
newWidth = Math.max(400, startDimensions.width - dx);
|
|||
|
|
}
|
|||
|
|
if (resizeDirection.includes('s')) {
|
|||
|
|
newHeight = Math.max(450, startDimensions.height + dy);
|
|||
|
|
}
|
|||
|
|
if (resizeDirection.includes('n')) {
|
|||
|
|
newHeight = Math.max(450, startDimensions.height - dy);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
dimensions = { width: newWidth, height: newHeight };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopDrag() {
|
|||
|
|
if (isDragging || isResizing) {
|
|||
|
|
savePosition();
|
|||
|
|
}
|
|||
|
|
isDragging = false;
|
|||
|
|
isResizing = false;
|
|||
|
|
resizeDirection = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startResize(e, direction) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
isResizing = true;
|
|||
|
|
resizeDirection = direction;
|
|||
|
|
startPos = { x: e.clientX, y: e.clientY };
|
|||
|
|
startDimensions = { ...dimensions };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Watch for overlay changes
|
|||
|
|
let currentMatchId = null;
|
|||
|
|
$: if (browser && $gamesOverlay.enabled && $gamesOverlay.matchId && $gamesOverlay.matchId !== currentMatchId) {
|
|||
|
|
console.log('[ChessOverlay] Reactive: matchId changed from', currentMatchId, 'to', $gamesOverlay.matchId);
|
|||
|
|
currentMatchId = $gamesOverlay.matchId;
|
|||
|
|
initChess();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cleanup when game is closed
|
|||
|
|
$: if (!$hasGame) {
|
|||
|
|
currentMatchId = null;
|
|||
|
|
if (unsubscribeMatch) {
|
|||
|
|
unsubscribeMatch();
|
|||
|
|
unsubscribeMatch = null;
|
|||
|
|
}
|
|||
|
|
game = null;
|
|||
|
|
boardSquares = [];
|
|||
|
|
moveHistory = [];
|
|||
|
|
myColor = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMount(() => {
|
|||
|
|
loadPosition();
|
|||
|
|
if (browser) {
|
|||
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|||
|
|
window.addEventListener('mouseup', stopDrag);
|
|||
|
|
|
|||
|
|
if ($gamesOverlay.enabled && $gamesOverlay.matchId) {
|
|||
|
|
currentMatchId = $gamesOverlay.matchId;
|
|||
|
|
initChess();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onDestroy(() => {
|
|||
|
|
isDestroyed = true;
|
|||
|
|
if (unsubscribeMatch) {
|
|||
|
|
unsubscribeMatch();
|
|||
|
|
}
|
|||
|
|
if (browser) {
|
|||
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|||
|
|
window.removeEventListener('mouseup', stopDrag);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Computed values for display
|
|||
|
|
$: statusMessage = (() => {
|
|||
|
|
switch ($gameMode) {
|
|||
|
|
case 'waiting': return 'Waiting for opponent...';
|
|||
|
|
case 'playing':
|
|||
|
|
if (!game) return 'Loading...';
|
|||
|
|
return game.turn() === myColor ? 'Your turn' : "Opponent's turn";
|
|||
|
|
case 'spectating':
|
|||
|
|
if (!$currentGame) return 'Spectating...';
|
|||
|
|
return `${$currentGame.turn === 'w' ? 'White' : 'Black'} to move`;
|
|||
|
|
case 'finished':
|
|||
|
|
return getResultText();
|
|||
|
|
default: return 'Connecting...';
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
function getResultText() {
|
|||
|
|
if (!$currentGame) return 'Game over';
|
|||
|
|
const result = $currentGame.result;
|
|||
|
|
const reason = $currentGame.reason || '';
|
|||
|
|
|
|||
|
|
if ($gameMode === 'spectating') {
|
|||
|
|
if (result === '1-0') return `White wins${reason ? ` (${reason})` : ''}`;
|
|||
|
|
if (result === '0-1') return `Black wins${reason ? ` (${reason})` : ''}`;
|
|||
|
|
return `Draw${reason ? ` (${reason})` : ''}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (result === '1-0') {
|
|||
|
|
return myColor === 'w' ? 'You win!' : 'You lose';
|
|||
|
|
} else if (result === '0-1') {
|
|||
|
|
return myColor === 'b' ? 'You win!' : 'You lose';
|
|||
|
|
} else if (result === 'timeout') {
|
|||
|
|
return reason || 'Match timed out';
|
|||
|
|
}
|
|||
|
|
return `Draw${reason ? ` (${reason})` : ''}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$: headerTitle = (() => {
|
|||
|
|
if (!$currentGame) return 'Chess960';
|
|||
|
|
const white = $currentGame.whiteName || 'Waiting...';
|
|||
|
|
const black = $currentGame.blackName || 'Waiting...';
|
|||
|
|
return `${white} vs ${black}`;
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
$: positionLabel = $currentGame?.positionId !== undefined
|
|||
|
|
? `#${$currentGame.positionId}`
|
|||
|
|
: '';
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
{#if $gamesOverlay.enabled && $gamesOverlay.matchId}
|
|||
|
|
<div
|
|||
|
|
class="chess-overlay"
|
|||
|
|
class:minimized={$gamesOverlay.minimized}
|
|||
|
|
style="
|
|||
|
|
{computedPosition.right !== 'auto' ? `right: ${computedPosition.right};` : ''}
|
|||
|
|
{computedPosition.top !== 'auto' ? `top: ${computedPosition.top};` : ''}
|
|||
|
|
{computedPosition.left !== 'auto' ? `left: ${computedPosition.left};` : ''}
|
|||
|
|
{computedPosition.bottom !== 'auto' ? `bottom: ${computedPosition.bottom};` : ''}
|
|||
|
|
{computedPosition.transform !== 'none' ? `transform: ${computedPosition.transform};` : ''}
|
|||
|
|
width: {dimensions.width}px;
|
|||
|
|
{!$gamesOverlay.minimized ? `height: ${dimensions.height}px;` : ''}
|
|||
|
|
"
|
|||
|
|
>
|
|||
|
|
<div class="overlay-header" on:mousedown={startDrag}>
|
|||
|
|
<div class="header-left">
|
|||
|
|
<span class="header-icon">♛</span>
|
|||
|
|
<div class="header-info">
|
|||
|
|
<span class="header-title">{headerTitle}</span>
|
|||
|
|
{#if positionLabel}
|
|||
|
|
<span class="header-meta">Position {positionLabel}</span>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-controls">
|
|||
|
|
{#if $gameMode === 'spectating'}
|
|||
|
|
<span class="mode-badge spectator">Spectating</span>
|
|||
|
|
{/if}
|
|||
|
|
<button class="control-btn" on:click={handleMinimize} title={$gamesOverlay.minimized ? 'Expand' : 'Minimize'}>
|
|||
|
|
{$gamesOverlay.minimized ? '□' : '−'}
|
|||
|
|
</button>
|
|||
|
|
<button class="control-btn close" on:click={handleClose} title="Close">×</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{#if !$gamesOverlay.minimized}
|
|||
|
|
<div class="overlay-body">
|
|||
|
|
<!-- Status Bar -->
|
|||
|
|
<div class="status-bar" class:your-turn={$gameMode === 'playing' && game?.turn() === myColor}>
|
|||
|
|
{#if $gameMode === 'playing' && myColor}
|
|||
|
|
<span class="color-indicator" class:white={myColor === 'w'} class:black={myColor === 'b'}>
|
|||
|
|
{myColor === 'w' ? 'White' : 'Black'}
|
|||
|
|
</span>
|
|||
|
|
{/if}
|
|||
|
|
<span class="status-text">{statusMessage}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Chess Board -->
|
|||
|
|
<div class="board-wrapper">
|
|||
|
|
<div class="chess-board">
|
|||
|
|
{#each boardSquares as row}
|
|||
|
|
<div class="board-row">
|
|||
|
|
{#each row as cell}
|
|||
|
|
<div
|
|||
|
|
class="board-cell"
|
|||
|
|
class:light={cell.isLight}
|
|||
|
|
class:dark={!cell.isLight}
|
|||
|
|
class:selected={selectedSquare === cell.square}
|
|||
|
|
class:legal-move={legalMoves.includes(cell.square)}
|
|||
|
|
class:clickable={$gameMode === 'playing'}
|
|||
|
|
style="width: {cellSize}px; height: {cellSize}px;"
|
|||
|
|
on:click={() => handleSquareClick(cell.square)}
|
|||
|
|
>
|
|||
|
|
{#if cell.piece}
|
|||
|
|
<span class="piece" style="font-size: {cellSize * 0.7}px;" class:white-piece={cell.color === 'w'} class:black-piece={cell.color === 'b'}>
|
|||
|
|
{cell.piece}
|
|||
|
|
</span>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
{/each}
|
|||
|
|
</div>
|
|||
|
|
{/each}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="overlay-footer">
|
|||
|
|
{#if $gameMode === 'waiting'}
|
|||
|
|
<button class="action-btn" on:click={copyInviteLink}>Copy Invite Link</button>
|
|||
|
|
{:else if $gameMode === 'playing'}
|
|||
|
|
<button class="action-btn danger" on:click={resign}>Resign</button>
|
|||
|
|
{:else if $gameMode === 'finished'}
|
|||
|
|
<button class="action-btn" on:click={handleClose}>Close</button>
|
|||
|
|
{/if}
|
|||
|
|
|
|||
|
|
<div class="move-count">
|
|||
|
|
{moveHistory.length} moves
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Resize handles -->
|
|||
|
|
<div class="resize-handle resize-e" on:mousedown={(e) => startResize(e, 'e')}></div>
|
|||
|
|
<div class="resize-handle resize-s" on:mousedown={(e) => startResize(e, 's')}></div>
|
|||
|
|
<div class="resize-handle resize-se" on:mousedown={(e) => startResize(e, 'se')}></div>
|
|||
|
|
<div class="resize-handle resize-w" on:mousedown={(e) => startResize(e, 'w')}></div>
|
|||
|
|
<div class="resize-handle resize-sw" on:mousedown={(e) => startResize(e, 'sw')}></div>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
{/if}
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.chess-overlay {
|
|||
|
|
position: fixed;
|
|||
|
|
background: #161b22;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|||
|
|
z-index: 9998;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|||
|
|
min-width: 400px;
|
|||
|
|
min-height: 450px;
|
|||
|
|
max-width: calc(100vw - 2rem);
|
|||
|
|
max-height: calc(100vh - 2rem);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chess-overlay.minimized {
|
|||
|
|
height: auto !important;
|
|||
|
|
min-height: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.overlay-header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0.5rem 0.75rem;
|
|||
|
|
background: #0d1117;
|
|||
|
|
border-bottom: 1px solid #30363d;
|
|||
|
|
cursor: move;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-left {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
flex: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-icon {
|
|||
|
|
font-size: 1.2rem;
|
|||
|
|
color: #f59e0b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-info {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.1rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-title {
|
|||
|
|
color: #c9d1d9;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-meta {
|
|||
|
|
color: #6e7681;
|
|||
|
|
font-size: 0.65rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-controls {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-badge {
|
|||
|
|
font-size: 0.6rem;
|
|||
|
|
padding: 0.15rem 0.4rem;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.mode-badge.spectator {
|
|||
|
|
background: rgba(139, 92, 246, 0.2);
|
|||
|
|
color: #a78bfa;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn {
|
|||
|
|
width: 22px;
|
|||
|
|
height: 22px;
|
|||
|
|
border: none;
|
|||
|
|
background: none;
|
|||
|
|
color: #8b949e;
|
|||
|
|
font-size: 1rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn:hover {
|
|||
|
|
background: rgba(255, 255, 255, 0.1);
|
|||
|
|
color: #c9d1d9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.control-btn.close:hover {
|
|||
|
|
color: #f85149;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.overlay-body {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
padding: 0.5rem;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-bar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
padding: 0.4rem 0.6rem;
|
|||
|
|
background: #1a1a2e;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-bar.your-turn {
|
|||
|
|
background: #2a4a2a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.color-indicator {
|
|||
|
|
font-weight: bold;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
padding: 0.15rem 0.4rem;
|
|||
|
|
border-radius: 3px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.color-indicator.white {
|
|||
|
|
background: #fff;
|
|||
|
|
color: #000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.color-indicator.black {
|
|||
|
|
background: #333;
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-text {
|
|||
|
|
color: #ccc;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-wrapper {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chess-board {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
border: 3px solid #333;
|
|||
|
|
background: #333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-row {
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell {
|
|||
|
|
width: 42px;
|
|||
|
|
height: 42px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.clickable {
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.light {
|
|||
|
|
background: #f0d9b5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.dark {
|
|||
|
|
background: #b58863;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.selected {
|
|||
|
|
background: #7fc97f !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.legal-move::after {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
width: 30%;
|
|||
|
|
height: 30%;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
background: rgba(0, 0, 0, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell.legal-move:hover {
|
|||
|
|
background: #aad4aa !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.piece {
|
|||
|
|
font-size: 2rem;
|
|||
|
|
line-height: 1;
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.white-piece {
|
|||
|
|
color: #fff;
|
|||
|
|
text-shadow: 0 0 2px #000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.black-piece {
|
|||
|
|
color: #000;
|
|||
|
|
text-shadow: 0 0 2px #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.overlay-footer {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0.5rem 0.75rem;
|
|||
|
|
background: #0d1117;
|
|||
|
|
border-top: 1px solid #30363d;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn {
|
|||
|
|
padding: 0.4rem 0.8rem;
|
|||
|
|
border: 1px solid #30363d;
|
|||
|
|
background: #161b22;
|
|||
|
|
color: #c9d1d9;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
transition: all 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn:hover {
|
|||
|
|
background: #21262d;
|
|||
|
|
border-color: #484f58;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn.danger {
|
|||
|
|
border-color: rgba(248, 81, 73, 0.4);
|
|||
|
|
color: #f85149;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn.danger:hover {
|
|||
|
|
background: rgba(248, 81, 73, 0.15);
|
|||
|
|
border-color: #f85149;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.move-count {
|
|||
|
|
color: #6e7681;
|
|||
|
|
font-size: 0.65rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Resize handles */
|
|||
|
|
.resize-handle {
|
|||
|
|
position: absolute;
|
|||
|
|
background: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-e {
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
width: 6px;
|
|||
|
|
height: 100%;
|
|||
|
|
cursor: ew-resize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-w {
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 6px;
|
|||
|
|
height: 100%;
|
|||
|
|
cursor: ew-resize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-s {
|
|||
|
|
bottom: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 6px;
|
|||
|
|
cursor: ns-resize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-se {
|
|||
|
|
bottom: 0;
|
|||
|
|
right: 0;
|
|||
|
|
width: 12px;
|
|||
|
|
height: 12px;
|
|||
|
|
cursor: se-resize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-sw {
|
|||
|
|
bottom: 0;
|
|||
|
|
left: 0;
|
|||
|
|
width: 12px;
|
|||
|
|
height: 12px;
|
|||
|
|
cursor: sw-resize;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-handle:hover {
|
|||
|
|
background: rgba(245, 158, 11, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 500px) {
|
|||
|
|
.chess-overlay {
|
|||
|
|
right: 0.5rem !important;
|
|||
|
|
left: 0.5rem !important;
|
|||
|
|
top: auto !important;
|
|||
|
|
bottom: 0.5rem !important;
|
|||
|
|
width: auto !important;
|
|||
|
|
transform: none !important;
|
|||
|
|
max-width: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.board-cell {
|
|||
|
|
width: 36px;
|
|||
|
|
height: 36px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.piece {
|
|||
|
|
font-size: 1.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resize-handle {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|