├── .editorconfig ├── .env.development ├── .env.production ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── choose.mp3 ├── error.mp3 ├── fail.mp3 ├── favicon.ico ├── over.mp3 └── success.mp3 ├── res ├── custom.png ├── game.png ├── homepage.png ├── logo.svg ├── store-1.png └── store-2.png ├── shims └── globals.d.ts ├── src ├── App.vue ├── assets │ └── merchant.png ├── components │ ├── Button.vue │ ├── Grid.vue │ ├── GridItem.vue │ ├── Increase.vue │ ├── Input.vue │ ├── LevelTag.vue │ ├── NavHeader.vue │ ├── PropsBag.vue │ ├── Select.vue │ ├── Switch.vue │ └── ToggleDark.vue ├── composables │ ├── use-checked-blocks.ts │ ├── use-countdown.ts │ ├── use-delay-loading.ts │ ├── use-game-score.ts │ ├── use-game-sounds.ts │ ├── use-game-status.ts │ ├── use-i18n.ts │ ├── use-lazy-show.ts │ ├── use-local-cache.ts │ ├── use-lru.ts │ ├── use-range-random.ts │ ├── use-set-query-params.ts │ └── use-toast.ts ├── config │ ├── game.ts │ ├── goods.ts │ └── theme.ts ├── locale │ ├── en-US.json │ ├── ja-JP.json │ └── zh-CN.json ├── main.ts ├── router │ ├── index.ts │ └── plugins │ │ └── view-transition.ts ├── styles │ ├── global.css │ ├── index.ts │ ├── reset.css │ └── toast.scss ├── utils │ ├── shared │ │ └── index.ts │ └── transform │ │ ├── format.ts │ │ └── http.ts └── views │ ├── game │ └── [level].vue │ ├── index.vue │ ├── record │ ├── [date].vue │ └── index.vue │ ├── settings │ └── custom.vue │ └── store │ └── index.vue ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=http://127.0.0.1:3335 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=http://127.0.0.1:3335 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # workflow for deploying static content to GitHub Pages 2 | name: ci 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Checkout pnpm 34 | uses: pnpm/action-setup@v2.0.1 35 | with: 36 | version: 7.13.4 37 | - name: Install 38 | run: pnpm install 39 | - name: Build 40 | run: pnpm run build 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v2 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v1 45 | with: 46 | # Upload entire repository 47 | path: './dist' 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v1 51 | -------------------------------------------------------------------------------- /.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 | **/components.d.ts 17 | **/auto-imports.d.ts 18 | 19 | 20 | /cypress/videos/ 21 | /cypress/screenshots/ 22 | 23 | # Editor directories and files 24 | !.vscode/extensions.json 25 | .idea 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # registry=https://registry.npmjs.org/ 2 | strict-peer-dependencies=false 3 | prefer-frozen-lockfile=true 4 | #use-node-version=16.16.0 5 | shamefully-hoist=true 6 | prefer-offline=true 7 | # 禁用已安装的文件检查 8 | verify-store-integrity=false 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:5173", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | 5 | // Disable the default formatter, use eslint instead 6 | "prettier.enable": false, 7 | "editor.formatOnSave": false, 8 | 9 | // Auto fix 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit", 12 | "source.organizeImports": "never" 13 | }, 14 | 15 | "tailwindCSS.classAttributes": [ 16 | "class", 17 | "className", 18 | "ngClass", 19 | "ui" 20 | ], 21 | 22 | // Silent the stylistic rules in you IDE, but still auto fix them 23 | "eslint.rules.customizations": [ 24 | // { "rule": "style/*", "severity": "off" }, 25 | // { "rule": "*-indent", "severity": "off" }, 26 | // { "rule": "*-spacing", "severity": "off" }, 27 | // { "rule": "*-spaces", "severity": "off" }, 28 | // { "rule": "*-order", "severity": "off" }, 29 | // { "rule": "*-dangle", "severity": "off" }, 30 | // { "rule": "*-newline", "severity": "off" }, 31 | // { "rule": "*quotes", "severity": "off" }, 32 | // { "rule": "*semi", "severity": "off" } 33 | ], 34 | 35 | // Enable eslint for all supported languages 36 | "eslint.validate": [ 37 | "javascript", 38 | "javascriptreact", 39 | "typescript", 40 | "typescriptreact", 41 | "vue", 42 | "html", 43 | "markdown", 44 | "json", 45 | "jsonc", 46 | "yaml" 47 | ], 48 | "cSpell.words": [ 49 | "clsx", 50 | "lightningcss", 51 | "localforage" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |

Memory Block(记忆方块)

5 |
6 | 7 | --- 8 | 9 | ## 截图 10 |
11 | 主界面 12 | 练习模式设置 13 | 游戏界面 14 | 商店 15 | 商人 16 |
17 | 18 | ## 在线游玩 19 | 你现在就可以立马玩到这款有趣的 [Memory Block](https://libondev.github.io/memory-block/) 小游戏 20 | 21 | ## 游戏规则 22 | - 选择游戏难度 23 | - 根据游戏难度的不同, 在每关开始前会有不同的记忆时间 24 | - 记忆时间结束后, 在网格中点击高亮过的方块 25 | - 点选完成后如果方块正确, 得分增加, 并重新生成方块 26 | - 得分初始倍率为 10, 每隔 5 秒倍率减 1, 直到倍率为 1, 不同难度还会有额外得分倍率加成 27 | 28 | ## 功能 29 | - [x] 基本流程 30 | - [x] 难度选择 31 | - [x] 计分系统 32 | - [x] 计时系统 33 | - [x] 数量展示 34 | - [x] 容错机制 35 | - [x] 练习模式 36 | - [x] 历史最高分 37 | - [x] 表盘自适应 38 | - [ ] 游玩热力图 39 | - [x] 本地缓存 40 | - [ ] 彩色方块 41 | - [x] 游戏音效 42 | - [x] 游戏界面增加键盘操作 43 | - [x] i18n 44 | - [x] 积分系统 45 | - [x] 道具兑换 46 | - [x] 道具系统 47 | - [x] 道具商人 48 | 49 | ### 后续功能 50 | 一些想做但不确定会不会做的功能 51 | - [ ] 排行榜 52 | - [ ] 联机模式 53 | 54 | ## 补充 55 | - 图标来自于:[iconify](https://iconify.design/),同时使用了 [icones](https://icones.js.org/) 网站来预览图 56 | - 有趣的游戏音效来自:[Duolingo](https://www.duolingo.com/),因为我也是个Duolingo软件的忠实用户,很喜欢TA的音效设计。PS:请原谅我未经允许的使用😜 57 | - 项目技术栈为:Vue3 + Vite5 + TypeScript + TailwindCSS + VueRouter + VueUse 58 | 59 | 119 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "style": "new-york", 3 | "typescript": true, 4 | "tailwind": { 5 | "config": "tailwind.config.js", 6 | "css": "src/styles/global.css", 7 | "baseColor": "slate", 8 | "cssVariables": true 9 | }, 10 | "framework": "vue", 11 | "aliases": { 12 | "components": "@/components", 13 | "utils": "@/utils/cls" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu( 4 | { jsonc: false, markdown: false, yaml: false }, 5 | { 6 | ignores: ['.DS_Store', '*.d.ts'], 7 | rules: { 8 | // 'sort-keys/sort-keys-fix': 'error', 9 | 'curly': ['error', 'multi-line', 'consistent'], 10 | 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], 11 | 'vue/custom-event-name-casing': ['error', 'kebab-case'], 12 | 'vue/max-attributes-per-line': ['error', { multiline: 1, singleline: 5 }], 13 | }, 14 | }, 15 | // { 16 | // files: ['**/*.ts'], 17 | // rules: {} 18 | // }, 19 | ) 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Memory Block(记忆方块) 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memory-block", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "private": true, 6 | "packageManager": "pnpm@8.15.4", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "build-check": "run-p type-check build", 12 | "type-check": "vue-tsc --noEmit --composite false", 13 | "lint": "eslint src", 14 | "lint:fix": "eslint src --fix" 15 | }, 16 | "dependencies": { 17 | "@headlessui/vue": "^1.7.19", 18 | "@iconify-json/game-icons": "^1.1.7", 19 | "@vueuse/core": "^10.9.0", 20 | "canvas-confetti": "^1.9.2", 21 | "date-fns": "^3.5.0", 22 | "localforage": "^1.10.0", 23 | "pinia": "^2.1.7", 24 | "ts-pattern": "^5.0.8", 25 | "vue": "^3.4.21", 26 | "vue-router": "^4.3.0" 27 | }, 28 | "devDependencies": { 29 | "@antfu/eslint-config": "^2.8.3", 30 | "@egoist/tailwindcss-icons": "^1.7.4", 31 | "@iconify-json/carbon": "^1.1.31", 32 | "@iconify-json/solar": "^1.1.9", 33 | "@types/canvas-confetti": "^1.6.4", 34 | "@types/node": "^20.11.28", 35 | "@vitejs/plugin-vue": "^5.0.4", 36 | "@vitejs/plugin-vue-jsx": "^3.1.0", 37 | "autoprefixer": "^10.4.18", 38 | "eslint": "^8.57.0", 39 | "lightningcss": "^1.24.1", 40 | "npm-run-all": "^4.1.5", 41 | "postcss": "^8.4.35", 42 | "sass": "^1.72.0", 43 | "tailwindcss": "^3.4.1", 44 | "tailwindcss-animate": "^1.0.7", 45 | "typescript": "~5.4.2", 46 | "unplugin-auto-import": "^0.17.5", 47 | "unplugin-vue-components": "^0.26.0", 48 | "vite": "^5.1.7", 49 | "vite-plugin-pages": "^0.32.0", 50 | "vue-tsc": "^1.8.27" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | autoprefixer: {}, 4 | tailwindcss: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/choose.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/choose.mp3 -------------------------------------------------------------------------------- /public/error.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/error.mp3 -------------------------------------------------------------------------------- /public/fail.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/fail.mp3 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/favicon.ico -------------------------------------------------------------------------------- /public/over.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/over.mp3 -------------------------------------------------------------------------------- /public/success.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/public/success.mp3 -------------------------------------------------------------------------------- /res/custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/res/custom.png -------------------------------------------------------------------------------- /res/game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/res/game.png -------------------------------------------------------------------------------- /res/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/res/homepage.png -------------------------------------------------------------------------------- /res/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /res/store-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/res/store-1.png -------------------------------------------------------------------------------- /res/store-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/res/store-2.png -------------------------------------------------------------------------------- /shims/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | export { } 6 | 7 | declare global { 8 | interface Document { 9 | startViewTransition?: (callback: () => Promise | void) => { 10 | finished: Promise 11 | updateCallbackDone: Promise 12 | ready: Promise 13 | } 14 | } 15 | 16 | interface MatcherResult { 17 | input: Input 18 | state: { 19 | matched: boolean 20 | value: Value 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /src/assets/merchant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/src/assets/merchant.png -------------------------------------------------------------------------------- /src/components/Button.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 39 | -------------------------------------------------------------------------------- /src/components/Grid.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | -------------------------------------------------------------------------------- /src/components/GridItem.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 40 | 41 | 131 | -------------------------------------------------------------------------------- /src/components/Increase.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 70 | 71 | 82 | -------------------------------------------------------------------------------- /src/components/Input.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/components/LevelTag.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /src/components/NavHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 47 | -------------------------------------------------------------------------------- /src/components/PropsBag.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 80 | -------------------------------------------------------------------------------- /src/components/Select.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 60 | -------------------------------------------------------------------------------- /src/components/Switch.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/ToggleDark.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/composables/use-checked-blocks.ts: -------------------------------------------------------------------------------- 1 | export function useCheckedBlocks(blocks: Ref>) { 2 | const allBlocks = [] as HTMLInputElement[] 3 | const missBlocks = [] as HTMLElement[] 4 | const wrongBlocks = [] as HTMLElement[] 5 | 6 | // 当前选中的 block 数量 7 | const checkedNumber = shallowRef(0) 8 | 9 | /** 10 | * 获取页面中所有的 block 11 | */ 12 | function getAllBlocks() { 13 | const blocks = document.querySelectorAll( 14 | 'input.peer[type="checkbox"]', 15 | ) 16 | 17 | allBlocks.push(...blocks) 18 | } 19 | 20 | /** 21 | * 标记所有漏选的 block 22 | */ 23 | function markAllMissBlocks() { 24 | allBlocks.forEach((block) => { 25 | // 本该选中但是却没有选中 26 | if (blocks.value.has(block.dataset.axis!) && !block.checked) { 27 | const el = block.nextSibling as HTMLElement 28 | 29 | el.classList.add('!text-yellow-500', 'i-carbon-warning') 30 | missBlocks.push(el) 31 | } 32 | }) 33 | } 34 | 35 | /** 36 | * 标记所有选错的 block 37 | */ 38 | function markAllWrongBlocks() { 39 | const checkedBlocks = getAllCheckedBlocks() 40 | 41 | checkedBlocks.forEach((block) => { 42 | if (!blocks.value.has(block.dataset.axis!)) { 43 | // 如果选错了, 则收集起来, 再重置的时候需要删除状态 44 | const el = block.nextSibling as HTMLElement 45 | 46 | el.classList.add('!text-red-500', 'i-carbon-close-large') 47 | wrongBlocks.push(el) 48 | } 49 | }) 50 | } 51 | 52 | /** 53 | * 取消选择左右选中的 block 54 | */ 55 | function uncheckAllBlocks() { 56 | allBlocks.forEach((block) => { 57 | block.checked = false 58 | }) 59 | 60 | setCheckedNumber(0) 61 | } 62 | 63 | /** 64 | * 取消选择所有漏选的 65 | */ 66 | function uncheckMissBlocks() { 67 | missBlocks.forEach((el) => { 68 | el.classList.remove('!text-yellow-500', 'i-carbon-warning') 69 | }) 70 | 71 | missBlocks.splice(0) 72 | } 73 | 74 | /** 75 | * 取消选择所有选错的 block 76 | */ 77 | function uncheckWrongBlocks() { 78 | wrongBlocks.forEach((el) => { 79 | el.classList.remove('!text-red-500', 'i-carbon-close-large') 80 | }) 81 | 82 | wrongBlocks.splice(0) 83 | } 84 | 85 | /** 86 | * 选中所有目标 block 87 | */ 88 | function checkedTargetBlock() { 89 | allBlocks.forEach((block) => { 90 | if (blocks.value.has(block.dataset.axis!)) { 91 | block.checked = true 92 | } 93 | }) 94 | } 95 | 96 | /** 97 | * 获取当前所有选中的 block 98 | */ 99 | function getAllCheckedBlocks() { 100 | return allBlocks.filter(block => block.checked) 101 | } 102 | 103 | /** 104 | * 匹配所有选中的是否达成胜利条件 105 | */ 106 | function getAllCheckedResult(ignoreErrorProp: Ref) { 107 | const checkedBlocks = getAllCheckedBlocks() 108 | 109 | // 如果道具的数量 110 | if (ignoreErrorProp.value > 0) { 111 | ignoreErrorProp.value -= 1 112 | 113 | return { 114 | matched: true, 115 | blocks: checkedBlocks, 116 | } 117 | } 118 | 119 | // 如果选中的数量和生成的数量不相等 120 | if (checkedBlocks.length !== blocks.value.size) { 121 | return { 122 | matched: false, 123 | blocks: checkedBlocks, 124 | } 125 | } 126 | 127 | // 检查所有选中的块的匹配状态 128 | return { 129 | matched: checkedBlocks.every(block => blocks.value.has(block.dataset.axis!)), 130 | blocks: checkedBlocks, 131 | } 132 | } 133 | 134 | function setCheckedNumber(counts: number) { 135 | checkedNumber.value = counts 136 | } 137 | 138 | onMounted(() => { 139 | getAllBlocks() 140 | }) 141 | 142 | onBeforeUnmount(() => { 143 | allBlocks.splice(0) 144 | missBlocks.splice(0) 145 | wrongBlocks.splice(0) 146 | }) 147 | 148 | return { 149 | checkedNumber, 150 | 151 | setCheckedNumber, 152 | uncheckAllBlocks, 153 | checkedTargetBlock, 154 | markAllMissBlocks, 155 | uncheckMissBlocks, 156 | markAllWrongBlocks, 157 | uncheckWrongBlocks, 158 | getAllCheckedResult, 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/composables/use-countdown.ts: -------------------------------------------------------------------------------- 1 | import { shallowRef } from 'vue' 2 | 3 | interface Options { 4 | /** 5 | * 倒计时 6 | */ 7 | times?: number 8 | 9 | /** 10 | * 倒数的间隔 11 | */ 12 | interval?: number 13 | 14 | /** 15 | * 是否立即开始 16 | */ 17 | immediate?: boolean 18 | 19 | /** 20 | * 当倒计时结束时 21 | */ 22 | onFinished?: () => void 23 | 24 | /** 25 | * 当读秒改变时 26 | * @param second {number} 27 | */ 28 | // onChange?: (second: number) => void 29 | } 30 | 31 | export function useCountdown({ 32 | times = 60, 33 | interval = 1, 34 | immediate = false, 35 | onFinished = () => { }, 36 | // onChange = (second: number) => { }, 37 | } = {} as Options) { 38 | let timeoutId: number 39 | 40 | const remainder = shallowRef(0) 41 | 42 | function pause() { 43 | clearTimeout(timeoutId) 44 | } 45 | 46 | function start(manual = true) { 47 | if (manual && !remainder.value) { 48 | remainder.value = times 49 | } 50 | 51 | timeoutId = window.setTimeout(() => { 52 | remainder.value -= interval 53 | 54 | remainder.value ? start(false) : onFinished() 55 | }, interval * 1000) 56 | } 57 | 58 | function reset() { 59 | pause() 60 | remainder.value = times 61 | } 62 | 63 | immediate && start() 64 | 65 | onBeforeUnmount(() => { 66 | pause() 67 | }) 68 | 69 | return { 70 | pause, 71 | reset, 72 | start, 73 | value: remainder, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/composables/use-delay-loading.ts: -------------------------------------------------------------------------------- 1 | import { shallowRef } from 'vue' 2 | 3 | interface Options { 4 | default?: boolean 5 | delay?: number 6 | } 7 | 8 | interface ReturnType { 9 | loading: Ref 10 | startLoading: () => void 11 | cancelLoading: () => void 12 | } 13 | 14 | export function useDelayLoading(valueOrOptions?: boolean): ReturnType 15 | export function useDelayLoading(valueOrOptions?: Options): ReturnType 16 | export function useDelayLoading(valueOrOptions: boolean | Options = {}): ReturnType { 17 | let timeoutId: number 18 | 19 | const { 20 | delay = 300, 21 | default: value = false, 22 | } = typeof valueOrOptions === 'boolean' 23 | ? { default: valueOrOptions, delay: 300 } 24 | : valueOrOptions 25 | 26 | const loading = shallowRef(value) 27 | 28 | function startLoading() { 29 | timeoutId = window.setTimeout(() => { 30 | loading.value = true 31 | }, delay) 32 | } 33 | 34 | function cancelLoading() { 35 | clearTimeout(timeoutId) 36 | loading.value = false 37 | } 38 | 39 | return { 40 | loading, 41 | startLoading, 42 | cancelLoading, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/composables/use-game-score.ts: -------------------------------------------------------------------------------- 1 | import type { GameLevel, LEVEL_GRIDS } from '@/config/game' 2 | import { getGameMoney, getHighestScoreInHistory } from '@/composables/use-local-cache' 3 | 4 | export function useGameScore( 5 | { rate: multiplier }: typeof LEVEL_GRIDS[GameLevel], 6 | blocks: Ref>, 7 | ) { 8 | const BASIC_GAME_RATE = 10 9 | 10 | const timestamp = shallowRef(0) 11 | const gameScore = shallowRef(0) 12 | const gameMoney = shallowRef(0) 13 | 14 | const highestScore = shallowRef(0) 15 | const showHighestScoreBadge = shallowRef(false) 16 | 17 | let lastTimestamp = 0 18 | let timestampId = -1 19 | 20 | function setGameScore() { 21 | stopRecording() 22 | const deltaTime = performance.now() - lastTimestamp 23 | 24 | // 时间每过 5 秒倍率 -1, 直到倍率为 1 25 | const timeScoreRate = Math.max(BASIC_GAME_RATE - Math.floor(deltaTime / 5000), 1) 26 | 27 | // 计分公式: 方块数量 * 难度倍率 * 时间倍率 28 | const deltaScore = Math.round(blocks.value.size * multiplier * timeScoreRate) 29 | 30 | gameScore.value += deltaScore 31 | } 32 | 33 | /** 34 | * 开始记录时间 35 | * @param lastTime 上次记录的时间, 用于恢复 36 | */ 37 | function startRecording(lastTime: number = 0) { 38 | timestamp.value = lastTime 39 | lastTimestamp = performance.now() 40 | 41 | stopRecording() 42 | timestampId = window.setInterval(() => { 43 | timestamp.value += 1 44 | }, 1000) 45 | } 46 | 47 | function stopRecording() { 48 | clearInterval(timestampId) 49 | } 50 | 51 | // 更新历史最高分状态 52 | function updateHighestScoreStatus(level: GameLevel) { 53 | getHighestScoreInHistory(level).then((v) => { 54 | // 更新历史最高分 55 | highestScore.value = v || 0 56 | }) 57 | 58 | getGameMoney().then((money) => { 59 | gameMoney.value = money || 0 60 | }) 61 | } 62 | 63 | return { 64 | timestamp, 65 | gameScore, 66 | gameMoney, 67 | highestScore, 68 | showHighestScoreBadge, 69 | 70 | setGameScore, 71 | stopRecording, 72 | startRecording, 73 | updateHighestScoreStatus, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/composables/use-game-sounds.ts: -------------------------------------------------------------------------------- 1 | import { useStorage, useToggle } from '@vueuse/core' 2 | import { name } from '@/../package.json' 3 | 4 | interface GameSounds { 5 | enableSounds: Ref 6 | toggleSounds: () => void 7 | sounds: { 8 | fail: HTMLAudioElement 9 | over: HTMLAudioElement 10 | error: HTMLAudioElement 11 | // choose: HTMLAudioElement 12 | success: HTMLAudioElement 13 | } 14 | } 15 | 16 | // 因为 Symbol 每次热更新的时候都会变, 导致开发环境下用这个会导致热更新以后inject错误 17 | export const gameSoundsInjectionKey = import.meta.env.PROD 18 | ? Symbol('gameSounds') as InjectionKey 19 | : 'gameSounds' 20 | 21 | export function provideGameSounds() { 22 | const enableSounds = useStorage(`${name}.fe.game-sounds`, true) 23 | const toggleSounds = useToggle(enableSounds) 24 | 25 | const sounds = { 26 | fail: new Audio(new URL('/fail.mp3', import.meta.url).href), 27 | over: new Audio(new URL('/over.mp3', import.meta.url).href), 28 | error: new Audio(new URL('/error.mp3', import.meta.url).href), 29 | // choose: new Audio(new URL('/choose.mp3', import.meta.url).href), 30 | success: new Audio(new URL('/success.mp3', import.meta.url).href), 31 | } 32 | 33 | provide(gameSoundsInjectionKey, { 34 | toggleSounds, 35 | enableSounds, 36 | sounds, 37 | }) 38 | } 39 | 40 | export function useGameSounds() { 41 | const { sounds, enableSounds } = inject(gameSoundsInjectionKey)! 42 | 43 | // 确保每次都能正常播放(如果之前正在播放则暂停并重置进度) 44 | function ensurePlay(audio: HTMLAudioElement) { 45 | return () => { 46 | if (!enableSounds.value) { 47 | return 48 | } 49 | 50 | audio.pause() 51 | audio.currentTime = 0 52 | audio.play() 53 | } 54 | } 55 | 56 | return { 57 | // choose: ensurePlay(sounds.choose), 58 | 59 | fail: ensurePlay(sounds.fail), 60 | success: ensurePlay(sounds.success), 61 | error: ensurePlay(sounds.error), 62 | over: ensurePlay(sounds.over), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/composables/use-game-status.ts: -------------------------------------------------------------------------------- 1 | import { type GameLevel, LEVEL_GRIDS } from '@/config/game' 2 | 3 | // 游戏核心状态管理,分数、生命值、目标方块等 4 | export function useGameStatus() { 5 | const route = useRoute() 6 | 7 | const { level, levelConfig } = (() => { 8 | // 获取当前游戏配置 9 | const level = route.params.level as GameLevel || 'easy' 10 | let levelConfig = LEVEL_GRIDS[level] 11 | 12 | // 如果是自定义模式 13 | if (level === 'custom') { 14 | const localCustomLevel = localStorage.getItem('customLevelConfig') 15 | 16 | // 如果没设置就用预设的最简单的 17 | if (localCustomLevel) { 18 | levelConfig = JSON.parse(localCustomLevel) 19 | 20 | // 本地保存的配置可能会有问题,这里做一下合法性校验 21 | 22 | // 如果最小数量大于最大数量,交换两者 23 | if (levelConfig.min > levelConfig.max) { 24 | // @ts-expect-error let me do this! 25 | [levelConfig.min, levelConfig.max] = [levelConfig.max, levelConfig.min] 26 | } 27 | } 28 | } 29 | 30 | return { 31 | level, 32 | levelConfig, 33 | } 34 | })() 35 | 36 | // 需要选中的方块 37 | const targetBlocks = shallowRef(new Set()) 38 | 39 | // 当前生命值 40 | const gameHealth = shallowRef(levelConfig.health) 41 | const gameHealthList = computed(() => Array.from({ length: levelConfig.health + gameHealth.value }, (_, i) => i)) 42 | 43 | // 生成目标方块 44 | function generateRandomTargetBlock() { 45 | const { min, max, grid, fillFull } = levelConfig 46 | 47 | const target = new Set() 48 | 49 | // 如果设置了【始终填满】则将所有格子高亮 50 | if (fillFull) { 51 | for (let x = 0; x < grid; x++) { 52 | for (let y = 0; y < grid; y++) { 53 | target.add(`${x},${y}`) 54 | } 55 | } 56 | } else { 57 | // 生成随机数量 58 | const counts = Math.floor(Math.random() * (max - min + 1)) + min 59 | 60 | // 如果生成的数量不够则继续生成 61 | while (target.size < counts) { 62 | const row = Math.floor(Math.random() * grid) 63 | const col = Math.floor(Math.random() * grid) 64 | 65 | target.add(`${row},${col}`) 66 | } 67 | } 68 | 69 | targetBlocks.value = target 70 | } 71 | 72 | return { 73 | level, 74 | gameHealth, 75 | levelConfig, 76 | targetBlocks, 77 | gameHealthList, 78 | generateRandomTargetBlock, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/composables/use-i18n.ts: -------------------------------------------------------------------------------- 1 | import type { ShallowRef } from 'vue' 2 | import { type Language, getLanguage, setLanguage } from './use-local-cache' 3 | import zhCN from '@/locale/zh-CN.json' 4 | import enUS from '@/locale/en-US.json' 5 | import jaJP from '@/locale/ja-JP.json' 6 | 7 | interface I18N { 8 | lang: ShallowRef 9 | setLanguage: (language: Language) => void 10 | $t: (key: string, fallback?: string) => string 11 | } 12 | 13 | export const i18NInjectionKey = import.meta.env.PROD 14 | ? Symbol('i18n') as InjectionKey 15 | : 'i18n' 16 | 17 | export function useI18N() { 18 | const lang = shallowRef(getLanguage() as Language) 19 | 20 | const messages = >>{ 21 | 'zh-CN': zhCN, 22 | 'en-US': enUS, 23 | 'ja-JP': jaJP, 24 | } 25 | 26 | const msg = computed(() => messages[lang.value] ?? messages['zh-CN']) 27 | 28 | const _setLang = (language: Language) => { 29 | lang.value = language 30 | setLanguage(language) 31 | } 32 | 33 | const $t = (key: string, fallback: string = ''): string => { 34 | return msg.value[key] ?? fallback 35 | } 36 | 37 | provide(i18NInjectionKey, { 38 | lang, 39 | setLanguage: _setLang, 40 | $t, 41 | }) 42 | 43 | return { 44 | lang, 45 | setLanguage: _setLang, 46 | $t, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/composables/use-lazy-show.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | /** 3 | * 默认值, 初始情况下是否渲染 4 | */ 5 | default?: boolean 6 | 7 | /** 8 | * 延迟时间, 单位毫秒, 在设置为隐藏后, 多少毫秒后才真正隐藏 9 | */ 10 | delay?: number 11 | } 12 | 13 | interface ReturnType { 14 | /** 15 | * 用于 v-if, 控制元素是否渲染 16 | */ 17 | render: Ref 18 | 19 | /** 20 | * 用于 v-show, 控制元素是否可见 21 | */ 22 | visible: Ref 23 | 24 | /** 25 | * 渲染元素 26 | */ 27 | open: () => void 28 | 29 | /** 30 | * 先隐藏元素, 在延迟结束后销毁元素(组件) 31 | */ 32 | close: () => void 33 | } 34 | 35 | export function useLazyShow(valueOrOptions?: boolean): ReturnType 36 | export function useLazyShow(valueOrOptions?: Options): ReturnType 37 | export function useLazyShow(valueOrOptions: boolean | Options = {}): ReturnType { 38 | const { 39 | delay = 300, 40 | default: value = false, 41 | } = typeof valueOrOptions === 'boolean' 42 | ? { default: valueOrOptions, delay: 300 } 43 | : valueOrOptions 44 | 45 | const render = shallowRef(value) 46 | const visible = shallowRef(value) 47 | 48 | let delayTimeoutId = -1 49 | 50 | const open = () => { 51 | clearTimeout(delayTimeoutId) 52 | 53 | render.value = true 54 | 55 | Promise.resolve().then(() => { 56 | visible.value = true 57 | }) 58 | } 59 | 60 | const close = () => { 61 | visible.value = false 62 | 63 | delayTimeoutId = window.setTimeout(() => { 64 | render.value = false 65 | }, delay) 66 | } 67 | 68 | return { 69 | open, 70 | close, 71 | render, 72 | visible, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/composables/use-local-cache.ts: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage' 2 | import { name } from '@/../package.json' 3 | import type { GameLevel } from '@/config/game' 4 | import type { GameGood } from '@/config/goods' 5 | 6 | localforage.config({ 7 | name, 8 | storeName: 'records', 9 | }) 10 | 11 | export interface RecordItem { 12 | level: GameLevel 13 | score: number 14 | durations: string 15 | startTime: string 16 | endTime: string 17 | } 18 | 19 | export type Language = 'zh-CN' | 'en-US' | 'ja-JP' 20 | 21 | const HIGHEST_SCORE_KEY = 'highestScore.' 22 | 23 | const LANGUAGE_KEY = 'memoryBlockLanguage' 24 | 25 | const GAME_MONEY_KEY = 'gameMoney' 26 | 27 | const GAME_GOODS_KEY = 'gameGoods' 28 | 29 | // 获取最高分 30 | export function getHighestScoreInHistory(level: GameLevel) { 31 | return localforage.getItem(HIGHEST_SCORE_KEY + level, v => v ?? 0) 32 | } 33 | 34 | // 设置最高分 35 | export function setHighestScoreInHistory(level: GameLevel, score: number) { 36 | localforage.setItem(HIGHEST_SCORE_KEY + level, score) 37 | } 38 | 39 | // 获取游戏中的金币 40 | export function getGameMoney() { 41 | return localforage.getItem(GAME_MONEY_KEY, v => v ?? 0) 42 | } 43 | 44 | // 设置游戏中的金币 45 | export function setGameMoney(money: number) { 46 | localforage.setItem(GAME_MONEY_KEY, money) 47 | } 48 | 49 | // 获取游戏道具 50 | export function getGameGoods() { 51 | return localforage.getItem(GAME_GOODS_KEY, v => v ?? []) 52 | } 53 | 54 | // 更新游戏道具数量 55 | export function setGameGoods(goods: GameGood[]) { 56 | localforage.setItem(GAME_GOODS_KEY, goods.map(toRaw)) 57 | } 58 | 59 | export function appendRecordToStore(record: RecordItem) { 60 | localforage.setItem(record.startTime, record) 61 | } 62 | 63 | export function getTargetDateRecords(date: string) { 64 | return localforage.getItem(date)! 65 | } 66 | 67 | export async function getAllRecordsFromStore() { 68 | const _records = [] as RecordItem[] 69 | 70 | await localforage.iterate((value) => { 71 | _records.push(value as RecordItem) 72 | }) 73 | 74 | return _records 75 | } 76 | 77 | // 获取语言 78 | export const getLanguage = () => localStorage.getItem(LANGUAGE_KEY) ?? 'zh-CN' 79 | 80 | // 设置语言 81 | export function setLanguage(lang: Language = 'zh-CN') { 82 | localStorage.setItem(LANGUAGE_KEY, lang) 83 | } 84 | -------------------------------------------------------------------------------- /src/composables/use-lru.ts: -------------------------------------------------------------------------------- 1 | export default function (capacity: number = 10) { 2 | return new LRUCache(capacity) 3 | } 4 | 5 | export class LRUCache { 6 | private readonly items: Map 7 | private readonly capacity: number 8 | 9 | constructor(capacity: number) { 10 | this.items = new Map() 11 | this.capacity = capacity 12 | } 13 | 14 | get(key: K): V | undefined { 15 | let item: V | undefined 16 | 17 | // 如果 key 存在,将其移动到最后(最近使用) 18 | if (this.items.has(key)) { 19 | item = this.items.get(key) 20 | this.items.delete(key) 21 | this.items.set(key, item as V) 22 | } 23 | 24 | return item 25 | } 26 | 27 | set(key: K, value: V): void { 28 | // 如果 key 已经存在,先删除旧的 29 | if (this.items.has(key)) { 30 | this.items.delete(key) 31 | } else if (this.items.size >= this.capacity) { 32 | // 如果当前缓存已满,删除最不常用的项 33 | const firstKey = this.items.keys().next().value 34 | 35 | this.items.delete(firstKey) 36 | } 37 | // 将新的 key-value 对添加到最后 38 | this.items.set(key, value) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/composables/use-range-random.ts: -------------------------------------------------------------------------------- 1 | export function useRangeRandom({ min = 0, max = 1 } = {}) { 2 | return Math.floor(Math.random() * (max - min + 1)) + min 3 | } 4 | -------------------------------------------------------------------------------- /src/composables/use-set-query-params.ts: -------------------------------------------------------------------------------- 1 | interface Options { 2 | urlKey: string 3 | options: string[] 4 | } 5 | 6 | /** 7 | * 当从配置项中切换值得时候将值记录到 url 中 8 | */ 9 | export function useSetQueryParams({ 10 | options, 11 | urlKey, 12 | } = {} as Options) { 13 | const route = useRoute() 14 | 15 | const value = shallowRef( 16 | (route.query[urlKey] || options[0]) as ValueType, 17 | ) 18 | 19 | const toggle = (mode: ValueType) => { 20 | value.value = mode 21 | 22 | // 如果设置了 urlKey 则表示需要将状态记录在 url 23 | if (!urlKey) { 24 | return 25 | } 26 | 27 | route.query[urlKey] = mode as string 28 | 29 | const newQueryString = new URLSearchParams( 30 | route.query as Record, 31 | ).toString() 32 | 33 | // 如果有历史记录则更新历史记录, 如果不修改这个值而直接设置为 null 34 | // 在跳转页面的时候会出现一个 vue-router 的警告 35 | if (history.state) { 36 | history.state.current = `${location.href.includes('#') ? '#' : ''}${route.path}?${newQueryString}` 37 | } 38 | 39 | // 切换后在 url 上记录状态避免刷新后需要重新选择 40 | history.replaceState(history.state, '', history.state.current) 41 | } 42 | 43 | return { 44 | value, 45 | toggle, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/composables/use-toast.ts: -------------------------------------------------------------------------------- 1 | import '@/styles/toast.scss' 2 | 3 | interface ToastOptions { 4 | type?: 'warning' | 'success' | 'error' 5 | durations?: number 6 | } 7 | 8 | let Toaster: HTMLElement 9 | 10 | export function initToast(): HTMLElement { 11 | const id = 'toaster' 12 | const tagName = 'section' 13 | const className = 'toast-container' 14 | const toaster = Toaster || document.querySelector(`#${id}.${className}`) as HTMLElement 15 | 16 | if (toaster) { 17 | return toaster 18 | } 19 | 20 | Toaster = Object.assign( 21 | document.createElement(tagName), 22 | { className, id }, 23 | ) 24 | 25 | document.body.append(Toaster) 26 | 27 | return Toaster 28 | } 29 | 30 | // https://aerotwist.com/blog/flip-your-animations/ 31 | // flip: First Last Invert Play 32 | function flipToast(toast: HTMLOutputElement): void { 33 | // First: 获取初始高度 34 | const initialHeight = Toaster.offsetHeight 35 | 36 | // 添加子元素让容器高度变化 37 | Toaster.appendChild(toast) 38 | 39 | // Last: 再次获取容器高度 40 | const changedHeight = Toaster.offsetHeight 41 | 42 | // PLAY: 开始动画 43 | const animation = Toaster.animate([ 44 | // Invert: 两者相减得到需要移动的距离 45 | { transform: `translateY(${changedHeight - initialHeight}px)` }, 46 | { transform: 'translateY(0)' }, 47 | ], { duration: 150, easing: 'ease-out' }) 48 | 49 | animation.startTime = document.timeline.currentTime 50 | } 51 | 52 | function addToast(toast: HTMLOutputElement): void { 53 | const { matches: motionOK } = window.matchMedia( 54 | '(prefers-reduced-motion: no-preference)', 55 | ) 56 | 57 | Toaster.children.length && motionOK 58 | ? flipToast(toast) 59 | : Toaster.appendChild(toast) 60 | } 61 | 62 | export function useToast(htmlText: string, { type, durations = 2000 } = {} as ToastOptions): void { 63 | const toast = Object.assign(document.createElement('output'), { 64 | 'aria-live': 'polite', 65 | 'className': `use-toast ${type ?? ''}`, 66 | 'innerHTML': htmlText, 67 | 'role': 'status', 68 | 'style': `--durations: ${durations}ms`, 69 | }) 70 | 71 | addToast(toast) 72 | 73 | Promise.allSettled(toast.getAnimations() 74 | .map(async animation => await animation.finished)) 75 | .then(() => Toaster.removeChild(toast)) 76 | } 77 | 78 | export function useToastError(htmlText: string) { 79 | useToast(htmlText, { type: 'error' }) 80 | } 81 | -------------------------------------------------------------------------------- /src/config/game.ts: -------------------------------------------------------------------------------- 1 | export const GAME_LEVELS = { 2 | easy: { code: 'easy', type: 'default', en: 'Easy Level', zh: '简单难度', ja: '簡単なレベル', path: '/game/easy' }, 3 | normal: { code: 'normal', type: 'primary', en: 'Normal Level', zh: '中等难度', ja: '中レベル', path: '/game/normal' }, 4 | master: { code: 'master', type: 'warning', en: 'Master Level', zh: '困难难度', ja: 'マスターレベル', path: '/game/master' }, 5 | expert: { code: 'expert', type: 'danger', en: 'Expert Level', zh: '专家难度', ja: 'エキスパートレベル', path: '/game/expert' }, 6 | custom: { code: 'custom', type: 'custom', en: 'Practice Mode', zh: '练习模式', ja: '練習モード', path: '/settings/custom' }, 7 | } as const 8 | 9 | export const LEVEL_GRIDS = { 10 | easy: { 11 | grid: 4, 12 | min: 2, 13 | max: 4, 14 | rate: 1, 15 | health: 3, 16 | internal: 2, 17 | fillFull: false, 18 | size: 'size-12', 19 | }, 20 | 21 | normal: { 22 | grid: 5, 23 | min: 5, 24 | max: 9, 25 | rate: 1.2, 26 | health: 3, 27 | internal: 3, 28 | fillFull: false, 29 | size: 'size-11', 30 | }, 31 | 32 | master: { 33 | grid: 7, 34 | min: 8, 35 | max: 12, 36 | rate: 1.5, 37 | health: 2, 38 | internal: 2, 39 | fillFull: false, 40 | size: 'size-10', 41 | }, 42 | 43 | expert: { 44 | grid: 9, 45 | min: 10, 46 | max: 15, 47 | rate: 1.8, 48 | health: 2, 49 | internal: 2, 50 | fillFull: false, 51 | size: 'size-8', 52 | }, 53 | 54 | custom: { 55 | grid: 3, 56 | min: 1, 57 | max: 1, 58 | rate: 1, 59 | health: 1, 60 | internal: 2, 61 | fillFull: false, 62 | size: 'size-12', 63 | }, 64 | } as const 65 | 66 | export type GameLevel = { 67 | [K in keyof typeof GAME_LEVELS]: typeof GAME_LEVELS[K]['code']; 68 | }[keyof typeof GAME_LEVELS] 69 | 70 | export const languages = [ 71 | { label: '中文', value: 'zh-CN' }, 72 | { label: 'English', value: 'en-US' }, 73 | { label: '日本語', value: 'ja-JP' }, 74 | ] 75 | -------------------------------------------------------------------------------- /src/config/goods.ts: -------------------------------------------------------------------------------- 1 | // https://icones.js.org/collection/game-icons 2 | 3 | export const GAME_GOODS = [ 4 | { 5 | id: 'REGENERATE', 6 | name: '重新开始', 7 | icon: 'i-game-icons-perspective-dice-six-faces-random', 8 | description: '说不上来为什么,但总感觉哪里不对劲(重新生成方块数量及位置)', 9 | color: 'text-orange-600', 10 | discountPrice: 0.5, 11 | price: 300, 12 | count: 0, 13 | }, 14 | { 15 | id: 'IGNORE_ERROR', 16 | name: '走个后门', 17 | icon: 'i-game-icons-broken-shield', 18 | description: '尽情犯错吧,但这可不是长久之计(可以选择任意方块作为结果)', 19 | color: 'text-teal-600', 20 | discountPrice: 0.5, 21 | price: 300, 22 | count: 0, 23 | }, 24 | { 25 | id: 'LOOK_AGAIN', 26 | name: '再看一眼', 27 | icon: 'i-game-icons-brain', 28 | description: '刚刚发生了什么?(回到预览阶段重新开始本回合)', 29 | color: 'text-indigo-600', 30 | discountPrice: 0.5, 31 | price: 150, 32 | count: 0, 33 | }, 34 | { 35 | id: 'RESTORE_LIFE', 36 | name: '打个补丁', 37 | icon: 'i-game-icons-arm-bandage', 38 | description: '休息一下,做些你想做的事(回复 1 点生命值)', 39 | color: 'text-sky-600', 40 | discountPrice: 0.5, 41 | price: 150, 42 | count: 0, 43 | }, 44 | ] 45 | 46 | export type GameGood = typeof GAME_GOODS[number] 47 | -------------------------------------------------------------------------------- /src/config/theme.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | easy: 'bg-white dark:bg-gray-700', 3 | expert: 'bg-red-500 dark:bg-red-500 text-white', 4 | master: 'bg-orange-500 dark:bg-orange-600 text-white', 5 | normal: 'bg-emerald-500 dark:bg-emerald-600 text-white', 6 | custom: 'bg-violet-500 dark:bg-violet-500 text-white', 7 | } 8 | 9 | export const VARIANT = { 10 | default: COLORS.easy, 11 | danger: COLORS.expert, 12 | warning: COLORS.master, 13 | primary: COLORS.normal, 14 | custom: COLORS.custom, 15 | } 16 | -------------------------------------------------------------------------------- /src/locale/en-US.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "memory-block": "Memory block", 4 | "custom-levels": "Custom levels", 5 | "number-of-grids": "Number of grids", 6 | "minimum-blocks": "Minimum number of generated blocks", 7 | "maximum-blocks": "Maximum number of generated blocks", 8 | "hp": "HP", 9 | "second": "second", 10 | "save-success": "Save success", 11 | "setup-completed": "Start the game", 12 | 13 | "score": "Score", 14 | "start-time": "Start time", 15 | "end-time": "Ends time", 16 | "using-time": "Using time", 17 | 18 | "game-over": "Game over", 19 | "start": "Start", 20 | "again": "Again", 21 | "clear": "Clear", 22 | "selected": "Selected", 23 | "continue": "Continue", 24 | 25 | "memory-time": "Memory time before the start of each round", 26 | "configuration-integer-gt": "Configuration can only be an integer greater than 1", 27 | "select-one-first": "Please select at least one block first", 28 | "remember-block-locations": "Please remember the following block locations" 29 | } 30 | -------------------------------------------------------------------------------- /src/locale/ja-JP.json: -------------------------------------------------------------------------------- 1 | { 2 | "memory-block": "メモリ ブロック", 3 | "custom-levels": "カスタム レベル", 4 | "number-of-grids": "グリッドの数", 5 | "minimum-blocks": "生成されるブロックの最小数", 6 | "maximum-blocks": "生成されるブロックの最大数", 7 | "hp": "HP", 8 | "second": "秒", 9 | "save-success": "設定が正常に保存されました", 10 | "setup-completed": "ゲームをスタート", 11 | 12 | "score": "スコア", 13 | "start-time": "開始時刻", 14 | "end-time": "終了時刻", 15 | "using-time": "使用時間", 16 | 17 | "game-over": "ゲームオーバー", 18 | "start": "ゲーム開始", 19 | "again": "また", 20 | "clear": "選択をクリアします", 21 | "selected": "選択済み", 22 | "continue": "継続", 23 | 24 | "memory-time": "各ラウンド開始前の記憶時間", 25 | "configuration-integer-gt": "構成には 1 より大きい整数のみを指定できます", 26 | "select-one-first": "最初に少なくとも 1 つのブロックを選択してください", 27 | "remember-block-locations": "次のブロックの場所を覚えておいてください" 28 | } 29 | -------------------------------------------------------------------------------- /src/locale/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "memory-block": "记忆方块", 3 | "custom-levels": "自定义关卡", 4 | "number-of-grids": "网格数量", 5 | "minimum-blocks": "最小生成方块数", 6 | "maximum-blocks": "最大生成方块数", 7 | "hp": "生命值", 8 | "second": "秒", 9 | "save-success": "设置成功", 10 | "setup-completed": "开始游戏", 11 | 12 | "score": "得分", 13 | "start-time": "开始于", 14 | "end-time": "结束于", 15 | "using-time": "用时", 16 | 17 | "game-over": "游戏结束", 18 | "start": "游戏开始", 19 | "again": "再来一次", 20 | "clear": "清空选中", 21 | "selected": "选好了", 22 | "continue": "继续", 23 | 24 | "memory-time": "每回合开始前的记忆时间", 25 | "configuration-integer-gt": "配置只能为大于 1 的整数", 26 | "select-one-first": "请先选择至少一个方块", 27 | "remember-block-locations": "请记住以下方块位置" 28 | } 29 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | 3 | import router from './router' 4 | 5 | import App from './App.vue' 6 | 7 | import { initToast } from '@/composables/use-toast' 8 | 9 | import './styles' 10 | 11 | const app = createApp(App) 12 | 13 | app.use(router) 14 | 15 | app.mount('#app') 16 | 17 | initToast() 18 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | import generatedRoutes from 'virtual:generated-pages' 3 | 4 | import { useViewTransition } from './plugins/view-transition' 5 | 6 | const router = createRouter({ 7 | history: createWebHashHistory(import.meta.env.BASE_URL), 8 | routes: generatedRoutes, 9 | }) 10 | 11 | useViewTransition(router) 12 | 13 | export default router 14 | -------------------------------------------------------------------------------- /src/router/plugins/view-transition.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'vue-router' 2 | 3 | export function useViewTransition(router: Router) { 4 | if (!document.startViewTransition) { 5 | return 6 | } 7 | 8 | let finishTransition: undefined | (() => void) 9 | let abortTransition: undefined | (() => void) 10 | 11 | router.beforeResolve(() => { 12 | const promise = new Promise((resolve, reject) => { 13 | finishTransition = resolve 14 | abortTransition = reject 15 | }) 16 | 17 | let changeRoute: () => void 18 | const ready = new Promise(resolve => (changeRoute = resolve)) 19 | 20 | const transition = document.startViewTransition!(() => { 21 | changeRoute() 22 | 23 | return promise 24 | }) 25 | 26 | transition.finished.then(() => { 27 | abortTransition = undefined 28 | finishTransition = undefined 29 | }) 30 | 31 | return ready 32 | }) 33 | 34 | router.afterEach(() => { 35 | finishTransition?.() 36 | finishTransition = undefined 37 | }) 38 | 39 | router.onError(() => { 40 | abortTransition?.() 41 | abortTransition = undefined 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --sc: #ddd; 8 | --sh: #bbb; 9 | 10 | --background: 0 0% 100%; 11 | --foreground: 222.2 84% 4.9%; 12 | 13 | --muted: 210 40% 96.1%; 14 | --muted-foreground: 215.4 16.3% 66.9%; 15 | 16 | --popover: 0 0% 100%; 17 | --popover-foreground: 222.2 84% 4.9%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 84% 4.9%; 21 | 22 | --border: 214.3 31.8% 91.4%; 23 | --input: 214.3 31.8% 91.4%; 24 | 25 | --primary: 160 84% 39%; 26 | --primary-foreground: 0 0% 100%; 27 | 28 | --secondary-primary: 118 42% 49%; 29 | 30 | --secondary: 210 40% 96.1%; 31 | --secondary-foreground: 222.2 47.4% 11.2%; 32 | 33 | --accent: 210 40% 96.1%; 34 | --accent-foreground: 222.2 47.4% 11.2%; 35 | 36 | --destructive: 0 84.2% 60.2%; 37 | --destructive-foreground: 210 40% 98%; 38 | 39 | --ring: 222.2 84% 4.9%; 40 | 41 | --radius: 0.5rem; 42 | 43 | background-color: hsl(var(--background)); 44 | color: hsl(var(--foreground)) 45 | } 46 | 47 | .dark { 48 | --sc: #242424; 49 | --sh: #424242; 50 | 51 | --background: 222.2 84% 4.9%; 52 | --foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --popover: 222.2 84% 4.9%; 58 | --popover-foreground: 210 40% 98%; 59 | 60 | --card: 222.2 84% 4.9%; 61 | --card-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | 66 | --secondary: 217.2 32.6% 17.5%; 67 | --secondary-foreground: 210 40% 98%; 68 | 69 | --accent: 217.2 32.6% 17.5%; 70 | --accent-foreground: 210 40% 98%; 71 | 72 | --destructive: 0 62.8% 30.6%; 73 | --destructive-foreground: 210 40% 98%; 74 | 75 | --ring: 212.7 26.8% 83.9%; 76 | 77 | color-scheme: dark; 78 | } 79 | 80 | } 81 | 82 | @media (max-width: 640px) { 83 | .container { 84 | padding-left: 1rem; 85 | padding-right: 1rem 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | import './reset.css' 2 | import './global.css' 3 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* https://andy-bell.co.uk/a-more-modern-css-reset/ */ 2 | 3 | *, 4 | *::before, 5 | *::after { 6 | box-sizing: border-box; 7 | /* border-color: hsl(var(--border)) */ 8 | } 9 | 10 | ::-webkit-scrollbar { 11 | width: 6px; 12 | height: 6px; 13 | } 14 | 15 | ::-webkit-scrollbar-track, 16 | ::-webkit-scrollbar-corner { 17 | background: var(--bc); 18 | border-radius: 10px; 19 | } 20 | 21 | ::-webkit-scrollbar-thumb { 22 | border-radius: 10px; 23 | background: var(--sc); 24 | } 25 | 26 | ::-webkit-scrollbar-thumb:hover { 27 | background: var(--sh); 28 | } 29 | 30 | ::placeholder { 31 | user-select: none; 32 | } 33 | 34 | /* 35 | --spacing-30: clamp(1.5rem, 5vw, 2rem); 36 | --spacing-40: clamp(1.8rem, 1.8rem + ((1vw - 0.48rem) * 2.885), 3rem); 37 | --spacing-50: clamp(2.5rem, 8vw, 4.5rem); 38 | --spacing-60: clamp(3.75rem, 10vw, 7rem); 39 | --spacing-70: clamp(5rem, 5.25rem + ((1vw - 0.48rem) * 9.096), 8rem); 40 | --spacing-80: clamp(7rem, 14vw, 11rem); 41 | */ 42 | 43 | html { 44 | --c1: #4997f0; 45 | --c2: #8e9eec; 46 | --c3: #7f4eea; 47 | --c4: #ec8ebf; 48 | --c5: #f74e63; 49 | --c6: #f8b85c; 50 | --cp: #42b883; 51 | 52 | scroll-behavior: smooth; 53 | scroll-padding-top: 50px; 54 | background-color: var(--background); 55 | text-rendering: optimizeLegibility; 56 | /* 非标准属性, 目前仅在 macos 上生效 */ 57 | -webkit-font-smoothing: antialiased; 58 | 59 | /* Prevent font size inflation */ 60 | -moz-text-size-adjust: none; 61 | -webkit-text-size-adjust: none; 62 | text-size-adjust: none; 63 | 64 | line-height: 1.5; 65 | font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 66 | color: var(--tc); 67 | } 68 | 69 | ul { 70 | margin: 0 71 | } 72 | 73 | 74 | ul { 75 | padding-left: 0; 76 | list-style: none; 77 | } 78 | 79 | html, 80 | body, 81 | #app { 82 | height: 100%; 83 | } 84 | 85 | /* Fallback for browsers that don't support scrollbar-gutter */ 86 | body, .scrollbar { 87 | overflow-y: auto; 88 | } 89 | 90 | /* scrollbar-gutter FTW! */ 91 | /* @supports (scrollbar-gutter: stable) { 92 | body, .scrollbar { 93 | overflow-y: auto; 94 | scrollbar-gutter: stable; 95 | } 96 | } */ 97 | 98 | h1, h2, h3, h4, p, 99 | figure, blockquote, dl, dd { 100 | margin: 0; 101 | } 102 | 103 | ul[role='list'], 104 | ol[role='list'] { 105 | padding-left: 0; 106 | list-style: none; 107 | } 108 | 109 | /* input, button { 110 | appearance: none; 111 | } */ 112 | 113 | p, h1, h2, h3, h4 { 114 | overflow-wrap: break-word; 115 | } 116 | 117 | h1, h2, h3, h4 { 118 | line-height: 1.1; 119 | width: fit-content; 120 | text-wrap: balance 121 | } 122 | 123 | 124 | a { 125 | text-decoration: none; 126 | color: inherit 127 | } 128 | 129 | img { 130 | /* display: block; */ 131 | max-width: 100%; 132 | height: auto; 133 | font-style: italic; 134 | object-fit: cover; 135 | background-size: cover; 136 | vertical-align: middle; 137 | background-repeat: no-repeat; 138 | shape-margin: 0.75rem; 139 | } 140 | 141 | @supports (font: -apple-system-body) and (-webkit-appearance: none) { 142 | /* 裁剪掉图片未能加载时的边框 */ 143 | img[loading="lazy"] { 144 | clip-path: inset(0.6px) 145 | } 146 | } 147 | 148 | /* Make sure textarea without a rows attribute are not tiny */ 149 | textarea:not([rows]) { 150 | min-height: 10em; 151 | } 152 | 153 | /* Anything that has been anchored to should have extra scroll margin */ 154 | :target { 155 | scroll-margin-block: 5ex; 156 | } 157 | 158 | /* a:not(.no-underline, .underline):hover { 159 | text-decoration: underline dashed; 160 | text-decoration-thickness: 1.5px; 161 | text-underline-offset: 1.5px; 162 | color: hsl(var(--primary)) 163 | } */ 164 | 165 | /* 外部链接并且不是以 # 开头的链接加上图标 */ 166 | /* .external-link::after, */ 167 | /* a[target="_blank"]:not([href^="#"], [role="icon"], .no-icon) { 168 | content: ''; 169 | display: inline-block; 170 | height: 1em; 171 | padding-right: 1em; 172 | vertical-align: -3.5px; 173 | background-position: right center; 174 | background-repeat: no-repeat; 175 | background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjEgMjEiPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iY3VycmVudENvbG9yIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xOC41IDguNXYtNWgtNW01IDBsLTcgN20tMS03aC01YTIgMiAwIDAgMC0yIDJ2MTBhMiAyIDAgMCAwIDIgMmgxMWEyIDIgMCAwIDAgMi0ydi00Ii8+PC9zdmc+); 176 | background-size: .85em .85em; 177 | } */ 178 | 179 | /* kbd { 180 | padding: 0.22rem .55rem; 181 | border: 2px solid #7d7d7d4d; 182 | border-radius: 4px; 183 | box-shadow: 0 -1.5px 0 0 #7d7d7d4d inset; 184 | font-size: .825rem; 185 | pointer-events: none; 186 | } */ 187 | 188 | table { 189 | width: 100%; 190 | table-layout: fixed; 191 | } 192 | 193 | hr { 194 | margin: 30px 5%; 195 | border-width: 0; 196 | border-bottom: 1px dashed #9ca3af80; 197 | } 198 | 199 | #app { 200 | isolation: isolate; 201 | } 202 | 203 | .text-nowrap { 204 | display: inline-block; 205 | text-decoration: inherit; 206 | white-space: nowrap; 207 | text-indent: 0; 208 | } 209 | 210 | @supports (scrollbar-width: thin) { 211 | .scrollbar { 212 | scrollbar-width: thin; 213 | } 214 | } 215 | 216 | @supports (scrollbar-color: #000) { 217 | .scrollbar { 218 | scrollbar-color: var(--sc); 219 | } 220 | } 221 | 222 | @supports (-webkit-tap-highlight-color: transparent) { 223 | a, 224 | button, 225 | [role="button"] { 226 | -webkit-tap-highlight-color: transparent; 227 | } 228 | } 229 | 230 | @supports (text-wrap: pretty) { 231 | p { 232 | text-wrap: pretty; 233 | } 234 | } 235 | 236 | /* 可以等有需要适配的项目再打开 */ 237 | /* 苹果全面屏的底部安全区域, 因为这两个属性的声明是必要的, 所以可以忽略属性重复的错误 */ 238 | /* @supports (bottom: env(safe-area-inset-bottom)) { 239 | body { 240 | padding-bottom: constant(safe-area-inset-bottom); 241 | padding-bottom: env(safe-area-inset-bottom); 242 | } 243 | } */ 244 | -------------------------------------------------------------------------------- /src/styles/toast.scss: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | --travel-distance: 2vh; 3 | display: grid; 4 | position: fixed; 5 | z-index: 100; 6 | gap: 1vh; 7 | inset-inline: 0; 8 | inset-block-end: 0; 9 | justify-items: center; 10 | padding-bottom: var(--travel-distance); 11 | pointer-events: none; 12 | } 13 | 14 | .use-toast { 15 | padding: .5ch 1.5ch; 16 | max-inline-size: min(30ch, 90vw); 17 | will-change: opacity, transform; 18 | border: 1px solid hsl(var(--border)); 19 | background-color: hsl(var(--background)); 20 | box-shadow: rgba(0, 0, 0, 0.05) 0 1px 2px 0; 21 | overflow-wrap: anywhere; 22 | word-wrap: break-word; 23 | pointer-events: none; 24 | font-weight: normal; 25 | border-radius: 4px; 26 | font-size: .875rem; 27 | animation: 28 | fade-in .3s ease, 29 | slide-in .3s ease, 30 | fade-out .3s ease var(--durations); 31 | 32 | 33 | &.success { 34 | border-color: #a6eeac; 35 | background-color: rgb(166, 238, 172, .5) 36 | } 37 | 38 | &.warning { 39 | border-color: #eed3a6; 40 | background-color: rgb(238, 211, 166, .5) 41 | } 42 | 43 | &.error { 44 | border-color: #eea6a6; 45 | background-color: rgba(247, 149, 149, 0.5) 46 | } 47 | } 48 | 49 | @keyframes fade-in { 50 | from { opacity: 0 } 51 | } 52 | 53 | @keyframes fade-out { 54 | to { opacity: 0 } 55 | } 56 | 57 | @keyframes slide-in { 58 | from { transform: translateY(var(--travel-distance, 10px)) } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/shared/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 生成一个随机的 UUID 3 | * @example 4 | * ```js 5 | * getUUID() 6 | * // -> '7982fcfe-5721-4632-bede-6000885be57d' 7 | * ``` 8 | */ 9 | export function getUUID() { 10 | return (`${1e7}${-1e3}${-4e3}${-8e3}${-1e11}`).replace(/[018]/g, (c) => { 11 | return (Number(c) ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (Number(c) / 4)))).toString(16) 12 | }) 13 | } 14 | 15 | /** 16 | * 复制文本到剪贴板 17 | */ 18 | export function copyToClipboard(string: string) { 19 | if (navigator && navigator.clipboard && navigator.clipboard.writeText) { 20 | return navigator.clipboard.writeText(string) 21 | } 22 | 23 | return Promise.reject(new Error('The Clipboard API is not available.')) 24 | } 25 | 26 | /** 27 | * 获取指定时间的 24 小时制时间 28 | * @example 29 | * ```js 30 | * getColonTimeFromDate(new Date()) // '08:38:00' 31 | * ``` 32 | */ 33 | export const getDateTimeFromDate = (date: Date = new Date()) => date.toTimeString().slice(0, 8) 34 | 35 | /** 36 | * 获取用户的系统是否为暗黑模式 37 | * @example 38 | * ```js 39 | * prefersDarkColorScheme() // true 40 | * ``` 41 | */ 42 | export const prefersDarkColorScheme = () => window && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches 43 | 44 | /** 45 | * 是否为空对象 46 | * @param object 要检测的对象 47 | */ 48 | export const isEmptyObject = (object: object) => Reflect.ownKeys(object).length === 0 49 | 50 | // 千分位格式化分数 51 | export function formatScore(score: number) { 52 | const numStr = score.toString() 53 | const reg = /\B(?=(\d{4})+(?!\d))/g 54 | return numStr.replace(reg, ',') 55 | } 56 | 57 | export const isMobile = 'ontouchstart' in window 58 | -------------------------------------------------------------------------------- /src/utils/transform/format.ts: -------------------------------------------------------------------------------- 1 | export function toArray(value: any) { 2 | if (!value) { 3 | return [] 4 | } 5 | 6 | if (Array.isArray(value)) { 7 | return value 8 | } 9 | 10 | return [value] 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/transform/http.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { Promise } promise 3 | * @param {object} errorExt - Additional Information you can pass to the err object 4 | * @return { Promise } 5 | */ 6 | export function to( 7 | promise: Promise, 8 | errorExt?: object, 9 | ): Promise<[null, U] | [Value]> { 10 | return promise 11 | .then<[Value]>((data: Value) => [data]) 12 | .catch<[null, U]>((err: U) => { 13 | if (errorExt) { 14 | const parsedError = Object.assign({}, err, errorExt) 15 | return [null, parsedError] 16 | } 17 | 18 | return [null, err] 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/views/game/[level].vue: -------------------------------------------------------------------------------- 1 | 342 | 343 | 426 | -------------------------------------------------------------------------------- /src/views/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | -------------------------------------------------------------------------------- /src/views/record/[date].vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /src/views/record/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /src/views/settings/custom.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 128 | -------------------------------------------------------------------------------- /src/views/store/index.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 153 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import animations from 'tailwindcss-animate' 2 | import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons' 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | export default { 6 | content: [ 7 | './index.html', 8 | './src/**/*.{vue,js,ts,jsx,tsx}', 9 | ], 10 | 11 | // darkMode: ['class'], 12 | darkMode: ['class'], 13 | 14 | plugins: [ 15 | animations, 16 | iconsPlugin({ 17 | collections: getIconCollections(['solar', 'carbon', 'game-icons']), 18 | }), 19 | ], 20 | 21 | theme: { 22 | container: { 23 | center: true, 24 | padding: '2rem', 25 | screens: { 26 | '2xl': '1400px', 27 | }, 28 | }, 29 | extend: { 30 | animation: { 31 | 'accordion-down': 'accordion-down 0.2s ease-out', 32 | 'accordion-up': 'accordion-up 0.2s ease-out', 33 | }, 34 | borderRadius: { 35 | lg: 'var(--radius)', 36 | md: 'calc(var(--radius) - 2px)', 37 | sm: 'calc(var(--radius) - 4px)', 38 | }, 39 | colors: { 40 | accent: { 41 | DEFAULT: 'hsl(var(--accent))', 42 | foreground: 'hsl(var(--accent-foreground))', 43 | }, 44 | background: 'hsl(var(--background))', 45 | border: 'hsl(var(--border))', 46 | card: { 47 | DEFAULT: 'hsl(var(--card))', 48 | foreground: 'hsl(var(--card-foreground))', 49 | }, 50 | destructive: { 51 | DEFAULT: 'hsl(var(--destructive))', 52 | foreground: 'hsl(var(--destructive-foreground))', 53 | }, 54 | foreground: 'hsl(var(--foreground))', 55 | input: 'hsl(var(--input))', 56 | muted: { 57 | DEFAULT: 'hsl(var(--muted))', 58 | foreground: 'hsl(var(--muted-foreground))', 59 | }, 60 | popover: { 61 | DEFAULT: 'hsl(var(--popover))', 62 | foreground: 'hsl(var(--popover-foreground))', 63 | }, 64 | primary: { 65 | DEFAULT: 'hsl(var(--primary))', 66 | foreground: 'hsl(var(--primary-foreground))', 67 | }, 68 | ring: 'hsl(var(--primary))', 69 | secondary: { 70 | DEFAULT: 'hsl(var(--secondary))', 71 | foreground: 'hsl(var(--secondary-foreground))', 72 | primary: 'hsl(var(--secondary-primary))', 73 | }, 74 | }, 75 | keyframes: { 76 | 'accordion-down': { 77 | from: { height: 0 }, 78 | to: { height: 'var(--radix-accordion-content-height)' }, 79 | }, 80 | 'accordion-up': { 81 | from: { height: 'var(--radix-accordion-content-height)' }, 82 | to: { height: 0 }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowArbitraryExtensions": true, 4 | "allowImportingTsExtensions": true, 5 | "allowJs": false, 6 | "baseUrl": "./", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "isolatedModules": true, 10 | "jsx": "preserve", 11 | "lib": [ 12 | "ESNext", 13 | "DOM", 14 | "DOM.Iterable" 15 | ], 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "noEmit": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitReturns": true, 21 | "noImplicitThis": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "paths": { 25 | "@/*": [ 26 | "./src/*" 27 | ] 28 | }, 29 | "resolveJsonModule": true, 30 | "skipLibCheck": true, 31 | "strict": true, 32 | "target": "ESNext", 33 | "useDefineForClassFields": true 34 | }, 35 | "include": [ 36 | "src/**/*.ts", 37 | "src/**/*.tsx", 38 | "src/**/*.vue", 39 | "shims/**/*.d.ts", 40 | "uno.config.*", 41 | "vite.config.*", 42 | "vitest.config.*", 43 | "playwright.config.*" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { URL, fileURLToPath } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import pages from 'vite-plugin-pages' 6 | import jsx from '@vitejs/plugin-vue-jsx' 7 | import autoImport from 'unplugin-auto-import/vite' 8 | import components from 'unplugin-vue-components/vite' 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig(({ mode }) => ({ 12 | base: './', 13 | 14 | build: { 15 | cssMinify: 'lightningcss', 16 | // 是否输出 gzip 压缩大小的报告,设置 false 可以提高构建速度 17 | reportCompressedSize: false, 18 | rollupOptions: { 19 | output: { 20 | manualChunks: { 21 | // vueuse: ['@vueuse/core'], 22 | }, 23 | }, 24 | }, 25 | }, 26 | 27 | css: { 28 | devSourcemap: true, 29 | }, 30 | 31 | esbuild: { 32 | target: 'esnext', 33 | // 在生产环境下去掉 console/debugger 34 | drop: mode === 'production' ? ['console', 'debugger'] : [], 35 | }, 36 | 37 | optimizeDeps: { 38 | include: ['vue', 'pinia', 'vue-router', 'canvas-confetti', 'localforage'], 39 | exclude: ['vue-demi'], 40 | }, 41 | 42 | plugins: [ 43 | vue({ 44 | script: { 45 | defineModel: true, 46 | propsDestructure: true, 47 | }, 48 | }), 49 | 50 | jsx(), 51 | 52 | components({ 53 | dts: './shims/components.d.ts', 54 | extensions: ['vue', 'tsx'], 55 | // globs: ['src/components/**/index.{vue,tsx,ts}'] 56 | }), 57 | 58 | pages({ 59 | dirs: 'src/views', 60 | routeBlockLang: 'yaml', 61 | extensions: ['vue', 'tsx'], 62 | exclude: [ 63 | '**/*/components/**/*', 64 | '**/*/composables/**/*', 65 | '**/*/styles/**/*', 66 | '**/*/utils/**/*', 67 | ], 68 | }), 69 | 70 | autoImport({ 71 | dirs: [ 72 | './src/composables/**', 73 | ], 74 | dts: './shims/auto-imports.d.ts', 75 | imports: [ 76 | 'vue', 77 | 'vue-router', 78 | ], 79 | }), 80 | ], 81 | 82 | resolve: { 83 | alias: { 84 | '@': fileURLToPath(new URL('./src', import.meta.url)), 85 | }, 86 | }, 87 | 88 | // server: { 89 | // open: true, 90 | // proxy: { 91 | // '/api': { 92 | // changeOrigin: true, 93 | // target: 'http://localhost:3000', 94 | // rewrite: (path) => path.replace(/^\/api/, '') 95 | // } 96 | // } 97 | // } 98 | })) 99 | --------------------------------------------------------------------------------