feat(chess): drop-target hover indicator + personalized turn banner
Board now renders three visual states during a drag: faint dot for legal quiet-move targets, ring for legal capture targets, and a bright emerald fill when the cursor is actually over a valid drop square so the user sees exactly where the piece will land. Hovering an invalid square shows a subtle red tint telling the player the drag will snap back on release. Turn indicator shows "Your turn" (with a pulsing emerald dot and green card treatment) when it`s the local player`s turn in multiplayer, and "Opponent`s turn" otherwise. Solo play falls back to the neutral "White`s turn" / "Black`s turn" phrasing.
This commit is contained in:
parent
08b5f794a7
commit
ae87772277
3 changed files with 148 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<number | null>(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(
|
||||
<div
|
||||
key={sq}
|
||||
|
|
@ -147,14 +190,64 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
|||
isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]'
|
||||
} shadow-[inset_0_0_8px_rgba(0,0,0,0.15)] ${isHostingDragged ? 'z-50' : ''}`}
|
||||
onDragOver={(e) => handleDragOver(e, sq)}
|
||||
onDragLeave={(e) => handleDragLeave(e, sq)}
|
||||
onDrop={(e) => handleDrop(e, sq)}
|
||||
>
|
||||
{isLastMove && (
|
||||
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
||||
)}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className="absolute inset-0 bg-black/15 pointer-events-none ring-4 ring-inset ring-black/20 z-10 rounded-full m-4" />
|
||||
{/*
|
||||
* 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 && (
|
||||
<div
|
||||
data-role="drop-target"
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
|
||||
>
|
||||
<div className="w-1/3 h-1/3 rounded-full bg-black/25" />
|
||||
</div>
|
||||
)}
|
||||
{isHighlighted && !hoveredValid && isCaptureTarget && (
|
||||
<div
|
||||
data-role="drop-target-capture"
|
||||
className="absolute inset-1 pointer-events-none z-10 rounded-full ring-[6px] ring-inset ring-black/25"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* 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 && (
|
||||
<motion.div
|
||||
data-role="drop-hover-valid"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={`absolute inset-0 pointer-events-none z-10 ${
|
||||
isCaptureTarget
|
||||
? 'ring-[6px] ring-inset ring-emerald-400/80 bg-emerald-400/25'
|
||||
: 'bg-emerald-400/35 ring-4 ring-inset ring-emerald-500/50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* 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 && (
|
||||
<div
|
||||
data-role="drop-hover-invalid"
|
||||
className="absolute inset-0 pointer-events-none z-10 bg-red-500/15"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCheckedKing && (
|
||||
|
|
|
|||
|
|
@ -216,15 +216,51 @@ function GameLayout({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
/>
|
||||
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
|
||||
</div>
|
||||
{/*
|
||||
* 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 (
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
data-my-turn={isMyTurn ? 'true' : 'false'}
|
||||
className={containerClass}
|
||||
>
|
||||
{isMyTurn ? (
|
||||
<motion.div
|
||||
className="w-3 h-3 rounded-full bg-emerald-500 shadow-inner"
|
||||
animate={{ scale: [1, 1.25, 1], opacity: [1, 0.7, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Undo is only meaningful in local mode (server is authoritative
|
||||
* in multiplayer); the hook exposes canUndo=false there so the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue