不用框架也能写好小游戏:一个 Canvas 项目的模块拆分与状态管理

举报
yd_295628683 发表于 2026/10/19 09:45:06 2026/10/19
【摘要】 用一个纯前端像素打砖块项目,拆解 Canvas 小游戏的模块划分、固定时间步、状态机、碰撞检测、关卡数据与 Web Audio 音效,给不想引入框架的开发者一套可直接复用的组织方式。

为什么不再把所有逻辑塞进一个文件

做第一个纯前端小游戏时,我习惯把 HTML、样式和脚本都写在一个文件里。刚开始确实快,但项目一旦加入关卡、音效、暂停和移动端触控,index.html 很快就会变成几百行:改一个球的反弹参数,可能顺手碰坏计分逻辑;想复用上一关的砖块布局,又得从一大段代码里慢慢找。

后来我把最近的像素打砖块项目按职责拆成了几个小模块。没有引入框架,也没有上构建工具,仍然是浏览器直接打开的静态页面,但代码的可读性和修改成本明显下降。

src/
  main.js        // 启动与主循环
  game.js        // 游戏状态机
  input.js       // 键盘与触摸输入
  entities.js    // 挡板、球、砖块
  render.js      // Canvas 绘制
  levels.js      // 关卡数据
  audio.js       // 合成音效

模块之间只通过少量明确的函数和数据交互:输入层负责收集按键,游戏层负责推进状态,渲染层只读取当前状态。这样调试时可以先判断问题属于“输入没进来”“状态没更新”还是“绘制参数不对”,排查范围会小很多。

固定时间步,让物理表现更稳定

最初我直接在 requestAnimationFrame 回调里用两帧之间的时间差更新球的位置。台式机上很顺,换到刷新率不同的屏幕后,球速和碰撞手感就会变化。更稳妥的做法是使用固定时间步:逻辑每次只推进一个很小的固定间隔,渲染仍然跟随屏幕刷新。

const STEP = 1 / 120;
let acc = 0;
let last = performance.now();

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

  while (acc >= STEP) {
    update(STEP);
    acc -= STEP;
  }

  render(acc / STEP);
  requestAnimationFrame(frame);
}

这里的 0.1 是一个保护值。切到后台再回来时,两帧之间可能隔了很久,如果不限制 dt,球会瞬间穿过整张地图。把累计时间截断,可以避免这种“回归后穿模”的问题。

用状态机管住开始、暂停和结束

小游戏最容易写乱的地方,是各种条件判断散落在更新函数里:是否开始、是否暂停、是否已经结束、是否正在等待发球。与其到处写布尔值,不如把游戏状态收拢到一个枚举中。

const State = {
  READY: 'ready',
  PLAYING: 'playing',
  PAUSED: 'paused',
  OVER: 'over',
};

function update(dt) {
  if (state === State.PLAYING) {
    movePaddle(dt);
    moveBall(dt);
    resolveCollisions();
  }
}

function togglePause() {
  if (state === State.PLAYING) state = State.PAUSED;
  else if (state === State.PAUSED) state = State.PLAYING;
}

输入层只负责触发状态转换,不直接修改球速或分数。窗口失去焦点时自动切到暂停,重新点击后再恢复;游戏结束时停止更新,只保留重新开始的入口。状态一清楚,后面加“暂停菜单”或“关卡切换”就不需要重写主循环。

碰撞检测先粗后细

打砖块里数量最多的是球和砖块的碰撞。砖块都是轴对齐矩形,用 AABB 判断就够用;真正需要留意的是高速运动时的穿透。我的做法是保留球的上一帧位置,先判断这一帧的移动线段是否经过砖块附近,再做矩形重叠检测。

function overlaps(a, b) {
  return a.x < b.x + b.w &&
         a.x + a.w > b.x &&
         a.y < b.y + b.h &&
         a.y + a.h > b.y;
}

function hitBrick(ball, brick) {
  if (!overlaps(ball, brick)) return null;
  const overlapX = Math.min(
    ball.x + ball.w - brick.x,
    brick.x + brick.w - ball.x
  );
  const overlapY = Math.min(
    ball.y + ball.h - brick.y,
    brick.y + brick.h - ball.y
  );
  return overlapX < overlapY ? 'x' : 'y';
}

返回碰撞轴之后,只需要翻转对应方向的速度,并把球推出重叠区域。挡板则可以根据球击中位置与挡板中心的偏移量,给球一个轻微的角度变化,手感会比单纯反弹更自然。

关卡用数据描述,而不是写死在逻辑里

砖块布局适合用字符串数组表示。每个字符代表一格,1 表示普通砖块,2 表示需要两次击打的砖块,0 表示空位。新增关卡时只改数据,不动更新和绘制代码。

const LEVEL_1 = [
  '1111111111',
  '1222222221',
  '1111111111',
  '0011111100',
];

解析时把字符映射成带坐标、尺寸、生命值和颜色的对象。这样关卡编辑器、随机生成器或者难度递增都可以在数据层完成,游戏循环始终保持简单。

音效不一定需要音频文件

像素风小游戏对音效的要求不高,几个短促的方波就能做出击球和碎砖的反馈。使用 Web Audio API 现场合成,可以省掉资源加载和跨域问题。

let audioContext;

function beep(frequency, duration = 0.06) {
  if (!audioContext) audioContext = new AudioContext();
  const osc = audioContext.createOscillator();
  const gain = audioContext.createGain();

  osc.type = 'square';
  osc.frequency.value = frequency;
  gain.gain.setValueAtTime(0.04, audioContext.currentTime);
  gain.gain.exponentialRampToValueAtTime(
    0.0001,
    audioContext.currentTime + duration
  );

  osc.connect(gain).connect(audioContext.destination);
  osc.start();
  osc.stop(audioContext.currentTime + duration);
}

浏览器的自动播放策略要求音频上下文在用户操作之后才能启动,所以第一次点击开始按钮时顺便调用一次 resume(),后面就能正常播放。

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

  • 给 Canvas 设置 devicePixelRatio 缩放,避免高分屏上文字和线条发虚。
  • 同时支持键盘和触摸,挡板拖动要用 pointermove,不要只监听鼠标事件。
  • 监听 visibilitychange,页面隐藏时自动暂停,防止回来时球已经飞出边界。
  • 把游戏尺寸限制在一个合理范围,窗口变化时重新计算缩放,而不是简单拉伸画布。
  • 尽量不依赖外部 CDN,静态文件放到一起,断网时也能直接打开。

这些改动单独看都不复杂,但组合起来会让一个“能玩”的演示更接近可以分享的小作品。模块拆分不意味着工程化,它只是让每一部分都有明确边界;对于几千行以内的纯前端项目,这种轻量组织方式已经足够。

下一步我准备把关卡数据和碰撞参数抽成一个可视化调试面板,边玩边调手感。等完成后会继续记录实际效果和新的踩坑点。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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