其实这并不是我第一次做类似的小游戏,但却是第一次从构思、UI、到功能逻辑全盘自己打磨的一个完整项目。那段时间我刚好在看一本关于注意力训练的书,里面提到“舒尔特方格”这个训练法。看着那一格格数字,我忽然想到——不如做一个网页版的试试?

于是,一个念头就这么埋下了。

一切从一个想法开始

很多项目的起点都很微妙,而我的舒尔特方格,是从一张便签纸上写下“1~25的方格点击游戏”开始的。

我设想的核心逻辑并不复杂:生成一个打乱顺序的 N×N 数字矩阵,用户从 1 开始依次点击,记录点击时间,直到点击完最后一个数字。听上去就像一个初中生都能完成的项目,但真正写起来却一点都不轻松。

我决定用 Vue3 来实现这个项目,UI 构建和逻辑管理我都已经比较熟悉,而且 Vue3 的 Composition API 能让我更好地控制每一块逻辑的作用域。而构建工具,我选了 Vite,主要是因为它几乎不需要配置,起步够快,调试也爽。

技术选型:轻快与现代

项目的整体技术架构如下:

简单做一个舒尔特方格小游戏_UI

整套系统没有引入 Vuex 或 pinia,因为状态并不复杂,我直接用 refcomputed 就能管理好。这种轻量的方式在小项目里反而更高效。

下面是我最初启动 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 来完成主界面布局,并搭配媒体查询做出响应式适配。相比于 flexgrid 更适合固定网格结构的场景。

.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 显示提示语

流程如下图所示:

简单做一个舒尔特方格小游戏_Math_02


计时逻辑:不是秒表,却很准

我的计时需求很简单,不需要每一帧实时刷新,只需要在点击第一个数字时记录 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设计,其实我没有上来就开干,而是先画了一张草图,把界面按照功能块划分:

简单做一个舒尔特方格小游戏_css_03

我将界面分为三个主要区域:

  • 顶部栏:展示“舒尔特方格”标题,让用户知道自己在干嘛。
  • 中间内容区:包含难度选择、方格本体、状态提示(当前点击数字、用时等)。
  • 底部操作区:放置“重置”按钮,用户可以方便地重新开始。

这种模块式思维其实非常适合用于 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;
}

配色既符合注意力训练类应用的冷静氛围,又不失现代感,界面整体看起来也比较有呼吸感。


小型项目的调试策略

虽然项目很轻量,但我依然为自己定了一套简单的测试节奏:

  1. 组件开发阶段:使用 console.log() 检查 props 和 emits;
  2. 交互整合阶段:使用 Chrome DevTools 查看状态变化;
  3. 点击顺序测试:点击顺序错误时是否能正确标记;
  4. 完成流程测试:是否能正确判断胜利;
  5. 重置测试:状态是否能彻底重置,时间是否归零;
  6. 移动端测试:使用 Chrome 模拟器和手机真机双测;
  7. 浏览器兼容性:在 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 都影响手感
  • “点击正确数字”这一个功能,其实包含了状态管理、计时控制、用户反馈等多个层面

这些内容是我真切地去做、去调试、去体会出来的。

我一直觉得——技术文章写得再漂亮,如果读者读完没有“我也想做一个试试”的冲动,那就太可惜了。


项目总结流程图

为了方便大家回顾整个项目的设计与实现流程,我画了一张总结流程图:

简单做一个舒尔特方格小游戏_前端_04

从一开始的想法,到最终上线,这个过程虽然不算复杂,但很完整、很踏实。


我的收获

这个项目对我来说,最大的收获不在于技术的提升,而在于“如何做一个真正为人使用的小工具”。我学会了从用户角度思考,去感受一个按钮应该多大、一段动画应该多快、字体是否好认。

Vue3 和 Vite 的组合也让我感受到前端工具链的进化给开发者带来的巨大红利。开发过程真正做到了“所想即所得”,没有太多卡壳或者阻力。


结尾

回顾这个项目的开发旅程,我更想说一句话是:写代码,也是一种表达。

我想表达的,不只是技能或代码技巧,而是“我希望你也能享受这个小工具带来的专注时刻”。

如果你读到了这里,也许我们有着相似的思考方式与感知方式。很高兴能与你分享这段旅程。


感谢阅读。

简单做一个舒尔特方格小游戏_css_05