├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── images │ ├── cards │ │ ├── Enhancers.png │ │ ├── Jokers.png │ │ ├── Tarots.png │ │ ├── Vouchers.png │ │ └── stickers.png │ ├── check.png │ ├── chip.png │ └── chips.png └── sounds │ ├── button.mp3 │ ├── chips1.mp3 │ ├── paper1.mp3 │ └── tarot2.mp3 ├── src ├── app │ ├── error.tsx │ ├── favicon.ico │ ├── icon.png │ ├── layout.tsx │ ├── not-found.tsx │ ├── opengraph-image.png │ ├── page.tsx │ └── styles │ │ ├── colors.css │ │ ├── globals.css │ │ └── m6x11plus.ttf ├── components │ ├── inputs │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── Combobox.tsx │ │ ├── Filters.tsx │ │ ├── InputContainer.tsx │ │ ├── Search.tsx │ │ ├── Select.tsx │ │ └── inputs.module.css │ ├── pieces │ │ ├── Card.tsx │ │ ├── Chips.tsx │ │ ├── Hand.tsx │ │ ├── Tooltip.tsx │ │ └── pieces.module.css │ ├── sections │ │ ├── collection │ │ │ ├── Collection.tsx │ │ │ ├── Graph.tsx │ │ │ ├── Grid.tsx │ │ │ ├── List.tsx │ │ │ └── collection.module.css │ │ ├── footer │ │ │ └── Footer.tsx │ │ ├── hero │ │ │ ├── Hero.tsx │ │ │ └── hero.module.css │ │ ├── navbar │ │ │ ├── NavBar.tsx │ │ │ └── navbar.module.css │ │ ├── popups │ │ │ ├── Help.tsx │ │ │ ├── LogIn.tsx │ │ │ ├── Options.tsx │ │ │ ├── Popup.tsx │ │ │ └── popup.module.css │ │ └── records │ │ │ ├── CareerStats.tsx │ │ │ ├── HandStats.tsx │ │ │ ├── HighScores.tsx │ │ │ ├── Records.tsx │ │ │ └── records.module.css │ └── stats │ │ ├── HandStat.tsx │ │ ├── Progress.tsx │ │ ├── Stat.tsx │ │ └── stat.module.css └── lib │ ├── cards │ ├── cardKeys.ts │ ├── cardMappings.ts │ └── cards.ts │ ├── context.ts │ ├── profile.ts │ ├── settings.ts │ ├── types.ts │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Balatro Stats 2 | 3 | [balatrostats.com](https://www.balatrostats.com/) 4 | 5 | View your stats in Balatro! 6 | 7 | - View all the stats the game tracks, including the hidden ones. 8 | - Track, sort, and graph your collection with various filters. 9 | - Share your stats through a saved image or [PLANNED] a URL. 10 | 11 | ## What's New 12 | 13 | - 07/31/2024 - Launch 14 | - 03/15/2025 - Added support for custom cards and error handling 15 | 16 | Oops, it's been a while. I've been busy with real life and I lost interest in Balatro after trying to gold stake black deck, but slow progress is coming for V2. I am also working on another Balatro-related project that will probably come out first, so stay tuned. 17 | 18 | ## Running the app locally 19 | 20 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 21 | 22 | First, run the development server: 23 | 24 | ```bash 25 | npm run dev 26 | # or 27 | yarn dev 28 | # or 29 | pnpm dev 30 | # or 31 | bun dev 32 | ``` 33 | 34 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | export default nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "balatro-stats", 3 | "version": "1.0.2", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "prettier": "prettier --write ." 10 | }, 11 | "dependencies": { 12 | "classnames": "^2.5.1", 13 | "file-saver": "^2.0.5", 14 | "html-to-image": "^1.11.11", 15 | "immer": "^10.1.1", 16 | "next": "^14.2.5", 17 | "next-auth": "^5.0.0-beta.20", 18 | "pako": "^2.1.0", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "use-immer": "^0.10.0", 22 | "use-sound": "^4.0.3" 23 | }, 24 | "devDependencies": { 25 | "@types/file-saver": "^2.0.7", 26 | "@types/howler": "^2.2.11", 27 | "@types/node": "^20", 28 | "@types/pako": "^2.0.3", 29 | "@types/react": "^18", 30 | "@types/react-dom": "^18", 31 | "eslint": "^8", 32 | "eslint-config-next": "14.2.4", 33 | "postcss": "^8", 34 | "prettier": "^3.3.3", 35 | "tailwindcss": "^3.4.1", 36 | "typescript": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /public/images/cards/Enhancers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Enhancers.png -------------------------------------------------------------------------------- /public/images/cards/Jokers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Jokers.png -------------------------------------------------------------------------------- /public/images/cards/Tarots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Tarots.png -------------------------------------------------------------------------------- /public/images/cards/Vouchers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/Vouchers.png -------------------------------------------------------------------------------- /public/images/cards/stickers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/cards/stickers.png -------------------------------------------------------------------------------- /public/images/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/check.png -------------------------------------------------------------------------------- /public/images/chip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/chip.png -------------------------------------------------------------------------------- /public/images/chips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/images/chips.png -------------------------------------------------------------------------------- /public/sounds/button.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/button.mp3 -------------------------------------------------------------------------------- /public/sounds/chips1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/chips1.mp3 -------------------------------------------------------------------------------- /public/sounds/paper1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/paper1.mp3 -------------------------------------------------------------------------------- /public/sounds/tarot2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/public/sounds/tarot2.mp3 -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string } 10 | reset: () => void 11 | }) { 12 | return ( 13 |
21 |

Oops! Something went wrong:

22 |
28 | {error.message} 29 |
30 | 31 |
32 | Crash Reports are set to Off. If you would like to send crash reports, 33 | please{' '} 34 | 35 | create a new issue in the GitHub 36 | 37 | . 38 |
39 | 40 | 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/icon.png -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import localFont from 'next/font/local' 3 | import './styles/globals.css' 4 | import './styles/colors.css' 5 | 6 | const balatroFont = localFont({ 7 | src: './styles/m6x11plus.ttf', 8 | }) 9 | 10 | export const metadata: Metadata = { 11 | title: 'Balatro Stats', 12 | description: 'View your stats in Balatro!', 13 | } 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode 19 | }>) { 20 | return ( 21 | 22 | {children} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { soundContext } from '@/lib/context' 4 | import { jokerExtraSprites, jokerSprites } from '@/lib/cards/cardMappings' 5 | import Card from '@/components/pieces/Card' 6 | import Link from 'next/link' 7 | 8 | export default function NotFound() { 9 | const joker = { 10 | name: '404', 11 | image: `url(/images/cards/Jokers.png) ${jokerSprites.undiscovered} / 1000%`, 12 | topImage: `url(/images/cards/Enhancers.png) ${jokerExtraSprites.undiscovered} / 700% 500%`, 13 | } 14 | 15 | const sounds = { 16 | cardSound: () => {}, 17 | } 18 | 19 | return ( 20 | 21 |
31 | 37 | Go Back 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/opengraph-image.png -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { initializeSettings } from '@/lib/settings' 4 | import { useEffect, useState } from 'react' 5 | import { useImmer } from 'use-immer' 6 | import useSound from 'use-sound' 7 | import { initialProfile } from '@/lib/profile' 8 | import Hero from '@/components/sections/hero/Hero' 9 | import NavBar from '@/components/sections/navbar/NavBar' 10 | import Records from '@/components/sections/records/Records' 11 | import Collection from '@/components/sections/collection/Collection' 12 | import Footer from '@/components/sections/footer/Footer' 13 | import { settingsContext, soundContext } from '@/lib/context' 14 | 15 | export default function Page() { 16 | // initialize and update profile 17 | const [profile, setProfile] = useState(initialProfile) 18 | const [saved, setSaved] = useState(false) 19 | useEffect(() => { 20 | const savedProfile = localStorage.getItem('profile') 21 | if (savedProfile) { 22 | setProfile(JSON.parse(savedProfile)) 23 | setSaved(true) 24 | } 25 | }, [setProfile]) 26 | 27 | // initialize and update settings 28 | const [settings, setSettings] = useImmer(initializeSettings()) 29 | useEffect(() => { 30 | const savedSettings = localStorage.getItem('settings') 31 | if (savedSettings) { 32 | const parsedSettings: Record = JSON.parse(savedSettings) 33 | setSettings((draft) => { 34 | Object.entries(parsedSettings).forEach(([setting, enabled]) => { 35 | if (draft[setting] !== undefined) { 36 | draft[setting].enabled = enabled 37 | } 38 | }) 39 | }) 40 | } 41 | }, [settings, setSettings]) 42 | 43 | // initialize sounds 44 | // This prevents us creating a new sound function every time we create a component using a sound 45 | const [buttonSound] = useSound('/sounds/button.mp3', { 46 | volume: 0.3, 47 | playbackRate: 0.95, 48 | soundEnabled: settings.soundEnabled.enabled, 49 | }) 50 | const [cardSound] = useSound('/sounds/paper1.mp3', { 51 | volume: 0.5, 52 | soundEnabled: settings.soundEnabled.enabled, 53 | }) 54 | const [chipSound] = useSound('/sounds/chips1.mp3', { 55 | volume: 0.3, 56 | soundEnabled: settings.soundEnabled.enabled, 57 | }) 58 | const [tarotSound] = useSound('/sounds/tarot2.mp3', { 59 | volume: 0.66, 60 | soundEnabled: settings.soundEnabled.enabled, 61 | }) 62 | const sounds = { 63 | buttonSound: buttonSound, 64 | cardSound: cardSound, 65 | chipSound: chipSound, 66 | tarotSound: tarotSound, 67 | } 68 | 69 | return ( 70 | 71 | 72 |
73 | 74 | 75 | 76 | 77 |
78 |
79 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/app/styles/colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --shadow: rgba(0, 0, 20, 0.5); 3 | --black: #4f6367; 4 | --white: white; 5 | --red: #ff4c40; 6 | --blue: #0093ff; 7 | --yellow: #f5b244; 8 | --green: #429f79; 9 | --orange: #ff8f00; 10 | 11 | --Common: #0093ff; 12 | --Uncommon: #35bd86; 13 | --Rare: #ff4c40; 14 | --Legendary: #ab5bb5; 15 | 16 | --Joker: #5f7e85; 17 | --Deck: #525db0; 18 | --Tarot: #9e74ce; 19 | --Planet: #00a7ca; 20 | --Spectral: #2e76fd; 21 | --Voucher: #ff5611; 22 | 23 | --Unknown: #5f7e85; 24 | --Consumable: #5f7e85; 25 | } 26 | 27 | .red { 28 | background-color: var(--red); 29 | } 30 | 31 | .red:hover, 32 | .red:active, 33 | .red:focus { 34 | background-color: #a02721; 35 | } 36 | 37 | .blue { 38 | background-color: var(--blue); 39 | } 40 | 41 | .blue:hover, 42 | .blue:active, 43 | .blue:focus { 44 | background-color: #0057a1; 45 | } 46 | 47 | .orange { 48 | background-color: var(--orange); 49 | } 50 | 51 | .orange:hover, 52 | .orange:active, 53 | .orange:focus { 54 | background-color: #a05b00; 55 | } 56 | 57 | .green { 58 | background-color: var(--green); 59 | } 60 | 61 | .green:hover, 62 | .green:active, 63 | .green:focus { 64 | background-color: #215f46; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-size: calc(18px); 7 | line-height: 1.15; 8 | 9 | --card-width: 71px; 10 | --card-height: calc(var(--card-width) * 1.338); 11 | --chip-size: 29px; 12 | 13 | --shadow-length: 0.125rem; 14 | --text-shadow: 0.0625rem 0.0625rem var(--shadow); 15 | 16 | --button-width: 6rem; 17 | 18 | text-shadow: var(--text-shadow); 19 | } 20 | 21 | body { 22 | color: white; 23 | background: #438368; 24 | } 25 | 26 | main { 27 | margin: 0 auto; 28 | max-width: calc(var(--card-width) * 5.75); 29 | } 30 | 31 | a:hover { 32 | text-decoration-line: underline; 33 | text-underline-offset: 0.25rem; 34 | } 35 | 36 | @media (min-width: 768px) { 37 | :root { 38 | font-size: calc(18px + 1.25vmin); 39 | --card-width: calc(142px + 1vmin); 40 | --chip-size: calc(58px + 0.5vmin); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/styles/m6x11plus.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylebatucal/balatro-stats/0bc50eeb416519f2346709ac4f75f26839a14b98/src/app/styles/m6x11plus.ttf -------------------------------------------------------------------------------- /src/components/inputs/Button.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { CSSProperties, useContext } from 'react' 3 | import { soundContext } from '@/lib/context' 4 | import classNames from 'classnames/bind' 5 | 6 | export default function Button({ 7 | name, 8 | active, 9 | color, 10 | style, 11 | callback, 12 | disabled, 13 | underline, 14 | full, 15 | }: { 16 | name: string 17 | active?: boolean 18 | color?: 'red' | 'blue' | 'orange' 19 | style?: CSSProperties 20 | callback: () => void 21 | disabled?: boolean 22 | underline?: boolean 23 | full?: boolean 24 | }) { 25 | const sounds = useContext(soundContext) 26 | const play = sounds['buttonSound'] 27 | 28 | const cx = classNames.bind(styles) 29 | const buttonClasses = cx('button', { 30 | full: full, 31 | }) 32 | const insideClasses = cx('buttonInsides', color, { 33 | underline: underline, 34 | }) 35 | 36 | return ( 37 |
38 |
45 | 46 | 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/inputs/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { useContext } from 'react' 3 | import { soundContext } from '@/lib/context' 4 | 5 | export default function Checkbox({ 6 | label, 7 | checked, 8 | callback, 9 | }: { 10 | label: string 11 | checked: boolean 12 | callback: () => void 13 | }) { 14 | const sounds = useContext(soundContext) 15 | const play = sounds['buttonSound'] 16 | 17 | return ( 18 |
19 | { 25 | play() 26 | callback() 27 | }} 28 | /> 29 |
{label}
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/inputs/Combobox.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { 3 | Dispatch, 4 | RefObject, 5 | SetStateAction, 6 | useContext, 7 | useEffect, 8 | useRef, 9 | } from 'react' 10 | import { soundContext } from '@/lib/context' 11 | 12 | export default function Combobox({ 13 | children, 14 | options, 15 | current, 16 | popupOpen, 17 | setPopupOpen, 18 | inputRef, 19 | optionCallback, 20 | }: { 21 | children: React.ReactNode 22 | options: string[] 23 | current: string 24 | popupOpen: boolean 25 | setPopupOpen: Dispatch> 26 | inputRef: RefObject 27 | optionCallback?: (option: string) => void 28 | }) { 29 | const sounds = useContext(soundContext) 30 | const playButtonSound = sounds['buttonSound'] 31 | const playCardSound = sounds['cardSound'] 32 | 33 | // Make sure the options scroll 34 | const optionsRef = useRef(null) 35 | useEffect(() => { 36 | if (optionsRef.current) { 37 | const currentIndex = options.indexOf(current) 38 | if (current && currentIndex != -1 && popupOpen) { 39 | const optionsNode = 40 | optionsRef.current.querySelectorAll('li')[currentIndex] 41 | optionsNode.scrollIntoView({ block: 'nearest' }) 42 | } 43 | } 44 | }, [options, current, popupOpen]) 45 | 46 | return ( 47 |
{ 50 | playButtonSound() 51 | setPopupOpen(!popupOpen) 52 | }} 53 | onBlur={(e) => { 54 | if (!e.currentTarget.contains(e.relatedTarget)) { 55 | setPopupOpen(false) 56 | } 57 | }} 58 | > 59 | {children} 60 | 61 |
67 |
    74 | {options.map((option, i) => { 75 | return ( 76 |
  • { 81 | if (optionCallback !== undefined) { 82 | optionCallback(options[i]) 83 | } 84 | setPopupOpen(false) 85 | if (inputRef.current) { 86 | inputRef.current.focus() 87 | } 88 | }} 89 | onPointerEnter={() => 90 | playCardSound({ playBackRate: Math.random() * 0.2 + 0.9 }) 91 | } 92 | > 93 | {option} 94 |
  • 95 | ) 96 | })} 97 |
98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/inputs/Filters.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { FilterType } from '@/lib/types' 3 | import Button from './Button' 4 | import { Updater } from 'use-immer' 5 | import Checkbox from './Checkbox' 6 | import { useState } from 'react' 7 | 8 | export default function Filters({ 9 | filters, 10 | setFilters, 11 | }: { 12 | filters: FilterType[] 13 | setFilters: Updater> 14 | }) { 15 | const [open, setOpen] = useState(false) 16 | 17 | const activeFilterCount = filters 18 | .map((group) => { 19 | return Object.values(group.filters) 20 | .map(({ enabled }) => enabled) 21 | .filter(Boolean).length 22 | }) 23 | .reduce((sum, x) => sum + x, 0) 24 | 25 | return ( 26 |
32 |
73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /src/components/inputs/InputContainer.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import React from 'react' 3 | 4 | export default function InputContainer({ 5 | label, 6 | children, 7 | }: { 8 | label: string 9 | children: React.ReactNode 10 | }) { 11 | return ( 12 |
13 |
{label}
14 | {children} 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/inputs/Search.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { 3 | Dispatch, 4 | KeyboardEvent, 5 | SetStateAction, 6 | useContext, 7 | useRef, 8 | useState, 9 | } from 'react' 10 | import { soundContext } from '@/lib/context' 11 | import Combobox from './Combobox' 12 | 13 | function getAction(e: KeyboardEvent, popupOpen: boolean) { 14 | const { key, altKey, ctrlKey } = e 15 | if (altKey) { 16 | if (key == 'ArrowUp') { 17 | if (popupOpen) { 18 | return 'close' 19 | } 20 | return null 21 | } 22 | if (key == 'ArrowDown') { 23 | return 'open' 24 | } 25 | } 26 | 27 | if (ctrlKey) { 28 | if (key == 'Home') { 29 | return 'first' 30 | } 31 | if (key == 'End') return 'last' 32 | } 33 | 34 | switch (key) { 35 | case 'Escape': 36 | return 'clear' 37 | case 'Enter': 38 | return 'submit' 39 | case 'ArrowUp': 40 | return 'up' 41 | case 'ArrowDown': 42 | return 'down' 43 | case 'PageUp': 44 | return 'pageUp' 45 | case 'PageDown': 46 | return 'pageDown' 47 | } 48 | 49 | return null 50 | } 51 | 52 | export default function Search({ 53 | options, 54 | current, 55 | setCurrent, 56 | placeholder, 57 | }: { 58 | options: string[] 59 | current: string 60 | setCurrent: Dispatch> 61 | placeholder: string 62 | }) { 63 | const [popupOpen, setPopupOpen] = useState(false) 64 | const [selected, setSelected] = useState('') 65 | const pageSize = 15 66 | const filteredOptions = options.filter((option) => 67 | option.toLowerCase().includes(current.toLowerCase()), 68 | ) 69 | const inputRef = useRef(null) 70 | 71 | const sounds = useContext(soundContext) 72 | const playButtonSound = sounds['buttonSound'] 73 | const playCardSound = sounds['cardSound'] 74 | 75 | return ( 76 | { 83 | setCurrent(option) 84 | setSelected(option) 85 | }} 86 | > 87 | { 95 | setCurrent(e.target.value) 96 | setSelected(e.target.value) 97 | }} 98 | onKeyDown={(e) => { 99 | const action = getAction(e, popupOpen) 100 | let newIndex = 0 101 | 102 | switch (action) { 103 | case 'open': 104 | case 'submit': 105 | if (!popupOpen) { 106 | playButtonSound() 107 | } 108 | break 109 | case 'close': 110 | if (popupOpen) { 111 | playButtonSound() 112 | } 113 | break 114 | case 'up': 115 | case 'down': 116 | case 'pageUp': 117 | case 'pageDown': 118 | case 'first': 119 | case 'last': 120 | e.preventDefault() 121 | if (!popupOpen) { 122 | setPopupOpen(true) 123 | } 124 | playCardSound({ playbackRate: Math.random() * 0.2 + 0.9 }) 125 | break 126 | } 127 | 128 | switch (action) { 129 | case 'open': 130 | setPopupOpen(true) 131 | break 132 | case 'close': 133 | setPopupOpen(false) 134 | break 135 | case 'submit': 136 | setPopupOpen(false) 137 | setCurrent(selected) 138 | break 139 | 140 | case 'clear': 141 | if (popupOpen) { 142 | setPopupOpen(false) 143 | } else { 144 | setCurrent('') 145 | } 146 | setSelected(current) 147 | break 148 | 149 | case 'up': 150 | newIndex = Math.max(filteredOptions.indexOf(selected) - 1, 0) 151 | setSelected(filteredOptions[newIndex]) 152 | break 153 | case 'down': 154 | newIndex = Math.min( 155 | filteredOptions.indexOf(selected) + 1, 156 | filteredOptions.length - 1, 157 | ) 158 | setSelected(filteredOptions[newIndex]) 159 | break 160 | case 'pageUp': 161 | newIndex = Math.max( 162 | filteredOptions.indexOf(selected) - pageSize, 163 | 0, 164 | ) 165 | setSelected(filteredOptions[newIndex]) 166 | break 167 | case 'pageDown': 168 | newIndex = Math.min( 169 | filteredOptions.indexOf(selected) + pageSize, 170 | filteredOptions.length - 1, 171 | ) 172 | setSelected(filteredOptions[newIndex]) 173 | break 174 | case 'first': 175 | newIndex = 0 176 | setSelected(filteredOptions[newIndex]) 177 | break 178 | case 'last': 179 | newIndex = filteredOptions.length - 1 180 | setSelected(filteredOptions[newIndex]) 181 | break 182 | } 183 | }} 184 | /> 185 | 186 | ) 187 | } 188 | -------------------------------------------------------------------------------- /src/components/inputs/Select.tsx: -------------------------------------------------------------------------------- 1 | import styles from './inputs.module.css' 2 | import { 3 | useState, 4 | useContext, 5 | useEffect, 6 | useRef, 7 | KeyboardEvent, 8 | Dispatch, 9 | SetStateAction, 10 | } from 'react' 11 | import { soundContext } from '@/lib/context' 12 | import Combobox from './Combobox' 13 | 14 | function getAction(e: KeyboardEvent, popupOpen: boolean) { 15 | const { key, altKey, ctrlKey, metaKey } = e 16 | if (altKey) { 17 | if (key == 'ArrowUp') { 18 | if (popupOpen) { 19 | return 'close' 20 | } 21 | return null 22 | } 23 | if (key == 'ArrowDown') { 24 | return 'open' 25 | } 26 | } 27 | 28 | switch (key) { 29 | case 'Escape': 30 | return 'close' 31 | case ' ': 32 | case 'Enter': 33 | if (popupOpen) { 34 | return 'close' 35 | } else { 36 | return 'open' 37 | } 38 | case 'ArrowUp': 39 | return 'up' 40 | case 'ArrowDown': 41 | return 'down' 42 | case 'PageUp': 43 | case 'Home': 44 | return 'first' 45 | case 'PageDown': 46 | case 'End': 47 | return 'last' 48 | } 49 | 50 | if (key.length == 1 && key != 'Tab' && !altKey && !ctrlKey && !metaKey) { 51 | return 'type' 52 | } 53 | 54 | return null 55 | } 56 | 57 | export default function Select({ 58 | options, 59 | current, 60 | setCurrent, 61 | color, 62 | }: { 63 | options: string[] 64 | current: string 65 | setCurrent: Dispatch> 66 | color: 'orange' | 'green' 67 | }) { 68 | const [popupOpen, setPopupOpen] = useState(false) 69 | const [searchString, setSearchString] = useState('') 70 | 71 | useEffect(() => { 72 | const timeout = setTimeout(() => { 73 | setSearchString('') 74 | }, 1000) 75 | 76 | return () => clearTimeout(timeout) 77 | }, [searchString]) 78 | 79 | const inputRef = useRef(null) 80 | 81 | const sounds = useContext(soundContext) 82 | const playButtonSound = sounds['buttonSound'] 83 | const playCardSound = sounds['cardSound'] 84 | 85 | return ( 86 | setCurrent(option)} 93 | > 94 |
{ 99 | const action = getAction(e, popupOpen) 100 | let newIndex = 0 101 | 102 | switch (action) { 103 | case 'open': 104 | e.preventDefault() 105 | if (!popupOpen) { 106 | playButtonSound() 107 | } 108 | break 109 | case 'close': 110 | e.preventDefault() 111 | if (popupOpen) { 112 | playButtonSound() 113 | } 114 | break 115 | case 'up': 116 | case 'down': 117 | case 'first': 118 | case 'last': 119 | e.preventDefault() 120 | playCardSound({ playbackRate: Math.random() * 0.2 + 0.9 }) 121 | break 122 | } 123 | 124 | switch (action) { 125 | case 'type': 126 | setPopupOpen(true) 127 | 128 | const newSearchString = searchString + e.key 129 | setSearchString(newSearchString) 130 | 131 | let matches = options.filter((option) => 132 | option.toLowerCase().startsWith(newSearchString[0]), 133 | ) 134 | 135 | if (matches.length == 0) { 136 | return 137 | } else if ( 138 | newSearchString 139 | .split('') 140 | .every((letter) => letter == newSearchString[0]) 141 | ) { 142 | setCurrent( 143 | matches[(newSearchString.length - 1) % matches.length], 144 | ) 145 | } else { 146 | matches = options.filter((option) => 147 | option.toLowerCase().startsWith(newSearchString), 148 | ) 149 | if (matches[0]) { 150 | setCurrent(matches[0]) 151 | } 152 | } 153 | break 154 | 155 | case 'open': 156 | setPopupOpen(true) 157 | break 158 | case 'close': 159 | setPopupOpen(false) 160 | break 161 | case 'up': 162 | newIndex = Math.max(options.indexOf(current) - 1, 0) 163 | setCurrent(options[newIndex]) 164 | break 165 | case 'down': 166 | newIndex = Math.min( 167 | options.indexOf(current) + 1, 168 | options.length - 1, 169 | ) 170 | setCurrent(options[newIndex]) 171 | break 172 | case 'first': 173 | newIndex = 0 174 | setCurrent(options[newIndex]) 175 | break 176 | case 'last': 177 | newIndex = options.length - 1 178 | setCurrent(options[newIndex]) 179 | break 180 | } 181 | }} 182 | > 183 | {current} 184 |
185 |
186 | ) 187 | } 188 | -------------------------------------------------------------------------------- /src/components/inputs/inputs.module.css: -------------------------------------------------------------------------------- 1 | /* Button */ 2 | .buttonContainer { 3 | display: flex; 4 | position: relative; 5 | flex-direction: column; 6 | align-items: center; 7 | user-select: none; 8 | } 9 | 10 | .buttonContainer:active .buttonTriangle { 11 | transform: translateY(var(--shadow-length)); 12 | filter: none; 13 | } 14 | 15 | .button:active .buttonInsides { 16 | transform: translateY(var(--shadow-length)); 17 | box-shadow: none; 18 | } 19 | 20 | .buttonTriangle { 21 | position: absolute; 22 | bottom: 100%; 23 | width: 0; 24 | height: 0; 25 | border-style: solid; 26 | border-width: 0.65rem 0.375rem 0 0.375rem; 27 | padding-bottom: 0.5rem; 28 | filter: drop-shadow(0 var(--shadow-length) var(--shadow)); 29 | pointer-events: none; 30 | } 31 | 32 | .buttonInsides { 33 | box-shadow: 0 var(--shadow-length) var(--shadow); 34 | padding: 0.25rem 0.5rem; 35 | text-shadow: var(--text-shadow); 36 | border-radius: 0.5rem; 37 | } 38 | 39 | .buttonInsides:active { 40 | box-shadow: none; 41 | } 42 | 43 | @media (min-width: 1024px) { 44 | .buttonInsides { 45 | min-width: var(--button-width); 46 | } 47 | } 48 | 49 | .underline:hover { 50 | text-decoration-line: underline; 51 | text-underline-offset: 0.25rem; 52 | } 53 | 54 | .full { 55 | width: 100%; 56 | } 57 | 58 | /* Chicot reference */ 59 | .button:disabled .buttonInsides { 60 | color: #6b6b6b; 61 | background-color: #545454; 62 | } 63 | 64 | .button:disabled:active .buttonInsides { 65 | transform: translateY(0); 66 | box-shadow: 0 var(--shadow-length) var(--shadow); 67 | } 68 | 69 | .button:disabled .buttonInsides:hover { 70 | background-color: #2d2d2d; 71 | } 72 | 73 | /* Combobox */ 74 | .dropdown { 75 | position: relative; 76 | user-select: none; 77 | cursor: pointer; 78 | } 79 | 80 | .select { 81 | border-radius: 0.5rem; 82 | box-shadow: 0 var(--shadow-length) var(--shadow); 83 | color: white; 84 | text-shadow: var(--text-shadow); 85 | padding: 0.25rem; 86 | width: 100%; 87 | text-align: center; 88 | } 89 | 90 | .select::placeholder { 91 | color: #54bfff; 92 | } 93 | 94 | .select:active { 95 | box-shadow: none; 96 | transform: translateY(var(--shadow-length)); 97 | } 98 | 99 | .optionsContainer { 100 | position: absolute; 101 | display: grid; 102 | /* transition: grid-template-rows 100ms ease-in-out; */ 103 | width: 100%; 104 | } 105 | 106 | .options { 107 | background-color: white; 108 | color: var(--black); 109 | text-align: center; 110 | text-shadow: none; 111 | margin-top: 0.5rem; 112 | border-radius: 0.5rem; 113 | box-shadow: 0 var(--shadow-length) var(--shadow); 114 | z-index: 5; 115 | 116 | max-height: 15rem; 117 | overflow-y: scroll; 118 | 119 | /* transition: padding 100ms ease-in-out; */ 120 | } 121 | 122 | .current { 123 | background-color: #e0e0e6; 124 | } 125 | 126 | .option { 127 | border-radius: 0.5rem; 128 | padding: 0.125rem; 129 | cursor: pointer; 130 | } 131 | 132 | .option:hover { 133 | background-color: var(--blue); 134 | color: white; 135 | } 136 | 137 | /* Input Container */ 138 | .inputContainer { 139 | border-width: 0.125rem; 140 | border-radius: 0.5rem; 141 | box-shadow: 142 | 0 var(--shadow-length) #9c9da0, 143 | inset 0 var(--shadow-length) #9c9da0; 144 | padding: 0.5rem; 145 | text-align: center; 146 | margin-bottom: 0.5rem; 147 | } 148 | 149 | .inputContainerLabel { 150 | padding-bottom: 0.25rem; 151 | } 152 | 153 | /* Checkbox */ 154 | .checkbox { 155 | display: flex; 156 | align-items: center; 157 | gap: 0.5rem; 158 | } 159 | 160 | .checkboxInput { 161 | appearance: none; 162 | height: 1rem; 163 | width: 1rem; 164 | background-color: #1e2b2d; 165 | border-radius: 0.375rem; 166 | border-width: 0.125rem; 167 | border-color: white; 168 | box-shadow: 0 0.0625rem 0 #ababab; 169 | flex-shrink: 0; 170 | } 171 | 172 | .checkboxInput:checked { 173 | background-image: url('/images/check.png'); 174 | background-size: 100%; 175 | background-color: var(--red); 176 | } 177 | 178 | .checkboxInput:checked:hover { 179 | background-color: #a02721; 180 | } 181 | 182 | .checkboxInput:hover { 183 | background-color: #0a1213; 184 | box-shadow: 0 0.0625rem 0 #707070; 185 | } 186 | 187 | .checkboxInput:active { 188 | transform: translateY(0.0625rem); 189 | } 190 | 191 | /* Filters */ 192 | .filtersContainer { 193 | display: grid; 194 | /* transition: grid-template-rows 100ms ease-in-out; */ 195 | } 196 | 197 | .filters { 198 | display: grid; 199 | grid-auto-columns: 1fr; 200 | grid-auto-flow: column; 201 | justify-items: center; 202 | overflow: hidden; 203 | /* transition: padding 100ms ease-in-out; */ 204 | } 205 | 206 | .filterName { 207 | padding-bottom: 0.125rem; 208 | } 209 | -------------------------------------------------------------------------------- /src/components/pieces/Card.tsx: -------------------------------------------------------------------------------- 1 | import styles from './pieces.module.css' 2 | import { useContext, useRef, useState } from 'react' 3 | import { settingsContext, soundContext } from '@/lib/context' 4 | import Tooltip from './Tooltip' 5 | import { getHighestStake, sumStakes } from '@/lib/utils' 6 | import classNames from 'classnames/bind' 7 | import { jokerNames } from '@/lib/cards/cardMappings' 8 | 9 | function getStickerImage(sticker: number) { 10 | const stickerOffsets: Record = { 11 | 1: '-100% 0', 12 | 2: '-200% 0', 13 | 3: '-300% 0', 14 | 4: '0 -100%', 15 | 5: '-400% 0', 16 | 6: '-100% -100%', 17 | 7: '-200% -100%', 18 | 8: '-300% -100%', 19 | } 20 | return `url(/images/cards/stickers.png) ${stickerOffsets[sticker]} / 500%` 21 | } 22 | 23 | export default function Card({ 24 | name, 25 | wins, 26 | losses, 27 | count, 28 | status, 29 | image, 30 | topImage, 31 | style, 32 | desc, 33 | }: { 34 | name: string 35 | wins?: Record 36 | losses?: Record 37 | count?: number 38 | status?: string 39 | image: string 40 | topImage?: string 41 | style?: { translateY?: number; degrees?: number } 42 | desc?: string 43 | }) { 44 | const cardRef = useRef(null) 45 | const settings = useContext(settingsContext) 46 | const [[x, y], setDegrees] = useState([0, 0]) 47 | 48 | let opacity = 1 49 | if (settings.fadeCardsWithNoRounds.enabled) { 50 | opacity = count === undefined || count != 0 ? opacity : 0.5 51 | } 52 | if (settings.fadeCardsWithNoWins.enabled) { 53 | opacity = wins === undefined || (wins && sumStakes(wins)) ? opacity : 0.5 54 | } 55 | 56 | let stickerImage 57 | if (wins && Object.keys(wins).length != 0) { 58 | const sticker = getHighestStake(wins) 59 | stickerImage = getStickerImage(sticker) 60 | } 61 | 62 | const cx = classNames.bind(styles) 63 | const classes = cx({ 64 | topImage: topImage, 65 | legendary: 66 | !['Hologram', 'The Soul'].includes(name) && 67 | Object.values(jokerNames).includes(name), 68 | hologram: name == 'Hologram', 69 | soul: name == 'The Soul', 70 | undiscovered: !Object.values(jokerNames).includes(name), 71 | }) 72 | 73 | let transform = [] 74 | if (settings.cardPerspective.enabled) { 75 | transform.push(`rotateY(${x}deg) rotateX(${-y}deg)`) 76 | } 77 | if (name == 'Wee Joker') { 78 | transform.push('scale(0.75)') 79 | } 80 | if (style?.degrees) { 81 | transform.push(`rotate(${style.degrees}deg)`) 82 | } 83 | const transformStyle = transform.join(' ') 84 | 85 | const sounds = useContext(soundContext) 86 | const play = sounds['cardSound'] 87 | 88 | return ( 89 | sum + x, 0)} 92 | losses={losses && Object.values(losses).reduce((sum, x) => sum + x, 0)} 93 | rounds={count} 94 | status={status} 95 | translateY={style?.translateY} 96 | desc={desc} 97 | > 98 |
{ 107 | play({ playbackRate: Math.random() * 0.2 + 0.9 }) 108 | }} 109 | onPointerMove={(e) => { 110 | if (settings.cardPerspective.enabled && cardRef.current) { 111 | const { top, right, bottom, left, x, y } = 112 | cardRef.current.getBoundingClientRect() 113 | const width = right - left 114 | const height = bottom - top 115 | 116 | const { clientX, clientY } = e 117 | 118 | const xPercentage = (clientX - x) / width 119 | const yPercentage = (clientY - y) / height 120 | 121 | const degrees = 15 122 | setDegrees([ 123 | degrees * xPercentage - degrees / 2, 124 | degrees * yPercentage - degrees / 2, 125 | ]) 126 | } 127 | }} 128 | onPointerLeave={() => { 129 | setDegrees([0, 0]) 130 | }} 131 | > 132 | {topImage && ( 133 |
139 | )} 140 |
141 | 142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /src/components/pieces/Chips.tsx: -------------------------------------------------------------------------------- 1 | import styles from './pieces.module.css' 2 | import Tooltip from './Tooltip' 3 | import { useContext } from 'react' 4 | import { soundContext } from '@/lib/context' 5 | import { settingsContext } from '@/lib/context' 6 | import { getHighestStake } from '@/lib/utils' 7 | 8 | function getChipImage(chip: string) { 9 | const chipOffsets: Record = { 10 | 'White Stake': '0 0', 11 | 'Red Stake': '-100% 0%', 12 | 'Green Stake': '-200% 0%', 13 | 'Black Stake': '-400% 0%', 14 | 'Blue Stake': '-300% 0%', 15 | 'Purple Stake': '0 -100%', 16 | 'Orange Stake': '-100% -100%', 17 | 'Gold Stake': '-200% -100%', 18 | } 19 | return `url(/images/chips.png) ${chipOffsets[chip]} / 500%` 20 | } 21 | 22 | function Chip({ 23 | name, 24 | wins, 25 | losses, 26 | beaten, 27 | }: { 28 | name: string 29 | wins: number 30 | losses: number 31 | beaten: boolean 32 | }) { 33 | const sounds = useContext(soundContext) 34 | const play = sounds['chipSound'] 35 | 36 | return ( 37 | 38 |
45 | play({ playbackRate: Math.random() * 0.1 + 0.55 }) 46 | } 47 | /> 48 | 49 | ) 50 | } 51 | 52 | const stakes = { 53 | 1: 'White Stake', 54 | 2: 'Red Stake', 55 | 3: 'Green Stake', 56 | 4: 'Black Stake', 57 | 5: 'Blue Stake', 58 | 6: 'Purple Stake', 59 | 7: 'Orange Stake', 60 | 8: 'Gold Stake', 61 | allStakes: [1, 2, 3, 4, 5, 6, 7, 8] as const, 62 | } 63 | 64 | export default function Chips({ 65 | wins, 66 | losses, 67 | }: { 68 | wins: Record 69 | losses: Record 70 | }) { 71 | const settings = useContext(settingsContext) 72 | const highestStake = getHighestStake(wins) 73 | 74 | return ( 75 |
76 | {stakes.allStakes.map((stake, i) => { 77 | const condition = settings.highlightChipsOnlyIfWin.enabled 78 | ? wins[stake] 79 | : i < highestStake 80 | 81 | return ( 82 | 89 | ) 90 | })} 91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/components/pieces/Hand.tsx: -------------------------------------------------------------------------------- 1 | import { CardType } from '@/lib/types' 2 | import styles from './pieces.module.css' 3 | import Card from './Card' 4 | 5 | export default function Hand({ 6 | cards, 7 | gap, 8 | }: { 9 | cards: CardType[] 10 | gap: string 11 | }) { 12 | const maximumDegrees = 5 13 | 14 | return ( 15 |
21 | {cards.map((card, i) => { 22 | const translateY = 23 | -(1 / 2) * Math.sin((Math.PI / (cards.length - 1)) * i) - 0.5 24 | const degrees = 25 | cards.length > 1 26 | ? (maximumDegrees * 2 * i) / (cards.length - 1) - maximumDegrees 27 | : 0 28 | 29 | return ( 30 | 44 | ) 45 | })} 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/components/pieces/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import styles from './pieces.module.css' 2 | 3 | export default function Tooltip({ 4 | children, 5 | name, 6 | wins, 7 | losses, 8 | rounds, 9 | status, 10 | translateY, 11 | desc, 12 | }: { 13 | children: React.ReactNode 14 | name?: string 15 | wins?: number 16 | losses?: number 17 | rounds?: number 18 | status?: string 19 | translateY?: number 20 | desc?: string 21 | }) { 22 | const roundNoun = (() => { 23 | const plural = rounds != 1 24 | switch (status) { 25 | case 'Tarot': 26 | case 'Planet': 27 | case 'Spectral': 28 | case 'Consumable': 29 | return plural ? 'uses' : 'use' 30 | case 'Voucher': 31 | return plural ? 'redeems' : 'redeem' 32 | default: 33 | return plural ? 'rounds' : 'round' 34 | } 35 | })() 36 | let statusText = status 37 | if (name) { 38 | if (['Pluto', 'Ceres', 'Eris'].includes(name)) { 39 | statusText = 'Dwarf Planet' 40 | } else if (name == 'Planet X') { 41 | statusText = 'Planet?' 42 | } 43 | } 44 | 45 | return ( 46 |
52 |
53 | {name &&

{name}

} 54 | 55 |
56 | {wins != undefined && ( 57 |

58 | {wins}{' '} 59 | {wins != 1 ? 'wins' : 'win'} 60 |

61 | )} 62 | 63 | {losses != undefined && ( 64 |

65 | {losses}{' '} 66 | {losses != 1 ? 'losses' : 'loss'} 67 |

68 | )} 69 | 70 | {rounds != undefined && ( 71 |

72 | {rounds} {roundNoun} 73 |

74 | )} 75 | 76 | {desc &&

{desc}

} 77 |
78 | 79 | {status && ( 80 |
87 | {statusText} 88 |
89 | )} 90 |
91 | 92 |
{children}
93 |
94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/components/pieces/pieces.module.css: -------------------------------------------------------------------------------- 1 | /* Tooltip */ 2 | .tooltipWrapper { 3 | position: relative; 4 | user-select: none; 5 | width: fit-content; 6 | } 7 | 8 | .tooltipWrapper:hover { 9 | z-index: 1; 10 | } 11 | 12 | .tooltip { 13 | display: none; 14 | position: absolute; 15 | left: 50%; 16 | transform: translate(-50%); 17 | bottom: calc(100% + 0.5rem); 18 | text-align: center; 19 | 20 | background-color: #3f4a4d; 21 | border-color: #dee2ea; 22 | border-width: 0.125rem; 23 | border-radius: 0.5rem; 24 | box-shadow: 0 var(--shadow-length) 0 #919499; 25 | 26 | white-space: nowrap; 27 | 28 | padding: 0.1875rem; 29 | padding-bottom: calc(0.1875rem + var(--shadow-length)); 30 | z-index: 2; 31 | } 32 | 33 | .tooltipTitle { 34 | font-size: 125%; 35 | line-height: 1.3; 36 | padding: 0 0.125rem; 37 | } 38 | 39 | .tooltipDesc { 40 | background-color: white; 41 | color: var(--black); 42 | text-shadow: none; 43 | 44 | border-color: transparent; 45 | box-shadow: 0 var(--shadow-length) 0 #ababab; 46 | 47 | padding: 0 0.125rem; 48 | 49 | border-width: 0.125rem; 50 | border-radius: 0.5rem; 51 | } 52 | 53 | .tooltipStat { 54 | color: #ff8f00; 55 | } 56 | 57 | .tooltipStatus { 58 | font-size: 110%; 59 | padding-bottom: 0.0625rem; 60 | border-radius: 0.5rem; 61 | margin: 0 auto; 62 | margin-top: calc(0.1875rem + var(--shadow-length)); 63 | min-width: 5rem; 64 | padding: 0 0.75rem; 65 | padding-bottom: 0.0625rem; 66 | max-width: fit-content; 67 | } 68 | 69 | .tooltipItem { 70 | width: fit-content; 71 | perspective-origin: center bottom; 72 | perspective: var(--card-height); 73 | } 74 | 75 | .tooltipWrapper:has(.tooltipItem:hover) .tooltip { 76 | display: block; 77 | } 78 | 79 | /* Hand and Card */ 80 | .hand { 81 | display: grid; 82 | justify-content: center; 83 | align-items: center; 84 | justify-items: center; 85 | } 86 | 87 | .card { 88 | aspect-ratio: 71/95; 89 | width: var(--card-width); 90 | 91 | filter: drop-shadow(0 calc(1.5 * var(--shadow-length)) var(--shadow)); 92 | image-rendering: pixelated; 93 | backface-visibility: hidden; 94 | 95 | position: relative; 96 | --degrees: 0; 97 | 98 | background-color: white; 99 | } 100 | 101 | .card:hover { 102 | z-index: 1; 103 | animation: bounce 100ms ease forwards; 104 | will-change: transform; 105 | } 106 | 107 | .topImage { 108 | height: 100%; 109 | image-rendering: pixelated; 110 | } 111 | 112 | .hologram { 113 | filter: drop-shadow(0 0 0.25rem rgba(0, 255, 255, 0.8)); 114 | transform: scale(1.2); 115 | } 116 | 117 | .hologram:hover, 118 | .undiscovered:hover { 119 | animation: 120 | hologramScale 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate, 121 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s; 122 | } 123 | 124 | .legendary { 125 | filter: drop-shadow(0 var(--shadow-length) var(--shadow)); 126 | transform: scale(1.05); 127 | } 128 | 129 | .legendary:hover { 130 | animation: 131 | popoutScale 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate, 132 | popoutShadow 1.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate, 133 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s; 134 | } 135 | 136 | .soul { 137 | filter: drop-shadow(0 calc(2 * var(--shadow-length)) var(--shadow)); 138 | } 139 | 140 | .soul:hover { 141 | animation: 142 | soulScale 1s cubic-bezier(0.37, 0, 0.63, 1) infinite, 143 | popoutRotate 2.75s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate -4.125s; 144 | } 145 | 146 | /* Chip and Chips */ 147 | .chip { 148 | aspect-ratio: 29/29; 149 | width: var(--chip-size); 150 | 151 | image-rendering: pixelated; 152 | margin: 0.0625rem; 153 | } 154 | 155 | .chip:hover { 156 | transform: scale(1.15); 157 | } 158 | 159 | .chips { 160 | display: grid; 161 | grid-template-columns: repeat(8, minmax(0, 1fr)); 162 | justify-items: center; 163 | filter: drop-shadow(0 calc(0.75 * var(--shadow-length)) var(--shadow)); 164 | } 165 | 166 | /* Animations */ 167 | @keyframes bounce { 168 | /* Damped Sinusoidal Wave */ 169 | 8% { 170 | scale: 1.13; 171 | } 172 | 28% { 173 | scale: 1.08; 174 | } 175 | 48% { 176 | scale: 1.12; 177 | } 178 | 68% { 179 | scale: 1.09; 180 | } 181 | 88% { 182 | scale: 1.11; 183 | } 184 | 100% { 185 | scale: 1.1; 186 | } 187 | } 188 | 189 | @keyframes popoutScale { 190 | 0% { 191 | scale: 1.05; 192 | } 193 | 100% { 194 | scale: 1.08; 195 | } 196 | } 197 | 198 | @keyframes popoutRotate { 199 | 0% { 200 | rotate: 4deg; 201 | } 202 | 100% { 203 | rotate: -4deg; 204 | } 205 | } 206 | 207 | @keyframes popoutShadow { 208 | 0% { 209 | filter: drop-shadow(0 var(--shadow-length) var(--shadow)); 210 | } 211 | 100% { 212 | filter: drop-shadow(0 calc(2.5 * var(--shadow-length)) var(--shadow)); 213 | } 214 | } 215 | 216 | @keyframes hologramScale { 217 | 0% { 218 | scale: 1; 219 | } 220 | 100% { 221 | scale: 1.1; 222 | } 223 | } 224 | 225 | @keyframes soulScale { 226 | /* Modified version of bounce */ 227 | 0% { 228 | scale: 1; 229 | } 230 | 4% { 231 | scale: 1.13; 232 | } 233 | 14% { 234 | scale: 1.08; 235 | } 236 | 24% { 237 | scale: 1.12; 238 | } 239 | 34% { 240 | scale: 1.09; 241 | } 242 | 44% { 243 | scale: 1.11; 244 | } 245 | 68% { 246 | scale: 1.09; 247 | } 248 | 88% { 249 | scale: 1.1; 250 | } 251 | 100% { 252 | scale: 1; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/components/sections/collection/Collection.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react' 2 | import styles from './collection.module.css' 3 | import { CardType, FilterType, Profile } from '@/lib/types' 4 | import Button from '@/components/inputs/Button' 5 | import { 6 | filterCards, 7 | getHighestStake, 8 | sumStakes, 9 | useToImage, 10 | } from '@/lib/utils' 11 | import InputContainer from '../../inputs/InputContainer' 12 | import Select from '../../inputs/Select' 13 | import Search from '../../inputs/Search' 14 | import { 15 | initialJokers, 16 | initialDecks, 17 | initialConsumables, 18 | initialVouchers, 19 | initializeJoker, 20 | initializeDeck, 21 | initializeConsumable, 22 | initializeVouchers, 23 | } from '@/lib/cards/cards' 24 | import Grid from './Grid' 25 | import List from './List' 26 | import Graph from './Graph' 27 | import Filters from '../../inputs/Filters' 28 | import { useImmer } from 'use-immer' 29 | import { 30 | baseVoucherNames, 31 | upgradedVoucherNames, 32 | } from '../../../lib/cards/cardKeys' 33 | import { settingsContext } from '@/lib/context' 34 | 35 | interface TabType { 36 | name: string 37 | sorts: string[] 38 | graphs: string[] 39 | filters: string[] 40 | views: string[] 41 | } 42 | 43 | const tabs: Record = { 44 | Jokers: { 45 | name: 'Jokers', 46 | sorts: [ 47 | 'Game Order', 48 | 'Name', 49 | 'Wins', 50 | 'Win %', 51 | 'Wins per Loss', 52 | 'Losses', 53 | 'Rounds', 54 | 'Rarity', 55 | 'Stake', 56 | ], 57 | graphs: ['Rounds', 'Wins', 'Win %', 'Wins per Loss', 'Losses'], 58 | filters: ['Stake', 'Rarity'], 59 | views: ['Grid', 'List', 'Graph'], 60 | }, 61 | Decks: { 62 | name: 'Decks', 63 | sorts: [ 64 | 'Game Order', 65 | 'Name', 66 | 'Wins', 67 | 'Win %', 68 | 'Wins per Loss', 69 | 'Losses', 70 | 'Stake', 71 | ], 72 | graphs: ['Wins', 'Win %', 'Wins per Loss', 'Losses'], 73 | filters: ['Stake'], 74 | views: ['Grid', 'List', 'Graph'], 75 | }, 76 | Consumables: { 77 | name: 'Consumables', 78 | sorts: ['Game Order', 'Name', 'Uses'], 79 | graphs: ['Uses'], 80 | filters: ['Type'], 81 | views: ['Grid', 'Graph'], 82 | }, 83 | Vouchers: { 84 | name: 'Vouchers', 85 | sorts: ['Game Order', 'Name', 'Redeems'], 86 | graphs: ['Redeems'], 87 | filters: ['Tier'], 88 | views: ['Grid', 'Graph'], 89 | }, 90 | } 91 | 92 | const cardFilters: Record = { 93 | Stake: { 94 | name: 'Stake', 95 | filters: { 96 | None: { 97 | filter: (card: CardType) => 98 | card.wins ? sumStakes(card.wins) == 0 : false, 99 | enabled: false, 100 | }, 101 | 'White Stake': { 102 | filter: (card: CardType) => 103 | card.wins ? getHighestStake(card.wins) == 1 : false, 104 | enabled: false, 105 | }, 106 | 'Red Stake': { 107 | filter: (card: CardType) => 108 | card.wins ? getHighestStake(card.wins) == 2 : false, 109 | enabled: false, 110 | }, 111 | 'Green Stake': { 112 | filter: (card: CardType) => 113 | card.wins ? getHighestStake(card.wins) == 3 : false, 114 | enabled: false, 115 | }, 116 | 'Black Stake': { 117 | filter: (card: CardType) => 118 | card.wins ? getHighestStake(card.wins) == 4 : false, 119 | enabled: false, 120 | }, 121 | 'Blue Stake': { 122 | filter: (card: CardType) => 123 | card.wins ? getHighestStake(card.wins) == 5 : false, 124 | enabled: false, 125 | }, 126 | 'Purple Stake': { 127 | filter: (card: CardType) => 128 | card.wins ? getHighestStake(card.wins) == 6 : false, 129 | enabled: false, 130 | }, 131 | 'Orange Stake': { 132 | filter: (card: CardType) => 133 | card.wins ? getHighestStake(card.wins) == 7 : false, 134 | enabled: false, 135 | }, 136 | 'Gold Stake': { 137 | filter: (card: CardType) => 138 | card.wins ? getHighestStake(card.wins) == 8 : false, 139 | enabled: false, 140 | }, 141 | }, 142 | }, 143 | Rarity: { 144 | name: 'Rarity', 145 | filters: { 146 | Common: { 147 | filter: (card: CardType) => card.status == 'Common', 148 | enabled: false, 149 | }, 150 | Uncommon: { 151 | filter: (card: CardType) => card.status == 'Uncommon', 152 | enabled: false, 153 | }, 154 | Rare: { 155 | filter: (card: CardType) => card.status == 'Rare', 156 | enabled: false, 157 | }, 158 | Legendary: { 159 | filter: (card: CardType) => card.status == 'Legendary', 160 | enabled: false, 161 | }, 162 | }, 163 | }, 164 | Type: { 165 | name: 'Type', 166 | filters: { 167 | Tarot: { 168 | filter: (card: CardType) => card.status == 'Tarot', 169 | enabled: false, 170 | }, 171 | Planet: { 172 | filter: (card: CardType) => card.status == 'Planet', 173 | enabled: false, 174 | }, 175 | Spectral: { 176 | filter: (card: CardType) => card.status == 'Spectral', 177 | enabled: false, 178 | }, 179 | }, 180 | }, 181 | Tier: { 182 | name: 'Tier', 183 | filters: { 184 | Base: { 185 | filter: (card: CardType) => { 186 | return baseVoucherNames.includes(card.name) 187 | }, 188 | enabled: false, 189 | }, 190 | Upgraded: { 191 | filter: (card: CardType) => { 192 | return upgradedVoucherNames.includes(card.name) 193 | }, 194 | enabled: false, 195 | }, 196 | }, 197 | }, 198 | } 199 | 200 | export default function Collection({ profile }: { profile: Profile }) { 201 | const [tab, setTab] = useState(tabs['Jokers']) 202 | const [search, setSearch] = useState('') 203 | const [view, setView] = useState(tab.views[0]) 204 | const sorts = view == 'Graph' ? tab.graphs : tab.sorts 205 | const [sort, setSort] = useState(sorts[0]) 206 | const [filters, setFilters] = useImmer(cardFilters) 207 | const activeFilters = tab.filters.map((group) => 208 | Object.values(filters[group].filters) 209 | .filter(({ enabled }) => enabled) 210 | .map(({ filter }) => filter), 211 | ) 212 | const settings = useContext(settingsContext) 213 | const [ref, savePNG] = useToImage( 214 | tab.name.toLowerCase(), 215 | settings.saveImageinNewTab.enabled, 216 | ) 217 | 218 | if (!tab.views.includes(view)) { 219 | setView(tab.views[1]) 220 | } 221 | if (!sorts.includes(sort)) { 222 | setSort(sorts[0]) 223 | } 224 | 225 | const jokers = initialJokers() 226 | Object.entries(profile.joker_usage).forEach(([joker, stats]) => { 227 | if (!jokers[joker]) { 228 | jokers[joker] = initializeJoker(joker) 229 | } 230 | jokers[joker].wins = stats.wins 231 | jokers[joker].losses = stats.losses 232 | jokers[joker].count = stats.count 233 | }) 234 | 235 | const decks = initialDecks() 236 | Object.entries(profile.deck_usage).forEach(([deck, stats]) => { 237 | if (!decks[deck]) { 238 | decks[deck] = initializeDeck(deck) 239 | } 240 | decks[deck].wins = stats.wins 241 | decks[deck].losses = stats.losses 242 | }) 243 | 244 | const consumables = initialConsumables() 245 | Object.entries(profile.consumeable_usage).forEach(([card, stats]) => { 246 | if (!consumables[card]) { 247 | consumables[card] = initializeConsumable(card) 248 | } 249 | consumables[card].count = stats.count 250 | }) 251 | 252 | const vouchers = initialVouchers() 253 | Object.entries(profile.voucher_usage).forEach(([voucher, stats]) => { 254 | if (!vouchers[voucher]) { 255 | vouchers[voucher] = initializeVouchers(voucher) 256 | } 257 | vouchers[voucher].count = stats.count 258 | }) 259 | 260 | const cards = (() => { 261 | switch (tab.name) { 262 | default: 263 | case 'Jokers': 264 | return Object.values(jokers) 265 | case 'Decks': 266 | return Object.values(decks) 267 | case 'Consumables': 268 | return Object.values(consumables) 269 | case 'Vouchers': 270 | return Object.values(vouchers) 271 | } 272 | })() 273 | 274 | const filteredCards = filterCards(cards, search, sort, activeFilters) 275 | 276 | const insides = (() => { 277 | switch (view) { 278 | case 'Grid': 279 | return 280 | case 'List': 281 | return 282 | case 'Graph': 283 | return 284 | } 285 | })() 286 | 287 | return ( 288 |
289 |
290 |
291 | {Object.keys(tabs).map((tabName) => { 292 | return ( 293 |
303 | 304 | <> 305 | 306 | card.name)} 308 | current={search} 309 | setCurrent={setSearch} 310 | placeholder={tab.name} 311 | /> 312 | 313 | 314 | 315 | 337 | 338 | 339 | 340 | {insides} 341 |
342 | 343 |
348 |
357 |
358 | ) 359 | } 360 | -------------------------------------------------------------------------------- /src/components/sections/collection/Graph.tsx: -------------------------------------------------------------------------------- 1 | import styles from './collection.module.css' 2 | import { CardType } from '@/lib/types' 3 | import Card from '../../pieces/Card' 4 | import { sumStakes } from '@/lib/utils' 5 | 6 | function getStat(card: CardType, sort: string) { 7 | switch (sort) { 8 | default: 9 | case 'Rounds': 10 | case 'Uses': 11 | case 'Redeems': 12 | return card.count ?? 0 13 | case 'Wins': 14 | return card.wins ? sumStakes(card.wins) : 0 15 | case 'Win %': 16 | var wins = card.wins ? sumStakes(card.wins) : 0 17 | var losses = card.losses ? sumStakes(card.losses) : 0 18 | var rounds = wins + losses 19 | var winPercentage = wins / (rounds || 1) 20 | return Math.round(winPercentage * 100) 21 | case 'Wins per Loss': 22 | var wins = card.wins ? sumStakes(card.wins) : 0 23 | var losses = card.losses ? sumStakes(card.losses) : 0 24 | return Math.round((wins / (losses || 1)) * 100) / 100 25 | case 'Losses': 26 | return card.losses ? sumStakes(card.losses) : 0 27 | } 28 | } 29 | 30 | function getTotal(cards: CardType[], sort: string) { 31 | let total 32 | const card = cards[0] 33 | switch (sort) { 34 | default: 35 | case 'Rounds': 36 | case 'Uses': 37 | case 'Redeems': 38 | total = card.count 39 | break 40 | case 'Wins': 41 | total = card.wins ? sumStakes(card.wins) : 0 42 | break 43 | case 'Win %': 44 | return 100 45 | case 'Wins per Loss': 46 | var wins = card.wins ? sumStakes(card.wins) : 0 47 | var losses = card.losses ? sumStakes(card.losses) : 0 48 | total = wins / (losses || 1) 49 | break 50 | case 'Losses': 51 | total = card.losses ? sumStakes(card.losses) : 0 52 | break 53 | } 54 | return total || 1 55 | } 56 | 57 | function GraphRow({ 58 | card, 59 | stat, 60 | total, 61 | asPercentage, 62 | }: { 63 | card: CardType 64 | stat: number 65 | total: number 66 | asPercentage: boolean 67 | }) { 68 | const isJoker = 69 | card.status == 'Common' || 70 | card.status == 'Uncommon' || 71 | card.status == 'Rare' || 72 | card.status == 'Legendary' 73 | return ( 74 |
75 |
87 | 88 |
89 | {asPercentage ? `${stat}%` : stat || '-'} 90 |
91 | 92 | 101 |
102 | ) 103 | } 104 | 105 | export default function Graph({ 106 | cards, 107 | tab, 108 | sort, 109 | }: { 110 | cards: CardType[] 111 | tab: string 112 | sort: string 113 | }) { 114 | const legendText: Record = { 115 | Rounds: 'Total completed rounds with this card', 116 | Uses: 'Number of times this card has been used', 117 | Redeems: 'Number of times this Voucher has been redeemed', 118 | 119 | 'Round %': 'Percentage of completed rounds with this card', 120 | 'Use %': 'Percentage of times this card has been used', 121 | 'Redeem %': 'Percentage of times this Voucher has been redeemed', 122 | 123 | Wins: `Number of wins with this ${tab == 'Decks' ? 'deck' : 'card'}`, 124 | 'Win %': `Percentage of games won with this ${tab == 'Decks' ? 'deck' : 'card'}`, 125 | 'Wins per Loss': `Number of wins per loss with this ${tab == 'Decks' ? 'deck' : 'card'}`, 126 | Losses: `Number of losses with this ${tab == 'Decks' ? 'deck' : 'card'}`, 127 | } 128 | 129 | return ( 130 |
131 |
132 |
133 | <>{legendText[sort]} 134 |
135 | {cards.map((card, i) => { 136 | const stat = getStat(card, sort) 137 | const total = getTotal(cards, sort) 138 | return ( 139 | 146 | ) 147 | })} 148 |
149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /src/components/sections/collection/Grid.tsx: -------------------------------------------------------------------------------- 1 | import styles from './collection.module.css' 2 | import Hand from '../../pieces/Hand' 3 | import { CardType } from '@/lib/types' 4 | 5 | export default function Grid({ 6 | cards, 7 | tab, 8 | }: { 9 | cards: CardType[] 10 | tab: string 11 | }) { 12 | const cardsPerHand = tab != 'Vouchers' ? 5 : 4 13 | const gap = '0.25rem' 14 | const hands = [] 15 | for (let i = 0; i < cards.length; i += cardsPerHand) { 16 | hands.push( 17 | , 18 | ) 19 | } 20 | 21 | return
{hands}
22 | } 23 | -------------------------------------------------------------------------------- /src/components/sections/collection/List.tsx: -------------------------------------------------------------------------------- 1 | import styles from './collection.module.css' 2 | import { CardType } from '@/lib/types' 3 | import Card from '../../pieces/Card' 4 | import Chips from '../../pieces/Chips' 5 | 6 | function ListRow({ card }: { card: CardType }) { 7 | return ( 8 |
9 | 18 | {card.wins && card.losses && ( 19 | 20 | )} 21 |
22 | ) 23 | } 24 | 25 | export default function List({ cards }: { cards: CardType[] }) { 26 | return ( 27 |
28 | {cards.map((card) => { 29 | return 30 | })} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/components/sections/collection/collection.module.css: -------------------------------------------------------------------------------- 1 | .collection { 2 | background-color: #3a5055; 3 | box-shadow: 0 var(--shadow-length) 0 #777e89; 4 | 5 | border-width: var(--shadow-length); 6 | border-radius: 0.5rem; 7 | border-color: #b9c2d2; 8 | padding: 0 0.5rem; 9 | padding-bottom: calc(0.5rem + var(--shadow-length)); 10 | } 11 | 12 | .tabs { 13 | display: grid; 14 | grid-template-columns: repeat(4, minmax(0, auto)); 15 | justify-content: center; 16 | gap: 0.5rem; 17 | padding: 1.5rem 0; 18 | } 19 | 20 | .grid { 21 | display: grid; 22 | gap: 0.25rem; 23 | 24 | background-color: #1e2b2d; 25 | box-shadow: 0 var(--shadow-length) 0 #0b1415; 26 | border-radius: 0.5rem; 27 | 28 | padding-top: 0.25rem; 29 | margin-top: 1rem; 30 | min-height: var(--card-height); 31 | } 32 | 33 | .list { 34 | background-color: #33464b; 35 | padding: 0.5rem; 36 | border-radius: 0.5rem; 37 | margin-top: 1rem; 38 | min-height: var(--card-height); 39 | } 40 | 41 | .list .listRow:first-child, 42 | .list .listRow:last-child, 43 | .list .graphRow:first-child, 44 | .list .graphRow:last-child { 45 | margin: 0; 46 | } 47 | 48 | .listRow { 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | gap: 1rem; 53 | 54 | border-radius: 0.5rem; 55 | background-color: #2d3e42; 56 | padding: 0.25rem; 57 | margin: 0.35rem 0; 58 | } 59 | 60 | .graphRow { 61 | display: flex; 62 | align-items: center; 63 | 64 | border-radius: 0.5rem; 65 | background-color: #2d3e42; 66 | padding: 0.25rem 0; 67 | margin: 0.35rem 0; 68 | } 69 | 70 | .graphBar { 71 | border-radius: 0.125rem; 72 | height: var(--card-width); 73 | transition: width 900ms ease; 74 | } 75 | 76 | .graphNumber { 77 | width: 3rem; 78 | text-align: center; 79 | color: var(--orange); 80 | } 81 | 82 | .graphLegend { 83 | display: flex; 84 | align-items: center; 85 | justify-content: center; 86 | margin-bottom: 0.5rem; 87 | } 88 | 89 | .graphSquare { 90 | background-color: #ff8f00; 91 | height: 0.5rem; 92 | width: 0.5rem; 93 | border-radius: 0.125rem; 94 | margin: 0 0.25rem; 95 | } 96 | -------------------------------------------------------------------------------- /src/components/sections/footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer() { 2 | return ( 3 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/sections/hero/Hero.tsx: -------------------------------------------------------------------------------- 1 | import styles from './hero.module.css' 2 | import { initialProfile, readProfile } from '@/lib/profile' 3 | import { Profile } from '@/lib/types' 4 | import { 5 | Dispatch, 6 | SetStateAction, 7 | useRef, 8 | useState, 9 | useContext, 10 | useEffect, 11 | } from 'react' 12 | import Button from '../../inputs/Button' 13 | import { soundContext } from '@/lib/context' 14 | import Help from '../popups/Help' 15 | 16 | export default function Hero({ 17 | saved, 18 | profileSetter, 19 | }: { 20 | saved: boolean 21 | profileSetter: Dispatch> 22 | }) { 23 | const inputRef = useRef(null) 24 | const [confirm, setConfirm] = useState(false) 25 | const [disabled, setDisabled] = useState(false) 26 | const [help, setHelp] = useState(false) 27 | 28 | useEffect(() => { 29 | const timeout = setTimeout(() => { 30 | setConfirm(false) 31 | }, 3000) 32 | 33 | return () => clearTimeout(timeout) 34 | }, [confirm]) 35 | 36 | const sounds = useContext(soundContext) 37 | const play = sounds['tarotSound'] 38 | 39 | return ( 40 | <> 41 |
42 |

49 | Balatro Stats 50 |

51 | 52 |
57 |

View your stats in Balatro!

58 |
65 | Upload your profile.jkr to begin. 66 |
79 |
80 | 81 |
82 |
83 | { 89 | const file = e.target.files?.[0] 90 | if (file) { 91 | readProfile(file, (profile: Profile) => { 92 | const date = new Date().toLocaleDateString() 93 | profile.lastUpdated = date 94 | profileSetter(profile) 95 | localStorage.setItem('profile', JSON.stringify(profile)) 96 | }) 97 | } 98 | }} 99 | /> 100 |
114 | 115 |
116 |
148 |
149 | 150 |

156 | Click again to confirm 157 |

158 |
159 | 160 | {help && } 161 | 162 | ) 163 | } 164 | -------------------------------------------------------------------------------- /src/components/sections/hero/hero.module.css: -------------------------------------------------------------------------------- 1 | .hero { 2 | padding: 0.5rem 0 1rem 0; 3 | } 4 | 5 | .input { 6 | display: none; 7 | } 8 | 9 | .buttons { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | gap: 0.5rem; 14 | padding: 0.5rem; 15 | } 16 | 17 | .warning { 18 | padding-top: 0.5rem; 19 | text-align: center; 20 | transform-origin: center 75%; 21 | user-select: none; 22 | } 23 | 24 | .animation { 25 | animation: 26 | bounce 400ms ease-in-out, 27 | spin 300ms ease-in-out; 28 | } 29 | 30 | @keyframes bounce { 31 | /* Damped Sinusoidal Wave */ 32 | 8% { 33 | scale: 1.4; 34 | } 35 | 28% { 36 | scale: 0.8; 37 | } 38 | 48% { 39 | scale: 1.3; 40 | } 41 | 68% { 42 | scale: 0.9; 43 | } 44 | 88% { 45 | scale: 1.1; 46 | } 47 | 100% { 48 | scale: 1; 49 | } 50 | } 51 | 52 | @keyframes spin { 53 | /* Damped Sinusoidal Wave */ 54 | 8% { 55 | rotate: 8deg; 56 | } 57 | 28% { 58 | rotate: -7deg; 59 | } 60 | 48% { 61 | rotate: 7deg; 62 | } 63 | 68% { 64 | rotate: -6deg; 65 | } 66 | 88% { 67 | rotate: 4deg; 68 | } 69 | 100% { 70 | rotate: 0deg; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/sections/navbar/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import styles from './navbar.module.css' 2 | import { Profile } from '@/lib/types' 3 | import { Options } from '../popups/Options' 4 | import { useState } from 'react' 5 | import { Updater } from 'use-immer' 6 | import Button from '../../inputs/Button' 7 | import Login from '../popups/LogIn' 8 | 9 | export default function NavBar({ 10 | profile, 11 | setter, 12 | }: { 13 | profile: Profile 14 | setter: Updater> 15 | }) { 16 | const [shareOpen, setShareOpen] = useState(false) 17 | const [optionsOpen, setOptionsOpen] = useState(false) 18 | 19 | return ( 20 | <> 21 |
22 | <> 23 | {`${profile.name}'s stats`} 24 | {profile.lastUpdated && ` [${profile.lastUpdated}]`} 25 | 26 | 27 |
28 | {/*
53 |
54 | {shareOpen && } 55 | {optionsOpen && ( 56 | 57 | )} 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/sections/navbar/navbar.module.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | position: sticky; 3 | top: 0.125rem; 4 | background-color: #2e3a3c; 5 | 6 | box-shadow: 0 var(--shadow-length) #091113; 7 | 8 | border-radius: 0.5rem; 9 | border-width: 0.125rem; 10 | border-color: #1e2b2d; 11 | 12 | margin-bottom: 0.5rem; 13 | padding: 0.25rem 0.5rem; 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | 18 | z-index: 10; 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | gap: 1rem; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/sections/popups/Help.tsx: -------------------------------------------------------------------------------- 1 | import styles from './popup.module.css' 2 | import { Dispatch, ReactNode, SetStateAction } from 'react' 3 | import Popup from './Popup' 4 | 5 | function Highlight({ 6 | children, 7 | color, 8 | }: { 9 | children: ReactNode 10 | color?: string 11 | }) { 12 | if (color === undefined) { 13 | color = 'orange' 14 | } 15 | 16 | return ( 17 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | export default function Help({ 28 | setter, 29 | }: { 30 | setter: Dispatch> 31 | }) { 32 | return ( 33 | 34 | <> 35 |
36 | Where to find your profile.jkr 37 |
38 | 39 |
40 |
41 |
42 | Windows: 43 |
    44 |
  1. Open File Explorer and click the address bar
  2. 45 |
  3. 46 | Type: %AppData%/Balatro 47 |
  4. 48 |
  5. 49 | Press [Enter] 50 |
  6. 51 |
  7. Choose from folder 1, 2, or 3
  8. 52 |
53 |
54 | 55 |
56 | MacOS: 57 |
    58 |
  1. Open Finder
  2. 59 |
  3. 60 | Press [Shift] +{' '} 61 | [Command] + [G] 62 |
  4. 63 |
  5. 64 | Type:{' '} 65 | 66 | ~/Library/Application Support/Balatro 67 | 68 |
  6. 69 |
  7. 70 | Press [Enter] 71 |
  8. 72 |
  9. Choose from folder 1, 2, or 3
  10. 73 |
74 |
75 | 76 |
77 | Steam Deck: 78 |
    79 |
  1. 80 | Navigate to:{' '} 81 | 82 | ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro 83 | 84 |
  2. 85 |
  3. Choose from folder 1, 2, or 3
  4. 86 |
87 |
88 |
89 |
90 | 91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /src/components/sections/popups/LogIn.tsx: -------------------------------------------------------------------------------- 1 | import styles from './popup.module.css' 2 | import { Dispatch, SetStateAction } from 'react' 3 | import Popup from './Popup' 4 | import { signIn } from 'next-auth/react' 5 | 6 | export default function Login({ 7 | setter, 8 | }: { 9 | setter: Dispatch> 10 | }) { 11 | return Log in with Discord to share your stats. 12 | } 13 | -------------------------------------------------------------------------------- /src/components/sections/popups/Options.tsx: -------------------------------------------------------------------------------- 1 | import styles from './popup.module.css' 2 | import { Dispatch, SetStateAction, useContext, useEffect } from 'react' 3 | import { settingsContext } from '@/lib/context' 4 | import Checkbox from '../../inputs/Checkbox' 5 | import { Updater } from 'use-immer' 6 | import Popup from './Popup' 7 | 8 | function minifySettings( 9 | settings: Record, 10 | ) { 11 | // Compress the settings to only keep the boolean value 12 | const miniSettings: Record = {} 13 | Object.entries(settings).forEach(([key, { enabled }]) => { 14 | miniSettings[key] = enabled 15 | }) 16 | return JSON.stringify(miniSettings) 17 | } 18 | 19 | export function Options({ 20 | activeSetter, 21 | settingsSetter, 22 | }: { 23 | activeSetter: Dispatch> 24 | settingsSetter: Updater> 25 | }) { 26 | const settings = useContext(settingsContext) 27 | 28 | useEffect(() => { 29 | localStorage.setItem('settings', minifySettings(settings)) 30 | }, [settings]) 31 | 32 | return ( 33 | 34 | <> 35 |
36 |
Settings
37 |
38 | {Object.entries(settings).map(([key, { label, enabled }]) => { 39 | return ( 40 | { 45 | settingsSetter((draft) => { 46 | draft[key].enabled = !enabled 47 | }) 48 | }} 49 | /> 50 | ) 51 | })} 52 |
53 |
54 | 55 |
56 |
Credits
57 | 76 |
77 | 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /src/components/sections/popups/Popup.tsx: -------------------------------------------------------------------------------- 1 | import styles from './popup.module.css' 2 | import { Dispatch, SetStateAction } from 'react' 3 | import Button from '../../inputs/Button' 4 | 5 | export default function Popup({ 6 | children, 7 | setter, 8 | }: { 9 | children: React.ReactNode 10 | setter: Dispatch> 11 | }) { 12 | const exit = () => { 13 | setter(false) 14 | } 15 | 16 | return ( 17 | <> 18 |
19 | 20 |
21 | {children} 22 |
24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/sections/popups/popup.module.css: -------------------------------------------------------------------------------- 1 | .background { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | height: 100vh; 6 | width: 100vw; 7 | background-color: rgba(0, 0, 0, 0.5); 8 | z-index: 14; 9 | 10 | animation: fadeIn 250ms linear forwards; 11 | } 12 | 13 | .popup { 14 | position: fixed; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | 18 | width: calc(var(--card-width) * 5); 19 | 20 | background-color: #3a5055; 21 | box-shadow: 0 var(--shadow-length) 0 #777e89; 22 | border-width: var(--shadow-length); 23 | border-radius: 0.5rem; 24 | border-color: #b9c2d2; 25 | padding: 0.5rem; 26 | 27 | z-index: 15; 28 | 29 | animation: slide 500ms ease-in-out forwards; 30 | } 31 | 32 | .section { 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | padding: 0.5rem; 37 | padding-bottom: 1rem; 38 | } 39 | 40 | .subSections { 41 | display: flex; 42 | flex-direction: column; 43 | gap: 0.5rem; 44 | } 45 | 46 | .sectionHeading { 47 | font-size: 125%; 48 | padding-bottom: 0.25rem; 49 | text-align: center; 50 | } 51 | 52 | .list { 53 | list-style: auto; 54 | padding-left: 1.5rem; 55 | } 56 | 57 | .link { 58 | color: var(--blue); 59 | } 60 | 61 | @keyframes fadeIn { 62 | 0% { 63 | opacity: 0; 64 | } 65 | 100% { 66 | opacity: 1; 67 | } 68 | } 69 | 70 | @keyframes slide { 71 | 0% { 72 | top: 250%; 73 | } 74 | 66% { 75 | top: 48%; 76 | } 77 | 100% { 78 | top: 50%; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/sections/records/CareerStats.tsx: -------------------------------------------------------------------------------- 1 | import styles from './records.module.css' 2 | import { Profile } from '@/lib/types' 3 | import Stat from '@/components/stats/Stat' 4 | import { numberWithCommas } from '@/lib/utils' 5 | import { useContext } from 'react' 6 | import { settingsContext } from '@/lib/context' 7 | import { 8 | planetKeys, 9 | spectralKeys, 10 | tarotKeys, 11 | } from '../../../lib/cards/cardKeys' 12 | 13 | export default function CareerStats({ profile }: { profile: Profile }) { 14 | const settings = useContext(settingsContext) 15 | 16 | interface StatType { 17 | name: string 18 | value: number 19 | perGame: boolean 20 | color: string 21 | type: 'number' | 'money' | 'percentage' 22 | } 23 | 24 | const stats: Record = { 25 | c_cards_discarded: { 26 | name: 'Cards Discarded', 27 | value: 0, 28 | perGame: true, 29 | color: 'red', 30 | type: 'number', 31 | }, 32 | c_cards_played: { 33 | name: 'Cards Played', 34 | value: 0, 35 | perGame: true, 36 | color: 'blue', 37 | type: 'number', 38 | }, 39 | c_cards_sold: { 40 | name: 'Cards Sold', 41 | value: 0, 42 | perGame: true, 43 | color: 'white', 44 | type: 'number', 45 | }, 46 | c_dollars_earned: { 47 | name: 'Total Money Earned', 48 | value: 0, 49 | perGame: true, 50 | color: 'yellow', 51 | type: 'money', 52 | }, 53 | c_face_cards_played: { 54 | name: 'Face Cards Played', 55 | value: 0, 56 | perGame: true, 57 | color: 'white', 58 | type: 'number', 59 | }, 60 | c_hands_played: { 61 | name: 'Hands Played', 62 | value: 0, 63 | perGame: true, 64 | color: 'white', 65 | type: 'number', 66 | }, 67 | c_jokers_sold: { 68 | name: 'Jokers Sold', 69 | value: 0, 70 | perGame: true, 71 | color: 'white', 72 | type: 'number', 73 | }, 74 | c_losses: { 75 | name: 'Losses', 76 | value: 0, 77 | perGame: false, 78 | color: 'red', 79 | type: 'number', 80 | }, 81 | c_planetarium_used: { 82 | name: 'Celestial Packs Used', 83 | value: 0, 84 | perGame: true, 85 | color: 'Planet', 86 | type: 'number', 87 | }, 88 | c_planets_bought: { 89 | name: 'Planets Purchased', 90 | value: 0, 91 | perGame: true, 92 | color: 'Planet', 93 | type: 'number', 94 | }, 95 | c_playing_cards_bought: { 96 | name: 'Playing Cards Purchased', 97 | value: 0, 98 | perGame: true, 99 | color: 'white', 100 | type: 'number', 101 | }, 102 | c_round_interest_cap_streak: { 103 | name: 'Current Max Interest Streak', 104 | value: 0, 105 | perGame: false, 106 | color: 'white', 107 | type: 'number', 108 | }, 109 | c_rounds: { 110 | name: 'Rounds Played', 111 | value: 0, 112 | perGame: true, 113 | color: 'orange', 114 | type: 'number', 115 | }, 116 | c_shop_dollars_spent: { 117 | name: 'Total Money Spent', 118 | value: 0, 119 | perGame: true, 120 | color: 'yellow', 121 | type: 'money', 122 | }, 123 | c_shop_rerolls: { 124 | name: 'Times Rerolled', 125 | value: 0, 126 | perGame: true, 127 | color: 'green', 128 | type: 'number', 129 | }, 130 | c_single_hand_round_streak: { 131 | name: 'Current Consecutive Single Hand Round Wins', 132 | value: 0, 133 | perGame: false, 134 | color: 'white', 135 | type: 'number', 136 | }, 137 | c_tarot_reading_used: { 138 | name: 'Tarot Packs Used', 139 | value: 0, 140 | perGame: true, 141 | color: 'Tarot', 142 | type: 'number', 143 | }, 144 | c_tarots_bought: { 145 | name: 'Tarot Cards Purchased', 146 | value: 0, 147 | perGame: true, 148 | color: 'Tarot', 149 | type: 'number', 150 | }, 151 | c_vouchers_bought: { 152 | name: 'Vouchers Redeemed', 153 | value: 0, 154 | perGame: true, 155 | color: 'Voucher', 156 | type: 'number', 157 | }, 158 | c_wins: { 159 | name: 'Wins', 160 | value: 0, 161 | perGame: false, 162 | color: 'blue', 163 | type: 'number', 164 | }, 165 | win_percentage: { 166 | name: 'Win %', 167 | value: 0, 168 | perGame: false, 169 | color: 'orange', 170 | type: 'percentage', 171 | }, 172 | gold_stake_wins: { 173 | name: 'Gold Stake Wins', 174 | value: 0, 175 | perGame: false, 176 | color: 'blue', 177 | type: 'number', 178 | }, 179 | gold_stake_losses: { 180 | name: 'Gold Stake Losses', 181 | value: 0, 182 | perGame: false, 183 | color: 'red', 184 | type: 'number', 185 | }, 186 | gold_stake_win_percentage: { 187 | name: 'Gold Stake Win %', 188 | value: 0, 189 | perGame: false, 190 | color: 'orange', 191 | type: 'percentage', 192 | }, 193 | total_tarots_used: { 194 | name: 'Tarot Cards Used', 195 | value: 0, 196 | perGame: true, 197 | color: 'Tarot', 198 | type: 'number', 199 | }, 200 | total_planets_used: { 201 | name: 'Planet Cards Used', 202 | value: 0, 203 | perGame: true, 204 | color: 'Planet', 205 | type: 'number', 206 | }, 207 | total_spectrals_used: { 208 | name: 'Spectral Cards Used', 209 | value: 0, 210 | perGame: true, 211 | color: 'Spectral', 212 | type: 'number', 213 | }, 214 | total_consumables_used: { 215 | name: 'Consumables Used', 216 | value: 0, 217 | perGame: true, 218 | color: 'White', 219 | type: 'number', 220 | }, 221 | } 222 | 223 | const sections = { 224 | Record: ['c_wins', 'c_losses', 'win_percentage', 'c_rounds'], 225 | 'Gold Stake': [ 226 | 'gold_stake_wins', 227 | 'gold_stake_losses', 228 | 'gold_stake_win_percentage', 229 | ], 230 | Cards: [ 231 | 'c_cards_played', 232 | 'c_cards_discarded', 233 | 'c_face_cards_played', 234 | 'c_hands_played', 235 | ], 236 | Economy: [ 237 | 'c_dollars_earned', 238 | 'c_shop_dollars_spent', 239 | 'c_playing_cards_bought', 240 | 'c_cards_sold', 241 | 'c_jokers_sold', 242 | 'c_shop_rerolls', 243 | ], 244 | Consumables: [ 245 | 'c_tarot_reading_used', 246 | 'c_tarots_bought', 247 | 'total_tarots_used', 248 | 'c_planetarium_used', 249 | 'c_planets_bought', 250 | 'total_planets_used', 251 | 'total_spectrals_used', 252 | 'total_consumables_used', 253 | 'c_vouchers_bought', 254 | ], 255 | Streaks: ['c_round_interest_cap_streak', 'c_single_hand_round_streak'], 256 | } 257 | 258 | Object.keys(stats) 259 | .slice(0, 20) 260 | .forEach((key) => { 261 | stats[key].value = profile.career_stats[key] 262 | }) 263 | 264 | const goldStakeWins = Object.values(profile.deck_usage) 265 | .map((deck) => deck.wins?.['8'] ?? 0) 266 | .reduce((sum, x) => sum + x, 0) 267 | const goldStakeLosses = Object.values(profile.deck_usage) 268 | .map((deck) => deck.losses?.['8'] ?? 0) 269 | .reduce((sum, x) => sum + x, 0) 270 | const goldStakeWinPercentage = 271 | Math.round( 272 | (goldStakeWins / (goldStakeWins + goldStakeLosses || 1)) * 10000, 273 | ) / 100 274 | 275 | stats['gold_stake_wins'].value = goldStakeWins 276 | stats['gold_stake_losses'].value = goldStakeLosses 277 | stats['gold_stake_win_percentage'].value = goldStakeWinPercentage 278 | 279 | stats['total_tarots_used'].value = Object.entries( 280 | profile.consumeable_usage, 281 | ).reduce((sum, [key, { count }]) => { 282 | if (tarotKeys.includes(key)) { 283 | return sum + count 284 | } 285 | return sum 286 | }, 0) 287 | 288 | stats['total_planets_used'].value = Object.entries( 289 | profile.consumeable_usage, 290 | ).reduce((sum, [key, { count }]) => { 291 | if (planetKeys.includes(key)) { 292 | return sum + count 293 | } 294 | return sum 295 | }, 0) 296 | 297 | stats['total_spectrals_used'].value = Object.entries( 298 | profile.consumeable_usage, 299 | ).reduce((sum, [key, { count }]) => { 300 | if (spectralKeys.includes(key)) { 301 | return sum + count 302 | } 303 | return sum 304 | }, 0) 305 | 306 | stats['total_consumables_used'].value = Object.values( 307 | profile.consumeable_usage, 308 | ).reduce((sum, { count }) => { 309 | return sum + count 310 | }, 0) 311 | 312 | const gamesPlayed = 313 | profile.career_stats['c_wins'] + profile.career_stats['c_losses'] || 1 314 | const winPercentage = 315 | Math.round((profile.career_stats['c_wins'] / gamesPlayed) * 10000) / 100 316 | stats['win_percentage'].value = winPercentage 317 | 318 | return ( 319 |
320 | {Object.entries(sections).map(([section, keys]) => { 321 | return ( 322 |
323 |
{section}
324 | 325 | {keys.map((key) => { 326 | const value = (() => { 327 | switch (stats[key].type) { 328 | default: 329 | case 'number': 330 | return numberWithCommas(stats[key].value) 331 | case 'money': 332 | return `$${numberWithCommas(stats[key].value)}` 333 | case 'percentage': 334 | return `${stats[key].value}%` 335 | } 336 | })() 337 | return ( 338 | 349 | ) 350 | })} 351 |
352 | ) 353 | })} 354 |
355 | ) 356 | } 357 | -------------------------------------------------------------------------------- /src/components/sections/records/HandStats.tsx: -------------------------------------------------------------------------------- 1 | import styles from './records.module.css' 2 | import { Profile } from '@/lib/types' 3 | import { useState } from 'react' 4 | import InputContainer from '@/components/inputs/InputContainer' 5 | import HandStat from '@/components/stats/HandStat' 6 | import Select from '@/components/inputs/Select' 7 | 8 | export default function HandStats({ profile }: { profile: Profile }) { 9 | interface HandType { 10 | name: string 11 | level: number 12 | usage: number 13 | chips: number 14 | mult: number 15 | pchips: number 16 | pmult: number 17 | } 18 | 19 | const hands: Record = { 20 | HighCard: { 21 | name: 'High Card', 22 | level: 1, 23 | usage: 0, 24 | 25 | chips: 5, 26 | mult: 1, 27 | pchips: 10, 28 | pmult: 1, 29 | }, 30 | Pair: { 31 | name: 'Pair', 32 | level: 1, 33 | usage: 0, 34 | 35 | chips: 10, 36 | mult: 2, 37 | pchips: 15, 38 | pmult: 1, 39 | }, 40 | TwoPair: { 41 | name: 'Two Pair', 42 | level: 1, 43 | usage: 0, 44 | 45 | chips: 20, 46 | mult: 2, 47 | pchips: 20, 48 | pmult: 1, 49 | }, 50 | ThreeofaKind: { 51 | name: 'Three of a Kind', 52 | level: 1, 53 | usage: 0, 54 | 55 | chips: 30, 56 | mult: 3, 57 | pchips: 20, 58 | pmult: 2, 59 | }, 60 | Straight: { 61 | name: 'Straight', 62 | level: 1, 63 | usage: 0, 64 | 65 | chips: 30, 66 | mult: 4, 67 | pchips: 30, 68 | pmult: 3, 69 | }, 70 | Flush: { 71 | name: 'Flush', 72 | level: 1, 73 | usage: 0, 74 | 75 | chips: 35, 76 | mult: 4, 77 | pchips: 15, 78 | pmult: 2, 79 | }, 80 | FullHouse: { 81 | name: 'Full House', 82 | level: 1, 83 | usage: 0, 84 | 85 | chips: 40, 86 | mult: 4, 87 | pchips: 25, 88 | pmult: 2, 89 | }, 90 | FourofaKind: { 91 | name: 'Four of a Kind', 92 | level: 1, 93 | usage: 0, 94 | 95 | chips: 60, 96 | mult: 7, 97 | pchips: 30, 98 | pmult: 3, 99 | }, 100 | StraightFlush: { 101 | name: 'Straight Flush', 102 | level: 1, 103 | usage: 0, 104 | 105 | chips: 100, 106 | mult: 8, 107 | pchips: 40, 108 | pmult: 4, 109 | }, 110 | FiveofaKind: { 111 | name: 'Five of a Kind', 112 | level: 1, 113 | usage: 0, 114 | 115 | chips: 120, 116 | mult: 12, 117 | pchips: 35, 118 | pmult: 3, 119 | }, 120 | FlushHouse: { 121 | name: 'Flush House', 122 | level: 1, 123 | usage: 0, 124 | 125 | chips: 140, 126 | mult: 14, 127 | pchips: 40, 128 | pmult: 4, 129 | }, 130 | FlushFive: { 131 | name: 'Flush Five', 132 | level: 1, 133 | usage: 0, 134 | 135 | chips: 160, 136 | mult: 16, 137 | pchips: 50, 138 | pmult: 3, 139 | }, 140 | } 141 | 142 | const planetsToHands = { 143 | c_pluto: 'HighCard', 144 | c_mercury: 'Pair', 145 | c_uranus: 'TwoPair', 146 | c_venus: 'ThreeofaKind', 147 | c_saturn: 'Straight', 148 | c_jupiter: 'Flush', 149 | c_earth: 'FullHouse', 150 | c_mars: 'FourofaKind', 151 | c_neptune: 'StraightFlush', 152 | c_planet_x: 'FiveofaKind', 153 | c_ceres: 'FlushHouse', 154 | c_eris: 'FlushFive', 155 | } as const 156 | 157 | // Update use count 158 | Object.entries(profile.hand_usage).forEach(([key, { count }]) => { 159 | hands[key].usage = count 160 | }) 161 | 162 | // Update levels 163 | if (profile.consumeable_usage?.c_black_hole) { 164 | Object.keys(hands).forEach((hand) => { 165 | hands[hand].level += profile.consumeable_usage.c_black_hole.count 166 | }) 167 | } 168 | 169 | for (const [planet, hand] of Object.entries(planetsToHands)) { 170 | if (profile.consumeable_usage?.[planet]) { 171 | hands[hand].level += profile.consumeable_usage[planet].count 172 | } 173 | } 174 | 175 | const calculateTotal = (base: number, plus: number, level: number) => { 176 | return base + plus * (level - 1) 177 | } 178 | 179 | const sortingOptions: Record number> = { 180 | Rank: () => 0, 181 | Level: (a: HandType, b: HandType) => a.level - b.level, 182 | Chips: (a: HandType, b: HandType) => 183 | calculateTotal(a.chips, a.pchips, a.level) - 184 | calculateTotal(b.chips, b.pchips, b.level), 185 | Mult: (a: HandType, b: HandType) => 186 | calculateTotal(a.mult, a.pmult, a.level) - 187 | calculateTotal(b.mult, b.pmult, b.level), 188 | Score: (a: HandType, b: HandType) => 189 | calculateTotal(a.chips, a.pchips, a.level) + 190 | calculateTotal(a.mult, a.pmult, a.level) - 191 | calculateTotal(b.chips, b.pchips, b.level) - 192 | calculateTotal(b.mult, b.pmult, b.level), 193 | Usage: (a: HandType, b: HandType) => a.usage - b.usage, 194 | } 195 | 196 | const [sort, setSort] = useState(Object.keys(sortingOptions)[0]) 197 | const sortedHands = Object.values(hands).toSorted(sortingOptions[sort]) 198 | 199 | return ( 200 |
201 | 202 |