原生 JS 实战:300 行代码写五子棋小游戏(含完整源码 + 胜负算法解析)
大家好,我是老周。最近帮实习生做前端入门项目,发现五子棋是最适合练手的小游戏 —— 涵盖 Canvas 绘图、事件监听、二维数组操作等核心知识点,还能玩出优化花样。今天带大家用纯原生技术栈实现,代码可直接复制运行,新手也能 1 小时上手。
一、核心需求与技术选型
先明确开发边界,避免过度设计:
- 核心功能:棋盘绘制 + 黑白落子 + 胜负判断 + 重新开始
- 技术栈:HTML5 Canvas(绘图高效)+ TailwindCSS(快速样式)+ 原生 JS(无依赖,便于理解)
- 避坑提醒:早期试过用 DOM 画格子,15×15 的棋盘就卡得明显,Canvas 的像素操作效率甩 DOM 十条街
二、分步实现(附关键代码)
1. 基础结构搭建(HTML)
用 Canvas 作游戏区,加 2 个控制按钮,结构极简:html
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>原生JS五子棋</title><!-- 引入Tailwind简化样式 --><script src="https://cdn.tailwindcss.com"></script></head><body ><div ><!-- 游戏容器:Canvas必须设宽高属性,不能只靠CSS --><canvas id="chessboard" ></canvas><div ><button id="restart" >重新开始</button><button id="undo" >悔棋</button></div><p id="status" >当前回合:黑棋</p></div><script src="game.js"></script></body></html>
踩坑点:Canvas 的 / 属性决定绘图分辨率,CSS 样式只负责显示大小,两者不一致会导致图形拉伸。
2. 样式与常量定义(JS 初始化)
先定义全局常量,避免魔法数字:javascript
// 游戏常量(集中管理,便于修改)const CHESS_SIZE = 30; // 棋子大小const GRID_COUNT = 15; // 棋盘格子数const CANVAS_SIZE = CHESS_SIZE * GRID_COUNT; // 画布尺寸// 获取DOM元素const canvas = document.getElementById('chessboard');const ctx = canvas.getContext('2d');const restartBtn = document.getElementById('restart');const undoBtn = document.getElementById('undo');const statusText = document.getElementById('status');// 游戏状态let chessBoard = []; // 二维数组存棋盘状态:0空/1黑/2白let currentPlayer = 1; // 当前玩家:1黑棋/2白棋let gameOver = false;let history = []; // 悔棋历史// 初始化棋盘数组(15×15)function initChessBoard() {for (let i = 0; i < GRID_COUNT; i++) {chessBoard[i] = [];for (let j = 0; j < GRID_COUNT; j++) {chessBoard[i][j] = 0;}}history = [];gameOver = false;currentPlayer = 1;statusText.textContent = '当前回合:黑棋';}
3. 核心绘图逻辑(Canvas 实战)
分棋盘绘制和棋子绘制两部分,用 “中心点定位法” 避免偏移:javascript
// 绘制棋盘网格function drawBoard() {ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); // 清空画布ctx.strokeStyle = '#333';ctx.lineWidth = 1;// 画横线和竖线(从边缘留出半个棋子距离)const offset = CHESS_SIZE / 2;for (let i = 0; i < GRID_COUNT; i++) {// 横线ctx.beginPath();ctx.moveTo(offset, offset + i * CHESS_SIZE);ctx.lineTo(CANVAS_SIZE - offset, offset + i * CHESS_SIZE);ctx.stroke();// 竖线ctx.beginPath();ctx.moveTo(offset + i * CHESS_SIZE, offset);ctx.lineTo(offset + i * CHESS_SIZE, CANVAS_SIZE - offset);ctx.stroke();}// 画天元和星位(五子棋标准点位)const starPoints = [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]];starPoints.forEach(([x, y]) => {drawCircle(offset + x * CHESS_SIZE, offset + y * CHESS_SIZE, 3, '#000');});}// 通用画圆函数(复用性设计)function drawCircle(x, y, radius, color) {ctx.fillStyle = color;ctx.beginPath();ctx.arc(x, y, radius, 0, Math.PI * 2);ctx.fill();}// 绘制棋子function drawChess(x, y, type) {const X = CHESS_SIZE / 2 + x * CHESS_SIZE;const Y = CHESS_SIZE / 2 + y * CHESS_SIZE;// 棋子渐变效果(比纯色更真实)const gradient = ctx.createRadialGradient(X, Y, 0,X, Y, CHESS_SIZE / 2);if (type === 1) { // 黑棋gradient.addColorStop(0, '#666');gradient.addColorStop(1, '#000');} else { // 白棋gradient.addColorStop(0, '#fff');gradient.addColorStop(1, '#ccc');}drawCircle(X, Y, CHESS_SIZE / 2 - 1, gradient);}
优化技巧:用createRadialGradient做棋子渐变,比直接填色质感好太多,这是新手容易忽略的细节。
4. 落子与胜负判断(核心算法)
落子要处理 “坐标转换” 和 “重复落子”,胜负判断用 “四点一线检测法”:javascript
// 鼠标点击落子canvas.addEventListener('click', (e) => {if (gameOver) return;// 获取点击的画布坐标(修正偏移)const rect = canvas.getBoundingClientRect();const clickX = e.clientX - rect.left;const clickY = e.clientY - rect.top;// 转换为棋盘格子坐标(四舍五入取整)const x = Math.round((clickX - CHESS_SIZE / 2) / CHESS_SIZE);const y = Math.round((clickY - CHESS_SIZE / 2) / CHESS_SIZE);// 校验:边界内且空位if (x >= 0 && x < GRID_COUNT && y >= 0 && y < GRID_COUNT && chessBoard[x][y] === 0) {chessBoard[x][y] = currentPlayer;history.push([x, y]); // 记录悔棋历史drawChess(x, y, currentPlayer);// 判断胜负if (checkWin(x, y, currentPlayer)) {gameOver = true;statusText.textContent = `${currentPlayer === 1 ? '黑棋' : '白棋'}获胜!点击重新开始`;return;}// 切换玩家currentPlayer = currentPlayer === 1 ? 2 : 1;statusText.textContent = `当前回合:${currentPlayer === 1 ? '黑棋' : '白棋'}`;}});// 胜负判断算法(关键!)function checkWin(x, y, type) {// 四个方向:水平、垂直、左斜、右斜const directions = [[0, 1], // 水平[1, 0], // 垂直[1, 1], // 右斜[1, -1] // 左斜];for (let [dx, dy] of directions) {let count = 1; // 当前棋子算1个// 正向遍历for (let i = 1; i < 5; i++) {const newX = x + dx * i;const newY = y + dy * i;if (newX >= 0 && newX < GRID_COUNT && newY >= 0 && newY < GRID_COUNT && chessBoard[newX][newY] === type) {count++;} else {break;}}// 反向遍历for (let i = 1; i < 5; i++) {const newX = x - dx * i;const newY = y - dy * i;if (newX >= 0 && newX < GRID_COUNT && newY >= 0 && newY < GRID_COUNT && chessBoard[newX][newY] === type) {count++;} else {break;}}// 五子连珠if (count >= 5) {return true;}}return false;}
算法解析:对落子点的四个方向分别遍历,正向 + 反向计数≥5 即获胜,比暴力遍历整个棋盘效率高,实测 15×15 棋盘响应毫秒级。
5. 附加功能(悔棋 + 重启)
javascript
// 重新开始restartBtn.addEventListener('click', () => {initChessBoard();drawBoard();});// 悔棋功能undoBtn.addEventListener('click', () => {if (gameOver || history.length === 0) return;const [x, y] = history.pop();chessBoard[x][y] = 0; // 清空棋盘状态drawBoard(); // 重绘棋盘(简单直接,小棋盘够用)// 恢复上一玩家currentPlayer = currentPlayer === 1 ? 2 : 1;statusText.textContent = `当前回合:${currentPlayer === 1 ? '黑棋' : '白棋'}`;});// 初始化游戏initChessBoard();drawBoard();
注意:这里用重绘棋盘实现悔棋,适合小棋盘;如果做大型游戏,建议用 “脏矩形优化” 局部重绘,减少性能消耗。
三、完整源码与调试技巧
1. 单文件版源码(复制即用)
把 JS 代码嵌入 HTML 的<script>标签,保存为.html直接在浏览器打开:html
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>原生JS五子棋</title><script src="https://cdn.tailwindcss.com"></script></head><body ><div ><canvas id="chessboard" ></canvas><div ><button id="restart" >重新开始</button><button id="undo" >悔棋</button></div><p id="status" >当前回合:黑棋</p></div><script>// 此处粘贴上述所有JS代码</script></body></html>
2. 调试避坑指南
- 棋子偏移:检查
CHESS_SIZE是否整除CANVAS_SIZE,推荐用 30×15=450 的整数组合 - 胜负判断失效:在
checkWin里加console.log(count),看计数是否正确 - 移动端适配:给 Canvas 加
touchstart事件,逻辑和click一致,记得阻止默认行为
四、进阶拓展方向(给想深入的同学)
- AI 对手:用 “极大极小值算法” 实现简单 AI,核心是评估棋型得分(活四 > 冲四 > 活三)
- 联机对战:用 WebSocket 实现双人联机,参考 Istrolid 的网络架构思路
- 历史记录:用 localStorage 保存对局记录,支持复盘
- 皮肤系统:加载不同棋盘 / 棋子图片,用 Canvas 的
drawImage替代绘制
总结
五子棋看似简单,实则涵盖前端游戏开发的核心思维:状态管理(二维数组)+ 图形渲染(Canvas)+ 交互逻辑(事件监听)+ 算法优化(胜负判断)。新手别一开始就贪多,先跑通基础版,再逐步加功能 —— 我当年第一次写的时候,连坐标转换都踩了半天坑,多调试几次自然就懂了。你们做过哪些前端小游戏?或者想给这个五子棋加什么功能?评论区交流,我会抽时间解答!
