├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── cspell.json ├── index.html ├── package-lock.json ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json ├── mstile-150x150.png ├── robots.txt ├── safari-pinned-tab.svg ├── site.webmanifest └── spell-book.png ├── renovate.json ├── src ├── app.module.css ├── app.tsx ├── cards │ ├── assassinate │ │ ├── artwork.png │ │ ├── config.ts │ │ └── sfx.wav │ ├── earthquake │ │ ├── artwork.png │ │ ├── config.ts │ │ └── sfx.wav │ ├── firebolt │ │ ├── artwork.png │ │ ├── config.ts │ │ └── sfx.wav │ ├── lightning │ │ ├── artwork.png │ │ ├── config.ts │ │ └── sfx.wav │ ├── shield-slam │ │ ├── artwork.png │ │ ├── config.ts │ │ └── sfx.wav │ └── strike │ │ ├── artwork.png │ │ ├── artwork.webp │ │ ├── config.ts │ │ └── sfx.wav ├── character-classes │ ├── berzerker │ │ └── config.ts │ ├── rogue │ │ └── config.ts │ └── wizard │ │ └── config.ts ├── components │ ├── app-preloader.tsx │ ├── avatar.module.css │ ├── avatar.tsx │ ├── banner.module.css │ ├── banner.tsx │ ├── button.module.css │ ├── button.tsx │ ├── card.module.css │ ├── card.tsx │ ├── character-creation.module.css │ ├── character-creation.tsx │ ├── debug-tag.tsx │ ├── deck.module.css │ ├── deck.tsx │ ├── dialog.module.css │ ├── dialog.tsx │ ├── empty-deck.module.css │ ├── empty-deck.tsx │ ├── feedback.module.css │ ├── feedback.tsx │ ├── game-error.tsx │ ├── health-bar.module.css │ ├── health-bar.tsx │ ├── inline.module.css │ ├── inline.tsx │ ├── item-shop-card.module.css │ ├── item-shop-card.tsx │ ├── item-shop-item.module.css │ ├── item-shop-item.tsx │ ├── price-stats-row.module.css │ ├── price-stats-row.tsx │ ├── stack.module.css │ ├── stack.tsx │ ├── stats.module.css │ └── stats.tsx ├── events │ └── index.ts ├── global.css ├── helpers │ ├── array.ts │ ├── cards.ts │ ├── character-classes.ts │ ├── get-sound.ts │ ├── item.ts │ ├── monsters.ts │ ├── rng.ts │ ├── shuffle.ts │ ├── string.ts │ └── vite.ts ├── images │ ├── backgrounds │ │ ├── dark-dungeon.png │ │ └── dark-library.png │ ├── bag.png │ ├── card-back.png │ ├── chest.png │ ├── close-up.png │ ├── dagger.png │ ├── gold-coins-transparent.png │ ├── gold-coins.png │ ├── heart.png │ ├── knuckles.png │ ├── large-potion.png │ ├── player-portraits │ │ ├── artist-warrior-1.png │ │ ├── berzerker-1.png │ │ ├── cat-artist.webp │ │ ├── fallen-king.png │ │ ├── fashion-designer.webp │ │ ├── giant-1.png │ │ ├── giant-2.png │ │ ├── golden-warrior.png │ │ ├── mage-1.png │ │ ├── marshy.png │ │ ├── rainbow-cat-1.webp │ │ ├── rainbow-mage.png │ │ ├── rainbow-samurai.webp │ │ ├── rainbow-shooting-star-warrior.webp │ │ ├── rainbow-warrior-1.png │ │ ├── samurai.png │ │ ├── warrior-1.png │ │ └── warrior-2.png │ ├── royal-dagger.png │ ├── small-potion.png │ ├── spell-book.png │ ├── sword.png │ └── wooden-shield.png ├── items │ ├── large-potion │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.effect.wav │ │ ├── sfx.obtain.wav │ │ └── sfx.use.wav │ └── small-potion │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.effect.wav │ │ ├── sfx.obtain.wav │ │ └── sfx.use.wav ├── machines │ └── app-machine │ │ └── app-machine.ts ├── main.tsx ├── monsters │ ├── ancient-giant │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── arachnid │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── bone-dragon │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── clown │ │ ├── artwork.png │ │ └── config.ts │ ├── creepy │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── devil-mallow │ │ └── artwork.png │ ├── evil-sorcerer │ │ ├── artwork.png │ │ └── config.ts │ ├── gargoyle │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── ghoul │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── haunting-spirit │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── hulking-giant │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── imp │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── orc │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── ronin │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── the-black-cat │ │ ├── artwork.png │ │ └── config.ts │ ├── the-council │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── troll │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ ├── watcher-from-the-deep │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav │ └── zombie-hoard │ │ ├── artwork.png │ │ ├── config.ts │ │ ├── sfx.damage.wav │ │ ├── sfx.death.wav │ │ └── sfx.intro.wav ├── reset.css ├── sfx │ ├── button.click.wav │ ├── card.destroy.wav │ ├── card.use.wav │ ├── cash-register.wav │ ├── coins.wav │ ├── door.open.wav │ ├── impact.blunt.wav │ ├── impact.cold.wav │ ├── impact.punch.wav │ ├── impact.slice.wav │ ├── impact.stone.wav │ ├── melee.slam.wav │ └── melee.woosh.flac ├── types │ ├── cards.ts │ ├── character-classes.ts │ ├── env.ts │ ├── items.ts │ ├── monsters.ts │ ├── player.ts │ └── tokens.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['eslint:recommended', 'plugin:react/recommended'], 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /dist 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | .eslintcache 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.12.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "semi": false, 6 | "proseWrap": "always" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[shellscript]": { 5 | "editor.defaultFormatter": "foxundermoon.shell-format" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [React Deckbuilder](https://deckbuilder.nicklemmon.com) 2 | 3 | A simple [deck-building game](https://en.wikipedia.org/wiki/Deck-building_game) built with React and 4 | [XState](https://xstate.js.org/). Current deployed via [Vercel](https://vercel.com/). 5 | 6 | ## Development 7 | 8 | ```bash 9 | npm start 10 | ``` 11 | 12 | ## Production 13 | 14 | ```bash 15 | npm run build 16 | ``` 17 | 18 | ## Imagery 19 | 20 | The majority of the images used in the game were generated using OpenAI's [DALL-E 2](https://openai.com/dall-e-2/). 21 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [], 4 | "dictionaryDefinitions": [], 5 | "dictionaries": [], 6 | "words": [ 7 | "Berzerker", 8 | "clsx", 9 | "deckbuilder", 10 | "firebolt", 11 | "janky", 12 | "webp", 13 | "xstate" 14 | ], 15 | "ignoreWords": [], 16 | "import": [] 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | React Deckbuilder 15 | 16 | 103 | 104 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 136 | 137 |
138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-deckbuilder", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@vercel/analytics": "1.5.0", 13 | "@xstate/react": "5.0.3", 14 | "array-shuffle": "3.0.0", 15 | "clsx": "2.1.1", 16 | "eslint-plugin-react": "^7.31.11", 17 | "howler": "2.2.4", 18 | "motion": "12.6.3", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "reset-css": "5.0.2", 22 | "xstate": "5.19.2" 23 | }, 24 | "devDependencies": { 25 | "@types/howler": "2.2.12", 26 | "@types/node": "22.14.0", 27 | "@types/random": "3.0.1", 28 | "@types/react": "19.1.0", 29 | "@types/react-dom": "19.1.1", 30 | "@vitejs/plugin-react": "4.3.4", 31 | "eslint": "9.24.0", 32 | "prettier": "3.5.3", 33 | "typescript": "5.8.3", 34 | "vite": "6.2.5", 35 | "vite-plugin-compression": "0.5.1", 36 | "vite-plugin-imagemin": "^0.6.0" 37 | }, 38 | "optionalDependencies": { 39 | "@rollup/rollup-linux-x64-gnu": "4.39.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /public/spell-book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/public/spell-book.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchDepTypes": ["devDependencies"], 6 | "groupName": "devDependencies (non-major)" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/app.module.css: -------------------------------------------------------------------------------- 1 | .play-area { 2 | --play-area-bleed: var(--spacing-500); 3 | 4 | min-height: 100vh; 5 | width: 100vw; 6 | height: 100vh; 7 | background-color: whitesmoke; 8 | overflow: hidden; 9 | } 10 | 11 | .play-area-debugger { 12 | position: fixed; 13 | top: var(--play-area-bleed); 14 | left: 50%; 15 | transform: translateX(-50%); 16 | z-index: var(--z-index-100); 17 | background-color: rgba(0, 0, 0, 0.8); 18 | border-radius: var(--rounding-100); 19 | color: var(--color-stone-100); 20 | padding: var(--spacing-200); 21 | } 22 | 23 | .combat-zone { 24 | position: absolute; 25 | display: flex; 26 | justify-content: space-between; 27 | align-items: center; 28 | width: 100%; 29 | height: 50%; 30 | max-width: 50vw; 31 | top: 25%; 32 | left: 50%; 33 | transform: translate(-50%, -25%); 34 | } 35 | 36 | .current-hand { 37 | position: fixed; 38 | z-index: var(--z-index-100); 39 | bottom: var(--play-area-bleed); 40 | left: 50%; 41 | transform: translateX(-50%); 42 | } 43 | 44 | .current-hand-wrapper { 45 | display: flex; 46 | gap: var(--spacing-100); 47 | } 48 | 49 | .play-area-wrapper { 50 | height: 100%; 51 | width: 100%; 52 | position: relative; 53 | padding: var(--play-area-bleed); 54 | box-sizing: border-box; 55 | } 56 | 57 | .discard-pile { 58 | position: absolute; 59 | right: var(--play-area-bleed); 60 | top: var(--play-area-bleed); 61 | } 62 | 63 | .draw-pile { 64 | position: absolute; 65 | right: var(--play-area-bleed); 66 | bottom: var(--play-area-bleed); 67 | } 68 | 69 | .monster-name-wrapper { 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | width: auto; 74 | padding: var(--spacing-100) var(--spacing-300); 75 | background-color: rgba(0, 0, 0, 0.15); 76 | clip-path: var(--clip-path-banner); 77 | } 78 | 79 | .monster-name { 80 | font-weight: 700; 81 | text-align: center; 82 | color: var(--color-stone-700); 83 | font-family: var(--font-secondary); 84 | font-size: var(--font-size-400); 85 | } 86 | 87 | .play-area-banner { 88 | position: fixed; 89 | top: 0; 90 | left: 50%; 91 | transform: translateX(-50%); 92 | width: 100%; 93 | height: calc(var(--spacing-500) * 1.5); 94 | max-width: 33vw; 95 | display: flex; 96 | align-items: center; 97 | justify-content: space-between; 98 | padding: var(--spacing-300); 99 | background-color: var(--color-stone-800); 100 | border-right: 2px solid var(--color-stone-400); 101 | border-bottom: 2px solid var(--color-stone-400); 102 | border-left: 2px solid var(--color-stone-400); 103 | box-shadow: 0 15px 15px -10px rgba(0, 0, 0, 0.5); 104 | border-bottom-left-radius: var(--rounding-200); 105 | border-bottom-right-radius: var(--rounding-200); 106 | color: var(--color-stone-100); 107 | } 108 | 109 | .play-area-item-btn { 110 | width: var(--spacing-500); 111 | height: var(--spacing-500); 112 | display: inline-flex; 113 | align-items: center; 114 | justify-content: center; 115 | border-radius: var(--rounding-500); 116 | outline: none; 117 | border: 0; 118 | box-shadow: var(--shadow-900); 119 | padding: var(--spacing-50); 120 | background-color: var(--color-stone-800); 121 | border: var(--border-width-200) solid var(--color-stone-700); 122 | cursor: pointer; 123 | transition-property: background-color; 124 | transition-duration: var(--anim-easing-100); 125 | transition-duration: var(--anim-dur-200); 126 | 127 | &:hover { 128 | background-color: var(--color-stone-700); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from '@xstate/react' 2 | import { motion, AnimatePresence } from 'motion/react' 3 | import { appMachine } from './machines/app-machine/app-machine.ts' 4 | import { type Card as CardType } from './types/cards.ts' 5 | import coinsIcon from './images/gold-coins.png' 6 | import { AppPreloader } from './components/app-preloader.tsx' 7 | import { CharacterCreation } from './components/character-creation.tsx' 8 | import { Avatar } from './components/avatar.tsx' 9 | import { Card } from './components/card.tsx' 10 | import { Deck } from './components/deck.tsx' 11 | import { Dialog, DialogContent } from './components/dialog.tsx' 12 | import { HealthBar } from './components/health-bar.tsx' 13 | import { Feedback } from './components/feedback.tsx' 14 | import { Stack } from './components/stack.tsx' 15 | import { Inline } from './components/inline.tsx' 16 | import { Button } from './components/button.tsx' 17 | import { ItemShopCard, type ItemShopCardStatus } from './components/item-shop-card.tsx' 18 | import { ItemShopItem } from './components/item-shop-item.tsx' 19 | import { StatsRow, StatIcon, StatVal } from './components/stats.tsx' 20 | import { cardUseSound } from './machines/app-machine/app-machine.ts' 21 | import { getItem } from './helpers/item.ts' 22 | import css from './app.module.css' 23 | import { Banner } from './components/banner.tsx' 24 | 25 | export function App() { 26 | const [{ context, value }, send] = useMachine(appMachine) 27 | 28 | if (value === 'LoadingAssets') { 29 | return 30 | } 31 | 32 | if (value === 'CharacterCreation') { 33 | return ( 34 | { 36 | send({ 37 | type: 'CREATE_CHARACTER', 38 | // @ts-expect-error 39 | data: formData, 40 | }) 41 | }} 42 | /> 43 | ) 44 | } 45 | 46 | return ( 47 |
48 |
49 |
50 | 51 | 52 | 53 | {context.game.player.gold} 54 | 55 | 56 | 57 | 58 | 59 | {context.game.player.inventory.map((item) => { 60 | return ( 61 | 64 | send({ type: 'USING_ITEM_ANIMATION_COMPLETE', data: { item } }) 65 | } 66 | initial={{ x: 1, y: 1, opacity: 1 }} 67 | animate={{ x: 1, y: 1, opacity: 1 }} 68 | exit={{ x: 0.33, y: 0.33, opacity: 0 }} 69 | transition={{ duration: 0.33 }} 70 | > 71 | 77 | 78 | ) 79 | })} 80 | 81 | 82 |
83 | 84 |
85 | 86 | {context.game.player.characterPortrait ? ( 87 | 94 | 95 | {context.game.player.characterPortrait ? ( 96 | send({ type: 'ITEM_EFFECTS_ANIMATION_COMPLETE' })} 100 | /> 101 | ) : null} 102 | 103 | {context.game.player.characterName} 104 | 105 | 109 | 110 | 111 | {value === 'Defending' ? ( 112 | send({ type: 'MONSTER_ATTACK_ANIMATION_COMPLETE' })} 115 | > 116 | {/* TODO: This is the wrong value! */} 117 | {context.game.monster?.stats.attack} 118 | 119 | ) : null} 120 | 121 | ) : null} 122 | 123 | 124 | 125 | send({ type: 'MONSTER_DEATH_ANIMATION_COMPLETE' })} 127 | > 128 | {context.game.monster 129 | ? [ 130 | 143 |
144 | 145 | {context.game.monster.artwork ? ( 146 | 150 | ) : null} 151 | 152 | {value === 'ApplyingCardEffects' ? ( 153 | 156 | send({ type: 'CARD_EFFECTS_ANIMATION_COMPLETE' }) 157 | } 158 | > 159 | {context.game.cardInPlay?.stats.attack} 160 | 161 | ) : null} 162 | 163 |
164 |
, 165 | 166 | 176 | {context.game.monster?.name} 177 | , 178 | 179 | 190 | 194 | , 195 | ] 196 | : null} 197 |
198 |
199 |
200 | 201 | 202 | 203 |
204 | {context.game.currentHand.map((card, index) => { 205 | return ( 206 | { 213 | setTimeout(() => { 214 | cardUseSound.play() 215 | }, index * 100) 216 | }} 217 | > 218 | send({ type: 'PLAY_CARD', data: { card } })} 223 | /> 224 | 225 | ) 226 | })} 227 |
228 | Current hand with {context.game.currentHand.length} cards 229 |
230 |
231 | 232 | 233 | 234 | {context.game.discardPile.map((card, index) => { 235 | return ( 236 | 241 | ) 242 | })} 243 | 244 |
Discard pile with {context.game.discardPile.length} cards
245 |
246 | 247 | send({ type: 'DISCARD_CARD_ANIMATION_COMPLETE' })} 249 | mode="popLayout" 250 | > 251 | {context.game.cardInPlay ? ( 252 | 260 | send({ 261 | type: 'PLAY_CARD_ANIMATION_COMPLETE', 262 | data: { card: context.game.cardInPlay as CardType }, 263 | }) 264 | } 265 | > 266 | 267 | 268 | ) : null} 269 | 270 | 271 | 272 | 273 | {context.game.drawPile.map((card, index) => { 274 | return ( 275 | 280 | ) 281 | })} 282 | 283 |
Draw pile with {context.game.drawPile.length} cards
284 |
285 |
286 | 287 | 288 | 289 | 290 | 291 | 294 | 295 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 |

Buy cards and items

308 | 309 | 310 | {context.game.shop.cards.map((card) => { 311 | let status: ItemShopCardStatus = 'affordable' 312 | 313 | if (context.game.player.gold <= card.price) { 314 | status = 'unaffordable' 315 | } 316 | 317 | if (context.game.player.deck.find((deckCard) => deckCard.id === card.id)) { 318 | status = 'purchased' 319 | } 320 | 321 | return ( 322 | send({ type: 'ITEM_SHOP_CARD_CLICK', data: { card } })} 326 | {...card} 327 | /> 328 | ) 329 | })} 330 | 331 | 332 | 333 | = 30 ? 'affordable' : 'unaffordable'} 336 | onClick={() => 337 | send({ 338 | type: 'ITEM_SHOP_ITEM_CLICK', 339 | data: { item: getItem('small-potion', context.game.items) }, 340 | }) 341 | } 342 | /> 343 | 344 | = 50 ? 'affordable' : 'unaffordable'} 347 | onClick={() => 348 | send({ 349 | type: 'ITEM_SHOP_ITEM_CLICK', 350 | data: { item: getItem('large-potion', context.game.items) }, 351 | }) 352 | } 353 | /> 354 | 355 | 356 | 357 | 360 | 361 | 362 | 363 |
364 |
365 |
366 | 367 | 368 | 369 | 370 |

Permanently destroy cards

371 | 372 | 373 | {value === 'DestroyingCard' ? ( 374 | { 387 | console.log('here') 388 | send({ 389 | type: 'CARD_DESTRUCTION_ANIMATION_COMPLETE', 390 | data: { card: context.game.cardToDestroy as CardType }, 391 | }) 392 | }} 393 | > 394 | 395 | 396 | ) : null} 397 | 398 | 399 |
410 | {context.game.player.deck.map((card) => { 411 | return ( 412 | = context.game.cardDestructionPrice 417 | ? 'affordable' 418 | : 'unaffordable' 419 | } 420 | price={context.game.cardDestructionPrice} 421 | onClick={() => send({ type: 'DESTRUCTION_SHOP_CARD_CLICK', data: { card } })} 422 | /> 423 | ) 424 | })} 425 |
426 | 427 | 428 | 434 | 435 | 436 | 437 |
438 |
439 |
440 |
441 | ) 442 | } 443 | -------------------------------------------------------------------------------- /src/cards/assassinate/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/assassinate/artwork.png -------------------------------------------------------------------------------- /src/cards/assassinate/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Assassinate', 5 | description: 'A quick stab in the back', 6 | rarity: 1, 7 | price: 14, 8 | stats: { 9 | attack: 8, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/assassinate/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/assassinate/sfx.wav -------------------------------------------------------------------------------- /src/cards/earthquake/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/earthquake/artwork.png -------------------------------------------------------------------------------- /src/cards/earthquake/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Earthquake', 5 | description: 'The earth split in two', 6 | rarity: 3, 7 | price: 25, 8 | stats: { 9 | attack: 12, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/earthquake/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/earthquake/sfx.wav -------------------------------------------------------------------------------- /src/cards/firebolt/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/firebolt/artwork.png -------------------------------------------------------------------------------- /src/cards/firebolt/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Firebolt', 5 | description: 'Ashes to ashes', 6 | rarity: 2, 7 | price: 15, 8 | stats: { 9 | attack: 9, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/firebolt/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/firebolt/sfx.wav -------------------------------------------------------------------------------- /src/cards/lightning/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/lightning/artwork.png -------------------------------------------------------------------------------- /src/cards/lightning/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Lightning', 5 | description: 'A flash in the pan', 6 | rarity: 2, 7 | price: 12, 8 | stats: { 9 | attack: 7, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/lightning/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/lightning/sfx.wav -------------------------------------------------------------------------------- /src/cards/shield-slam/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/shield-slam/artwork.png -------------------------------------------------------------------------------- /src/cards/shield-slam/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Shield Slam', 5 | description: 'Slam your shield', 6 | rarity: 3, 7 | price: 18, 8 | stats: { 9 | attack: 5, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/shield-slam/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/shield-slam/sfx.wav -------------------------------------------------------------------------------- /src/cards/strike/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/strike/artwork.png -------------------------------------------------------------------------------- /src/cards/strike/artwork.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/strike/artwork.webp -------------------------------------------------------------------------------- /src/cards/strike/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCard } from '../../helpers/cards' 2 | 3 | export default defineCard({ 4 | name: 'Strike', 5 | description: 'Smack your opponent!', 6 | rarity: 0, 7 | price: 14, 8 | stats: { 9 | attack: 3, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/cards/strike/sfx.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/cards/strike/sfx.wav -------------------------------------------------------------------------------- /src/character-classes/berzerker/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCharacterClass } from '../../helpers/character-classes' 2 | import { getCard, CARDS } from '../../helpers/cards' 3 | 4 | export default defineCharacterClass({ 5 | id: 'berzerker', 6 | name: 'Berzerker', 7 | deck: [ 8 | getCard('shield-slam', CARDS), 9 | getCard('shield-slam', CARDS), 10 | getCard('shield-slam', CARDS), 11 | getCard('lightning', CARDS), 12 | getCard('lightning', CARDS), 13 | getCard('assassinate', CARDS), 14 | getCard('earthquake', CARDS), 15 | ].filter(Boolean), 16 | }) 17 | -------------------------------------------------------------------------------- /src/character-classes/rogue/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCharacterClass } from '../../helpers/character-classes' 2 | import { getCard, CARDS } from '../../helpers/cards' 3 | 4 | export default defineCharacterClass({ 5 | id: 'rogue', 6 | name: 'Rogue', 7 | deck: [ 8 | getCard('shield-slam', CARDS), 9 | getCard('shield-slam', CARDS), 10 | getCard('lightning', CARDS), 11 | getCard('earthquake', CARDS), 12 | getCard('assassinate', CARDS), 13 | ].filter(Boolean), 14 | }) 15 | -------------------------------------------------------------------------------- /src/character-classes/wizard/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCharacterClass } from '../../helpers/character-classes' 2 | import { getCard, CARDS } from '../../helpers/cards' 3 | 4 | export default defineCharacterClass({ 5 | id: 'wizard', 6 | name: 'Wizard', 7 | deck: [ 8 | getCard('shield-slam', CARDS), 9 | getCard('shield-slam', CARDS), 10 | getCard('lightning', CARDS), 11 | getCard('earthquake', CARDS), 12 | getCard('assassinate', CARDS), 13 | ].filter(Boolean), 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/app-preloader.tsx: -------------------------------------------------------------------------------- 1 | export function AppPreloader() { 2 | return
Assets loading
3 | } 4 | -------------------------------------------------------------------------------- /src/components/avatar.module.css: -------------------------------------------------------------------------------- 1 | .avatar { 2 | position: relative; 3 | display: inline-flex; 4 | border-radius: var(--rounding-1000); 5 | width: var(--size-500); 6 | height: var(--size-500); 7 | overflow: hidden; 8 | border: var(--border-width-400) solid var(--color-stone-400); 9 | outline: var(--border-width-200) solid var(--color-stone-300); 10 | } 11 | 12 | .avatar-img { 13 | width: 100%; 14 | height: 100%; 15 | object-fit: cover; 16 | border-radius: inherit; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'motion/react' 2 | import { rng } from '../helpers/rng' 3 | import css from './avatar.module.css' 4 | 5 | const DAMAGE_FLASH_DURATION = 0.4 6 | const HEALING_FLASH_DURATION = 0.75 7 | 8 | export type AvatarStatus = 'idle' | 'taking-damage' | 'healing' | 'dead' 9 | 10 | /** Displays the current player character portrait */ 11 | export function Avatar({ 12 | src, 13 | status = 'idle', 14 | onAnimationComplete, 15 | }: { 16 | src: string 17 | status?: AvatarStatus 18 | onAnimationComplete?: () => void 19 | }) { 20 | return ( 21 |
22 | 23 |
24 | 25 | 26 | {status === 'taking-damage' ? ( 27 | 28 | ) : null} 29 | 30 | {status === 'healing' ? : null} 31 |
32 |
33 |
34 | ) 35 | } 36 | 37 | /** Handles shaking the avatar when taking damage */ 38 | function AvatarAnimationWrapper({ 39 | children, 40 | status, 41 | }: { 42 | children: React.ReactNode 43 | status: AvatarStatus 44 | }) { 45 | const animation = 46 | status === 'taking-damage' 47 | ? { x: [0, -rng(25), rng(25), rng(-25), 0], y: [0, -rng(15), rng(15), rng(-15), 0] } 48 | : { x: [0, 0], y: [0, 0] } 49 | 50 | return ( 51 | 52 | {children} 53 | 54 | ) 55 | } 56 | 57 | /** Overlays the avatar and applies a flash when taking damage */ 58 | function DamageFlash({ onAnimationComplete }: { onAnimationComplete?: () => void }) { 59 | return ( 60 | 77 | ) 78 | } 79 | 80 | /** Overlays the avatar and applies a flash when healing */ 81 | function HealingFlash({ onAnimationComplete }: { onAnimationComplete?: () => void }) { 82 | return ( 83 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/banner.module.css: -------------------------------------------------------------------------------- 1 | .banner { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | width: auto; 6 | padding: var(--spacing-100) var(--spacing-300); 7 | background-color: rgba(0, 0, 0, 0.15); 8 | clip-path: var(--clip-path-banner); 9 | } 10 | 11 | .banner-content { 12 | font-weight: 700; 13 | text-align: center; 14 | color: var(--color-stone-700); 15 | font-family: var(--font-secondary); 16 | font-size: var(--font-size-400); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/banner.tsx: -------------------------------------------------------------------------------- 1 | import css from './banner.module.css' 2 | 3 | export function Banner({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
{children}
7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/components/button.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | --text-color: var(--color-stone-800); 3 | --gradient-start: var(--color-stone-300); 4 | --gradient-end: var(--color-stone-100); 5 | 6 | display: grid; 7 | cursor: pointer; 8 | color: var(--text-color); 9 | font-family: var(--font-secondary); 10 | font-size: var(--font-size-400); 11 | border-radius: var(--rounding-100); 12 | overflow: hidden; 13 | border: 0; 14 | padding: 0; 15 | box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.33); 16 | border: 2px solid var(--gradient-end); 17 | transition: 18 | transform var(--anim-dur-100) var(--anim-easing-100), 19 | box-shadow var(--anim-dur-100) var(--anim-easing-100); 20 | } 21 | 22 | .button-content, 23 | .button-bg { 24 | padding: var(--spacing-200) var(--spacing-400); 25 | align-items: center; 26 | justify-content: center; 27 | grid-row: 1; 28 | grid-column: 1; 29 | height: 100%; 30 | width: 100%; 31 | } 32 | 33 | .button-content { 34 | color: var(--text-color); 35 | transform: translateY(-1px); /* helps make text look vertically centered */ 36 | box-shadow: inset 0 -5px var(--gradient-start); 37 | z-index: 1; 38 | } 39 | 40 | .button-bg { 41 | height: 150%; 42 | z-index: 0; 43 | background: linear-gradient(180deg, var(--gradient-start) 10%, var(--gradient-end) 100%); 44 | transition: transform var(--anim-dur-200) var(--anim-easing-100); 45 | } 46 | 47 | .button:hover { 48 | transform: translateY(-2px); 49 | box-shadow: 0 8px 15px 0px rgba(0, 0, 0, 0.33); 50 | 51 | & .button-bg { 52 | transform: translateY(-25%); 53 | } 54 | } 55 | 56 | .button.primary { 57 | --text-color: var(--color-stone-100); 58 | --gradient-start: var(--color-blood-500); 59 | --gradient-end: var(--color-blood-300); 60 | } 61 | 62 | .button.secondary { 63 | /* --text-color: var(--color-stone-900); 64 | --gradient-start: var(--color-stone-500); 65 | --gradient-end: var(--color-stone-300); */ 66 | } 67 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import css from './button.module.css' 3 | 4 | /** Re-usable button component */ 5 | export function Button({ 6 | children, 7 | variant = 'primary', 8 | ...props 9 | }: { 10 | children: React.ReactNode 11 | variant?: 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'unstyled' 12 | } & React.ComponentPropsWithRef<'button'>) { 13 | const withClsx = (root: string) => 14 | clsx({ 15 | [css[root]]: true, 16 | [css[variant]]: true, 17 | }) 18 | 19 | return ( 20 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/card.module.css: -------------------------------------------------------------------------------- 1 | .card { 2 | display: inline-flex; 3 | border: 1px solid var(--color-stone-400); 4 | border-radius: var(--rounding-100); 5 | overflow: hidden; 6 | height: var(--card-height); 7 | width: var(--card-width); 8 | perspective: 1000px; 9 | background: linear-gradient( 10 | to bottom, 11 | var(--color-stone-800), 12 | var(--color-stone-500), 13 | var(--color-stone-900) 14 | ); 15 | color: var(--color-stone-100); 16 | transition-duration: var(--anim-dur-300); 17 | transition-timing-function: var(--anim-easing-100); 18 | transition-property: transform, box-shadow; 19 | } 20 | 21 | .card-front { 22 | outline: 1px solid var(--color-stone-500); 23 | border-radius: calc(1 * var(--rounding-100)); 24 | outline-offset: -2px; 25 | } 26 | 27 | .card.face-up.idle:hover { 28 | transform: translateY(calc(-1 * var(--spacing-100))); 29 | } 30 | 31 | .card.face-up { 32 | box-shadow: 0 var(--spacing-100) var(--spacing-300) calc(var(--spacing-200) * -1) 33 | var(--color-stone-700); 34 | } 35 | 36 | .card.face-up:hover { 37 | box-shadow: 0 var(--spacing-200) var(--spacing-400) calc(var(--spacing-100) * -1) 38 | var(--color-stone-700); 39 | } 40 | 41 | .card.in-play { 42 | box-shadow: 0 var(--spacing-200) var(--spacing-500) calc(var(--spacing-100) * -1) 43 | var(--color-stone-700); 44 | } 45 | 46 | .card.face-down { 47 | pointer-events: none; 48 | } 49 | 50 | .card.disabled, 51 | .card.purchased, 52 | .card.in-play { 53 | pointer-events: none; 54 | } 55 | 56 | .card-header { 57 | padding: var(--spacing-100); 58 | } 59 | 60 | .card-content { 61 | padding-inline: var(--spacing-100); 62 | } 63 | 64 | .card-artwork { 65 | position: relative; 66 | width: 100%; 67 | height: 100%; 68 | object-fit: contain; 69 | border-radius: var(--rounding-100) var(--rounding-100) 0 0; 70 | border: var(--border-width-300) solid var(--color-stone-200); 71 | z-index: 15; 72 | box-shadow: 0 var(--spacing-200) var(--spacing-200) calc(var(--spacing-200) * -1) 73 | var(--color-stone-400); 74 | } 75 | 76 | .card-description { 77 | text-wrap: pretty; 78 | text-align: center; 79 | height: var(--size-300); 80 | background-color: var(--color-stone-200); 81 | border-radius: 0 0 var(--rounding-50) var(--rounding-50); 82 | padding: var(--spacing-100); 83 | color: var(--color-stone-700); 84 | font-size: var(--font-size-200); 85 | } 86 | 87 | .card-name { 88 | display: flex; 89 | background-color: var(--color-stone-200); 90 | border-radius: var(--rounding-50); 91 | border: var(--border-width-200) solid var(--color-stone-100); 92 | padding-inline: var(--spacing-100); 93 | font-family: var(--font-secondary); 94 | color: var(--color-stone-700); 95 | font-size: var(--font-size-300); 96 | text-shadow: 0 2px 1px var(--color-stone-100); 97 | box-shadow: inset 0 var(--spacing-200) var(--spacing-100) calc(var(--spacing-200) * -1) 98 | var(--color-stone-400); 99 | } 100 | 101 | .card-footer { 102 | position: absolute; 103 | width: 100%; 104 | height: var(--size-200); 105 | bottom: 0; 106 | z-index: 99; 107 | } 108 | 109 | .card-back { 110 | position: absolute; 111 | top: 0; 112 | left: 0; 113 | width: 100%; 114 | height: 100%; 115 | opacity: 0; 116 | z-index: 100; 117 | background-color: var(--color-stone-900); 118 | } 119 | 120 | .card-back-img { 121 | width: 100%; 122 | height: 100%; 123 | object-fit: contain; 124 | opacity: 0.75; 125 | } 126 | 127 | .card-back-overlay { 128 | position: absolute; 129 | top: 0; 130 | left: 0; 131 | width: 100%; 132 | height: 100%; 133 | background-color: var(--color-stone-900); 134 | opacity: 0.825; 135 | } 136 | 137 | .card-back.face-down { 138 | opacity: 1; 139 | } 140 | 141 | .card-stats-row { 142 | position: absolute; 143 | bottom: 0; 144 | left: 0; 145 | background: radial-gradient( 146 | circle at bottom left, 147 | var(--color-stone-800) 0%, 148 | var(--color-stone-600) 50%, 149 | var(--color-stone-800) 100% 150 | ); 151 | padding: var(--spacing-100); 152 | border-top-right-radius: var(--rounding-100); 153 | border-bottom-right-radius: var(--rounding-100); 154 | box-shadow: 2px 2px 3px -1px var(--color-stone-900); 155 | overflow: hidden; 156 | } 157 | -------------------------------------------------------------------------------- /src/components/card.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import type { Card } from '../types/cards' 3 | import cardBackImg from '../images/card-back.png' 4 | import swordIcon from '../images/sword.png' 5 | import { StatsRow, StatIcon, StatVal } from './stats' 6 | import css from './card.module.css' 7 | 8 | export function Card({ 9 | className, 10 | description, 11 | name, 12 | onClick, 13 | rarity, 14 | isStacked, 15 | orientation = 'face-up', 16 | status = 'idle', 17 | stats, 18 | artwork, 19 | id, 20 | }: { isStacked?: boolean; onClick?: () => void; className?: string } & Card) { 21 | const withClsx = (rootClass: string, additionalClassName?: string) => { 22 | return clsx( 23 | { 24 | [rootClass]: true, 25 | [css['disabled']]: status === 'disabled', 26 | [css['in-play']]: status === 'in-play', 27 | [css['idle']]: status === 'idle', 28 | [css['face-down']]: orientation === 'face-down', 29 | [css['face-up']]: orientation === 'face-up', 30 | [css['stacked']]: isStacked === true, 31 | [css['rarity-0']]: rarity === 0, 32 | [css['rarity-1']]: rarity === 1, 33 | [css['rarity-2']]: rarity === 2, 34 | [css['rarity-3']]: rarity === 3, 35 | }, 36 | additionalClassName, 37 | ) 38 | } 39 | 40 | return ( 41 |
42 |
43 |
44 |
{name}
45 |
46 | 47 |
48 | 49 | 50 |
{description}
51 |
52 | 53 |
54 | 55 | {stats?.attack ? ( 56 | <> 57 | 58 | {stats.attack} 59 | 60 | ) : null} 61 | 62 |
63 |
64 | 65 |
66 | 67 |
68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/character-creation.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | max-width: 1100px; 3 | margin: 0 auto; 4 | padding: var(--spacing-400); 5 | } 6 | 7 | .input-ctrl { 8 | display: flex; 9 | flex-direction: column; 10 | gap: var(--spacing-100); 11 | width: 100%; 12 | } 13 | 14 | .input-label { 15 | font-family: var(--font-secondary); 16 | font-weight: var(--font-weight-700); 17 | } 18 | 19 | .input { 20 | appearance: none; 21 | border: 0; 22 | padding: var(--spacing-100); 23 | background-color: var(--color-stone-200); 24 | border-radius: var(--rounding-100); 25 | width: 100%; 26 | 27 | &:focus-visible { 28 | outline: 2px solid var(--color-stone-400); 29 | } 30 | } 31 | 32 | .character-portrait-fieldset { 33 | display: grid; 34 | width: 100%; 35 | grid-template-columns: repeat(auto-fit, minmax(var(--size-500), 1fr)); 36 | gap: var(--spacing-300); 37 | padding: 0; 38 | border: 0; 39 | } 40 | 41 | .character-portrait-label { 42 | display: block; 43 | line-height: 0; 44 | border-radius: var(--rounding-300); 45 | overflow: hidden; 46 | cursor: pointer; 47 | transition: 48 | box-shadow 0.2s ease-out, 49 | outline 0.3s ease-in-out, 50 | transform 0.4s ease-in-out; 51 | 52 | &:hover, 53 | &:has(input:checked) { 54 | transform: scale(1.1); 55 | box-shadow: 0 0 30px 15px rgba(255, 255, 255, 0.7); 56 | z-index: var(--z-index-700); 57 | } 58 | 59 | &:has(input:checked) { 60 | } 61 | } 62 | 63 | .character-portrait-label { 64 | } 65 | 66 | .character-portrait-input { 67 | display: none; 68 | } 69 | 70 | .character-portrait-img { 71 | } 72 | -------------------------------------------------------------------------------- /src/components/character-creation.tsx: -------------------------------------------------------------------------------- 1 | import { type SyntheticEvent } from 'react' 2 | import { useMachine } from '@xstate/react' 3 | import { appMachine } from '../machines/app-machine/app-machine' 4 | import { Button } from './button' 5 | import css from './character-creation.module.css' 6 | import { resolveModules } from '../helpers/vite' 7 | import { Stack } from './stack' 8 | 9 | // TODO Use import globbing here instead 10 | 11 | const PLAYER_PORTRAIT_MODULES = import.meta.glob('../images/player-portraits/*.(png|webp)', { 12 | eager: true, 13 | }) 14 | 15 | const PLAYER_PORTRAITS = resolveModules(PLAYER_PORTRAIT_MODULES) 16 | 17 | /** UI view for character creation */ 18 | export function CharacterCreation({ 19 | onCreate, 20 | }: { 21 | onCreate: (data: Record) => void 22 | }) { 23 | const [{ context }] = useMachine(appMachine) 24 | 25 | /** Handler for the form submit event */ 26 | const handleSubmit = (e: SyntheticEvent) => { 27 | e.preventDefault() 28 | 29 | const target = e.target as HTMLFormElement 30 | const formData = new FormData(target) 31 | const json = Object.fromEntries(formData) 32 | 33 | onCreate(json) 34 | } 35 | 36 | return ( 37 |
38 |
39 | 40 |

Create your character

41 | 42 |
43 | 46 | 55 |
56 | 57 | 72 | 73 |
74 | Character portrait 75 | 76 | {PLAYER_PORTRAITS.map((portrait, index) => { 77 | return ( 78 | 89 | ) 90 | })} 91 |
92 | 93 | 94 |
95 |
96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/components/debug-tag.tsx: -------------------------------------------------------------------------------- 1 | export function DebugTag({ 2 | children, 3 | style, 4 | }: { 5 | children: React.ReactNode 6 | style?: React.CSSProperties 7 | }) { 8 | return ( 9 |
18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/deck.module.css: -------------------------------------------------------------------------------- 1 | .deck { 2 | /* allows absolute positioning within */ 3 | position: relative; 4 | pointer-events: none; 5 | display: flex; 6 | flex-direction: row; 7 | width: var(--card-width); 8 | height: var(--card-height); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/deck.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { clsx } from 'clsx' 3 | import { EmptyDeck } from './empty-deck' 4 | import css from './deck.module.css' 5 | 6 | export function Deck({ children }: { children?: React.ReactNode }) { 7 | return ( 8 |
13 | {React.Children.count(children) === 0 ? ( 14 | 15 | ) : ( 16 | React.Children.map(children, (child, index) => { 17 | return ( 18 |
22 | {child} 23 |
24 | ) 25 | }) 26 | )} 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/components/dialog.module.css: -------------------------------------------------------------------------------- 1 | .dialog-container { 2 | --dialog-max-width: 800px; 3 | 4 | container-type: inline-size; 5 | container-name: dialog; 6 | width: 100%; 7 | max-width: 800px; 8 | display: flex; 9 | justify-content: center; 10 | position: fixed; 11 | z-index: var(--z-index-600); 12 | top: 50%; 13 | left: 50%; 14 | transform: scale(1) translate(-50%, -50%); 15 | } 16 | 17 | .dialog { 18 | max-width: var(--dialog-max-width); 19 | width: 100%; 20 | opacity: 0; 21 | visibility: hidden; 22 | background-image: linear-gradient(to top, var(--color-stone-800) 10%, var(--color-stone-900)); 23 | color: var(--color-stone-100); 24 | border-radius: var(--rounding-200); 25 | transform: scale(0); 26 | display: none; 27 | z-index: var(--z-index-600); 28 | } 29 | 30 | /** The max width of the dialog is hit when shrinking the viewport, so we reduce the max width slightly so that it does not touch the edges of the viewport. */ 31 | @container dialog (max-width: 800px) { 32 | .dialog { 33 | width: 90%; 34 | } 35 | } 36 | 37 | .dialog { 38 | opacity: 1; 39 | visibility: visible; 40 | display: flex; 41 | border: 1px solid var(--color-stone-600); 42 | filter: drop-shadow(0 0.75rem 0.75rem rgba(0, 0, 0, 0.5)); 43 | transform: scale(1); 44 | } 45 | 46 | .dialog-content { 47 | width: 100%; 48 | padding: var(--spacing-400); 49 | } 50 | 51 | .dialog-overlay { 52 | display: none; 53 | } 54 | 55 | .dialog-overlay { 56 | display: block; 57 | position: fixed; 58 | top: 0; 59 | left: 0; 60 | height: 100vh; 61 | width: 100vw; 62 | z-index: var(--z-index-500); 63 | background-color: rgba(0, 0, 0, 0.5); 64 | backdrop-filter: blur(5px); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import { AnimatePresence, motion } from 'motion/react' 3 | import css from './dialog.module.css' 4 | 5 | export function Dialog({ 6 | open, 7 | children, 8 | }: { 9 | open: boolean 10 | children: React.ReactNode 11 | onClose?: () => void 12 | }) { 13 | const withStatusClsx = (root: string) => 14 | clsx({ 15 | [css[root]]: true, 16 | [css['is-open']]: open === true, 17 | }) 18 | 19 | return ( 20 | <> 21 |
22 | 23 | {open ? ( 24 | 33 | {children} 34 | 35 | ) : null} 36 | 37 |
38 | 39 | 40 | {open ? ( 41 | 48 | ) : null} 49 | 50 | 51 | ) 52 | } 53 | 54 | export function DialogContent({ children }: { children: React.ReactNode }) { 55 | return
{children}
56 | } 57 | -------------------------------------------------------------------------------- /src/components/empty-deck.module.css: -------------------------------------------------------------------------------- 1 | .empty-deck { 2 | display: inline-flex; 3 | width: var(--card-width); 4 | height: var(--card-height); 5 | border-radius: var(--rounding-200); 6 | background-color: var(--color-stone-400); 7 | outline: var(--spacing-100) solid var(--color-stone-400); 8 | border: var(--border-width-300) dashed var(--color-stone-800); 9 | opacity: 0.25; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/empty-deck.tsx: -------------------------------------------------------------------------------- 1 | import css from './empty-deck.module.css' 2 | 3 | export function EmptyDeck() { 4 | return
5 | } 6 | -------------------------------------------------------------------------------- /src/components/feedback.module.css: -------------------------------------------------------------------------------- 1 | .feedback-text { 2 | font-family: var(--font-heading); 3 | font-weight: 700; 4 | font-size: var(--font-size-300); 5 | text-shadow: 0 2px 1px var(--color-shadow-200); 6 | } 7 | 8 | .feedback-text.neutral { 9 | color: var(--color-stone-200); 10 | } 11 | 12 | .feedback-text.positive { 13 | color: var(--color-success); 14 | } 15 | 16 | .feedback-text.negative { 17 | color: var(--color-danger); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/feedback.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'motion/react' 2 | import { clsx } from 'clsx' 3 | import css from './feedback.module.css' 4 | 5 | const FEEDBACK_DURATION = 1.0 6 | 7 | export function Feedback({ 8 | children, 9 | variant, 10 | duration = FEEDBACK_DURATION, 11 | orientation = 'top', 12 | onAnimationComplete, 13 | }: { 14 | children: React.ReactNode 15 | variant: 'neutral' | 'positive' | 'negative' 16 | duration?: number 17 | orientation?: 'bottom' | 'top' 18 | onAnimationComplete?: () => void 19 | }) { 20 | return ( 21 | 36 |
44 | {variant === 'positive' && '+'} 45 | 46 | {variant === 'negative' && '-'} 47 | 48 | {children} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/components/game-error.tsx: -------------------------------------------------------------------------------- 1 | export function GameError() { 2 | return

Something went wrong :(

3 | } 4 | -------------------------------------------------------------------------------- /src/components/health-bar.module.css: -------------------------------------------------------------------------------- 1 | .health-bar-wrapper { 2 | filter: drop-shadow(0 2px 2px var(--color-stone-400)); 3 | width: 100%; 4 | } 5 | 6 | .health-bar { 7 | --health-percentage: 1; 8 | 9 | position: relative; 10 | display: flex; 11 | overflow: hidden; 12 | height: var(--size-150); 13 | border-radius: var(--rounding-200); 14 | border-bottom-left-radius: 0; 15 | border: var(--border-width-100) solid var(--color-blood-500); 16 | background: linear-gradient(0deg, var(--color-stone-600) 50%, var(--color-stone-800) 100%); 17 | } 18 | 19 | .health-bar-fill { 20 | position: absolute; 21 | left: 0; 22 | top: 0; 23 | height: 100%; 24 | width: 100%; 25 | transform: scaleX(var(--health-percentage)); 26 | transform-origin: left; 27 | transition: transform var(--anim-dur-400) var(--anim-easing-100); 28 | transition-delay: var(--anim-dur-300); 29 | background: linear-gradient( 30 | 180deg, 31 | var(--color-blood-300) 0%, 32 | var(--color-blood-900) 60%, 33 | var(--color-blood-900) 100% 34 | ); 35 | } 36 | 37 | .health-bar-text-wrapper { 38 | display: inline-flex; 39 | justify-content: center; 40 | align-items: center; 41 | padding: var(--spacing-50) 0; 42 | width: 40%; 43 | background: linear-gradient(180deg, var(--color-blood-300) 5%, var(--color-blood-500) 80%); 44 | border-bottom-right-radius: var(--rounding-200); 45 | border-bottom-left-radius: var(--rounding-200); 46 | border: var(--border-width-100) solid var(--color-blood-500); 47 | } 48 | 49 | .health-bar-text { 50 | font-size: var(--font-size-200); 51 | font-weight: 900; 52 | color: var(--color-stone-100); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/health-bar.tsx: -------------------------------------------------------------------------------- 1 | import css from './health-bar.module.css' 2 | 3 | /** Shows a health bar */ 4 | export function HealthBar({ health, maxHealth }: { health: number; maxHealth: number }) { 5 | const healthPercentage = health / maxHealth 6 | const healthText = health > 0 ? health : 0 7 | 8 | return ( 9 |
10 |
14 |
15 |
16 | 17 |
18 |
{healthText} HP
19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/inline.module.css: -------------------------------------------------------------------------------- 1 | .inline { 2 | display: flex; 3 | flex-direction: row; 4 | gap: var(--gap); 5 | text-align: inherit; 6 | } 7 | 8 | .top { 9 | align-items: flex-start; 10 | } 11 | 12 | .right { 13 | align-items: flex-end; 14 | } 15 | 16 | .center { 17 | align-items: center; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/inline.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import type { Spacing } from '../types/tokens' 3 | import css from './inline.module.css' 4 | 5 | export function Inline({ 6 | className, 7 | children, 8 | spacing = '300', 9 | align = 'center', 10 | }: { 11 | children: React.ReactNode 12 | className?: string 13 | spacing?: Spacing 14 | align?: 'top' | 'bottom' | 'center' 15 | }) { 16 | const alignClass = align === 'top' ? css.top : align === 'bottom' ? css.bottom : css.center 17 | 18 | return ( 19 |
23 | {children} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/item-shop-card.module.css: -------------------------------------------------------------------------------- 1 | .item-shop-card { 2 | position: relative; 3 | } 4 | 5 | .unaffordable, 6 | .purchased { 7 | cursor: not-allowed; 8 | } 9 | 10 | .item-shop-card-card.unaffordable, 11 | .item-shop-card-card.purchased { 12 | filter: grayscale(1); 13 | } 14 | 15 | .purchased { 16 | --clip-path: var(--clip-path-banner); 17 | } 18 | 19 | .unaffordable { 20 | --clip-path: none; 21 | } 22 | 23 | .item-shop-card-overlay { 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | background-color: rgba(0, 0, 0, 0.4); 28 | color: var(--color-stone-200); 29 | font-size: var(--font-size-400); 30 | font-family: var(--font-secondary); 31 | font-weight: var(--font-weight-bold); 32 | position: absolute; 33 | z-index: 90; 34 | top: 0; 35 | left: 0; 36 | width: var(--card-width); 37 | height: var(--card-height); 38 | 39 | span { 40 | display: flex; 41 | background-color: rgba(0, 0, 0, 0.75); 42 | padding: var(--spacing-100) var(--spacing-200); 43 | box-shadow: var(--shadow-900); 44 | clip-path: var(--clip-path); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/item-shop-card.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import { AnimatePresence, motion } from 'motion/react' 3 | import type { Card as CardType } from '../types/cards' 4 | import { PriceStatsRow } from './price-stats-row' 5 | import { Card } from './card' 6 | import css from './item-shop-card.module.css' 7 | 8 | export type ItemShopCardStatus = 'affordable' | 'unaffordable' | 'purchased' 9 | 10 | export function ItemShopCard({ 11 | className, 12 | shopStatus, 13 | onClick, 14 | ...props 15 | }: { 16 | shopStatus: ItemShopCardStatus 17 | onClick: () => void 18 | className?: string 19 | } & CardType) { 20 | const disabled = shopStatus === 'unaffordable' || shopStatus === 'purchased' 21 | 22 | const withClsx = (rootClass: string, className?: string) => { 23 | return clsx( 24 | { 25 | [rootClass]: true, 26 | [css['purchased']]: shopStatus === 'purchased', 27 | [css['unaffordable']]: shopStatus === 'unaffordable', 28 | [css['affordable']]: shopStatus === 'affordable', 29 | }, 30 | className, 31 | ) 32 | } 33 | 34 | /** Handle clicks, bailing when the card is disabled */ 35 | const handleClick = () => { 36 | if (disabled) return 37 | 38 | if (!onClick) return 39 | 40 | return onClick() 41 | } 42 | 43 | return ( 44 |
45 | 46 | {shopStatus === 'purchased' ? ( 47 | 53 | 59 | Purchased! 60 | 61 | 62 | ) : null} 63 | 64 | {shopStatus === 'unaffordable' ? ( 65 | 73 | 79 | Unaffordable 80 | 81 | 82 | ) : null} 83 | 84 | 85 | 90 | 91 | 92 |
93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /src/components/item-shop-item.module.css: -------------------------------------------------------------------------------- 1 | .item-shop-item { 2 | position: relative; 3 | 4 | &.unaffordable { 5 | cursor: not-allowed; 6 | } 7 | } 8 | 9 | .item-shop-item-btn { 10 | display: inline-flex; 11 | width: var(--card-width); 12 | height: var(--card-width); 13 | align-items: center; 14 | justify-content: center; 15 | cursor: pointer; 16 | outline: none; 17 | border: 1px solid var(--color-stone-400); 18 | background: none; 19 | background: linear-gradient( 20 | to bottom, 21 | var(--color-stone-900), 22 | var(--color-stone-700), 23 | var(--color-stone-900) 24 | ); 25 | padding: var(--spacing-200); 26 | border-radius: var(--rounding-200); 27 | box-shadow: 0 var(--spacing-100) var(--spacing-300) calc(var(--spacing-200) * -1) 28 | var(--color-stone-700); 29 | overflow: hidden; 30 | transition-duration: var(--anim-dur-300); 31 | transition-timing-function: var(--anim-easing-100); 32 | transition-property: transform, box-shadow; 33 | 34 | &:hover { 35 | transform: translateY(calc(-1 * var(--spacing-100))); 36 | box-shadow: 0 var(--spacing-200) var(--spacing-400) calc(var(--spacing-100) * -1) 37 | var(--color-stone-700); 38 | } 39 | } 40 | 41 | .item-shop-item-overlay { 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | background-color: rgba(0, 0, 0, 0.4); 46 | border-radius: var(--rounding-200); 47 | color: var(--color-stone-200); 48 | font-size: var(--font-size-400); 49 | font-family: var(--font-secondary); 50 | font-weight: var(--font-weight-bold); 51 | position: absolute; 52 | z-index: 90; 53 | top: 0; 54 | left: 0; 55 | width: var(--card-width); 56 | height: var(--card-width); 57 | 58 | span { 59 | display: flex; 60 | background-color: rgba(0, 0, 0, 0.75); 61 | padding: var(--spacing-100) var(--spacing-200); 62 | box-shadow: var(--shadow-900); 63 | clip-path: var(--clip-path); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/item-shop-item.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import { AnimatePresence, motion } from 'motion/react' 3 | import { PriceStatsRow } from './price-stats-row' 4 | import type { Item } from '../types/items' 5 | import css from './item-shop-item.module.css' 6 | 7 | export type ItemShopItemStatus = 'affordable' | 'unaffordable' 8 | 9 | export function ItemShopItem({ 10 | className, 11 | item, 12 | shopStatus, 13 | price, 14 | onClick, 15 | }: { 16 | shopStatus: ItemShopItemStatus 17 | item: Item 18 | onClick: () => void 19 | price?: number 20 | className?: string 21 | }) { 22 | const withClsx = (rootClass: string, className?: string) => { 23 | return clsx( 24 | { 25 | [rootClass]: true, 26 | [css['unaffordable']]: shopStatus === 'unaffordable', 27 | [css['affordable']]: shopStatus === 'affordable', 28 | }, 29 | className, 30 | ) 31 | } 32 | 33 | const handleClick = () => { 34 | if (!onClick) return 35 | 36 | if (shopStatus == 'unaffordable') return 37 | 38 | return onClick() 39 | } 40 | 41 | return ( 42 |
43 | 44 | {shopStatus === 'unaffordable' ? ( 45 | 54 | 60 | Unaffordable 61 | 62 | 63 | ) : null} 64 | 65 | 66 | 69 | 70 | 71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /src/components/price-stats-row.module.css: -------------------------------------------------------------------------------- 1 | .price-stats-row { 2 | background: var(--color-stone-700); 3 | padding: var(--spacing-100); 4 | border-radius: var(--rounding-100); 5 | margin-top: var(--spacing-200); 6 | background: radial-gradient( 7 | circle at bottom left, 8 | var(--color-stone-800) 0%, 9 | var(--color-stone-600) 33%, 10 | var(--color-stone-800) 100% 11 | ); 12 | box-shadow: var(--shadow-900); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/price-stats-row.tsx: -------------------------------------------------------------------------------- 1 | import { StatsRow, StatIcon, StatVal } from './stats' 2 | import coinsIcon from '../images/gold-coins.png' 3 | import css from './price-stats-row.module.css' 4 | 5 | export function PriceStatsRow({ price }: { price: number }) { 6 | return ( 7 | 8 | 9 | {price} 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/stack.module.css: -------------------------------------------------------------------------------- 1 | .stack { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--gap); 5 | text-align: inherit; 6 | } 7 | 8 | .left { 9 | align-items: flex-start; 10 | } 11 | 12 | .right { 13 | align-items: flex-end; 14 | } 15 | 16 | .center { 17 | align-items: center; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/stack.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import type { Spacing } from '../types/tokens' 3 | import css from './stack.module.css' 4 | 5 | const ALIGN_PROP_VALS = ['left', 'right', 'center'] as const 6 | 7 | type AlignProp = (typeof ALIGN_PROP_VALS)[number] 8 | 9 | const ALIGN_CLASS_MAP: Record = { 10 | left: css['left'], 11 | right: css['right'], 12 | center: css['center'], 13 | } 14 | 15 | export function Stack({ 16 | className, 17 | children, 18 | spacing = '300', 19 | align = 'left', 20 | style, 21 | }: { 22 | children: React.ReactNode 23 | className?: string 24 | spacing?: Spacing 25 | align?: AlignProp 26 | style?: React.CSSProperties 27 | }) { 28 | const alignClass = ALIGN_CLASS_MAP[align] 29 | 30 | return ( 31 |
35 | {children} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/stats.module.css: -------------------------------------------------------------------------------- 1 | .stats-row { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | gap: var(--spacing-100); 6 | } 7 | 8 | .stat { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | color: var(--color); 13 | } 14 | 15 | .stat-icon { 16 | border-radius: var(--rounding-100); 17 | min-width: var(--size-150); 18 | height: var(--size-150); 19 | box-shadow: 0 2px 3px -1px var(--color-stone-900); 20 | } 21 | 22 | .stat-val { 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | border-radius: var(--rounding-100); 27 | background-color: var(--color-stone-100); 28 | color: var(--color-stone-700); 29 | font-size: var(--font-size-300); 30 | padding: var(--spacing-100) 0; 31 | font-weight: bold; 32 | min-width: var(--size-150); 33 | height: var(--size-150); 34 | box-shadow: 0 2px 3px -1px var(--color-stone-900); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/stats.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx' 2 | import css from './stats.module.css' 3 | 4 | export function Stats({ children, className }: { children: React.ReactNode; className?: string }) { 5 | return
{children}
6 | } 7 | 8 | export function StatsRow({ 9 | children, 10 | className, 11 | }: { 12 | children: React.ReactNode 13 | className?: string 14 | }) { 15 | return
{children}
16 | } 17 | 18 | export function Stat({ children, className }: { children: React.ReactNode; className?: string }) { 19 | return
{children}
20 | } 21 | 22 | export function StatIcon({ src, className }: { src: string; className?: string }) { 23 | return 24 | } 25 | 26 | export function StatVal({ 27 | children, 28 | className, 29 | }: { 30 | children: React.ReactNode 31 | className?: string 32 | }) { 33 | return
{children}
34 | } 35 | -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export const bundleLoadEvent = new Event('bundle load') 2 | 3 | export const imagesLoadEvent = new Event('images load') 4 | 5 | export const sfxLoadEvent = new Event('sfx load') 6 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Merriweather+Sans:ital,wght@0,300..800;1,300..800&family=Suez+One&display=swap'); 2 | @import './reset.css'; 3 | 4 | :root { 5 | font-size: 14px; 6 | 7 | /** Spacing tokens */ 8 | --spacing-50: 0.25rem; 9 | --spacing-100: 0.5rem; 10 | --spacing-200: 0.75rem; 11 | --spacing-300: 1rem; 12 | --spacing-400: 1.5rem; 13 | --spacing-500: 2.5rem; 14 | 15 | /** Sizing tokens */ 16 | --size-100: 1rem; 17 | --size-150: 1.5rem; 18 | --size-200: 2rem; 19 | --size-300: 4rem; 20 | --size-400: 8rem; 21 | --size-500: 12rem; 22 | --size-600: 18rem; 23 | --size-700: 22rem; 24 | 25 | /** Font size tokens */ 26 | --font-size-100: 0.64rem; 27 | --font-size-200: 0.8rem; 28 | --font-size-300: 1rem; 29 | --font-size-400: 1.25rem; 30 | --font-size-500: 1.563rem; 31 | --font-size-600: 1.953rem; 32 | --font-size-700: 2.441rem; 33 | --font-size-800: 3.052rem; 34 | --font-size-900: 3.815rem; 35 | 36 | /** Color tokens */ 37 | /** Color tokens */ 38 | /* Base colors */ 39 | --color-primary: #8b0000; /* Deep red */ 40 | --color-secondary: #4b0082; /* Indigo */ 41 | --color-accent: #ffd700; /* Gold */ 42 | 43 | /* Neutrals */ 44 | --color-stone-50: #fafaf9; 45 | --color-stone-100: #f5f5f4; 46 | --color-stone-200: #e7e5e4; 47 | --color-stone-300: #d6d3d1; 48 | --color-stone-400: #a8a29e; 49 | --color-stone-500: #78716c; 50 | --color-stone-600: #57534e; 51 | --color-stone-700: #44403c; 52 | --color-stone-800: #292524; 53 | --color-stone-900: #1c1917; 54 | --color-stone-950: #0c0a09; 55 | 56 | /* Theme-specific colors */ 57 | --color-blood-100: #ffebee; 58 | --color-blood-300: #f06292; 59 | --color-blood-500: #b71c1c; 60 | --color-blood-900: #4a0000; 61 | 62 | --color-shadow-100: #eceff1; 63 | --color-shadow-500: #263238; 64 | --color-shadow-900: #000a12; 65 | 66 | --color-magic-100: #e8eaf6; 67 | --color-magic-500: #3f51b5; 68 | --color-magic-900: #1a237e; 69 | 70 | --color-nature-100: #e8f5e9; 71 | --color-nature-500: #2e7d32; 72 | --color-nature-900: #1b5e20; 73 | 74 | /* Status colors */ 75 | --color-danger: #ff3d00; 76 | --color-warning: #ffa000; 77 | --color-success: #00c853; 78 | 79 | /** Border tokens */ 80 | --border-width-0: 0; 81 | --border-width-100: 1px; 82 | --border-width-200: 2px; 83 | --border-width-300: 3px; 84 | --border-width-400: 4px; 85 | --border-width-500: 8px; 86 | 87 | /** Border-radius tokens */ 88 | --rounding-0: 0; 89 | --rounding-50: 0.25rem; 90 | --rounding-100: 0.5rem; 91 | --rounding-200: 1rem; 92 | --rounding-300: 2rem; 93 | --rounding-400: 4rem; 94 | --rounding-500: 8rem; 95 | --rounding-1000: 100%; 96 | 97 | /** Animation duration */ 98 | --anim-dur-100: 0.1s; 99 | --anim-dur-200: 0.2s; 100 | --anim-dur-300: 0.3s; 101 | --anim-dur-400: 0.6s; 102 | 103 | /** Animation easing */ 104 | --anim-easing-100: ease-in-out; 105 | 106 | /** Shared component tokens */ 107 | --card-width: 11.25rem; 108 | --card-height: 17.5rem; 109 | 110 | /** Font family tokens */ 111 | --font-primary: 'Merriweather Sans', sans-serif; 112 | --font-secondary: 'Suez One', serif; 113 | 114 | /** Z-index tokens */ 115 | --z-index-0: 0; 116 | --z-index-100: 10; 117 | --z-index-200: 20; 118 | --z-index-300: 30; 119 | --z-index-400: 40; 120 | --z-index-500: 50; 121 | --z-index-600: 60; 122 | --z-index-700: 70; 123 | --z-index-800: 80; 124 | --z-index-900: 90; 125 | --z-index-1000: 100; 126 | 127 | /** Box shadow tokens */ 128 | --shadow-100: 0 var(--border-width-100) var(--border-width-300) var(--color-shadow-100); 129 | --shadow-500: 0 var(--border-width-100) var(--border-width-300) var(--color-shadow-500); 130 | --shadow-900: 0 var(--border-width-100) var(--border-width-300) var(--color-shadow-900); 131 | 132 | /** Clip path tokens */ 133 | --clip-path-banner: polygon(0% 0%, 100% 0%, 97% 50%, 100% 100%, 0% 100%, 3% 50%); 134 | } 135 | 136 | * { 137 | box-sizing: border-box; 138 | } 139 | 140 | [hidden] { 141 | display: none !important; 142 | } 143 | 144 | html { 145 | font-family: var(--font-primary); 146 | } 147 | 148 | body, 149 | button, 150 | .dagger-cursor-override { 151 | cursor: url('./images/dagger.png'), auto; 152 | } 153 | -------------------------------------------------------------------------------- /src/helpers/array.ts: -------------------------------------------------------------------------------- 1 | /** Determines whether an array is empty */ 2 | export function isEmpty(arr: Array | null | undefined) { 3 | if (!arr) return true 4 | 5 | if (arr.length === 0) return true 6 | 7 | return false 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/cards.ts: -------------------------------------------------------------------------------- 1 | import type { Card } from '../types/cards' 2 | import { getSound } from './get-sound' 3 | 4 | /** Defines a card config. */ 5 | export function defineCard(config: Omit) { 6 | return config 7 | } 8 | 9 | /** Returns a card from a deck by its id */ 10 | export function getCard(id: string, deck: Array) { 11 | return [...deck].find((card) => card.id === id) as Card 12 | } 13 | 14 | const CARD_CONFIG_MODULES = import.meta.glob('../cards/**/config.ts', { 15 | eager: true, 16 | import: 'default', 17 | }) 18 | 19 | const CARD_SFX_MODULES = import.meta.glob('../cards/**/*.wav', { eager: true, import: 'default' }) 20 | 21 | const CARD_ARTWORK_MODULES = import.meta.glob('../cards/**/*.png', { 22 | eager: true, 23 | import: 'default', 24 | }) 25 | 26 | /** Array of available cards derived from `src/cards` file contents */ 27 | export const CARDS = Object.entries(CARD_CONFIG_MODULES).map(([path, mod]) => { 28 | const dir = path.replace('/config.ts', '') 29 | const id = dir.replace('../cards/', '') 30 | 31 | return { 32 | ...(mod as Card), 33 | id, 34 | artwork: CARD_ARTWORK_MODULES[`${dir}/artwork.png`], 35 | sfx: getSound({ src: CARD_SFX_MODULES[`${dir}/sfx.wav`] as string }), 36 | } 37 | }) as Array 38 | 39 | /** Default starting deck - TODO: Build starting decks per character class */ 40 | export const STARTING_DECK = [ 41 | getCard('earthquake', CARDS), 42 | getCard('strike', CARDS), 43 | getCard('strike', CARDS), 44 | getCard('strike', CARDS), 45 | getCard('firebolt', CARDS), 46 | getCard('firebolt', CARDS), 47 | ].filter(Boolean) as Array 48 | -------------------------------------------------------------------------------- /src/helpers/character-classes.ts: -------------------------------------------------------------------------------- 1 | import type { CharacterClass } from '../types/character-classes' 2 | 3 | /** Definition helper for character classes */ 4 | export function defineCharacterClass(config: CharacterClass) { 5 | return config 6 | } 7 | 8 | /** Returns a character class from an array of character classes by its id */ 9 | export function getCharacterClass(id: string, characterClasses: Array) { 10 | return [...characterClasses].find((characterClass) => characterClass.id === id) as CharacterClass 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/get-sound.ts: -------------------------------------------------------------------------------- 1 | import { Howl, type HowlCallback } from 'howler' 2 | 3 | /** Wrapper function to get sound for use in the application */ 4 | export function getSound(options: { src: string; volume?: number; onload?: HowlCallback }) { 5 | return new Howl({ 6 | preload: true, 7 | autoplay: false, 8 | src: [options.src], 9 | volume: options.volume, 10 | onload: options.onload, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/item.ts: -------------------------------------------------------------------------------- 1 | import type { Item } from '../types/items' 2 | import { getSound } from './get-sound' 3 | 4 | /** Defines an item config */ 5 | export function defineItem(config: Omit) { 6 | return config 7 | } 8 | 9 | /** Returns an item from an array by its id */ 10 | export function getItem(id: string, items: Array) { 11 | return [...items].find((item) => item.id === id) as Item 12 | } 13 | 14 | /** Starting volume for monster sound effects */ 15 | const ITEM_SFX_VOLUME = 0.75 16 | 17 | /** Retrieves monster sound with build in defaults */ 18 | const getItemSound = (sfx: string) => getSound({ src: sfx, volume: ITEM_SFX_VOLUME }) 19 | 20 | const ITEM_CONFIG_MODULES = import.meta.glob('../items/**/config.ts', { 21 | eager: true, 22 | import: 'default', 23 | }) 24 | 25 | const ITEM_SFX_MODULES = import.meta.glob('../items/**/*.wav', { 26 | eager: true, 27 | import: 'default', 28 | }) 29 | 30 | const ITEM_ARTWORK = import.meta.glob('../items/**/*.png', { 31 | eager: true, 32 | import: 'default', 33 | }) 34 | 35 | /** Array of available monsters derived from `src/monsters` file contents */ 36 | export const getAllItems = () => 37 | Object.entries(ITEM_CONFIG_MODULES).map(([path, mod]) => { 38 | const dir = path.replace('/config.ts', '') 39 | const id = dir.replace('../items/', '') 40 | 41 | return { 42 | ...(mod as Item), 43 | id, 44 | artwork: ITEM_ARTWORK[`${dir}/artwork.png`], 45 | sfx: { 46 | obtain: getItemSound(ITEM_SFX_MODULES[`${dir}/sfx.obtain.wav`] as string), 47 | use: getItemSound(ITEM_SFX_MODULES[`${dir}/sfx.use.wav`] as string), 48 | effect: getItemSound(ITEM_SFX_MODULES[`${dir}/sfx.effect.wav`] as string), 49 | }, 50 | } 51 | }) as Array 52 | -------------------------------------------------------------------------------- /src/helpers/monsters.ts: -------------------------------------------------------------------------------- 1 | import type { Monster } from '../types/monsters' 2 | import { getSound } from './get-sound' 3 | 4 | /** Helper function to define and configure a monster */ 5 | export function defineMonster(config: Omit) { 6 | return config 7 | } 8 | 9 | /** Starting volume for monster sound effects */ 10 | const MONSTER_SFX_VOLUME = 0.55 11 | 12 | /** Retrieves monster sound with build in defaults */ 13 | const getMonsterSound = (sfx: string) => getSound({ src: sfx, volume: MONSTER_SFX_VOLUME }) 14 | 15 | const MONSTER_CONFIG_MODULES = import.meta.glob('../monsters/**/config.ts', { 16 | eager: true, 17 | import: 'default', 18 | }) 19 | 20 | const MONSTER_SFX_MODULES = import.meta.glob('../monsters/**/*.wav', { 21 | eager: true, 22 | import: 'default', 23 | }) 24 | 25 | const MONSTER_ARTWORK = import.meta.glob('../monsters/**/*.png', { 26 | eager: true, 27 | import: 'default', 28 | }) 29 | 30 | /** Array of available monsters derived from `src/monsters` file contents */ 31 | export const getAllMonsters = () => 32 | Object.entries(MONSTER_CONFIG_MODULES).map(([path, mod]) => { 33 | const dir = path.replace('/config.ts', '') 34 | const id = dir.replace('../monsters/', '') 35 | 36 | return { 37 | ...(mod as Monster), 38 | id, 39 | artwork: MONSTER_ARTWORK[`${dir}/artwork.png`], 40 | sfx: { 41 | intro: getMonsterSound(MONSTER_SFX_MODULES[`${dir}/sfx.intro.wav`] as string), 42 | damage: getMonsterSound(MONSTER_SFX_MODULES[`${dir}/sfx.damage.wav`] as string), 43 | death: getMonsterSound(MONSTER_SFX_MODULES[`${dir}/sfx.death.wav`] as string), 44 | }, 45 | } 46 | }) as Array 47 | -------------------------------------------------------------------------------- /src/helpers/rng.ts: -------------------------------------------------------------------------------- 1 | export function rng(max: number) { 2 | return Math.floor(Math.random() * Math.floor(max)) 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/shuffle.ts: -------------------------------------------------------------------------------- 1 | import { Deck } from '../types' 2 | 3 | export function shuffle(cards: Deck) { 4 | for (let i = cards.length - 1; i > 0; i--) { 5 | const j = Math.floor(Math.random() * (i + 1)) 6 | ;[cards[i], cards[j]] = [cards[j], cards[i]] 7 | } 8 | return cards 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/string.ts: -------------------------------------------------------------------------------- 1 | /** Converts a string to title case */ 2 | export function toTitleCase(str: string): string { 3 | return str.replace(/\w\S*/g, function (txt) { 4 | return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/vite.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to return eagerly resolved modules. 3 | * @param modules - The return value of import.meta.glob with eager loading enabled. 4 | * @returns An object containing the resolved modules. 5 | */ 6 | export function resolveModules(modules: Record) { 7 | const result: Array = [] 8 | 9 | // Loop through each module entry and assign the default export to the result. 10 | for (const [_path, module] of Object.entries(modules)) { 11 | result.push(module.default || module) // Handle cases where there's no default export 12 | } 13 | 14 | return result as Array 15 | } 16 | -------------------------------------------------------------------------------- /src/images/backgrounds/dark-dungeon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/backgrounds/dark-dungeon.png -------------------------------------------------------------------------------- /src/images/backgrounds/dark-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/backgrounds/dark-library.png -------------------------------------------------------------------------------- /src/images/bag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/bag.png -------------------------------------------------------------------------------- /src/images/card-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/card-back.png -------------------------------------------------------------------------------- /src/images/chest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/chest.png -------------------------------------------------------------------------------- /src/images/close-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/close-up.png -------------------------------------------------------------------------------- /src/images/dagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/dagger.png -------------------------------------------------------------------------------- /src/images/gold-coins-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/gold-coins-transparent.png -------------------------------------------------------------------------------- /src/images/gold-coins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/gold-coins.png -------------------------------------------------------------------------------- /src/images/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/heart.png -------------------------------------------------------------------------------- /src/images/knuckles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/knuckles.png -------------------------------------------------------------------------------- /src/images/large-potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/large-potion.png -------------------------------------------------------------------------------- /src/images/player-portraits/artist-warrior-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/artist-warrior-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/berzerker-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/berzerker-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/cat-artist.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/cat-artist.webp -------------------------------------------------------------------------------- /src/images/player-portraits/fallen-king.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/fallen-king.png -------------------------------------------------------------------------------- /src/images/player-portraits/fashion-designer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/fashion-designer.webp -------------------------------------------------------------------------------- /src/images/player-portraits/giant-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/giant-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/giant-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/giant-2.png -------------------------------------------------------------------------------- /src/images/player-portraits/golden-warrior.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/golden-warrior.png -------------------------------------------------------------------------------- /src/images/player-portraits/mage-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/mage-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/marshy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/marshy.png -------------------------------------------------------------------------------- /src/images/player-portraits/rainbow-cat-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/rainbow-cat-1.webp -------------------------------------------------------------------------------- /src/images/player-portraits/rainbow-mage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/rainbow-mage.png -------------------------------------------------------------------------------- /src/images/player-portraits/rainbow-samurai.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/rainbow-samurai.webp -------------------------------------------------------------------------------- /src/images/player-portraits/rainbow-shooting-star-warrior.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/rainbow-shooting-star-warrior.webp -------------------------------------------------------------------------------- /src/images/player-portraits/rainbow-warrior-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/rainbow-warrior-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/samurai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/samurai.png -------------------------------------------------------------------------------- /src/images/player-portraits/warrior-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/warrior-1.png -------------------------------------------------------------------------------- /src/images/player-portraits/warrior-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/player-portraits/warrior-2.png -------------------------------------------------------------------------------- /src/images/royal-dagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/royal-dagger.png -------------------------------------------------------------------------------- /src/images/small-potion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/small-potion.png -------------------------------------------------------------------------------- /src/images/spell-book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/spell-book.png -------------------------------------------------------------------------------- /src/images/sword.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/sword.png -------------------------------------------------------------------------------- /src/images/wooden-shield.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/images/wooden-shield.png -------------------------------------------------------------------------------- /src/items/large-potion/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/large-potion/artwork.png -------------------------------------------------------------------------------- /src/items/large-potion/config.ts: -------------------------------------------------------------------------------- 1 | import { defineItem } from '../../helpers/item' 2 | 3 | export default defineItem({ 4 | name: 'Large Potion', 5 | type: 'healing', 6 | value: 25, 7 | cost: 50, 8 | }) 9 | -------------------------------------------------------------------------------- /src/items/large-potion/sfx.effect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/large-potion/sfx.effect.wav -------------------------------------------------------------------------------- /src/items/large-potion/sfx.obtain.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/large-potion/sfx.obtain.wav -------------------------------------------------------------------------------- /src/items/large-potion/sfx.use.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/large-potion/sfx.use.wav -------------------------------------------------------------------------------- /src/items/small-potion/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/small-potion/artwork.png -------------------------------------------------------------------------------- /src/items/small-potion/config.ts: -------------------------------------------------------------------------------- 1 | import { defineItem } from '../../helpers/item' 2 | 3 | export default defineItem({ 4 | name: 'Small Potion', 5 | type: 'healing', 6 | value: 10, 7 | cost: 30, 8 | }) 9 | -------------------------------------------------------------------------------- /src/items/small-potion/sfx.effect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/small-potion/sfx.effect.wav -------------------------------------------------------------------------------- /src/items/small-potion/sfx.obtain.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/small-potion/sfx.obtain.wav -------------------------------------------------------------------------------- /src/items/small-potion/sfx.use.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/items/small-potion/sfx.use.wav -------------------------------------------------------------------------------- /src/machines/app-machine/app-machine.ts: -------------------------------------------------------------------------------- 1 | import { assign, fromPromise, setup } from 'xstate' 2 | import arrayShuffle from 'array-shuffle' 3 | import impactSfx from '../../sfx/impact.slice.wav' 4 | import cardUseSfx from '../../sfx/card.use.wav' 5 | import buttonClickSfx from '../../sfx/button.click.wav' 6 | import doorOpenSfx from '../../sfx/door.open.wav' 7 | import coinsSfx from '../../sfx/coins.wav' 8 | import cashRegisterSfx from '../../sfx/cash-register.wav' 9 | import { resolveModules } from '../../helpers/vite.ts' 10 | import { rng } from '../../helpers/rng.ts' 11 | import { getSound } from '../../helpers/get-sound.ts' 12 | import { getCharacterClass } from '../../helpers/character-classes.ts' 13 | import { getAllMonsters } from '../../helpers/monsters.ts' 14 | import { CARDS, STARTING_DECK } from '../../helpers/cards.ts' 15 | import { getAllItems } from '../../helpers/item.ts' 16 | import type { CharacterClass } from '../../types/character-classes.ts' 17 | import type { Monster } from '../../types/monsters.ts' 18 | import type { Card } from '../../types/cards.ts' 19 | import type { Item } from '../../types/items.ts' 20 | import type { AvatarStatus } from '../../components/avatar.tsx' 21 | 22 | /** Unique ID for the application machine */ 23 | const APP_MACHINE_ID = 'app' 24 | 25 | /** THe maximum number of allowed cards in the `currentHand` */ 26 | const MAX_HAND_SIZE = 5 27 | 28 | /** The price (in gold) of destroying a card */ 29 | const CARD_DESTRUCTION_PRICE = 100 30 | 31 | /** All image files in the project */ 32 | const IMAGE_MODULES = import.meta.glob('../**/**/*.(png|webp)', { eager: true }) 33 | 34 | /** All sound effect files in the project */ 35 | const SFX_MODULES = import.meta.glob('../**/**/*.wav', { eager: true }) 36 | 37 | /** All character class config files in the project */ 38 | const CHARACTER_CLASS_MODULES = import.meta.glob('../../character-classes/**/config.ts', { 39 | eager: true, 40 | }) 41 | 42 | /* Resolved character class configs */ 43 | const CHARACTER_CLASSES = resolveModules(CHARACTER_CLASS_MODULES) 44 | 45 | const impactSound = getSound({ src: impactSfx }) 46 | 47 | // TODO: We really need to keep these somewhere else 48 | export const cardUseSound = getSound({ src: cardUseSfx, volume: 0.33 }) 49 | 50 | const buttonClickSound = getSound({ src: buttonClickSfx, volume: 0.5 }) 51 | 52 | const doorOpenSound = getSound({ src: doorOpenSfx, volume: 0.5 }) 53 | 54 | const cashRegisterSound = getSound({ src: cashRegisterSfx, volume: 0.5 }) 55 | 56 | const coinsSound = getSound({ src: coinsSfx, volume: 0.7 }) 57 | 58 | /** Prefetches assets from multiple sources returned by `import.meta.glob` */ 59 | async function prefetchAssets() { 60 | return Promise.all([ 61 | ...Object.values(IMAGE_MODULES).map((module: any) => { 62 | return new Promise((resolve) => { 63 | const img = new Image() 64 | img.src = module.default 65 | img.onload = () => resolve(null) 66 | }) 67 | }), 68 | ...Object.values(SFX_MODULES).map((module: any) => { 69 | return new Promise((resolve) => { 70 | const audio = new Audio() 71 | audio.src = module.default 72 | audio.oncanplaythrough = () => resolve(null) 73 | }) 74 | }), 75 | ]) 76 | } 77 | 78 | /** Context for the app-machine */ 79 | export type AppMachineContext = { 80 | assets: { 81 | characterClasses: Array 82 | cards: Array 83 | } 84 | game: { 85 | player: { 86 | characterClass: CharacterClass | undefined 87 | characterClassDeck: Array 88 | characterName: string | undefined 89 | characterPortrait: string | undefined 90 | deck: Array 91 | status: AvatarStatus 92 | gold: number 93 | inventory: Array 94 | stats: { 95 | maxHealth: number 96 | health: number 97 | defense: number 98 | } 99 | } 100 | shop: { 101 | cards: Array 102 | items: Array 103 | } 104 | monsters: Array 105 | items: Array 106 | currentHand: Array 107 | discardPile: Array 108 | drawPile: Array 109 | cardDestructionPrice: number 110 | itemInPlay?: Item 111 | cardInPlay?: Card 112 | cardToDestroy?: Card 113 | monster?: Monster 114 | lastDefeatedMonster?: Monster 115 | } 116 | } 117 | 118 | type AppMachineEvent = 119 | | { 120 | type: 'CREATE_CHARACTER' 121 | data: { characterClass: string; characterName: string; characterPortrait: string } 122 | } 123 | | { 124 | type: 'PLAY_CARD' 125 | data: { card: Card } 126 | } 127 | | { 128 | type: 'PLAY_CARD_ANIMATION_COMPLETE' 129 | data: { card: Card } 130 | } 131 | | { 132 | type: 'USING_ITEM_ANIMATION_COMPLETE' 133 | data: { item: Item } 134 | } 135 | | { type: 'CARD_EFFECTS_ANIMATION_COMPLETE' } 136 | | { type: 'ITEM_EFFECTS_ANIMATION_COMPLETE' } 137 | | { type: 'CARD_DESTRUCTION_ANIMATION_COMPLETE'; data: { card: Card } } 138 | | { type: 'PLAY_CARD_ANIMATION_COMPLETE' } 139 | | { type: 'DISCARD_CARD_ANIMATION_COMPLETE' } 140 | | { type: 'MONSTER_DEATH_ANIMATION_COMPLETE' } 141 | | { type: 'MONSTER_ATTACK_ANIMATION_COMPLETE' } 142 | | { type: 'NEXT_BATTLE_CLICK' } 143 | | { type: 'ITEM_SHOP_CLICK' } 144 | | { type: 'DESTROY_CARDS_CLICK' } 145 | | { type: 'LEAVE_SHOP_CLICK' } 146 | | { type: 'LEAVE_DESTROYING_CARDS_CLICK' } 147 | | { type: 'ITEM_SHOP_CARD_CLICK'; data: { card: Card } } 148 | | { type: 'DESTRUCTION_SHOP_CARD_CLICK'; data: { card: Card } } 149 | | { type: 'ITEM_SHOP_ITEM_CLICK'; data: { item: Item } } 150 | | { type: 'INVENTORY_ITEM_CLICK'; data: { item: Item } } 151 | 152 | export const appMachine = setup({ 153 | types: { 154 | context: {} as AppMachineContext, 155 | events: {} as AppMachineEvent, 156 | }, 157 | actions: { 158 | applyCardEffects: assign({ 159 | game: ({ context }) => { 160 | context.game?.monster?.sfx?.damage.play() 161 | impactSound.play() 162 | 163 | if (!context.game.monster || !context.game.cardInPlay) { 164 | return context.game 165 | } 166 | 167 | return { 168 | ...context.game, 169 | monster: { 170 | ...context.game.monster, 171 | status: 'taking-damage' as const, 172 | stats: { 173 | ...context.game.monster.stats, 174 | health: context.game.monster?.stats.health - context.game.cardInPlay?.stats.attack, 175 | }, 176 | }, 177 | } 178 | }, 179 | }), 180 | applyItemEffects: assign({ 181 | game: ({ context }) => { 182 | if (!context.game.itemInPlay) return context.game 183 | 184 | context.game.itemInPlay.sfx.effect.play() 185 | 186 | let nextHealth = context.game.player.stats.health + context.game.itemInPlay.value 187 | 188 | if (nextHealth > context.game.player.stats.maxHealth) { 189 | nextHealth = context.game.player.stats.maxHealth 190 | } 191 | 192 | return { 193 | ...context.game, 194 | player: { 195 | ...context.game.player, 196 | stats: { 197 | ...context.game.player.stats, 198 | health: nextHealth, 199 | }, 200 | status: 'healing' as const, 201 | }, 202 | } 203 | }, 204 | }), 205 | createDrawPile: assign({ 206 | game: ({ context }) => { 207 | const newDrawPile = arrayShuffle(context.game.player.deck).map((card) => { 208 | return { 209 | ...card, 210 | orientation: 'face-down' as const, 211 | } 212 | }) 213 | 214 | return { 215 | ...context.game, 216 | discardPile: [], 217 | drawPile: newDrawPile, 218 | } 219 | }, 220 | }), 221 | stockShop: assign({ 222 | game: ({ context }) => { 223 | if (!context.game.player.characterClass) return context.game 224 | 225 | const classDeck = arrayShuffle(context.game.player.characterClassDeck) 226 | const rngMax = classDeck.length - 1 227 | const cardsOnOffer = [ 228 | classDeck[rng(rngMax)], 229 | classDeck[rng(rngMax)], 230 | classDeck[rng(rngMax)], 231 | ].map((card) => { 232 | return { 233 | ...card, 234 | id: `${card.id}-${crypto.randomUUID()}`, 235 | } 236 | }) 237 | 238 | return { 239 | ...context.game, 240 | shop: { 241 | cards: cardsOnOffer, 242 | items: [], 243 | }, 244 | } 245 | }, 246 | }), 247 | disableUnaffordableItems: assign({ 248 | game: ({ context }) => context.game, 249 | }), 250 | getNextMonster: assign({ 251 | game: ({ context }) => { 252 | const shuffledMonsters = arrayShuffle(context.game.monsters) 253 | const nextMonster = shuffledMonsters[rng(shuffledMonsters.length)] 254 | 255 | nextMonster.sfx?.intro.play() 256 | 257 | return { 258 | ...context.game, 259 | monster: { 260 | ...nextMonster, 261 | id: `${nextMonster.id}-${crypto.randomUUID()}`, 262 | stats: { 263 | ...nextMonster.stats, 264 | health: nextMonster.stats.maxHealth, 265 | }, 266 | status: 'idle' as const, 267 | }, 268 | } 269 | }, 270 | }), 271 | reshuffle: assign({ 272 | game: ({ context }) => { 273 | const nextDrawPile = arrayShuffle([ 274 | ...context.game.currentHand, 275 | ...context.game.discardPile, 276 | ]) 277 | 278 | return { 279 | ...context.game, 280 | currentHand: [], 281 | discardPile: [], 282 | drawPile: nextDrawPile, 283 | } 284 | }, 285 | }), 286 | /** Draws a hand of cards from the draw pile */ 287 | drawHand: assign({ 288 | game: ({ context }) => { 289 | const drawPile = context.game.drawPile 290 | const currentHandSize = context.game.currentHand.length 291 | 292 | // Draw up to 3 cards, but stop if we hit the max hand size 293 | const cardsToDrawCount = Math.min(3, MAX_HAND_SIZE - currentHandSize) 294 | const drawnCards = drawPile.slice(0, cardsToDrawCount) 295 | const remainingCards = drawPile.slice(cardsToDrawCount) 296 | 297 | for (const _card of drawnCards) { 298 | cardUseSound.play() 299 | } 300 | 301 | return { 302 | ...context.game, 303 | currentHand: [ 304 | ...context.game.currentHand, 305 | ...drawnCards.map((card) => { 306 | return { 307 | ...card, 308 | orientation: 'face-up' as const, 309 | } 310 | }), 311 | ], 312 | drawPile: remainingCards, 313 | } 314 | }, 315 | }), 316 | /** Discards the player's current hand */ 317 | discardCurrentHand: assign({ 318 | game: ({ context }) => { 319 | return { 320 | ...context.game, 321 | currentHand: [], 322 | } 323 | }, 324 | }), 325 | /** The monster attacks the player */ 326 | monsterAttack: assign({ 327 | game: ({ context }) => { 328 | if (!context.game.monster) return context.game 329 | 330 | const rawDamage = context.game.monster.stats.attack - context.game.player.stats.defense 331 | const damage = rawDamage > 0 ? rawDamage : 0 332 | impactSound.play() 333 | 334 | return { 335 | ...context.game, 336 | player: { 337 | ...context.game.player, 338 | status: 'taking-damage' as const, 339 | stats: { 340 | ...context.game.player.stats, 341 | health: context.game.player.stats.health - damage, 342 | }, 343 | }, 344 | } 345 | }, 346 | }), 347 | /** Awards the player with the spoils of war */ 348 | awardSpoils: assign({ 349 | game: ({ context }) => { 350 | const lastDefeatedMonster = context.game.lastDefeatedMonster 351 | 352 | if (!lastDefeatedMonster) return context.game 353 | 354 | coinsSound.play() 355 | 356 | return { 357 | ...context.game, 358 | player: { 359 | ...context.game.player, 360 | gold: context.game.player.gold + lastDefeatedMonster.goldBounty, 361 | }, 362 | } 363 | }, 364 | }), 365 | }, 366 | actors: { 367 | loadAllAssets: fromPromise(prefetchAssets), 368 | }, 369 | guards: { 370 | playerIsAlive: ({ context }) => { 371 | return context.game.player.stats.health > 0 372 | }, 373 | playerIsDead: ({ context }) => { 374 | return context.game.player.stats.health <= 0 375 | }, 376 | monsterIsAlive: ({ context }) => { 377 | if (!context.game.monster) return false 378 | 379 | return context.game.monster.stats.health > 0 380 | }, 381 | monsterIsDead: ({ context }) => { 382 | if (!context.game.monster) return false 383 | 384 | return context.game.monster.stats.health <= 0 385 | }, 386 | playerCanDraw: ({ context }) => { 387 | return context.game.drawPile.length > 0 && context.game.currentHand.length < MAX_HAND_SIZE 388 | }, 389 | playerCannotDraw: ({ context }) => { 390 | return context.game.drawPile.length === 0 && context.game.currentHand.length < MAX_HAND_SIZE 391 | }, 392 | drawingNotNeeded: ({ context }) => { 393 | return context.game.drawPile.length === 0 && context.game.currentHand.length === MAX_HAND_SIZE 394 | }, 395 | }, 396 | }).createMachine({ 397 | id: APP_MACHINE_ID, 398 | initial: 'LoadingAssets', 399 | context: { 400 | assets: { 401 | characterClasses: CHARACTER_CLASSES, 402 | cards: CARDS, 403 | }, 404 | game: { 405 | player: { 406 | characterClass: undefined, 407 | characterClassDeck: [], 408 | characterName: undefined, 409 | characterPortrait: undefined, 410 | deck: STARTING_DECK.map((card) => { 411 | return { 412 | ...card, 413 | // Create a unique identifier for every card in the game 414 | id: `${card.id}-${crypto.randomUUID()}`, 415 | } 416 | }), 417 | gold: 125, 418 | status: 'idle' as const, 419 | stats: { 420 | // TODO: Update with character class stats 421 | maxHealth: 100, 422 | health: 100, 423 | defense: 0, 424 | }, 425 | inventory: [], 426 | }, 427 | monsters: [], 428 | items: [], 429 | shop: { 430 | cards: [], 431 | items: [], 432 | }, 433 | cardDestructionPrice: CARD_DESTRUCTION_PRICE, 434 | currentHand: [], 435 | discardPile: [], 436 | drawPile: [], 437 | }, 438 | }, 439 | states: { 440 | LoadingAssets: { 441 | invoke: { 442 | src: 'loadAllAssets', 443 | onDone: 'CharacterCreation', 444 | onError: 'LoadingAssetsError', 445 | }, 446 | }, 447 | LoadingAssetsError: {}, 448 | CharacterCreation: { 449 | on: { 450 | CREATE_CHARACTER: { 451 | target: 'NewRound', 452 | actions: assign({ 453 | game: (args) => { 454 | const { context, event } = args 455 | const characterClass = getCharacterClass(event.data.characterClass, CHARACTER_CLASSES) 456 | 457 | if (!characterClass) return context.game 458 | 459 | return { 460 | ...context.game, 461 | items: getAllItems(), 462 | monsters: getAllMonsters(), 463 | player: { 464 | ...context.game.player, 465 | characterClass: characterClass, 466 | characterName: event.data.characterName, 467 | characterPortrait: event.data.characterPortrait, 468 | characterClassDeck: characterClass.deck, 469 | }, 470 | } 471 | }, 472 | }), 473 | }, 474 | }, 475 | }, 476 | NewRound: { 477 | entry: ['discardCurrentHand', 'getNextMonster', 'createDrawPile'], 478 | always: ['Surveying'], 479 | }, 480 | Surveying: { 481 | // Resets player status to idle 482 | entry: assign({ 483 | game: ({ context }) => ({ 484 | ...context.game, 485 | player: { ...context.game.player, status: 'idle' as const }, 486 | }), 487 | }), 488 | always: [ 489 | { 490 | target: 'PlayerChoosing', 491 | guard: 'drawingNotNeeded', 492 | }, 493 | { 494 | target: 'Drawing', 495 | guard: 'playerCanDraw', 496 | }, 497 | { 498 | target: 'Reshuffling', 499 | guard: 'playerCannotDraw', 500 | }, 501 | ], 502 | }, 503 | Reshuffling: { 504 | entry: 'reshuffle', 505 | always: 'Drawing', 506 | }, 507 | Drawing: { 508 | entry: ['drawHand'], 509 | always: 'PlayerChoosing', 510 | }, 511 | PlayerChoosing: { 512 | on: { 513 | PLAY_CARD: { 514 | target: 'CardInPlay', 515 | actions: assign({ 516 | game: ({ context, event }) => { 517 | cardUseSound.play() 518 | 519 | // TODO: Need to have a slight delay here 520 | event.data.card?.sfx?.play() 521 | 522 | return { 523 | ...context.game, 524 | // Remove the selected card from the current hand 525 | currentHand: context.game.currentHand.filter( 526 | (card) => card.id !== event.data.card.id, 527 | ), 528 | cardInPlay: { 529 | ...event.data.card, 530 | orientation: 'face-up' as const, 531 | status: 'in-play' as const, 532 | }, 533 | } 534 | }, 535 | }), 536 | }, 537 | INVENTORY_ITEM_CLICK: { 538 | target: 'UsingItem', 539 | actions: assign({ 540 | game: ({ context, event }) => { 541 | const itemInPlay = event.data.item 542 | 543 | itemInPlay.sfx.use.play() 544 | 545 | return { 546 | ...context.game, 547 | itemInPlay, 548 | player: { 549 | ...context.game.player, 550 | inventory: context.game.player.inventory.filter((item) => { 551 | return item.id !== itemInPlay.id 552 | }), 553 | }, 554 | } 555 | }, 556 | }), 557 | }, 558 | }, 559 | entry: assign({ 560 | game: ({ context }) => { 561 | if (!context.game.monster) { 562 | return context.game 563 | } 564 | 565 | return { 566 | ...context.game, 567 | monster: { 568 | ...context.game.monster, 569 | status: 'idle' as const, 570 | }, 571 | } 572 | }, 573 | }), 574 | }, 575 | CardInPlay: { 576 | on: { 577 | PLAY_CARD_ANIMATION_COMPLETE: { 578 | target: 'ApplyingCardEffects', 579 | }, 580 | }, 581 | }, 582 | UsingItem: { 583 | on: { 584 | USING_ITEM_ANIMATION_COMPLETE: { 585 | target: 'ApplyingItemEffects', 586 | }, 587 | }, 588 | }, 589 | ApplyingCardEffects: { 590 | entry: 'applyCardEffects', 591 | on: { 592 | CARD_EFFECTS_ANIMATION_COMPLETE: { 593 | target: 'CardPlayed', 594 | }, 595 | }, 596 | }, 597 | ApplyingItemEffects: { 598 | entry: 'applyItemEffects', 599 | on: { 600 | ITEM_EFFECTS_ANIMATION_COMPLETE: { 601 | target: 'PlayerChoosing', 602 | }, 603 | }, 604 | }, 605 | CardPlayed: { 606 | entry: assign({ 607 | game: ({ context }) => { 608 | // Should be an impossible state, but need to keep TypeScript happy 609 | if (!context.game.monster) { 610 | return { 611 | ...context.game, 612 | cardInPlay: undefined, 613 | discardPile: [ 614 | ...context.game.discardPile, 615 | { 616 | ...(context.game?.cardInPlay as Card), 617 | status: 'idle', 618 | }, 619 | ], 620 | } 621 | } 622 | 623 | return { 624 | ...context.game, 625 | cardInPlay: undefined, 626 | monster: { 627 | ...context.game.monster, 628 | status: 'idle' as const, 629 | }, 630 | discardPile: [ 631 | ...context.game.discardPile, 632 | { 633 | ...(context.game?.cardInPlay as Card), 634 | status: 'idle', 635 | }, 636 | ], 637 | } 638 | }, 639 | }), 640 | always: [ 641 | { 642 | target: 'Victory', 643 | guard: 'monsterIsDead', 644 | actions: assign({ 645 | game: ({ context }) => { 646 | // Should be an impossible state, but need to keep TypeScript happy 647 | if (!context.game.monster) { 648 | return context.game 649 | } 650 | 651 | context.game.monster.sfx?.death.play() 652 | 653 | return { 654 | ...context.game, 655 | lastDefeatedMonster: context.game.monster, 656 | monster: undefined, 657 | } 658 | }, 659 | }), 660 | }, 661 | { 662 | target: 'Defending', 663 | guard: 'monsterIsAlive', 664 | }, 665 | ], 666 | }, 667 | Defending: { 668 | entry: 'monsterAttack', 669 | on: { 670 | MONSTER_ATTACK_ANIMATION_COMPLETE: [ 671 | { 672 | target: 'Surveying', 673 | guard: 'playerIsAlive', 674 | }, 675 | { 676 | target: 'Defeat', 677 | guard: 'playerIsDead', 678 | }, 679 | ], 680 | }, 681 | }, 682 | Victory: { 683 | entry: ['stockShop'], 684 | on: { 685 | MONSTER_DEATH_ANIMATION_COMPLETE: { 686 | actions: ['awardSpoils'], 687 | target: 'BetweenRounds', 688 | }, 689 | }, 690 | }, 691 | BetweenRounds: { 692 | on: { 693 | NEXT_BATTLE_CLICK: { 694 | target: 'NewRound', 695 | actions: () => buttonClickSound.play(), 696 | }, 697 | ITEM_SHOP_CLICK: { 698 | target: 'Shopping', 699 | actions: () => buttonClickSound.play(), 700 | }, 701 | DESTROY_CARDS_CLICK: { 702 | target: 'DestroyingCards', 703 | actions: () => buttonClickSound.play(), 704 | }, 705 | }, 706 | }, 707 | Shopping: { 708 | entry: ['disableUnaffordableItems', () => doorOpenSound.play()], 709 | on: { 710 | LEAVE_SHOP_CLICK: { 711 | target: 'BetweenRounds', 712 | actions: () => buttonClickSound.play(), 713 | }, 714 | ITEM_SHOP_CARD_CLICK: { 715 | target: 'Shopping', 716 | actions: assign({ 717 | game: ({ context, event }) => { 718 | buttonClickSound.play() 719 | cashRegisterSound.play() 720 | 721 | return { 722 | ...context.game, 723 | player: { 724 | ...context.game.player, 725 | deck: [...context.game.player.deck, event.data.card], 726 | gold: context.game.player.gold - event.data.card.price, 727 | }, 728 | } 729 | }, 730 | }), 731 | }, 732 | ITEM_SHOP_ITEM_CLICK: { 733 | target: 'Shopping', 734 | actions: assign({ 735 | game: ({ context, event }) => { 736 | buttonClickSound.play() 737 | cashRegisterSound.play() 738 | 739 | const item = event.data.item 740 | 741 | item.sfx.obtain.play() 742 | 743 | return { 744 | ...context.game, 745 | player: { 746 | ...context.game.player, 747 | inventory: [ 748 | ...context.game.player.inventory, 749 | // We need to add a unique identifier in the case of duplicate inventory items in player inventory 750 | { ...item, id: `${item.id}-${crypto.randomUUID()}` }, 751 | ], 752 | gold: context.game.player.gold - item.cost, 753 | }, 754 | } 755 | }, 756 | }), 757 | }, 758 | NEXT_BATTLE_CLICK: { 759 | target: 'NewRound', 760 | actions: () => buttonClickSound.play(), 761 | }, 762 | }, 763 | }, 764 | DestroyingCard: { 765 | entry: assign({ 766 | game: ({ context }) => { 767 | buttonClickSound.play() 768 | cashRegisterSound.play() 769 | 770 | const cardToDestroy = context.game.cardToDestroy 771 | 772 | if (!cardToDestroy) return context.game 773 | 774 | const nextDeck = [ 775 | ...context.game.player.deck.filter((card) => card.id !== cardToDestroy.id), 776 | ] 777 | 778 | return { 779 | ...context.game, 780 | player: { 781 | ...context.game.player, 782 | deck: nextDeck, 783 | gold: context.game.player.gold - context.game.cardDestructionPrice, 784 | }, 785 | } 786 | }, 787 | }), 788 | on: { 789 | CARD_DESTRUCTION_ANIMATION_COMPLETE: { 790 | target: 'DestroyingCards', 791 | }, 792 | }, 793 | }, 794 | DestroyingCards: { 795 | on: { 796 | DESTRUCTION_SHOP_CARD_CLICK: { 797 | target: 'DestroyingCard', 798 | actions: assign({ 799 | game: ({ context, event }) => { 800 | console.log('event.data.card', event.data.card) 801 | return { 802 | ...context.game, 803 | cardToDestroy: { 804 | ...event.data.card, 805 | orientation: 'face-up' as const, 806 | status: 'in-play' as const, 807 | }, 808 | } 809 | }, 810 | }), 811 | }, 812 | LEAVE_DESTROYING_CARDS_CLICK: { 813 | target: 'BetweenRounds', 814 | actions: () => buttonClickSound.play(), 815 | }, 816 | NEXT_BATTLE_CLICK: { 817 | target: 'NewRound', 818 | actions: () => buttonClickSound.play(), 819 | }, 820 | }, 821 | }, 822 | Defeat: {}, 823 | }, 824 | }) 825 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { inject } from '@vercel/analytics' 3 | import ReactDOM from 'react-dom/client' 4 | import { App } from './app.tsx' 5 | import './global.css' 6 | 7 | inject() 8 | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /src/monsters/ancient-giant/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ancient-giant/artwork.png -------------------------------------------------------------------------------- /src/monsters/ancient-giant/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Ancient Giant', 5 | level: 7, 6 | goldBounty: 10, 7 | stats: { 8 | maxHealth: 15, 9 | health: 15, 10 | attack: 4, 11 | defense: 5, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/ancient-giant/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ancient-giant/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/ancient-giant/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ancient-giant/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/ancient-giant/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ancient-giant/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/arachnid/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/arachnid/artwork.png -------------------------------------------------------------------------------- /src/monsters/arachnid/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Arachnid', 5 | level: 4, 6 | goldBounty: 5, 7 | stats: { 8 | maxHealth: 6, 9 | health: 6, 10 | attack: 4, 11 | defense: 2, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/arachnid/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/arachnid/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/arachnid/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/arachnid/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/arachnid/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/arachnid/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/bone-dragon/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/bone-dragon/artwork.png -------------------------------------------------------------------------------- /src/monsters/bone-dragon/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Bone Dragon', 5 | level: 8, 6 | goldBounty: 10, 7 | stats: { 8 | maxHealth: 20, 9 | health: 20, 10 | attack: 8, 11 | defense: 8, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/bone-dragon/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/bone-dragon/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/bone-dragon/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/bone-dragon/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/bone-dragon/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/bone-dragon/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/clown/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/clown/artwork.png -------------------------------------------------------------------------------- /src/monsters/clown/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Clown', 5 | level: 3, 6 | goldBounty: 10, 7 | stats: { 8 | maxHealth: 4, 9 | health: 4, 10 | attack: 8, 11 | defense: 4, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/creepy/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/creepy/artwork.png -------------------------------------------------------------------------------- /src/monsters/creepy/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Creepy', 5 | level: 5, 6 | goldBounty: 15, 7 | stats: { 8 | maxHealth: 6, 9 | health: 6, 10 | attack: 12, 11 | defense: 3, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/creepy/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/creepy/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/creepy/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/creepy/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/creepy/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/creepy/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/devil-mallow/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/devil-mallow/artwork.png -------------------------------------------------------------------------------- /src/monsters/evil-sorcerer/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/evil-sorcerer/artwork.png -------------------------------------------------------------------------------- /src/monsters/evil-sorcerer/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Evil Sorcerer', 5 | level: 5, 6 | goldBounty: 9, 7 | stats: { 8 | maxHealth: 8, 9 | health: 8, 10 | attack: 9, 11 | defense: 1, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/gargoyle/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/gargoyle/artwork.png -------------------------------------------------------------------------------- /src/monsters/gargoyle/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Gargoyle', 5 | level: 3, 6 | goldBounty: 2, 7 | stats: { 8 | maxHealth: 4, 9 | health: 4, 10 | attack: 2, 11 | defense: 3, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/gargoyle/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/gargoyle/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/gargoyle/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/gargoyle/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/gargoyle/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/gargoyle/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/ghoul/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ghoul/artwork.png -------------------------------------------------------------------------------- /src/monsters/ghoul/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Ghoul', 5 | level: 6, 6 | goldBounty: 9, 7 | stats: { 8 | maxHealth: 15, 9 | health: 15, 10 | attack: 6, 11 | defense: 2, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/ghoul/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ghoul/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/ghoul/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ghoul/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/ghoul/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ghoul/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/haunting-spirit/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/haunting-spirit/artwork.png -------------------------------------------------------------------------------- /src/monsters/haunting-spirit/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Haunting Spirit', 5 | level: 4, 6 | goldBounty: 5, 7 | stats: { 8 | maxHealth: 10, 9 | health: 10, 10 | attack: 2, 11 | defense: 0, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/haunting-spirit/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/haunting-spirit/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/haunting-spirit/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/haunting-spirit/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/haunting-spirit/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/haunting-spirit/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/hulking-giant/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/hulking-giant/artwork.png -------------------------------------------------------------------------------- /src/monsters/hulking-giant/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Hulking Giant', 5 | level: 7, 6 | goldBounty: 10, 7 | stats: { 8 | maxHealth: 15, 9 | health: 15, 10 | attack: 4, 11 | defense: 5, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/hulking-giant/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/hulking-giant/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/hulking-giant/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/hulking-giant/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/hulking-giant/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/hulking-giant/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/imp/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/imp/artwork.png -------------------------------------------------------------------------------- /src/monsters/imp/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Imp', 5 | level: 2, 6 | goldBounty: 1, 7 | stats: { 8 | maxHealth: 12, 9 | health: 12, 10 | attack: 4, 11 | defense: 2, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/imp/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/imp/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/imp/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/imp/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/imp/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/imp/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/orc/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/orc/artwork.png -------------------------------------------------------------------------------- /src/monsters/orc/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Orc', 5 | level: 3, 6 | goldBounty: 4, 7 | stats: { 8 | maxHealth: 8, 9 | health: 8, 10 | attack: 3, 11 | defense: 2, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/orc/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/orc/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/orc/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/orc/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/orc/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/orc/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/ronin/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ronin/artwork.png -------------------------------------------------------------------------------- /src/monsters/ronin/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Ronin', 5 | level: 3, 6 | goldBounty: 9, 7 | stats: { 8 | maxHealth: 9, 9 | health: 9, 10 | attack: 7, 11 | defense: 4, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/ronin/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ronin/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/ronin/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ronin/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/ronin/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/ronin/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/the-black-cat/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/the-black-cat/artwork.png -------------------------------------------------------------------------------- /src/monsters/the-black-cat/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'The Black Cat', 5 | level: 2, 6 | goldBounty: 4, 7 | stats: { 8 | maxHealth: 6, 9 | health: 6, 10 | attack: 3, 11 | defense: 3, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/the-council/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/the-council/artwork.png -------------------------------------------------------------------------------- /src/monsters/the-council/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'The Council', 5 | level: 8, 6 | goldBounty: 7, 7 | stats: { 8 | maxHealth: 15, 9 | health: 15, 10 | attack: 10, 11 | defense: 3, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/the-council/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/the-council/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/the-council/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/the-council/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/the-council/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/the-council/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/troll/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/troll/artwork.png -------------------------------------------------------------------------------- /src/monsters/troll/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Troll', 5 | level: 8, 6 | goldBounty: 9, 7 | stats: { 8 | maxHealth: 10, 9 | health: 10, 10 | attack: 7, 11 | defense: 7, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/troll/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/troll/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/troll/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/troll/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/troll/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/troll/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/watcher-from-the-deep/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/watcher-from-the-deep/artwork.png -------------------------------------------------------------------------------- /src/monsters/watcher-from-the-deep/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Watcher from the Deep', 5 | level: 10, 6 | goldBounty: 12, 7 | stats: { 8 | maxHealth: 12, 9 | health: 12, 10 | attack: 9, 11 | defense: 5, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/monsters/watcher-from-the-deep/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/watcher-from-the-deep/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/watcher-from-the-deep/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/watcher-from-the-deep/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/watcher-from-the-deep/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/watcher-from-the-deep/sfx.intro.wav -------------------------------------------------------------------------------- /src/monsters/zombie-hoard/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/zombie-hoard/artwork.png -------------------------------------------------------------------------------- /src/monsters/zombie-hoard/config.ts: -------------------------------------------------------------------------------- 1 | import { defineMonster } from '../../helpers/monsters' 2 | 3 | export default defineMonster({ 4 | name: 'Zombie Hoard', 5 | id: 'zombie-hoard', 6 | level: 5, 7 | goldBounty: 8, 8 | stats: { 9 | maxHealth: 30, 10 | health: 30, 11 | attack: 3, 12 | defense: 1, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /src/monsters/zombie-hoard/sfx.damage.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/zombie-hoard/sfx.damage.wav -------------------------------------------------------------------------------- /src/monsters/zombie-hoard/sfx.death.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/zombie-hoard/sfx.death.wav -------------------------------------------------------------------------------- /src/monsters/zombie-hoard/sfx.intro.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/monsters/zombie-hoard/sfx.intro.wav -------------------------------------------------------------------------------- /src/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Use a more-intuitive box-sizing model. 3 | */ 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | /* 10 | 2. Remove default margin 11 | */ 12 | * { 13 | margin: 0; 14 | } 15 | /* 16 | Typographic tweaks! 17 | 3. Add accessible line-height 18 | 4. Improve text rendering 19 | */ 20 | body { 21 | line-height: 1.5; 22 | -webkit-font-smoothing: antialiased; 23 | } 24 | /* 25 | 5. Improve media defaults 26 | */ 27 | img, 28 | picture, 29 | video, 30 | canvas, 31 | svg { 32 | display: block; 33 | max-width: 100%; 34 | } 35 | /* 36 | 6. Remove built-in form typography styles 37 | */ 38 | input, 39 | button, 40 | textarea, 41 | select { 42 | font: inherit; 43 | } 44 | /* 45 | 7. Avoid text overflows 46 | */ 47 | p, 48 | h1, 49 | h2, 50 | h3, 51 | h4, 52 | h5, 53 | h6 { 54 | overflow-wrap: break-word; 55 | } 56 | /* 57 | 8. Create a root stacking context 58 | */ 59 | #root, 60 | #__next { 61 | isolation: isolate; 62 | } 63 | -------------------------------------------------------------------------------- /src/sfx/button.click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/button.click.wav -------------------------------------------------------------------------------- /src/sfx/card.destroy.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/card.destroy.wav -------------------------------------------------------------------------------- /src/sfx/card.use.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/card.use.wav -------------------------------------------------------------------------------- /src/sfx/cash-register.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/cash-register.wav -------------------------------------------------------------------------------- /src/sfx/coins.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/coins.wav -------------------------------------------------------------------------------- /src/sfx/door.open.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/door.open.wav -------------------------------------------------------------------------------- /src/sfx/impact.blunt.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/impact.blunt.wav -------------------------------------------------------------------------------- /src/sfx/impact.cold.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/impact.cold.wav -------------------------------------------------------------------------------- /src/sfx/impact.punch.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/impact.punch.wav -------------------------------------------------------------------------------- /src/sfx/impact.slice.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/impact.slice.wav -------------------------------------------------------------------------------- /src/sfx/impact.stone.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/impact.stone.wav -------------------------------------------------------------------------------- /src/sfx/melee.slam.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/melee.slam.wav -------------------------------------------------------------------------------- /src/sfx/melee.woosh.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicklemmon/react-deckbuilder/dbe8550a7911aa68f1be04eddd85797eea130997/src/sfx/melee.woosh.flac -------------------------------------------------------------------------------- /src/types/cards.ts: -------------------------------------------------------------------------------- 1 | import type { Howl } from 'howler' 2 | 3 | export type Card = { 4 | id: string 5 | name: string 6 | sfx: Howl 7 | rarity: 0 | 1 | 2 | 3 8 | description: string 9 | price: number 10 | stats: { 11 | attack: number 12 | } 13 | // TODO: Can this be removed? 14 | align?: 'left' | 'right' 15 | artwork?: string 16 | status?: 'disabled' | 'in-play' | 'idle' 17 | orientation?: 'face-up' | 'face-down' 18 | } 19 | -------------------------------------------------------------------------------- /src/types/character-classes.ts: -------------------------------------------------------------------------------- 1 | import type { Card } from './cards' 2 | 3 | /** Types for a character class */ 4 | export type CharacterClass = { 5 | id: string 6 | name: string 7 | deck: Array 8 | } 9 | -------------------------------------------------------------------------------- /src/types/env.ts: -------------------------------------------------------------------------------- 1 | export interface ProcessEnv { 2 | [key: string]: string | undefined 3 | } 4 | -------------------------------------------------------------------------------- /src/types/items.ts: -------------------------------------------------------------------------------- 1 | import type { Howl } from 'howler' 2 | 3 | export type Item = { 4 | id: string 5 | name: string 6 | type: 'healing' | 'buff' 7 | value: number 8 | cost: number 9 | artwork: string 10 | sfx: { 11 | obtain: Howl 12 | use: Howl 13 | effect: Howl 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/types/monsters.ts: -------------------------------------------------------------------------------- 1 | import { Howl } from 'howler' 2 | import type { AvatarStatus } from '../components/avatar' 3 | 4 | export type Monster = { 5 | id: string 6 | status: AvatarStatus 7 | name: string 8 | level: number 9 | goldBounty: number 10 | artwork?: string 11 | damageTaken?: number 12 | sfx?: { 13 | intro: Howl 14 | damage: Howl 15 | death: Howl 16 | } 17 | stats: { 18 | maxHealth: number 19 | health: number 20 | attack: number 21 | defense: number 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/types/player.ts: -------------------------------------------------------------------------------- 1 | import type { Item } from './items' 2 | 3 | export interface Player { 4 | name?: string 5 | characterClass?: string 6 | characterPortrait?: string 7 | damageTaken?: number 8 | healingAmount?: number 9 | stats: { 10 | maxHealth: number 11 | health: number 12 | attack: number 13 | defense: number 14 | } 15 | inventory: { 16 | gold: number 17 | items: Array | [] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types/tokens.ts: -------------------------------------------------------------------------------- 1 | export const SPACING = ['100', '200', '300', '400', '500'] as const 2 | 3 | export type Spacing = (typeof SPACING)[number] 4 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type PluginOption } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import compression from 'vite-plugin-compression' 4 | import imageMin from 'vite-plugin-imagemin' 5 | 6 | const DEFAULT_PLUGINS: PluginOption = [ 7 | react(), 8 | imageMin({ 9 | optipng: { 10 | optimizationLevel: 7, 11 | }, 12 | mozjpeg: { 13 | quality: 20, 14 | }, 15 | pngquant: { 16 | quality: [0.8, 0.9], 17 | speed: 4, 18 | }, 19 | }), 20 | ] 21 | 22 | // https://vitejs.dev/config/ 23 | export default defineConfig(({ mode }) => { 24 | if (mode === 'production') { 25 | return { 26 | plugins: [...DEFAULT_PLUGINS, compression()], 27 | } 28 | } 29 | 30 | return { 31 | plugins: DEFAULT_PLUGINS, 32 | } 33 | }) 34 | --------------------------------------------------------------------------------