beeta/frontend/src/lib/components/ChessGameOverlay.svelte

928 lines
27 KiB
Svelte
Raw Normal View History

2026-01-05 22:54:27 -05:00
<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();
}
2026-01-05 22:54:27 -05:00
gamesOverlay.setMode('waiting');
gamesOverlay.updateState({
positionId: payload.positionId,
fen: payload.fen,
2026-01-05 22:54:27 -05:00
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>