最近,当我看着我的孩子在平板电脑上玩免费记忆游戏时,我注意到她在应付大量广告和烦人的弹出横幅时感到困难。
这激发了我为她构建类似游戏的想法。由于她目前喜欢动漫,我决定使用可爱的动漫风格图片来创建游戏。
在本文中,我将带你了解如何为自己或孩子🎮构建这款游戏。
我们将首先探讨游戏功能,然后介绍技术栈和项目结构,两者都很简单。最后,我们将讨论优化和确保在移动设备上📱流畅游玩的问题。
如果你想跳过阅读,这里 💁 是 GitHub 代码库 🙌。而这里你可以查看实际演示。
目录
项目描述
在本教程中,我们将使用React构建一个具有挑战性的记忆卡游戏,测试您的回忆能力。您的目标是点击独特的动漫图像,不要连续点击相同的图像。每次独特的点击都会为您赚取积分,但要小心,连续点击一张图像会重置您的进度。
游戏特点:
-
🎯 挑战您记忆能力的动态游戏玩法
-
🔄 每次点击后卡片重新洗牌,增加难度
-
🏆 记分跟踪并保留最佳分数
-
😺 来自 The Nekosia API 的可爱动漫图片
-
✨ 流畅的加载过渡和动画
-
📱 适配所有设备的响应式设计
-
🎨 简洁,现代化的用户界面
这款游戏将帮助您在享受可爱的动漫图片的同时测试您的记忆技能。你能达到完美分数吗?
玩法
-
点击任意卡片开始
-
记住您已经点击过的卡片
-
尝试仅点击每个卡片一次
-
观察您每次独特选择时得分的增长
-
然后继续玩以尝试超越您的最佳分数
技术栈
以下是我们将使用的主要技术列表:
-
NPM – 用于 JavaScript 的包管理器,帮助管理项目的依赖关系和脚本。
- Vite – 一种构建工具,提供快速的开发环境,特别优化用于现代Web项目。
- React – 用于构建用户界面的流行JavaScript库,实现高效的渲染和状态管理。
- CSS Modules – 一种样式解决方案,将CSS限定在单个组件中,防止样式冲突并确保可维护性。
让我们来构建游戏
从这一点开始,我将引导您完成构建此游戏的过程。
项目结构和架构
在构建这个记忆卡片游戏时,我精心组织了代码库,以确保可维护性、可扩展性和关注点清晰分离。让我们探讨结构和每个决定背后的理由:
基于组件的架构
我选择了基于组件的架构,原因有几个:
-
模块化: 每个组件都是自包含的,具有自己的逻辑和样式
-
可重用性: 像
Card
和Loader
这样的组件可以在整个应用中重用 -
可维护性: 更容易调试和修改单独的组件
-
测试: 组件可以独立测试
组件组织
- 卡片组件
-
被分离到自己的目录中,因为它是核心游戏元素
-
包含JSX和SCSS模块以实现封装
-
处理单个卡片的渲染、加载状态和点击事件
- 卡片网格组件
-
管理游戏板布局
-
处理卡片的洗牌和分发
-
控制不同屏幕尺寸的响应式网格布局
- 加载器组件
-
可重复使用的加载指示器
-
在图片加载过程中改善用户体验
-
可被任何需要加载状态的组件使用
- 头部/底部/副标题组件
-
应用布局的结构组件
-
头部显示游戏标题和得分
-
底部显示版权和版本信息
-
副标题提供游戏说明
CSS 模块化方法
我使用 CSS 模块(.module.scss
文件)带来多个好处:
-
作用域样式:防止组件之间的样式泄漏
-
命名冲突:自动生成唯一的类名
-
可维护性:样式与组件共存
-
SCSS特性:利用SCSS特性保持样式模块化
自定义Hooks
hooks
目录包含自定义的Hooks,如useFetch:
-
关注点分离:隔离数据获取逻辑
-
可重用性:可供任何需要图像数据的组件使用
-
状态管理:处理加载、错误和数据状态
-
性能:实现诸如图像尺寸控制等优化
根级文件
App.jsx:
-
作为应用程序的入口点
-
管理全局状态和路由(如果需要)
-
协调组件组合
-
处理顶层布局
性能考虑
该结构支持性能优化:
-
代码分割:必要时可以延迟加载组件
-
记忆化:可以有效地对组件进行记忆
-
样式加载:CSS模块可以实现高效的样式加载
-
资源管理:图像和资源被正确组织
可扩展性
该结构支持轻松扩展:
-
新功能可以作为新组件添加
-
可以为新功能创建额外的钩子
-
随着应用程序的增长,样式保持可维护性
-
测试可以在任何级别实施
开发经验
该结构增强开发者体验:
-
清晰的文件组织
-
直观的组件位置
-
易于查找和修改特定功能
-
支持有效的协作
当优化游戏以适配平板时,这种架构尤其有价值,因为它允许我:
-
轻松识别和优化性能瓶颈
-
添加平板特定样式而不影响其他设备
-
实现加载状态以提升移动体验
-
保持游戏逻辑和UI组件之间的清晰分离
好了,现在让我们开始编码。
逐步构建指南
1. 项目设置
设置开发环境
为了开始一个干净的 React 项目,打开你的终端应用程序并运行以下命令(你可以根据自己的喜好命名项目文件夹 – 在我的例子中,名称是’memory-card’):
npm create vite@latest memory-card -- --template react
cd memory-card
npm install
安装所需的依赖项
这个项目中我们将使用的唯一依赖是来自 UI.dev 的 hook 包(顺便说一句,在这里你可以找到一篇关于 React 中渲染工作原理的解释详尽的文章)。
另一个依赖是著名的 CSS 预处理器,SASS,我们将需要它来以 SASS 而不是常规 CSS 写我们的 CSS 模块。
npm install @uidotdev/usehooks sass
配置 Vite 和项目设置
在设置项目时,我们需要进行一些特定的配置调整以处理 SASS 警告并改善我们的开发体验。以下是你可以如何配置 Vitest:
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.js'],
css: {
modules: {
classNameStrategy: 'non-scoped'
}
},
preprocessors: {
'**/*.scss': 'sass'
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/setupTests.js',
'src/main.jsx',
'src/vite-env.d.ts',
],
},
},
css: {
preprocessorOptions: {
scss: {
quietDeps: true, // 禁止 SASS 依赖项警告
charset: false // 防止在最新的 SASS 版本中出现字符集警告
}
}
}
});
请记住,当你使用 Vite 创建项目时,大多数这些配置都会自动生成给你。这是发生的情况:
-
SASS配置:
-
quietDeps: true
:这将消除关于SASS模块中弃用依赖项的警告。在使用第三方SASS/SCSS文件时特别有用。 -
charset: false
:防止在样式表中使用特殊字符时出现新版本SASS中的“@charset”警告。
-
-
测试配置:
-
globals: true
:使测试函数在测试文件中全局可用 -
environment: 'jsdom'
:为测试提供一个DOM环境 -
setupFiles
:指向我们的测试设置文件
-
这些配置有助于通过消除控制台中不必要的警告消息、设置适当的测试环境配置以及确保SASS/SCSS处理顺利运行,从而创建更清晰的开发体验。
当没有这些配置时,您可能会在控制台中看到警告消息,情况包括:
-
使用SASS/SCSS功能或导入SASS文件
-
运行需要DOM操作的测试
-
在样式表中使用特殊字符
2. 构建组件
创建卡片组件
首先,让我们创建基本的卡片组件,用于显示单独的图像:
// src/components/Card/Card.jsx
import React, { useState, useCallback } from "react";
import Loader from "../Loader";
import styles from "./Card.module.scss";
const Card = React.memo(function Card({ imgUrl, imageId, categoryName, processTurn }) {
const [isLoading, setIsLoading] = useState(true);
const handleImageLoad = useCallback(() => {
setIsLoading(false);
}, []);
const handleClick = useCallback(() => {
processTurn(imageId);
}, [processTurn, imageId]);
return (
<div className={styles.container} onClick={handleClick}>
{isLoading && (
<div className={styles.loaderContainer}>
<Loader message="Loading..." />
</div>
)}
<img
src={imgUrl}
alt={categoryName}
onLoad={handleImageLoad}
className={`${styles.image} ${isLoading ? styles.hidden : ''}`}
/>
</div>
);
});
export default Card;
卡片组件是我们游戏的基础构件。它负责显示单独的图像并处理玩家交互。让我们来分解它的实现:
属性细分:
-
image
: (字符串)-
要显示的图像的URL,从我们的API服务接收。
-
直接用于img标记的src属性。
-
-
id
: (字符串)-
每张卡片的唯一标识符,对跟踪已点击的卡片至关重要。
-
点击卡片时传递给
processTurn
回调函数。
-
-
category
: (字符串)-
描述图像的类型(例如,“动漫”,“猫咪”),用于alt属性以提高可访问性。
-
有助于SEO和屏幕阅读器。
-
-
processTurn
:(函数)-
从父组件传递的回调函数,用于处理点击卡片时的游戏逻辑。
-
还负责管理得分更新和游戏状态变化,并确定卡片是否已被点击。
-
-
isLoading
:(布尔值)-
控制是否显示加载状态。当为true时,显示Loader组件而不是图片。
在加载图片时提升用户体验。
-
组件样式:
// src/components/Card/Card.module.scss
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(0, 0, 0, 0.8);
padding: 20px;
font-size: 30px;
text-align: center;
min-height: 200px;
position: relative;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
.image {
width: 10rem;
height: auto;
opacity: 1;
transition: opacity 0.3s ease;
&.hidden {
opacity: 0;
}
}
.loaderContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
在组件中的使用:
<Card
key={getKey()}
imgUrl={item?.image?.original?.url || ""}
imageId={item?.id}
categoryName={item?.category}
processTurn={(imageId) => processTurn(imageId)}
/>
关键特性:
-
性能优化:
-
使用
React.memo
防止不必要的重新渲染 -
为事件处理程序实现
useCallback
-
内部管理加载状态以提升用户体验
-
-
加载状态管理:
-
内部
isLoading
状态跟踪图像加载 -
在加载过程中显示带有消息的Loader组件
-
使用CSS类隐藏图像直到完全加载
-
-
事件处理:
-
handleImageLoad
:管理加载状态转换 -
handleClick
:通过processTurn
回调处理玩家移动
-
构建CardsGrid组件
这是我们的主游戏组件,负责管理游戏状态、记分逻辑和卡片交互。让我们来解析其实现:
// src/components/CardsGrid/CardsGrid.jsx
import React, { useState, useEffect } from "react";
import { useLocalStorage } from "@uidotdev/usehooks";
import Card from "../Card";
import Loader from "../Loader";
import styles from "./CardsGrid.module.scss";
import useFetch from "../../hooks/useFetch";
function CardsGrid(data) {
// 状态管理
const [images, setImages] = useState(data?.data?.images || []);
const [clickedImages, setClickedImages] = useLocalStorage("clickedImages", []);
const [score, setScore] = useLocalStorage("score", 0);
const [bestScore, setBestScore] = useLocalStorage("bestScore", 0);
const [isLoading, setIsLoading] = useState(!data?.data?.images?.length);
// 用于获取图片的自定义钩子
const { data: fetchedData, fetchData, error } = useFetch();
// 当获取新数据时更新图片
useEffect(() => {
if (fetchedData?.images) {
setImages(fetchedData.images);
setIsLoading(false);
// 加载新批次时重置点击的图片
setClickedImages([]);
}
}, [fetchedData]);
// 更新最佳分数的辅助函数
function updateBestScore(currentScore) {
if (currentScore > bestScore) {
setBestScore(currentScore);
}
}
// 核心游戏逻辑
function processTurn(imageId) {
const newClickedImages = [...clickedImages, imageId];
setClickedImages(newClickedImages);
// 如果连续点击同一张图片两次,则重置所有内容
if (clickedImages.includes(imageId)) {
// 如有必要,更新最佳分数
updateBestScore(score);
setClickedImages([]);
setScore(0);
} else {
// 处理成功的卡片选择
const newScore = score + 1;
setScore(newScore);
// 检查完美分数(所有卡片都点击一次)
if (newClickedImages.length === images.length) {
updateBestScore(newScore);
fetchData();
setClickedImages([]);
} else {
// 对图片进行洗牌
const shuffled = [...images].sort(() => Math.random() - 0.5);
setImages(shuffled);
}
}
}
if (error) {
return <p>Failed to fetch data</p>;
}
if (isLoading) {
return <Loader message="Loading new images..." />;
}
return (
<div className={styles.container}>
{images.map((item) => (
<Card
key={getKey()}
imgUrl={item?.image?.original?.url || ""}
imageId={item?.id}
categoryName={item?.category}
processTurn={(imageId) => processTurn(imageId)}
/>
))}
</div>
);
}
export default React.memo(CardsGrid);
组件样式:
.container {
display: grid;
gap: 1rem 1rem;
grid-template-columns: auto; /* 默认:移动优先的一列 */
background-color: #2196f3;
padding: 0.7rem;
cursor: pointer;
}
@media (min-width: 481px) {
.container {
grid-template-columns: auto auto; /* 平板电脑及更高分辨率:两列 */
}
}
@media (min-width: 769px) {
.container {
grid-template-columns: auto auto auto; /* 台式电脑及更大分辨率:三列 */
}
}
主要特点分解:
-
状态管理:
-
使用
useState
来管理组件级状态 -
实现
useLocalStorage
来持久化游戏数据:-
clickedImages
: 跟踪已点击的卡片 -
score
: 当前游戏得分 -
bestScore
: 最高得分
-
-
管理加载状态以获取图片
-
洗牌卡片
-
-
游戏逻辑:
-
processTurn
:处理玩家移动-
跟踪重复点击
-
更新分数
-
管理完美得分情况
-
-
updateBestScore
:必要时更新最高分数 -
在完成一轮时自动获取新图片
-
-
数据获取:
-
使用自定义
useFetch
钩子获取图片数据 -
处理加载和错误状态
-
在获取新数据时更新图片
-
-
性能优化:
-
组件包裹在
React.memo
中 -
高效的状态更新
-
响应式网格布局
-
-
持久化:
-
游戏状态在页面重新加载时保持不变
-
最佳分数追踪
-
当前游戏进度保存
-
使用示例:
...
...
function App() {
const { data, loading, error } = useFetch();
if (loading) return <Loader />;
if (error) return <p>Error: {error}</p>;
return (
<div className={styles.container}>
<Header />
<Subtitle />
<CardsGrid data={data} />
<Footer />
</div>
);
}
export default App;
Memory Card游戏的核心是CardsGrid组件,负责管理:
-
游戏状态和逻辑
-
记分追踪
-
卡片交互
-
图像加载和显示
-
响应式布局
-
数据持久性
此实现提供了流畅的游戏体验,同时通过明确的关注点分离和适当的状态管理保持代码的可读性和可维护性。
3. 实现API层
我们的游戏使用一个强大的API层,具有多个后备选项,以确保可靠的图像传递。让我们实现每个服务和后备机制。
设置主要API服务:
// src/services/api/nekosiaApi.js
const NEKOSIA_API_URL = "https://api.nekosia.cat/api/v1/images/catgirl";
export async function fetchNekosiaImages() {
const response = await fetch(
`${NEKOSIA_API_URL}?count=21&additionalTags=white-hair,uniform&blacklistedTags=short-hair,sad,maid&width=300`
);
if (!response.ok) {
throw new Error(`Nekosia API error: ${response.status}`);
}
const result = await response.json();
if (!result.images || !Array.isArray(result.images)) {
throw new Error('Invalid response format from Nekosia API');
}
const validImages = result.images.filter(item => item?.image?.original?.url);
if (validImages.length === 0) {
throw new Error('No valid images received from Nekosia API');
}
return { ...result, images: validImages };
}
创建第一个后备API服务:
// src/services/api/nekosBestApi.js
const NEKOS_BEST_API_URL = "https://nekos.best/api/v2/neko?amount=21";
export async function fetchNekosBestImages() {
const response = await fetch(NEKOS_BEST_API_URL, {
method: "GET",
mode: "no-cors"
});
if (!response.ok) {
throw new Error(`Nekos Best API error: ${response.status}`);
}
const result = await response.json();
// 转换响应以匹配我们的预期格式
const transformedImages = result.results.map(item => ({
id: item.url.split('/').pop().split('.')[0], // 从URL中提取UUID
image: {
original: {
url: item.url
}
},
artist: {
name: item.artist_name,
href: item.artist_href
},
source: item.source_url
}));
return { images: transformedImages };
}
创建第二个后备API服务:
// src/services/api/nekosApi.js
const NEKOS_API_URL = "https://api.nekosapi.com/v3/images/random?limit=21&rating=safe";
export async function fetchNekosImages() {
const response = await fetch(NEKOS_API_URL, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Nekos API error: ${response.status}`);
}
const result = await response.json();
// 转换响应以匹配我们的预期格式
const transformedImages = result.items.map(item => ({
id: item.id,
image: {
original: {
url: item.image_url
}
}
}));
return { images: transformedImages };
}
构建 API 回退机制:
// src/services/api/imageService.js
import { fetchNekosiaImages } from "./nekosiaApi";
import { fetchNekosImages } from "./nekosApi";
import { fetchNekosBestImages } from "./nekosBestApi";
export async function fetchImages() {
try {
// 首先尝试主要 API
return await fetchNekosiaImages();
} catch (error) {
console.warn("Primary API failed, trying fallback:", error);
// 尝试第一个回退 API
try {
return await fetchNekosBestImages();
} catch (fallbackError) {
console.warn("First fallback API failed, trying second fallback:", fallbackError);
// 尝试第二个回退 API
try {
return await fetchNekosImages();
} catch (secondFallbackError) {
console.error("All image APIs failed:", secondFallbackError);
throw new Error("All image APIs failed");
}
}
}
}
使用图片服务:
// src/hooks/useFetch.js
import { useState, useEffect } from "react";
import { fetchImages } from "../services/api/imageService";
export default function useFetch() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const result = await fetchImages();
setData(result);
} catch (err) {
setError(err.message || 'An error occurred');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
return {
data,
loading,
error,
fetchData,
};
}
我们 API 实现的关键特点:
-
多个 API 来源:
-
主要 API(Nekosia):提供高质量的动漫图片
-
第一个回退(Nekos Best):包含艺术家信息
-
第二个回退(Nekos):简单可靠的备用
-
-
统一的数据格式:
- 所有 API 都会将它们的响应转换成我们期望的格式:
{
images: [
{
id: string,
image: {
original: {
url: string
}
}
}
]
}
-
健壮的错误处理:
-
验证API响应
-
检查有效的图片URL
-
提供详细的错误信息
-
优雅的回退机制
-
-
安全特性:
-
安全内容过滤(
rating=safe
) -
图片数量限制(21张图片)
-
URL验证
-
响应格式验证
-
-
性能考虑:
-
优化的图片大小
-
过滤的内容标签
-
高效的数据转换
-
最小化API调用
-
这种实现确保我们的游戏拥有可靠的图像来源,同时能够优雅地处理潜在的 API 失败。所有 API 中的一致数据格式使得在不影响游戏功能的情况下轻松切换。
应用测试
测试是任何应用开发的重要部分,对于我们的记忆卡游戏,我们采用了现代工具和实践实施了全面的测试策略。让我们深入了解我们是如何构建测试以及我们使用的一些关键测试模式的。
测试堆栈
-
Vitest: 我们的主要测试框架,选择它是因为速度快且与 Vite 无缝集成
-
React 测试库: 用于以用户为中心的方式测试 React 组件
-
@testing-library/user-event: 用于模拟用户交互
-
jsdom: 用于在我们的测试中创建一个DOM环境
关键测试模式
测试是确保这款记忆卡游戏可靠性和可维护性的关键部分。我使用React Testing Library和Vitest实施了全面的测试策略,重点关注以下几个关键领域:
1. 组件测试
我为我的React组件编写了大量测试,以确保它们正确渲染并按预期运行。例如,CardsGrid
组件是游戏的核心,其完整的测试覆盖包括:
-
初始渲染状态
-
加载状态
-
错误处理
-
得分跟踪
-
卡片交互行为
2. 测试模拟
为了确保可靠且快速的测试,我实施了几种模拟策略:
-
使用useLocalStorage钩子进行本地存储操作
-
使用
useFetch
钩子进行API调用 -
事件处理程序和状态更新
3. 测试最佳实践
在我的测试实现过程中,我遵循了几项最佳实践:
-
使用
beforeEach
和afterEach
钩子在测试之间重置状态 -
使用
fireEvent
从React Testing Library测试用户交互 -
编写类似用户与应用程序交互的测试
-
测试成功和错误场景
-
使用适当的模拟来隔离测试
4. 测试工具
该项目利用了现代化的测试工具和库:
-
Vitest:作为测试运行器
-
React Testing Library:用于测试React组件
-
@testing-library/jest-dom:用于增强DOM测试断言
-
@testing-library/user-event:用于模拟用户交互
这种全面的测试方法帮助我及早发现错误,确保代码质量,并使重构更安全和更易管理。
优化
为确保流畅性能,特别是在移动设备上,我们实施了几种优化技术:
-
响应转换
-
在所有API中统一数据格式
-
从URL中高效提取ID
-
结构化图像元数据以便快速访问
-
-
网络优化
-
在适当的情况下使用
no-cors
模式以有效处理CORS问题 -
使用特定状态代码进行错误处理以便更好调试
-
在所有API实现中保持一致的响应结构
-
-
移动优先考虑事项
-
优化的图片加载策略
-
高效的错误处理以防止不必要的重试
-
简化的数据转换以减少处理开销
-
未来改进
有几种方式可以进一步改进这个项目:
-
API响应缓存
-
为经常使用的图片实现本地存储缓存
-
为新内容添加缓存失效策略
-
实现渐进式图片加载
-
-
性能优化
-
为更好的初始加载时间添加图像懒加载
-
实现请求排队以更好地管理带宽
-
为更快数据传输添加响应压缩
-
-
可靠性增强
-
尝试之前添加API健康检查
-
使用指数退避实现重试机制
-
为失败的API添加断路器模式
-
-
分析与监控
-
跟踪API成功率
-
监控响应时间
-
根据性能指标实现自动API切换
-
这种强大的实现确保我们的游戏在不利的网络条件或API不可用时仍然保持功能和性能,同时仍然留有未来改进和优化的空间。
结论
构建这个记忆卡片游戏不仅仅是为孩子们创造一个有趣的、无广告的替代品——这也是在解决一个现实问题的同时实施现代网页开发最佳实践的练习。
该项目展示了如何将深思熟虑的架构、强大的测试和可靠的回退机制结合起来,从而产生一个既有趣又具有教育意义的可投入生产的应用。
🗝️ 关键要点
-
以用户为中心的开发
-
从一个明确的问题开始(广告填充的游戏影响用户体验)
-
实施增强游戏体验而不干扰的功能
-
始终专注于跨设备的性能和可靠性
-
-
技术卓越
-
利用现代的React模式和hooks编写清晰、可维护的代码
-
实施全面的测试策略,确保可靠性
-
为不间断的游戏体验创建强大的API备用系统
-
-
性能至上
-
采用响应式设计的移动优先方法
-
优化图片加载和处理
-
实现了高效的状态管理和缓存策略
-
📚 学习成果
这个项目展示了看似简单的游戏如何成为实施复杂技术解决方案的绝佳载体。从组件架构到 API 回退,每个功能都考虑到了可扩展性和可维护性,证明即使是业余项目也可以保持专业级的代码质量。
🔮 展望未来
虽然游戏成功实现了提供无广告、愉快体验的主要目标,但记录的未来改进提供了明确的演进路线图。无论是实施额外的优化还是增加新功能,基础坚实且准备好扩展。
这款记忆卡游戏展示了个人项目如何既解决现实世界问题,又作为实施现代 Web 开发最佳实践的平台的证明。随意探索代码,贡献代码,或将其作为启发开展您自己的项目!
Source:
https://www.freecodecamp.org/news/how-to-build-a-memory-card-game-using-react/