├── env.d.ts ├── src ├── Application │ ├── types.ts │ ├── AppStartedUsecase.ts │ ├── StopGameUsecase.ts │ ├── StartGameUsecase.ts │ └── OpenCardUsecase.ts ├── Presentation │ ├── view │ │ ├── assets │ │ │ ├── card │ │ │ │ ├── 01.jpg │ │ │ │ ├── 02.jpg │ │ │ │ ├── 03.jpg │ │ │ │ ├── 04.jpg │ │ │ │ ├── 05.jpg │ │ │ │ ├── 06.jpg │ │ │ │ ├── 07.jpg │ │ │ │ └── 08.jpg │ │ │ ├── github-mark.png │ │ │ ├── bg-8-bit-nature.jpg │ │ │ ├── logo.svg │ │ │ ├── main.css │ │ │ └── base.css │ │ ├── utils │ │ │ ├── get-image-url.ts │ │ │ └── theme.ts │ │ ├── router │ │ │ └── index.ts │ │ ├── scss │ │ │ └── main.scss │ │ ├── components │ │ │ ├── AppModal.vue │ │ │ ├── GameCard.vue │ │ │ └── AppHeader.vue │ │ └── pages │ │ │ └── PageHome.vue │ ├── presenter │ │ ├── GamePresenter.ts │ │ ├── ShowedCardsPresenter.ts │ │ ├── PairCardAttempListPresenter.ts │ │ └── CardPresenter.ts │ └── usecaseMap │ │ └── index.ts ├── Domain │ ├── Game.ts │ └── Card.ts ├── App.vue ├── Data │ ├── game.store.ts │ └── card.store.ts ├── Repositories │ ├── GameRepository.ts │ └── CardRepository.ts └── main.ts ├── public └── favicon.ico ├── .vscode └── extensions.json ├── tsconfig.vitest.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.app.json ├── vite.config.ts ├── .gitignore ├── .eslintrc.cjs ├── index.html ├── package.json └── README.md /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/Application/types.ts: -------------------------------------------------------------------------------- 1 | export interface Usecase { 2 | execute: () => unknown 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/01.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/02.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/03.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/04.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/05.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/06.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/06.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/07.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/07.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/card/08.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/card/08.jpg -------------------------------------------------------------------------------- /src/Presentation/view/assets/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/github-mark.png -------------------------------------------------------------------------------- /src/Presentation/view/assets/bg-8-bit-nature.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/attikos/ddd-vue-match-game/HEAD/src/Presentation/view/assets/bg-8-bit-nature.jpg -------------------------------------------------------------------------------- /src/Domain/Game.ts: -------------------------------------------------------------------------------- 1 | export enum GameStatus { 2 | inProgress, 3 | stopped, 4 | } 5 | 6 | export interface GameState { 7 | gameStatus: GameStatus 8 | } 9 | -------------------------------------------------------------------------------- /src/Presentation/view/utils/get-image-url.ts: -------------------------------------------------------------------------------- 1 | export function getImageUrl(name: string) { 2 | return new URL(`../assets/${name}`, import.meta.url).href 3 | } 4 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.config.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Presentation/view/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Presentation/presenter/GamePresenter.ts: -------------------------------------------------------------------------------- 1 | import { useGameStore } from '@/Data/game.store'; 2 | import { storeToRefs } from 'pinia'; 3 | import type { Ref } from 'vue'; 4 | import type { GameStatus } from '@/Domain/Game'; 5 | 6 | export function GamePresenter(): Ref { 7 | const { gameStatus } = storeToRefs(useGameStore()); 8 | 9 | return gameStatus; 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue from '@vitejs/plugin-vue'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | '@': fileURLToPath(new URL('./src', import.meta.url)), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/Presentation/presenter/ShowedCardsPresenter.ts: -------------------------------------------------------------------------------- 1 | import { useCardStore } from '@/Data/card.store'; 2 | import { storeToRefs } from 'pinia'; 3 | import type { Ref } from 'vue'; 4 | import type { ShowedCards } from '@/Domain/Card'; 5 | 6 | export function ShowedCardsPresenter(): Ref { 7 | const { showedCards } = storeToRefs(useCardStore()); 8 | 9 | return showedCards; 10 | } 11 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/Presentation/view/router/index.ts: -------------------------------------------------------------------------------- 1 | import PageHome from '@/Presentation/view/pages/PageHome.vue'; 2 | import { createRouter, createWebHashHistory } from 'vue-router' 3 | 4 | export const createAppRouter = () => { 5 | const routes = [{ path: '/', component: PageHome }]; 6 | 7 | const router = createRouter({ 8 | history: createWebHashHistory(), 9 | routes, 10 | }); 11 | 12 | return router; 13 | } 14 | -------------------------------------------------------------------------------- /src/Presentation/presenter/PairCardAttempListPresenter.ts: -------------------------------------------------------------------------------- 1 | import { useCardStore } from '@/Data/card.store'; 2 | import { storeToRefs } from 'pinia'; 3 | import type { Ref } from 'vue'; 4 | import type { PairCardAttempList } from '@/Domain/Card'; 5 | 6 | export function PairCardAttempListPresenter(): Ref { 7 | const { pairCardAttempList } = storeToRefs(useCardStore()); 8 | 9 | return pairCardAttempList; 10 | } 11 | -------------------------------------------------------------------------------- /src/Application/AppStartedUsecase.ts: -------------------------------------------------------------------------------- 1 | import type { Usecase } from '@/Application/types'; 2 | import type { CardRepository } from '@/Repositories/CardRepository'; 3 | import { cardImageAsset } from '@/Domain/Card'; 4 | 5 | export class AppStartedUsecase implements Usecase { 6 | constructor( 7 | private cardRepository: CardRepository, 8 | ) {} 9 | 10 | async execute(): Promise { 11 | this.cardRepository.setCardImageAsset(cardImageAsset); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Presentation/view/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 'cyan' | 'green' | 'blue' | 'purple' | 'red' | 'yellow'; 2 | 3 | export const setTheme = (theme: Theme) => { 4 | const htmpEl = document.getElementsByTagName('html')[0]; 5 | 6 | htmpEl.className = ''; 7 | htmpEl.classList.add(theme); 8 | window.localStorage.setItem('theme', theme); 9 | } 10 | 11 | export const getSavedTheme = (): Theme => { 12 | const theme = window.localStorage.getItem('theme') as Theme; 13 | 14 | return theme; 15 | } 16 | -------------------------------------------------------------------------------- /src/Presentation/presenter/CardPresenter.ts: -------------------------------------------------------------------------------- 1 | import { useCardStore } from '@/Data/card.store'; 2 | import { storeToRefs } from 'pinia'; 3 | import type { Ref } from 'vue'; 4 | 5 | export interface CardPresenter { 6 | cardImageAsset: Ref 7 | currentCards: Ref 8 | } 9 | 10 | export function CardPresenter(): CardPresenter { 11 | const { cardImageAsset, currentCards } = storeToRefs(useCardStore()); 12 | 13 | return { 14 | cardImageAsset, 15 | currentCards, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/Data/game.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import type { GameState } from '@/Domain/Game'; 3 | import { GameStatus } from '@/Domain/Game'; 4 | 5 | export const useGameStore = defineStore('game', { 6 | state: (): GameState => ({ 7 | gameStatus : GameStatus.inProgress, 8 | }), 9 | 10 | actions: { 11 | setGameStatus(gameStatus: GameStatus) { 12 | this.gameStatus = gameStatus; 13 | }, 14 | resetGameStatus() { 15 | this.gameStatus = GameStatus.stopped; 16 | }, 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/Repositories/GameRepository.ts: -------------------------------------------------------------------------------- 1 | import { useGameStore } from '@/Data/game.store'; 2 | import type { GameStatus } from '@/Domain/Game'; 3 | 4 | export class GameRepository { 5 | private _store: ReturnType; 6 | 7 | constructor() { 8 | this._store = useGameStore(); 9 | } 10 | 11 | get store() { 12 | return this._store; 13 | } 14 | 15 | setGameStatus(gameStatus: GameStatus) { 16 | this.store.setGameStatus(gameStatus); 17 | } 18 | 19 | resetGameStatus() { 20 | this.store.resetGameStatus(); 21 | } 22 | 23 | 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution'); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest', 13 | }, 14 | rules: { 15 | quotes : ['error', 'single'], 16 | 'vue/html-indent': ['error', 4], 17 | 'vue/multi-word-component-names': 0, 18 | '@typescript-eslint/indent': ['error', 4], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { createPinia } from 'pinia'; 3 | import { createAppRouter } from '@/Presentation/view/router'; 4 | import App from '@/App.vue'; 5 | import '@/Presentation/view/scss/main.scss'; 6 | import { mapUsecase } from '@/Presentation/usecaseMap'; 7 | 8 | const app = createApp(App); 9 | 10 | app.use(createPinia()); 11 | 12 | const router = createAppRouter(); 13 | app.use(router); 14 | 15 | router.isReady().then(async () => { 16 | const appStartedUsecase = mapUsecase('AppStartedUsecase'); 17 | 18 | await appStartedUsecase(); 19 | 20 | app.mount('#app'); 21 | }); 22 | -------------------------------------------------------------------------------- /src/Presentation/view/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | #app { 4 | max-width: 1280px; 5 | margin: 0 auto; 6 | padding: 2rem; 7 | 8 | font-weight: normal; 9 | } 10 | 11 | a, 12 | .green { 13 | text-decoration: none; 14 | color: hsla(160, 100%, 37%, 1); 15 | transition: 0.4s; 16 | } 17 | 18 | @media (hover: hover) { 19 | a:hover { 20 | background-color: hsla(160, 100%, 37%, 0.2); 21 | } 22 | } 23 | 24 | @media (min-width: 1024px) { 25 | body { 26 | display: flex; 27 | place-items: center; 28 | } 29 | 30 | #app { 31 | display: grid; 32 | grid-template-columns: 1fr 1fr; 33 | padding: 0 2rem; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Application/StopGameUsecase.ts: -------------------------------------------------------------------------------- 1 | import type { Usecase } from '@/Application/types'; 2 | import { GameStatus } from '@/Domain/Game'; 3 | import type { CardRepository } from '@/Repositories/CardRepository'; 4 | import type { GameRepository } from '@/Repositories/GameRepository'; 5 | 6 | export class StopGameUsecase implements Usecase { 7 | constructor( 8 | private cardRepository: CardRepository, 9 | private gameRepository: GameRepository 10 | ) {} 11 | 12 | async execute(): Promise { 13 | const STOP_GAME_ANIMATION_DELAY = 400; 14 | 15 | if (this.gameRepository.store.gameStatus === GameStatus.stopped) { 16 | return; 17 | } 18 | 19 | this.cardRepository.resetPairCardAttempList(); 20 | this.cardRepository.openAllShowedCards(); 21 | 22 | setTimeout(() => { 23 | this.gameRepository.setGameStatus(GameStatus.stopped); 24 | }, STOP_GAME_ANIMATION_DELAY) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Match Match Game 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Application/StartGameUsecase.ts: -------------------------------------------------------------------------------- 1 | import type { Usecase } from '@/Application/types'; 2 | import { getRandomGameCards } from '@/Domain/Card'; 3 | import { GameStatus } from '@/Domain/Game'; 4 | import type { CardRepository } from '@/Repositories/CardRepository'; 5 | import type { GameRepository } from '@/Repositories/GameRepository'; 6 | 7 | export class StartGameUsecase implements Usecase { 8 | constructor( 9 | private cardRepository: CardRepository, 10 | private gameRepository: GameRepository 11 | ) {} 12 | 13 | async execute(): Promise { 14 | const START_GAME_ANIMATION_DELAY = 400; 15 | const cardImageAsset = this.cardRepository.store.cardImageAsset; 16 | 17 | this.cardRepository.resetShowedCards(); 18 | this.cardRepository.resetPairCardAttempList(); 19 | 20 | setTimeout(() => { 21 | const randomCards = getRandomGameCards(cardImageAsset); 22 | this.cardRepository.setCurrentCards(randomCards); 23 | this.gameRepository.setGameStatus(GameStatus.inProgress); 24 | }, START_GAME_ANIMATION_DELAY) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Repositories/CardRepository.ts: -------------------------------------------------------------------------------- 1 | import { useCardStore } from '@/Data/card.store'; 2 | import type { PairCardAttempList, ShowedCards } from '@/Domain/Card'; 3 | 4 | export class CardRepository { 5 | private _store: ReturnType; 6 | 7 | constructor() { 8 | this._store = useCardStore(); 9 | } 10 | 11 | get store() { 12 | return this._store; 13 | } 14 | 15 | setCurrentCards(cards: string[]) { 16 | this.store.setCurrentCards(cards); 17 | } 18 | 19 | setCardImageAsset(cards: string[]) { 20 | this.store.setCardImageAsset(cards); 21 | } 22 | 23 | setShowedCards(cards: ShowedCards) { 24 | this.store.setShowedCards(cards); 25 | } 26 | 27 | addShowedCards(cards: ShowedCards) { 28 | this.store.addShowedCards(cards); 29 | } 30 | 31 | openAllShowedCards() { 32 | this.store.openAllShowedCards(); 33 | } 34 | 35 | resetShowedCards() { 36 | this.store.resetShowedCards(); 37 | } 38 | 39 | setPairCardAttempList(attemps: PairCardAttempList) { 40 | this.store.setPairCardAttempList(attemps); 41 | } 42 | 43 | resetPairCardAttempList() { 44 | this.store.resetPairCardAttempList(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-demo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "run-p type-check build-only", 8 | "preview": "vite preview", 9 | "test:unit": "vitest --environment jsdom --root src/", 10 | "build-only": "vite build", 11 | "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", 12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 13 | }, 14 | "dependencies": { 15 | "pinia": "^2.0.28", 16 | "vue": "^3.2.45" 17 | }, 18 | "devDependencies": { 19 | "@rushstack/eslint-patch": "^1.1.4", 20 | "@types/jsdom": "^20.0.1", 21 | "@types/node": "^18.11.12", 22 | "@vitejs/plugin-vue": "^4.0.0", 23 | "@vue/eslint-config-typescript": "^11.0.0", 24 | "@vue/test-utils": "^2.2.6", 25 | "@vue/tsconfig": "^0.1.3", 26 | "eslint": "^8.22.0", 27 | "eslint-plugin-vue": "^9.3.0", 28 | "jsdom": "^20.0.3", 29 | "npm-run-all": "^4.1.5", 30 | "sass": "^1.57.1", 31 | "typescript": "~4.7.4", 32 | "vite": "^4.0.0", 33 | "vitest": "^0.25.6", 34 | "vue-router": "^4.1.6", 35 | "vue-tsc": "^1.0.12" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean code. Domain Driven Design with Vue 3 2 | Used: Typescript, Vue 3, Pinia 3 | 4 | ### DEMO 5 | https://vuegit.ru 6 | 7 | ### Layers 8 | Flow of controls: 9 | 10 | ``` 11 | USER 12 | ↓ 13 | UI (Vue) ← Presentor 14 | ↓ ↑ 15 | (entities) (usecases) (State, Pinia) 16 | Domain → Application Data 17 | ↓ ↓ ↑ 18 | Adapters (Driven) → Repository 19 | ↓ 20 | Infrastructure ← API, etc. 21 | ``` 22 | 23 | Dependencies: 24 | ``` 25 | USER 26 | ↓ 27 | UI (Vue) ← Presentor 28 | ↓ ↑ 29 | (entities) (usecases) (State, Pinia) 30 | Domain ← Application Data 31 | ↑ ↓ 32 | Adapters (Driven) ← Repository 33 | ↑ 34 | Infrastructure ← API, etc. 35 | ``` 36 | 37 | ## Project Setup 38 | 39 | ```sh 40 | npm install 41 | ``` 42 | 43 | Dev mode: 44 | ```sh 45 | npm run dev 46 | ``` 47 | Build: 48 | ```sh 49 | npm run build 50 | ``` 51 | 52 | # Links 53 | https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/ 54 | -------------------------------------------------------------------------------- /src/Data/card.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import type { CardState, PairCardAttempList, ShowedCards } from '@/Domain/Card'; 3 | 4 | export const useCardStore = defineStore('card', { 5 | state: (): CardState => ({ 6 | cardImageAsset : [], 7 | currentCards : [], 8 | showedCards: {}, 9 | pairCardAttempList: [], 10 | }), 11 | 12 | actions: { 13 | setCurrentCards(urls: string[]) { 14 | this.currentCards = urls; 15 | }, 16 | setCardImageAsset(urls: string[]) { 17 | this.cardImageAsset = urls; 18 | }, 19 | setShowedCards(cards: ShowedCards) { 20 | this.showedCards = cards; 21 | }, 22 | addShowedCards(cards: ShowedCards) { 23 | this.$patch({ showedCards : cards }); 24 | }, 25 | openAllShowedCards() { 26 | const result: ShowedCards = {}; 27 | 28 | this.currentCards.forEach((_, index) => { 29 | result[index] = true; 30 | }); 31 | 32 | this.showedCards = result; 33 | }, 34 | resetShowedCards() { 35 | this.showedCards = {}; 36 | }, 37 | setPairCardAttempList(attemps: PairCardAttempList) { 38 | this.pairCardAttempList = attemps; 39 | }, 40 | resetPairCardAttempList() { 41 | this.pairCardAttempList = []; 42 | }, 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /src/Presentation/usecaseMap/index.ts: -------------------------------------------------------------------------------- 1 | import { CardRepository } from '@/Repositories/CardRepository'; 2 | import { AppStartedUsecase } from '@/Application/AppStartedUsecase'; 3 | import { StartGameUsecase } from '@/Application/StartGameUsecase'; 4 | import { OpenCardUsecase } from '@/Application/OpenCardUsecase'; 5 | import { GameRepository } from '@/Repositories/GameRepository'; 6 | import { StopGameUsecase } from '@/Application/StopGameUsecase'; 7 | 8 | const usecaseMapping = { 9 | AppStartedUsecase: (): Promise => { 10 | const usecase = new AppStartedUsecase( 11 | new CardRepository(), 12 | ); 13 | 14 | return usecase.execute(); 15 | }, 16 | 17 | StartGameUsecase: (): Promise => { 18 | const usecase = new StartGameUsecase( 19 | new CardRepository(), 20 | new GameRepository(), 21 | ); 22 | 23 | return usecase.execute(); 24 | }, 25 | 26 | StopGameUsecase: (): Promise => { 27 | const usecase = new StopGameUsecase( 28 | new CardRepository(), 29 | new GameRepository(), 30 | ); 31 | 32 | return usecase.execute(); 33 | }, 34 | 35 | OpenCardUsecase: (index: number, showWonNotification: () => void): Promise => { 36 | const usecase = new OpenCardUsecase( 37 | index, 38 | new CardRepository(), 39 | new GameRepository(), 40 | showWonNotification, 41 | ); 42 | 43 | return usecase.execute(); 44 | } 45 | } 46 | 47 | export const mapUsecase = (usecase: T) => { 48 | return usecaseMapping[usecase]; 49 | }; 50 | -------------------------------------------------------------------------------- /src/Presentation/view/scss/main.scss: -------------------------------------------------------------------------------- 1 | @mixin theme-color($name, $hue) { 2 | --hue-#{"" + $name}: #{$hue}; 3 | --color-#{"" + $name}: #{'hsl(var(--hue-#{$name}), 71%, 46%)'}; 4 | 5 | &.#{"" + $name} { 6 | --primary-hue: var(--hue-#{$name}); 7 | } 8 | } 9 | 10 | html { 11 | &:root { 12 | --text-color: #444; 13 | --border-color: #999; 14 | --main-bg-color: #b1ddf8; 15 | --color-shaddow-first: #ff98fc; 16 | --color-shaddow-second: #5af7ff; 17 | --primary-hue: 58deg; 18 | --primary-color: hsl(var(--primary-hue), 72%, 44%); 19 | --secondary-color: hsl(var(--primary-hue), 70%, 60%); 20 | --button-text-color: #fff; 21 | --button-bg-color: var(--primary-color); 22 | --button-text-color-hover: #ddd; 23 | --button-text-color-active: #ccc; 24 | 25 | @include theme-color(cyan, 180deg); 26 | @include theme-color(green, 115deg); 27 | @include theme-color(blue, 200deg); 28 | @include theme-color(purple, 280deg); 29 | @include theme-color(red, 5deg); 30 | @include theme-color(yellow, 58deg); 31 | } 32 | } 33 | 34 | body { 35 | font-family: MS Sans Serif, Geneva, sans-serif; 36 | padding: 0; 37 | margin: 0; 38 | background: url(../assets/bg-8-bit-nature.jpg) no-repeat center; 39 | background-color: var(--main-bg-color); 40 | background-size: cover; 41 | } 42 | 43 | .btn { 44 | color: var(--button-text-color); 45 | background: var(--primary-color); 46 | padding: 6px 12px; 47 | font-size: 16px; 48 | cursor: pointer; 49 | border: 1px solid var(--border-color); 50 | display: inline-block; 51 | min-width: 96px; 52 | 53 | &:hover { 54 | color: var(--button-text-color-hover); 55 | } 56 | 57 | &:active { 58 | color: var(--button-text-color-active); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Presentation/view/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | :root { 3 | --vt-c-white: #ffffff; 4 | --vt-c-white-soft: #f8f8f8; 5 | --vt-c-white-mute: #f2f2f2; 6 | 7 | --vt-c-black: #181818; 8 | --vt-c-black-soft: #222222; 9 | --vt-c-black-mute: #282828; 10 | 11 | --vt-c-indigo: #2c3e50; 12 | 13 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 14 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 15 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 16 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 17 | 18 | --vt-c-text-light-1: var(--vt-c-indigo); 19 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 20 | --vt-c-text-dark-1: var(--vt-c-white); 21 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 22 | } 23 | 24 | /* semantic color variables for this project */ 25 | :root { 26 | --color-background: var(--vt-c-white); 27 | --color-background-soft: var(--vt-c-white-soft); 28 | --color-background-mute: var(--vt-c-white-mute); 29 | 30 | --color-border: var(--vt-c-divider-light-2); 31 | --color-border-hover: var(--vt-c-divider-light-1); 32 | 33 | --color-heading: var(--vt-c-text-light-1); 34 | --color-text: var(--vt-c-text-light-1); 35 | 36 | --section-gap: 160px; 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --color-background: var(--vt-c-black); 42 | --color-background-soft: var(--vt-c-black-soft); 43 | --color-background-mute: var(--vt-c-black-mute); 44 | 45 | --color-border: var(--vt-c-divider-dark-2); 46 | --color-border-hover: var(--vt-c-divider-dark-1); 47 | 48 | --color-heading: var(--vt-c-text-dark-1); 49 | --color-text: var(--vt-c-text-dark-2); 50 | } 51 | } 52 | 53 | *, 54 | *::before, 55 | *::after { 56 | box-sizing: border-box; 57 | margin: 0; 58 | position: relative; 59 | font-weight: normal; 60 | } 61 | 62 | body { 63 | min-height: 100vh; 64 | color: var(--color-text); 65 | background: var(--color-background); 66 | transition: color 0.5s, background-color 0.5s; 67 | line-height: 1.6; 68 | font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, 69 | Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 70 | font-size: 15px; 71 | text-rendering: optimizeLegibility; 72 | -webkit-font-smoothing: antialiased; 73 | -moz-osx-font-smoothing: grayscale; 74 | } 75 | -------------------------------------------------------------------------------- /src/Domain/Card.ts: -------------------------------------------------------------------------------- 1 | export const cardImageAsset = [ 2 | 'card/01.jpg', 3 | 'card/02.jpg', 4 | 'card/03.jpg', 5 | 'card/04.jpg', 6 | 'card/05.jpg', 7 | 'card/06.jpg', 8 | 'card/07.jpg', 9 | 'card/08.jpg', 10 | ]; 11 | 12 | export interface PairCardAttemp { 13 | index: number 14 | id: string 15 | } 16 | 17 | export type PairCardAttempList = [PairCardAttemp?, PairCardAttemp?]; 18 | 19 | export interface ShowedCards { 20 | [key: number]: boolean 21 | } 22 | 23 | export type CurrentCard = string; 24 | export type CardImage = string; 25 | export type CardImageAsset = CardImage[]; 26 | 27 | export interface CardState { 28 | cardImageAsset: CardImageAsset 29 | currentCards: CurrentCard[] 30 | showedCards: ShowedCards 31 | pairCardAttempList: PairCardAttempList 32 | } 33 | 34 | export function checkPairCards(pairCardAttemp: PairCardAttempList): boolean { 35 | return pairCardAttemp[0]?.id === pairCardAttemp[1]?.id; 36 | } 37 | 38 | export function removeAttempsCards( 39 | showedCardList: ShowedCards, 40 | pairCards: PairCardAttempList 41 | ): ShowedCards { 42 | const result = { ...showedCardList }; 43 | 44 | pairCards.forEach(card => { 45 | if (card && typeof card.index === 'number') { 46 | delete result[card.index]; 47 | } 48 | }); 49 | 50 | return result; 51 | } 52 | 53 | export function checkIsShowedCard (showedCards: ShowedCards, index: number): boolean { 54 | return !!showedCards[index]; 55 | } 56 | 57 | export function addCardAttemps( 58 | pairCardAttempList: PairCardAttempList, 59 | currentCards: CurrentCard[], 60 | index: number 61 | ): PairCardAttempList { 62 | const result : PairCardAttempList = []; 63 | pairCardAttempList.forEach(val => result.push(Object.assign({}, val))); 64 | 65 | if (result.length >= 2) { 66 | return result; 67 | } 68 | 69 | result.push({ 70 | index, 71 | id: currentCards[index] 72 | }); 73 | 74 | return result; 75 | } 76 | 77 | export function checkIsFinish(showedCardList: ShowedCards, currentCards: CurrentCard[]): boolean { 78 | return Object.keys(showedCardList).length === currentCards.length; 79 | } 80 | 81 | export function getRandomGameCards(cardImageAsset: CardImageAsset) { 82 | return [...cardImageAsset, ...cardImageAsset].sort(() => (Math.random() > 0.5) ? 1 : -1); 83 | } 84 | -------------------------------------------------------------------------------- /src/Presentation/view/components/AppModal.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | 33 | 101 | -------------------------------------------------------------------------------- /src/Application/OpenCardUsecase.ts: -------------------------------------------------------------------------------- 1 | import type { Usecase } from '@/Application/types'; 2 | import { addCardAttemps, checkPairCards, checkIsFinish, removeAttempsCards, checkIsShowedCard } from '@/Domain/Card'; 3 | import { GameStatus } from '@/Domain/Game'; 4 | import type { CardRepository } from '@/Repositories/CardRepository'; 5 | import type { GameRepository } from '@/Repositories/GameRepository'; 6 | 7 | export class OpenCardUsecase implements Usecase { 8 | constructor( 9 | private index: number, 10 | private cardRepository: CardRepository, 11 | private gameRepository: GameRepository, 12 | private showWonNotification: () => void, 13 | ) {} 14 | 15 | async execute(): Promise { 16 | const DELAY_TO_CLOSE = 1000; 17 | const resetPairAttemps = () => this.cardRepository.setPairCardAttempList([]); 18 | const { showedCards } = this.cardRepository.store; 19 | 20 | if (checkIsShowedCard(showedCards, this.index)) { 21 | return; 22 | } 23 | 24 | let { pairCardAttempList } = this.cardRepository.store; 25 | 26 | if (pairCardAttempList.length >= 2) { 27 | return; 28 | } 29 | 30 | const { currentCards } = this.cardRepository.store; 31 | 32 | const cardAttemps = addCardAttemps( 33 | pairCardAttempList, 34 | currentCards, 35 | this.index 36 | ); 37 | 38 | this.cardRepository.setPairCardAttempList(cardAttemps); 39 | pairCardAttempList = this.cardRepository.store.pairCardAttempList; 40 | 41 | this.cardRepository.addShowedCards({ [this.index] : true }); 42 | 43 | if (pairCardAttempList.length === 2) { 44 | let newShowedCards = this.cardRepository.store.showedCards; 45 | 46 | if (!checkPairCards(pairCardAttempList)) { 47 | newShowedCards = removeAttempsCards( 48 | showedCards, 49 | pairCardAttempList 50 | ); 51 | } 52 | else { 53 | const { currentCards } = this.cardRepository.store; 54 | 55 | const isFinishedGame = checkIsFinish( 56 | showedCards, 57 | currentCards 58 | ); 59 | 60 | if (isFinishedGame) { 61 | this.cardRepository.setShowedCards(newShowedCards); 62 | this.showWonNotification(); 63 | this.gameRepository.setGameStatus(GameStatus.stopped); 64 | } 65 | } 66 | 67 | setTimeout(() => { 68 | const { gameStatus } = this.gameRepository.store; 69 | 70 | if (gameStatus === GameStatus.stopped) { 71 | return; 72 | } 73 | 74 | this.cardRepository.setShowedCards(newShowedCards); 75 | 76 | resetPairAttemps(); 77 | }, DELAY_TO_CLOSE); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Presentation/view/components/GameCard.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 48 | 49 | 123 | -------------------------------------------------------------------------------- /src/Presentation/view/pages/PageHome.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 67 | 68 | 109 | -------------------------------------------------------------------------------- /src/Presentation/view/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 65 | 66 | 195 | --------------------------------------------------------------------------------