近期文章:
- 动手用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
// 这样避免了重新查询DOM
document.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或2
ws.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开发的魅力就在于此——从零开始,用代码构建出无限可能。现在,是不是觉得做个小游戏也没那么难了?快动手尝试一下吧!最后欢迎体验: 五子棋在线体验