├── .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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/assets/merchant.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/libondev/memory-block/18d110979a24f6d502dbb2f333f527250762a6cf/src/assets/merchant.png
--------------------------------------------------------------------------------
/src/components/Button.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/Grid.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/GridItem.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
39 |
40 |
41 |
131 |
--------------------------------------------------------------------------------
/src/components/Increase.vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
57 | {{ value }}
58 |
59 |
60 | {{ operater }}{{ deltaValue }}
67 |
68 |
69 |
70 |
71 |
82 |
--------------------------------------------------------------------------------
/src/components/Input.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/components/LevelTag.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 | {{ GAME_LEVELS[level].zh }}
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/components/NavHeader.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/PropsBag.vue:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
38 |
39 |
44 | 道具栏
45 |
46 |
47 |
55 |
56 |
62 |
63 |
64 | {{ good.name }}
65 |
66 |
67 | {{ good.count }}
68 |
69 |
70 |
71 | 暂无道具,
72 | 去购买
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/src/components/Select.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
31 | {{ optionsMap[modelValue!] }}
32 |
33 |
34 |
42 |
45 |
46 |
53 | {{ option.label }}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/src/components/Switch.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ToggleDark.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
15 |
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 |
344 |
345 |
346 |
347 | {{ gameStatus.previewing ? $t('remember-block-locations', '请记住以下方块位置') : gameStatus.over ? $t('game-over', '游戏结束') : $t('start', '游戏开始')
348 | }}
349 | ({{ countdown }})
350 |
351 |
352 |
353 |
354 |
355 |
356 | {{ checkedNumber }}/{{ targetBlocks.size }}
357 |
358 |
359 |
360 |
361 | {{ timestamp }}s
362 |
363 |
364 |
375 |
376 |
377 |
378 |
379 |
380 |
381 | {{ formatScore(gameMoney) }}
382 |
383 |
384 |
385 |
386 |
387 | {{ formatScore(highestScore) }}
388 |
389 |
390 |
391 |
392 |
393 | {{ formatScore(gameScore) }}
394 |
395 | BEST
399 |
400 |
401 |
402 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
418 |
419 |
422 |
423 |
424 |
425 |
426 |
--------------------------------------------------------------------------------
/src/views/index.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 | {{ $t('memory-block', '记忆方块') }}
14 |
15 |
16 |
17 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/views/record/[date].vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
29 |
30 |
--------------------------------------------------------------------------------
/src/views/record/index.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
18 |
19 |
--------------------------------------------------------------------------------
/src/views/settings/custom.vue:
--------------------------------------------------------------------------------
1 |
77 |
78 |
79 |
127 |
128 |
--------------------------------------------------------------------------------
/src/views/store/index.vue:
--------------------------------------------------------------------------------
1 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 流浪商人还有 {{ comingCountdown }} 天抵达...
102 |
103 |
104 |
105 |

106 |
107 |
108 | 你好,我是(流浪)旅行至此的商人,我每周末都会为你带来更低的价格(别问为什么,问就是老板是我表哥),随便看看吧!(商品滞销帮帮我们)
109 |
110 |
111 |
112 |
151 |
152 |
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 |
--------------------------------------------------------------------------------