├── src
├── vite-env.d.ts
├── assets
│ ├── bg-pattern.png
│ └── vue.svg
├── App.vue
├── plugins
│ └── plausible.ts
├── components
│ ├── icons
│ │ ├── Icon0.vue
│ │ ├── IconChevron.vue
│ │ └── IconX.vue
│ ├── Heading.vue
│ ├── modals
│ │ ├── ModalDisconnect.vue
│ │ ├── ModalInstructions.vue
│ │ ├── ModalCountdown.vue
│ │ ├── ModalRoundGameWinner.vue
│ │ └── ModalRoundWinner.vue
│ ├── Players.vue
│ ├── Player.vue
│ ├── Score.vue
│ ├── Button.vue
│ ├── Modal.vue
│ ├── GridCell.vue
│ ├── InputText.vue
│ ├── IconConfetti.vue
│ ├── TurnTimer.vue
│ ├── Logo.vue
│ └── GridGame.vue
├── supabase.ts
├── style.css
├── main.ts
├── layouts
│ └── LayoutDefault.vue
├── router.ts
├── pages
│ ├── PageAbout.vue
│ ├── PageNewGame.vue
│ ├── PageWaiting.vue
│ ├── PageJoinGame.vue
│ ├── PageGame.vue
│ └── PageHome.vue
└── stores
│ └── game.ts
├── public
├── og-image.png
├── favicon.svg
└── vite.svg
├── .vscode
└── extensions.json
├── postcss.config.cjs
├── .prettierrc.json
├── vite.config.ts
├── tsconfig.node.json
├── .editorconfig
├── .gitignore
├── tailwind.config.cjs
├── tsconfig.json
├── .eslintrc.cjs
├── package.json
├── README.md
└── index.html
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pixelhop/tic-tac-go/HEAD/public/og-image.png
--------------------------------------------------------------------------------
/src/assets/bg-pattern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pixelhop/tic-tac-go/HEAD/src/assets/bg-pattern.png
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
3 | }
4 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "printWidth": 120,
6 | "trailingComma": "es5"
7 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import vue from '@vitejs/plugin-vue'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [vue()],
7 | })
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 | charset = utf-8
11 | trim_trailing_whitespace = false
12 | insert_final_newline = false
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/plugins/plausible.ts:
--------------------------------------------------------------------------------
1 | import Plausible, { PlausibleOptions } from 'plausible-tracker';
2 |
3 | export default {
4 | install: (app: any, options: PlausibleOptions) => {
5 | const { enableAutoPageviews } = Plausible(options);
6 |
7 | enableAutoPageviews();
8 |
9 | app.provide('plausible');
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/components/icons/Icon0.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js';
2 |
3 | const supabaseUrl = 'https://shzuzglpuckyfwbstipu.supabase.co';
4 | const supabaseKey = import.meta.env.VITE_SUPABASE_KEY;
5 | export const supabase = createClient(supabaseUrl, supabaseKey, {
6 | realtime: {
7 | params: {
8 | eventsPerSecond: 20,
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/icons/IconChevron.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.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 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .env
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | #app {
8 | height: 100%;
9 | }
10 |
11 | body {
12 | font-family: 'Orbitron', sans-serif;
13 | background-color: theme('colors.black');
14 | background-image: url('./assets/bg-pattern.png');
15 | background-position: center center;
16 | background-repeat: repeat;
17 | color: theme('colors.white');
18 | }
19 |
--------------------------------------------------------------------------------
/src/assets/vue.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Heading.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx,vue}'],
4 | theme: {
5 | extend: {
6 | colors: {
7 | black: '#000201',
8 | orange: {
9 | DEFAULT: '#FF7615',
10 | },
11 | blue: {
12 | DEFAULT: '#02FFFF',
13 | },
14 | },
15 | dropShadow: {
16 | 'blue-sm': '0px 0px 5px #02FFFF',
17 | blue: '0px 0px 11px #02FFFF',
18 | },
19 | },
20 | },
21 | plugins: [],
22 | };
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "moduleResolution": "Node",
7 | "strict": true,
8 | "jsx": "preserve",
9 | "resolveJsonModule": true,
10 | "isolatedModules": true,
11 | "esModuleInterop": true,
12 | "lib": ["ESNext", "DOM"],
13 | "skipLibCheck": true,
14 | "noEmit": true
15 | },
16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
17 | "references": [{ "path": "./tsconfig.node.json" }]
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/modals/ModalDisconnect.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
Oh no, you have been disconnected from the other player!
10 |
11 |
12 |
13 | Back home?
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue';
2 | import { createPinia } from 'pinia';
3 | import '@fontsource/orbitron';
4 | import './style.css';
5 | import App from './App.vue';
6 | import router from './router';
7 | import plausible from './plugins/plausible';
8 |
9 | const app = createApp(App);
10 |
11 | if (import.meta.env.VITE_PLAUSIBLE_DOMAIN) {
12 | app.use(plausible, {
13 | domain: import.meta.env.VITE_PLAUSIBLE_DOMAIN,
14 | apiHost: `https://${import.meta.env.VITE_PLAUSIBLE_DOMAIN}`,
15 | });
16 | }
17 | app.use(createPinia());
18 | app.use(router);
19 |
20 | app.mount('#app');
21 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/Players.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/Player.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
Player {{ player }}
18 |
{{ name }}
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/layouts/LayoutDefault.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Built in the future by
24 | Pixelhop
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/components/Score.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
Score
12 |
24 |
Best of 5
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | browser: true,
6 | 'vue/setup-compiler-macros': true,
7 | },
8 | settings: {
9 | 'import/resolver': {
10 | node: {
11 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
12 | },
13 | },
14 | },
15 | extends: [
16 | 'eslint:recommended',
17 | 'plugin:@typescript-eslint/recommended',
18 | 'plugin:vue/vue3-recommended',
19 | 'airbnb-base',
20 | 'prettier',
21 | ],
22 | parserOptions: {
23 | ecmaVersion: 'latest',
24 | parser: '@typescript-eslint/parser',
25 | sourceType: 'module',
26 | },
27 | plugins: ['vue', '@typescript-eslint', 'prettier'],
28 | rules: {
29 | 'vue/script-setup-uses-vars': 'error',
30 | 'prettier/prettier': 'error',
31 | 'import/extensions': 'off',
32 | 'import/prefer-default-export': 'off',
33 | 'import/no-extraneous-dependencies': [
34 | 'error',
35 | {
36 | devDependencies: ['.storybook/**', 'stories/**', '**/*.stories.*'],
37 | },
38 | ],
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/modals/ModalInstructions.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
Take your turn quickly before the timer runs out.
15 |
16 |
If the timer RUNs out your marker will be placed randomly!
17 |
18 |
Create sets of 3 markers Vertically, Horizontally or Diagonally.
19 |
20 |
The first to win 3 rounds wins.
21 |
22 |
23 |
24 |
I'm ready
25 |
Ready: Waiting for {{ isPlayer1 ? player2Name : player1Name }}
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/router.ts:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router';
2 | import PageHome from './pages/PageHome.vue';
3 | import PageNewGame from './pages/PageNewGame.vue';
4 | import PageWaiting from './pages/PageWaiting.vue';
5 | import PageJoinGame from './pages/PageJoinGame.vue';
6 | import PageGame from './pages/PageGame.vue';
7 | import PageAbout from './pages/PageAbout.vue';
8 |
9 | const routes = [
10 | { path: '/', component: PageHome },
11 | { path: '/new-game', component: PageNewGame },
12 | { path: '/waiting', component: PageWaiting },
13 | { path: '/join-game', component: PageJoinGame },
14 | { path: '/game', component: PageGame },
15 | { path: '/about', component: PageAbout },
16 | ];
17 |
18 | // 3. Create the router instance and pass the `routes` option
19 | // You can pass in additional options here, but let's
20 | // keep it simple for now.
21 | const router = createRouter({
22 | // 4. Provide the history implementation to use. We are using the hash history for simplicity here.
23 | history: createWebHistory(),
24 | routes, // short for `routes: routes`
25 | });
26 |
27 | export default router;
28 |
--------------------------------------------------------------------------------
/src/components/Button.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/Modal.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {{ title }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
47 |
--------------------------------------------------------------------------------
/src/components/GridCell.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
48 |
--------------------------------------------------------------------------------
/src/components/modals/ModalCountdown.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 |
24 |
25 |
Will go first
26 |
27 |
28 |
{{ secondsRemaining }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/InputText.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
{{ label }}
36 |
45 |
{{ errorMessage }}
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tic-tac-go",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vue-tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@fontsource/orbitron": "^4.5.11",
13 | "@supabase/supabase-js": "^2.4.1",
14 | "@vueuse/core": "^9.11.1",
15 | "animejs": "^3.2.1",
16 | "dayjs": "^1.11.7",
17 | "pinia": "^2.0.29",
18 | "plausible-tracker": "^0.3.8",
19 | "unique-names-generator": "^4.7.1",
20 | "vee-validate": "^4.7.3",
21 | "vue": "^3.2.45",
22 | "vue-router": "4",
23 | "yup": "^0.32.11"
24 | },
25 | "devDependencies": {
26 | "@types/animejs": "^3.1.7",
27 | "@typescript-eslint/eslint-plugin": "^5.48.2",
28 | "@typescript-eslint/parser": "^5.48.2",
29 | "@vitejs/plugin-vue": "^4.0.0",
30 | "autoprefixer": "^10.4.13",
31 | "eslint": "^8.32.0",
32 | "eslint-config-airbnb-base": "^15.0.0",
33 | "eslint-config-prettier": "^8.6.0",
34 | "eslint-plugin-import": "^2.27.5",
35 | "eslint-plugin-prettier": "^4.2.1",
36 | "eslint-plugin-vue": "^9.9.0",
37 | "postcss": "^8.4.21",
38 | "prettier": "^2.8.3",
39 | "tailwindcss": "^3.2.4",
40 | "typescript": "^4.9.3",
41 | "vite": "^4.0.0",
42 | "vue-tsc": "^1.0.11"
43 | }
44 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tic Tac Go
2 |
3 | This project was built as part of a tutorial series demonstrating how to build a real-time game with Supabase and Vue.
4 |
5 | ## Recommended IDE Setup
6 |
7 | - [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
8 |
9 | ## Type Support For `.vue` Imports in TS
10 |
11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
12 |
13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
14 |
15 | 1. Disable the built-in TypeScript Extension
16 | 1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
17 | 2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
19 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/IconConfetti.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
37 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/pages/PageAbout.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
About
12 |
13 |
29 |
30 |
31 | Back
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/pages/PageNewGame.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
New game
30 |
31 |
34 |
35 |
36 | Back
37 | Next
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/pages/PageWaiting.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
Waiting for
29 | player 2
31 |
32 |
33 |
Game code
34 |
{{ game.code }}
35 |
Copied!
36 |
click to copy
37 |
38 |
39 |
40 | Send player 2 Your game code so They can join. The game will start once they join.
41 |
42 |
43 |
44 | Quit
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/pages/PageJoinGame.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/pages/PageGame.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
21 |
22 |
26 |
27 |
31 |
35 |
36 |
40 |
41 |
42 | Tic Tac Go
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/components/icons/IconX.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
16 |
22 |
23 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/pages/PageHome.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | New game
21 | Join game
22 | About
23 |
24 |
25 |
26 |
27 |
91 |
--------------------------------------------------------------------------------
/src/components/modals/ModalRoundGameWinner.vue:
--------------------------------------------------------------------------------
1 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
47 |
51 |
55 |
59 |
63 |
64 |
65 |
66 |
67 |
68 |
77 |
78 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
It's a draw!
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/src/components/TurnTimer.vue:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
38 |
Turn timer
39 |
46 |
47 |
48 |
49 |
58 |
59 |
68 |
77 |
86 |
95 |
104 |
113 |
122 |
131 |
140 |
149 |
158 |
167 |
176 |
185 |
194 |
203 |
212 |
221 |
230 |
239 |
248 |
249 |
250 |
251 |
261 |
262 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
286 |
287 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
324 |
--------------------------------------------------------------------------------
/src/components/modals/ModalRoundWinner.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
36 |
40 |
44 |
48 |
52 |
53 |
54 |
55 |
56 |
57 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
Round Draw
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
120 |
--------------------------------------------------------------------------------
/src/stores/game.ts:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia';
2 | import dayjs from 'dayjs';
3 | import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator';
4 | import { useIntervalFn } from '@vueuse/core';
5 | import { computed, reactive, ref, watch } from 'vue';
6 | import { useRouter } from 'vue-router';
7 | import { RealtimeChannel } from '@supabase/supabase-js';
8 | import { supabase } from '../supabase';
9 |
10 | type GameState =
11 | | 'waiting-for-players'
12 | | 'instructions'
13 | | 'countdown'
14 | | 'game'
15 | | 'round-winner'
16 | | 'game-winner'
17 | | 'disconnected';
18 | type GameGrid = ('x' | '0' | undefined)[];
19 |
20 | export interface Game {
21 | code: string;
22 | round: number;
23 | roundStartTime?: string;
24 | state: GameState;
25 | currentPlayer: 1 | 2;
26 | currentGoStart?: string;
27 | currentGoEnd?: string;
28 | roundWinners: ((1 | 2) | null)[];
29 | }
30 |
31 | const TURN_DURATION = 3000;
32 |
33 | /**
34 | * The below masks represent all the winning combinations possible
35 | */
36 | const WIN_MASKS = [
37 | [true, true, true, false, false, false, false, false, false],
38 | [false, false, false, true, true, true, false, false, false],
39 | [false, false, false, false, false, false, true, true, true],
40 | [true, false, false, true, false, false, true, false, false],
41 | [false, true, false, false, true, false, false, true, false],
42 | [false, false, true, false, false, true, false, false, true],
43 | [true, false, false, false, true, false, false, false, true],
44 | [false, false, true, false, true, false, true, false, false],
45 | ];
46 |
47 | export const useGameStore = defineStore('game', () => {
48 | const router = useRouter();
49 |
50 | const isPlayer1 = ref(false);
51 | const player1Name = ref('');
52 | const player1Connected = ref(false);
53 | const player1Ready = ref(false);
54 |
55 | const player2Name = ref('');
56 | const player2Connected = ref(false);
57 | const player2Ready = ref(false);
58 |
59 | const imReady = computed(() => (isPlayer1.value ? player1Ready.value : player2Ready.value));
60 |
61 | function initGameState(): Game {
62 | return {
63 | code: '',
64 | round: 1,
65 | roundStartTime: undefined,
66 | state: 'waiting-for-players',
67 | currentPlayer: 1,
68 | currentGoStart: undefined,
69 | currentGoEnd: undefined,
70 | roundWinners: [null, null, null, null, null],
71 | };
72 | }
73 |
74 | const game = reactive(initGameState());
75 |
76 | const grid = ref([]);
77 |
78 | const winningMask = computed(() => {
79 | // Test X
80 | const winningXMask = WIN_MASKS.find((mask) =>
81 | mask.every((shouldMatch, index) => (shouldMatch ? grid.value[index] === 'x' : true))
82 | );
83 |
84 | if (winningXMask) {
85 | return winningXMask;
86 | }
87 |
88 | // Test 0
89 | const winning0Mask = WIN_MASKS.find((mask) =>
90 | mask.every((shouldMatch, index) => (shouldMatch ? grid.value[index] === '0' : true))
91 | );
92 |
93 | if (winning0Mask) {
94 | return winning0Mask;
95 | }
96 |
97 | return null;
98 | });
99 |
100 | const channel = ref();
101 |
102 | /**
103 | * Broadcast the current game state to the
104 | * other player
105 | */
106 | async function broadcastState() {
107 | const payload = {
108 | game,
109 | grid: grid.value,
110 | };
111 |
112 | console.log('Broadcast state');
113 | console.log({ payload });
114 |
115 | await channel.value?.send({
116 | type: 'broadcast',
117 | event: 'state',
118 | payload,
119 | });
120 | }
121 |
122 | /**
123 | * Recieve game state from the other player and update our local state
124 | * @param state
125 | */
126 | function onReceiveState(state: { game: Game; grid: GameGrid }) {
127 | console.log('Receive state');
128 | console.log({ state });
129 | Object.assign(game, state.game);
130 | grid.value = state.grid;
131 | }
132 |
133 | /**
134 | * Conect to the supabase realtime channel for this game
135 | * and setup listeners for the events we are interested in
136 | */
137 | function connect() {
138 | channel.value = supabase.channel(game.code, {
139 | config: {
140 | presence: {
141 | key: isPlayer1.value ? 'player1' : 'player2',
142 | },
143 | },
144 | });
145 |
146 | channel.value.on('presence', { event: 'sync' }, () => {
147 | const presenceState: any = channel.value?.presenceState();
148 | console.log('Online users: ', channel.value?.presenceState());
149 | if (Object.keys(channel.value?.presenceState() as object).length === 2) {
150 | console.log({ name: presenceState.player1[0].name });
151 | player1Name.value = presenceState.player1[0].name;
152 | player2Name.value = presenceState.player2[0].name;
153 | game.state = 'instructions';
154 | router.push('/game');
155 | }
156 | });
157 |
158 | channel.value.on('presence', { event: 'join' }, ({ newPresences }) => {
159 | console.log('New users have joined: ', newPresences);
160 | });
161 |
162 | channel.value.on('presence', { event: 'leave' }, ({ leftPresences }) => {
163 | console.log('Users have left: ', leftPresences);
164 | game.state = 'disconnected';
165 | });
166 |
167 | channel.value.on('broadcast', { event: 'player-ready' }, ({ payload }) => {
168 | if (payload.player === 1) {
169 | player1Ready.value = true;
170 | router.push('/game');
171 | }
172 |
173 | if (payload.player === 2) {
174 | player2Ready.value = true;
175 | router.push('/game');
176 | }
177 | });
178 |
179 | // Listen for updates to the grid
180 | channel.value.on('broadcast', { event: 'grid' }, (event) => {
181 | console.log('Update grid');
182 | grid.value = event.payload.grid;
183 | console.log({ grid: grid.value });
184 | });
185 |
186 | // List for state broadcasts
187 | channel.value.on('broadcast', { event: 'state' }, (event) => {
188 | onReceiveState(event.payload);
189 | });
190 |
191 | channel.value.subscribe(async (status) => {
192 | if (status === 'SUBSCRIBED') {
193 | const stat = await channel.value?.track({
194 | online_at: new Date().toISOString(),
195 | name: isPlayer1.value ? player1Name.value : player2Name.value,
196 | });
197 | console.log(stat);
198 | // your callback function will now be called with the messages broadcast by the other client
199 | }
200 | });
201 | }
202 |
203 | /**
204 | * Disconnect from the current game
205 | */
206 | function disconnect() {
207 | channel.value?.unsubscribe();
208 | game.code = '';
209 | player1Name.value = '';
210 | player1Connected.value = false;
211 | player1Ready.value = false;
212 |
213 | player2Name.value = '';
214 | player2Connected.value = false;
215 | player2Ready.value = false;
216 |
217 | Object.assign(game, initGameState());
218 | grid.value = [];
219 | }
220 |
221 | /**
222 | * Create a new game. This sets the player 1 name and generates
223 | * a new random game code that can be used to connec to the
224 | * correct channel
225 | * @param name
226 | */
227 | function newGame(name: string) {
228 | player1Name.value = name;
229 | game.code = uniqueNamesGenerator({
230 | dictionaries: [adjectives, colors, animals],
231 | separator: '-',
232 | length: 3,
233 | });
234 | isPlayer1.value = true;
235 | router.push('/waiting');
236 | connect();
237 | }
238 |
239 | /**
240 | * Join an existing game. This will set the player 2 name
241 | * and connect them to the supabase realtime channel
242 | * @param name
243 | * @param joinGameCode
244 | */
245 | function joinGame(name: string, joinGameCode: string) {
246 | player2Name.value = name;
247 | game.code = joinGameCode.toLowerCase();
248 | connect();
249 | router.push('/game');
250 | }
251 |
252 | /**
253 | * Mark the player as ready. When both players are ready the game will start
254 | */
255 | async function ready() {
256 | if (isPlayer1.value) {
257 | player1Ready.value = true;
258 | const begin = performance.now();
259 | await channel.value?.send({
260 | type: 'broadcast',
261 | event: 'player-ready',
262 | payload: {
263 | player: 1,
264 | },
265 | });
266 | const end = performance.now();
267 | console.log(`Latency is ${end - begin} milliseconds`);
268 | } else {
269 | player2Ready.value = true;
270 | channel.value?.send({
271 | type: 'broadcast',
272 | event: 'player-ready',
273 | payload: {
274 | player: 2,
275 | },
276 | });
277 | }
278 | }
279 |
280 | watch([player1Ready, player2Ready], ([player1ReadyValue, player2ReadyValue]) => {
281 | if (isPlayer1.value && player1ReadyValue && player2ReadyValue) {
282 | console.log('Readyyyyy');
283 | game.state = 'countdown';
284 | game.roundStartTime = dayjs().add(5, 'seconds').toISOString();
285 | broadcastState();
286 | setTimeout(() => {
287 | game.state = 'game';
288 | game.currentGoStart = new Date().toISOString();
289 | game.currentGoEnd = dayjs().add(TURN_DURATION, 'milliseconds').toISOString();
290 | broadcastState();
291 | }, 5000);
292 | }
293 | });
294 |
295 | function nextRound() {
296 | grid.value = [];
297 | game.round += 1;
298 | game.currentPlayer = (2 - (game.round % 2)) as 1 | 2;
299 | game.roundStartTime = dayjs().add(5, 'seconds').toISOString();
300 | game.state = 'countdown';
301 | broadcastState();
302 | setTimeout(() => {
303 | game.state = 'game';
304 | game.currentGoStart = new Date().toISOString();
305 | game.currentGoEnd = dayjs().add(TURN_DURATION, 'milliseconds').toISOString();
306 | broadcastState();
307 | }, 5000);
308 | }
309 |
310 | function placeMarker(gridIndex: number) {
311 | // Check it is the current players turn
312 | if ((isPlayer1.value && game.currentPlayer !== 1) || (!isPlayer1.value && game.currentPlayer !== 2)) {
313 | return;
314 | }
315 |
316 | // Check we aren't placing our marker on an existing marker
317 | if (grid.value[gridIndex]) {
318 | console.warn('Cannot place marker here');
319 | return;
320 | }
321 |
322 | // Check someone hasn't already won
323 | if (winningMask.value) {
324 | return;
325 | }
326 |
327 | // Logic to check if its the players turn
328 | const marker = isPlayer1.value ? '0' : 'x';
329 | grid.value[gridIndex] = marker;
330 |
331 | // Test for a win
332 | const winner = WIN_MASKS.some((mask) =>
333 | mask.every((shouldMatch, index) => (shouldMatch ? grid.value[index] === marker : true))
334 | );
335 |
336 | if (winner) {
337 | console.log('Winner');
338 | game.roundWinners[game.round - 1] = isPlayer1.value ? 1 : 2;
339 |
340 | setTimeout(() => {
341 | game.state = 'round-winner';
342 | broadcastState();
343 |
344 | setTimeout(() => {
345 | // If we have had less than five rounds and no player has won 3 games, start the next round
346 | // otherwise show the game winner screen
347 | const player1Wins = game.roundWinners.filter((value) => value === 1).length;
348 | const player2Wins = game.roundWinners.filter((value) => value === 2).length;
349 |
350 | console.log({
351 | player1Wins,
352 | player2Wins,
353 | gameRound: game.round,
354 | });
355 | if (game.round < 5 && player1Wins < 3 && player2Wins < 3) {
356 | nextRound();
357 | } else {
358 | game.state = 'game-winner';
359 | broadcastState();
360 | }
361 | }, 5000);
362 | }, 2000);
363 | }
364 |
365 | // Test for a draw
366 | const usedGridCells = grid.value.filter((cell) => !!cell);
367 | if (usedGridCells.length === 9) {
368 | console.log('Draw');
369 | setTimeout(() => {
370 | game.state = 'round-winner';
371 | broadcastState();
372 |
373 | setTimeout(() => {
374 | if (game.round < 5) {
375 | nextRound();
376 | } else {
377 | game.state = 'game-winner';
378 | broadcastState();
379 | }
380 | }, 5000);
381 | }, 2000);
382 | }
383 |
384 | // Next go
385 | game.currentPlayer = game.currentPlayer === 1 ? 2 : 1;
386 | game.currentGoStart = new Date().toISOString();
387 | game.currentGoEnd = dayjs().add(TURN_DURATION, 'milliseconds').toISOString();
388 |
389 | broadcastState();
390 | }
391 |
392 | useIntervalFn(() => {
393 | const player = isPlayer1.value ? 1 : 2;
394 | if (game.state !== 'game' || game.currentPlayer !== player) {
395 | return;
396 | }
397 |
398 | // If our go has ended place a random marker
399 | const freeIndexes = [0, 1, 2, 3, 4, 5, 6, 7, 8].filter((index) => !grid.value[index]);
400 | if (dayjs().isAfter(dayjs(game.currentGoEnd))) {
401 | console.log('Missed turn playing randomly!');
402 | console.log({ freeIndexes });
403 | const randomIndex = freeIndexes[Math.floor(Math.random() * freeIndexes.length)];
404 | console.log({ randomIndex });
405 | placeMarker(randomIndex);
406 | }
407 | }, 200);
408 |
409 | return {
410 | isPlayer1,
411 | player1Name,
412 | player1Connected,
413 | player1Ready,
414 | player2Name,
415 | player2Connected,
416 | player2Ready,
417 | imReady,
418 | newGame,
419 | joinGame,
420 | disconnect,
421 | ready,
422 | // gameCode,
423 | // gameState,
424 | // gameRound,
425 | // gameRoundStartTime,
426 | // gameCurrentPlayer,
427 | // gameCurrentGoStart,
428 | // gameCurrentGoEnd,
429 | // gameRoundWinners,
430 | winningMask,
431 | // state,
432 | game,
433 | grid,
434 | placeMarker,
435 | };
436 | });
437 |
--------------------------------------------------------------------------------
/src/components/Logo.vue:
--------------------------------------------------------------------------------
1 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
146 |
147 |
152 |
157 |
162 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
185 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
219 |
220 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
242 |
243 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
265 |
266 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
288 |
289 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
311 |
312 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
334 |
335 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
362 |
--------------------------------------------------------------------------------
/src/components/GridGame.vue:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
67 |
73 |
74 |
75 |
84 |
85 |
92 |
93 |
94 |
95 |
96 |
97 |
106 |
115 |
124 |
133 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
331 |
332 |
333 |
342 |
343 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
365 |
366 |
367 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
388 |
389 |
395 |
396 |
397 |
398 |
399 |
400 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
422 |
423 |
429 |
430 |
431 |
432 |
433 |
434 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
456 |
457 |
463 |
464 |
465 |
466 |
467 |
468 |
474 |
475 |
476 |
477 |
478 |
479 |
480 |
481 |
490 |
491 |
497 |
498 |
499 |
500 |
501 |
502 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 |
525 |
531 |
537 |
538 |
544 |
550 |
556 |
557 |
563 |
569 |
575 |
576 |
577 |
578 |
579 |
585 |
--------------------------------------------------------------------------------