井字棋(Tic-Tac-Toe)是最经典的入门小游戏,但它的实现中藏着一个高频bug:重新开局后,第一步落子的玩家不一定是X。明明需求是“每局从X开始”,可实际运行时偶尔会从O开局。这段看似简单的问题代码,往往在胜负判定与重置逻辑的衔接处埋雷。

一、问题复现:残留的回合状态
先看一段典型的有缺陷的实现。棋盘用数组表示,currentPlayer记录当前该谁落子,resetGame负责清空棋盘:
let board = Array(9).fill(null);
let currentPlayer = 'X';
function handleClick(index) {
if (board[index] || getWinner(board)) return;
board[index] = currentPlayer;
// 切换回合
if (currentPlayer === 'X') {
currentPlayer = 'O';
} else {
currentPlayer = 'X';
}
render();
}
function resetGame() {
board = Array(9).fill(null);
// 忘记重置 currentPlayer!
render();
}这段代码的缺陷很明显:resetGame只清空了棋盘,却忘记把currentPlayer归位为X。假设上一局结束时轮到O落子(X下完最后一步获胜),此时点击“新游戏”,新对局就会从O开始,直接违反规则。
更隐蔽的一种情况是重置函数存在多个调用入口:胜利后自动重置、按钮点击重置、快捷键重置。如果其中某个入口绕过了重置函数,或者重置函数本身被拆成“清棋盘”和“切回合”两步且调用顺序颠倒,都会导致回合状态不可预测。这类bug的本质是状态没有归一化管理:谁都能改currentPlayer,谁都能触发重置,却没有一个统一的地方保证“新对局必然X先手”这一不变量。
二、修复思路一:在重置函数中显式归位
最直接的修复是在resetGame里显式把回合设回X,并保证所有重置入口都走这一个函数:
function resetGame() {
board = Array(9).fill(null);
currentPlayer = 'X'; // 核心修复:回合强制归位
winner = null;
render();
}这个方案的关键不只是加一行代码,而是确立一个原则:重置即全量初始化。所有参与对局状态的可变变量——棋盘、当前回合、胜利者、步数计数器、平局标记——都必须在重置函数中恢复初始值。只要漏掉任何一项,就等于埋下新的隐患。
建议把初始值集中定义成常量,让重置逻辑与初始状态保持单一来源:
const INITIAL_STATE = { board: Array(9).fill(null), player: 'X', winner: null };
function resetGame() {
board = INITIAL_STATE.board.slice(); // 注意复制,避免引用共享
currentPlayer = INITIAL_STATE.player;
winner = INITIAL_STATE.winner;
render();
}这里有个细节值得强调:slice()的调用不是多余的。如果直接赋值board = INITIAL_STATE.board,后续落子会直接修改常量数组,第二次重置时得到的就不是空棋盘了。这类引用共享的坑在JavaScript这类引用类型语言中非常常见。
三、修复思路二:状态机与断言兜底
如果项目规模更大,推荐把对局状态收敛为一个对象,用纯函数式的思路重建状态,而不是逐个字段修改。这在React等声明式框架中尤其自然:
function createInitialState() {
return { board: Array(9).fill(null), player: 'X', winner: null, moves: 0 };
}
function reducer(state, action) {
switch (action.type) {
case 'MOVE': {
if (state.board[action.index] || state.winner) return state;
const board = state.board.slice();
board[action.index] = state.player;
const winner = getWinner(board);
return {
board,
player: winner ? state.player : (state.player === 'X' ? 'O' : 'X'),
winner,
moves: state.moves + 1
};
}
case 'RESET':
return createInitialState(); // 新对局必然从X开始
default:
return state;
}
}这种写法的优势在于:重置不再依赖“记得改哪些字段”,而是直接返回一个全新的初始状态对象,不可能遗漏。所有状态变更都通过action集中处理,回合切换逻辑只存在于一处,排查问题时定位极快。
另一个实用技巧是加入开发环境断言,作为逻辑兜底。在每次对局开始前检查不变量:
function assertNewGame(state) {
if (state.player !== 'X') {
console.error('新对局必须从X开始,当前回合为 ' + state.player);
}
if (state.moves !== 0) {
console.error('新对局步数应为0,当前为 ' + state.moves);
}
}断言不影响生产运行,却能在开发阶段第一时间暴露重置遗漏,比靠肉眼点几十次界面靠谱得多。
四、边界场景测试:验证修复效果
修复完成后,不要只点一次“重开”就收工。以下几个场景最容易触发回合残留问题,值得逐一验证:
- X获胜后立即重开:此时轮到O,是最容易暴露bug的场景,新局必须仍由X先手。
- 平局后重开:平局时双方各下满,回合状态取决于总步数,需确认重置后回到X。
- 对局中途直接刷新或重新进入页面:如果状态有持久化(如localStorage),要确保加载旧存档后仍按规则恢复回合,而不是被当作新对局。
- 快速连续点击重置按钮:多次重置不应产生异常状态,例如步数变为负数或回合错乱。
可以为这些场景编写自动化测试,把“新对局第一个落子者是X”写成一条断言:
test('重置后新对局从X开始', () => {
const game = createGame();
game.move(0); // X 落子
game.move(1); // O 落子
game.reset();
expect(game.currentPlayer).toBe('X');
expect(game.board.every(cell => cell === null)).toBe(true);
});总结一下,回合重置错误的根源是可变状态分散管理加上重置逻辑不完整。无论是用“重置即全量初始化”的朴素方案,还是用状态机统一管理状态,核心都是让“新对局从X开始”成为一个不可破坏的不变量。养成这个习惯后,类似的残留状态bug会在你的项目中越来越少。