Fix mobile: compress card fan to fit any screen; kill horizontal overflow
- PlayerHand measures viewport and compresses the fan (responsive card size + dynamic overlap) so all 13 cards are visible and tappable on phones — no off-screen cards, no horizontal scroll; added tap feedback - globals: html/body overflow-x hidden + overscroll-behavior none Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,8 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -354,12 +354,28 @@ function TrickArea({
|
|||||||
|
|
||||||
/* ----------------------------- Player hand ---------------------------- */
|
/* ----------------------------- Player hand ---------------------------- */
|
||||||
|
|
||||||
|
function useViewportWidth() {
|
||||||
|
const [vw, setVw] = useState(typeof window !== "undefined" ? window.innerWidth : 390);
|
||||||
|
useEffect(() => {
|
||||||
|
const f = () => setVw(window.innerWidth);
|
||||||
|
f();
|
||||||
|
window.addEventListener("resize", f);
|
||||||
|
window.addEventListener("orientationchange", f);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", f);
|
||||||
|
window.removeEventListener("orientationchange", f);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return vw;
|
||||||
|
}
|
||||||
|
|
||||||
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||||
const hand = useGameStore((s) => s.game.players[0].hand);
|
const hand = useGameStore((s) => s.game.players[0].hand);
|
||||||
const phase = useGameStore((s) => s.game.phase);
|
const phase = useGameStore((s) => s.game.phase);
|
||||||
const turn = useGameStore((s) => s.game.turn);
|
const turn = useGameStore((s) => s.game.turn);
|
||||||
const playHuman = useGameStore((s) => s.playHuman);
|
const playHuman = useGameStore((s) => s.playHuman);
|
||||||
const { front } = useCardSkins();
|
const { front } = useCardSkins();
|
||||||
|
const vw = useViewportWidth();
|
||||||
|
|
||||||
const sorted = sortHand(hand);
|
const sorted = sortHand(hand);
|
||||||
const myTurn = phase === "playing" && turn === 0;
|
const myTurn = phase === "playing" && turn === 0;
|
||||||
@@ -367,45 +383,53 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
|||||||
const choosing = phase === "choosing-trump";
|
const choosing = phase === "choosing-trump";
|
||||||
const n = sorted.length;
|
const n = sorted.length;
|
||||||
|
|
||||||
|
// Compress the fan so every card fits the screen width (no overflow/scroll).
|
||||||
|
const small = vw < 560;
|
||||||
|
const size = vw < 360 ? "sm" : vw < 560 ? "md" : "lg";
|
||||||
|
const cardW = size === "sm" ? 44 : size === "md" ? 60 : 74;
|
||||||
|
const avail = Math.min(vw - 12, 620);
|
||||||
|
const step = n > 1 ? Math.min(cardW * 0.94, Math.max(15, (avail - cardW) / (n - 1))) : 0;
|
||||||
|
const overlap = step - cardW; // negative inline-start margin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-0 inset-x-0 flex justify-center pb-3 pointer-events-none",
|
"absolute bottom-0 inset-x-0 flex justify-center pb-2 pointer-events-none",
|
||||||
choosing ? "z-50" : "z-20"
|
choosing ? "z-50" : "z-20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex items-end justify-center pointer-events-auto">
|
<div className="relative flex items-end justify-center pointer-events-auto max-w-full">
|
||||||
{sorted.map((card, i) => {
|
{sorted.map((card, i) => {
|
||||||
const playable = myTurn && legalIds.has(card.id);
|
const playable = myTurn && legalIds.has(card.id);
|
||||||
const dimmed = myTurn && !playable;
|
const dimmed = myTurn && !playable;
|
||||||
const mid = (n - 1) / 2;
|
const mid = (n - 1) / 2;
|
||||||
const rot = (i - mid) * 3.2;
|
const rot = (i - mid) * (small ? 2 : 3.2);
|
||||||
const lift = Math.abs(i - mid) * 4;
|
const lift = Math.abs(i - mid) * (small ? 2 : 4);
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={card.id}
|
key={card.id}
|
||||||
layout
|
layout
|
||||||
initial={{ y: 120, opacity: 0 }}
|
initial={{ y: 120, opacity: 0 }}
|
||||||
animate={{ y: lift, opacity: 1, rotate: rot }}
|
animate={{ y: lift, opacity: 1, rotate: rot }}
|
||||||
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.015 }}
|
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.012 }}
|
||||||
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}}
|
whileHover={playable ? { y: lift - 26, scale: 1.08, zIndex: 50 } : {}}
|
||||||
|
whileTap={playable ? { scale: 1.05 } : {}}
|
||||||
onClick={() => playable && playHuman(card)}
|
onClick={() => playable && playHuman(card)}
|
||||||
disabled={!playable}
|
disabled={!playable}
|
||||||
data-card={card.id}
|
data-card={card.id}
|
||||||
data-playable={playable ? "1" : "0"}
|
data-playable={playable ? "1" : "0"}
|
||||||
style={{ marginInlineStart: i === 0 ? 0 : -22 }}
|
style={{ marginInlineStart: i === 0 ? 0 : overlap }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"origin-bottom",
|
"origin-bottom shrink-0",
|
||||||
playable && "cursor-pointer",
|
playable ? "cursor-pointer relative z-30" : "cursor-default"
|
||||||
!myTurn && "cursor-default"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PlayingCard
|
<PlayingCard
|
||||||
card={card}
|
card={card}
|
||||||
size="lg"
|
size={size}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
front={front}
|
front={front}
|
||||||
className={cn(playable && "ring-2 ring-gold-400/70")}
|
className={cn(playable && "ring-2 ring-gold-400/80")}
|
||||||
/>
|
/>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user