pin_drop当前位置:知识文库 ❯ 图文

游戏开发:2D益智游戏 - 从入门到实践详解

教程简介

益智游戏(Puzzle Game)是最考验游戏设计能力的类型之一,从俄罗斯方块到三消游戏,优秀的益智游戏需要精巧的规则设计和流畅的交互体验。本教程将带你实现一个完整的三消益智游戏,涵盖益智游戏设计、消除算法、拖拽交互、匹配检测、连锁反应、关卡生成等核心内容。

核心概念

益智游戏设计

三消游戏的核心要素:

  • 棋盘网格:固定大小的网格存放不同类型的方块

  • 拖拽交换:玩家通过拖拽交换相邻方块

  • 匹配检测:检测3个或以上相同方块的连线

  • 消除动画:匹配方块的消除和得分动画

  • 下落填充:消除后上方方块下落,顶部生成新方块

  • 连锁反应:下落后可能产生新的匹配,形成连锁

消除算法

代码示例

class MatchDetector {
    constructor(board, rows, cols) {
        this.board = board;
        this.rows = rows;
        this.cols = cols;
    }

    // 检测所有匹配
    findAllMatches() {
        const matches = new Set();

        // 水平检测
        for (let r = 0; r < this.rows; r++) {
            for (let c = 0; c < this.cols - 2; c++) {
                const type = this.board[r][c];
                if (type !== null && type === this.board[r][c+1] && type === this.board[r][c+2]) {
                    let end = c + 2;
                    while (end + 1 < this.cols && this.board[r][end+1] === type) end++;
                    for (let i = c; i <= end; i++) matches.add(`${r},${i}`);
                }
            }
        }

        // 垂直检测
        for (let c = 0; c < this.cols; c++) {
            for (let r = 0; r < this.rows - 2; r++) {
                const type = this.board[r][c];
                if (type !== null && type === this.board[r+1][c] && type === this.board[r+2][c]) {
                    let end = r + 2;
                    while (end + 1 < this.rows && this.board[end+1][c] === type) end++;
                    for (let i = r; i <= end; i++) matches.add(`${i},${c}`);
                }
            }
        }

        return [...matches].map(s => { const [r, c] = s.split(',').map(Number); return { r, c }; });
    }
}

拖拽交互

代码示例

class DragHandler {
    constructor(cellSize) {
        this.cellSize = cellSize;
        this.dragging = false;
        this.startCell = null;
        this.currentCell = null;
    }

    startDrag(gridX, gridY) {
        this.dragging = true;
        this.startCell = { r: gridY, c: gridX };
    }

    updateDrag(gridX, gridY) {
        if (!this.dragging) return null;
        this.currentCell = { r: gridY, c: gridX };

        const dr = this.currentCell.r - this.startCell.r;
        const dc = this.currentCell.c - this.startCell.c;

        // 只允许相邻交换
        if (Math.abs(dr) + Math.abs(dc) === 1) {
            return { from: this.startCell, to: this.currentCell };
        }
        return null;
    }

    endDrag() {
        this.dragging = false;
        this.startCell = null;
        this.currentCell = null;
    }
}

语法与用法

完整的三消益智游戏

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>2D益智游戏 - 三消</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
        canvas { border: 2px solid #2a2a4a; border-radius: 8px; }
        .info { margin-top: 8px; font-size: 12px; color: #555; text-align: center; }
    </style>
</head>
<body>
    <canvas id="gameCanvas" width="800" height="600"></canvas>
    <div class="info">拖拽交换相邻宝石 | 三个以上相同消除 | 达到目标分数过关</div>
    <script>
        class Match3Game {
            constructor() {
                this.canvas = document.getElementById('gameCanvas');
                this.ctx = this.canvas.getContext('2d');
                this.W = 800; this.H = 600;
                this.ROWS = 8; this.COLS = 8;
                this.CELL = 56;
                this.BOARD_X = (this.W - this.COLS * this.CELL) / 2;
                this.BOARD_Y = 80;
                this.GEM_TYPES = 6;

                this.gemColors = ['#e94560', '#4a90d9', '#16c79a', '#f0a500', '#533483', '#ff8c42'];
                this.gemShapes = ['circle', 'diamond', 'square', 'triangle', 'star', 'hexagon'];

                this.state = 'menu';
                this.score = 0;
                this.targetScore = 1000;
                this.level = 1;
                this.moves = 30;
                this.combo = 0;
                this.lastTime = 0;
                this.gameTime = 0;
                this.particles = [];

                // 棋盘
                this.board = [];
                this.animating = false;
                this.animations = [];

                // 拖拽
                this.dragStart = null;
                this.dragCurrent = null;
                this.isDragging = false;

                this.setupInput();
            }

            setupInput() {
                const getGridPos = (e) => {
                    const rect = this.canvas.getBoundingClientRect();
                    const mx = (e.clientX - rect.left) * (this.W / rect.width);
                    const my = (e.clientY - rect.top) * (this.H / rect.height);
                    const c = Math.floor((mx - this.BOARD_X) / this.CELL);
                    const r = Math.floor((my - this.BOARD_Y) / this.CELL);
                    if (r >= 0 && r < this.ROWS && c >= 0 && c < this.COLS) return { r, c };
                    return null;
                };

                this.canvas.addEventListener('mousedown', (e) => {
                    if (this.state !== 'playing' || this.animating) return;
                    const pos = getGridPos(e);
                    if (pos) { this.isDragging = true; this.dragStart = pos; this.dragCurrent = pos; }
                });

                this.canvas.addEventListener('mousemove', (e) => {
                    if (!this.isDragging) return;
                    const pos = getGridPos(e);
                    if (pos) this.dragCurrent = pos;
                });

                this.canvas.addEventListener('mouseup', (e) => {
                    if (!this.isDragging) return;
                    const pos = getGridPos(e);
                    if (pos && this.dragStart) {
                        const dr = pos.r - this.dragStart.r;
                        const dc = pos.c - this.dragStart.c;
                        if (Math.abs(dr) + Math.abs(dc) === 1) {
                            this.trySwap(this.dragStart, pos);
                        }
                    }
                    this.isDragging = false;
                    this.dragStart = null;
                    this.dragCurrent = null;
                });

                this.canvas.addEventListener('click', () => {
                    if (this.state === 'menu') this.startGame();
                    if (this.state === 'gameover' || this.state === 'win') this.state = 'menu';
                });

                document.addEventListener('keydown', (e) => {
                    if (e.code === 'Space') {
                        if (this.state === 'menu') this.startGame();
                        if (this.state === 'gameover' || this.state === 'win') this.state = 'menu';
                    }
                });
            }

            startGame() {
                this.state = 'playing';
                this.score = 0; this.level = 1;
                this.moves = 30; this.combo = 0;
                this.targetScore = 1000;
                this.particles = [];
                this.initBoard();
            }

            initBoard() {
                this.board = [];
                for (let r = 0; r < this.ROWS; r++) {
                    this.board[r] = [];
                    for (let c = 0; c < this.COLS; c++) {
                        let type;
                        do {
                            type = Math.floor(Math.random() * this.GEM_TYPES);
                        } while (this.wouldMatch(r, c, type));
                        this.board[r][c] = { type, scale: 1, alpha: 1, offsetY: 0 };
                    }
                }
            }

            wouldMatch(r, c, type) {
                // 检查左边两个
                if (c >= 2 && this.board[r][c-1] && this.board[r][c-2] &&
                    this.board[r][c-1].type === type && this.board[r][c-2].type === type) return true;
                // 检查上面两个
                if (r >= 2 && this.board[r-1] && this.board[r-2] &&
                    this.board[r-1][c] && this.board[r-2][c] &&
                    this.board[r-1][c].type === type && this.board[r-2][c].type === type) return true;
                return false;
            }

            async trySwap(from, to) {
                // 交换
                const temp = this.board[from.r][from.c];
                this.board[from.r][from.c] = this.board[to.r][to.c];
                this.board[to.r][to.c] = temp;

                // 检查是否有匹配
                const matches = this.findMatches();
                if (matches.length === 0) {
                    // 换回
                    const temp2 = this.board[from.r][from.c];
                    this.board[from.r][from.c] = this.board[to.r][to.c];
                    this.board[to.r][to.c] = temp2;
                    return;
                }

                this.moves--;
                this.combo = 0;
                this.animating = true;

                // 处理连锁
                await this.processMatches();

                this.animating = false;

                // 检查游戏状态
                if (this.score >= this.targetScore) {
                    this.level++;
                    this.targetScore += 500 + this.level * 200;
                    this.moves = Math.max(20, 30 - this.level);
                    this.state = this.level > 5 ? 'win' : 'playing';
                } else if (this.moves <= 0) {
                    this.state = 'gameover';
                }
            }

            async processMatches() {
                let matches = this.findMatches();
                while (matches.length > 0) {
                    this.combo++;

                    // 消除动画
                    for (const { r, c } of matches) {
                        if (this.board[r][c]) {
                            this.spawnGemParticles(r, c, this.board[r][c].type);
                            this.board[r][c] = null;
                        }
                    }

                    const comboBonus = Math.min(this.combo, 5);
                    this.score += matches.length * 10 * comboBonus;

                    await this.delay(200);

                    // 下落
                    this.dropGems();
                    // 填充
                    this.fillBoard();

                    await this.delay(300);

                    matches = this.findMatches();
                }
            }

            findMatches() {
                const matches = new Set();

                // 水平
                for (let r = 0; r < this.ROWS; r++) {
                    for (let c = 0; c < this.COLS - 2; c++) {
                        if (!this.board[r][c]) continue;
                        const type = this.board[r][c].type;
                        if (this.board[r][c+1] && this.board[r][c+2] &&
                            this.board[r][c+1].type === type && this.board[r][c+2].type === type) {
                            let end = c + 2;
                            while (end + 1 < this.COLS && this.board[r][end+1] && this.board[r][end+1].type === type) end++;
                            for (let i = c; i <= end; i++) matches.add(`${r},${i}`);
                        }
                    }
                }

                // 垂直
                for (let c = 0; c < this.COLS; c++) {
                    for (let r = 0; r < this.ROWS - 2; r++) {
                        if (!this.board[r][c]) continue;
                        const type = this.board[r][c].type;
                        if (this.board[r+1][c] && this.board[r+2][c] &&
                            this.board[r+1][c].type === type && this.board[r+2][c].type === type) {
                            let end = r + 2;
                            while (end + 1 < this.ROWS && this.board[end+1][c] && this.board[end+1][c].type === type) end++;
                            for (let i = r; i <= end; i++) matches.add(`${i},${c}`);
                        }
                    }
                }

                return [...matches].map(s => { const [r, c] = s.split(',').map(Number); return { r, c }; });
            }

            dropGems() {
                for (let c = 0; c < this.COLS; c++) {
                    let writeRow = this.ROWS - 1;
                    for (let r = this.ROWS - 1; r >= 0; r--) {
                        if (this.board[r][c]) {
                            if (r !== writeRow) {
                                this.board[writeRow][c] = this.board[r][c];
                                this.board[r][c] = null;
                            }
                            writeRow--;
                        }
                    }
                }
            }

            fillBoard() {
                for (let c = 0; c < this.COLS; c++) {
                    for (let r = 0; r < this.ROWS; r++) {
                        if (!this.board[r][c]) {
                            this.board[r][c] = {
                                type: Math.floor(Math.random() * this.GEM_TYPES),
                                scale: 1, alpha: 1, offsetY: 0
                            };
                        }
                    }
                }
            }

            spawnGemParticles(r, c, type) {
                const x = this.BOARD_X + c * this.CELL + this.CELL / 2;
                const y = this.BOARD_Y + r * this.CELL + this.CELL / 2;
                const color = this.gemColors[type];
                for (let i = 0; i < 8; i++) {
                    const angle = Math.random() * Math.PI * 2;
                    const speed = 50 + Math.random() * 100;
                    this.particles.push({
                        x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
                        life: 0.4 + Math.random() * 0.3, size: 3 + Math.random() * 3, color
                    });
                }
            }

            delay(ms) {
                return new Promise(resolve => setTimeout(resolve, ms));
            }

            update(dt) {
                this.gameTime += dt;

                // 粒子
                for (let i = this.particles.length - 1; i >= 0; i--) {
                    const p = this.particles[i];
                    p.x += p.vx * dt; p.y += p.vy * dt;
                    p.vy += 150 * dt; p.life -= dt;
                    if (p.life <= 0) this.particles.splice(i, 1);
                }
            }

            render() {
                const ctx = this.ctx;
                const W = this.W, H = this.H;

                if (this.state === 'menu') { this.renderMenu(ctx, W, H); return; }
                if (this.state === 'gameover') { this.renderGameOver(ctx, W, H); return; }
                if (this.state === 'win') { this.renderWin(ctx, W, H); return; }

                // 背景
                ctx.fillStyle = '#0a0a1a';
                ctx.fillRect(0, 0, W, H);

                // 棋盘背景
                ctx.fillStyle = '#0f0f2a';
                ctx.fillRect(this.BOARD_X - 4, this.BOARD_Y - 4,
                    this.COLS * this.CELL + 8, this.ROWS * this.CELL + 8);

                // 网格
                for (let r = 0; r < this.ROWS; r++) {
                    for (let c = 0; c < this.COLS; c++) {
                        const x = this.BOARD_X + c * this.CELL;
                        const y = this.BOARD_Y + r * this.CELL;
                        ctx.fillStyle = (r + c) % 2 === 0 ? '#141430' : '#181840';
                        ctx.fillRect(x, y, this.CELL, this.CELL);
                    }
                }

                // 拖拽高亮
                if (this.isDragging && this.dragStart) {
                    const sx = this.BOARD_X + this.dragStart.c * this.CELL;
                    const sy = this.BOARD_Y + this.dragStart.r * this.CELL;
                    ctx.strokeStyle = '#fff4';
                    ctx.lineWidth = 2;
                    ctx.strokeRect(sx + 2, sy + 2, this.CELL - 4, this.CELL - 4);
                }

                // 宝石
                for (let r = 0; r < this.ROWS; r++) {
                    for (let c = 0; c < this.COLS; c++) {
                        const gem = this.board[r][c];
                        if (!gem) continue;
                        const x = this.BOARD_X + c * this.CELL + this.CELL / 2;
                        const y = this.BOARD_Y + r * this.CELL + this.CELL / 2;
                        this.drawGem(ctx, x, y, gem.type, this.CELL * 0.38);
                    }
                }

                // 粒子
                for (const p of this.particles) {
                    ctx.globalAlpha = p.life * 2;
                    ctx.fillStyle = p.color;
                    ctx.beginPath();
                    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
                    ctx.fill();
                }
                ctx.globalAlpha = 1;

                // HUD
                ctx.fillStyle = 'rgba(0,0,0,0.5)';
                ctx.fillRect(0, 0, W, 60);

                ctx.fillStyle = '#fff'; ctx.font = '16px monospace'; ctx.textAlign = 'left';
                ctx.fillText(`分数: ${this.score} / ${this.targetScore}`, 15, 28);
                ctx.fillText(`关卡: ${this.level}`, 280, 28);
                ctx.fillText(`步数: ${this.moves}`, 400, 28);

                if (this.combo > 1) {
                    ctx.fillStyle = '#f0a500';
                    ctx.fillText(`连击 x${this.combo}!`, 520, 28);
                }

                // 分数进度条
                const progress = Math.min(1, this.score / this.targetScore);
                ctx.fillStyle = '#333';
                ctx.fillRect(15, 40, 200, 8);
                ctx.fillStyle = '#16c79a';
                ctx.fillRect(15, 40, 200 * progress, 8);
            }

            drawGem(ctx, x, y, type, size) {
                const color = this.gemColors[type];
                const shape = this.gemShapes[type];
                ctx.fillStyle = color;
                ctx.strokeStyle = color + 'aa';
                ctx.lineWidth = 1.5;

                switch (shape) {
                    case 'circle':
                        ctx.beginPath();
                        ctx.arc(x, y, size, 0, Math.PI * 2);
                        ctx.fill(); ctx.stroke();
                        break;
                    case 'diamond':
                        ctx.beginPath();
                        ctx.moveTo(x, y - size);
                        ctx.lineTo(x + size, y);
                        ctx.lineTo(x, y + size);
                        ctx.lineTo(x - size, y);
                        ctx.closePath();
                        ctx.fill(); ctx.stroke();
                        break;
                    case 'square':
                        ctx.beginPath();
                        const s = size * 0.8;
                        ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4);
                        ctx.fillRect(-s, -s, s * 2, s * 2);
                        ctx.strokeRect(-s, -s, s * 2, s * 2);
                        ctx.restore();
                        break;
                    case 'triangle':
                        ctx.beginPath();
                        ctx.moveTo(x, y - size);
                        ctx.lineTo(x + size, y + size * 0.7);
                        ctx.lineTo(x - size, y + size * 0.7);
                        ctx.closePath();
                        ctx.fill(); ctx.stroke();
                        break;
                    case 'star':
                        ctx.beginPath();
                        for (let i = 0; i < 5; i++) {
                            const angle = (i * 72 - 90) * Math.PI / 180;
                            const innerAngle = ((i * 72) + 36 - 90) * Math.PI / 180;
                            ctx.lineTo(x + Math.cos(angle) * size, y + Math.sin(angle) * size);
                            ctx.lineTo(x + Math.cos(innerAngle) * size * 0.45, y + Math.sin(innerAngle) * size * 0.45);
                        }
                        ctx.closePath();
                        ctx.fill(); ctx.stroke();
                        break;
                    case 'hexagon':
                        ctx.beginPath();
                        for (let i = 0; i < 6; i++) {
                            const angle = (i * 60 - 30) * Math.PI / 180;
                            ctx.lineTo(x + Math.cos(angle) * size, y + Math.sin(angle) * size);
                        }
                        ctx.closePath();
                        ctx.fill(); ctx.stroke();
                        break;
                }

                // 高光
                ctx.fillStyle = 'rgba(255,255,255,0.25)';
                ctx.beginPath();
                ctx.arc(x - size * 0.2, y - size * 0.2, size * 0.35, 0, Math.PI * 2);
                ctx.fill();
            }

            renderMenu(ctx, W, H) {
                ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, W, H);
                ctx.fillStyle = '#f0a500'; ctx.font = 'bold 48px Arial'; ctx.textAlign = 'center';
                ctx.fillText('宝石消消乐', W / 2, 180);
                ctx.fillStyle = '#16c79a'; ctx.font = '20px Arial';
                ctx.fillText('三消益智游戏', W / 2, 220);
                ctx.fillStyle = '#fff'; ctx.font = '18px Arial';
                ctx.fillText('点击开始', W / 2, 350);
                ctx.fillStyle = '#888'; ctx.font = '13px monospace';
                ctx.fillText('拖拽交换相邻宝石 | 三个以上相同消除', W / 2, 430);
            }

            renderGameOver(ctx, W, H) {
                ctx.fillStyle = '#1a0a0a'; ctx.fillRect(0, 0, W, H);
                ctx.fillStyle = '#e94560'; ctx.font = 'bold 48px Arial'; ctx.textAlign = 'center';
                ctx.fillText('步数用完', W / 2, 200);
                ctx.fillStyle = '#fff'; ctx.font = '20px Arial';
                ctx.fillText(`最终分数: ${this.score}`, W / 2, 280);
                ctx.fillText(`到达关卡: ${this.level}`, W / 2, 320);
                ctx.fillStyle = '#888'; ctx.font = '16px Arial';
                ctx.fillText('点击返回', W / 2, 420);
            }

            renderWin(ctx, W, H) {
                ctx.fillStyle = '#0a1a0a'; ctx.fillRect(0, 0, W, H);
                ctx.fillStyle = '#16c79a'; ctx.font = 'bold 48px Arial'; ctx.textAlign = 'center';
                ctx.fillText('恭喜通关!', W / 2, 200);
                ctx.fillStyle = '#fff'; ctx.font = '20px Arial';
                ctx.fillText(`最终分数: ${this.score}`, W / 2, 280);
                ctx.fillStyle = '#888'; ctx.font = '16px Arial';
                ctx.fillText('点击返回', W / 2, 420);
            }

            start() {
                this.lastTime = performance.now();
                requestAnimationFrame((t) => this.loop(t));
            }

            loop(currentTime) {
                const dt = Math.min((currentTime - this.lastTime) / 1000, 0.1);
                this.lastTime = currentTime;
                this.update(dt);
                this.render();
                requestAnimationFrame((t) => this.loop(t));
            }
        }

        const game = new Match3Game();
        game.start();
    </script>
</body>
</html>

浏览器兼容性

特性 Chrome Firefox Safari Edge 移动端Chrome 移动端Safari
Canvas 2D 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Touch Events 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
async/await 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持
Promise 完全支持 完全支持 完全支持 完全支持 完全支持 完全支持

注意事项与最佳实践

  1. 初始棋盘无匹配:生成棋盘时确保不会出现初始匹配,避免玩家还没操作就开始消除。

  2. 交换验证:交换后如果没有产生匹配,应换回原位,避免无效操作消耗步数。

  3. 连锁处理:消除后下落填充可能产生新的匹配,需要循环处理直到无匹配为止。

  4. 动画时序:消除、下落、填充的动画应有明确的时序,避免视觉混乱。

  5. 无解检测:当棋盘上没有可行的交换时,应重新洗牌。

  6. 触摸适配:移动端使用触摸事件,支持滑动交换。

  7. 宝石辨识度:不同类型的宝石应使用不同颜色和形状,确保色盲玩家也能区分。

  8. 步数限制:步数限制增加策略性,但不应过于严格。

常见问题与解决方案

问题1:初始棋盘有匹配

解决方案:生成每个格子时检查是否会形成3连,如果会则重新随机。

问题2:棋盘无解

解决方案:检测是否存在可行交换,如果没有则重新洗牌。

问题3:连锁消除时动画混乱

解决方案:使用async/await确保动画按顺序执行,消除完成后再下落填充。

问题4:触摸操作不灵敏

解决方案:增加触摸区域,支持滑动方向判定而非精确位置匹配。

问题5:特殊消除(4连、5连)未处理

解决方案:4连生成条纹宝石,5连生成彩虹宝石,增加策略深度。

总结

本教程带你实现了一个完整的三消益智游戏。我们学习了三消游戏的核心设计,实现了匹配检测算法(水平和垂直扫描),实现了拖拽交换交互,处理了消除后的下落和填充逻辑,实现了连锁反应机制,添加了连击加分和关卡系统。

关键要点:

  • 初始棋盘生成时确保无匹配

  • 交换后验证是否产生匹配,无效则换回

  • 匹配检测需要水平和垂直双向扫描

  • 消除后需要处理下落和填充

  • 连锁反应需要循环处理直到无匹配

  • 动画时序需要严格管理

  • 步数限制增加策略性

  • 宝石应使用颜色+形状双重区分

掌握这些技术后,你将能够开发出规则精巧、交互流畅的益智游戏。

常见问题

什么是2D益智游戏?

2D益智游戏是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。

如何学习2D益智游戏的实际应用?

教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。

2D益智游戏有哪些注意事项?

常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。

2D益智游戏适合初学者吗?

本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。

2D益智游戏的核心要点是什么?

核心要点包括教程简介等内容,建议按教程顺序逐步学习。

标签: Promise 益智游戏 HTML5游戏 Canvas async await 异步函数 帧率 拼图

本文由小确幸生活整理发布,转载请注明出处

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: 游戏开发:2D射击游戏 - 从入门到实践详解 下一篇: 游戏开发:游戏性能优化 - 从入门到实践详解

poll相关推荐