diff --git a/package.json b/package.json index 19fbd51..7e9ea52 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "next": "14.2.8", "react": "^18", "react-dom": "^18", + "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b29892..1a11e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-icons: specifier: ^5.3.0 version: 5.3.0(react@18.3.1) @@ -1332,6 +1335,11 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + goober@2.1.14: + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -1952,6 +1960,13 @@ packages: peerDependencies: react: ^18.3.1 + react-hot-toast@2.4.1: + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-icons@5.3.0: resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==} peerDependencies: @@ -3863,6 +3878,10 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + goober@2.1.14(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -4432,6 +4451,14 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + goober: 2.1.14(csstype@3.1.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - csstype + react-icons@5.3.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src/components/atoms/Modal/index.tsx b/src/components/atoms/Modal/index.tsx index b082b95..b7b4681 100644 --- a/src/components/atoms/Modal/index.tsx +++ b/src/components/atoms/Modal/index.tsx @@ -8,9 +8,10 @@ import { import { DialogDescription, DialogProps } from "@radix-ui/react-dialog"; import { ReactNode } from "react"; import { VisuallyHidden } from "styled-system/jsx"; +import { Pretty } from "styled-system/types"; export const Modal = (props: { - wrapperProps: DialogProps; + wrapperProps: Pretty; trigger?: ReactNode; title?: string; content?: ReactNode; diff --git a/src/components/organisms/GameBoard/Footer/Decks.tsx b/src/components/organisms/GameBoard/Footer/Decks.tsx deleted file mode 100644 index 6082f41..0000000 --- a/src/components/organisms/GameBoard/Footer/Decks.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Grid, VStack } from "styled-system/jsx"; -import { GRIDS, LAYOUT_HEIGHTS } from "../constants"; -import { GameStateActions } from ".."; -import { actDrawAtlas, actDrawDeck } from "@/utils/actions"; - -export const DecksTray = (props: GameStateActions) => { - function draw(deck: GRIDS.DECK | GRIDS.ATLAS_DECK) { - deck === GRIDS.DECK - ? props.setGridItems(actDrawDeck(props.gridItems)) - : props.setGridItems(actDrawAtlas(props.gridItems)); - } - - function drawDeck() { - draw(GRIDS.DECK); - } - function drawAtlas() { - draw(GRIDS.ATLAS_DECK); - } - - const atlasRemainingCards = props.gridItems[GRIDS.ATLAS_DECK]?.length; - const deckRemainingCards = props.gridItems[GRIDS.DECK]?.length; - - return ( - - -

- {atlasRemainingCards} -

-
- -

- {deckRemainingCards} -

-
-
- ); -}; diff --git a/src/components/organisms/GameBoard/Footer/Decks/DeckPreviewModal.tsx b/src/components/organisms/GameBoard/Footer/Decks/DeckPreviewModal.tsx new file mode 100644 index 0000000..154c02b --- /dev/null +++ b/src/components/organisms/GameBoard/Footer/Decks/DeckPreviewModal.tsx @@ -0,0 +1,93 @@ +import { CardImage } from "@/components/atoms/card-view/card"; +import { GameCard } from "@/types/card"; +import { Box, Grid, HStack } from "styled-system/jsx"; +import { button } from "styled-system/recipes"; + +import { PiHandWithdrawFill as IconHand } from "react-icons/pi"; +import { useHover } from "@/utils/hooks/useHover"; +import { useRef } from "react"; +import { GameStateActions } from "../.."; +import { GRIDS } from "../../constants"; +import { actDrawDeckIndex } from "@/utils/actions"; + +export const DeckModalBody = ({ + deckType, + ...props +}: { + deckType?: "deck" | "atlas"; +} & GameStateActions) => { + if (deckType === undefined) return null; + + const deckTypeGridIndex = deckType === "deck" ? GRIDS.DECK : GRIDS.ATLAS_DECK; + const cards = props.gridItems[deckTypeGridIndex]; + + function returnToHand(cardIndex: number) { + if (deckType === undefined) return; + props.setGridItems(actDrawDeckIndex(props.gridItems, deckType, cardIndex)); + } + + return ( + + {cards?.map((card, index) => ( + returnToHand(index)} + /> + ))} + + ); +}; + +const Card = ({ + card, + returnToHand, +}: { + card: GameCard; + returnToHand(): void; +}) => { + const hoverRef = useRef(null); + const isHovering = useHover(hoverRef); + + return ( + + + + + ); +}; diff --git a/src/components/organisms/GameBoard/Footer/Decks/index.tsx b/src/components/organisms/GameBoard/Footer/Decks/index.tsx new file mode 100644 index 0000000..979cbf5 --- /dev/null +++ b/src/components/organisms/GameBoard/Footer/Decks/index.tsx @@ -0,0 +1,112 @@ +import { Grid, VStack } from "styled-system/jsx"; +import { + GRIDS, + LAYOUT_HEIGHTS, +} from "@/components/organisms/GameBoard/constants"; +import { GameStateActions } from "@/components/organisms/GameBoard"; +import { actDrawAtlas, actDrawDeck, actShuffleDeck } from "@/utils/actions"; +import { cva } from "styled-system/css/cva.mjs"; +import { Modal } from "@/components/atoms/Modal"; +import { useState } from "react"; +import { DeckModalBody } from "./DeckPreviewModal"; +import toast from "react-hot-toast"; + +export const DecksTray = (props: GameStateActions) => { + const [preview, setPreview] = useState<"deck" | "atlas">(); + + function draw(deck: GRIDS.DECK | GRIDS.ATLAS_DECK) { + deck === GRIDS.DECK + ? props.setGridItems(actDrawDeck(props.gridItems)) + : props.setGridItems(actDrawAtlas(props.gridItems)); + } + + function drawDeck() { + draw(GRIDS.DECK); + } + function drawAtlas() { + draw(GRIDS.ATLAS_DECK); + } + + function shuffleDeck(deckType: typeof preview) { + if (deckType) props.setGridItems(actShuffleDeck(props.gridItems, deckType)); + toast.success("Shuffled after closing"); + } + + const atlasRemainingCards = props.gridItems[GRIDS.ATLAS_DECK]?.length; + const deckRemainingCards = props.gridItems[GRIDS.DECK]?.length; + + return ( + <> + { + // when closing the modal, delay shuffling the deck briefly + // avoids briefly showing new deck order before modal closes + const wasOpen = preview; + setPreview(undefined); + setTimeout(() => { + if (!!wasOpen) shuffleDeck(wasOpen); + }, 100); + }, + }} + content={} + /> + + { + e.preventDefault(); + setPreview("atlas"); + }} + h="70px" + aspectRatio={3 / 2} + onClick={drawAtlas} + placeItems="center" + borderRadius="0.25rem" + cursor="pointer" + backgroundSize="cover" + style={{ + backgroundImage: "url(/card-backs/m_atlas.png)", + }} + > +

{atlasRemainingCards}

+
+ { + e.preventDefault(); + setPreview("deck"); + }} + w="60px" + aspectRatio={2 / 3} + onClick={drawDeck} + placeItems="center" + borderRadius="0.25rem" + cursor="pointer" + backgroundSize="cover" + style={{ + backgroundImage: "url(/card-backs/m_spells.png)", + }} + > +

{deckRemainingCards}

+
+
+ + ); +}; + +const remainingCards = cva({ + base: { + fontWeight: 700, + background: "rgba(255,255,255,0.7)", + padding: "0 0.25rem", + borderRadius: "0.25rem", + }, +}); diff --git a/src/components/organisms/GameBoard/Footer/index.tsx b/src/components/organisms/GameBoard/Footer/index.tsx index 7d65e6c..95906f8 100644 --- a/src/components/organisms/GameBoard/Footer/index.tsx +++ b/src/components/organisms/GameBoard/Footer/index.tsx @@ -40,7 +40,7 @@ export const GameFooter = (props: GameStateActions) => { overflowX: "auto", }} > - + diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 9e4038e..2872b1a 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,6 +1,7 @@ import "@/styles/globals.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AppProps } from "next/app"; +import { Toaster } from "react-hot-toast"; const queryClient = new QueryClient(); @@ -8,6 +9,7 @@ export default function App({ Component, pageProps }: AppProps) { return ( + ); } diff --git a/src/pages/game.tsx b/src/pages/game.tsx index 346ed36..ed25720 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -5,8 +5,6 @@ import { GameCard, GameState } from "@/types/card"; import { useState } from "react"; const initCards: GameCard[][] = Array.from({ length: 36 }, () => []); -// initCards[GRIDS.HAND] = handCards as GameCard[]; -// initCards[GRIDS.DECK] = [...deckCards, ...mockDeck] as GameCard[]; export default function GamePage() { const [gridItems, setGridItems] = useState(initCards); diff --git a/src/utils/actions/deck.ts b/src/utils/actions/deck.ts index 9daea07..59687ad 100644 --- a/src/utils/actions/deck.ts +++ b/src/utils/actions/deck.ts @@ -1,6 +1,25 @@ import { GRIDS } from "@/components/organisms/GameBoard/constants"; import { GameState } from "@/types/card"; +/** + * Shuffle deck with fisher yates algorithm + * */ +export function actShuffleDeck(state: GameState, deckType: "deck" | "atlas") { + const GRID_DECK_TYPE = deckType === "deck" ? GRIDS.DECK : GRIDS.ATLAS_DECK; + + const newState = [...state]; + const newDeck = [...newState[GRID_DECK_TYPE]]; + + const array = newDeck; + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); // Random index from 0 to i + [array[i], array[j]] = [array[j], array[i]]; // Swap elements + } + + newState[GRID_DECK_TYPE] = newDeck; + return newState; +} + export function actDrawDeck(state: GameState) { // Create a shallow copy of the previous grid items array const newState = [...state]; @@ -40,3 +59,33 @@ export function actDrawAtlas(state: GameState) { // Return the updated newState array to trigger a re-render return newState; } + +export function actDrawDeckIndex( + state: GameState, + deckType: "deck" | "atlas", + cardIndex: number, +) { + // Create a shallow copy of the previous grid items array + const newState = [...state]; + const GRID_DECK_TYPE = deckType === "deck" ? GRIDS.DECK : GRIDS.ATLAS_DECK; + + // Make a copy of the deck and hand arrays, preserving their positions + const newDeck = [...newState[GRID_DECK_TYPE]]; + const newHand = [...newState[GRIDS.HAND]]; + + // Ensure the cardIndex is within bounds + if (cardIndex >= 0 && cardIndex < newDeck.length) { + // Remove the card at the specified index from the deck + const [card] = newDeck.splice(cardIndex, 1); + + // Push the card to the hand if it exists + if (card) newHand.push(card); + } + + // Update the newState array with the updated deck and hand arrays + newState[GRID_DECK_TYPE] = newDeck; + newState[GRIDS.HAND] = newHand; + + // Return the updated newState array to trigger a re-render + return newState; +}