其实这并不是我第一次做类似的小游戏,但却是第一次从构思、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') return
const expected = clickedNumbers.value.length + 1
if (num === expected) {
if (clickedNumbers.value.length === 0) {
startTime.value = Date.now()
}
clickedNumbers.value.push(num)
wrongClick.value = null
if (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 0
const 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 = null
endTime.value = null
wrongClick.value = null
generateNumbers(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 的组合也让我感受到前端工具链的进化给开发者带来的巨大红利。开发过程真正做到了“所想即所得”,没有太多卡壳或者阻力。
结尾
回顾这个项目的开发旅程,我更想说一句话是:写代码,也是一种表达。
我想表达的,不只是技能或代码技巧,而是“我希望你也能享受这个小工具带来的专注时刻”。
如果你读到了这里,也许我们有着相似的思考方式与感知方式。很高兴能与你分享这段旅程。
感谢阅读。

















