├── .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 | ![Screenshot](./public/screenshot.png) 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 | 2 | 3 | 4 | 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 | 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 | 37 | 38 | 64 | -------------------------------------------------------------------------------- /src/components/GameHand.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 57 | 58 | 144 | -------------------------------------------------------------------------------- /src/components/GameHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 68 | 69 | 152 | -------------------------------------------------------------------------------- /src/components/HandBet.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | 22 | 51 | -------------------------------------------------------------------------------- /src/components/HandTotal.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 24 | 25 | 69 | -------------------------------------------------------------------------------- /src/components/PlayerBank.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | 30 | 84 | -------------------------------------------------------------------------------- /src/components/PlayerToolbar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 15 | 16 | 29 | -------------------------------------------------------------------------------- /src/components/PlayingCard.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 76 | 77 | 468 | -------------------------------------------------------------------------------- /src/components/SvgSprite.vue: -------------------------------------------------------------------------------- 1 | 399 | 400 | 447 | -------------------------------------------------------------------------------- /src/components/TitleScreen.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 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 | --------------------------------------------------------------------------------