当前位置: 首页 > news >正文

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

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

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

一切从一个想法开始

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

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

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

技术选型:轻快与现代

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

在这里插入图片描述

整套系统没有引入 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') 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 来完成主界面布局,并搭配媒体查询做出响应式适配。相比于 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 显示提示语

流程如下图所示:

在这里插入图片描述


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

我的计时需求很简单,不需要每一帧实时刷新,只需要在点击第一个数字时记录 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;
}

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


小型项目的调试策略

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

  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 都影响手感
  • “点击正确数字”这一个功能,其实包含了状态管理、计时控制、用户反馈等多个层面

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

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


项目总结流程图

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

在这里插入图片描述

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


我的收获

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

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


结尾

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

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

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


感谢阅读。

在这里插入图片描述

http://www.dtcms.com/a/463069.html

相关文章:

  • jdk自带调优工具
  • 网站加载速度影响因素网站建设的步骤过程文库
  • 电子商务网站的后台管理系统爱网度假
  • C语言基础之指针3
  • 青岛网站权重提升联兴建设官方网站
  • 北大荒建设集团有限公司网站龙湖镇华南城网站建设
  • 中英网站搭建报价表网站制作公司拟
  • 从 C1K 到 C1M:高并发网络 I/O 模型的四次关键演进
  • 了解公司的网站网站案例 中企动力技术支持
  • 历史级行情来袭?
  • 站内免费推广的方式有哪些电商网站建设好么
  • 泰州做企业网站的哪里好深圳光明区最新消息
  • 网站建设结算系统注册一家公司需要多少费用
  • [论文阅读]Dataset Protection via Watermarked Canaries in Retrieval-Augmented LLMs
  • 2023自动化测试岗位面试题分享(部分给出答案,持续更新中。。。)
  • 宜兴网站建设ci框架建设网站案例
  • 淄博高效网站建设找哪家广东省企业信用信息网
  • 个人用云计算学习笔记 --22(Shell 编程-1)
  • 市场调研报告ppt模板成都网站优化软件
  • JVM 类加载机制深度解析
  • 【并发编程】详解volatile
  • 商务网站建设与维护实训报告网站建设的工作计划
  • 佛山电商网站制作那些网站是静态
  • 网络营销的发展趋势太原网站排名优化价格
  • 基于Qt框架开发的IP地址输入控件
  • Redis高可用与扩展性深度解析:主从复制、哨兵与集群
  • 深入理解手机快充技术:原理、协议与嵌入式实现
  • 小杰深度学习(seven)——卷积神经网络——池化
  • gSOAP: 一个C++构建Web服务和跨语言开发的利器
  • 网站简易后台计算机网站开发毕业设计论文开题报告