feat(chess): spring-physics drag with cursor-release FLIP animation
Dragging a piece now follows the cursor with spring lag and a subtle tilt, scales up with a deeper shadow while lifted, and on a valid drop glides smoothly from the cursor-release position into the destination square. Invalid drops spring back to the origin. Dragging is disabled entirely for the non-playing side (no grab cursor, no transforms, no drag events). Cursor tracking uses a document-level `dragover` listener — the `drag` event on the source element is throttled by Chromium and reports 0/0 in Firefox, so is unusable for smooth tracking. Replaces motion`s `layoutId` FLIP with a manual implementation. Motion measures layout rects without inline transforms, so `layoutId` always animated from the source square instead of the cursor position. The new approach stashes the transformed `getBoundingClientRect` on dragend and consumes it from a `useLayoutEffect` at mount, jumping the spring to the delta and letting it animate home — producing a true release-to-target FLIP.
This commit is contained in:
parent
858d326895
commit
d4622b2cb4
2 changed files with 299 additions and 48 deletions
|
|
@ -152,26 +152,29 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
|
|||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{piece && (
|
||||
<motion.div
|
||||
key={piece.id}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0, transition: { duration: 0.2 } }}
|
||||
className="absolute inset-0 z-20"
|
||||
>
|
||||
<Piece
|
||||
color={piece.color}
|
||||
type={piece.type}
|
||||
pieceId={piece.id}
|
||||
square={sq}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/*
|
||||
* No AnimatePresence wrapper here: the `<Piece>` component runs
|
||||
* its own FLIP across unmount (at source) and mount (at target)
|
||||
* via a module-level rect stash. An AnimatePresence exit
|
||||
* animation on the source wrapper would show a ghost of the old
|
||||
* piece at its origin while the new mount springs in from the
|
||||
* cursor position — competing artifacts. Captures pop instantly
|
||||
* without a fade, which is acceptable and keeps the movement
|
||||
* FLIP clean.
|
||||
*/}
|
||||
{piece && (
|
||||
<div key={piece.id} className="absolute inset-0 z-20">
|
||||
<Piece
|
||||
color={piece.color}
|
||||
type={piece.type}
|
||||
pieceId={piece.id}
|
||||
square={sq}
|
||||
isDraggable={piece.color === turn}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank/File labels (optional but helpful for debug) */}
|
||||
{f === 0 && (
|
||||
|
|
@ -191,7 +194,7 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
|
|||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] overflow-hidden bg-neutral-900 border-4 border-neutral-800">
|
||||
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] bg-neutral-900 border-4 border-neutral-800">
|
||||
{squares}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import type { PieceColor, PieceType } from '../schema';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from 'motion/react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent as ReactDragEvent } from 'react';
|
||||
|
||||
export interface PieceProps {
|
||||
|
|
@ -8,59 +14,301 @@ export interface PieceProps {
|
|||
type: PieceType;
|
||||
pieceId: number;
|
||||
square: number;
|
||||
/** Whether this piece can be picked up right now (its color's turn, game
|
||||
* ongoing, etc). When false, drag is disabled and the piece shows a
|
||||
* default cursor. */
|
||||
isDraggable: boolean;
|
||||
onDragStart: (pieceId: number, square: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level registry that bridges a piece's unmount-at-old-square and
|
||||
* mount-at-new-square across a valid drop.
|
||||
*
|
||||
* Why not motion's `layoutId` FLIP? `layoutId` measures each element's
|
||||
* *layout* rect — i.e. the rect as if no inline transforms were applied.
|
||||
* Since our cursor-following juice is implemented via `x`/`y` motion
|
||||
* values (inline transforms), motion's FLIP source rect is always the
|
||||
* piece's original square, not the cursor's release position. The animation
|
||||
* looks like a teleport-then-glide.
|
||||
*
|
||||
* We do FLIP by hand instead:
|
||||
* 1. In `handleDragEnd`, snapshot the piece's *rendered* center (which
|
||||
* includes the transform) into this map, keyed by `pieceId`.
|
||||
* 2. React updates the board, unmounting the old `<Piece>` and mounting
|
||||
* a fresh one at the target square (same `pieceId`, new `square`).
|
||||
* 3. The new piece's `useLayoutEffect` finds the stashed center, measures
|
||||
* its own mount center, computes the delta, and jumps the springs to
|
||||
* that delta without animation. Setting the spring targets back to 0
|
||||
* then animates the piece from the cursor's release position to home.
|
||||
*
|
||||
* On an invalid drop the same `<Piece>` instance stays mounted — no new
|
||||
* mount consumes the stash — so we clear stale entries on the next
|
||||
* dragstart and after a grace period.
|
||||
*/
|
||||
const pendingDropRects = new Map<number, { cx: number; cy: number }>();
|
||||
|
||||
/**
|
||||
* Piece component.
|
||||
*
|
||||
* We wrap the draggable DOM node in an outer `motion.div` that owns the
|
||||
* FLIP layout animation (via `layoutId`). The inner plain `<div>` owns the
|
||||
* HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the
|
||||
* browser's DnD subsystem both dispatch events on this node. Keeping the
|
||||
* two responsibilities separate avoids a type clash — `motion.div`'s
|
||||
* `onDragStart` prop is typed for its own pointer-based drag gesture,
|
||||
* which is incompatible with `React.DragEvent`. The extra DOM node costs
|
||||
* nothing and sidesteps the cast.
|
||||
* Three-layer structure:
|
||||
*
|
||||
* 1. Outer plain `<div>` — stable bounding box for Playwright's
|
||||
* actionability check. A `motion.div` here whose descendants are
|
||||
* spring-animating would register as "not stable" and cause `dragTo`
|
||||
* to time out.
|
||||
*
|
||||
* 2. Middle plain `<div draggable>` — owns HTML5 native drag-and-drop.
|
||||
* The browser's DnD subsystem and Playwright's `locator.dragTo()` both
|
||||
* dispatch events against this node.
|
||||
*
|
||||
* 3. Inner `motion.div` — cursor-following transform (`x`/`y`/`rotate`)
|
||||
* plus scale/shadow lift. `pointer-events: none` while dragging so
|
||||
* the translated piece doesn't block `dragover` from reaching the
|
||||
* target square beneath it.
|
||||
*
|
||||
* Cursor tracking: while dragging we attach a document-level `dragover`
|
||||
* listener. That event fires at a high rate with accurate `clientX/Y` in
|
||||
* every browser — unlike `drag` on the source element, which is throttled
|
||||
* by Chromium and reports 0/0 in Firefox. Cursor delta feeds `xRaw`/`yRaw`,
|
||||
* smoothed through a spring (`x`/`y`) so the piece trails with weight. A
|
||||
* `useTransform` over the raw-vs-smoothed gap drives a tilt.
|
||||
*/
|
||||
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
|
||||
export function Piece({
|
||||
color,
|
||||
type,
|
||||
pieceId,
|
||||
square,
|
||||
isDraggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: PieceProps) {
|
||||
const imgSrc = pieceAssets[color][type];
|
||||
|
||||
// Raw pointer offset from drag origin, updated on every `dragover`.
|
||||
const xRaw = useMotionValue(0);
|
||||
const yRaw = useMotionValue(0);
|
||||
|
||||
// Smoothed offset — what the piece actually renders at. The spring lag
|
||||
// gives the piece weight while dragging and drives both the snap-back
|
||||
// (invalid drop) and the mount-FLIP (valid drop).
|
||||
const springCfg = { stiffness: 350, damping: 28, mass: 0.6 };
|
||||
const x = useSpring(xRaw, springCfg);
|
||||
const y = useSpring(yRaw, springCfg);
|
||||
|
||||
// Tilt is proportional to the raw-vs-smoothed X gap, clamped subtle.
|
||||
const rotate = useTransform<number, number>([xRaw, x], ([rawVal, smoothVal]) => {
|
||||
const delta = (rawVal as number) - (smoothVal as number);
|
||||
return Math.max(-25, Math.min(25, delta * 0.35));
|
||||
});
|
||||
|
||||
const transformRef = useRef<HTMLDivElement>(null);
|
||||
const originRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragOverHandlerRef = useRef<((e: globalThis.DragEvent) => void) | null>(null);
|
||||
const snapBackRafRef = useRef<number | null>(null);
|
||||
const staleStashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const detachDragOver = () => {
|
||||
if (dragOverHandlerRef.current) {
|
||||
document.removeEventListener('dragover', dragOverHandlerRef.current);
|
||||
dragOverHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Mount effect: consume a pending drop rect for this pieceId, if one was
|
||||
// stashed by the piece that just unmounted at the old square. We measure
|
||||
// the new mount's center and jump the springs to the delta so the piece
|
||||
// appears at the cursor-release position, then let them animate to 0.
|
||||
//
|
||||
// `useLayoutEffect` runs synchronously after mutation and before paint,
|
||||
// so there's no visible flash of the piece at the new square before the
|
||||
// FLIP begins.
|
||||
useLayoutEffect(() => {
|
||||
const stash = pendingDropRects.get(pieceId);
|
||||
if (!stash) return;
|
||||
pendingDropRects.delete(pieceId);
|
||||
|
||||
const node = transformRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const nextRect = node.getBoundingClientRect();
|
||||
const nextCx = nextRect.left + nextRect.width / 2;
|
||||
const nextCy = nextRect.top + nextRect.height / 2;
|
||||
const dx = stash.cx - nextCx;
|
||||
const dy = stash.cy - nextCy;
|
||||
|
||||
// `.jump()` snaps the spring's current value without triggering an
|
||||
// animation. Setting `xRaw`/`yRaw` back to 0 re-targets the spring,
|
||||
// so it animates from `delta` to `0` — a FLIP from release to home.
|
||||
x.jump(dx);
|
||||
y.jump(dy);
|
||||
xRaw.set(0);
|
||||
yRaw.set(0);
|
||||
// Intentionally only re-run when pieceId changes: x/y/xRaw/yRaw are
|
||||
// stable motion-value references and this effect is about the initial
|
||||
// mount animation.
|
||||
}, [pieceId]);
|
||||
|
||||
// Mount/unmount tracking. Empty deps by design: this only wires cleanup
|
||||
// on unmount, regardless of prop changes.
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (snapBackRafRef.current !== null) {
|
||||
cancelAnimationFrame(snapBackRafRef.current);
|
||||
snapBackRafRef.current = null;
|
||||
}
|
||||
if (staleStashTimerRef.current !== null) {
|
||||
clearTimeout(staleStashTimerRef.current);
|
||||
staleStashTimerRef.current = null;
|
||||
}
|
||||
detachDragOver();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (e: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isDraggable) {
|
||||
// Belt-and-suspenders: `draggable={false}` on the DOM should already
|
||||
// prevent this, but guard against prop-flip races between render
|
||||
// and event dispatch.
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Cancel any in-flight snap-back from a previous drag (quick re-drag).
|
||||
if (snapBackRafRef.current !== null) {
|
||||
cancelAnimationFrame(snapBackRafRef.current);
|
||||
snapBackRafRef.current = null;
|
||||
}
|
||||
// Clear any stale stashed drop rect for this piece (e.g. from a prior
|
||||
// invalid drop that never got consumed by a remount).
|
||||
pendingDropRects.delete(pieceId);
|
||||
if (staleStashTimerRef.current !== null) {
|
||||
clearTimeout(staleStashTimerRef.current);
|
||||
staleStashTimerRef.current = null;
|
||||
}
|
||||
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires non-empty drag data for dragstart to take effect.
|
||||
e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
|
||||
// Suppress the browser's default ghost image — we'd rather the piece
|
||||
// stay visually in place while motion handles the move animation.
|
||||
// Suppress the browser's default ghost image — we render our own
|
||||
// dangling piece via the motion layer.
|
||||
const img = new Image();
|
||||
img.src =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
|
||||
originRef.current = { x: e.clientX, y: e.clientY };
|
||||
xRaw.set(0);
|
||||
yRaw.set(0);
|
||||
setIsDragging(true);
|
||||
|
||||
// Attach a document-level dragover listener. Unlike `drag` on the
|
||||
// source, `dragover` fires on every pointer movement with accurate
|
||||
// coordinates across Chromium, Firefox, and WebKit.
|
||||
detachDragOver();
|
||||
const onDocDragOver = (ev: globalThis.DragEvent) => {
|
||||
if (!originRef.current) return;
|
||||
if (ev.clientX === 0 && ev.clientY === 0) return;
|
||||
xRaw.set(ev.clientX - originRef.current.x);
|
||||
yRaw.set(ev.clientY - originRef.current.y);
|
||||
};
|
||||
document.addEventListener('dragover', onDocDragOver);
|
||||
dragOverHandlerRef.current = onDocDragOver;
|
||||
|
||||
onDragStart(pieceId, square);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
detachDragOver();
|
||||
originRef.current = null;
|
||||
setIsDragging(false);
|
||||
|
||||
// Snapshot the piece's *rendered* center (including the cursor-offset
|
||||
// transform) BEFORE notifying the parent, because `onDragEnd` is
|
||||
// synchronous and React will apply the new board state — unmounting
|
||||
// us — as soon as we return. The rect has to be captured while the
|
||||
// transformed element still exists in the DOM.
|
||||
const node = transformRef.current;
|
||||
if (node) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
pendingDropRects.set(pieceId, {
|
||||
cx: rect.left + rect.width / 2,
|
||||
cy: rect.top + rect.height / 2,
|
||||
});
|
||||
// Grace period for invalid drops: if no remount consumes the stash
|
||||
// within a few frames, clear it so a later unrelated mount doesn't
|
||||
// receive a stale FLIP.
|
||||
if (staleStashTimerRef.current !== null) {
|
||||
clearTimeout(staleStashTimerRef.current);
|
||||
}
|
||||
staleStashTimerRef.current = setTimeout(() => {
|
||||
pendingDropRects.delete(pieceId);
|
||||
staleStashTimerRef.current = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Notify parent. On a valid drop this triggers the unmount/remount
|
||||
// that our `useLayoutEffect` consumes (via `pendingDropRects`).
|
||||
onDragEnd();
|
||||
|
||||
// Defer the spring snap-back for invalid drops. If React unmounts us
|
||||
// (valid drop), `mountedRef.current` is false and we skip the reset
|
||||
// — the new mount handles the animation via the stashed rect. If we
|
||||
// survive (invalid drop), zero the motion values so the spring
|
||||
// animates the piece back to its origin square.
|
||||
snapBackRafRef.current = requestAnimationFrame(() => {
|
||||
snapBackRafRef.current = requestAnimationFrame(() => {
|
||||
snapBackRafRef.current = null;
|
||||
if (!mountedRef.current) return;
|
||||
xRaw.set(0);
|
||||
yRaw.set(0);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const cursorClass = isDraggable
|
||||
? 'cursor-grab active:cursor-grabbing'
|
||||
: 'cursor-default';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={`piece-${pieceId}`}
|
||||
className="w-full h-full"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="w-full h-full">
|
||||
<div
|
||||
draggable
|
||||
draggable={isDraggable}
|
||||
data-piece={`${color}-${type}`}
|
||||
data-piece-id={pieceId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing select-none"
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-center justify-center w-full h-full select-none ${cursorClass}`}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${color} ${type}`}
|
||||
className="w-[85%] h-[85%] drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)] pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<motion.div
|
||||
ref={transformRef}
|
||||
className={`flex items-center justify-center w-full h-full ${isDragging ? 'pointer-events-none' : ''}`}
|
||||
style={{ x, y, rotate }}
|
||||
animate={{
|
||||
scale: isDragging ? 1.15 : 1,
|
||||
zIndex: isDragging ? 50 : 0,
|
||||
}}
|
||||
transition={{
|
||||
scale: { type: 'spring', stiffness: 500, damping: 30 },
|
||||
zIndex: { duration: 0 },
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${color} ${type}`}
|
||||
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
|
||||
isDragging
|
||||
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
|
||||
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue