Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved bot skill
- More to come soon!

<<<<<<< HEAD
## [1.7.0] - 2026-05-08

### Changed
Expand All @@ -30,6 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- **Play again** after a game ends: start a new hand without leaving the room (singleplayer keeps the same local lobby; multiplayer chains end + start via the API).
=======
## 1.6.0 - 2026-07-24

### Added

- Card motion animations! (Pull request #36)
>>>>>>> 61e851d1fd2af4e0bf20d077ff92accc43fce436

## [1.5.0] - 2026-02-02

Expand Down
191 changes: 159 additions & 32 deletions src/components/ActiveGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ import {
Tooltip,
} from '@chakra-ui/react';
import { Action, type ActionData } from '@utils/actions';
import {
comboFlyOriginForSeat,
comboPlayVariants,
opponentDealVariants,
} from '@utils/card-motion';
import { useHandDealIntro } from '@utils/hooks/useHandDealIntro';
import useIsTabletAndAbove from '@utils/hooks/useIsTabletAndAbove';
import type { GameWithPlayers } from '@utils/prisma';
import { AnimatePresence, motion } from 'framer-motion';
import { SOLO_GAME_ID } from 'pages/game/singleplayer';
import { useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import CardImage from './CardImage';
import OpponentHand from './OpponentHand';
import PlayerHand from './PlayerHand';
Expand Down Expand Up @@ -84,6 +91,44 @@ function sanitizeCardLabel(cardLabel: string) {
return cardLabel.replace(/(?<=(.*;[a-z]*))\d*/gi, '');
}

function MobileOpponentCards({
dealStamp,
hand,
gameId,
playerIndex,
cardSpacing,
skipDealIntro,
}: {
dealStamp: string;
hand: string[];
gameId: string;
playerIndex: number;
cardSpacing: string;
skipDealIntro: boolean;
}) {
const playDealIntro = useHandDealIntro(skipDealIntro, dealStamp, hand.length);
return (
<Stack direction="row">
{hand.map((card, cardIndex) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: slot index disambiguates identical card strings (multi-deck)
key={`${dealStamp}::${playerIndex}::${cardIndex}`}
custom={cardIndex}
variants={opponentDealVariants}
initial={playDealIntro ? 'hidden' : false}
animate="show"
style={{
display: 'inline-block',
...overlapStyles(cardIndex, cardSpacing),
}}
>
<CardImage card={gameId !== SOLO_GAME_ID ? card : ''} />
</motion.div>
))}
</Stack>
);
}

export default function ActiveGame({ game, playerId, handleAction }: Props) {
const isTabletAndAbove = useIsTabletAndAbove();

Expand Down Expand Up @@ -111,6 +156,54 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
/** Set of emojis to be randomly chosen from for player icons. */
const playerEmojis = PLAYER_EMOJIS[game.id.length % PLAYER_EMOJIS.length];

const dealStamp = game.startedAt.toISOString();
/** Disambiguates duplicate card strings (multi-deck) and stabilizes AnimatePresence keys per trick. */
const comboRowKey = game.combo.join('|');

const [skipDealIntro, setSkipDealIntro] = useState(() => {
if (typeof window === 'undefined' || !game.id || !dealStamp) return false;
try {
return (
sessionStorage.getItem(`bt-deal-intro-done:${game.id}:${dealStamp}`) ===
'1'
);
} catch {
return false;
}
});

useLayoutEffect(() => {
if (!game.id || !dealStamp) {
setSkipDealIntro(false);
return;
}
try {
const skip =
sessionStorage.getItem(`bt-deal-intro-done:${game.id}:${dealStamp}`) ===
'1';
setSkipDealIntro(skip);
} catch {
setSkipDealIntro(false);
}
}, [game.id, dealStamp]);

// Defer marking the deal "seen" so (1) the deal stagger can finish and (2) React 18
// Strict Mode’s mount → unmount → remount cycle does not read this key on the second
// mount while it is already set — immediate setItem breaks the intro in dev.
useEffect(() => {
if (!game.id || !dealStamp) return;
const key = `bt-deal-intro-done:${game.id}:${dealStamp}`;
try {
if (sessionStorage.getItem(key) === '1') return;
const t = window.setTimeout(() => {
sessionStorage.setItem(key, '1');
}, 1800);
return () => window.clearTimeout(t);
} catch {
return undefined;
}
}, [game.id, dealStamp]);

// use dummy flag to force rerenders, so we don't have to copy the comboToPlay set every time to trigger rerender
const [dummy, setDummy] = useState(false);
function forceUpdate() {
Expand Down Expand Up @@ -152,6 +245,13 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
else return index + game.players.length - thisPlayer.index;
}

const comboPlayFly =
game.lastPlaymaker != null
? comboFlyOriginForSeat(indexToPosition(game.lastPlaymaker), {
compact: !isTabletAndAbove,
})
: { x: 0, y: 44 };

return (
<>
{isTabletAndAbove ? (
Expand Down Expand Up @@ -184,15 +284,26 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
borderRadius="lg"
p={4}
>
<Stack direction="row">
{game.combo.map((card, index) => (
<CardImage
// biome-ignore lint/suspicious/noArrayIndexKey: Cards have no unique ID's
key={card + index}
card={card}
style={overlapStyles(index, cardSpacing)}
/>
))}
<Stack direction="row" alignItems="center" minH="8.2em">
<AnimatePresence mode="popLayout">
{game.combo.map((card, index) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: slot index disambiguates identical card strings (multi-deck)
key={`${comboRowKey}::${card}::${index}`}
custom={{ index, fly: comboPlayFly }}
variants={comboPlayVariants}
initial="hidden"
animate="show"
exit="exit"
style={{
display: 'inline-block',
...overlapStyles(index, cardSpacing),
}}
>
<CardImage card={card} />
</motion.div>
))}
</AnimatePresence>
{!game.combo.length && <Box width={'6em'} height={'8.2em'} />}
</Stack>
</Box>
Expand All @@ -207,13 +318,16 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
{/* For solo games, manually obscure opponent hand. */}
{(spectating || player.index !== thisPlayer?.index) && (
<OpponentHand
key={`${dealStamp}-opp-${player.index}`}
position={indexToPosition(player.index)}
player={
game.id !== SOLO_GAME_ID
? player
: { ...player, hand: player.hand.map(() => '') }
}
roundLeaderIndex={roundLeaderIndex}
dealStamp={dealStamp}
skipDealIntro={skipDealIntro}
/>
)}

Expand Down Expand Up @@ -274,12 +388,15 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
{thisPlayer && (
<Box>
<PlayerHand
key={`${dealStamp}-you`}
hand={thisPlayer.hand}
comboToPlay={comboToPlay}
cardSpacing={cardSpacing}
isTabletAndAbove={isTabletAndAbove}
handleClick={handleClick}
onDeselectAll={handleDeselectAll}
dealStamp={dealStamp}
skipDealIntro={skipDealIntro}
>
{/* Current turn: Display actions */}
{game.currentPlayer &&
Expand Down Expand Up @@ -338,15 +455,26 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
) : (
<Box>
Current combo:
<Stack direction="row">
{game.combo.map((card, index) => (
<CardImage
// biome-ignore lint/suspicious/noArrayIndexKey: Cards have no unique ID's
key={card + index}
card={card}
style={overlapStyles(index, cardSpacing)}
/>
))}
<Stack direction="row" alignItems="center" minH="8.2em">
<AnimatePresence mode="popLayout">
{game.combo.map((card, index) => (
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: slot index disambiguates identical card strings (multi-deck)
key={`${comboRowKey}::${card}::${index}`}
custom={{ index, fly: comboPlayFly }}
variants={comboPlayVariants}
initial="hidden"
animate="show"
exit="exit"
style={{
display: 'inline-block',
...overlapStyles(index, cardSpacing),
}}
>
<CardImage card={card} />
</motion.div>
))}
</AnimatePresence>
{!game.combo.length && <CardImage card="" />}
</Stack>
{/* game */}
Expand All @@ -371,19 +499,15 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
{game.passedPlayers.includes(player.index) && '⏭️'}
</Text>
{player.name !== thisPlayer?.name && (
<Stack direction="row">
{player.hand.map((card, cardIndex) => (
<Box
// biome-ignore lint/suspicious/noArrayIndexKey: Cards have no unique ID's
key={card + cardIndex}
style={overlapStyles(cardIndex, cardSpacing)}
>
<CardImage
card={game.id !== SOLO_GAME_ID ? card : ''}
/>
</Box>
))}
</Stack>
<MobileOpponentCards
key={`${dealStamp}-m-${player.index}`}
dealStamp={dealStamp}
hand={player.hand}
gameId={game.id}
playerIndex={player.index}
cardSpacing={cardSpacing}
skipDealIntro={skipDealIntro}
/>
)}
</Box>
))}
Expand All @@ -392,12 +516,15 @@ export default function ActiveGame({ game, playerId, handleAction }: Props) {
Your hand ({thisPlayer.hand.length} cards):
<br />
<PlayerHand
key={`${dealStamp}-you`}
hand={thisPlayer.hand}
comboToPlay={comboToPlay}
cardSpacing={cardSpacing}
isTabletAndAbove={isTabletAndAbove}
handleClick={handleClick}
onDeselectAll={handleDeselectAll}
dealStamp={dealStamp}
skipDealIntro={skipDealIntro}
>
{/* Current turn: Display actions */}
{game.currentPlayer &&
Expand Down
2 changes: 1 addition & 1 deletion src/components/CardImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function CardImage({
borderRadius: '0.33em',
backgroundColor: 'white',
cursor: 'pointer',
transform: selected ? 'translate(0, -1em)' : '',
marginTop: selected ? '-1em' : undefined,
position: 'relative',
...style,
}}
Expand Down
30 changes: 26 additions & 4 deletions src/components/OpponentHand.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Box, Stack } from '@chakra-ui/react';
import type { Player } from '@prisma/client';
import { opponentDealVariants } from '@utils/card-motion';
import { useHandDealIntro } from '@utils/hooks/useHandDealIntro';
import { motion } from 'framer-motion';

import CardImage from './CardImage';

Expand Down Expand Up @@ -32,9 +35,21 @@ interface Props {
position: number;
player: Player;
roundLeaderIndex: number | null;
dealStamp?: string;
skipDealIntro?: boolean;
}

export default function OpponentHand({ position, player }: Props) {
export default function OpponentHand({
position,
player,
dealStamp = '',
skipDealIntro = false,
}: Props) {
const playDealIntro = useHandDealIntro(
skipDealIntro,
dealStamp,
player.hand.length,
);
const spacing = {
marginInlineStart: position % 2 ? '0' : '-3.4em',
marginTop: position % 2 ? '-6.7em' : '0',
Expand All @@ -45,8 +60,15 @@ export default function OpponentHand({ position, player }: Props) {
<Box position="fixed" {...HAND_STYLES[position]}>
<Stack direction={position % 2 ? 'column' : 'row'}>
{player.hand.map((card, cardIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Cards have no unique ID's
<Box key={card + cardIndex} {...(cardIndex === 0 ? {} : spacing)}>
<motion.div
// biome-ignore lint/suspicious/noArrayIndexKey: slot index disambiguates identical card strings (multi-deck)
key={`${dealStamp}::${position}::${cardIndex}`}
custom={cardIndex}
variants={opponentDealVariants}
initial={playDealIntro ? 'hidden' : false}
animate="show"
style={cardIndex === 0 ? undefined : spacing}
>
<CardImage
card={card}
style={{
Expand All @@ -55,7 +77,7 @@ export default function OpponentHand({ position, player }: Props) {
transform: `rotate(${HAND_ROTATIONS[position]}deg)`,
}}
/>
</Box>
</motion.div>
))}
</Stack>
</Box>
Expand Down
Loading