├── 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 | 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 | 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 | 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 | 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 | 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 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/Player.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/layouts/LayoutDefault.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/Score.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 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 | 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 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | 47 | -------------------------------------------------------------------------------- /src/components/GridCell.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 34 | 35 | 48 | -------------------------------------------------------------------------------- /src/components/modals/ModalCountdown.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/InputText.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 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 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/pages/PageAbout.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/pages/PageNewGame.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/pages/PageWaiting.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/pages/PageJoinGame.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/pages/PageGame.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 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 | 49 | -------------------------------------------------------------------------------- /src/pages/PageHome.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 26 | 27 | 91 | -------------------------------------------------------------------------------- /src/components/modals/ModalRoundGameWinner.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/components/TurnTimer.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 304 | 305 | 324 | -------------------------------------------------------------------------------- /src/components/modals/ModalRoundWinner.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 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 | 354 | 355 | 362 | -------------------------------------------------------------------------------- /src/components/GridGame.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 578 | 579 | 585 | --------------------------------------------------------------------------------