beeta/frontend/src/lib/components/ChessGameOverlay.svelte
doomtube f53d8f040b
All checks were successful
Build and Push / build-all (push) Successful in 1m32s
Fix: Load Chess960 FEN in waiting state to show correct piece placement
2026-01-08 03:15:03 -05:00

927 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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;
// Load the Chess960 starting position
if (game && payload.fen) {
game.load(payload.fen);
updateBoardDisplay();
}
gamesOverlay.setMode('waiting');
gamesOverlay.updateState({
positionId: payload.positionId,
fen: payload.fen,
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>