├── .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 |
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 |
303 |
304 |
366 |
367 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------