diff --git a/panda.config.ts b/panda.config.ts index 727a08a..31cf76e 100644 --- a/panda.config.ts +++ b/panda.config.ts @@ -70,6 +70,14 @@ export default defineConfig({ // Useful for theme customization theme: { extend: { + keyframes: { + show: { + '100%': { opacity: 1 }, + }, + enemyCardPlayed: { + '100%': { opacity: 0.5, filter: 'grayscale(1)' }, + }, + }, tokens: { fonts: { philosopher: { value: 'var(--font-philosopher), sans-serif' }, diff --git a/src/components/@common/Drawer.tsx b/src/components/@common/Drawer.tsx new file mode 100644 index 0000000..020db1f --- /dev/null +++ b/src/components/@common/Drawer.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Dialog as ArkDrawer } from '@ark-ui/react/dialog'; +import { ark } from '@ark-ui/react/factory'; +import { styled } from '@style/jsx'; +import { drawer } from '@style/recipes'; +import { createStyleContext } from 'lib/create-style-context'; +import type { ComponentProps } from 'react'; + +const { withProvider, withContext } = createStyleContext(drawer); + +export const Root = withProvider(ArkDrawer.Root); +export const Backdrop = withContext(styled(ArkDrawer.Backdrop), 'backdrop'); +export const Body = withContext(styled(ark.div), 'body'); +export const CloseTrigger = withContext( + styled(ArkDrawer.CloseTrigger), + 'closeTrigger', +); +export const Content = withContext(styled(ArkDrawer.Content), 'content'); +export const Description = withContext( + styled(ArkDrawer.Description), + 'description', +); +export const Footer = withContext(styled(ark.div), 'footer'); +export const Header = withContext(styled(ark.div), 'header'); +export const Positioner = withContext( + styled(ArkDrawer.Positioner), + 'positioner', +); +export const Title = withContext(styled(ArkDrawer.Title), 'title'); +export const Trigger = withContext(styled(ArkDrawer.Trigger), 'trigger'); + +export type RootProps = ComponentProps; +export type BackdropProps = ComponentProps; +export type BodyProps = ComponentProps; +export type CloseTriggerProps = ComponentProps; +export type ContentProps = ComponentProps; +export type DescriptionProps = ComponentProps; +export type FooterProps = ComponentProps; +export type HeaderProps = ComponentProps; +export type PositionerProps = ComponentProps; +export type TitleProps = ComponentProps; +export type TriggerProps = ComponentProps; diff --git a/src/components/@common/index.ts b/src/components/@common/index.ts index 65bc72c..cbe1fe0 100644 --- a/src/components/@common/index.ts +++ b/src/components/@common/index.ts @@ -2,6 +2,7 @@ export * from './Badge'; export * from './Button'; export * as Card from './Card'; export * as Dialog from './Dialog'; +export * as Drawer from './Drawer'; export * from './Heading'; export * from './IconButton'; export * from './Input'; diff --git a/src/components/@scenario/CardThumbnail.tsx b/src/components/@scenario/CardThumbnail.tsx index 97b26ba..2abe992 100644 --- a/src/components/@scenario/CardThumbnail.tsx +++ b/src/components/@scenario/CardThumbnail.tsx @@ -19,6 +19,7 @@ const CardThumbnail = ({ name, image }: Props) => { justifyContent="center" alignItems="center" height="75px" + width="auto" aspectRatio="128/147" position="relative" cursor="pointer" diff --git a/src/components/@scenario/EnemyCard.tsx b/src/components/@scenario/EnemyCard.tsx index 14d2b78..219342d 100644 --- a/src/components/@scenario/EnemyCard.tsx +++ b/src/components/@scenario/EnemyCard.tsx @@ -2,7 +2,9 @@ import { Box, Divider } from '@style/jsx'; import { Icon } from 'icons'; +import { useShallow } from 'zustand/react/shallow'; +import { useInitiative } from 'hooks/useInitiative'; import { useStore } from 'services/stores'; import type { BossDeck, MonsterDeck } from 'types/deck.types'; @@ -18,7 +20,10 @@ interface Props { } const EnemyCard = ({ deck }: Props) => { - const activeCard = useStore((state) => state.activeCards[deck.name]); + const [activeCard] = useStore( + useShallow((state) => [state.activeCards[deck.name]]), + ); + const { isActiveTurn, hasPlayed } = useInitiative(); const { closeEnemy, selectCard, clearCard } = useStore( (state) => state.actions, ); @@ -28,8 +33,18 @@ const EnemyCard = ({ deck }: Props) => { const handleClearCard = () => clearCard(deck.name); + const hasEnemyPlayed = hasPlayed(deck.name); + const isEnemyActive = isActiveTurn(deck.name); + return ( - + {deck.isBoss ? ( @@ -52,7 +67,7 @@ const EnemyCard = ({ deck }: Props) => { - {activeCard ? ( + {activeCard && !hasEnemyPlayed ? ( deck.isBoss ? ( - Object.values(CharacterNames).includes(name as CharacterNames); - -const InitiativeList = () => { - const initiatives = useStore((state) => state.initiatives); - console.log(initiatives); - const { toggleInitiativePlayed } = useStore((state) => state.actions); - - const sortedInitiatives = Object.values(initiatives).sort( - (initiativeA, initiativeB) => - initiativeA.initiative - initiativeB.initiative, - ); - - console.log({ sortedInitiatives }); - - return ( - - - {sortedInitiatives.map((initiative) => { - const isCharacter = isCharacterName(initiative.name); - const icon = isCharacterName(initiative.name) - ? `/images/characters/${CHARACTERS[initiative.name].icon}` - : `/images/thumbnails/${getEnemyArtwork(initiative.name)}`; - - return ( - toggleInitiativePlayed(initiative.name)} - > - {initiative.name} - - ); - })} - - - ); -}; - -export default InitiativeList; diff --git a/src/components/@scenario/InitiativeList/InitiativeList.tsx b/src/components/@scenario/InitiativeList/InitiativeList.tsx new file mode 100644 index 0000000..1f6bbfd --- /dev/null +++ b/src/components/@scenario/InitiativeList/InitiativeList.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { Box, Stack } from '@style/jsx'; +import { Icon } from 'icons'; +import { ArrowLeftFromLineIcon } from 'lucide-react'; +import { useLayoutEffect, useState } from 'react'; + +import { useInitiative } from 'hooks/useInitiative'; +import { useStore } from 'services/stores'; +import { CharacterNames } from 'types/character.types'; +import { EnemyNames } from 'types/enemies.types'; + +import { Drawer, IconButton } from 'components/@common'; + +import Item from './Item'; +import Widget from './Widget'; + +const InitiativeList = () => { + const [drawerOpen, setDrawerOpen] = useState(false); + const { initiatives, activeTurn, hasPlayed, roundEnded } = useInitiative(); + const { toggleInitiativePlayed } = useStore((state) => state.actions); + + const handleToggleInitiativePlayed = (name: CharacterNames | EnemyNames) => { + if (name === activeTurn || hasPlayed(name)) { + toggleInitiativePlayed(name); + } + }; + + useLayoutEffect(() => { + if (roundEnded) setDrawerOpen(false); + }, [roundEnded]); + + return ( + + + {initiatives.map((initiative) => ( + + ))} + + + {!!initiatives.length && ( + setDrawerOpen(e.open)} + > + + + + + + + + + Initiative overview + + + + + + + + + + {initiatives.map((initiative) => ( + + ))} + + + + + + )} + + ); +}; + +export default InitiativeList; diff --git a/src/components/@scenario/InitiativeList/Item.tsx b/src/components/@scenario/InitiativeList/Item.tsx new file mode 100644 index 0000000..c68a585 --- /dev/null +++ b/src/components/@scenario/InitiativeList/Item.tsx @@ -0,0 +1,47 @@ +import { Box } from '@style/jsx'; +import { CHARACTERS } from 'data/characters'; + +import { CharacterNames } from 'types/character.types'; +import { EnemyNames } from 'types/enemies.types'; +import { Initiative } from 'types/initiative.types'; + +import { Text } from 'components/@common'; + +import Thumbnail from './Thumbnail'; +import { isCharacterName } from './utils'; + +interface Props { + initiative: Initiative; + onClick: (name: CharacterNames | EnemyNames) => void; +} + +const Item = ({ initiative, onClick }: Props) => { + return ( + onClick(initiative.name)} + display="flex" + alignItems="center" + justifyContent="space-between" + gap={4} + > + + + + {isCharacterName(initiative.name) + ? CHARACTERS[initiative.name].spoilerName + : initiative.name} + + + + {initiative.initiative} + + + ); +}; + +export default Item; diff --git a/src/components/@scenario/InitiativeList/Thumbnail.tsx b/src/components/@scenario/InitiativeList/Thumbnail.tsx new file mode 100644 index 0000000..40aa734 --- /dev/null +++ b/src/components/@scenario/InitiativeList/Thumbnail.tsx @@ -0,0 +1,67 @@ +import { cva } from '@style/css'; +import { CHARACTERS } from 'data/characters'; +import Image from 'next/image'; + +import { getEnemyArtwork } from 'utils/deck.utils'; + +import { Initiative } from 'types/initiative.types'; + +import { isCharacterName } from './utils'; + +const inactiveImage = cva({ + base: { filter: 'none' }, + variants: { + state: { + disabled: {}, + active: { filter: 'none' }, + }, + type: { + character: {}, + enemy: {}, + }, + }, + compoundVariants: [ + { + state: 'disabled', + type: 'character', + css: { + filter: + 'brightness(0) invert(24%) sepia(2%) saturate(17%) hue-rotate(324deg) brightness(98%) contrast(82%)', + }, + }, + { + state: 'disabled', + type: 'enemy', + css: { + filter: 'grayscale(1) brightness(75%)', + }, + }, + ], +}); + +interface Props { + size: number; + initiative: Initiative; +} + +const Thumbnail = ({ initiative, size }: Props) => { + const isCharacter = isCharacterName(initiative.name); + const icon = isCharacterName(initiative.name) + ? `/images/characters/${CHARACTERS[initiative.name].icon}` + : `/images/thumbnails/${getEnemyArtwork(initiative.name)}`; + + return ( + {initiative.name} + ); +}; + +export default Thumbnail; diff --git a/src/components/@scenario/InitiativeList/Widget.tsx b/src/components/@scenario/InitiativeList/Widget.tsx new file mode 100644 index 0000000..4a9ac6d --- /dev/null +++ b/src/components/@scenario/InitiativeList/Widget.tsx @@ -0,0 +1,31 @@ +import { Box } from '@style/jsx'; + +import { CharacterNames } from 'types/character.types'; +import { EnemyNames } from 'types/enemies.types'; +import { Initiative } from 'types/initiative.types'; + +import Thumbnail from './Thumbnail'; + +interface Props { + initiative: Initiative; + activeTurn: boolean; + onClick: (name: CharacterNames | EnemyNames) => void; +} + +const Widget = ({ initiative, activeTurn, onClick }: Props) => { + return ( + onClick(initiative.name)} + opacity={0} + transform={activeTurn ? 'scale(1.3)' : 'none'} + transition="transform 150ms ease-in" + animation="show 300ms 100ms ease-in forwards" + > + + + ); +}; + +export default Widget; diff --git a/src/components/@scenario/InitiativeList/index.ts b/src/components/@scenario/InitiativeList/index.ts new file mode 100644 index 0000000..cf191e2 --- /dev/null +++ b/src/components/@scenario/InitiativeList/index.ts @@ -0,0 +1 @@ +export { default } from './InitiativeList'; diff --git a/src/components/@scenario/InitiativeList/utils.ts b/src/components/@scenario/InitiativeList/utils.ts new file mode 100644 index 0000000..07fed6f --- /dev/null +++ b/src/components/@scenario/InitiativeList/utils.ts @@ -0,0 +1,4 @@ +import { CharacterNames } from 'types/character.types'; + +export const isCharacterName = (name: string): name is CharacterNames => + Object.values(CharacterNames).includes(name as CharacterNames); diff --git a/src/components/@scenario/Navbar.tsx b/src/components/@scenario/Navbar.tsx index 97065b2..97d08d2 100644 --- a/src/components/@scenario/Navbar.tsx +++ b/src/components/@scenario/Navbar.tsx @@ -10,7 +10,7 @@ import { MenuIcon, UsersIcon, } from 'lucide-react'; -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useStore } from 'services/stores'; @@ -28,10 +28,19 @@ interface Props { } const Navbar = ({ scenarioName }: Props) => { - const [level, characters, deckSortBy] = useStore( - useShallow((state) => [state.level, state.characters, state.deckSortBy]), + const [level, characters, deckSortBy, initiatives] = useStore( + useShallow((state) => [ + state.level, + state.characters, + state.deckSortBy, + state.initiatives, + ]), ); + const roundEnded = + Object.values(initiatives).every((initiative) => initiative.played) && + Object.values(initiatives).length > 0; + const { setLevel, setCharacters, @@ -61,6 +70,12 @@ const Navbar = ({ scenarioName }: Props) => { } }; + useLayoutEffect(() => { + if (roundEnded) { + setIsNewRoundOpen(true); + } + }, [roundEnded]); + const menuState = { sorting: deckSortBy === 'initiative' ? 'initiative' : 'scenario', }; diff --git a/src/components/@scenario/styles.ts b/src/components/@scenario/styles.ts index de320c7..b02aece 100644 --- a/src/components/@scenario/styles.ts +++ b/src/components/@scenario/styles.ts @@ -14,48 +14,3 @@ export const characterInactive = cva({ }, }, }); - -export const monsterInactive = cva({ - base: { filter: 'none' }, - variants: { - state: { - disabled: { - filter: 'grey-scale(1)', - }, - active: { - filter: 'none', - }, - }, - }, -}); - -export const inactiveImage = cva({ - base: { filter: 'none' }, - variants: { - state: { - disabled: {}, - active: { filter: 'none' }, - }, - type: { - character: {}, - enemy: {}, - }, - }, - compoundVariants: [ - { - state: 'disabled', - type: 'character', - css: { - filter: - 'brightness(0) invert(24%) sepia(2%) saturate(17%) hue-rotate(324deg) brightness(98%) contrast(82%)', - }, - }, - { - state: 'disabled', - type: 'enemy', - css: { - filter: 'grayscale(1) brightness(75%)', - }, - }, - ], -}); diff --git a/src/hooks/useInitiative.ts b/src/hooks/useInitiative.ts new file mode 100644 index 0000000..55fb346 --- /dev/null +++ b/src/hooks/useInitiative.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react'; + +import { useStore } from 'services/stores'; +import { CharacterNames } from 'types/character.types'; +import { EnemyNames } from 'types/enemies.types'; + +export const useInitiative = () => { + const initiatives = useStore((store) => store.initiatives); + + const sortedInitiatives = Object.values(initiatives).sort( + (initiativeA, initiativeB) => + initiativeA.initiative - initiativeB.initiative, + ); + + const activeTurnIdx = sortedInitiatives.findIndex( + (initiative) => !initiative.played, + ); + + const activeTurn = sortedInitiatives[activeTurnIdx]?.name; + const nextTurn = sortedInitiatives[activeTurnIdx + 1]?.name; + + const hasPlayed = useCallback( + (name: CharacterNames | EnemyNames) => initiatives[name]?.played, + [initiatives], + ); + + const isActiveTurn = useCallback( + (name: CharacterNames | EnemyNames) => name === activeTurn, + [activeTurn], + ); + + const roundEnded = sortedInitiatives.every((initiative) => initiative.played); + + return { + initiatives: sortedInitiatives, + isActiveTurn, + activeTurn, + nextTurn, + hasPlayed, + roundEnded, + }; +}; diff --git a/src/services/stores/store.ts b/src/services/stores/store.ts index 027bfd4..47bd675 100644 --- a/src/services/stores/store.ts +++ b/src/services/stores/store.ts @@ -117,6 +117,7 @@ export const createMonsterMirrorStore = ( set((state) => { state.enemies = state.enemies.filter((deck) => deck !== enemy); state.activeCards[enemy] = undefined; + delete state.initiatives[enemy]; }), setDeckSortBy: (sortBy) => @@ -138,6 +139,7 @@ export const createMonsterMirrorStore = ( clearCard: (enemy) => set((state) => { state.activeCards[enemy] = undefined; + delete state.initiatives[enemy]; }), clearActiveCards: () =>