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(); const { front } = useCardSkins();
return ( return (
<div className="absolute inset-0"> <div className="absolute inset-0">
<div className="absolute left-1/2 top-1/2 size-0"> <AnimatePresence>
<AnimatePresence> {trick.map((pc) => {
{trick.map((pc) => { const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale }; const enter = TRICK_ENTER[pc.seat];
const enter = TRICK_ENTER[pc.seat]; const isWinner = phase === "trick-complete" && winner === pc.seat;
const isWinner = phase === "trick-complete" && winner === pc.seat; return (
return ( <motion.div
<motion.div key={pc.card.id}
key={pc.card.id} initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
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 }}
animate={{ x: off.x, y: off.y, opacity: 1, scale: isWinner ? 1.14 : 1 }} exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }} transition={{ type: "spring", stiffness: 260, damping: 26 }}
transition={{ type: "spring", stiffness: 260, damping: 26 }} className="absolute left-1/2 top-1/2"
className="absolute -translate-x-1/2 -translate-y-1/2" // Bake the -50% centering into Framer's transform — Framer owns the
style={{ // `transform` (from x/y/scale), so a Tailwind -translate-x-1/2 class
filter: isWinner // gets clobbered (in RTL the card then anchors right → drifts left).
? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))" transformTemplate={(t) =>
: undefined, `translate(-50%, -50%) translate(${t.x ?? "0px"}, ${t.y ?? "0px"}) scale(${t.scale ?? 1})`}
}} style={{
> filter: isWinner
<PlayingCard card={pc.card} size={cardSize} front={front} /> ? "drop-shadow(0 0 18px rgba(212,175,55,1)) drop-shadow(0 0 6px rgba(255,240,120,0.8))"
</motion.div> : undefined,
); }}
})} >
</AnimatePresence> <PlayingCard card={pc.card} size={cardSize} front={front} />
{/* Burst particles when trick is won */} </motion.div>
<AnimatePresence> );
{phase === "trick-complete" && winner != null && ( })}
<TrickBurst key={`burst-${winner}`} seat={winner} /> </AnimatePresence>
)} {/* Burst particles when trick is won (centered on the felt) */}
</AnimatePresence> <AnimatePresence>
</div> {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> </div>
); );
} }