用 200 行写一个像素风贪吃蛇:网格步进、输入缓冲与速度曲线

举报
yd_295628683 发表于 2026/10/20 07:48:28 2026/10/20
【摘要】 用一个纯前端像素贪吃蛇项目,讲清网格坐标、固定时间步、输入缓冲、自撞判断、食物生成与难度曲线,给不想引入框架的开发者一套可直接复用的组织方式。

为什么用网格,而不是自由坐标

做贪吃蛇时,最容易走的弯路是把蛇当成自由移动的物体,给每一节一个浮点坐标和速度向量。真正写下去会发现,吃到食物、判断自撞、计算身体跟随都变得麻烦:浮点误差会让“正好对齐”变成不可靠的假设。更省心的做法是把整个世界放进一张整数网格里,蛇的每一节都只占一个格子。

这样一来,移动就是一次坐标平移,吃到食物就是判断头部坐标是否等于食物坐标,自撞就是检查头部是否落在身体占用的集合里。所有逻辑都建立在整数之上,没有误差,也不需要额外的容差判断。

const COLS = 20;
const ROWS = 20;
const CELL = 24; // 每格像素

function nextHead(head, dir) {
  return { x: head.x + dir.x, y: head.y + dir.y };
}

固定节奏:让每一步都可预期

贪吃蛇的节奏感来自“每隔固定时间前进一格”。如果直接用 requestAnimationFrame 的帧间隔去累加移动,速度会随刷新率变化,手感时快时慢。更稳妥的方式是维护一个累加器,用固定的步进间隔推进逻辑,渲染仍然跟随屏幕刷新。

let acc = 0;
let last = performance.now();
let stepMs = 140; // 每前进一格的间隔,越小越快

function frame(now) {
  const dt = Math.min(now - last, 100);
  last = now;
  acc += dt;

  while (acc >= stepMs) {
    tick();
    acc -= stepMs;
  }

  draw();
  requestAnimationFrame(frame);
}

把 dt 截断到 100 毫秒是个小保护:切到后台再回来时,两帧之间可能隔了几秒,不加限制就会一次性补跑很多步,蛇会瞬移到墙上。

输入缓冲,别让快速转身丢操作

玩家在一格还没走完时连续按两个方向键是很常见的。如果每次都直接覆盖当前方向,可能在一次 tick 内先转一次又转回来,最终什么也没发生。我准备了一个长度很小、只保留最近一次有效输入的队列。

const queue = [];

function pushDir(dir) {
  const lastDir = queue.length ? queue[queue.length - 1] : currentDir;
  // 与当前方向相反或相同就忽略
  if (dir.x === -lastDir.x && dir.y === -lastDir.y) return;
  if (dir.x === lastDir.x && dir.y === lastDir.y) return;
  if (queue.length < 2) queue.push(dir);
}

function tick() {
  if (queue.length) currentDir = queue.shift();
  move(currentDir);
}

把它限制在两步以内,既能吸收一次快速连按,又不会因为排太多步而让蛇“自作主张”地连续转弯,手感更跟手。

自撞判断与“禁止直接掉头”

蛇的身体用数组保存,数组第一项是头。每次前进时,先在头部插入新坐标,再看是否吃到食物:吃到就保留尾巴,否则删掉尾巴。自撞判断要在插入之前完成,并且要把“即将离开的尾巴”排除掉,否则长度大于 1 的蛇会误判。

function tick() {
  const head = snakes[0];
  const nh = nextHead(head, currentDir);

  if (nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS) {
    return gameOver();
  }

  const willGrow = nh.x === food.x && nh.y === food.y;
  const body = willGrow ? snakes : snakes.slice(0, -1);
  if (body.some((s) => s.x === nh.x && s.y === nh.y)) {
    return gameOver();
  }

  snakes.unshift(nh);
  if (willGrow) {
    score += 10;
    placeFood();
  } else {
    snakes.pop();
  }
}

另外要禁止直接掉头:当前向右时按左键应当被忽略,否则头会立刻撞进自己的第二节。这个判断放在输入缓冲那一步处理,逻辑更集中。

食物、计分与难度曲线

食物随机落在空格上,不能生成在蛇身上。随机位置用拒绝采样最简单:随机一个格子,如果是空的就用,否则重试。

function placeFood() {
  const occupied = new Set(snakes.map((s) => s.x + ',' + s.y));
  let p;
  do {
    p = {
      x: (Math.random() * COLS) | 0,
      y: (Math.random() * ROWS) | 0,
    };
  } while (occupied.has(p.x + ',' + p.y));
  food = p;
}

难度曲线不用太陡。我的做法是每吃到一个食物把步进间隔减少一点点,但设一个下限,避免后期快到无法反应。

stepMs = Math.max(70, stepMs - 3);

像素风渲染与屏幕适配

像素感主要靠三件事:整数倍格子、干净的方块、克制的配色。绘制时用 fillRect 直接铺格子,不做圆角也不做渐变。为了在高分屏上不发虚,把画布按 devicePixelRatio 放大,再用 CSS 控制显示尺寸。

const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = COLS * CELL * dpr;
canvas.height = ROWS * CELL * dpr;
canvas.style.width = COLS * CELL + 'px';
canvas.style.height = ROWS * CELL + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

蛇头画一个小方块表示眼睛,身体用另一档颜色,食物用高对比色。整个画面只有几种颜色,反而更容易有复古游戏机的味道。

发布前值得检查的几件小事

  • 同时支持方向键和 WASD,移动端提供方向按钮或滑动。
  • 监听 visibilitychange,页面隐藏时暂停并在恢复时重置时间基准。
  • 最高分用 localStorage 保存,刷新后不丢。
  • 游戏结束后提供一键重开,不要强制刷新页面。
  • 不依赖外部 CDN,单文件就能离线打开。

贪吃蛇看起来简单,但它把网格、时间步、输入缓冲和状态切换这些基础问题都串了一遍。把这些边界处理清楚,再去写更复杂的游戏,节奏和手感会更容易控制。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。