├── .editorconfig
├── .github
└── workflows
│ └── gh-pages.yml
├── .gitignore
├── .prettierrc.json
├── .vscode
└── extensions.json
├── README.md
├── env.d.ts
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
├── favicon.svg
├── fonts
│ └── big_shoulders_display.woff2
└── screenshot.png
├── src
├── App.vue
├── assets
│ ├── main.css
│ └── sounds
│ │ ├── 159698__qubodup__scroll-step-hover-sound-for-user-interface.mp3
│ │ ├── 619832__cogfirestudios__puzzle-game-victory-melody.mp3
│ │ ├── 619839__cogfirestudios__sine-beep-sytlized-app-ui.mp3
│ │ ├── 619840__cogfirestudios__achievement-accomplish-jingle-app-ui.mp3
│ │ ├── 636624__cogfirestudios__deep-hit.mp3
│ │ ├── 636634__cogfirestudios__app-ui-puzzle-game.mp3
│ │ ├── 636635__cogfirestudios__app-ui-puzzle-game.mp3
│ │ ├── 636642__cogfirestudios__app-ui-puzzle-game.mp3
│ │ ├── 636643__cogfirestudios__ui-app.mp3
│ │ ├── 636647__cogfirestudios__ui-app.mp3
│ │ ├── 636649__cogfirestudios__ui-app.mp3
│ │ ├── 636655__cogfirestudios__ui-achievement-puzzle-game-application.mp3
│ │ ├── 636659__cogfirestudios__ui-achievement-puzzle-game-application.mp3
│ │ ├── 636674__cogfirestudios__app-ui-sound.mp3
│ │ ├── 636677__cogfirestudios__app-ui-sound.mp3
│ │ ├── 677860__el_boss__ui-button-click-snap.mp3
│ │ └── 757328__steaq__ui-hover-item.mp3
├── cards.ts
├── components
│ ├── AnimatedBackground.vue
│ ├── GameHand.vue
│ ├── GameHeader.vue
│ ├── HandBet.vue
│ ├── HandTotal.vue
│ ├── PlayerBank.vue
│ ├── PlayerToolbar.vue
│ ├── PlayingCard.vue
│ ├── SvgSprite.vue
│ └── TitleScreen.vue
├── main.ts
├── sound.ts
├── store.ts
└── types.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.vitest.json
├── vite.config.ts
└── vitest.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
2 | charset = utf-8
3 | indent_size = 2
4 | indent_style = space
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v3
15 |
16 | - name: Install and build
17 | run: |
18 | npm install
19 | npm run build
20 |
21 | - name: Deploy to GitHub Pages
22 | uses: peaceiris/actions-gh-pages@v4
23 | with:
24 | github_token: ${{ secrets.GITHUB_TOKEN }}
25 | publish_dir: ./dist
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | /cypress/videos/
18 | /cypress/screenshots/
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
30 | *.tsbuildinfo
31 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "$schema": "https://json.schemastore.org/prettierrc",
4 | "semi": false,
5 | "singleQuote": true,
6 | "printWidth": 100
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "Vue.volar",
4 | "vitest.explorer",
5 | "dbaeumer.vscode-eslint",
6 | "EditorConfig.EditorConfig",
7 | "esbenp.prettier-vscode"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VLACKJACK ♠️♥️♣️♦️
2 |
3 | > [!NOTE]
4 | > As of January 2025, Vlackjack is now written in Vue 3! 🥳 If you would still like to view the source code for the Vue 2 / vuex
5 | > version, see the [vue2 branch](https://github.com/kevinleedrum/vlackjack/tree/vue2).
6 |
7 | ## Play Now 🚀
8 |
9 | https://kevinleedrum.github.io/vlackjack/
10 |
11 | ## Introduction
12 |
13 | Vlackjack is a single-player HTML5 blackjack game built with [Vue 3](https://vuejs.org/).
14 |
15 | 
16 |
17 | All of the sounds in this game are from [Freesound](https://freesound.org) and have a CC0 license.
18 |
19 | ## NPM Scripts
20 |
21 | ```bash
22 | # install dependencies
23 | npm install
24 |
25 | # serve for development
26 | npm run dev
27 |
28 | # build
29 | npm run build
30 | ```
31 |
32 | ## Rules
33 |
34 | - To keep the game simple, the initial bet is always one coin
35 | - 6 Decks, shuffled after 75% have been played
36 | - Blackjack pays 2-to-1
37 | - Dealer stands on any 17 (`S17`)
38 | - Double down on any two cards (`D2`)
39 | - Double down after splitting (`DAS`) (except Aces)
40 | - No resplitting (`NR`)
41 | - No insurance (`NI`)
42 |
43 | ## License
44 |
45 | [MIT](http://opensource.org/licenses/MIT)
46 |
47 | Copyright (c) 2017-Present, Kevin Lee Drum
48 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import pluginVue from 'eslint-plugin-vue'
2 | import vueTsEslintConfig from '@vue/eslint-config-typescript'
3 | import pluginVitest from '@vitest/eslint-plugin'
4 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
5 |
6 | export default [
7 | {
8 | name: 'app/files-to-lint',
9 | files: ['**/*.{ts,mts,tsx,vue}'],
10 | },
11 |
12 | {
13 | name: 'app/files-to-ignore',
14 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
15 | },
16 |
17 | ...pluginVue.configs['flat/essential'],
18 | ...vueTsEslintConfig(),
19 |
20 | {
21 | ...pluginVitest.configs.recommended,
22 | files: ['src/**/__tests__/*'],
23 | },
24 | skipFormatting,
25 | ]
26 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | VLACKJACK
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vlackjack3",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "run-p type-check \"build-only {@}\" --",
9 | "preview": "vite preview",
10 | "test:unit": "vitest",
11 | "build-only": "vite build",
12 | "type-check": "vue-tsc --build",
13 | "lint": "eslint . --fix",
14 | "format": "prettier --write src/"
15 | },
16 | "dependencies": {
17 | "@types/howler": "^2.2.12",
18 | "howler": "^2.2.4",
19 | "vue": "^3.5.13"
20 | },
21 | "devDependencies": {
22 | "@tsconfig/node22": "^22.0.0",
23 | "@types/jsdom": "^21.1.7",
24 | "@types/node": "^22.10.2",
25 | "@vitejs/plugin-vue": "^5.2.1",
26 | "@vitest/eslint-plugin": "1.1.20",
27 | "@vue/eslint-config-prettier": "^10.1.0",
28 | "@vue/eslint-config-typescript": "^14.1.3",
29 | "@vue/test-utils": "^2.4.6",
30 | "@vue/tsconfig": "^0.7.0",
31 | "eslint": "^9.14.0",
32 | "eslint-plugin-vue": "^9.30.0",
33 | "jsdom": "^25.0.1",
34 | "npm-run-all2": "^7.0.2",
35 | "prettier": "^3.3.3",
36 | "typescript": "~5.6.3",
37 | "vite": "^6.0.5",
38 | "vite-plugin-vue-devtools": "^7.6.8",
39 | "vitest": "^2.1.8",
40 | "vue-tsc": "^2.1.10"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/fonts/big_shoulders_display.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/public/fonts/big_shoulders_display.woff2
--------------------------------------------------------------------------------
/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/public/screenshot.png
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
37 |
38 |
39 |
40 |
41 |
42 |
67 |
--------------------------------------------------------------------------------
/src/assets/main.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Big Shoulders Display';
3 | font-style: normal;
4 | font-display: block;
5 | src: url('/fonts/big_shoulders_display.woff2') format('woff2');
6 | }
7 |
8 | :root {
9 | --color-chip: #0055ff;
10 | --color-red: #f33;
11 | --color-dark-red: #b60000;
12 | --color-black: #161718;
13 | --color-cyan: #8fe;
14 | --color-dark-cyan: #00467f;
15 | --color-white: #fff;
16 | --color-off-white: #ffffef;
17 | --color-blue-white: #deffff;
18 | --color-gold: #ffd900;
19 |
20 | font-size: 8px;
21 | font-family: 'Big Shoulders Display', 'Arial Narrow', sans-serif;
22 | font-optical-sizing: auto;
23 | color: var(--color-black);
24 | -webkit-font-smoothing: antialiased;
25 | -moz-osx-font-smoothing: grayscale;
26 | }
27 |
28 | @media (min-width: 600px) and (min-height: 768px) {
29 | :root {
30 | font-size: 12px;
31 | }
32 | }
33 |
34 | @media (min-width: 1024px) and (min-height: 900px) {
35 | :root {
36 | font-size: 16px;
37 | }
38 | }
39 |
40 | * {
41 | box-sizing: border-box;
42 | -webkit-tap-highlight-color: transparent;
43 | user-select: none;
44 | -webkit-user-select: none;
45 | }
46 |
47 | html,
48 | body {
49 | margin: 0;
50 | padding: 0;
51 | height: 100%;
52 | overflow: hidden;
53 | }
54 |
55 | body {
56 | overscroll-behavior: none;
57 | background-color: var(--color-dark-cyan);
58 | }
59 |
60 | #app {
61 | height: 100%;
62 | }
63 |
64 | h1 {
65 | font-weight: 400;
66 | margin: 0;
67 | }
68 |
69 | h1 > span {
70 | color: var(--color-red);
71 | }
72 |
73 | button,
74 | input,
75 | textarea,
76 | select {
77 | font: inherit;
78 | }
79 |
80 | button {
81 | display: flex;
82 | align-items: center;
83 | justify-content: center;
84 | text-transform: uppercase;
85 | font-size: 2.5rem;
86 | font-variation-settings: 'wght' 500;
87 | line-height: 1;
88 | padding: 1rem 1.5rem;
89 | border-radius: 1.75rem;
90 | border: 0;
91 | letter-spacing: 0.05rem;
92 | background-color: rgba(from var(--color-off-white) r g b / 0.9);
93 | color: currentColor;
94 | cursor: pointer;
95 | }
96 |
97 | button:disabled {
98 | opacity: 0.4;
99 | cursor: not-allowed;
100 | }
101 |
102 | button:focus-visible:not(:disabled),
103 | button:active:not(:disabled) {
104 | background-color: rgba(from var(--color-off-white) r g b / 1);
105 | transform: translateY(-0.1rem);
106 | }
107 |
108 | @media (hover: hover) {
109 | button:hover:not(:disabled) {
110 | background-color: rgba(from var(--color-off-white) r g b / 1);
111 | transform: translateY(-0.1rem);
112 | }
113 | }
114 |
115 | button:focus-visible:not(:disabled) {
116 | outline-offset: 0.25rem;
117 | outline: 2px solid var(--color-white);
118 | }
119 |
120 | button:active:not(:disabled) {
121 | transform: translateY(0.1rem);
122 | }
123 |
124 | .sr-only {
125 | position: absolute;
126 | width: 1px;
127 | height: 1px;
128 | padding: 0;
129 | margin: -1px;
130 | overflow: hidden;
131 | clip: rect(0, 0, 0, 0);
132 | white-space: nowrap;
133 | border-width: 0;
134 | }
135 |
136 | @media (prefers-reduced-motion: reduce) {
137 | * {
138 | animation-duration: 0s !important;
139 | animation-iteration-count: 1 !important;
140 | transition-duration: 0s !important;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/assets/sounds/159698__qubodup__scroll-step-hover-sound-for-user-interface.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/159698__qubodup__scroll-step-hover-sound-for-user-interface.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/619832__cogfirestudios__puzzle-game-victory-melody.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/619832__cogfirestudios__puzzle-game-victory-melody.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/619839__cogfirestudios__sine-beep-sytlized-app-ui.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/619839__cogfirestudios__sine-beep-sytlized-app-ui.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/619840__cogfirestudios__achievement-accomplish-jingle-app-ui.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/619840__cogfirestudios__achievement-accomplish-jingle-app-ui.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636624__cogfirestudios__deep-hit.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636624__cogfirestudios__deep-hit.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636634__cogfirestudios__app-ui-puzzle-game.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636634__cogfirestudios__app-ui-puzzle-game.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636635__cogfirestudios__app-ui-puzzle-game.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636635__cogfirestudios__app-ui-puzzle-game.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636642__cogfirestudios__app-ui-puzzle-game.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636642__cogfirestudios__app-ui-puzzle-game.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636643__cogfirestudios__ui-app.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636643__cogfirestudios__ui-app.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636647__cogfirestudios__ui-app.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636647__cogfirestudios__ui-app.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636649__cogfirestudios__ui-app.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636649__cogfirestudios__ui-app.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636655__cogfirestudios__ui-achievement-puzzle-game-application.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636655__cogfirestudios__ui-achievement-puzzle-game-application.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636659__cogfirestudios__ui-achievement-puzzle-game-application.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636659__cogfirestudios__ui-achievement-puzzle-game-application.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636674__cogfirestudios__app-ui-sound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636674__cogfirestudios__app-ui-sound.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/636677__cogfirestudios__app-ui-sound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/636677__cogfirestudios__app-ui-sound.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/677860__el_boss__ui-button-click-snap.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/677860__el_boss__ui-button-click-snap.mp3
--------------------------------------------------------------------------------
/src/assets/sounds/757328__steaq__ui-hover-item.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinleedrum/vlackjack/f880514a3418c57862d4c79b2f62596b89581fb1/src/assets/sounds/757328__steaq__ui-hover-item.mp3
--------------------------------------------------------------------------------
/src/cards.ts:
--------------------------------------------------------------------------------
1 | import type { Card, CardRank } from './types'
2 |
3 | export const CardValue = {
4 | A: 1,
5 | '2': 2,
6 | '3': 3,
7 | '4': 4,
8 | '5': 5,
9 | '6': 6,
10 | '7': 7,
11 | '8': 8,
12 | '9': 9,
13 | '10': 10,
14 | J: 10,
15 | Q: 10,
16 | K: 10,
17 | } as const
18 |
19 | export const CardSuits = ['♠', '♦', '♣', '♥'] as const
20 |
21 | export function generateShoe(numberOfDecks: number) {
22 | const shoe: Card[] = []
23 | for (let i = 0; i < numberOfDecks; i++) {
24 | for (const card of generateDeck()) {
25 | shoe.push(card)
26 | }
27 | }
28 | return shuffle(shoe)
29 | }
30 |
31 | export function generateDeck() {
32 | const deck: Card[] = []
33 | let index = 0
34 | for (const rank of Object.keys(CardValue) as CardRank[]) {
35 | for (const suit of CardSuits) {
36 | deck.push({ rank, suit, index: index++ })
37 | }
38 | }
39 | return deck
40 | }
41 |
42 | export function shuffle(array: T[]) {
43 | for (let i = array.length - 1; i > 0; i--) {
44 | const j = Math.floor(Math.random() * (i + 1))
45 | ;[array[i], array[j]] = [array[j], array[i]]
46 | }
47 | return array
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/AnimatedBackground.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
35 |
36 |
37 |
38 |
64 |
--------------------------------------------------------------------------------
/src/components/GameHand.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
36 | {{ isDealer ? "Dealer's" : 'Your' }} hand
37 |
38 |
45 |
46 |
47 |
48 |
49 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
144 |
--------------------------------------------------------------------------------
/src/components/GameHeader.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
17 |
23 |
24 | Vlackjack
25 |
66 |
67 |
68 |
69 |
152 |
--------------------------------------------------------------------------------
/src/components/HandBet.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
12 |
13 |
14 |
21 |
22 |
51 |
--------------------------------------------------------------------------------
/src/components/HandTotal.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 | Total:
20 | {{ total }}
21 |
22 |
23 |
24 |
25 |
69 |
--------------------------------------------------------------------------------
/src/components/PlayerBank.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | ×
7 | {{ state.players[0].bank }}
8 |
9 |
10 |
11 |
29 |
30 |
84 |
--------------------------------------------------------------------------------
/src/components/PlayerToolbar.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
29 |
--------------------------------------------------------------------------------
/src/components/PlayingCard.vue:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 |
48 |
49 |
50 |
51 | {{ card.rank.toUpperCase() }}
52 |
55 |
56 |
57 |
60 |
61 |
62 | {{ card.rank.toUpperCase() }}
63 |
66 |
67 |
68 |
69 |
70 |
73 |
74 |
75 |
76 |
77 |
468 |
--------------------------------------------------------------------------------
/src/components/SvgSprite.vue:
--------------------------------------------------------------------------------
1 |
2 |
398 |
399 |
400 |
447 |
--------------------------------------------------------------------------------
/src/components/TitleScreen.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
Vlackjack
22 |
Blackjack Simulator
23 |
24 |
25 |
26 |
29 |
30 |
33 |
34 |
35 |
36 |
37 |
38 |
120 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import './assets/main.css'
2 |
3 | import { createApp } from 'vue'
4 | import App from './App.vue'
5 |
6 | createApp(App).mount('#app')
7 |
--------------------------------------------------------------------------------
/src/sound.ts:
--------------------------------------------------------------------------------
1 | import { state } from './store'
2 |
3 | import deal from '@/assets/sounds/757328__steaq__ui-hover-item.mp3'
4 | import click from '@/assets/sounds/159698__qubodup__scroll-step-hover-sound-for-user-interface.mp3'
5 | import win from '@/assets/sounds/636649__cogfirestudios__ui-app.mp3'
6 | import bust from '@/assets/sounds/636642__cogfirestudios__app-ui-puzzle-game.mp3'
7 | import lose from '@/assets/sounds/636647__cogfirestudios__ui-app.mp3'
8 | import blackjack from '@/assets/sounds/619832__cogfirestudios__puzzle-game-victory-melody.mp3'
9 | import dealerBlackjack from '@/assets/sounds/619840__cogfirestudios__achievement-accomplish-jingle-app-ui.mp3'
10 | import chipDown from '@/assets/sounds/636635__cogfirestudios__app-ui-puzzle-game.mp3'
11 | import chipUp from '@/assets/sounds/636634__cogfirestudios__app-ui-puzzle-game.mp3'
12 | import bank from '@/assets/sounds/636659__cogfirestudios__ui-achievement-puzzle-game-application.mp3'
13 | import badHit from '@/assets/sounds/636677__cogfirestudios__app-ui-sound.mp3'
14 | import goodHit from '@/assets/sounds/636674__cogfirestudios__app-ui-sound.mp3'
15 | import blackjackBoom from '@/assets/sounds/636624__cogfirestudios__deep-hit.mp3'
16 | import gameOver from '@/assets/sounds/636655__cogfirestudios__ui-achievement-puzzle-game-application.mp3'
17 | import bet from '@/assets/sounds/677860__el_boss__ui-button-click-snap.mp3'
18 |
19 | export enum Sounds {
20 | Deal,
21 | Click,
22 | Blackjack,
23 | BlackjackBoom,
24 | Bust,
25 | BadHit,
26 | GoodHit,
27 | Push,
28 | Win,
29 | Bet,
30 | Lose,
31 | Title,
32 | ChipDown,
33 | ChipUp,
34 | Bank,
35 | GameOver,
36 | DealerBlackjack,
37 | }
38 |
39 | const files = new Map([
40 | [Sounds.Deal, deal],
41 | [Sounds.Click, click],
42 | [Sounds.Blackjack, blackjack],
43 | [Sounds.BlackjackBoom, blackjackBoom],
44 | [Sounds.DealerBlackjack, dealerBlackjack],
45 | [Sounds.Bust, bust],
46 | [Sounds.Win, win],
47 | [Sounds.ChipDown, chipDown],
48 | [Sounds.ChipUp, chipUp],
49 | [Sounds.Bet, bet],
50 | [Sounds.Lose, lose],
51 | [Sounds.Bank, bank],
52 | [Sounds.BadHit, badHit],
53 | [Sounds.GoodHit, goodHit],
54 | [Sounds.GameOver, gameOver],
55 | ])
56 |
57 | const SOUND_PERCENT = 100 / files.size
58 |
59 | const buffers = new Map()
60 | const sources = new Map()
61 |
62 | const ctx = new AudioContext()
63 | const gainNode = ctx.createGain()
64 | gainNode.connect(ctx.destination)
65 |
66 | /** Resume the audio context (i.e. once a user clicks on the page) */
67 | export const initSound = async (): Promise => {
68 | if (ctx.state === 'suspended') await ctx.resume()
69 | }
70 |
71 | /** Fetch and decode an audio file. */
72 | const loadSound = async (sound: Sounds): Promise => {
73 | const response = await fetch(files.get(sound)!)
74 | const arrayBuffer = await response.arrayBuffer()
75 | const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
76 | buffers.set(sound, audioBuffer)
77 | state.soundLoadProgress += SOUND_PERCENT
78 | }
79 |
80 | /** Load all audio files. */
81 | export const loadSounds = (): Promise => {
82 | return Promise.all(Array.from(files.keys()).map(loadSound))
83 | }
84 | loadSounds()
85 |
86 | /** Stop a sound from playing. */
87 | export const stopSound = (sound: Sounds) => {
88 | if (!sources.has(sound)) return
89 | const source = sources.get(sound)!
90 | source.stop()
91 | sources.delete(sound)
92 | }
93 |
94 | /** Play a sound. If the sound is already playing, it will be restarted unless `restartIfPlaying` is false. */
95 | export const playSound = async (sound: Sounds, restartIfPlaying = true) => {
96 | if (ctx.state === 'suspended') await ctx.resume()
97 | if (!buffers.has(sound)) return
98 | if (state.isMuted) return
99 | if (sources.has(sound)) {
100 | if (!restartIfPlaying) return
101 | stopSound(sound)
102 | }
103 | const source = ctx.createBufferSource()
104 | source.buffer = buffers.get(sound)!
105 | sources.set(sound, source)
106 | source.addEventListener('ended', () => stopSound(sound))
107 | source.connect(gainNode)
108 | source.start()
109 | }
110 |
111 | /** Set a sound to loop. */
112 | export const setLooping = (sound: Sounds, loop: boolean) => {
113 | if (!sources.has(sound)) return
114 | sources.get(sound)!.loop = loop
115 | }
116 |
--------------------------------------------------------------------------------
/src/store.ts:
--------------------------------------------------------------------------------
1 | import { generateShoe, shuffle } from '@/cards'
2 | import type { GameState, HandResult, Player } from './types'
3 | import { computed, nextTick, reactive } from 'vue'
4 | import { Sounds, playSound } from './sound'
5 | import { Hand } from './types'
6 |
7 | const MINIMUM_BET = 1
8 | const STARTING_BANK = 20
9 | const NUMBER_OF_DECKS = 6
10 | /** Reshuffle once less than 25% of the cards are left */
11 | const SHUFFLE_THRESHOLD = 0.25
12 | const INITIAL_PLAYERS: Player[] = [
13 | { isDealer: false, bank: STARTING_BANK, hands: [new Hand()] },
14 | { isDealer: true, bank: 0, hands: [new Hand()] },
15 | ]
16 |
17 | export const state = reactive({
18 | shoe: generateShoe(NUMBER_OF_DECKS),
19 | cardsPlayed: 0,
20 | players: INITIAL_PLAYERS,
21 | activePlayer: null,
22 | activeHand: null,
23 | isDealing: true,
24 | showDealerHoleCard: false,
25 | isGameOver: false,
26 | isMuted: localStorage.getItem('isMuted') === 'true',
27 | soundLoadProgress: 0,
28 | })
29 |
30 | // Computed Properties
31 |
32 | export const dealer = computed(() => state.players[state.players.length - 1])
33 |
34 | const dealerHasBlackjack = computed(() => {
35 | return dealer.value.hands[0].isBlackjack
36 | })
37 |
38 | const dealerTotal = computed(() => dealer.value.hands[0].total)
39 |
40 | const nextPlayer = computed(() => {
41 | if (!state.activePlayer || state.activePlayer === dealer.value) return null
42 | return state.players[state.players.indexOf(state.activePlayer) + 1]
43 | })
44 |
45 | export const canDoubleDown = computed(() => {
46 | if (state.isDealing) return false
47 | if ((state.activePlayer?.bank ?? 0) < (state.activeHand?.bet ?? 0)) return false
48 | return state.activeHand?.cards.length === 2 && state.activePlayer?.hands.length === 1
49 | })
50 |
51 | export const canSplit = computed(() => {
52 | if (state.isDealing) return false
53 | if ((state.activePlayer?.bank ?? 0) < (state.activeHand?.bet ?? 0)) return false
54 | return (
55 | state.activeHand?.cards.length === 2 &&
56 | state.activePlayer?.hands.length === 1 &&
57 | state.activeHand?.cards[0].rank === state.activeHand!.cards[1].rank
58 | )
59 | })
60 |
61 | export const resetBank = () => {
62 | state.players.forEach((p) => (p.bank = STARTING_BANK))
63 | }
64 |
65 | // Functions
66 |
67 | /** Play a round of blackjack. Reset hands, reshuffle, place bets, deal cards, and play the first turn. */
68 | export async function playRound() {
69 | if (checkForGameOver()) return
70 | state.players.forEach((p) => (p.hands = [new Hand()]))
71 | state.showDealerHoleCard = false
72 | await placeBet(state.players[0], state.players[0].hands[0], MINIMUM_BET)
73 | await dealRound()
74 | if (dealerHasBlackjack.value) return endRound()
75 | playTurn(state.players[0])
76 | }
77 |
78 | /** If the player is bankrupt, end the game. */
79 | function checkForGameOver(): boolean {
80 | if (state.players[0].bank < MINIMUM_BET) {
81 | playSound(Sounds.GameOver)
82 | state.isGameOver = true
83 | return true
84 | }
85 | return false
86 | }
87 |
88 | /** Draw a card from the shoe. */
89 | function drawCard() {
90 | reshuffleIfNeeded()
91 | state.cardsPlayed++
92 | return state.shoe.shift()
93 | }
94 |
95 | /** Reshuffle the shoe if less than 25% of the cards are left. */
96 | function reshuffleIfNeeded() {
97 | const remainingPercentage = 1 - state.cardsPlayed / (NUMBER_OF_DECKS * 52)
98 | if (remainingPercentage > SHUFFLE_THRESHOLD) return
99 | state.shoe = shuffle(state.shoe)
100 | state.cardsPlayed = 0
101 | }
102 |
103 | /** Deal two cards to each player */
104 | async function dealRound() {
105 | for (let i = 0; i < 2; i++) {
106 | for (const player of state.players) {
107 | player.hands[0].cards.push(drawCard()!)
108 | playSound(Sounds.Deal)
109 | await sleep(600)
110 | }
111 | }
112 | }
113 |
114 | /** Place a bet for the player. */
115 | async function placeBet(player: Player, hand: Hand, amount: number) {
116 | state.isDealing = true
117 | await nextTick()
118 | player.bank -= amount
119 | hand.bet += amount
120 | playSound(Sounds.Bet)
121 | await sleep()
122 | }
123 |
124 | /** Start a player's turn by making them the active player and starting their first hand. */
125 | function playTurn(player: Player) {
126 | state.activePlayer = player
127 | if (player.isDealer) return playDealerHand(player.hands[0])
128 | playHand(player.hands[0])
129 | }
130 |
131 | /** Set a hand as the active hand. End immediately if the player has blackjack. Deal additional cards to split hands. */
132 | async function playHand(hand: Hand): Promise {
133 | state.isDealing = true
134 | state.activeHand = hand
135 | if (await checkForBlackjack(hand)) return
136 | if (hand.cards.length === 1) {
137 | // Newly split hand
138 | await hit()
139 | if (hand.cards[0].rank === 'A') return endHand() // Player cannot hit after splitting aces
140 | }
141 | state.isDealing = false
142 | }
143 |
144 | /** Check if the player has blackjack. If so, award the player and end the hand. */
145 | async function checkForBlackjack(hand: Hand): Promise {
146 | if (hand.isBlackjack) {
147 | hand.result = 'blackjack'
148 | await sleep(100)
149 | playSound(Sounds.BlackjackBoom)
150 | await sleep(500)
151 | playSound(Sounds.Blackjack)
152 | await sleep(1200)
153 | hand.bet *= 3
154 | endHand()
155 | return true
156 | }
157 | return false
158 | }
159 |
160 | /** Play the dealer's hand. */
161 | async function playDealerHand(hand: Hand) {
162 | state.isDealing = true
163 | state.activeHand = hand
164 | await revealDealerHoleCard()
165 | const allPlayersDone = state.players.every(
166 | (p) => p.isDealer || p.hands.every((h: Hand) => !!h.result),
167 | )
168 | if (allPlayersDone) return endRound()
169 | if (dealerTotal.value < 17) {
170 | await hit()
171 | if (!dealer.value.hands[0].result) return playDealerHand(hand)
172 | }
173 | endRound()
174 | }
175 |
176 | /** Deal one more card to the active hand, and check for 21 or a bust. */
177 | export async function hit() {
178 | state.isDealing = true
179 | state.activeHand!.cards.push(drawCard()!)
180 | playSound(Sounds.Deal)
181 | if (await checkForTwentyOne(state.activeHand!)) return
182 | if (await checkForBust(state.activeHand!)) return
183 | await sleep()
184 | if (!state.activePlayer?.isDealer) state.isDealing = false
185 | }
186 |
187 | /** Check if the player has 21. If so, end the hand. */
188 | async function checkForTwentyOne(hand: Hand): Promise {
189 | if (hand.total === 21) {
190 | if (!state.activePlayer?.isDealer) playSound(Sounds.GoodHit)
191 | await sleep()
192 | endHand()
193 | return true
194 | }
195 | return false
196 | }
197 |
198 | /** Check if the player has busted. If so, end the hand. */
199 | async function checkForBust(hand: Hand): Promise {
200 | if (hand.isBust) {
201 | if (!state.activePlayer?.isDealer) playSound(Sounds.BadHit)
202 | await sleep()
203 | state.activeHand = null
204 | await sleep(300)
205 | hand.result = 'bust'
206 | if (!state.activePlayer?.isDealer) playSound(Sounds.Bust)
207 | endHand()
208 | return true
209 | }
210 | return false
211 | }
212 |
213 | /** Split the active hand into two hands, and restart the player's turn. */
214 | export async function split(): Promise {
215 | if (!canSplit.value) return
216 | state.isDealing = true
217 | const bet = state.activeHand!.bet
218 | const splitHands = [new Hand(bet), new Hand(0)]
219 | splitHands[0].cards = state.activeHand!.cards.slice(0, 1)
220 | splitHands[1].cards = state.activeHand!.cards.slice(1)
221 | state.activeHand = null
222 | await sleep()
223 | state.activePlayer!.hands = splitHands
224 | await placeBet(state.activePlayer!, state.activePlayer!.hands[1], bet)
225 | playTurn(state.activePlayer!)
226 | }
227 |
228 | /** Double the bet for the active hand, and hit only once. */
229 | export async function doubleDown(): Promise {
230 | if (!canDoubleDown.value) return
231 | await placeBet(state.activePlayer!, state.activeHand!, state.activeHand!.bet)
232 | await hit()
233 | endHand()
234 | }
235 |
236 | /** Advance to the next hand or player. */
237 | export async function endHand() {
238 | const isSplit = state.activePlayer && state.activePlayer.hands.length > 1
239 | if (isSplit && state.activePlayer?.hands[1].cards.length === 1) {
240 | return playHand(state.activePlayer?.hands[1])
241 | }
242 | if (nextPlayer.value) playTurn(nextPlayer.value)
243 | }
244 |
245 | /** Determine any remaining results, settle bets, collect winnings, and reset hands before starting a new round. */
246 | async function endRound() {
247 | state.isDealing = true
248 | if (!state.showDealerHoleCard) await revealDealerHoleCard()
249 | if (dealerHasBlackjack.value) playSound(Sounds.DealerBlackjack)
250 | state.activeHand = null
251 | state.activePlayer = null
252 | await determineResults()
253 | await settleBets()
254 | await collectWinnings()
255 | await resetHands()
256 | playRound()
257 | }
258 |
259 | /** Reveal the dealer's hole card. */
260 | async function revealDealerHoleCard() {
261 | if (state.showDealerHoleCard) return
262 | await sleep()
263 | playSound(Sounds.Deal)
264 | state.showDealerHoleCard = true
265 | await sleep()
266 | }
267 |
268 | /** Determine the result for each hand (e.g. win, lose, push, blackjack, bust). */
269 | async function determineResults() {
270 | for (const player of state.players) {
271 | if (player.isDealer) continue
272 | for (const hand of player.hands) {
273 | if (hand.result) continue
274 | if (dealerTotal.value > 21) hand.result = 'win'
275 | else if (dealerTotal.value === hand.total) hand.result = 'push'
276 | else if (dealerTotal.value < hand.total) hand.result = 'win'
277 | else hand.result = 'lose'
278 | playSoundForResult(hand.result)
279 | await sleep()
280 | }
281 | }
282 | }
283 |
284 | /** Play a sound for the result of a hand. */
285 | function playSoundForResult(result: HandResult) {
286 | if (result === 'win') {
287 | playSound(Sounds.Win)
288 | } else if (result === 'push') {
289 | playSound(Sounds.Push)
290 | } else if (!dealerHasBlackjack.value) {
291 | playSound(Sounds.Lose)
292 | }
293 | }
294 |
295 | /** Add each hand's winnings to the hand's bet amount (so it can be collected later).*/
296 | async function settleBets() {
297 | let total = 0
298 | for (const player of state.players) {
299 | if (player.isDealer) continue
300 | for (const hand of player.hands) {
301 | // Blackjack is paid out immediately, so it is not handled here
302 | if (hand.result === 'win') hand.bet *= 2
303 | if (['lose', 'bust'].includes(hand.result!)) hand.bet = 0
304 | total += hand.bet
305 | }
306 | }
307 | playSound(total > 1 ? Sounds.ChipUp : Sounds.ChipDown)
308 | await sleep()
309 | }
310 |
311 | /** Collect the total winnings (from each hand's bet) and add it to the player's bank. */
312 | async function collectWinnings() {
313 | for (const player of state.players) {
314 | if (player.isDealer) continue
315 | const total = player.hands.reduce((acc: number, hand: Hand) => acc + hand.bet, 0)
316 | player.bank += total
317 | if (total > 0) playSound(Sounds.Bank)
318 | for (const hand of player.hands) hand.bet = 0
319 | }
320 | await sleep(300)
321 | }
322 |
323 | /** Reset all hands to an initial state. */
324 | async function resetHands() {
325 | for (const player of state.players) {
326 | for (const hand of player.hands) {
327 | state.shoe.push(...hand.cards)
328 | hand.reset()
329 | }
330 | }
331 | await sleep()
332 | }
333 |
334 | /** Sleep for a given number of milliseconds. This paces the game and gives time for animations and sounds. */
335 | function sleep(ms: number = 900) {
336 | return new Promise((resolve) => setTimeout(resolve, ms))
337 | }
338 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { CardSuits, CardValue } from '@/cards'
2 |
3 | export type CardSuit = (typeof CardSuits)[number]
4 | export type CardRank = keyof typeof CardValue
5 | export type HandResult = 'win' | 'lose' | 'push' | 'blackjack' | 'bust'
6 |
7 | export type Card = {
8 | rank: CardRank
9 | suit: CardSuit
10 | index: number
11 | }
12 |
13 | export type Player = {
14 | name?: string
15 | isDealer: boolean
16 | bank: number
17 | /** The player's hands (A player can have two hands after splitting) */
18 | hands: Hand[]
19 | }
20 |
21 | export type GameState = {
22 | /** The shoe of cards */
23 | shoe: Card[]
24 | /** Number of cards played */
25 | cardsPlayed: number
26 | /** The players in the game, including the dealer */
27 | players: Player[]
28 | /** The player whose turn it is */
29 | activePlayer: Player | null
30 | /** The hand that is currently being played */
31 | activeHand: Hand | null
32 | /** Whether the dealer is dealing cards (preventing interaction) */
33 | isDealing: boolean
34 | /** Whether the dealer's hole card is face up */
35 | showDealerHoleCard: boolean
36 | /** Whether the sound is muted */
37 | isMuted: boolean
38 | /** Whether the game is over due to bankruptcy */
39 | isGameOver: boolean
40 | /** The download progress of the sound files */
41 | soundLoadProgress: number
42 | }
43 |
44 | export class Hand {
45 | id: number
46 | cards: Card[]
47 | bet: number
48 | result?: 'win' | 'lose' | 'push' | 'bust' | 'blackjack'
49 |
50 | constructor(bet = 0) {
51 | this.id = new Date().getTime() + Math.random()
52 | this.cards = []
53 | this.bet = bet
54 | }
55 |
56 | get total(): number {
57 | let total = 0
58 | let addedHighAce = false
59 | for (const card of this.cards) {
60 | total += CardValue[card.rank as CardRank]
61 | if (card.rank === 'A' && !addedHighAce) {
62 | total += 10
63 | addedHighAce = true
64 | }
65 | }
66 | if (total > 21 && addedHighAce) total -= 10
67 | return total
68 | }
69 |
70 | get isBust(): boolean {
71 | return this.total > 21
72 | }
73 |
74 | get isBlackjack(): boolean {
75 | return this.total === 21 && this.cards.length === 2
76 | }
77 |
78 | reset() {
79 | this.cards = []
80 | this.bet = 0
81 | this.result = undefined
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
4 | "exclude": ["src/**/__tests__/*"],
5 | "compilerOptions": {
6 | "incremental": true,
7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
8 |
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.node.json"
6 | },
7 | {
8 | "path": "./tsconfig.app.json"
9 | },
10 | {
11 | "path": "./tsconfig.vitest.json"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node22/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*",
6 | "cypress.config.*",
7 | "nightwatch.conf.*",
8 | "playwright.config.*"
9 | ],
10 | "compilerOptions": {
11 | "noEmit": true,
12 | "incremental": true,
13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
14 |
15 | "module": "ESNext",
16 | "moduleResolution": "Bundler",
17 | "types": ["node"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "include": ["src/**/__tests__/*", "env.d.ts"],
4 | "exclude": [],
5 | "compilerOptions": {
6 | "incremental": true,
7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
8 |
9 | "lib": [],
10 | "types": ["node", "jsdom"]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url'
2 |
3 | import { defineConfig } from 'vite'
4 | import vue from '@vitejs/plugin-vue'
5 | import vueDevTools from 'vite-plugin-vue-devtools'
6 |
7 | // https://vite.dev/config/
8 | export default defineConfig({
9 | base: '/vlackjack/',
10 | plugins: [
11 | vue(),
12 | vueDevTools(),
13 | ],
14 | resolve: {
15 | alias: {
16 | '@': fileURLToPath(new URL('./src', import.meta.url))
17 | },
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
3 | import viteConfig from './vite.config'
4 |
5 | export default mergeConfig(
6 | viteConfig,
7 | defineConfig({
8 | test: {
9 | environment: 'jsdom',
10 | exclude: [...configDefaults.exclude, 'e2e/**'],
11 | root: fileURLToPath(new URL('./', import.meta.url)),
12 | },
13 | }),
14 | )
15 |
--------------------------------------------------------------------------------