贪吃蛇是"游戏循环"的最佳教材:每过固定时间,世界前进一格,蛇头移动、身体跟随、食物刷新。这背后是"定时器 + 状态更新 + 重绘"的铁三角,学会了它,你就掌握了所有实时游戏的骨架。

1. 游戏规则

蛇在 20×20 的网格上移动,吃到红色食物身体变长一节、得 10 分;撞到墙壁或撞到自己,游戏结束。方向由键盘控制,而且不能直接掉头——比如正在向右走,不能瞬间按左键,否则会一头撞上自己的身体。

听起来简单,但有两个细节是新手最容易翻车的:一是"身体跟随"到底怎么实现,二是"不能瞬间掉头"怎么约束。这两个问题正是本游戏的核心难点,后面的代码会逐一解决。

2. 界面搭建

用 Canvas 画游戏区域:一块 400×400 的画布,分成 20×20 个格子,每格 20 像素。旁边放上得分文字和一个开始按钮。

<div class="wrap">
  <h1>贪吃蛇</h1>
  <p id="score">得分:0</p>
  <canvas id="game" width="400" height="400"></canvas>
  <button id="start">开始游戏</button>
</div>

<canvas> 的 width 和 height 是画布的真实分辨率,和 CSS 里设置的大小是两回事——这里直接用属性定死 400×400,避免缩放模糊。后面所有绘制都通过 getContext('2d') 拿到的画笔对象完成,它就像一张白纸和一支笔。

3. 游戏循环与移动

游戏的核心是一个 setInterval 定时器,每 150 毫秒执行一次 tick。每次 tick 做三件事:更新蛇头位置、判断碰撞、重绘画布。先看移动和绘制的部分:

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const SIZE = 20;
const COLS = 20, ROWS = 20;

let snake = [{ x: 10, y: 10 }];
let dir = { x: 1, y: 0 };
let nextDir = { x: 1, y: 0 };
let food = { x: 15, y: 10 };
let score = 0;

function tick() {
  dir = nextDir;
  const head = {
    x: snake[0].x + dir.x,
    y: snake[0].y + dir.y
  };
  snake.unshift(head);
  if (head.x === food.x && head.y === food.y) {
    score += 10;
    document.getElementById('score').textContent = '得分:' + score;
    spawnFood();
  } else {
    snake.pop();
  }
  draw();
}

function draw() {
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#e94560';
  ctx.fillRect(food.x * SIZE, food.y * SIZE, SIZE, SIZE);
  ctx.fillStyle = '#4ecca3';
  snake.forEach(seg => {
    ctx.fillRect(seg.x * SIZE, seg.y * SIZE, SIZE - 1, SIZE - 1);
  });
}

身体跟随的秘诀是 unshift + pop:每帧把新蛇头插到数组最前面,如果没吃到食物,就把尾巴弹出去——整个身体"平移"一格,视觉效果就是蛇在前进;吃到食物则不弹尾巴,身体自然长一节。这个技巧叫队列(先进先出),是贪吃蛇的标准实现,面试时也常被问到。如果不用队列,而是每帧把整条蛇的每个格子都重新算一遍位置,代码会复杂十倍还容易出 bug——数据结构选对了,难题自动消失。

draw 里先清屏(铺底色),再画食物,最后遍历蛇身每个格子画方块。每格留 1 像素间隙,蛇身会有清晰的网格纹理,比贴在一起的方块好看得多。ctx.fillRect(x, y, w, h) 是 Canvas 最基础的画方块 API,坐标是像素,所以要乘上 SIZE。

4. 碰撞与结束

碰撞检测在移动之后立刻进行:新蛇头越界,或者新蛇头撞上自己身体,游戏结束。注意顺序:先算好新蛇头位置,判断合法,才真正 unshift 入队。

let running = false;
let timer = null;

function tick() {
  dir = nextDir;
  const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };

  if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
    return gameOver();
  }
  if (snake.some(seg => seg.x === head.x && seg.y === head.y)) {
    return gameOver();
  }

  snake.unshift(head);
  if (head.x === food.x && head.y === food.y) {
    score += 10;
    document.getElementById('score').textContent = '得分:' + score;
    spawnFood();
  } else {
    snake.pop();
  }
  draw();
}

function gameOver() {
  clearInterval(timer);
  running = false;
  document.getElementById('score').textContent = '游戏结束!得分:' + score;
}

function spawnFood() {
  while (true) {
    const f = {
      x: Math.floor(Math.random() * COLS),
      y: Math.floor(Math.random() * ROWS)
    };
    if (!snake.some(seg => seg.x === f.x && seg.y === f.y)) {
      food = f;
      return;
    }
  }
}

snake.some(...) 检查蛇身是否包含某个坐标。撞自己之所以不会误判,是因为新蛇头还没入队,数组里没有它。gameOver 里 clearInterval(timer) 停掉游戏循环,这是"结束"的标准动作——定时器不停,游戏就会在后台继续跑。spawnFood 用 while (true) 反复随机,直到食物不落在蛇身上;蛇越长,这个循环平均要跑的次数越多,但 20×20 的棋盘上几乎瞬间就能找到。

5. 键盘与触屏控制

方向控制的关键:不能直接改 dir,而是改 nextDir,下一帧开头才生效。这样即使玩家在一帧内快速按了两个键,也只会执行最后一个;同时配合"反向守卫",彻底杜绝向右走时按左键瞬间掉头的作弊操作。

document.addEventListener('keydown', e => {
  if (e.key === 'ArrowUp' && dir.y !== 1) nextDir = { x: 0, y: -1 };
  if (e.key === 'ArrowDown' && dir.y !== -1) nextDir = { x: 0, y: 1 };
  if (e.key === 'ArrowLeft' && dir.x !== 1) nextDir = { x: -1, y: 0 };
  if (e.key === 'ArrowRight' && dir.x !== -1) nextDir = { x: 1, y: 0 };
});

let touchStart = null;
canvas.addEventListener('touchstart', e => {
  touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
});
canvas.addEventListener('touchmove', e => {
  e.preventDefault();
  if (!touchStart) return;
  const dx = e.touches[0].clientX - touchStart.x;
  const dy = e.touches[0].clientY - touchStart.y;
  if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;
  if (Math.abs(dx) > Math.abs(dy)) {
    if (dx > 0 && dir.x !== -1) nextDir = { x: 1, y: 0 };
    if (dx < 0 && dir.x !== 1) nextDir = { x: -1, y: 0 };
  } else {
    if (dy > 0 && dir.y !== -1) nextDir = { x: 0, y: 1 };
    if (dy < 0 && dir.y !== 1) nextDir = { x: 0, y: -1 };
  }
  touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
});

每个方向都带一个反向守卫:比如当前向下(dir.y === 1)时,按上键会被 dir.y !== 1 挡住。触屏版用"滑动起止点"的位移判断方向,Math.abs(dx) > Math.abs(dy) 决定是横滑还是竖滑;位移小于 20 像素视为误触直接忽略;判断完成后重置 touchStart,让连续滑动也能被识别。

顺手再加个实用功能:空格键暂停/继续。原理很简单——定时器要么在跑,要么被清掉,用同一个键来回切换:

document.addEventListener('keydown', e => {
  if (e.key === ' ' && running) {
    e.preventDefault();
    if (timer) {
      clearInterval(timer);
      timer = null;
    } else {
      timer = setInterval(tick, 150);
    }
  }
});

暂停的本质就是"停掉定时器",继续就是"重新启动一个"。注意 e.preventDefault() 是为了防止空格键触发页面滚动——这个细节不处理,暂停键会附带"页面跳一下"的副作用。

6. 总结与练习

组装完整游戏只需把开始按钮接上定时器,并初始化所有状态:

document.getElementById('start').addEventListener('click', () => {
  if (running) return;
  snake = [{ x: 10, y: 10 }];
  dir = { x: 1, y: 0 };
  nextDir = { x: 1, y: 0 };
  score = 0;
  running = true;
  document.getElementById('score').textContent = '得分:0';
  spawnFood();
  timer = setInterval(tick, 150);
});

把 HTML、CSS(给页面配个深色背景即可)和这几段 JS 放进同一个文件,贪吃蛇就能跑了。运行逻辑的完整闭环是:定时器驱动 → 状态更新 → 碰撞检测 → 重绘,四个环节缺一不可。以后你玩任何实时游戏,都可以试着拆出这四层。

为什么用 setInterval 而不是 requestAnimationFrame?setInterval 简单直接,适合"固定节奏"的回合制移动;而 requestAnimationFrame 每帧都调用,适合追求流畅度的场景(比如粒子效果、物理模拟)。贪吃蛇这种"每秒走几格"的游戏,setInterval 完全够用,还省去计算时间差的麻烦——等做练习三时再升级也不迟。

总结要点:队列模拟身体(unshift/pop)、方向缓冲(nextDir 防掉头)、定时器游戏循环(setInterval)、Canvas 重绘(fillRect 画格子)。

练习一下:

💡 贪吃蛇最容易出现的 bug 是"蛇穿过了自己的身体":多半是 unshift 和碰撞判断的顺序写反了。记住口诀——先判断,后入队。