想做一个能"动"的东西,是学编程很自然的下一步。井字棋是最合适的起点 —— 规则三岁小孩都懂,实现起来只需要一个 3×3 的格子、九次点击、和一个判断胜负的函数。
这篇会带你从 0 写到完整版,顺便解释每一步在做什么。如果你已经会 React,直接看下面这个成品就行 ↓
🎮 先玩一局
这是我们最后要做出来的东西。可以现在就玩(在主页)。
规则:X 先手,轮流点击空格,先连成 3 个的一方赢(横、竖、对角都行)。平局就再来一局。
1 · 数据长什么样
整个游戏的状态就是一个长度为 9 的数组 —— 每个格子要么是 "X"、"O",要么是 null(空)。数组下标对应格子位置:
// 格子位置编号
// 0 | 1 | 2
// ---+---+---
// 3 | 4 | 5
// ---+---+---
// 6 | 7 | 8
type Cell = "X" | "O" | null;
// 一开始全是空
const board: Cell[] = [null, null, null,
null, null, null,
null, null, null];
整个游戏的所有信息(棋盘、轮到谁、胜负),都从这一个数组 + 几个布尔/数字推出来,不存任何多余的派生状态。这是 React 的核心原则之一:能算出来的东西不要单独存。
2 · 用 useState 存棋盘
棋盘需要能被 React 重渲染,所以放在 useState 里:
"use client";
import { useState } from "react";
export function TicTacToe() {
const [board, setBoard] = useState<Cell[]>(
Array(9).fill(null)
);
const [xIsNext, setXIsNext] = useState(true);
// ...
}
xIsNext 记录"下一个下棋的人是不是 X"。初始为 true,因为 X 先手。
3 · 点击格子下棋
每个格子是个按钮,点击时调用 play(i),把当前玩家写进第 i 格:
function play(i: number) {
if (isOver || board[i]) return; // 已结束 / 已下过 → 忽略
const next = board.slice(); // 复制,不能直接改原数组
next[i] = xIsNext ? "X" : "O";
setBoard(next);
setXIsNext(!xIsNext); // 换边
}
两个"规矩"很关键:
- 不能直接改原数组(
board[i] = ...)。React 靠"引用变了没"判断要不要重渲染,直接改的话它不知道。 - 已结束 / 已下过就不响应。游戏结束之后再点没意义,已下的格子也不能再被覆盖。
4 · 判断谁赢了
一共 8 条可能的"三连"(3 行 + 3 列 + 2 对角),挨个查一遍:
const LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // 横
[0, 3, 6], [1, 4, 7], [2, 5, 8], // 竖
[0, 4, 8], [2, 4, 6], // 对角
];
function checkWinner(board: Cell[]) {
for (const [a, b, c] of LINES) {
const v = board[a];
if (v && v === board[b] && v === board[c]) {
return { player: v, line: [a, b, c] };
}
}
return null;
}
返回的结果里 line 是赢家的那条线 —— 后面可以用来高亮显示,让玩家一眼看出"原来是这一行"。
5 · 显示棋盘
把 9 个格子渲染成一个 3×3 的 grid。每个格子读 board[i],空的就显示空白,已下的就显示 X 或 O:
<div className="grid grid-cols-3 gap-2">
{board.map((cell, i) => (
<button
key={i}
onClick={() => play(i)}
disabled={!!cell || isOver}
className="aspect-square rounded-lg border text-4xl"
>
{cell}
</button>
))}
</div>
disabled 这一行让已下的格子点不动,也顺便让键盘 Tab 路过的时候不会乱按。
6 · 加点细节
能玩之后,几件小事能让它"感觉对":
- 高亮赢家那条线 —— 在 checkWinner 的 line 里那几个格子换一种颜色,成就感会强很多。
- X / O 用不同颜色 —— 比如 X 用品牌色,O 用 info 色,一眼能分清谁是谁。
- 顶部状态栏 —— 显示"X 的回合"、"O 赢了"、"平局",比让玩家自己猜好得多。
- 一个"重开"按钮 —— 玩完了想再来一局,总不能让人手动刷新页面。
- 计分 —— 多存一个对象记 X / O / 平局次数,想从 1 局变成"玩 10 局看谁赢得多"也不难。
这些就是顶上那个成品版里加的东西。源码在 components/games/tic-tac-toe.tsx,整个文件不到 200 行。
7 · 想再玩点什么
井字棋太简单?下面是几个能在这个基础上扩展的方向,按难度递增:
- 改成 4×4 或 5×5 —— LINES 数组要重新算,但 checkWinner 的逻辑不变。
- 历史回放 —— 把每一步都存下来,加一个"上一步"按钮。这是理解"不可变数据"的好练习。
- 联机 —— 上 WebSocket,两个人在不同设备上玩(这个跳跃比较大,建议先把上面三步玩明白再说)。
8 · 加了什么:AI + 5s 自动重开
顶上那个成品版除了上面这些,还多了两块 —— 都是拿来"让一个人也能玩"的:
🤖 AI 对手
你是 X,O 是 AI。AI 不追求最强,但有一套简单优先级:
- 能自己连成 3 → 直接赢
- 下一步对方能赢 → 堵
- 占中心
- 占任意空角
- 随便挑一个剩下的空位
实现就是一个 findAiMove() 函数,挨个试空位。然后用 useEffect 监听"轮到 O 且游戏没结束",延迟 450ms 再下,让人感觉"它在思考"。
⏱️ 5 秒自动重开
一局结束(赢 / 输 / 平)→ 弹倒计时 → 5 秒后自动调用 reset()。想立刻玩可以手动点"重开"按钮,计时器会在 useEffect 清理时一并清掉。
#react #小游戏 #tutorial
— end —