☰
用JavaScript+Canvas从零实现五子棋:算法、AI与实战排坑
2026/10/13 10:47:15 网站建设 项目流程

作为一个前端开发者,五子棋几乎是每个人入门游戏开发时都会碰到的经典项目。说它经典,是因为这个项目麻雀虽小五脏俱全:界面渲染、交互事件、状态管理、胜负判定、甚至简单的AI博弈,全都浓缩在一块15x15的棋盘里。我前前后后用JavaScript写过好几个版本的五子棋,从最初只求能落子能判胜负的"教学版",到后来加入了悔棋、AI对战、落子动画的"完整版",踩了不少坑,也沉淀了不少可复用的套路。这篇文章我把整个项目的实现思路拆开来讲:技术选型是怎么定的、棋盘和落子的数据结构怎么设计、判赢算法怎么写才不容易出错、AI又该怎么从零写一个能玩的版本。不管你是有一定前端基础想做个完整项目练手,还是刚学完JavaScript想找一个小而全的实战练手项目,这个项目都很合适。

1. 开局之前:为什么选纯前端JavaScript做五子棋

先聊技术选型。五子棋这个项目看起来简单,但实现方案其实有好几条路:纯DOM操作、Canvas渲染、或者直接上Vue/React这类框架。我最终选择的是原生JavaScript加Canvas,没有引入任何框架和构建工具,原因很实在。

第一,这个项目的核心逻辑复杂度不高,不需要框架来做状态管理。五子棋的全部游戏状态基本就是"当前轮到谁""棋盘上有哪些棋子",用一个二维数组加一个布尔值就装下了。引入框架反而要处理响应式更新、组件通信这些额外问题,对一个棋类游戏来说是负担。第二,Canvas绘制棋盘和棋子的代码非常直观,一个循环画网格线,一个圆就是棋子,没有DOM节点创建销毁的包袱。如果用纯DOM做棋子,15x15就是最多225个节点,性能上倒也没压力,但每次落子都要创建节点再插入DOM,代码写起来繁琐,后期加上动画效果和AI计算时会觉得处处受限。第三,这个项目最适合拿来练原生基本功——手写事件处理、手动管理状态、手动优化渲染,这些能力在小游戏项目里能得到最直接的锻炼。

我这里也给个选型参考表:

方案优点缺点适用场景
原生DOM直观易理解节点多时代码繁琐极简入门版
Canvas渲染绘制灵活、性能好需要手动处理坐标映射本文采用
Vue/React组件化对游戏项目偏重想练框架时另开项目

技术方案定了之后,我把项目拆成三个独立模块来设计:棋盘数据模块只负责维护二维数组和落子逻辑,不关心任何界面内容;渲染模块只负责把棋盘状态画到Canvas上;交互模块负责监听鼠标事件并调用前两者。这个分层并不复杂,但非常关键,后面加AI、加悔棋、加动画的时候,你会发现各模块之间的解耦能省掉大量返工。

2. 棋盘与落子:数据结构决定代码的下限

很多新手在写五子棋时犯的第一个错误,是先画棋盘再想数据怎么存,结果到判胜负阶段发现数据结构和界面绑得太死,改起来痛苦。正确顺序是先把棋子存储结构想清楚。

2.1 二维数组建模棋盘状态

棋盘直接对应一个15行15列的二维数组。数组里存什么?我见过有人存棋子DOM元素、有人存颜色字符串,这些都不够干净。最稳妥的方案是存三个值之一:0代表空位,1代表黑棋,2代表白棋。同时用一个变量记录当前落子方,黑方先手,所以初始是1。

const BOARD_SIZE = 15; let board = Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(0)); let currentPlayer = 1; // 1: 黑棋, 2: 白棋 let steps = []; // 每一步落子的坐标,后续悔棋要用

这里的Array.from写法比new Array(15).fill(new Array(15).fill(0))严谨很多。后一种写法有一个经典大坑:内层数组全是同一个引用,改一行数据会连带改所有行。我第一次写就栽在这儿,落子时明明只在第5行下了棋,结果整列都变成了棋。这个坑后面第6章我会再展开说一次。

落子逻辑本身非常简单,三步走:判断目标位置是否为空、落子写进数组、切换当前落子方。

function move(row, col) { if (board[row][col] !== 0) return false; board[row][col] = currentPlayer; steps.push({ row, col, player: currentPlayer }); currentPlayer = currentPlayer === 1 ? 2 : 1; return true; }

2.2 Canvas画棋盘和棋子

棋盘数据的存储方式定了,渲染就好写了。我用Canvas而不拿图片素材,是为了让整个项目没有任何外部依赖,单文件就能跑。

网格线的绘制逻辑是把15x15的棋盘画在一个600x600像素的画布上。每个格子边长40像素,为了让棋盘四边留白,我给画布做了一圈20像素的边距。这样棋盘的交叉点坐标就可以统一用20 + index * 40来算,后面鼠标点击坐标映射也用同一个公式。

const margin = 20; const cellSize = 40; const canvasSize = margin * 2 + cellSize * (BOARD_SIZE - 1); // 600 ctx.clearRect(0, 0, canvasSize, canvasSize); ctx.fillStyle = '#D9A96A'; ctx.fillRect(0, 0, canvasSize, canvasSize); // 画网格线 ctx.strokeStyle = '#000000'; ctx.lineWidth = 1; for (let i = 0; i < BOARD_SIZE; i++) { const pos = margin + i * cellSize; ctx.beginPath(); ctx.moveTo(margin, pos); ctx.lineTo(canvasSize - margin, pos); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pos, margin); ctx.lineTo(pos, canvasSize - margin); ctx.stroke(); }

棋子绘制也简单,一个实心圆,黑棋深色填充,白棋浅色填充,再各加一圈描边让棋子边界清晰。有个小细节值得注意:棋子的半径可以设成格子边长的40%左右,也就是16像素,这样棋子之间会有轻微间隙,棋盘看起来透气不少,不会糊成一片。

2.3 鼠标点击坐标的换算

Canvas接收的是像素坐标,棋盘数组接收的是行列下标,中间必须做一次换算。这一步看着简单,但坑很多。标准做法:像素坐标减去边距再除以格子边长,然后四舍五入得到最近的交叉点下标。

canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const col = Math.round((x - margin) / cellSize); const row = Math.round((y - margin) / cellSize); if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) { move(row, col); render(); checkGameOver(row, col); } });

这里最容易出错的是忘记用getBoundingClientRect。如果页面有滚动或者Canvas不是位于页面左上角,e.clientX和e.clientY是相对浏览器视口的坐标,不减去Canvas自身的位置,点击的位置就会整体偏移。另一个坑是点击靠近棋盘边缘时,换算出来的行列下标可能超出数组边界,比如点到了边距区域,row - margin算出来会是负数。所以落子前必须做一次边界检查,防止数组越界。

3. 胜负判定是核心:判赢算法的三种写法对比

五子棋项目的灵魂在判赢。规则本身一句话能说完——同色棋子在横、竖、两条斜线任何一个方向上连成五颗。但代码实现却有讲究,网上能找到的写法五花八门,我复盘一下自己写过的三种,从最笨到最实用的都有,读者可以根据自己的需求选。

3.1 逐点扫描全局:实现最简单但不够干净

最朴素的思路是每落一子后,把整个棋盘遍历一遍,对每个有棋子的点,尝试四个方向,每个方向数五个格子,检查这五个格子是否同色。

function checkWinNaive() { const directions = [ [0, 1], [1, 0], [1, 1], [1, -1] ]; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (board[r][c] === 0) continue; for (const [dr, dc] of directions) { let count = 1; for (let k = 1; k < 5; k++) { const nr = r + dr * k; const nc = c + dc * k; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] !== board[r][c]) break; count++; } if (count >= 5) return board[r][c]; } } } return 0; }

这个算法在15x15的棋盘上性能完全没问题,15x15x4x5,最多4500次循环,对现代浏览器来说可以忽略不计。但它的问题是逻辑上不够聚焦——每下一颗子明明只需要检查跟这颗子有关的连线,却要扫描所有格子。虽然不影响结果,却让代码看起来不够"聪明"。

3.2 以落子点为中心向两端扩展:推荐的标准解法

我最终采用的是以新落子点为起点,向四个方向的两端延伸计数。每个方向从落子点出发,往正方向和反方向分别数一下有几个同色连子,加起来就是该方向的连续棋子数。这样每落一子只需检查4个方向,每个方向最多数10个格子,计算量小一个数量级,而且语义清晰:只关心新落子有没有形成五连。

function checkWin(row, col) { const player = board[row][col]; if (player === 0) return false; const directions = [ [0, 1], // 水平方向 [1, 0], // 垂直方向 [1, 1], // 右下斜线 [1, -1] // 左下斜线 ]; for (const [dr, dc] of directions) { let count = 1; // 正方向延伸 for (let step = 1; step < 5; step++) { const nr = row + dr * step; const nc = col + dc * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] !== player) break; count++; } // 反方向延伸 for (let step = 1; step < 5; step++) { const nr = row - dr * step; const nc = col - dc * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] !== player) break; count++; } if (count >= 5) return true; } return false; }

这个方法的边界处理要格外细心。正方向和反方向都要检查越界,其中任意一次越界就停止当前方向上的延伸。很多人写到这里会漏掉反方向的越界检查,导致在棋盘边缘的棋子被判出奇怪的结果。我自己截断过一次数组访问报错之后,就总结出一个检查清单:行下标、列下标、方向向量,三个都要同时验证。

3.3 位运算压缩:性能极致但可读性差,不推荐

还有一个以位运算为底的方案:把棋盘的每一行变成一个二进制数字,用位运算判断连续五个1。15位刚好能放进一个16位整数,判赢时可以快速对每行做移位和与运算。这种写法在极端追求性能的AI类项目中有人用,但在普通交互项目里完全没必要。代码变成一连串魔数,一两个月后自己都看不懂,调试成本远高于省下的那点微秒级时间。我的经验是:凡是非AI博弈类前端项目,一律用第3.2节的双向扩展写法就够了。

方案可读性性能推荐度
全局扫描中够用教学演示可用
落子点双向扩展高优强烈推荐
位运算压缩极低最优不推荐

4. 给单机版装上脑子:从零写一个能下赢普通人的AI

自己有棋下不难,难的是没人陪的时候电脑能陪你下。这里我不做那种调用第三方AI接口的方案,因为那个逻辑和棋类本身没关系,而且接口依赖网络环境,不稳定。我选择在浏览器本地实现一个基于打分评估的简易AI,它的棋力大概在"认真下能赢新手、会被熟练玩家抓住漏洞"的水平,但作为单机娱乐完全够用,而且算法原理非常容易理解。

4.1 打分思路:把棋型变成分数

这个AI的核心思想只有一句话:遍历棋盘上所有空白位置,给每个位置打分,分数代表"如果我在这个位置落子,对局势的帮助有多大",然后选分数最高的位置落子。

打分的依据是棋型。把棋盘上的连子形态抽象成几种标准情况:活四、冲四、活三、眠三、活二。活三就是两头都没被堵的三连子,冲四是一头被堵的四连子,活二是两头没堵的二连子。每种棋型对应一个分数,四连的分数远高于三连,三连远高于二连。

我用的评分表大概是这样:

棋型进攻分数防守分数
活四100000100000
冲四1000010000
活三50005000
眠三10001000
活二500500

分数怎么用?对于每个空白位置,我分别做两件事:假设自己在这个位置落子,统计它四个方向上的棋型,累加各棋型分数得到进攻得分;再假设对方在这个位置落子,同样统计棋型,累加得到防守得分。位置的总分等于进攻得分加上防守得分的一部分权重。我通常设权重为防守分值的80%到100%,因为五子棋里防守往往比进攻重要——堵住对方的活三比自己去凑一个眠四更紧迫。

4.2 核心评分代码实现

棋型统计的实现细节比较多,完整写完代码不短,核心部分是"沿着某个方向数连子数以及两端是否为空"。

function evaluatePoint(row, col, player) { let score = 0; const directions = [[0, 1], [1, 0], [1, 1], [1, -1]]; for (const [dr, dc] of directions) { let count = 1; let openEnds = 0; // 正方向 let nr = row + dr; let nc = col + dc; while (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === player) { count++; nr += dr; nc += dc; } if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === 0) { openEnds++; } // 反方向 nr = row - dr; nc = col - dc; while (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === player) { count++; nr -= dr; nc -= dc; } if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === 0) { openEnds++; } if (count >= 5) { score += 100000; } else if (count === 4 && openEnds === 2) { score += 10000; } else if (count === 4 && openEnds === 1) { score += 5000; } else if (count === 3 && openEnds === 2) { score += 2000; } else if (count === 3 && openEnds === 1) { score += 500; } else if (count === 2 && openEnds === 2) { score += 200; } } return score; }

这里openEnds的计数逻辑是关键中的关键。方向两端延伸结束后,如果端点是空位,说明这个棋型两头是敞开的,威胁大,分数要按活型计算。如果端点是对方棋子或者棋盘边界,则是死型,分数大降。真实的五子棋AI对棋型的判断远比这复杂,还会把"跳三""跳四"这类隔空棋型纳入计算,但对于练手项目,这个评分函数已经能产生相当像样的下法了。

4.3 组装AI决策函数

有了评分函数,AI决策函数就很简单了:遍历棋盘所有空位,每个空位分别用当前AI棋子和人类棋子颜色计算进攻分和防守分,最终得分取两者之和,选最高的位置。

function aiMove() { let bestScore = -1; let bestMove = null; const aiColor = currentPlayer; const humanColor = currentPlayer === 1 ? 2 : 1; for (let r = 0; r < BOARD_SIZE; r++) { for (let c = 0; c < BOARD_SIZE; c++) { if (board[r][c] !== 0) continue; const attackScore = evaluatePoint(r, c, aiColor); const defendScore = evaluatePoint(r, c, humanColor); const totalScore = attackScore + defendScore * 0.9; if (totalScore > bestScore) { bestScore = totalScore; bestMove = { row: r, col: c }; } } } if (bestMove) { move(bestMove.row, bestMove.col); render(); } }

一个可以让AI变强的进阶技巧是"抢占中心优先"。五子棋开局阶段,棋盘中心位置的价值远超边角,因为中心点向四周都有通畅的发展空间。我可以在评分公式里加入中心权重:离棋盘中心越近的空白格,额外加一个和距离成反比的奖励分。这样AI开局不会乱跑,会先抢占天元附近,观感立刻专业很多。代码实现就一行改动:

const centerDist = Math.abs(r - 7) + Math.abs(c - 7); const centerBonus = Math.max(0, (14 - centerDist) * 5);

这个改动虽然简单,但对AI前期棋型的塑造影响很明显。我实测下来,加了中心权重后AI开局的棋明显更有章法,至少懂得占中腹、发展势力,而不是满棋盘乱点。

5. 交互体验的细节打磨:悔棋、重开、落子反馈一次做到位

功能都跑通之后,这个游戏还缺几个提升体验的关键功能:悔棋、重开、落子动画和音效。这些不写也能玩,但写完之后整个项目才真正像个能端出去给人用的产品,而不是教学Demo。很多教程不讲这部分,我在这里把实现思路补上。

5.1 悔棋功能:用历史栈回退状态

悔棋在数据结构层面非常廉价:我们在第2章已经设计了steps数组来记录每一步落子的行列和落子方。悔棋就是从steps尾部弹出一个记录,把棋盘对应位置清零,然后把当前落子方改回那次落子的人。

function undo() { if (steps.length === 0) return false; const lastStep = steps.pop(); board[lastStep.row][lastStep.col] = 0; currentPlayer = lastStep.player; render(); return true; }

需要注意的是一局游戏的悔棋次数上限问题。有人觉得可以无限悔棋,但实际游戏中如果一方反复悔棋,游戏就没法继续了。我做了一个小设计:AI模式下一方每回合最多悔棋一次,双人同屏模式则不限制。限制的逻辑很简单,在undo函数里检查一个悔棋计数变量,如果等于次数上限直接返回false。另外,如果是AI已经落子之后悔棋,玩家会想连续悔两步——先悔AI的,再悔自己的。这时候可以做一个"连悔"按钮或者让悔棋逻辑自动触发两次,体验会好很多。

5.2 重开与胜负提示

重开一局,最简单粗暴的做法是重新加载页面,但那是偷懒。正确做法是重置数据结构和界面状态:二维数组全部清零,当前落子方设为黑棋,steps清空,渲染一帧空白棋盘。同时把游戏状态变量从"对局中"改为"可开始"再改回"对局中",给胜负提示UI留出展示空间。

胜负提示的UI我用了Canvas里直接画文字的方式,不用额外的DOM弹窗,这样保持界面统一。当checkWin判定成立时,在棋盘中上方画一个半透明遮罩层,上面写明"黑棋胜"或"白棋胜",再画一个"再来一局"的按钮区域。需要监听这个按钮的点击,本质上就是判断鼠标点击坐标是否落在按钮矩形区域里。

ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.fillRect(0, 200, canvasSize, 200); ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 36px sans-serif'; ctx.fillText(currentPlayer === 1 ? '白棋胜' : '黑棋胜', canvasSize / 2 - 70, 290);

这里的胜负颜色逻辑容易绕晕。checkWin是用棋子颜色来判断的,如果当前玩家是1(黑棋)时触发胜利,说明执棋方的对手赢了。所以提示文字要取currentPlayer === 1 ? '白棋胜' : '黑棋胜'。我第一次写反了,黑棋赢了界面显示白棋胜,差点以为判赢算法有问题。

5.3 落子动画和音效

落子动画不需要复杂,本质是"棋子从无到有"的视觉过渡。我用了Canvas的requestAnimationFrame做一个缩放动画:从半径0开始,每一帧把半径增加一定比例,直到铺满正常棋子大小,然后重绘该棋子。实现思路是这样:

let animating = null; function animatePiece(row, col) { const x = margin + col * cellSize; const y = margin + row * cellSize; const player = board[row][col]; let radius = 0; cancelAnimationFrame(animating); function step() { radius += 1.5; render(); // 先画完整棋盘 ctx.beginPath(); ctx.arc(x, y, Math.min(radius, pieceRadius), 0, Math.PI * 2); ctx.fillStyle = player === 1 ? '#333333' : '#FFFFFF'; ctx.fill(); ctx.stroke(); if (radius < pieceRadius) { animating = requestAnimationFrame(step); } } step(); }

因为每帧都要重新绘制整个棋盘的背景和所有棋子,所以这个动画的正确前提是渲染函数足够快。好在15x15棋盘的最大绘制对象也就是225个圆加16条线段,性能完全充裕。

音效方面,如果不引入外部音频文件,可以用Web Audio API程序化生成一个短促的棋子落盘音。原理非常简单:创建一个音源节点,生成一小段噪声或者正弦波,然后立刻衰减。代码核心几行就能实现:

function playPieceSound() { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gain = audioCtx.createGain(); oscillator.frequency.value = 220; gain.gain.setValueAtTime(0.3, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15); oscillator.connect(gain); gain.connect(audioCtx.destination); oscillator.start(); oscillator.stop(audioCtx.currentTime + 0.15); }

这段代码每落一子就新建一次AudioContext,频繁调用时可能会有性能消耗,更标准的做法是应用启动时创建一个复用的AudioContext,每次只重置增益和振荡器状态。不过作为练手项目,新建的方式胜在简单,实测也不会有明显卡顿。唯一要注意的是浏览器自动播放策略,AudioContext如果不在用户交互事件中创建会被挂起,这个函数在点击事件里调用,所以没问题。

6. 实战排坑记录:我从这个项目里踩过的几个典型坑

最后这部分是全文最想让读者少走弯路的章节。五子棋项目不算难,但有几个坑是所有新手都会遇到的,我把自己踩过的坑和排查过程完整写出来,按"现象、排查思路、根因、修复"的顺序呈现。

6.1 二维数组初始化引用共享导致整列变棋

这个坑前面提过一次,值得单独拿出来说完整。现象是:在某一行落子后,画布上同一列的多个位置都出现了棋子。第一反应以为是渲染逻辑重复调用,但排查渲染函数没发现问题。后来我打印board数组,发现board[3][7] = 1之后,board[0][7]、board[1][7]等位置也全变成了1。

根因就是像第2章说的,用Array(15).fill(new Array(15).fill(0))初始化时,fill填充的是同一个数组对象的引用。JS里数组元素是引用类型时,fill不拷贝对象,只是复制引用。结果15个外层元素指向同一个内层数组,改一行等于改所有行。

修复很简单,用循环或者Array.from逐行创建新数组:

// 错误写法 let board = Array(15).fill(new Array(15).fill(0)); // 正确写法 let board = Array.from({ length: 15 }, () => new Array(15).fill(0));

这个坑的教训比修复本身重要:在JavaScript里做二维数组初始化,第一反应必须是"每行的数组必须是独立引用",别用fill传对象。这个坑在棋盘类、网格类游戏里极其常见,排查询问自己"是不是引用被共享了"能节省大量时间。

6.2 点击偏移:漏掉getBoundingClientRect的坐标换算

第二个高频坑是点击棋盘边缘位置时,棋子落到完全不相干的地方。现象:鼠标点第2行第3列的交叉点,棋子画到了第4行第5列。最初我怀疑是取整方式有问题,从Math.floor改成Math.round,结果部分位置修好了,另一些位置还是偏。

排查过程我打印了点击的像素坐标和换算出来的行列下标,发现换算结果整体比预期大了半格左右。这时才意识到e.clientX是相对于浏览器视口的坐标,而Canvas绘制用的坐标是相对于Canvas自身左上角的坐标。一旦页面不是顶部对齐,或者Canvas外面有padding、margin,两个坐标系之间就有固定偏移量。getBoundingClientRect返回的left和top正好就是这个偏移。

修复方式就是第2.3节代码里的写法,点击坐标先减去Canvas自身矩形位置,再做换算。这个坑的困扰之处在于偏移量是固定的,看起来像"取整方法不对",其实坐标系根本没对齐。排查任何Canvas点击位置问题时,第一步就检查是否有rect偏移,这是标准的诊断优先级。

6.3 Canvas高分屏模糊:用设备像素比修正

还有一个和平台相关但不写就很容易被忽略的问题:在retina屏幕和高分屏上,Canvas的绘制内容会模糊发虚。原因是Canvas的CSS尺寸和物理像素尺寸不一致,浏览器默认使用CSS像素渲染,高分屏上每英寸对应的物理像素更多,Canvas内部画布分辨率没有跟着提升。

排查方式很直观:把同一个页面分别在普通屏和高分屏上看,高分屏明显发虚,基本可以确定是这个原因。修复标准做法是用设备像素比去放大Canvas的物理尺寸:

const dpr = window.devicePixelRatio || 1; canvas.width = canvasSize * dpr; canvas.height = canvasSize * dpr; canvas.style.width = canvasSize + 'px'; canvas.style.height = canvasSize + 'px'; ctx.scale(dpr, dpr);

这里的关键是把Canvas的物理分辨率设为逻辑尺寸的dpr倍,然后用ctx.scale把所有绘制指令放大到物理像素上,这样绘制的内容在物理像素层面就是完全对齐的,画面自然清晰。不做这一步,用高分屏玩会儿游戏眼睛是真的会累。

6.4 AI评估里的"伪活三"误判

AI评分函数还会遇到一个很有意思的误判问题:明明被堵了一头的三连子,被评分函数当成活三来高估价值。现象是AI总是冲向一侧进攻,看起来像无头苍蝇,而实际上那个方向早就被堵死了。

检查代码发现,我在统计连子数之后对openEnds计数时,只在两端延伸的while循环结束后各做了一次空位判断。如果延伸过程中遇到了对方棋子,这个方向的openEnds就不会增加,逻辑是对的。但问题出在"隔空"棋型上,比如"OO_OO"这种形状,中间有两个空位,两头是开放端。我的评分函数在单个方向上的连续计数只有2,openEnds=2,它被算成普通活二,但实战中这种跳二型棋很容易一步变成活四,威胁极大。这类棋型没有被识别出来,AI的防守就会显得"短视"。

修复方向有两个选择:让评分函数支持跳子棋型识别,或者直接不处理,当作简单版AI的特性保留。我当时选择简单处理,只做了一次改进:检查连子两端再往外两格范围内有没有同色子,如果有就适当加分。虽然还是不能完整覆盖所有跳棋型,但已经能把最致命的"对方下一步就要活四"的威胁识别出来。对练手项目来说,这个程度的AI已经能提供像样的对局体验了。

后续可以这样扩展

这个项目做到这里已经是一个功能完整的单机五子棋了。如果你玩腻了想继续加东西,我个人建议按这个顺序扩展:先加双人同屏模式,把AI入口换成模式选择,这样能和朋友一起玩;然后加本地存储保存对局记录,刷新页面不丢棋局;再往后可以加一个简单的房间对战,用前端的实时通信能力做联机,不过那就要引入服务端技术栈了,不属于纯前端范畴。每一次扩展都会把游戏逻辑、状态管理、渲染层搅动一遍,这本身就是比加新功能更有价值的练习。

我在重写这个项目的第三版时最深的一个体会是:像五子棋这种经典小项目,第一次写讲的是把功能跑通,第二次写讲的是把代码组织理顺,第三次写才会真正理解为什么数据结构、模块划分、算法选择这些东西如此重要。如果你现在第一遍还没写完,不要急着追求所有功能,先把一个能玩的版本跑起来,再回头重构,收获会远超预期。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询