Hexo 博客实战:小鸟闯关游戏开发指南

前言

光影拼图之后,本文将介绍博客系列第六款小游戏——Flappy Byte(小鸟闯关) 的开发。这是一款向经典 Flappy Bird 致敬的像素风作品,也是系列中第一款实时物理模拟游戏。

相比之前几款游戏,小鸟闯关在技术实现上有本质区别:

游戏 核心机制 技术难点 交互方式
五子棋 AI 博弈 Minimax 搜索树 点击落子
贪吃蛇 方向控制 蛇身移动逻辑 键盘方向键
俄罗斯方块 方块堆叠 碰撞消除 键盘操作
扫雷 逻辑推理 递归展开算法 点击标记
光影拼图 图片切割+拖拽 吸附算法+响应式 拖拽碎片
小鸟闯关 物理模拟+避障 实时碰撞+像素渲染 点击/触屏/键盘

Flappy Byte 的核心挑战:

  1. 物理模拟:重力加速度、跳跃反弹、实时碰撞检测
  2. 像素渲染:纯 Canvas 像素艺术绘制,无需外部图片资源
  3. 难度曲线:随分数动态调整管道间距和速度
  4. 交互优化:结束面板 3 秒延时防止误触
  5. 性能管理:单文件 requestAnimationFrame 游戏循环

项目结构

相比拼图游戏的模块化架构,小鸟闯关采用单文件紧凑设计,所有逻辑集中在 flappy-game.js 一个文件中:

1
2
3
4
5
6
source/ai-games/flappy/
├── index.md # 游戏页面入口(HTML + CSS)
└── flappy-game.js # 核心逻辑(物理、渲染、事件、状态管理)

source/ai-games/gamejs/
└── game-manager.js # 游戏生命周期管理器(所有游戏复用)

这种设计选择是因为 Flappy Bird 类游戏的逻辑天然紧凑——物理、渲染、事件三者紧密耦合,拆分成多个模块反而增加复杂度。

游戏状态机

游戏采用简洁的三态状态机管理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────┐
│ resetGame() │
│ 初始状态 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ ready(就绪状态) │
│ 显示标题 + 提示文字,等待用户点击开始 │
│ └── handleInput() → 切换到 playing │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ playing(运行状态) │
│ 物理模拟 → 碰撞检测 → 计分 → 难度调整 │
│ └── checkCollision() → 切换到 gameover │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ gameover(结束状态) │
│ 显示成绩面板 → 3 秒冷却 → 用户确认 → resetGame() │
└─────────────────────────────────────────────────────────────┘

状态切换的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function handleInput() {
if (gameState === 'ready') {
gameState = 'playing';
if (readyOverlay) readyOverlay.style.display = 'none';
bird.vy = JUMP_FORCE;
return;
}
if (gameState === 'gameover') {
if (Date.now() < gameoverTimer) return; // 3秒内不可操作
resetGame();
return;
}
if (gameState === 'playing') {
bird.vy = JUMP_FORCE;
}
}

关键设计:三种状态的输入响应不同。ready 状态开始游戏,playing 状态让鸟跳跃,gameover 状态需要等待 3 秒冷却后才能重置。

物理模拟与碰撞检测

重力与跳跃

小鸟的运动模拟了真实物理中的重力加速度:

1
2
3
4
5
6
7
// 重力加速度(每帧)
bird.vy += GRAVITY; // GRAVITY = 0.5
if (bird.vy > MAX_SPEED) bird.vy = MAX_SPEED;
bird.y += bird.vy;

// 用户跳跃(瞬间冲量)
bird.vy = JUMP_FORCE; // JUMP_FORCE = -6

参数设计逻辑:

  • GRAVITY = 0.5:每帧增加下落速度,模拟重力
  • JUMP_FORCE = -6:跳跃瞬间给一个向上的初速度
  • MAX_SPEED = 10:限制最大下落速度,防止飞出画面
  • 旋转角度根据速度动态计算:上升时仰头(-0.35),下落时俯冲(vy × 0.1,最大 1.4

AABB 碰撞检测

碰撞检测采用轴对齐包围盒(AABB) 算法,检测小鸟与管道、地面的碰撞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function checkCollision() {
// 顶部边界
if (bird.y < 0) return true;

// 地面碰撞
var groundY = canvas.height - 60;
if (bird.y + bird.h > groundY) return true;

// 管道碰撞(AABB 检测)
for (var i = 0; i < pipes.length; i++) {
var p = pipes[i];
if (bird.x + bird.w > p.x && bird.x < p.x + p.w) {
if (bird.y < p.gapY || bird.y + bird.h > p.gapY + p.gapH) {
return true;
}
}
}
return false;
}

AABB 检测的核心条件:两个矩形在 X 轴和 Y 轴上同时重叠才算碰撞。对于管道,X 轴检查小鸟是否进入管道区域,Y 轴检查是否碰到了管道上下部分(间隙之外)。

像素风格渲染系统

全局像素对齐策略

为了保持像素风格的一致性,所有绘制都基于像素块大小 PX = 4 对齐:

1
2
var PX = 4;      // 场景元素像素块大小
var PX_BIRD = 2; // 小鸟像素块大小(更精细)

所有位置和尺寸都通过 Math.round(x / PX) * PX 对齐到像素网格,确保画面干净利落。

天空与云

天空采用色带渐变替代平滑渐变,云朵用像素网格定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function drawBg() {
// 色带天空:5 层固定色块
var skyColors = ['#37c6c0', '#4dc9c3', '#6ed0c9', '#87CEEB', '#b8e6f0'];
var bandH = Math.ceil(canvas.height / skyColors.length / PX) * PX;
for (var i = 0; i < skyColors.length; i++) {
ctx.fillStyle = skyColors[i];
ctx.fillRect(0, i * bandH, canvas.width, bandH + PX);
}

// 像素云:2D 网格定义形状
var cloudGrid = [
[0,0,1,1,1,1,1,1,0,0],
[0,1,1,1,1,1,1,1,1,0],
[1,1,1,1,1,1,1,1,1,1],
[0,1,1,1,1,1,1,1,0,0],
];
// 逐像素块绘制
for (var r = 0; r < cloudGrid.length; r++)
for (var col = 0; col < cloudGrid[r].length; col++)
if (cloudGrid[r][col])
ctx.fillRect(cx + col * PX, cy + r * PX, PX, PX);
}

小鸟:像素网格精灵

小鸟是整个游戏中最复杂的像素绘制对象,使用 16×12 像素网格(每个像素 2×2 画布像素)定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
var palette = ['', '#1d746f', '#1d8a84', '#37c6c0', '#ffd93d', '#ffffff', '#222222', '#2aa39e'];
var grid = [
[0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,1,1,1,1,0,0,0,0,0],
[0,0,0,0,0,7,1,1,1,1,1,1,0,0,0,0], // ← wing
[0,0,0,0,7,7,1,1,1,1,1,1,0,0,0,0], // ← wing
[0,0,0,0,0,1,1,1,3,3,3,0,0,0,0,0], // ← belly
[0,0,0,0,0,1,1,1,1,3,3,0,0,5,5,4], // ← eye + beak
[0,0,0,0,0,1,1,1,1,1,3,0,5,6,5,4], // ← pupil
[0,0,0,0,0,1,1,1,1,1,1,0,5,5,5,4],
[0,0,0,0,0,0,1,1,1,1,0,0,0,0,4,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0],
];

颜色编码说明:

编码 颜色 用途
0 透明 背景
1 #1d746f 身体深色
3 #37c6c0 胸部浅色
4 #ffd93d 喙(金色)
5 #ffffff 眼白
6 #222222 瞳孔
7 #2aa39e 翅膀

翅膀动画通过网格中值为 7 的像素行上下偏移实现:

1
var yOff = (c === 7 && bird.wingUp) ? -2 : 0;

像素数字字模

得分使用自定义 3×5 像素字模渲染,而非系统字体:

1
2
3
4
5
6
7
8
var PIXEL_DIGITS = [
'111101101101111', // 0
'010010010010010', // 1
'111001111100111', // 2
// ...
];
var DIGIT_W = 3;
var DIGIT_H = 5;

每个数字由 15 位二进制值定义(5 行 × 3 列),1 表示点亮像素块。这种方案的好处:

  • 像素风格统一:与游戏其他元素风格一致
  • 无依赖:不需要加载外部字体
  • 可定制:可以精确控制每个像素

地面与管道

地面采用条纹像素地面设计,配合滚动装饰块模拟运动感:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function drawGround() {
// 深色基底
ctx.fillStyle = C_SAND_DARK;
ctx.fillRect(0, gy, canvas.width, 60);
// 交错条纹
var offset = -(Date.now() * 0.04) % (p * 2);
ctx.fillStyle = C_SAND;
for (var x = 0; x < canvas.width; x += p * 2)
ctx.fillRect(x + offset, gy, p, 60);
// 滚动装饰块
ctx.fillStyle = C_TEAL;
for (var i = 0; i < 12; i++) {
var bx = (i * p * 3 + offset * 2) % (canvas.width + p * 3) - p;
ctx.fillRect(bx, gy + p * 2, p, p);
ctx.fillRect(bx + p, gy + p * 4, p, p);
}
}

管道的像素化处理包括位置对齐纯色填充像素金边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function drawPipes() {
var p = PX;
// 对齐到像素网格
var x = Math.round(pp.x / p) * p;
var w = Math.round(pp.w / p) * p;

ctx.fillStyle = C_TEAL_DEEP; // 管道主体
ctx.fillRect(x, 0, w, gapY);

ctx.fillStyle = C_TEAL; // 高光列
ctx.fillRect(x + p, 0, p * 2, gapY);

ctx.fillStyle = C_GOLD; // 管帽金边
ctx.fillRect(capX, gapY - capH, capW, p);
}

绘制顺序

正确的绘制顺序保证层次关系:

1
2
3
4
5
6
7
8
9
function draw() {
drawBg(); // 1. 天空背景
drawPipes(); // 2. 管道(小鸟之后,地面之前)
drawParticles(); // 3. 粒子效果
drawGround(); // 4. 地面(覆盖管道底部)
drawBird(); // 5. 小鸟
drawOverlay(); // 6. 结束遮罩
drawHUD(); // 7. 得分数字(最顶层)
}

难度曲线系统

游戏难度随得分递增,采用双参数动态调整

1
2
3
4
5
function getDifficulty() {
var gap = Math.max(150, 170 - Math.floor(score / 8) * 5);
var speed = Math.min(2, 1.5 + Math.floor(score / 10) * 0.2);
return { gap: gap, speed: speed };
}

参数变化曲线:

得分区间 管道间距 移动速度 难度感受
0-7 170 1.5 简单
8-15 165 1.5 略紧
16-23 160 1.7 中等
24-31 155 1.9 困难
32+ 150 2.0 极限
  • 间距递减:每 8 分缩小 5px,下限 150px(防止出现无法通过的缝隙)
  • 速度递增:每 10 分增加 0.2,上限 2.0(避免速度过快导致性能问题)

积分系统与本地存储

游戏采用浏览器 localStorage 持久化最高分:

1
2
3
4
5
6
7
8
// 读取最佳成绩
bestScore = parseInt(localStorage.getItem('flappyBestScore') || '0', 10);

// 更新最佳成绩
if (score > bestScore) {
bestScore = score;
localStorage.setItem('flappyBestScore', bestScore.toString());
}

计分逻辑:小鸟通过管道时,管道对象的 scored 标记确保每个管道只计分一次:

1
2
3
4
if (!pipes[i].scored && pipes[i].x + pipes[i].w < bird.x) {
pipes[i].scored = true;
score++;
}

用户交互优化:3 秒关闭延时

游戏结束面板的 3 秒延时是体验优化的重要设计:

设计动机

  1. 防止误触:玩家刚死亡时可能条件反射点击屏幕,导致立即重开看不到成绩
  2. 成绩展示:给玩家时间查看本局得分与最佳成绩
  3. 情绪缓冲:从失败到重开之间提供一个自然的停顿

实现方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 游戏结束时设置计时器
gameoverTimer = Date.now() + 3000;

// 倒计时更新(每 200ms)
countdownInterval = setInterval(function() {
var remaining = Math.ceil((gameoverTimer - Date.now()) / 1000);
var hintEl = document.getElementById('go-hint');
if (remaining > 0) {
hintEl.textContent = remaining + ' 秒后可关闭';
} else {
hintEl.textContent = '点击关闭';
clearInterval(countdownInterval);
}
}, 200);

// 输入处理中的延时判断
if (gameState === 'gameover') {
if (Date.now() < gameoverTimer) return; // ← 关键:3 秒内直接忽略
resetGame();
}

倒计时精度:使用 Date.now() 时间戳判断而非计数递减,避免 setInterval 漂移导致的不准确。

资源清理

计时器必须在游戏重置和页面切换时正确清理:

1
2
3
4
5
6
7
function clearCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
gameoverTimer = 0;
}

clearCountdown() 在三个地方被调用:

  1. resetGame() — 玩家手动重置
  2. cleanupGame() — PJAX 页面切换
  3. 倒计时自身 — 到 0 时自动清理

PJAX 兼容性

小鸟闯关复用了博客统一的 GameManager 生命周期管理方案:

1
2
3
4
5
6
7
if (window.GameManager) {
window.GameManager.register('flappy', {
init: initGame,
cleanup: cleanupGame,
timerKey: null
});
}

清理逻辑详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function cleanupGame() {
// 1. 停止游戏循环
if (animFrame) {
cancelAnimationFrame(animFrame);
animFrame = null;
}
// 2. 移除事件监听
canvas.removeEventListener('click', window._flappyClick);
canvas.removeEventListener('touchstart', window._flappyTouchStart);
canvas.removeEventListener('touchend', window._flappyTouchEnd);
// 3. 移除键盘监听
window.removeEventListener('keydown', window._flappyKeyHandler);
// 4. 清除倒计时
clearCountdown();
}

清理要点

  1. 动画帧:必须 cancelAnimationFrame,否则循环持续消耗 GPU
  2. 事件监听:移除 clicktouchstarttouchendkeydown,避免 PJAX 切换后重复绑定
  3. 倒计时setInterval 必须清理,否则切页面后仍在运行

事件监听器通过 window._flappyClick 等全局引用保存,便于 removeEventListener 移除:

1
2
3
4
window._flappyClick = handleClick;
canvas.addEventListener('click', handleClick);
// cleanup 时:
canvas.removeEventListener('click', window._flappyClick);

总结

小鸟闯关是博客游戏系列中技术路线最独特的一款——它不依赖 AI 算法或复杂数据结构,而是用实时物理模拟像素艺术渲染带来完全不同的游戏体验。

通过本文,我们深入探讨了:

  1. 物理引擎:重力加速度、跳跃冲量、AABB 碰撞检测
  2. 像素渲染:像素对齐策略、网格精灵定义、点阵字模
  3. 难度曲线:双参数动态调整保证渐进式挑战
  4. 交互优化:3 秒关闭延时,兼顾展示与防误触
  5. 状态管理:三态状态机的干净切换与资源清理

对比系列中的其他游戏:

游戏 技术标签 复杂度
五子棋 AI 博弈、Minimax ⭐⭐⭐⭐
贪吃蛇 队列、方向控制 ⭐⭐
俄罗斯方块 矩阵旋转、碰撞消除 ⭐⭐⭐
扫雷 递归展开、逻辑推理 ⭐⭐⭐
光影拼图 图片处理、吸附算法 ⭐⭐⭐⭐⭐
小鸟闯关 物理模拟、像素渲染 ⭐⭐⭐

Flappy Byte 的成功说明:即使是最简单的游戏机制,通过精致的像素艺术和流畅的物理反馈,也能带来完整的游戏体验。


相关链接:

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: