使用React构建一个掷骰子的小游戏
这是一个用 React 构建的小游戏应用,名为 Tenzies,目标是掷骰子,直到所有骰子的值相同。玩家可以“冻结”某些骰子,使它们在后续掷骰中保持不变。
1. App.jsx
import Die from "../public/components/Die"
import { useState, useRef, useEffect } from "react"
import { nanoid } from "nanoid"
import Confetti from "react-confetti"
- Die:自定义组件,用于显示单个骰子。
- useState、useRef、useEffect:React hooks,用于管理状态、DOM引用和副作用。
- nanoid:用于生成唯一 ID 的库,确保每个骰子有唯一标识符。
- Confetti:库组件,用于显示游戏胜利的彩带特效。
初始化状态和引用
export default function App(){
const [dice, setDice] = useState(() => generateAllNewDice())
const buttonRef = useRef(null)
dice:管理 10 个骰子的数组状态。初始值通过 generateAllNewDice
函数生成。
buttonRef:引用按钮 DOM 元素,用于在胜利时自动聚焦。
游戏胜利条件
const gameWon = dice.every(die => die.isHeld) &&
dice.every(die => die.value === dice[0].value)
游戏胜利的条件:
- 所有骰子的 isHeld 属性为 true(即冻结)。
- 所有骰子的值相同。
处理副作用
useEffect(() => {
if (gameWon) {
buttonRef.current.focus()
}
}, [gameWon])
当 gameWon
为 true
时,自动让“新游戏”按钮获得焦点,提升可用性。
生成新的骰子数组
function generateAllNewDice() {
return new Array(10).fill(0).map(() => ({
value: Math.ceil(Math.random() * 6),
isHeld: false,
id: nanoid()
}))
}
创建 10 个骰子对象,每个骰子有:
value
:1 到 6 的随机数。isHeld
:初始为 false,表示未冻结。id
:唯一标识符,使用 nanoid 生成。
Math.ceil(Math.random() * 6)
是 JavaScript 中生成随机整数的常见方法之一。下面逐步解释其工作原理:
Math.random()
- 作用:生成一个 0(包含)到 1(不包含) 的随机浮点数。
- 范围:
[0, 1)
。- 例如:可能的结果是
0.2345
,0.9876
,0.0012
等。
- 例如:可能的结果是
Math.random() * 6
- 作用:将生成的随机数放大至 0 到 6(不包含 6) 的范围。
- 范围:
[0, 6)
。- 示例:
- 如果
Math.random()
返回0.2
,则0.2 * 6 = 1.2
。 - 如果
Math.random()
返回0.9
,则0.9 * 6 = 5.4
。
- 如果
- 示例:
Math.ceil()
- 作用:对数字向上取整,返回大于等于该数的最小整数。
- 例如:
Math.ceil(1.2)
返回2
。Math.ceil(5.4)
返回6
。Math.ceil(0)
返回0
。
综合步骤
Math.ceil(Math.random() * 6)
的完整过程:
- 调用
Math.random()
生成一个随机数,例如0.45
。 - 将该随机数乘以
6
,结果是2.7
。 - 用
Math.ceil()
对结果向上取整,得到3
。
返回结果
最终的返回值是一个 1 到 6 的随机整数:
- 范围:
[1, 6]
。 - 为何能覆盖 1 到 6?
Math.random()
取值为0
时,Math.random() * 6 = 0
,取整后为1
。Math.random()
接近1
时,Math.random() * 6
接近6
,取整后为6
。
掷骰子逻辑
function rollDice() {
if (!gameWon) {
setDice(oldDice => oldDice.map(die =>
die.isHeld ?
die :
{...die, value: Math.ceil(Math.random() * 6)}
))
} else {
setDice(generateAllNewDice())
}
}
游戏未胜利时:
- 对未冻结的骰子重新生成随机值。
游戏胜利时:
- 重置游戏,生成新的骰子数组。
切换冻结状态
function hold(id) {
setDice(oldDice => oldDice.map(die =>
die.id === id ?
{...die, isHeld: !die.isHeld} :
die
))
}
根据点击的骰子 id,切换对应骰子的 isHeld 状态。
显示骰子
const diceElements = dice.map(dieObj =>
(<Die
key={dieObj.id}
value={dieObj.value}
isHeld={dieObj.isHeld}
hold={() => hold(dieObj.id)}
/>)
)
- 使用 map 遍历 dice 状态,为每个骰子生成一个 Die 组件。
- hold 函数传递给每个骰子,用于处理点击事件。
渲染 UI
return (
<main>
{gameWon && <Confetti />}
<div aria-live="polite" className="sr-only">
{gameWon && <p>Congratulations! You won! Press "New Game" to start again.</p>}
</div>
<h1 className="title">Tenzies</h1>
<p className="instructions">Roll until all dice are the same. Click each die to freeze it at its current value between rolls.</p>
<div className="dice-container">
{diceElements}
</div>
<button ref={buttonRef} className="roll-dice" onClick={rollDice}>
{gameWon ? "New Game" : "Roll"}
</button>
</main>
)
- 胜利时显示彩带效果:通过 Confetti 组件。
- 无障碍支持:aria-live 提供游戏状态描述。
- 骰子容器:动态显示所有 Die 组件。
- 按钮文本:胜利时为“New Game”,否则为“Roll”。
代码的核心逻辑总结
- 初始状态下生成 10 个随机骰子。
- 用户点击骰子时,可冻结当前骰子值。
- 点击按钮:
– 若游戏未胜利,则重新掷未冻结的骰子。
– 若游戏胜利,则重置游戏。 - 实现游戏胜利条件检测和 UI 提示(如彩带效果、自动聚焦按钮)。
2. Die.jsx
export default function Die(props) {
const styles = {
backgroundColor: props.isHeld ? "#59E391" : "white"
}
styles 是一个动态样式对象,用于控制按钮的背景颜色:
- 如果
props.isHeld
为 true(表示该骰子被冻结),背景颜色为绿色 (#59E391)。 - 如果
props.isHeld
为 false(表示未冻结),背景颜色为白色。
样式切换使用户能够直观地看到冻结状态。
return (
<button
style={styles}
onClick={props.hold}
aria-pressed={props.isHeld}
aria-label={`Die with value ${props.value},
${props.isHeld ? "held" : "not held"}`}
>
{props.value}
</button>
)
style={styles}
- 将动态样式对象 styles 应用到按钮的 style 属性,调整按钮的背景颜色。
onClick={props.hold}
- 定义按钮的点击事件处理函数。
- props.hold 是从父组件传递的函数,当用户点击按钮时会触发这个函数。
- 通常,props.hold 用于切换 isHeld 状态,冻结或解冻当前骰子。
aria-pressed={props.isHeld}
- 用于无障碍支持。
- 指定按钮的按下状态:
- true:表示当前骰子已被“按下”或冻结。
- false:表示当前骰子未被按下。
- 帮助屏幕阅读器用户了解当前状态。
aria-label={Die with value ${props.value}, ${props.isHeld ? “held” : “not held”}}
- 用于描述按钮的详细信息,提升无障碍性。
- 动态生成描述,例如:
- Die with value 4, held:骰子的值为 4,已冻结。
- Die with value 6, not held:骰子的值为 6,未冻结。
代码的核心功能
- 动态显示骰子的值。
- 提供交互功能,点击按钮会调用 props.hold,切换冻结状态。
- 通过动态样式反映骰子的状态(冻结或未冻结)。
- 提供无障碍支持,便于屏幕阅读器识别和描述按钮状态。
3. index.css
1. 通用选择器 *
* {
box-sizing: border-box;
}
- 作用:将所有元素的
box-sizing
设置为border-box
。 box-sizing: border-box;
:- 包括内容、内边距 (
padding
) 和边框 (border
) 的宽度和高度在width
和height
的计算中。 - 效果:简化布局调整,避免意外的尺寸增大。
- 包括内容、内边距 (
2. body
样式
body {
font-family: Karla, sans-serif;
margin: 0;
background-color: #0B2434;
padding: 20px;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
- 字体:
font-family: Karla, sans-serif;
使用Karla
字体,不支持时回退到无衬线字体。 - 背景颜色:深蓝色背景 (
#0B2434
)。 - 布局:
display: flex;
:将body
设为弹性容器。flex-direction: column;
:子元素垂直排列。justify-content: center;
:子元素在垂直方向上居中。align-items: center;
:子元素在水平方向上居中。
- 高度:
100vh
使body
占据整个视口高度。 - 内边距:
20px
,用于给内容留出额外空间。
3. div#root
样式
div#root {
height: 100%;
width: 100%;
max-height: 400px;
max-width: 400px;
}
- 高度和宽度:默认占据父容器的全部空间。
- 最大尺寸限制:
max-height
和max-width
分别限制为400px
。- 效果:即使父容器较大,
#root
的尺寸也不会超过 400 像素。
- 效果:即使父容器较大,
4. main
样式
main {
background-color: #F5F5F5;
height: 100%;
border-radius: 5px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
}
- 背景颜色:浅灰色 (
#F5F5F5
)。 - 圆角:
border-radius: 5px;
使容器的边角圆滑。 - 布局:
- 弹性布局,子元素垂直排列。
justify-content: space-evenly;
:在主轴上均匀分布子元素,间隔相等。align-items: center;
:子元素在交叉轴(水平方向)上居中。
5. .title
样式
.title {
font-size: 40px;
margin: 0;
}
- 字体大小:
40px
。 - 边距:
margin: 0;
去除外边距。
6. .instructions
样式
.instructions {
font-family: 'Inter', sans-serif;
font-weight: 400;
margin-top: 0;
text-align: center;
}
- 字体:优先使用
Inter
字体。 - 字体粗细:普通粗细 (
font-weight: 400
)。 - 文本对齐:居中对齐 (
text-align: center
)。
7. .dice-container
样式
.dice-container {
display: grid;
grid-template: auto auto / repeat(5, 1fr);
gap: 20px;
margin-bottom: 40px;
}
- 布局:CSS 网格布局。
grid-template
:- 行模板:
auto auto
(两行,每行高度自适应内容)。 - 列模板:
repeat(5, 1fr)
(5 列,每列宽度相等)。
- 行模板:
gap: 20px;
:网格单元之间的间距。
- 底部边距:
margin-bottom: 40px;
。
8. 通用按钮样式
button {
font-family: Karla, sans-serif;
cursor: pointer;
}
- 字体:
Karla
。 - 鼠标样式:
cursor: pointer;
鼠标悬停时显示手型图标。
9. .dice-container button
样式
.dice-container button {
height: 50px;
width: 50px;
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.15);
border-radius: 10px;
border: none;
background-color: white;
font-size: 1.75rem;
font-weight: bold;
}
- 按钮尺寸:固定宽高
50px
。 - 阴影:
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.15);
添加轻微的阴影效果。 - 圆角:
border-radius: 10px;
。 - 无边框:
border: none;
。 - 背景颜色:白色。
- 字体样式:
font-size: 1.75rem;
大号字体。font-weight: bold;
加粗。
10. .roll-dice
按钮样式
button.roll-dice {
height: 50px;
white-space: nowrap;
width: auto;
padding: 6px 21px;
border: none;
border-radius: 6px;
background-color: #5035FF;
color: white;
font-size: 1.2rem;
}
- 尺寸:高度固定为
50px
,宽度根据内容调整。 - 背景颜色:深蓝色 (
#5035FF
)。 - 字体颜色:白色。
- 内边距:
6px
(垂直)和21px
(水平)。 - 无边框,并带有圆角。
11. .sr-only
样式
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
- 作用:隐藏元素,但保留给屏幕阅读器使用。
- 关键属性:
position: absolute;
:从文档流中移除。width
和height
设置为1px
。- 使用
clip
和overflow
确保内容不可见。 - 提供无障碍支持,例如为视觉障碍用户提供额外的语音描述。
总结
这段 CSS 代码:
- 设计布局:通过弹性盒布局和网格布局组织内容。
- 样式统一性:使用动态样式、字体和交互效果。
- 无障碍支持:添加屏幕阅读器友好的隐藏元素(
.sr-only
)。 - 视觉细节:通过颜色、圆角、阴影和间距提升用户体验。