猜数字玩的是"人和电脑对话",井字棋则要处理更复杂的局面:9 个格子、两名玩家轮流落子、还要判断胜负。这篇我们做一个点击即玩的井字棋,重点学习一个关键思想——用数据驱动界面:棋盘状态存在数组里,界面只是状态的"投影"。
1. 游戏规则
3×3 的棋盘,一方执 X,一方执 O。X 先手,两人轮流在空格上落子,谁先在横、竖、斜任意一条线上连成三子谁获胜;如果 9 个格子全部占满仍无人三连,则平局。
规则虽然简单,但它把编程里最常见的三类问题都涵盖了:
- 状态管理:当前棋盘长什么样、轮到谁走
- 输入处理:玩家点击了哪个格子,这个格子是否合法
- 胜负判定:如何判断三连,如何判断平局
把这三件事想明白,代码写起来就是水到渠成。顺便说一句:井字棋是"零和博弈"最简单的模型——一方赢另一方必输,不存在双赢。理解这一点,以后学博弈树、学 minimax 都会轻松很多。
2. 界面搭建
棋盘用 9 个 div 排成 3×3。每个格子上加一个 data-index 属性记录它在棋盘里的编号(0~8),这样点击时就能立刻知道玩家点的是第几格,不用靠猜位置。
<div class="game">
<h1>井字棋</h1>
<p id="status">轮到 X</p>
<div id="board">
<div class="cell" data-index="0"></div>
<div class="cell" data-index="1"></div>
<div class="cell" data-index="2"></div>
<div class="cell" data-index="3"></div>
<div class="cell" data-index="4"></div>
<div class="cell" data-index="5"></div>
<div class="cell" data-index="6"></div>
<div class="cell" data-index="7"></div>
<div class="cell" data-index="8"></div>
</div>
<button id="restart">再来一局</button>
</div>
9 个格子虽然长得一样,但每个都有独立的 data-index,这就是它们的"身份证"。#status 显示当前轮到谁或者比赛结果,是玩家的"信息栏"。
再用 CSS Grid 把棋盘排成三列,顺便给格子一点悬停反馈:
#board {
display: grid;
grid-template-columns: repeat(3, 100px);
gap: 4px;
}
.cell {
width: 100px;
height: 100px;
background: #f8f9fa;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
cursor: pointer;
user-select: none;
}
.cell:hover { background: #e9ecef; }
grid-template-columns: repeat(3, 100px) 直接声明"三列、每列 100 像素",比浮动布局省心太多。user-select: none 防止快速连点时选中文字,font-size: 2.5rem 让 X 和 O 足够醒目。
至此界面就绪。你可以先在 Console 里执行一句 cells[0].textContent = 'X',感受一下"界面可以被代码随意控制"——不过我们马上要用更规范的方式来做这件事。
3. 逻辑实现:状态与渲染
核心思想:用一个长度为 9 的数组 board 记录棋盘,board[i] 的值是 'X'、'O' 或 null(空格)。界面上的格子只是这个数组的展示。这样判断胜负、判断平局都只需操作数组,干净利落。
const board = new Array(9).fill(null);
let current = 'X';
let gameOver = false;
const statusEl = document.getElementById('status');
const cells = document.querySelectorAll('.cell');
function render() {
cells.forEach((cell, i) => {
cell.textContent = board[i];
});
}
new Array(9).fill(null) 创建 9 个空位的数组;render 把数组内容"刷"到 9 个格子上。以后任何操作改变了 board,只要调用一次 render(),界面就同步了——这就是"数据驱动界面"的雏形,也是现代前端框架(React、Vue)最核心的思想。
为什么不直接读格子上的文字来判断胜负?因为 DOM 是"展示层",把判断逻辑建立在展示层上,界面一改(比如把 X/O 换成图标、加特效)逻辑就得跟着崩;而数组是"数据层",稳定可靠、随时可查。这个"逻辑不碰界面"的分层习惯,是代码从"能跑"走向"好维护"的分水岭。
4. 逻辑实现:落子与胜负
接下来处理点击。玩家的每一次点击都走同一个流程:检查游戏是否结束、格子是否为空、落子、刷新界面、判断胜负、切换玩家。
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 checkWin(player) {
return lines.some(line =>
line.every(i => board[i] === player)
);
}
function handleClick(e) {
if (gameOver) return;
const i = Number(e.target.dataset.index);
if (board[i]) return;
board[i] = current;
render();
if (checkWin(current)) {
statusEl.textContent = current + ' 赢了!🎉';
gameOver = true;
} else if (board.every(c => c)) {
statusEl.textContent = '平局!';
gameOver = true;
} else {
current = current === 'X' ? 'O' : 'X';
statusEl.textContent = '轮到 ' + current;
}
}
cells.forEach(cell => cell.addEventListener('click', handleClick));
lines 把 8 条可能的胜利连线(3 横 + 3 竖 + 2 斜)提前列成数据,checkWin 用 some 加 every 检查是否有一条线上的三个格子都属于同一玩家——这两行是全场最精华的代码,值得反复品味。e.target.dataset.index 从被点击的格子身上读出编号;如果该格已有棋子(board[i] 非空)直接返回,防止覆盖。
注意平局判断用了 board.every(c => c):所有格子都有值时说明棋盘满了。胜负判断要放在平局判断前面,顺序不能反,否则"最后一步恰好三连"会被误判成平局。另外留意 render() 的调用时机:它在落子之后、判断胜负之前执行,这样玩家能立刻看到自己刚下的子,反馈零延迟。
5. 加一个 AI 对手
双人游戏做好后,再加个"单人模式"才有挑战。给电脑写一个简单但不算笨的策略:先赢,再堵——先找自己一步能赢的位置,找不到就堵玩家的必赢点,再不行就随机走。
function aiMove() {
for (let i = 0; i < 9; i++) {
if (!board[i]) {
board[i] = 'O';
if (checkWin('O')) return i;
board[i] = null;
}
}
for (let i = 0; i < 9; i++) {
if (!board[i]) {
board[i] = 'X';
if (checkWin('X')) return i;
board[i] = null;
}
}
const empty = [];
board.forEach((c, i) => { if (!c) empty.push(i); });
return empty[Math.floor(Math.random() * empty.length)];
}
这个函数的核心技巧是"试探-回退":先把棋子临时放下去,用现成的 checkWin 判断能不能赢,然后立刻恢复原状(board[i] = null)。这种"假装走一步,看看效果,再撤回来"的思路在棋类 AI 里非常常见,也是以后学递归、学 minimax 的起点。
把 AI 接进游戏,最好先把"落子 + 判胜负 + 切换玩家"抽成一个 applyMove 函数,让人类和电脑走同一条代码路径,逻辑不会出现两套、互相不一致:
function applyMove(i) {
board[i] = current;
render();
if (checkWin(current)) {
statusEl.textContent = current + ' 赢了!🎉';
gameOver = true;
} else if (board.every(c => c)) {
statusEl.textContent = '平局!';
gameOver = true;
} else {
current = current === 'X' ? 'O' : 'X';
statusEl.textContent = '轮到 ' + current;
}
}
function handleClick(e) {
if (gameOver) return;
const i = Number(e.target.dataset.index);
if (board[i]) return;
applyMove(i);
if (!gameOver && current === 'O') {
setTimeout(() => {
applyMove(aiMove());
}, 300);
}
}
原来的 handleClick 被重构成了两行核心:人类走 applyMove,轮到电脑时再用 setTimeout 延迟 300 毫秒走 applyMove(aiMove())——这点延迟让电脑显得在"思考",也让你看清它的落子。重构之后,单人和双人模式只差这一个 if。
6. 总结与练习
把第 2 节的 HTML、第 3~4 节的 JS 组装进同一个 index.html 就是一个完整的双人井字棋。想玩单人模式,在 handleClick 末尾加一段:如果当前是 O 且游戏未结束,调用 aiMove 自动落子,记得在 AI 落子后同样要判断胜负。
这篇的关键收获:数据驱动界面(数组是真相,界面是投影)、用查表法判断胜负(把规则翻译成数据)、试探-回退(AI 的雏形)。
练习一下:
- 给
aiMove升级:先手时优先抢占中心(4 号位),胜率会明显提升。 - 加一个难度选择:纯随机 / 堵截 / 必赢优先,三种策略对应三个函数。
- 把棋盘改成 4×4 连四子:胜利线一共 10 条(4 横 + 4 竖 + 2 斜),手写容易漏,试试用代码生成这些连线。
💡 想挑战真正的 AI,可以搜索 "minimax 井字棋"——它的思路就是把"试探-回退"递归地做到底,是博弈类 AI 的入门必学。