从零到一通过Web技术开发一个五子棋
文章目录
- 1 你的第一个Web互动游戏——五子棋
- 2 核心技术剖析
- 2.1 棋盘的绘制与渲染
- 2.1.1 HTML结构:棋盘的骨架
- 2.1.2 CSS美化:让棋盘看起来像个样
- 2.1.3 JavaScript动态生成:让棋盘“活”起来
- 2.2 落子逻辑与交互
- 2.2.1 事件监听:捕获用户的“点击”
- 2.2.2 落子判断与棋子渲染
- 2.3 胜负判断算法
- 2.3.1 判断方向:五种连珠可能
- 2.3.2 算法实现细节
- 2.4 (进阶)多人联机:WebSocket的魔力
- 2.4.1 为什么需要WebSocket?
- 2.4.2 WebSocket基本原理
- 2.4.3 前后端实现思路
- 3 实践
- 3.1 代码结构与模块化
- 3.2 错误处理与用户提示
- 3.3 性能优化
- 3.4 交互体验提升
- 4 最后
- 4.1 回顾
- 4.2 不同游戏难度在实现上有什么不同?
- 4.3 你的Web开发之旅才刚刚开始!
近期文章:
- 动手用Web技术开发一个数独游戏
- 动手用 Web 实现一个 2048 游戏
- 【前端练手必备】从零到一,教你用JS写出风靡全球的“贪吃蛇”!
- Google Search Console 做SEO分析之“已发现未编入” 与 “已抓取未编入” 有什么区别?
- 如何通过 noindex 阻止网页被搜索引擎编入索引?
- 建站SEO优化之站点地图sitemap
- 个人建站做SEO网站外链这一点需要注意,做错了可能受到Google惩罚
- 一文搞懂SEO优化之站点robots.txt
- 实现篇:二叉树遍历收藏版
- 实现篇:LRU算法的几种实现
- Nginx Upstream了解一下
- 一文搞懂 Markdown 文档规则
五子棋的规则简单易懂,但其背后的Web开发原理却能涵盖前端交互、游戏状态管理,甚至还能拓展到实时通信。它不仅是前端入门的绝佳实践项目,更能让你体验到亲手创造一个互动产品的巨大成就感。通过这个项目,你将不再是只会切图的“页面仔”,而是能让页面“活”起来的魔法师!
1 你的第一个Web互动游戏——五子棋
是不是经常羡慕那些能做出炫酷小游戏的开发者?今天,就来一起迈出第一步,用最纯粹的Web技术(HTML、CSS、JavaScript)打造一个经典小游戏——五子棋! 五子棋在线体验
- 绘制精美的五子棋盘:用HTML和CSS搭骨架,用JavaScript赋予生命。
- 实现核心落子逻辑:响应用户点击,更新游戏状态。
- 掌握经典的胜负判断算法:让你的五子棋能真正判断输赢。
- (进阶) 探索多人联机的奥秘:了解WebSocket如何打破实时通信的壁垒。
准备好了吗?让我们开始这场充满乐趣的Web游戏开发之旅!
2 核心技术剖析
2.1 棋盘的绘制与渲染
五子棋的第一步,当然是把棋盘画出来!我们有两种主要方案:基于DOM元素和基于Canvas。
2.1.1 HTML结构:棋盘的骨架
我们以DOM元素为例,创建一个容器来承载棋盘。
<div id="chessboard"></div>
干货:DOM vs Canvas?
- DOM方案:每个棋格或棋子都是一个独立的HTML元素(
<div>
)。优点是上手快,调试方便,可以使用CSS丰富的样式特性。缺点是当棋盘很大或元素很多时,DOM操作可能导致性能问题。 - Canvas方案:通过
<canvas>
元素在JavaScript中进行像素级绘图。优点是性能好,适合绘制大量图形,动画流畅。缺点是学习曲线稍高,调试不如DOM直观。
如何选择? 对于五子棋这种棋盘大小相对固定、元素数量可控的游戏,DOM方案足以应对,且更易于理解和实现。但如果你想做更复杂的策略游戏或动画,Canvas会是更好的选择。本文我们主要基于DOM来实现。
2.1.2 CSS美化:让棋盘看起来像个样
现在,我们给棋盘容器加上样式,并巧妙地利用CSS伪元素来绘制棋盘线。
#chessboard {display: grid;/* 15x15棋盘,每个格子大小为30px */grid-template-columns: repeat(15, 30px);grid-template-rows: repeat(15, 30px);width: 450px; /* 15 * 30px */height: 450px; /* 15 * 30px */border: 1px solid #333;background-color: #f7d6a2; /* 棋盘背景色 */position: relative; /* 用于棋子定位 */
}/* 使用伪元素绘制棋盘线 - 垂直线 */
#chessboard::before {content: '';position: absolute;top: 0;left: 0;width: 100%;height: 100%;background:linear-gradient(to right, #333 1px, transparent 1px),linear-gradient(to bottom, #333 1px, transparent 1px);background-size: 30px 30px; /* 每个格子的宽度 */pointer-events: none; /* 让点击事件穿透 */
}/* 棋子样式 */
.piece {position: absolute;width: 28px; /* 略小于格子大小,留出空隙 */height: 28px;border-radius: 50%;transform: translate(-50%, -50%); /* 居中显示 */transition: transform 0.1s ease-out; /* 增加落子动画 */
}.black { background-color: #000; }
.white { background-color: #fff; border: 1px solid #ccc; }
干货:CSS伪元素绘制棋盘线
这里我们用linear-gradient
和background-size
在::before
伪元素上创建了网格背景,它模拟了棋盘线。这种方法比创建225个<div>
元素来充当格子更高效,因为它减少了DOM元素的数量,从而优化了渲染性能。pointer-events: none;
确保伪元素不会阻挡鼠标事件,让点击能穿透到棋盘本身。
2.1.3 JavaScript动态生成:让棋盘“活”起来
棋盘画好了,但它还是静态的。我们需要用JavaScript来动态管理棋盘的状态。
const BOARD_SIZE = 15;
const CELL_SIZE = 30; // 和CSS中的格子大小一致
let board = []; // 二维数组表示棋盘状态:0:空, 1:黑棋, 2:白棋
let currentPlayer = 1; // 1:黑棋, 2:白棋function initBoard() {const chessboardDiv = document.getElementById('chessboard');chessboardDiv.innerHTML = ''; // 清空可能存在的旧棋子board = Array(BOARD_SIZE).fill(0).map(() => Array(BOARD_SIZE).fill(0));currentPlayer = 1; // 默认黑棋先手// 干货:使用DocumentFragment批量操作DOM// 创建一个文档片段,所有DOM操作都在这里进行,最后一次性添加到DOM树// 避免频繁重排重绘,提高性能const fragment = document.createDocumentFragment();for (let r = 0; r < BOARD_SIZE; r++) {for (let c = 0; c < BOARD_SIZE; c++) {const intersection = document.createElement('div');intersection.className = 'intersection'; // 作为一个可点击的落子区域intersection.dataset.row = r;intersection.dataset.col = c;// 定位每个交点(棋子实际落点)intersection.style.left = `${c * CELL_SIZE + CELL_SIZE / 2}px`;intersection.style.top = `${r * CELL_SIZE + CELL_SIZE / 2}px`;fragment.appendChild(intersection);}}chessboardDiv.appendChild(fragment);
}initBoard();
代码速览: initBoard
函数初始化了board
二维数组,所有值都设为0(空)。接着,它动态创建了代表棋盘交点的div
元素,并设置了data-row
和data-col
属性,方便后续获取点击位置。DocumentFragment
的使用是一个性能优化的干货,它能将多次DOM操作合并为一次,显著减少浏览器的重排重绘,让初始化过程更流畅。
2.2 落子逻辑与交互
现在,棋盘有了,我们得让它能响应玩家的点击。
2.2.1 事件监听:捕获用户的“点击”
const chessboardDiv = document.getElementById('chessboard');
chessboardDiv.addEventListener('click', handleBoardClick);function handleBoardClick(event) {// 干货:事件委托(Event Delegation)// 通过判断event.target来确定点击的是否是棋盘交点// 避免给每个intersection元素都绑定事件监听器,减少内存消耗if (!event.target.classList.contains('intersection')) {return; // 如果点击的不是交点,则忽略}const row = parseInt(event.target.dataset.row);const col = parseInt(event.target.dataset.col);placePiece(row, col);
}
代码速览: 我们只给chessboardDiv
这个父元素绑定了一个点击事件监听器handleBoardClick
。在handleBoardClick
中,通过event.target
判断实际点击的是哪个交点,并获取其row
和col
。这正是事件委托的妙用,它能大大减少事件监听器的数量,优化性能。
2.2.2 落子判断与棋子渲染
点击事件捕获后,我们需要判断是否可以落子,并渲染棋子。
function placePiece(row, col) {// 1. 判断是否已存在棋子if (board[row][col] !== 0) {console.log('此处已有棋子,请重新选择!');return;}// 2. 更新棋盘状态数组board[row][col] = currentPlayer;// 3. 渲染棋子const piece = document.createElement('div');piece.className = `piece ${currentPlayer === 1 ? 'black' : 'white'}`;// 定位棋子到对应的交点piece.style.left = `${col * CELL_SIZE + CELL_SIZE / 2}px`;piece.style.top = `${row * CELL_SIZE + CELL_SIZE / 2}px`;// 干货:优化渲染:直接添加到被点击的intersection的父级chessboardDiv// 这样避免了重新查询DOMdocument.getElementById('chessboard').appendChild(piece);// 4. 判断胜负if (checkWin(row, col, currentPlayer)) {alert(`${currentPlayer === 1 ? '黑棋' : '白棋'}胜利!`);// 游戏结束处理,例如禁用落子,显示重置按钮chessboardDiv.removeEventListener('click', handleBoardClick);return;}// 5. 切换当前玩家currentPlayer = currentPlayer === 1 ? 2 : 1;
}
代码速览: placePiece
函数是落子逻辑的核心。它首先检查点击位置是否为空,然后更新board
数组并动态创建div
元素来表示棋子,添加相应的CSS类名(black
或white
)来改变颜色。定位棋子时要根据格子大小和交点中心进行计算。最后,它调用checkWin
判断胜负,并切换当前玩家。
2.3 胜负判断算法
这是五子棋最核心的算法之一。我们需要检查当前落子点在水平、垂直、两个对角线方向上是否有连续的五颗同色棋子。
2.3.1 判断方向:五种连珠可能
- 水平:左右方向
- 垂直:上下方向
- 主对角线:左上到右下
- 副对角线:右上到左下
2.3.2 算法实现细节
干货:只判断新落子点周围
一个重要的优化是:我们不需要每次落子都遍历整个棋盘来判断胜负。我们只需要检查新落子点的八个方向(或四个方向的两端延伸),看是否有连续五子。这样可以大大减少计算量。
function checkWin(row, col, player) {// 定义8个方向的偏移量:水平、垂直、对角线const directions = [[0, 1], // 水平右[1, 0], // 垂直下[1, 1], // 主对角线右下[1, -1] // 副对角线左下];// 对于每个方向,我们检查正反两个方向的连珠数for (let i = 0; i < directions.length; i++) {const dr = directions[i][0];const dc = directions[i][1];let count = 1; // 初始连珠数为1(包括当前落子)// 向一个方向延伸for (let step = 1; step < 5; step++) {const newRow = row + dr * step;const newCol = col + dc * step;if (newRow >= 0 && newRow < BOARD_SIZE &&newCol >= 0 && newCol < BOARD_SIZE &&board[newRow][newCol] === player) {count++;} else {break; // 遇到边界或不同色棋子,停止延伸}}// 向相反方向延伸for (let step = 1; step < 5; step++) {const newRow = row - dr * step;const newCol = col - dc * step;if (newRow >= 0 && newRow < BOARD_SIZE &&newCol >= 0 && newCol < BOARD_SIZE &&board[newRow][newCol] === player) {count++;} else {break;}}if (count >= 5) {return true; // 发现五子连珠}}return false; // 没有五子连珠
}
代码速览: checkWin
函数遍历directions
数组,每个元素代表一个主方向的偏移量。对于每个主方向,它会分别向正方向和反方向延伸,计数连续同色棋子的数量。只要有一个方向上的计数达到或超过5,就说明赢了。边界条件检查(newRow >= 0 && newRow < BOARD_SIZE
等)是必不可少的,防止数组越界。
2.4 (进阶)多人联机:WebSocket的魔力
如果你的五子棋只能自己玩,那多无聊?实现联机对战才是游戏的灵魂!这里,我们需要WebSocket。
2.4.1 为什么需要WebSocket?
传统的HTTP协议是短连接、请求-响应模式。客户端发送请求,服务器响应后连接就关闭了。这种模式不适合实时互动游戏,因为服务器无法主动推送信息给客户端(例如对手的落子)。
而WebSocket提供了全双工通信的持久连接,一旦建立,客户端和服务器可以双向自由发送消息,且延迟极低,完美适用于实时游戏、聊天室等场景。
2.4.2 WebSocket基本原理
客户端通过特殊的HTTP握手请求升级到WebSocket协议。握手成功后,双方就建立了一条TCP连接上的“管道”,可以不受HTTP请求/响应模式的限制,直接发送消息帧。
2.4.3 前后端实现思路
- 前端(JavaScript):
let ws;
function connectWebSocket() {ws = new WebSocket('ws://localhost:3000'); // 替换成你的服务器地址ws.onopen = () => {console.log('WebSocket连接成功!');// 可以发送加入房间的消息ws.send(JSON.stringify({ type: 'joinGame', gameId: 'gomoku123' }));};ws.onmessage = (event) => {const message = JSON.parse(event.data);if (message.type === 'placePiece') {// 收到对手落子信息,在本地棋盘渲染并更新状态console.log('收到对手落子:', message.row, message.col);// 模拟placePiece逻辑,但不切换玩家,因为是对方的落子// 你需要一个单独的函数来处理接收到的落子renderOpponentPiece(message.row, message.col, message.player);} else if (message.type === 'gameStart') {console.log('游戏开始!你是玩家', message.playerRole);// 根据playerRole决定你是黑棋还是白棋}// 处理其他消息,如胜利、聊天等};ws.onclose = () => console.log('WebSocket连接关闭');ws.onerror = (error) => console.error('WebSocket错误:', error);
}// 玩家落子后,发送消息给服务器
function sendMove(row, col) {if (ws && ws.readyState === WebSocket.OPEN) {ws.send(JSON.stringify({type: 'placePiece',row: row,col: col,player: currentPlayer // 发送当前玩家信息}));}
}// 在 placePiece 函数中调用 sendMove
// ... (现有 placePiece 函数代码)
// if (!checkWin(...)) {
// sendMove(row, col); // 成功落子且未赢,则发送给服务器
// currentPlayer = currentPlayer === 1 ? 2 : 1;
// }
// ...
代码速览: 前端通过new WebSocket()
建立连接,ws.onopen
、ws.onmessage
、ws.onclose
、ws.onerror
处理连接生命周期和消息。当玩家落子后,通过ws.send()
将落子信息发送给服务器。
- 后端(Node.js +
ws
库为例):
后端需要一个WebSocket服务器来接收客户端连接、管理游戏房间、同步玩家操作。
// 假设使用 Node.js 和 ws 库
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });const gameRooms = {}; // 存储游戏房间信息 { gameId: { players: [], board: [] } }wss.on('connection', ws => {console.log('一个新客户端连接了!');ws.on('message', message => {const data = JSON.parse(message);switch (data.type) {case 'joinGame':// 简单房间匹配逻辑if (!gameRooms[data.gameId]) {gameRooms[data.gameId] = { players: [], board: Array(15).fill(0).map(() => Array(15).fill(0)) };}if (gameRooms[data.gameId].players.length < 2) {gameRooms[data.gameId].players.push(ws);ws.gameId = data.gameId; // 记录玩家所在房间ws.playerRole = gameRooms[data.gameId].players.length; // 1或2ws.send(JSON.stringify({ type: 'gameStart', playerRole: ws.playerRole }));if (gameRooms[data.gameId].players.length === 2) {// 通知双方游戏开始gameRooms[data.gameId].players.forEach(playerWs => {playerWs.send(JSON.stringify({ type: 'opponentJoined' }));});}} else {ws.send(JSON.stringify({ type: 'error', message: '房间已满' }));}break;case 'placePiece':const { row, col, player } = data;const room = gameRooms[ws.gameId];if (room && room.players.includes(ws) && room.board[row][col] === 0) {room.board[row][col] = player; // 更新服务器端棋盘状态// 广播给房间内所有其他玩家room.players.forEach(playerWs => {if (playerWs !== ws) { // 不发给自己playerWs.send(JSON.stringify({ type: 'placePiece', row, col, player }));}});// 后端也应该做胜负判断,并发送胜利/平局消息}break;// ... 其他消息处理,如聊天、认输等}});ws.on('close', () => {console.log('客户端断开连接');// 简单处理玩家离线:通知房间内其他玩家if (ws.gameId && gameRooms[ws.gameId]) {gameRooms[ws.gameId].players = gameRooms[ws.gameId].players.filter(p => p !== ws);if (gameRooms[ws.gameId].players.length === 0) {delete gameRooms[ws.gameId]; // 房间无人则删除} else {gameRooms[ws.gameId].players.forEach(playerWs => {playerWs.send(JSON.stringify({ type: 'opponentLeft' }));});}}});
});console.log('WebSocket服务器已启动在 ws://localhost:3000');
代码速览: 后端使用ws
库创建WebSocket服务器。wss.on('connection')
监听新连接。ws.on('message')
处理客户端发来的消息,例如joinGame
(房间管理)和placePiece
(落子同步)。核心是当一个玩家落子时,服务器接收到消息后,会广播给同一房间内的所有其他玩家,实现实时同步。
3 实践
一个可玩的游戏只是开始,良好的代码结构和用户体验同样重要。
3.1 代码结构与模块化
为了让代码更易于维护和扩展,你应该将不同功能的代码拆分到不同的文件,并使用ES6模块化进行导入导出。
index.html
:页面结构style.css
:样式board.js
:棋盘绘制、渲染逻辑gameLogic.js
:落子、胜负判断、玩家切换逻辑websocket.js
:WebSocket连接和消息处理(如果实现联机)main.js
:入口文件,协调各个模块
干货:ES6模块化
在HTML中引入main.js
时,使用type="module"
:
<script type="module" src="main.js"></script>
在main.js
中导入其他模块:
// main.js
import { initBoard } from './board.js';
import { handleBoardClick } from './gameLogic.js';
// ...
在board.js
中导出函数:
// board.js
export function initBoard() { /* ... */ }
3.2 错误处理与用户提示
- 当用户点击无效位置(已有棋子)时,给出清晰的提示。
- 联机模式下,处理网络断开、服务器错误等情况,告知用户。
3.3 性能优化
- 减少DOM操作:尽可能利用
DocumentFragment
或直接操作innerHTML
,避免频繁的DOM增删改查。 - 使用Canvas:如果对性能要求极高,或者想实现更复杂的动画效果,将棋盘和棋子绘制迁移到
<canvas>
上会是更好的选择。Canvas是像素级的绘图,性能远高于DOM元素。
3.4 交互体验提升
- 落子音效:每次落子播放简单的音效,增加代入感。
- 胜利动画/提示:游戏结束时,用更醒目的方式(如弹窗、棋子闪烁)提示胜利者。
- 悔棋功能:这是一个更高级的功能。需要维护一个历史操作栈,每次落子时将当前棋盘状态或操作记录入栈,悔棋时从栈中取出上一步状态恢复。
4 最后
4.1 回顾
- Web前端基础:HTML构建结构,CSS美化样式,JavaScript实现动态交互。
- 事件驱动编程:学会监听用户事件并响应。
- 算法思维:设计并优化了胜负判断的核心算法。
- 实时通信技术:了解了WebSocket在多人游戏中的强大作用。
4.2 不同游戏难度在实现上有什么不同?
这里我们主要实现的是双人对弈的五子棋。如果想增加游戏难度,例如实现人机对战(AI),那将是另一个巨大的挑战:
- 简单AI:随机落子,或只检查当前落子位置的简单连珠。
- 中等AI:评估棋盘上每个空位的得分(例如,构成活二、活三、冲四等),选择得分最高的点落子。这需要实现棋型分析算法。
- 高级AI(如 Minimax算法、Alpha-Beta剪枝):模拟未来多步棋局,通过树搜索和评估函数来找到最优解。这涉及到复杂的博弈论和搜索算法,实现难度呈指数级增长。
4.3 你的Web开发之旅才刚刚开始!
五子棋只是冰山一角。掌握了这些核心技术和思路,你完全可以尝试开发更多有趣的Web小游戏,例如:
- 俄罗斯方块
- 扫雷
- 贪吃蛇
- 甚至更复杂的策略游戏!
Web开发的魅力就在于此——从零开始,用代码构建出无限可能。现在,是不是觉得做个小游戏也没那么难了?快动手尝试一下吧!最后欢迎体验: 五子棋在线体验
- 五子棋规则