从零到一通过Web技术开发一个五子棋

发布于:2025-07-01 ⋅ 阅读:(22) ⋅ 点赞:(0)

近期文章

五子棋的规则简单易懂,但其背后的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-gradientbackground-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-rowdata-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判断实际点击的是哪个交点,并获取其rowcol。这正是事件委托的妙用,它能大大减少事件监听器的数量,优化性能。

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类名(blackwhite)来改变颜色。定位棋子时要根据格子大小和交点中心进行计算。最后,它调用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.onopenws.onmessagews.onclosews.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开发的魅力就在于此——从零开始,用代码构建出无限可能。现在,是不是觉得做个小游戏也没那么难了?快动手尝试一下吧!最后欢迎体验: 五子棋在线体验