用 200 行写一个像素风贪吃蛇:网格步进、输入缓冲与速度曲线
为什么用网格,而不是自由坐标
做贪吃蛇时,最容易走的弯路是把蛇当成自由移动的物体,给每一节一个浮点坐标和速度向量。真正写下去会发现,吃到食物、判断自撞、计算身体跟随都变得麻烦:浮点误差会让“正好对齐”变成不可靠的假设。更省心的做法是把整个世界放进一张整数网格里,蛇的每一节都只占一个格子。
这样一来,移动就是一次坐标平移,吃到食物就是判断头部坐标是否等于食物坐标,自撞就是检查头部是否落在身体占用的集合里。所有逻辑都建立在整数之上,没有误差,也不需要额外的容差判断。
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,单文件就能离线打开。
贪吃蛇看起来简单,但它把网格、时间步、输入缓冲和状态切换这些基础问题都串了一遍。把这些边界处理清楚,再去写更复杂的游戏,节奏和手感会更容易控制。
- 点赞
- 收藏
- 关注作者
评论(0)