1. 项目准备:为什么贪吃蛇是前端入门的绝佳选择

很多朋友问我,想学前端开发,有没有一个项目能让我把HTML、CSS和JavaScript都串起来,而且做出来还挺有成就感?我每次都会毫不犹豫地推荐贪吃蛇。这可不是随便说说,我自己带过不少新人,发现贪吃蛇这个项目有几个特别实在的好处。首先,它的逻辑足够清晰,就是控制一条蛇去吃食物,避免撞墙和撞自己,这个目标谁都能理解。其次,它麻雀虽小五脏俱全,你需要处理用户输入(键盘事件)、管理游戏状态(蛇的位置、食物位置、分数)、实现游戏循环(让蛇动起来)、进行碰撞检测,最后还要用Canvas画出来。这一套流程走下来,你对前端开发的核心概念就有了一个非常直观的体验。

你可能看过很多教程,上来就讲各种框架和库,但我觉得,不打好基础,那些高级玩意儿就像空中楼阁。用最原生的HTML5、CSS3和ES6+ JavaScript来实现贪吃蛇,恰恰是夯实基础的最好方式。你不用操心复杂的构建工具,一个浏览器、一个代码编辑器就够了。今天,我就带你从零开始,手把手实现一个功能完整、代码清晰、甚至还有点小炫酷的贪吃蛇游戏。我会把每一步的原理、我踩过的坑、以及怎么写出更优雅的代码都讲清楚。咱们的目标不只是让游戏跑起来,而是要让你理解背后的“为什么”。

在开始敲代码之前,我们先来规划一下。一个完整的贪吃蛇游戏,至少需要这几个部分:一个用来绘制的画布(HTML5 Canvas)、一套控制游戏逻辑的JavaScript代码、以及让界面看起来舒服点的CSS样式。我们还会加入一些现代前端项目常见的元素,比如可调节的难度和网格大小,让这个经典游戏玩起来更有趣。别担心,哪怕你之前只写过“Hello World”,跟着我的步骤,也绝对能搞定。

2. 搭建游戏骨架:HTML结构与CSS样式设计

2.1 创建清晰易懂的HTML文档

任何网页的起点都是HTML,它定义了页面的骨架。我们的贪吃蛇游戏页面结构并不复杂,但设计得好,后续写JavaScript逻辑会轻松很多。我习惯先搭建一个干净的结构,把游戏区域、控制面板、分数显示这些模块清晰地分开。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>经典贪吃蛇 - HTML5实现</title>
    <link rel="stylesheet" href="css/style.css">
    <link rel="stylesheet" href="css/game.css">
</head>
<body>
    <header class="game-header">
        <h1>🐍 经典贪吃蛇</h1>
        <p class="subtitle">使用纯HTML5 Canvas与JavaScript打造</p>
    </header>

    <main class="game-main">
        <aside class="control-panel">
            <div class="panel-section">
                <h3>游戏设置</h3>
                <div class="form-group">
                    <label for="grid-mode">网格尺寸</label>
                    <select id="grid-mode">
                        <option value="20">20x20 (简单)</option>
                        <option value="25" selected>25x25 (标准)</option>
                        <option value="30">30x30 (挑战)</option>
                        <option value="35">35x35 (专家)</option>
                    </select>
                </div>
                <div class="form-group">
                    <label for="speed">游戏速度</label>
                    <select id="speed">
                        <option value="200">慢速 (200ms/步)</option>
                        <option value="150" selected>中速 (150ms/步)</option>
                        <option value="100">快速 (100ms/步)</option>
                        <option value="70">极速 (70ms/步)</option>
                        <option value="50">疯狂 (50ms/步)</option>
                    </select>
                </div>
                <div class="button-group">
                    <button id="start-btn" class="btn btn-primary">开始游戏</button>
                    <button id="pause-btn" class="btn btn-secondary" disabled>暂停</button>
                    <button id="reset-btn" class="btn btn-warning">重新开始</button>
                </div>
            </div>

            <div class="panel-section">
                <h3>游戏状态</h3>
                <div class="stats">
                    <div class="stat-item">
                        <span class="stat-label">当前分数:</span>
                        <span id="current-score" class="stat-value">0</span>
                    </div>
                    <div class="stat-item">
                        <span class="stat-label">历史最高:</span>
                        <span id="high-score" class="stat-value">0</span>
                    </div>
                    <div class="stat-item">
                        <span class="stat-label">蛇身长度:</span>
                        <span id="snake-length" class="stat-value">3</span>
                    </div>
                </div>
            </div>

            <div class="panel-section">
                <h3>操作指南</h3>
                <ul class="instruction-list">
                    <li><strong>方向控制:</strong> 键盘 <kbd>↑↓←→</kbd> 或 <kbd>WASD</kbd></li>
                    <li><strong>开始/暂停:</strong> 点击按钮或按 <kbd>空格键</kbd></li>
                    <li><strong>目标:</strong> 吃红色食物,避免撞墙或撞到自己</li>
                    <li>每吃一个食物得 <strong>10分</strong>,蛇身长度+1</li>
                </ul>
            </div>
        </aside>

        <section class="game-area">
            <div class="canvas-container">
                <canvas id="game-canvas" width="800" height="600">
                    抱歉,您的浏览器不支持HTML5 Canvas,请升级浏览器。
                </canvas>
                <div id="game-overlay" class="game-overlay hidden">
                    <h2 id="overlay-title">游戏结束</h2>
                    <p id="overlay-message">你的最终得分是: <span id="final-score">0</span></p>
                    <button id="restart-overlay-btn" class="btn btn-primary">再玩一次</button>
                </div>
            </div>
            <div class="hint">提示:游戏开始后,请先按方向键让蛇移动</div>
        </section>
    </main>

    <script src="js/game.js" type="module"></script>
</body>
</html>

我稍微解释一下这个结构的设计思路。我把整个页面分成了三个主要部分:<header>、<main>和<script>。<main>里面又用<aside>和<section>分成了控制面板和游戏区域。这种语义化的标签不仅对搜索引擎友好,也让代码结构一目了然。控制面板里,我把设置、状态、操作说明分成了三个区块,这样用户找起来方便。游戏区域的核心就是那个<canvas>画布,所有蛇和食物的绘制都在这里发生。我还加了一个半透明的覆盖层(game-overlay),用来显示游戏结束的信息,这样比用alert弹窗体验好太多了。注意看<script>标签的type="module",这允许我们使用ES6模块化的语法,让代码组织更现代。

2.2 用CSS3打造舒适的游戏界面

HTML搭好了骨架,接下来就得用CSS给它穿上衣服。我们的目标不是做得多花哨,而是要清晰、舒适、有适当的反馈。我会用Flexbox进行布局,用CSS变量管理颜色,再加一些简单的过渡动画提升交互感。

/* style.css - 基础样式 */
:root {
    --primary-color: #4CAF50;
    --primary-dark: #3e8e41;
    --secondary-color: #2196F3;
    --danger-color: #f44336;
    --warning-color: #ff9800;
    --dark-bg: #2c3e50;
    --light-bg: #ecf0f1;
    --text-dark: #333;
    --text-light: #fff;
    --shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    --border-radius: 8px;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background: linear-gradient(135deg, var(--light-bg) 0%, #dfe6e9 100%);
    color: var(--text-dark);
    line-height: 1.6;
    min-height: 100vh;
    padding: 20px;
}

.game-header {
    text-align: center;
    margin-bottom: 30px;
    padding-bottom: 20px;
    border-bottom: 2px solid var(--primary-color);
}

.game-header h1 {
    color: var(--dark-bg);
    font-size: 2.8rem;
    margin-bottom: 10px;
}

.subtitle {
    color: #7f8c8d;
    font-size: 1.1rem;
}

/* 主布局使用Flexbox */
.game-main {
    display: flex;
    flex-wrap: wrap;
    gap: 30px;
    max-width: 1400px;
    margin: 0 auto;
}

.control-panel {
    flex: 1;
    min-width: 300px;
    background: white;
    border-radius: var(--border-radius);
    padding: 25px;
    box-shadow: var(--shadow);
}

.game-area {
    flex: 2;
    min-width: 500px;
}

.panel-section {
    margin-bottom: 30px;
}

.panel-section h3 {
    color: var(--dark-bg);
    margin-bottom: 15px;
    padding-bottom: 8px;
    border-bottom: 1px solid #eee;
}

上面是基础样式,定义了颜色变量和整体布局。接下来是表单控件和按钮的样式,这些细节直接影响用户的操作手感。

/* game.css - 游戏组件样式 */
.form-group {
    margin-bottom: 20px;
}

.form-group label {
    display: block;
    margin-bottom: 8px;
    font-weight: 600;
    color: #555;
}

.form-group select {
    width: 100%;
    padding: 12px 15px;
    border: 2px solid #ddd;
    border-radius: var(--border-radius);
    font-size: 1rem;
    background-color: white;
    cursor: pointer;
    transition: border-color 0.3s ease;
}

.form-group select:focus {
    outline: none;
    border-color: var(--primary-color);
}

.button-group {
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
}

.btn {
    padding: 14px 24px;
    border: none;
    border-radius: var(--border-radius);
    font-size: 1rem;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.25s ease;
    flex: 1;
    min-width: 120px;
}

.btn-primary {
    background-color: var(--primary-color);
    color: white;
}

.btn-primary:hover:not(:disabled) {
    background-color: var(--primary-dark);
    transform: translateY(-2px);
}

.btn-secondary {
    background-color: #6c757d;
    color: white;
}

.btn-warning {
    background-color: var(--warning-color);
    color: white;
}

.btn:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    transform: none !important;
}

/* 游戏状态统计 */
.stats {
    background-color: #f8f9fa;
    padding: 20px;
    border-radius: var(--border-radius);
}

.stat-item {
    display: flex;
    justify-content: space-between;
    margin-bottom: 12px;
    padding-bottom: 12px;
    border-bottom: 1px dashed #dee2e6;
}

.stat-item:last-child {
    margin-bottom: 0;
    border-bottom: none;
}

.stat-label {
    color: #666;
}

.stat-value {
    font-weight: bold;
    font-size: 1.2rem;
    color: var(--primary-color);
}

/* 画布容器 */
.canvas-container {
    position: relative;
    background: white;
    border-radius: var(--border-radius);
    overflow: hidden;
    box-shadow: var(--shadow);
}

#game-canvas {
    display: block;
    width: 100%;
    background-color: #fdfdfd;
    border: 1px solid #eee;
}

/* 游戏结束覆盖层 */
.game-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.85);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    color: white;
    z-index: 10;
    border-radius: var(--border-radius);
}

.game-overlay.hidden {
    display: none;
}

#overlay-title {
    font-size: 3.5rem;
    margin-bottom: 20px;
    color: #ff6b6b;
}

#overlay-message {
    font-size: 1.5rem;
    margin-bottom: 30px;
}

/* 响应式设计 */
@media (max-width: 1100px) {
    .game-main {
        flex-direction: column;
    }
    .control-panel, .game-area {
        min-width: 100%;
    }
}

@media (max-width: 600px) {
    .button-group {
        flex-direction: column;
    }
    .btn {
        width: 100%;
    }
}

写CSS的时候,我特别注重两点:一是响应式,确保在手机和平板上也能有不错的体验;二是状态反馈,比如按钮的悬停效果、选择框的聚焦状态、禁用按钮的样式,这些微交互能让用户感觉游戏很“跟手”。颜色上我选择了比较清新、对比度足够的配色,长时间玩眼睛不会太累。box-shadow和border-radius的运用让整个界面脱离了生硬的方块感,显得更现代。

3. 游戏核心逻辑:用JavaScript让蛇动起来

3.1 初始化游戏状态与Canvas绘图上下文

前端界面准备好了,现在进入最核心的部分——JavaScript游戏逻辑。我会采用模块化的方式来组织代码,这样结构更清晰,也方便以后维护或添加新功能。首先,我们得获取Canvas元素并设置好绘图上下文,这是所有绘制操作的基础。

// game.js
// 使用模块化语法,避免全局变量污染
class SnakeGame {
    constructor() {
        // 获取DOM元素
        this.canvas = document.getElementById('game-canvas');
        this.ctx = this.canvas.getContext('2d');
        this.startBtn = document.getElementById('start-btn');
        this.pauseBtn = document.getElementById('pause-btn');
        // ... 获取其他按钮和显示元素

        // 游戏核心状态变量
        this.gridSize = 25; // 每个网格的像素大小,会根据选择变化
        this.snake = [];    // 蛇身数组,每个元素是{x, y}坐标
        this.food = { x: 0, y: 0 }; // 食物位置
        this.direction = { dx: 1, dy: 0 }; // 当前移动方向 (1,0)表示向右
        this.nextDirection = { dx: 1, dy: 0 }; // 下一帧的方向,用于缓冲输入
        this.score = 0;
        this.highScore = localStorage.getItem('snakeHighScore') || 0; // 从本地存储读取最高分
        this.gameSpeed = 150; // 游戏循环间隔(毫秒)
        this.isRunning = false;
        this.isPaused = false;
        this.gameLoopId = null; // 用于存储setInterval的ID,方便清除

        // 初始化
        this.initCanvasSize();
        this.bindEvents();
        this.resetGame();
        this.drawStatic(); // 绘制初始静态画面
        this.updateStatsDisplay();
    }

    initCanvasSize() {
        // 让Canvas的显示尺寸适应容器,但保持固定的逻辑分辨率
        const container = this.canvas.parentElement;
        this.canvas.style.width = '100%';
        this.canvas.style.height = 'auto';
        // 我们内部仍然使用固定的逻辑像素进行绘制,确保游戏逻辑稳定
        // 如果需要响应式逻辑网格,可以在这里计算,但为了简单我们先固定
    }

    bindEvents() {
        // 键盘事件监听
        document.addEventListener('keydown', this.handleKeyDown.bind(this));
        // 按钮事件监听
        this.startBtn.addEventListener('click', () => this.startGame());
        this.pauseBtn.addEventListener('click', () => this.togglePause());
        // ... 绑定其他按钮
        // 模式选择变化事件
        document.getElementById('grid-mode').addEventListener('change', (e) => {
            this.gridSize = parseInt(e.target.value);
            this.resetGame();
        });
        document.getElementById('speed').addEventListener('change', (e) => {
            this.gameSpeed = parseInt(e.target.value);
            if (this.isRunning && !this.isPaused) {
                this.restartGameLoop();
            }
        });
    }
}

在构造函数里,我做了几件关键的事。一是把所有需要用到的DOM元素和游戏状态变量都集中定义在这里,一目了然。二是引入了nextDirection这个缓冲变量,这是实现平滑控制的关键技巧。如果玩家快速连续按下多个方向键,直接修改direction可能会导致蛇在下一帧直接反向移动(这是规则不允许的)。用nextDirection先存起来,在每帧更新时再安全地应用到direction上。三是从localStorage读取历史最高分,让玩家的成就有延续性。事件绑定也集中在这里处理,代码不会散落得到处都是。

3.2 实现游戏主循环与状态更新

游戏的核心是一个不断重复的循环:更新状态 -> 绘制画面。在浏览器里,我们可以用setInterval或者requestAnimationFrame来实现。对于贪吃蛇这种对精确计时要求不是极端高的游戏,setInterval更简单直接。但要注意,每次循环都要先清空画布,再重绘所有元素。

    // 游戏主循环
    gameLoop() {
        if (!this.isRunning || this.isPaused) return;

        // 1. 应用缓冲的方向输入
        this.applyNextDirection();

        // 2. 更新游戏状态(移动蛇,检查碰撞,吃食物)
        this.update();

        // 3. 绘制当前帧
        this.draw();

        // 4. 设置下一次循环
        this.gameLoopId = setTimeout(() => this.gameLoop(), this.gameSpeed);
    }

    applyNextDirection() {
        // 防止180度直接调头:只有新方向不是当前方向的反方向时,才更新
        const isOpposite = (this.nextDirection.dx === -this.direction.dx && 
                            this.nextDirection.dy === -this.direction.dy);
        if (!isOpposite) {
            this.direction = { ...this.nextDirection };
        }
    }

    update() {
        // 根据当前方向计算新的蛇头位置
        const head = this.snake[0];
        const newHead = {
            x: head.x + this.direction.dx,
            y: head.y + this.direction.dy
        };

        // 碰撞检测:撞墙或撞自己
        if (this.isCollision(newHead)) {
            this.gameOver();
            return;
        }

        // 将新头部加入蛇身
        this.snake.unshift(newHead);

        // 检查是否吃到食物
        if (newHead.x === this.food.x && newHead.y === this.food.y) {
            this.score += 10;
            this.generateFood();
            this.updateStatsDisplay();
            // 吃到食物,蛇身变长,所以不用pop掉尾部
        } else {
            // 没吃到食物,移动一步,移除尾部
            this.snake.pop();
        }
    }

    isCollision(head) {
        // 1. 撞墙检测:判断是否超出画布逻辑边界
        const gridCountX = Math.floor(this.canvas.width / this.gridSize);
        const gridCountY = Math.floor(this.canvas.height / this.gridSize);
        if (head.x < 0 || head.x >= gridCountX || head.y < 0 || head.y >= gridCountY) {
            return true;
        }

        // 2. 撞自己检测:遍历蛇身(从第二段开始,因为第一段是新头)
        for (let i = 1; i < this.snake.length; i++) {
            if (head.x === this.snake[i].x && head.y === this.snake[i].y) {
                return true;
            }
        }
        return false;
    }

update函数是游戏逻辑的心脏。它严格按照“计算新位置 -> 检测碰撞 -> 更新蛇身 -> 检测食物”的顺序执行。这里有个细节,碰撞检测我放到了蛇头移动之后、加入蛇身数组之前。为什么?因为我们需要用即将成为新头部的那个位置去检测,而不是用旧的头部。检测分两部分:墙壁和自身。墙壁检测需要根据画布尺寸和网格大小动态计算边界,这样即使我们改变网格密度,逻辑也是正确的。自身检测时,我跳过了数组的第一项(当前头部),因为在新头部加入前,它还不算“自己”的一部分。这种边界条件处理是游戏编程里最容易出错的地方,一定要想清楚。

3.3 绘制游戏画面与处理用户输入

画面绘制要兼顾清晰度和美观。蛇头、蛇身、食物用不同的颜色区分。我还会画上淡淡的网格线,方便玩家判断距离。绘制代码虽然不复杂,但组织得好能让逻辑更清晰。

    draw() {
        // 清空整个画布
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 绘制背景网格(辅助线)
        this.drawGrid();
        // 绘制食物
        this.drawFood();
        // 绘制蛇
        this.drawSnake();
        // 绘制分数(可选,也可以放在DOM里)
        this.drawScoreOnCanvas();
    }

    drawGrid() {
        this.ctx.strokeStyle = '#f0f0f0';
        this.ctx.lineWidth = 0.5;
        const width = this.canvas.width;
        const height = this.canvas.height;

        // 画竖线
        for (let x = 0; x <= width; x += this.gridSize) {
            this.ctx.beginPath();
            this.ctx.moveTo(x, 0);
            this.ctx.lineTo(x, height);
            this.ctx.stroke();
        }
        // 画横线
        for (let y = 0; y <= height; y += this.gridSize) {
            this.ctx.beginPath();
            this.ctx.moveTo(0, y);
            this.ctx.lineTo(width, y);
            this.ctx.stroke();
        }
    }

    drawFood() {
        this.ctx.fillStyle = '#ff4757'; // 鲜艳的红色
        this.ctx.beginPath();
        // 画一个圆角矩形,看起来比纯方块更柔和
        const x = this.food.x * this.gridSize;
        const y = this.food.y * this.gridSize;
        const radius = 4;
        this.ctx.roundRect(x + 2, y + 2, this.gridSize - 4, this.gridSize - 4, radius);
        this.ctx.fill();

        // 加一点内阴影效果(通过一个更深的色块偏移实现)
        this.ctx.fillStyle = '#ff3742';
        this.ctx.beginPath();
        this.ctx.roundRect(x + 3, y + 3, this.gridSize - 6, this.gridSize - 6, radius - 1);
        this.ctx.fill();
    }

    drawSnake() {
        // 绘制蛇身
        for (let i = 0; i < this.snake.length; i++) {
            const segment = this.snake[i];
            const x = segment.x * this.gridSize;
            const y = segment.y * this.gridSize;

            // 蛇头用不同颜色和样式
            if (i === 0) {
                this.ctx.fillStyle = '#2ed573'; // 鲜艳的绿色
                // 给蛇头画两个小眼睛
                this.ctx.fillRect(x + 2, y + 2, this.gridSize - 4, this.gridSize - 4);
                this.ctx.fillStyle = 'white';
                const eyeSize = 3;
                const offset = 5;
                // 根据方向决定眼睛位置(简单版:总是画在头部前方两侧)
                this.ctx.fillRect(x + offset, y + offset, eyeSize, eyeSize);
                this.ctx.fillRect(x + this.gridSize - offset - eyeSize, y + offset, eyeSize, eyeSize);
            } else {
                // 蛇身用渐变色,越靠近尾部颜色越浅
                const colorIntensity = 200 - Math.min(150, i * 3);
                this.ctx.fillStyle = `rgb(46, 213, 115, ${0.7 + i * 0.02})`;
                this.ctx.fillRect(x + 1, y + 1, this.gridSize - 2, this.gridSize - 2);
            }
        }
    }

绘制函数我尽量让它有层次感。网格线最底层,食物在中间层,蛇在最上层。给食物和蛇身加了内边距和圆角,这样它们就不会紧贴网格线,视觉上更舒服。蛇头我特意加了两个白点当眼睛,虽然简单,但立刻让蛇有了“脸”,生动了不少。蛇身用了轻微的透明度渐变,让整条蛇有了一点立体感。这些视觉效果代码量不大,但对游戏质感的提升非常明显。

用户输入处理,特别是键盘事件,需要小心。我们要防止默认行为(比如按箭头键滚动页面),还要处理按键缓冲。

    handleKeyDown(event) {
        // 防止方向键滚动页面
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'w', 'a', 's', 'd'].includes(event.key)) {
            event.preventDefault();
        }

        const key = event.key.toLowerCase();
        let newDx = this.nextDirection.dx;
        let newDy = this.nextDirection.dy;

        switch (key) {
            case 'arrowup':
            case 'w':
                if (this.direction.dy !== 1) { // 不能从向下直接变为向上
                    newDx = 0;
                    newDy = -1;
                }
                break;
            case 'arrowdown':
            case 's':
                if (this.direction.dy !== -1) { // 不能从向上直接变为向下
                    newDx = 0;
                    newDy = 1;
                }
                break;
            case 'arrowleft':
            case 'a':
                if (this.direction.dx !== 1) { // 不能从向右直接变为向左
                    newDx = -1;
                    newDy = 0;
                }
                break;
            case 'arrowright':
            case 'd':
                if (this.direction.dx !== -1) { // 不能从向左直接变为向右
                    newDx = 1;
                    newDy = 0;
                }
                break;
            case ' ':
                // 空格键切换暂停/继续
                this.togglePause();
                break;
        }

        // 更新缓冲方向
        this.nextDirection = { dx: newDx, dy: newDy };
    }

注意,我在switch语句里的每个条件都检查了当前方向(this.direction),而不是缓冲方向(this.nextDirection)。这是为了防止玩家在一帧内快速按下相反方向键导致蛇自杀。例如,蛇正在向右移动(dx=1),玩家快速按了左键然后右键。如果检查的是缓冲方向,可能第一帧缓冲了向左,第二帧缓冲了向右,由于向左和当前向右是反向,被拒绝;向右和缓冲的向左是反向,也被拒绝,最终蛇可能没反应。而检查当前方向则逻辑更稳定:只要新方向不是当前方向的反向,就可以缓冲。实际的方向切换在applyNextDirection里进行最终的安全确认。

4. 高级功能与优化:让游戏更专业

4.1 动态难度调节与食物生成算法

一个可调节的游戏能适应更多玩家。我们已经在UI里提供了网格大小和速度的选择。当网格变密(比如35x35),游戏区域格子更多,蛇需要走更长的路才能吃到食物,但转向空间也更灵活,这是策略性的变化。当速度变快,对玩家的反应时间要求就更高。在代码里,我们需要根据这些选择重新初始化游戏参数。

    resetGame() {
        this.snake = [
            { x: 5, y: 10 },
            { x: 4, y: 10 },
            { x: 3, y: 10 }
        ]; // 初始蛇身长度3,水平排列
        this.direction = { dx: 1, dy: 0 };
        this.nextDirection = { dx: 1, dy: 0 };
        this.score = 0;
        this.isRunning = false;
        this.isPaused = false;
        this.generateFood();
        this.updateStatsDisplay();
        this.hideOverlay();
        this.drawStatic();
    }

    generateFood() {
        const gridCountX = Math.floor(this.canvas.width / this.gridSize);
        const gridCountY = Math.floor(this.canvas.height / this.gridSize);
        let newFood;
        let attempts = 0;
        const maxAttempts = gridCountX * gridCountY * 2; // 防止无限循环的安全措施

        do {
            newFood = {
                x: Math.floor(Math.random() * gridCountX),
                y: Math.floor(Math.random() * gridCountY)
            };
            attempts++;
        } while (this.isFoodOnSnake(newFood) && attempts < maxAttempts);

        // 如果尝试多次还是找不到空位(比如蛇几乎占满了屏幕),就找一个蛇尾附近的位置
        if (attempts >= maxAttempts) {
            // 这是一个备用策略:从蛇尾开始向四周寻找空位
            const tail = this.snake[this.snake.length - 1];
            for (let dy = -1; dy <= 1; dy++) {
                for (let dx = -1; dx <= 1; dx++) {
                    const candidate = { x: tail.x + dx, y: tail.y + dy };
                    if (!this.isFoodOnSnake(candidate) && 
                        candidate.x >= 0 && candidate.x < gridCountX &&
                        candidate.y >= 0 && candidate.y < gridCountY) {
                        this.food = candidate;
                        return;
                    }
                }
            }
            // 如果还是找不到,游戏实际上无法继续了,可以提前结束
            console.warn('无法生成食物,蛇可能已占满空间');
        }

        this.food = newFood;
    }

    isFoodOnSnake(foodPos) {
        return this.snake.some(segment => segment.x === foodPos.x && segment.y === foodPos.y);
    }

generateFood函数有个细节值得注意。在最坏情况下,蛇的身体可能几乎填满整个屏幕,随机生成食物很容易重复撞到蛇身。简单的递归调用(如果位置无效就重新生成)在极端情况下可能导致栈溢出或性能问题。所以我用了do...while循环加最大尝试次数限制。如果尝试了足够多的次数(比如格子总数的两倍)还找不到,就启动备用策略:在蛇尾附近寻找空位。这保证了游戏在极端情况下也能继续,提升了鲁棒性。

4.2 游戏状态管理与用户体验优化

游戏不仅仅是逻辑正确,体验流畅同样重要。这包括平滑的开始/暂停/重启流程,清晰的状态反馈,以及数据的持久化。

    startGame() {
        if (this.isRunning && !this.isPaused) return;

        if (!this.isRunning) {
            this.resetGame();
            this.isRunning = true;
            this.startBtn.disabled = true;
            this.pauseBtn.disabled = false;
            this.startBtn.textContent = '游戏中...';
        }

        if (this.isPaused) {
            this.isPaused = false;
            this.pauseBtn.textContent = '暂停';
            this.hideMessage();
        }

        // 清除可能存在的旧循环,启动新循环
        if (this.gameLoopId) {
            clearTimeout(this.gameLoopId);
        }
        this.gameLoop();
    }

    togglePause() {
        if (!this.isRunning) return;

        this.isPaused = !this.isPaused;
        if (this.isPaused) {
            this.pauseBtn.textContent = '继续';
            this.showMessage('游戏已暂停', '按空格键或点击“继续”按钮恢复');
            // 暂停时停止循环
            if (this.gameLoopId) {
                clearTimeout(this.gameLoopId);
                this.gameLoopId = null;
            }
        } else {
            this.pauseBtn.textContent = '暂停';
            this.hideMessage();
            this.gameLoop(); // 重新启动循环
        }
    }

    gameOver() {
        this.isRunning = false;
        this.isPaused = false;
        this.startBtn.disabled = false;
        this.pauseBtn.disabled = true;
        this.startBtn.textContent = '开始游戏';
        this.pauseBtn.textContent = '暂停';

        // 更新最高分
        if (this.score > this.highScore) {
            this.highScore = this.score;
            localStorage.setItem('snakeHighScore', this.highScore);
            this.showOverlay('新纪录!', `恭喜!你创造了新的最高分:${this.score}`);
        } else {
            this.showOverlay('游戏结束', `你的得分是:${this.score}`);
        }

        this.updateStatsDisplay();

        // 清除游戏循环
        if (this.gameLoopId) {
            clearTimeout(this.gameLoopId);
            this.gameLoopId = null;
        }
    }

    showOverlay(title, message) {
        document.getElementById('overlay-title').textContent = title;
        document.getElementById('overlay-message').textContent = message;
        document.getElementById('final-score').textContent = this.score;
        document.getElementById('game-overlay').classList.remove('hidden');
    }

    updateStatsDisplay() {
        document.getElementById('current-score').textContent = this.score;
        document.getElementById('high-score').textContent = this.highScore;
        document.getElementById('snake-length').textContent = this.snake.length;
    }

状态管理的关键是保证isRunning和isPaused这两个标志位在各种操作下都处于正确的组合。我画过一个简单的状态转换图:初始 -> 运行 -> 暂停 -> 运行 -> 结束 -> 初始。每个按钮点击和键盘事件都要推动状态正确转换,并更新UI按钮的文本和禁用状态。最高分保存到localStorage是个小功能,但能让玩家有动力反复挑战。游戏结束时的覆盖层我设计了两种文案,打破新纪录时给予特别鼓励,这种细节很能提升满足感。

4.3 性能考量与代码组织建议

虽然贪吃蛇不算是性能敏感的应用,但养成良好的习惯对以后做更复杂的项目有帮助。首先,Canvas绘制操作是相对昂贵的,我们应该尽量减少每一帧的绘制区域(虽然我们这个游戏简单,全量重绘也没问题)。更重要的可能是代码的组织和维护性。

我强烈建议将游戏逻辑封装在一个类里,就像上面做的。这有几个好处:一是所有状态和方法都集中在同一个作用域下,不会污染全局命名空间;二是实例化简单,const game = new SnakeGame();就启动了所有功能;三是如果需要同时运行多个游戏实例(虽然贪吃蛇一般不需要),也非常容易。另外,我将绘制、更新、输入处理等职责分离到不同的方法中,每个方法只做一件事。这样当你想修改绘制样式时,就去draw相关的方法里改;想调整游戏规则(比如吃一个食物加20分),就去update里改,互不干扰。

对于更进阶的优化,可以考虑使用requestAnimationFrame代替setInterval。requestAnimationFrame会与浏览器的重绘同步,能提供更平滑的动画,尤其是在高刷新率显示器上。但用它实现固定时间步长的游戏循环需要一点技巧,你需要计算两帧之间的时间差(delta time),然后根据预设的“每步时间”来决定是否该更新游戏逻辑。这对于贪吃蛇来说有点杀鸡用牛刀,但如果你想挑战自己,这是一个很好的练习。

最后,记得在游戏结束时清理定时器(clearTimeout),这是一个常见的内存泄漏点。我们的gameOver和togglePause方法里都做了这个清理工作。

当你完成了所有这些代码,一个功能完整、体验流畅、代码清晰的贪吃蛇游戏就诞生了。你可以尝试修改颜色主题,增加新的食物类型(比如吃了会加速的蓝色食物),或者加入障碍物。这个项目最大的价值在于,它为你提供了一个坚实的前端基础,理解了状态管理、事件处理、Canvas绘图和游戏循环这些核心概念。下次当你看到更复杂的网页游戏时,你会自信地知道,它们也是由这些基本的构建块组合而成的。

更多推荐