不用框架也能写好小游戏:一个 Canvas 项目的模块拆分与状态管理
为什么不再把所有逻辑塞进一个文件
做第一个纯前端小游戏时,我习惯把 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,静态文件放到一起,断网时也能直接打开。
这些改动单独看都不复杂,但组合起来会让一个“能玩”的演示更接近可以分享的小作品。模块拆分不意味着工程化,它只是让每一部分都有明确边界;对于几千行以内的纯前端项目,这种轻量组织方式已经足够。
下一步我准备把关卡数据和碰撞参数抽成一个可视化调试面板,边玩边调手感。等完成后会继续记录实际效果和新的踩坑点。
- 点赞
- 收藏
- 关注作者
评论(0)