fix(game): center played cards — bake -50% into Framer transform (RTL)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 51s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 56s

Root cause: the trick cards used a Tailwind -translate-x-1/2 -translate-y-1/2 to
center on the felt, but Framer Motion owns `transform` (from x/y/scale), so that
centering class was clobbered. In RTL the auto-positioned card then anchored to
the right edge and the whole trick cross drifted left of center.

Fix: drop the size-0 anchor; position each card at left-1/2 top-1/2 and use
Framer `transformTemplate` to prepend translate(-50%,-50%) before the animated
translate(x,y) scale — so centering survives and the pile sits dead-center in
both LTR and RTL. Burst particles re-centered too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-15 20:34:43 +03:30
parent 7c6c9fcd90
commit 99b9ee5c91
+37 -32
View File
@@ -443,38 +443,43 @@ function TrickArea({
const { front } = useCardSkins();
return (
<div className="absolute inset-0">
<div className="absolute left-1/2 top-1/2 size-0">
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
const enter = TRICK_ENTER[pc.seat];
const isWinner = phase === "trick-complete" && winner === pc.seat;
return (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{
filter: isWinner
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
<PlayingCard card={pc.card} size={cardSize} front={front} />
</motion.div>
);
})}
</AnimatePresence>
{/* Burst particles when trick is won */}
<AnimatePresence>
{phase === "trick-complete" && winner != null && (
<TrickBurst key={`burst-${winner}`} seat={winner} />
)}
</AnimatePresence>
</div>
<AnimatePresence>
{trick.map((pc) => {
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
const enter = TRICK_ENTER[pc.seat];
const isWinner = phase === "trick-complete" && winner === pc.seat;
return (
<motion.div
key={pc.card.id}
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
className="absolute left-1/2 top-1/2"
// Bake the -50% centering into Framer's transform — Framer owns the
// `transform` (from x/y/scale), so a Tailwind -translate-x-1/2 class
// gets clobbered (in RTL the card then anchors right → drifts left).
transformTemplate={(t) =>
`translate(-50%, -50%) translate(${t.x ?? "0px"}, ${t.y ?? "0px"}) scale(${t.scale ?? 1})`}
style={{
filter: isWinner
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
: undefined,
}}
>
<PlayingCard card={pc.card} size={cardSize} front={front} />
</motion.div>
);
})}
</AnimatePresence>
{/* Burst particles when trick is won (centered on the felt) */}
<AnimatePresence>
{phase === "trick-complete" && winner != null && (
<div key={`burst-${winner}`} className="absolute left-1/2 top-1/2 size-0">
<TrickBurst seat={winner} />
</div>
)}
</AnimatePresence>
</div>
);
}