diff --git a/packages/chess/e2e/multiplayer.spec.ts b/packages/chess/e2e/multiplayer.spec.ts index 55a1702..673ac40 100644 --- a/packages/chess/e2e/multiplayer.spec.ts +++ b/packages/chess/e2e/multiplayer.spec.ts @@ -270,8 +270,11 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", await drag(pageB, 'b8', 'c6'); // 6. black Nc6 await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White'); - await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText('White'); + // After 6 half-moves the turn returns to white. In multiplayer the + // indicator is personalized: the white player (pageA) sees "Your + // turn"; the black player (pageB) sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn"); + await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 4: ctx B disconnects (simulates network drop) ─────────────────── await pageB.close(); @@ -279,7 +282,8 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate", // ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ───────────── await drag(pageA, 'd1', 'h5'); // 7. white Qh5 await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible(); - await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('Black'); + // After Qh5 it's black to move. PageA is white → sees "Opponent's turn". + await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn"); // ── Step 6: ctx B reconnects ───────────────────────────────────────────── // A new page in the SAME browser context inherits sessionStorage diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 30613d5..9913b70 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -61,7 +61,12 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec // Drag state const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null); - + // The square the cursor is currently hovering during a drag. Distinct + // from `draggedPiece.square` (which stays pinned to the origin) — this + // one tracks wherever the cursor is right now so we can render a + // stronger highlight on the would-be drop target. + const [hoverSquare, setHoverSquare] = useState(null); + // Promotion picker state const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null); @@ -91,13 +96,34 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec const handleDragEnd = () => { setDraggedPiece(null); + setHoverSquare(null); }; const handleDragOver = (e: React.DragEvent, square: number) => { if (!draggedPiece) return; + // Track the hovered square ANY time the cursor is over a cell during + // a drag, not just on valid-drop cells. We still gate preventDefault + // on `highlightedSquares.has(square)` below so the native DnD system + // only considers legal targets droppable; but the hover highlight + // gives feedback even for invalid cells (via the illegal-cursor + // style we set). + if (hoverSquare !== square) setHoverSquare(square); if (highlightedSquares.has(square)) { e.preventDefault(); // allow drop e.dataTransfer.dropEffect = 'move'; + } else { + // Legal-move set doesn't include this square → show the "no-drop" + // cursor so the user knows releasing here will snap back. + e.dataTransfer.dropEffect = 'none'; + } + }; + + const handleDragLeave = (e: React.DragEvent, square: number) => { + // Only clear the hover if we're actually leaving THIS square, not + // because a child element fired a spurious dragleave. Compare to + // currentTarget so cursor moves within the cell don't flicker. + if (hoverSquare === square && e.currentTarget === e.target) { + setHoverSquare(null); } }; @@ -118,6 +144,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec } } setDraggedPiece(null); + setHoverSquare(null); }; // Generate board squares (rank 7 down to 0, file 0 to 7) @@ -139,6 +166,22 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec // translated over them. Promoting the cell itself fixes that. const isHostingDragged = draggedPiece?.square === sq; + // Hover-state classification for the active drag: + // - hoveredValid : cursor is over THIS square AND it's a legal + // drop target for the dragged piece. + // - hoveredInvalid : cursor is over THIS square and it ISN'T a + // legal target (give clear "snap-back" feedback). + // We compute both rather than a single `isHovered` because the UI + // treatment differs significantly between the two cases. + const isHovered = hoverSquare === sq && draggedPiece !== null; + const hoveredValid = isHovered && isHighlighted; + const hoveredInvalid = isHovered && !isHighlighted && !isHostingDragged; + // Is the legal target a capture? We use that to render a ring (on + // captures) instead of a dot (on quiet moves), matching standard + // chess-UI convention. + const isCaptureTarget = + isHighlighted && piece !== undefined && piece.color !== turn; + squares.push(
handleDragOver(e, sq)} + onDragLeave={(e) => handleDragLeave(e, sq)} onDrop={(e) => handleDrop(e, sq)} > {isLastMove && (
)} - {isHighlighted && ( -
+ {/* + * Legal-target affordance — two visual forms: + * - Quiet move (empty destination): small central dot + * - Capture (destination has enemy piece): ring around cell + * Both are superseded by the stronger hoveredValid treatment + * below when the cursor is actually over the cell. + */} + {isHighlighted && !hoveredValid && !isCaptureTarget && ( +
+
+
+ )} + {isHighlighted && !hoveredValid && isCaptureTarget && ( +
+ )} + + {/* + * Hovered + legal: bright green glow so the player sees exactly + * where the piece will land on release. Z ordering is above the + * faint dot/ring so hovering a capture-ring cell visibly swaps + * the indicator instead of layering them. + */} + {hoveredValid && ( + + )} + + {/* + * Hovered + illegal: subtle red tint telling the user that + * releasing here will snap the piece back to origin. Subtle + * rather than alarming — it's not an error, just a hint. + */} + {hoveredInvalid && ( +
)} {isCheckedKing && ( diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index b421975..b081611 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -216,15 +216,51 @@ function GameLayout({
-
-
- {turn === 'white' ? "White's turn" : "Black's turn"} -
+ {/* + * Turn indicator. In multiplayer we personalize the text: + * - Your turn (turn === myColor) + * - Opponent's turn (turn !== myColor) + * In local/solo play (myColor === null) we fall back to the + * neutral "White's turn" / "Black's turn" phrasing. + * + * When it's the local player's turn we also upgrade the + * visual treatment (green tint + pulsing dot) so the call to + * action is unmistakable. + */} + {(() => { + const isMyTurn = myColor !== null && turn === myColor; + const isOpponentTurn = myColor !== null && turn !== myColor; + const label = isMyTurn + ? 'Your turn' + : isOpponentTurn + ? "Opponent's turn" + : turn === 'white' + ? "White's turn" + : "Black's turn"; + const containerClass = isMyTurn + ? 'flex items-center gap-2 px-4 py-2 bg-emerald-50 border border-emerald-300 rounded-md font-semibold text-emerald-900 shadow-sm' + : 'flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm'; + return ( +
+ {isMyTurn ? ( + + ) : ( +
+ )} + {label} +
+ ); + })()} {/* Undo is only meaningful in local mode (server is authoritative * in multiplayer); the hook exposes canUndo=false there so the