简单做一个舒尔特方格小游戏
其实这并不是我第一次做类似的小游戏,但却是第一次从构思、UI、到功能逻辑全盘自己打磨的一个完整项目。那段时间我刚好在看一本关于注意力训练的书,里面提到“舒尔特方格”这个训练法。看着那一格格数字,我忽然想到——不如做一个网页版的试试?
于是,一个念头就这么埋下了。
一切从一个想法开始
很多项目的起点都很微妙,而我的舒尔特方格,是从一张便签纸上写下“1~25的方格点击游戏”开始的。
我设想的核心逻辑并不复杂:生成一个打乱顺序的 N×N 数字矩阵,用户从 1 开始依次点击,记录点击时间,直到点击完最后一个数字。听上去就像一个初中生都能完成的项目,但真正写起来却一点都不轻松。
我决定用 Vue3 来实现这个项目,UI 构建和逻辑管理我都已经比较熟悉,而且 Vue3 的 Composition API 能让我更好地控制每一块逻辑的作用域。而构建工具,我选了 Vite,主要是因为它几乎不需要配置,起步够快,调试也爽。
技术选型:轻快与现代
项目的整体技术架构如下:
整套系统没有引入 Vuex 或 pinia,因为状态并不复杂,我直接用 ref
和 computed
就能管理好。这种轻量的方式在小项目里反而更高效。
下面是我最初启动 Vite 的配置:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'export default defineConfig({plugins: [vue()],server: {port: 3000,open: true}
})
启动后就是一块空白的页面,但也正是在这片空白里,我开始描绘出属于我自己的方格世界。
核心组件:SchulteGrid 的灵魂
我的核心组件叫做 SchulteGrid.vue
,这块组件负责的内容不少:
- 生成打乱顺序的数字序列
- 渲染成一个 n×n 的方格
- 响应用户点击
- 控制计时、结束状态
- 提供正确/错误点击的反馈
最初我并没有分太多模块,而是把所有逻辑塞进一个组件,随着功能变复杂我才慢慢抽离。
下面是组件的模板结构部分:
<template><div class="grid-container"><div v-for="(num, index) in shuffledNumbers" :key="index"class="grid-item"@click="handleClick(num)":class="{correct: clickedNumbers.includes(num),wrong: wrongClick === num}">{{ num }}</div></div>
</template>
从点击反馈到动态生成的顺序,我用的是纯 Vue 的响应式机制。
状态管理也比较简单:
const numbers = ref<number[]>([])
const clickedNumbers = ref<number[]>([])
const wrongClick = ref<number | null>(null)
const startTime = ref<number | null>(null)
const endTime = ref<number | null>(null)
随机打乱我用了最简单的 Fisher-Yates 洗牌:
const shuffledNumbers = computed(() => {return [...numbers.value].sort(() => Math.random() - 0.5)
})
我知道 .sort(() => Math.random() - 0.5)
并不严谨,但对于这个练习游戏来说,已经足够了。
点击交互:从“点”出发的逻辑
点击的逻辑其实是最关键的一块,是否正确、是否开始计时、是否完成、是否点错,都在这一瞬间做出判断。
const handleClick = (num: number) => {if (gameStatus.value === 'completed') returnconst expected = clickedNumbers.value.length + 1if (num === expected) {if (clickedNumbers.value.length === 0) {startTime.value = Date.now()}clickedNumbers.value.push(num)wrongClick.value = nullif (clickedNumbers.value.length === numbers.value.length) {endTime.value = Date.now()}} else {wrongClick.value = num}
}
这里我用了一个很小的 trick,就是 wrongClick
。当你点错的时候,这个值会变成错误的数字,配合样式渲染成红色+抖动动画,视觉上非常直观。
视觉体验:简单不等于简陋
我很重视 UI,哪怕是一个很轻的小游戏。视觉上我希望有一点点“Material”的简洁感,又不要太工业风。我用了以下样式:
:root {--color-primary: #6366f1;--color-correct: #10b981;--color-error: #ef4444;
}.grid-item {background: white;border-radius: 8px;box-shadow: 0 2px 4px rgba(0,0,0,0.06);transition: all 0.2s;cursor: pointer;user-select: none;
}.grid-item:hover {transform: scale(1.05);
}.grid-item.correct {background-color: var(--color-correct);color: white;
}.grid-item.wrong {background-color: var(--color-error);color: white;animation: shake 0.4s;
}@keyframes shake {0%, 100% { transform: translateX(0); }25% { transform: translateX(-4px); }50% { transform: translateX(4px); }75% { transform: translateX(-4px); }
}
颜色统一用 CSS 变量管理,逻辑清晰。动画都基于 transform
,因为它们不会引起重排,性能也更好。
响应式布局:从桌面到手机都不能“掉帧”
为了让这个方格游戏在所有设备上都能顺畅运行,我决定使用 CSS Grid
来完成主界面布局,并搭配媒体查询做出响应式适配。相比于 flex
,grid
更适合固定网格结构的场景。
.grid-container {display: grid;gap: 12px;grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));margin: 20px auto;padding: 10px;max-width: 600px;
}@media (max-width: 480px) {.grid-container {gap: 8px;grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));}
}
通过 minmax
搭配 auto-fill
,我实现了弹性列宽,数字方格会根据屏幕自动调整行列数量,即便是在竖屏手机上,也能保持良好可读性。
状态控制:游戏的“心跳”节奏
当我意识到游戏状态会影响到多个 UI 交互点时,我专门引入了一个计算属性 gameStatus
来统一判断游戏流程。
const gameStatus = computed(() => {if (clickedNumbers.value.length === 0) return 'ready'if (clickedNumbers.value.length === numbers.value.length) return 'completed'return 'playing'
})
我在多个地方都用到了这个状态值:
- 计时逻辑
- 点击控制(完成后禁止点击)
- UI 显示提示语
流程如下图所示:
计时逻辑:不是秒表,却很准
我的计时需求很简单,不需要每一帧实时刷新,只需要在点击第一个数字时记录 startTime
,点击最后一个数字时记录 endTime
,中间计算差值即可。
const elapsedTime = computed(() => {if (!startTime.value) return 0const now = endTime.value || Date.now()return Math.floor((now - startTime.value) / 1000)
})
为什么我选择用 Date.now()
而不是 performance.now()
?其实在这个项目里,我们只关心到秒级的准确性,用 Date
反而更直观,也免去了一些兼容性问题。
我还加了一点细节,当游戏还没结束时,每隔一秒强制更新一次 DOM,让用户看到“正在计时中”。这个通过 setInterval
实现:
onMounted(() => {timer.value = setInterval(() => {if (gameStatus.value === 'playing') elapsedTime.value}, 1000)
})onUnmounted(() => {clearInterval(timer.value)
})
游戏控制按钮:重置和难度选择
有时候用户想“再来一次”,我就加了一个重置按钮,它其实就是把状态恢复:
const resetGame = () => {clickedNumbers.value = []startTime.value = nullendTime.value = nullwrongClick.value = nullgenerateNumbers(gridSize.value)
}
另一个需求是改变游戏难度,也就是更改方格尺寸,我设计了一个可选项:
<select v-model="gridSize" @change="resetGame"><option :value="3">3×3</option><option :value="4">4×4</option><option :value="5">5×5</option><option :value="6">6×6</option>
</select>
变更 gridSize
后,会重新生成数字矩阵,整个 UI 会自动响应式地改变。
生成逻辑很简单:
const generateNumbers = (size: number) => {numbers.value = Array.from({ length: size * size }, (_, i) => i + 1)
}
项目结构:保持简洁的约定式组织
整个项目的目录结构我尽量保持扁平:
src/
├── assets/ # 图片和字体等资源
├── components/
│ └── SchulteGrid.vue # 游戏主组件
├── App.vue # 根组件
├── main.ts # 入口文件
└── style/└── base.css # 通用样式
其中 SchulteGrid.vue
是核心,其他都是围绕它进行功能包装和样式设定。
用户体验的“细节补丁”
游戏虽然简单,但很多细节不能含糊:
- 错误点击提示要及时消失:我加了一个
setTimeout
,点击错误 500ms 后自动清除红色状态; - 完成游戏后自动展示用时:UI 下方会出现“恭喜你,用时 X 秒”;
- 点击按钮时动画反馈:我为按钮也加上了按压动画,让点击感更真实;
- 字体大小自动调整:通过
clamp()
和vw
单位让数字字体在大屏与小屏上都不过小或过大; - 选择难度后自动 scroll 到顶部:移动端避免长页面点击不便。
这些东西说起来不值一提,但一旦缺失,就会影响整体验。
游戏UI的整体布局设计:组件组合与语义层次
回过头看整个UI设计,其实我没有上来就开干,而是先画了一张草图,把界面按照功能块划分:
我将界面分为三个主要区域:
- 顶部栏:展示“舒尔特方格”标题,让用户知道自己在干嘛。
- 中间内容区:包含难度选择、方格本体、状态提示(当前点击数字、用时等)。
- 底部操作区:放置“重置”按钮,用户可以方便地重新开始。
这种模块式思维其实非常适合用于 Vue 组件结构拆分,每个区域我都尽量做成可复用组件,并明确每个组件的职责边界。
组件拆解与封装原则
虽然项目体量不大,但我还是坚持把每个功能块拆成单独组件,这样的好处是:
- 逻辑集中:点击逻辑、动画效果、状态变化都只影响对应组件;
- 样式隔离:使用 scoped CSS 避免样式冲突;
- 方便复用:将来可以直接复用
GridCell.vue
来做别的游戏,比如滑块拼图。
组件层级如下:
App.vue
├── Header.vue // 顶部标题
├── SchulteGrid.vue // 网格主组件
│ ├── GridCell.vue // 单元格子项
├── ControlPanel.vue // 难度选择、状态显示
└── FooterPanel.vue // 重置按钮等
组件之间通过 props 和 emits 通信,状态集中在 App.vue
或通过 provide/inject 简单下传,不依赖 Vuex 或 Pinia 这样的大型状态管理库。
难度选择器实现逻辑
游戏支持 3×3 ~ 6×6 的网格切换,我不想硬编码,而是做成动态的:
<!-- ControlPanel.vue -->
<select v-model="selected" @change="$emit('change', selected)"><option v-for="n in [3,4,5,6]" :key="n" :value="n">{{ n }}×{{ n }}</option>
</select>
父组件接收到 change
后更新 gridSize
并重置游戏。为什么使用 select 而不是按钮?考虑到将来可能支持更大维度(比如 7×7、8×8),下拉菜单更好扩展。
GridCell.vue 中的点击反馈动画
单元格的点击动画对手感影响极大。我为正确点击和错误点击分别加了不同样式:
<div:class="['grid-item',clicked ? 'correct' : '',isWrong ? 'wrong' : '']"@click="handleClick"
>{{ number }}
</div>
搭配样式如下:
.grid-item {user-select: none;border-radius: 8px;transition: all 0.2s ease;background-color: white;font-weight: bold;font-size: clamp(1.2rem, 3vw, 2rem);
}.grid-item.correct {background-color: var(--color-success);color: white;
}.grid-item.wrong {background-color: var(--color-error);animation: shake 0.3s ease;
}@keyframes shake {0% { transform: translateX(0); }25% { transform: translateX(-4px); }50% { transform: translateX(4px); }75% { transform: translateX(-2px); }100% { transform: translateX(0); }
}
这种级别的动画足够有反馈,但又不会影响性能(尤其是在移动端)。我避免使用过多 box-shadow 或 scale,转而使用 transform 以提升渲染效率。
遇到的最大坑:数字乱序算法重复
最初我用这个方法打乱数字数组:
const shuffled = numbers.value.sort(() => Math.random() - 0.5)
结果连续好几次出现顺序没变的情况。后来我查到 .sort()
的实现不一定稳定,推荐使用“Fisher–Yates 洗牌算法”:
const shuffle = (array: number[]) => {const arr = [...array]for (let i = arr.length - 1; i > 0; i--) {const j = Math.floor(Math.random() * (i + 1));[arr[i], arr[j]] = [arr[j], arr[i]]}return arr
}
调用时:
numbers.value = shuffle(generateNumbers(size))
这个算法能确保每个排列等概率出现,不会出现“没洗干净”的情况。
样式主题设计:柔和色调 + 几何感字体
我选用的是一种偏冷静的蓝紫色主题:
:root {--color-primary: #6366f1;--color-success: #10b981;--color-error: #ef4444;--bg: #f8fafc;
}
页面背景使用柔和灰白 #f8fafc
,字体选择了 Poppins:
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
在 base.css
统一设定:
body {font-family: 'Poppins', sans-serif;background-color: var(--bg);color: #333;line-height: 1.6;
}
配色既符合注意力训练类应用的冷静氛围,又不失现代感,界面整体看起来也比较有呼吸感。
小型项目的调试策略
虽然项目很轻量,但我依然为自己定了一套简单的测试节奏:
- 组件开发阶段:使用
console.log()
检查 props 和 emits; - 交互整合阶段:使用 Chrome DevTools 查看状态变化;
- 点击顺序测试:点击顺序错误时是否能正确标记;
- 完成流程测试:是否能正确判断胜利;
- 重置测试:状态是否能彻底重置,时间是否归零;
- 移动端测试:使用 Chrome 模拟器和手机真机双测;
- 浏览器兼容性:在 Safari 上字体是否变形、动画是否卡顿。
移动端适配细节:一点点精调打磨出的舒适感
虽然项目在桌面端表现不错,但在实际手机真机预览时,我注意到几个明显问题:
- 数字太小,不容易点击
- 间距太小,容易误触
- 动画略微卡顿(尤其是点击反馈)
于是我做了针对性的调整,首先在 GridCell
中使用了 clamp()
来自动适配字体大小:
.grid-item {font-size: clamp(1rem, 5vw, 2rem);padding: clamp(8px, 2vw, 16px);
}
然后我在 App.vue
外层容器添加了最大宽度限制:
.container {max-width: 600px;margin: 0 auto;padding: 16px;
}
并针对小屏设置断点优化:
@media (max-width: 480px) {.grid-container {gap: 6px;}.control-panel {flex-direction: column;}
}
这使得整个 UI 在手机上看起来依然清晰舒展,不会有“缩小版桌面网页”的压迫感。
项目构建与部署流程
项目构建使用的是 Vite 内置的 build
命令,非常快速高效。配置文件如下:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'export default defineConfig({plugins: [vue()],base: './', // 支持 GitHub Pages 之类的路径部署build: {outDir: 'dist',minify: 'esbuild'}
})
构建命令:
npm run build
生成的 dist/
文件夹可以直接部署到静态托管平台,我本人用的是 GitHub Pages,非常方便:
npm install -g gh-pages
gh-pages -d dist
几分钟之后,线上地址就可以访问了。
项目效果预览截图位置
在实际部署后,我从手机和电脑两端分别截图,记录了各个阶段效果:
- 游戏初始界面
- 点击中动画反馈
- 游戏完成提示
- 各个难度的网格展示(3×3~6×6)
这些图像可以非常直观地呈现项目完成度与视觉效果,也便于在分享文章时展示。
写这篇博客的背后:关于“真实感”的追求
写这篇博客对我来说并不仅仅是一次“记录开发过程”的任务,而是一次表达真实经验的机会。我不想堆砌一堆高大上的术语,而是像聊天一样讲述这个小项目是怎么一点点被我做出来的。
过程中踩了很多小坑,比如:
- 数字打乱算法看似简单其实容易出错
- 动画看似装饰,其实极大影响体验
- 布局看似随意,其实每个 padding 和 gap 都影响手感
- “点击正确数字”这一个功能,其实包含了状态管理、计时控制、用户反馈等多个层面
这些内容是我真切地去做、去调试、去体会出来的。
我一直觉得——技术文章写得再漂亮,如果读者读完没有“我也想做一个试试”的冲动,那就太可惜了。
项目总结流程图
为了方便大家回顾整个项目的设计与实现流程,我画了一张总结流程图:
从一开始的想法,到最终上线,这个过程虽然不算复杂,但很完整、很踏实。
我的收获
这个项目对我来说,最大的收获不在于技术的提升,而在于“如何做一个真正为人使用的小工具”。我学会了从用户角度思考,去感受一个按钮应该多大、一段动画应该多快、字体是否好认。
Vue3 和 Vite 的组合也让我感受到前端工具链的进化给开发者带来的巨大红利。开发过程真正做到了“所想即所得”,没有太多卡壳或者阻力。
结尾
回顾这个项目的开发旅程,我更想说一句话是:写代码,也是一种表达。
我想表达的,不只是技能或代码技巧,而是“我希望你也能享受这个小工具带来的专注时刻”。
如果你读到了这里,也许我们有着相似的思考方式与感知方式。很高兴能与你分享这段旅程。
感谢阅读。