资讯详情

用JavaScript实现井字棋:状态管理、胜负判断与AI难度设计

📅 2026/10/18 6:14:49 | 华诺云谱 👁 阅读
用JavaScript实现井字棋:状态管理、胜负判断与AI难度设计
开头不算分整篇直接用 ## 编号从1开始。1. 项目从哪来为什么选井字棋当练手项目很多人在入门编程或者刚接触前端开发时都会纠结第一个小项目做什么。记账本、待办清单这些已经被写烂了做出来没什么成就感做个大型管理系统又hold不住。我自己的建议是如果目标是“快速跑通一个完整的项目闭环”——从需求分析、逻辑设计、界面搭建到测试上线——井字棋tic-tac-toe是最合适的选择之一。它规则足够简单三五分钟就能讲清楚但实现过程中几乎包含了所有基础项目都会遇到的核心问题状态管理、交互事件、界面刷新、胜负判断、以及简单的AI逻辑。可以说麻雀虽小五脏俱全。我当时做这个项目的动机很简单就是想在一个周末内完成一版“能拿得出手”的小成品既能自己玩也能发给朋友打开浏览器就能玩。不用装任何依赖不需要后端不用配置数据库一个HTML文件加一点JavaScript就全部搞定。做完之后我最大的感受是很多看起来“理所当然”的交互真正自己动手实现时才知道里面藏着不少细节。比如棋盘状态到底该用数组还是一组DOM节点来存胜负判断能不能写得足够优雅AI该按什么思路来设计才不至于太蠢这些坑如果不亲手踩一遍看再多教程也体会不到。这个项目适合谁去复现我认为大概是这几类人一是刚学完JavaScript基础语法、想做个完整小项目练手的前端初学者二是想复习DOM操作和数组方法、又不愿意做无聊练习的开发者三是想带学生或者带小朋友做编程启蒙的“组织者”井字棋的规则复杂度恰好在“不用费劲解释”和“足够训练逻辑思维”之间。对于老手来说这项目当然简单但它依然是一个很好的“半小时重构练手”素材比如试试用React或Vue重写一遍或者把AI换成带博弈树的最优策略又会收获一些新东西。抛开技术本身不谈井字棋还有一层“产品属性”它是一款经典到不能再经典的零随机性二人游戏先手只要在完全理性的前提下就至少能保证不输。正因为这样给游戏加上“人机对战”功能时就会出现一个非常经典的话题——AI到底要不要下得完美如果每一局都下成平局用户的挫败感会很重如果AI太笨用户又会觉得没意思。这个平衡问题在实际开发中特别有意思也是我这篇文章里花了比较多篇幅去聊的内容。你先理解井字棋的博弈特性再去设计AI的“强度等级”就会比单纯抄一段胜负判断代码有价值得多。2. 核心设计与实现方案先理清状态、流程、交互三层2.1 游戏状态应该如何建模很多初学者上手写井字棋时第一反应是直接在页面上摆9个格子然后给每个格子绑定点击事件处理函数轮流在里面填X或O。这样当然也能跑起来但随着逻辑变复杂——比如要加悔棋、要判断输赢、要换人机模式——你很快就会发现代码变得很难维护因为“哪个格子有值”这个信息散落在各个DOM节点里你很难统一读取和修改。更合理的做法是在脚本中单独维护一份核心竞争力棋局状态数据可以是数组、对象或者其他任何能被统一遍历的数据结构然后用这个数据结构来驱动界面渲染。9宫格用一个长度为9的一维数组就够了下标0到8分别对应棋盘上的每一个格子空位存null或玩家X填X玩家O填O。UI层每次做完操作之后根据这份数组去更新每一个格子的显示内容而不是直接修改格子的文本。这样设计有个明显的好处逻辑与界面解耦。你想调试胜负判断时直接在一个临时数组上手动摆几个X和O就能跑测试完全不用去管用户到底点了哪里。想加“重开一局”功能时只要把数组全部重置再刷新界面即可。这一点在项目规模变大之后价值会更明显哪怕是一个只有9个格子的小游戏先想清楚“状态放在哪里”后续的所有功能都会变得顺理成章。2.2 当前轮到谁、游戏是否结束除了棋盘本身的状态还必须记录两个额外的状态当前轮到哪一方X还是O以及游戏当前处于什么阶段进行中、X赢了、O赢了、平局。这两个状态虽然很简单但会把整个流程串起来。举个例子当我在手机上快速连点某个空格时如果没有“游戏是否结束”这个判断就可能出现结束时还能继续落子的bug如果没有“当前轮到谁”的判断则可能出现两个玩家都只能下X或者覆盖对手棋子等问题。这些状态本质上就是一个状态机初始状态轮到X游戏状态为“进行中”。玩家/电脑落子先检查该位置是否为空检查游戏是否处于进行中。落子后更新棋盘、切换轮次。每步落子后立刻判断胜负或平局并更新游戏状态。任意一方获胜或平局时禁止后续落子弹出结果。把流程拆成这个状态机之后代码写起来会非常顺畅因为每个函数只需要负责其中一步而且边界情况比如点击已占用的格子会被提前拦截。我见过不少半途而废的井字棋项目基本都是因为没想清楚这个状态机各种判断条件互相穿插结果逻辑越来越乱。2.3 交互层与界面刷新的工作方式界面部分我用的是DOM操作。9个格子用CSS Grid排成3x3布局每个格子是一个div绑定同一个点击事件处理函数通过event.target.dataset.index或循环添加事件时保存的下标判断用户点的是哪个格子。这个方案最简单也最容易理解。点击事件的流程大致是获取被点击格子的下标校验是否可落子位置为空且游戏未结束更新棋盘数组更新界面调用胜负判断若有结果则展示结果否则切换玩家。这里有一个很关键的细节所有对界面DOM的修改都应该直接来源于棋盘数组而不是逆过来。也就是说不要点击后就傻傻地改格子的文本而是先改数组再“根据数组重新渲染整个棋盘”或者至少“根据数组更新当前格子的文本”。这个“数据先动界面跟上”的顺序会让你未来加任何功能都不容易出错。3. 实操实现HTML结构、CSS布局与核心JS逻辑3.1 HTML与CSS部分先做出一个干净的棋盘先放出我实际使用的HTML骨架非常朴素没有任何框架!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title井字棋游戏/title style /* 样式部分见下文 */ /style /head body div idapp h2井字棋/h2 div idstatus轮到 X/div div idboard/div button idrestart重新开始/button /div script /* JS逻辑见下文 */ /script /body /html棋盘网格建议用CSS Grid实现#board { display: grid; grid-template-columns: repeat(3, 120px); grid-template-rows: repeat(3, 120px); gap: 4px; background-color: #333; } .cell { background-color: #fff; display: flex; align-items: center; justify-content: center; font-size: 56px; font-weight: bold; cursor: pointer; user-select: none; } .cell.taken { cursor: default; }CSS里的gap: 4px配合background-color: #333能做出一个很细的网格分隔线效果比给每个格子加border要干净利落。格子里的X和O用的字体尺寸建议调到50px以上不然在手机上看起来不够清晰同时给#board加上max-width并设置居中方便适配移动端。手机上另一个重要问题是让棋盘居中并且占满可用宽度。我给#board设置了width: min(90vw, 90vh)并把格子的宽高改成1fr或minmax(0, 1fr)这样一来在竖屏手机上也能完整显示。横屏用户也基本不会出问题因为min会取视口宽度和高度的较小值来约束棋盘尺寸。3.2 核心JS状态管理、渲染、胜负判断一次写清楚JS部分的核心数据结构我用了这样一段代码const board Array(9).fill(null); let currentPlayer X; let gameOver false; let winner null; // null-未结束 X/O-获胜 draw-平局 const cells document.querySelectorAll(.cell); const statusEl document.getElementById(status);初始化时用Array(9).fill(null)创建长度为9的数组每个位置都是null。之后每次点击覆盖对应下标的值。渲染函数是所有界面更新的唯一入口function render() { cells.forEach((cell, index) { cell.textContent board[index]; if (board[index]) { cell.classList.add(taken); } else { cell.classList.remove(taken); } }); if (winner) { if (winner draw) { statusEl.textContent 平局; } else { statusEl.textContent 玩家 ${winner} 获胜; } } else { statusEl.textContent 轮到 ${currentPlayer}; } }每次渲染时根据board数组的数据统一刷新所有格子这样即使未来加了高亮胜利线、动画等需求也只需要在这个函数里增加逻辑。点击事件处理函数function handleCellClick(event) { const index Number(event.currentTarget.dataset.index); if (board[index] ! null || gameOver) return; board[index] currentPlayer; if (checkWin(board, currentPlayer)) { winner currentPlayer; gameOver true; } else if (board.every(cell cell ! null)) { winner draw; gameOver true; } else { currentPlayer currentPlayer X ? O : X; } render(); } cells.forEach((cell, index) { cell.dataset.index index; cell.addEventListener(click, handleCellClick); });这里值得展开说的是为什么要在每次落子后同时检查“当前玩家是否获胜”和“棋盘是否已满”。因为如果只查胜负、不查平局就会出现最后一步填满格子后游戏还在继续的情况。反过来即使某个玩家的最后一步让棋盘满了也要先判断他这一步有没有形成三连这样可以优先展示“获胜”而非“平局”。胜负判断可以写得非常短function checkWin(board, player) { 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] ]; return lines.some(([a, b, c]) board[a] player board[b] player board[c] player ); }这个lines数组把3行、3列、2条对角线全部写出来了一共8种获胜组合。some方法只要找到一组格子全部等于当前玩家就立刻返回true。这种写法比用for循环加计数器更直白也更容易检查有没有漏掉某条线。你可以把lines定义到函数外面作为常量避免每次调用都重新创建二维数组这个小优化虽然影响不大但算是好习惯。3.3 重新开始、切换模式等基础功能“重新开始”按钮做的事情很直接function resetGame() { board.fill(null); currentPlayer X; gameOver false; winner null; render(); } document.getElementById(restart).addEventListener(click, resetGame);这个功能之所以简单恰好是因为状态集中保存在board、currentPlayer、gameOver、winner四个变量里。重置时一次全部归位然后渲染一次即可。如果你把状态分散在各个格子DOM的textContent里重置时就得再遍历9个格子又丑又容易漏。除此之外还可以加一个“人机对战模式”的按钮切换时把玩家角色固定为XAI为O并在每次轮到O时触发AI走子。逻辑上其实只是在handleCellClick里加一个分支如果当前是AI的回合就跳过等待点击直接调用AI函数。具体AI算法在下一节展开。4. 从“能玩”到“好玩”AI难度设计是个大学问4.1 最容易落地的AI基于必胜点与必防点的贪心算法如果只想让玩家和电脑对战电脑的AI设计是决定游戏体验的关键。最低级的实现是随机选空格但这样玩家闭眼都能赢几分钟就腻了。稍微提升一点体验的做法是贪心算法让电脑按优先级做决策如果自己能通过在某一个空格落子而获胜立即选择该空格。如果对手能在下一步通过某个空格获胜则必须抢占该空格来防守。如果棋盘中心位置下标4还空着抢占中心。如果棋盘角位置下标0、2、6、8还空着选择任意一个角。最后才“随便选一个剩下的边格”。这个策略在井字棋中已经能达到非常高的水平它的优势在于代码逻辑简单、容易解释。尤其是第1、2步本质上是对8条获胜线做一次遍历function getAIMove(board) { // 第1步检查自己能不能赢 for (let i 0; i 9; i) { if (board[i] null) { board[i] O; if (checkWin(board, O)) { board[i] null; return i; } board[i] null; } } // 第2步检查对手能不能赢如果对手下一步要赢则拦截 for (let i 0; i 9; i) { if (board[i] null) { board[i] X; if (checkWin(board, X)) { board[i] null; return i; } board[i] null; } } // 第3步优先中心 if (board[4] null) return 4; // 第4步优先角 const corners [0, 2, 6, 8].filter(i board[i] null); if (corners.length 0) return corners[Math.floor(Math.random() * corners.length)]; // 第5步随便选剩下的空格 const remaining board .map((val, idx) val null ? idx : -1) .filter(idx idx ! -1); return remaining[Math.floor(Math.random() * remaining.length)]; }注意第2步的判断逻辑先把空位临时填入O来测试对手能不能赢。如果对手在这个位置落子后能形成三连那AI就必须抢下这个位置。但有个细节这里应该用checkWin(board, X)判断的是“如果对手X在当前位置落子他是否会赢”所以临时填入的是X而不是O很多人第一次写会在这搞反。这个AI的问题在于它只能防守“当前这一步”的威胁对手如果设计了需要两步才能完成的陷阱它不一定防得住。不过在井字棋这种回合数非常短的游戏中这个级别的AI已经能让大部分玩家认真起来。它的决策速度极快不会出现任何卡顿非常适合作为第一版。4.2 进阶AI极小极大算法的原理与实现要让电脑达到“不败”级别需要用极小极大minimax算法。这个算法的核心思想是假设双方都足够理性。一方AI希望自己的结果分尽量高另一方对手希望这个分尽量低。从当前局面出发模拟所有可能的未来棋局直到游戏结束再根据“AI赢、AI输、平局”三种结果回推每一步的选择。在井字棋中局面总数非常有限穷举全部也就几十万种实际计算量远小于一个复杂棋类游戏所以完全可以用递归实现而不需要做剪枝优化。代码大致如下function minimax(board, current, isMaximizing) { // 先检查是否有胜者 if (checkWin(board, O)) return 10; if (checkWin(board, X)) return -10; if (board.every(cell cell ! null)) return 0; if (isMaximizing) { let best -Infinity; for (let i 0; i 9; i) { if (board[i] null) { board[i] O; best Math.max(best, minimax(board, X, false)); board[i] null; } } return best; } else { let best Infinity; for (let i 0; i 9; i) { if (board[i] null) { board[i] X; best Math.min(best, minimax(board, O, true)); board[i] null; } } return best; } } function getBestMove(board) { let bestScore -Infinity; let bestMove -1; for (let i 0; i 9; i) { if (board[i] null) { board[i] O; const score minimax(board, O, false); board[i] null; if (score bestScore) { bestScore score; bestMove i; } } } return bestMove; }这段代码里isMaximizing参数表示当前正在评估的节点是“AI回合”还是“对手回合”。AI回合要选对自己最有利的分值对手回合要选对AI最不利的分值。checkWin的结果是±10而平局是0这样AI会尽量追求胜利如果无法胜利则保平。实际调用时在AI回合执行let aiMove getBestMove(board); board[aiMove] O;这个实现已经能达到理论最优AI永远不会输最多平局。不过正因如此这个AI不适合作为默认难度否则新手玩家可能连赢一把都难体验反而不好。所以我在实际项目里把AI分成了“简单”和“困难”两档简单档用前面的贪心算法困难档用极小极大然后在困难档里加一点随机性比如在多个分数相同的最优解中随机选择让AI偶尔会露出破绽玩家不至于太绝望。4.3 模式切换与先手后手设置做成人机对战后最好再给用户一个“选择先后手”的选项。因为如果AI在困难档且总是先手玩家胜率会很低而玩家先手时至少能主动控制开局体验完全不一样。我建议提供一个下拉框或者两个单选按钮让用户决定自己是X还是O默认X先手。切换先手后手时要特别留意棋盘的初始状态。如果玩家选择O也就是AI先手那么点击“开始游戏”后应该立即让AI走一步。这里有两种处理方式方式A在“开始游戏”按钮的点击事件里判断如果当前轮到AI就调用AI函数方式B在渲染结束后用一个setTimeout延迟100毫秒再调用AI保证界面先更新完。方式A更干净但需要注意在游戏刚开局、board全空的时候调用AIAI应该选择中心或角落。方式B在某些极端情况下可能出现用户点击和AI自动落子冲突的问题我个人更推荐方式A。5. 实战中踩过的坑状态不同步、快速点击、事件冒泡5.1 快速连点时出现的覆盖Bug这是我最先遇到的经典问题。在没有任何锁定的情况下用户快速连续点击两个空格第二个点击会覆盖第一个点击的状态吗不会因为每次点击都同步修改了board数组和界面。但问题是如果用户连续点击同一个格子两次而第一次点击后格子里已经有值了第二次点击虽然会被board[index] ! null拦截但视觉效果上总归不好尤其加上CSS过渡动画时会有轻微闪烁。更麻烦的是在加入AI之后用户点击后AI落子需要一点点计算时间虽然极小极大很快但极端情况下也会有几毫秒如果用户在这期间又点了别的格子就可能出现AI和用户抢同一个格子或者用户直接替AI走了棋。解决方案很简单在AI计算期间给整个棋盘加上一个临时遮罩层或给所有格子增加一个isLocked标记任何点击在isLocked为真时直接返回。let isLocked false; function handleCellClick(event) { if (isLocked || gameOver) return; // ... 玩家落子逻辑 if (mode ai currentPlayer O !gameOver) { isLocked true; setTimeout(() { makeAIMove(); isLocked false; }, 50); } }这里的50毫秒延迟是为了避免在玩家刚点击后CPU立刻计算并刷新界面导致界面还没来得及显示玩家落子就被AI覆盖肉眼看起来就像“你下完AI瞬间变了”。加一个小延迟用户可以看到“自己落子 - AI落子”的先后顺序体验会舒适很多。5.2 事件绑定写到for循环里的闭包陷阱用querySelectorAll拿到所有格子时在不使用dataset.index而是用闭包保存下标的情况下如果写成for (var i 0; i cells.length; i) { cells[i].addEventListener(click, function () { // 这里访问到的 i 永远是9 }); }就会踩中经典的闭包陷阱。解决办法有两种一是把var改成let利用块级作用域绑定每个循环值二是像我前面那样用event.currentTarget.dataset.index从DOM属性里读取真实下标彻底避开闭包问题。两种都可以第二种更稳。5.3 棋盘数组与DOM状态不一致如果你已经养成了“改数组board再调用render”的好习惯基本不会出现这种问题。但如果你在事件处理函数里“为了省事”直接操作某个格子的文本而忘了更新board数组整个游戏逻辑就会变得玄学起来——某个格子看起来有X但程序认为它是空的。这种bug特别难排查因为它没有报错只在某些特定操作下暴露出来。解决办法只有一个永远不要直接改DOM所有修改都走board加render()。5.4 常见问题速查表问题现象根本原因解决办法点击格子无反应游戏已结束或棋盘已满检查gameOver状态同一个格子可以被重复点击没有判断board[index]是否为null落子前校验位置是否被占用AI和玩家同时落子缺少锁机制用isLocked或遮罩层胜负判断漏判对角线获胜组合数组漏了[0,4,8]或[2,4,6]仔细核对8条线每步点击手感很卡AI实时计算导致阻塞极小极大已经很快若卡顿可把计算放在setTimeout里手机端棋盘超出屏幕网格固定宽度改用min(90vw, 90vh)6. 进一步扩展悔棋、计分、动画与本地存储6.1 悔棋功能核心在于状态快照悔棋在井字棋里其实并不像象棋那么关键但对“双人对战模式”来说很有用因为两个人面对面玩的时候经常手滑。实现思路并不复杂核心是保存“历史棋盘状态”的数组const history []; function handleCellClick(event) { history.push({ board: [...board], currentPlayer, winner, gameOver }); // ... 正常落子逻辑 } function undo() { if (history.length 0) return; const last history.pop(); board.splice(0, 9, ...last.board); currentPlayer last.currentPlayer; winner last.winner; gameOver last.gameOver; render(); }比较关键的一点是[...board]要做一次浅拷贝而不是直接history.push({ board })否则你之后修改board时history里存的旧对象也会被一起修改悔棋就失效了。这个错误非常隐蔽我第一次写的时候也踩了调试了半天才发现是引用传递的问题。在人机对战模式下悔棋会复杂一些因为AI已经走了一步悔棋时最好一次回退两步玩家和AI各一步否则会让玩家感觉很别扭。处理办法是在undo函数里判断当前模式如果当前是AI模式且上一步是AI走的就连续pop两次。6.2 计分与轮次本地存储也不难给双人对战加一个计分板非常简单维护两个变量scoreX和scoreO在每次游戏结束时对应加分然后写入localStorage用户下次打开页面时还能看到累计比分。写入和读取代码都很短function saveScores() { localStorage.setItem(ttt_scores, JSON.stringify({ scoreX, scoreO })); } function loadScores() { const saved localStorage.getItem(ttt_scores); if (saved) { const { scoreX: x, scoreO: o } JSON.parse(saved); scoreX x || 0; scoreO o || 0; } } loadScores();这个功能虽然简单但在“朋友来家里做客”的场景下特别加分两个人玩几把会有很强的“再来一局”的动力。也可以把“平局数”也加上逻辑一模一样。6.3 动画与高亮让胜利更直观当一方获胜时把组成三连的三个格子高亮出来是提升视觉体验性价比最高的一步。可以在checkWin里不仅返回布尔值还能返回具体是哪些格子组成的连线function getWinningLine(board) { const lines [...]; // 8条线 for (const [a, b, c] of lines) { if (board[a] board[a] board[b] board[a] board[c]) { return [a, b, c]; } } return null; }拿到获胜线列表后给对应的三个格子加一个CSS类比如highlight然后定义样式为背景色变亮、字体颜色变深、加一个简单的过渡动画。注意在render每次刷新时先移除所有格子的highlight类再根据获胜线重新添加否则上一轮的胜利高亮会残留到重开后的棋盘上。6.4 换肤CSS变量带来的灵活体验想让界面支持多种配色深夜模式、复古手写风、极简黑白风最好的方式是用CSS变量。比如:root { --bg-color: #f0f0f0; --cell-color: #fff; --text-color: #222; } .dark { --bg-color: #1a1a1a; --cell-color: #2a2a2a; --text-color: #eee; }然后在标签的样式规则里使用var(--cell-color)。切换皮肤时只要给body切换类名即可。整个逻辑不涉及JavaScript对每个格子的逐个修改效率高也干净。7. 项目复盘与个人体会这个井字棋项目做完之后我觉得最有价值的收获有两点。一是关于状态管理的思维方式哪怕只是9个格子的小项目把状态集中管理也会比散落在DOM里好太多。这种思想继续延伸到React、Vue这些框架时你会很自然地理解为什么组件状态要“单一数据源”为什么要“状态驱动视图”。二是关于AI决策的层次从贪心到极小极大本质上是从“局部最优”到“全局最优”的跨越这个思维模型不光适用于游戏也适用于很多需要做决策的编程场景。另外我也想给刚做完基础版本的朋友一个建议不要急着加功能。先把双人模式打磨到交互顺畅、边界情况全部处理完再花时间做人机。很多初学者相反一上来就想着做AI、做皮肤、做动画结果连基本的“点谁谁落子”都没做好最后越改越乱干脆放弃。循序渐进的开发节奏是在这个小项目里最容易养成的习惯。实际我个人的体会是井字棋看似简单但如果你认真对待它完全可以把它当成一块“磨刀石”——锻炼你拆解需求、组织代码结构、调试边界条件的能力。做完之后再回头去看那些复杂的项目你会发现自己不再怕“从零到一”的空白起点了。希望这篇记录对你也有同样的帮助。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑