做中秋活动页时,我在内部工具库里翻到一个老项目「玉兔吃月饼」,当时花了两个下午把它从一个纯展示贺卡改成了能玩得上头的小游戏。玩法一句话就能说清楚:玉兔在画面底部接住从天上掉下来的月饼,接得多分数高,接住黑暗料理或者漏掉太多就提前结束。整件事从头到尾只用了一个 HTML 文件,没有框架、没有构建工具、没有后端,双击就能运行。这篇文章就把我做这个游戏的完整思路、核心代码和踩过的坑都摊开讲,适合刚学完 HTML/CSS/JS 基础、想拿一个小项目练手的朋友,也适合产品想给中秋 H5 加个互动彩蛋的开发者。
1. 玩法设计与技术选型:先想清楚再动手
开始写代码之前,我先把游戏到底长什么样、玩家怎么玩、什么时候算结束,全部在纸上列了一遍。这个阶段越清楚,后面写逻辑越省事。
1.1 玩法从一张"中秋节贺卡"演变而来
最初的需求其实很朴素:中秋活动页太静态了,想让用户有点参与感。后来我把交互收敛成一个核心循环:月饼从天空掉落,玉兔在下方移动接住它。这个玩法是典型的"捕抓类"小游戏,玩家操作简单、反馈即时,非常适合做成 H5 小游戏。
具体的规则我定成了这样:
- 玉兔固定在画面的中下方,不能上下移动,只能左右移动。
- 普通月饼从顶部随机位置掉落,玉兔碰到会加分(10分)。
- 金色月饼比较稀有,碰到加50分。
- 灰色发绿的"黑暗料理月饼"要躲开,碰到会扣一条命。
- 漏掉任意普通月饼,也扣一条命。
- 初始3条命,生命归零游戏结束。
为什么要把"漏掉月饼"和"碰到黑暗料理"都算作扣命?因为只靠加分没有负反馈,玩家很快就会无聊。加一个负面机制,游戏才有紧张感。而且这个机制实现起来非常简单,无非是在碰撞检测里多判断一个类型字段而已。
难度也得随着时间变化,否则玩两分钟就腻了。我设计了两个可调的难度参数:月饼的下落速度和生成间隔。初始下落速度是每秒120像素,每接住10个月饼,速度增加12像素每秒;初始生成间隔900毫秒,随着分数提高逐渐缩短,最后稳定在350毫秒左右。这样玩家会明显感觉到"后面越来越快",但又不至于一下子手忙脚乱。
这个难度曲线我是参考了主流"接物品"类小游戏给出的经验值,具体数值你可以根据自己的画面尺寸微调。核心原则是:前期给玩家足够的适应时间,中后期逐步加压,让挑战感曲线保持陡峭但不劝退。
1.2 技术选型:Canvas 优先于 DOM 动画的三个理由
很多新手做这种小游戏,第一反应是用 div 加 CSS 动画,或者直接操作 DOM 的 style.left、style.top。说实话,月饼数量少的时候确实能跑,但我不推荐,这次我选了 Canvas 重绘方案。
原因有三点:
第一,性能。DOM 动画每帧都在触发布局和重绘,尤其当屏幕上的月饼数量超过20个,再加上玉兔、背景、文字,页面明显会出现卡顿。Canvas 直接在一张画布上绘制所有元素,不涉及 DOM 节点的增删改,同样的场景跑起来要流畅得多。
第二,碰撞检测的实现更直观。如果用 DOM,你得读取每个元素的 offsetLeft、offsetTop 来计算位置,还得考虑盒模型和缩放;但在 Canvas 里,所有游戏对象都只是坐标和半径,碰撞检测就是一个非常直接的距离比较运算。
第三,Canvas 的绘制 API 足够灵活。月饼、玉兔、背景月亮、星星,这些元素我全都可以用 circle、ellipse、rect 这些基础方法画出来,不需要任何外部图片资源。这样整个项目自包含,随便拷到哪台电脑都能跑。
1.3 游戏状态机:让逻辑不再一团乱麻
小游戏最容易翻车的地方是逻辑状态混乱:游戏还没开始,月饼就在掉;游戏已经结束,键盘还能控制玉兔;重新开始时,上一局的月饼和分数没有清空。这些问题的根源是没有一个明确的状态管理。
我给自己规定了一个简单的状态机,只有三个状态:
- ready:开始界面,月饼不下落,玉兔跟随鼠标但接不住任何东西。
- playing:正式游戏,所有逻辑正常运转。
- over:结束界面,显示得分,等待重新开始。
每一帧的游戏循环里,我先看当前状态,再决定要不要执行更新逻辑。比如只有 playing 状态下才调用 spawnMooncake 和 updateMooncake,只有 playing 状态下才响应键盘移动。这样即使玩家在结束画面乱按键盘,也只会触发重新开始,不会出现玉兔乱跑的诡异情况。
实际写下来,状态机代码大概长这样:
let gameState = 'ready'; function loop(t) { update(); render(); requestAnimationFrame(loop); } function update() { if (gameState === 'playing') { updateRabbit(); updateMooncakes(); checkCollision(); updateDifficulty(); } }至于每个状态之间的切换,用一个小函数统一处理就好了:
function setState(next) { gameState = next; if (next === 'playing') { score = 0; lives = 3; mooncakes = []; // 重置所有游戏参数 } }把状态切换集中在一起,最大的好处是"重置游戏"的逻辑只写一次,不会出现漏清某个变量的情况。我后续做任何功能扩展,比如加暂停、加弹窗,都只需要在这个状态机上再挂一个节点。
2. 核心细节拆解:移动、掉落与碰撞检测
这个游戏最有技术含量的部分,其实不是界面多好看,而是三个核心机制:玉兔怎么移动、月饼怎么掉落、碰撞怎么判定。这三个弄明白了,小游戏就完成了一大半。
2.1 玉兔移动:鼠标跟随和键盘双支持
玉兔的移动我做了两套方案:鼠标/触屏直接定位,键盘方向键匀速移动。移动端用户比较习惯触屏滑动,桌面用户有人喜欢用键盘,两个都支持能覆盖更多场景。
鼠标跟随的核心逻辑是监听 canvas 上的 mousemove 事件,把鼠标的横向坐标直接赋给玉兔的 x 坐标:
canvas.addEventListener('mousemove', (e) => { if (gameState === 'playing') { rabbit.x = e.offsetX; } });注意这里用的是 e.offsetX,它在 canvas 元素上取到的是相对画布左上角的横坐标,不需要再做坐标换算。如果你用的是 e.clientX,就必须减去 canvas.getBoundingClientRect().left,否则鼠标一挪,玉兔就偏到屏幕外面去了。这也是我一开始踩过的坑。
键盘控制则是维护一个按键状态表:
const keys = {}; document.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'Space') startGame(); }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); function updateRabbit(dt) { if (keys['ArrowLeft'] || keys['KeyA']) { rabbit.x -= 360 * dt; } if (keys['ArrowRight'] || keys['KeyD']) { rabbit.x += 360 * dt; } rabbit.x = Math.max(rabbit.r, Math.min(W - rabbit.r, rabbit.x)); }速度360像素每秒是我测试下来手感比较舒服的值。太小了玉兔显得迟钝,跟不上掉落的月饼;太大了又很难精确定位,尤其是后期月饼下落变快时,容易一下子冲过头。你可以在代码里把360这个数字单独提成一个常量,方便调整。
还要注意不能让玉兔跑出画面。我用了 Math.max 和 Math.min 把 rabbit.x 限制在 [rabbit.r, W - rabbit.r] 之间,这样玉兔至少有一半身体不会跑到画布外面。
2.2 月饼生成与掉落节奏设计
月饼不是一个一个手动放的,而是用一个"定时器"逻辑不断生成。每一帧我都累加一个时间计数器,当累加时间超过当前的生成间隔,就会创建一个新月饼对象,并重置计数器。
function updateSpawn(dt) { spawnTimer += dt; if (spawnTimer >= spawnInterval) { spawnTimer = 0; const type = randomMooncakeType(); const r = 26; mooncakes.push({ x: r + Math.random() * (W - r * 2), y: -r, r: r, type: type, speed: 120 + score / 10 * 12, rotate: Math.random() * Math.PI * 2, rotateSpeed: (Math.random() - 0.5) * 2 }); } }注意月饼的初始 y 值是负的(-r),这样它从画面顶部外一点点进入视野,视觉上比较自然。落点 x 是随机生成的,但会确保月饼在水平方向不会被截掉一半,所以用 r 做了边界裁剪。
月饼的类型我用了一个权重随机:
function randomMooncakeType() { const n = Math.random(); if (n < 0.12) return 'gold'; if (n < 0.22) return 'bad'; return 'normal'; }金色月饼概率12%,黑暗料理概率10%,剩下78%都是普通月饼。这个比例是经过几轮试玩调整的:金色太少玩家会觉得它根本不存在,太多又不够珍惜;黑暗料理太少起不到威慑作用,太多会让玩家烦躁。
下落更新很简单,就是每帧让 y 增加 speed * dt:
mooncakes.forEach(mc => { mc.y += mc.speed * dt; mc.rotate += mc.rotateSpeed * dt; });我给月饼加了一个旋转动画,每帧绕中心旋转,视觉上会更活泼。实现上不需要真的旋转整个对象,只需要在 draw 之前调用 ctx.translate(mc.x, mc.y) 和 ctx.rotate(mc.rotate),然后以0,0为圆心绘制月饼即可。
2.3 碰撞检测的两种实现思路
碰撞检测是这个游戏的核心判定机制。物体多了以后,如果每帧都进行两两检测,性能会出问题,但这里月饼和玉兔是一对多的关系,检测量并不大。我采用的方案是:每一帧遍历所有月饼,判断它和玉兔的位置关系。
最直观的方式是"圆心距离法":把玉兔近似看成一个半径为 rabbit.r 的圆,把月饼看成半径为 mc.r 的圆,两个圆心之间的距离小于半径之和,就视为碰撞。
function checkCollision() { for (let i = mooncakes.length - 1; i >= 0; i--) { const mc = mooncakes[i]; const dx = mc.x - rabbit.x; const dy = mc.y - rabbit.y; const dist = Math.hypot(dx, dy); if (dist < mc.r + rabbit.r - 8) { handleEat(mc, i); } } }注意我减去了8,这是视觉容差。为什么?因为玉兔和月饼都是有图形的,两个图形边缘刚好碰到时,玩家在视觉上还没形成"吃到了"的感知,这时候触发得分会让人觉得莫名其妙。适当缩小碰撞范围,手感会明显变"准"。
另一种方法是"矩形包围盒检测",适用于角色形状接近矩形的场景。比如玉兔如果有很宽的身体,用圆形总觉得不对劲,那就改用矩形:
function rectCollide(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }矩形检测的优点是直观、效率高,缺点是旋转后的矩形不适合,需要做额外的坐标变换。玉兔和月饼本身没有大角度旋转,用圆形检测就够了。但如果你的角色是忍者猫、方块人这类明显矩形轮廓,用矩形检测会更贴图形边缘。
我个人的经验是:在不确定选哪种时,先用圆形检测,参数调起来最简单;如果游戏里出现细长的柱子、墙壁这类物体,再补矩形检测不迟。
2.4 计分、生命值和游戏结束的判断
计分和生命的逻辑听起来简单,但细节也不少。我在 handleEat 里对不同类型的月饼做了分别处理:
function handleEat(mc, index) { mooncakes.splice(index, 1); if (mc.type === 'normal') { score += 10; playEatSound(); } else if (mc.type === 'gold') { score += 50; playEatSound(true); showFloatingText('+50'); } else { lives -= 1; playBadSound(); shakeScreen(); } if (lives <= 0) { setState('over'); } }这里有几个细节经验:
第一,遍历月饼数组时必须从后往前,也就是 for (let i = mooncakes.length - 1; i >= 0; i--),因为 splice 删除元素会改变数组长度和索引,正着遍历容易跳过一个元素。
第二,漏掉月饼也算扣生命。这段逻辑放在 updateMooncakes 里,当月饼掉到 y > H + 60 时(完全离开屏幕),先移除它,再判断类型,普通月饼漏掉扣命:
function updateMooncakes(dt) { for (let i = mooncakes.length - 1; i >= 0; i--) { const mc = mooncakes[i]; mc.y += mc.speed * dt; if (mc.y > H + 60) { mooncakes.splice(i, 1); if (mc.type === 'normal' && gameState === 'playing') { lives -= 1; if (lives <= 0) setState('over'); } } } }为什么只对普通月饼漏掉扣命?金色月饼稀少,漏掉了只损失机会,不额外惩罚;黑暗料理漏掉反而是好事,躲过去了,当然不该扣命。玩家对规则的公平感很敏感,这种细节会让游戏显得更"讲理"。
第三,游戏结束的触发点有两个:碰到黑暗料理且命数为0,漏掉普通月饼且命数为0。每次扣命后马上判断 lives <= 0,而不是等下一帧,避免出现命数已经归零玉兔还在接月饼的情况。
3. 实操复现:从空白页到可玩游戏
前面把原理讲清楚了,这一节我带你把整个游戏撸出来。我没有用什么复杂的打包工具,就是一份 HTML 文件,包含 HTML 结构、CSS 样式和 JavaScript 逻辑。你复制到本地就能跑。
3.1 搭建页面骨架与节庆样式
页面结构非常简单,只有一个 canvas 元素:
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>中秋玉兔吃月饼</title> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #0b0f2a; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; } #game { display: block; margin: 0 auto; background: radial-gradient(ellipse at 50% 20%, #1b2355 0%, #0b0f2a 70%); cursor: pointer; } .overlay-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ffedb3; text-shadow: 0 2px 10px rgba(0,0,0,0.6); font-size: 24px; text-align: center; pointer-events: none; white-space: nowrap; } </style> </head> <body> <canvas id="game"></canvas> <script> // 所有游戏逻辑 </script> </body> </html>CSS 里我把 body 的 overflow 设为 hidden,是为了防止游戏过程中因为滚动条出现导致画面抖动。背景用了深蓝紫色径向渐变,模拟中秋节夜晚的天空,当然这个背景最后会被 Canvas 绘制覆盖大部分,但加上会更保险,避免页面加载那一瞬间出现难看的白屏。
3.2 初始化 Canvas 和游戏主循环
初始化比较关键的一步是处理画布的实际像素尺寸,避免模糊。我直接用窗口大小作为画布大小,同时考虑设备的 devicePixelRatio:
const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const DPR = window.devicePixelRatio || 1; let W = 0, H = 0; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = W * DPR; canvas.height = H * DPR; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } window.addEventListener('resize', resize); resize();为什么要乘 DPR?因为普通屏幕上 devicePixelRatio 是1,但 Retina 屏是2,如果你直接把 canvas.width 设成 CSS 像素宽度,实际渲染时图片会被浏览器放大两倍,看起来就是模糊的。简单说,canvas 的物理像素要大于等于 CSS 像素,才能保证高清;乘 DPR 就是让物理像素跟上设备。
游戏主循环用 requestAnimationFrame,它会跟随屏幕刷新率自动调用,比 setInterval 更适合做动画:
let lastTime = 0; function loop(t) { const dt = Math.min((t - lastTime) / 1000, 0.05); lastTime = t; update(dt); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);注意我用了 Math.min 把 dt 限制在 0.05 秒内。这招是为了防止"切到别的标签页再切回来"时,requestAnimationFrame 停了一段时间,恢复后第一帧的 t 和 lastTime 差值很大,导致 dt 异常大,月饼瞬间下落一大截、甚至直接穿过玉兔。限制之后,最坏情况也只是游戏慢半拍,不会出现不可控的跳变。
3.3 绘制玉兔、月饼和月亮背景
这一节是视觉部分,也是中秋氛围感的关键。我全部用 Canvas 基础绘图API手绘,不依赖图片。绘制顺序是:先背景,再月饼,最后玉兔。
背景部分包括夜空渐变、圆月、星星和云朵。圆月我画在右上角:
function drawBackground() { const grad = ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, '#0b0f2a'); grad.addColorStop(1, '#1e1a3a'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); // 月亮 ctx.save(); ctx.shadowColor = '#ffe4a1'; ctx.shadowBlur = 60; ctx.fillStyle = '#f6d786'; ctx.beginPath(); ctx.arc(W - 120, 120, 56, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = '#d9b66b'; ctx.beginPath(); ctx.arc(W - 100, 100, 10, 0, Math.PI * 2); ctx.arc(W - 135, 135, 8, 0, Math.PI * 2); ctx.arc(W - 135, 90, 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); // 星星 stars.forEach(s => { ctx.globalAlpha = 0.4 + 0.6 * Math.abs(Math.sin(s.twinkle)); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); }); ctx.globalAlpha = 1; }星星的位置事先生成在 resize 时初始化,这样窗口大小变化后星星能重新铺满画面。每颗星星有一个 twinkle 属性,每帧加一点变化,模拟闪烁。
玉兔绘制函数,我画了一个简约圆润的兔子:
function drawRabbit() { const x = rabbit.x; const y = H - 150; // 耳朵 ctx.fillStyle = '#f5f0e8'; ctx.beginPath(); ctx.ellipse(x - 16, y - 44, 9, 24, -0.18, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(x + 14, y - 42, 8, 22, 0.15, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffb7c5'; ctx.beginPath(); ctx.ellipse(x - 16, y - 44, 4, 14, -0.18, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(x + 14, y - 42, 3, 12, 0.15, 0, Math.PI * 2); ctx.fill(); // 身体 ctx.fillStyle = '#f8f4ee'; ctx.beginPath(); ctx.ellipse(x, y + 6, 30, 36, 0, 0, Math.PI * 2); ctx.fill(); // 眼睛 ctx.fillStyle = '#3b2b20'; ctx.beginPath(); ctx.arc(x - 9, y - 2, 3.4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x + 9, y - 2, 3.4, 0, Math.PI * 2); ctx.fill(); // 腮红和嘴 ctx.fillStyle = '#ffd3d9'; ctx.beginPath(); ctx.arc(x - 17, y + 6, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x + 17, y + 6, 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#3b2b20'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y + 8, 4, 0.2, Math.PI - 0.2); ctx.stroke(); }这里 rabbit.x 是动态变化的,y 固定为 H - 150,始终保持在画面底部。画耳朵时先画白色大耳朵,再叠加深粉色内耳;眼眶用深色,腮红用浅粉。整体风格偏可爱,符合中秋节日氛围。
月饼的绘制我用颜色区分三种类型:
function drawMooncake(mc) { ctx.save(); ctx.translate(mc.x, mc.y); ctx.rotate(mc.rotate); if (mc.type === 'gold') { ctx.shadowColor = '#ffd966'; ctx.shadowBlur = 28; ctx.fillStyle = '#ffd24a'; } else if (mc.type === 'bad') { ctx.fillStyle = '#7b8a6a'; } else { ctx.fillStyle = '#f0c75e'; } ctx.beginPath(); ctx.arc(0, 0, mc.r, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // 表面花纹:画一圈小点,模拟月饼压花 ctx.strokeStyle = mc.type === 'bad' ? '#4c5a3e' : (mc.type === 'gold' ? '#c99b22' : '#d19e32'); ctx.lineWidth = 2; for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2; const px = Math.cos(angle) * (mc.r * 0.7); const py = Math.sin(angle) * (mc.r * 0.7); ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); }普通月饼是淡黄色,金色月饼带光晕,黑暗料理用灰绿色,玩家一眼就能分辨。花纹不是必须的,但加上后月饼的辨识度高很多,尤其屏幕上有多个月饼重叠时,花纹能帮助玩家分辨对象。
3.4 接入生成、碰撞、计分的完整代码
前面几个小节分段实现了各项功能,这一节我把它们在主循环里串起来。完整的 update 逻辑大致是下面这个样子:
function update(dt) { if (gameState === 'playing') { updateRabbit(dt); updateSpawn(dt); updateMooncakes(dt); checkCollision(); } } function render() { drawBackground(); mooncakes.forEach(drawMooncake); drawRabbit(); drawUI(); if (gameState === 'ready') { drawStartScreen(); } else if (gameState === 'over') { drawGameOverScreen(); } }drawUI 画的是左上角的分数和生命值:
function drawUI() { ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; ctx.fillRect(10, 10, 210, 70); ctx.fillStyle = '#ffedb3'; ctx.font = 'bold 22px "PingFang SC", sans-serif'; ctx.fillText('玉兔吃月饼', 24, 40); ctx.fillStyle = '#fff'; ctx.font = '16px "PingFang SC", sans-serif'; ctx.fillText('得分: ' + score, 24, 66); ctx.fillStyle = '#ffb7c5'; ctx.fillText('生命: ' + '❤'.repeat(Math.max(0, lives)), 120, 66); }生命值显示我用了一个小技巧:'❤'.repeat(lives) 直接渲染出心形字符串。不过要注意,某些旧浏览器的默认字体不支持这个字符,最好设置一个支持它的字体,比如苹果系统中直接显示完整。如果你要在 Windows 旧浏览器上运行,建议把❤换成'★'或者用 Canvas 画小圆点来模拟生命。
开始界面和结束界面我用 canvas 覆盖文字绘制,而不是额外创建 HTML 元素:
function drawStartScreen() { ctx.fillStyle = 'rgba(10, 14, 40, 0.72)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#ffedb3'; ctx.font = 'bold 40px "PingFang SC", sans-serif'; ctx.textAlign = 'center'; ctx.fillText('中秋快乐', W / 2, H / 2 - 80); ctx.font = '20px "PingFang SC", sans-serif'; ctx.fillText('移动玉兔接住月饼', W / 2, H / 2 - 20); ctx.fillText('点击或按空格开始', W / 2, H / 2 + 30); ctx.textAlign = 'left'; }开始事件我绑定在 canvas 上,点击任意位置就可以开始游戏:
canvas.addEventListener('click', () => { if (gameState === 'ready') { setState('playing'); } else if (gameState === 'over') { setState('playing'); } }); document.addEventListener('keydown', (e) => { if (e.code === 'Space' && (gameState === 'ready' || gameState === 'over')) { setState('playing'); } });3.5 加入音效和屏幕震动反馈
纯视觉的游戏多少有点干,我加了两类反馈:吃月饼的音效和碰到黑暗料理时的屏幕震动。音效用 Web Audio API 合成,不依赖外部音频文件,稳定可靠。
先创建一个复用的音频上下文,然后用 oscillator 生成短音:
let audioCtx = null; function initAudio() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } } function playEatSound(gold = false) { if (!audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = gold ? 880 : 660; gain.gain.setValueAtTime(0.18, audioCtx.currentTime); gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.12); } function playBadSound() { if (!audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(180, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.25); gain.gain.setValueAtTime(0.15, audioCtx.currentTime); gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.25); }注意浏览器有自动播放策略,用户未点击页面之前,AudioContext 可能处于 suspended 状态。所以我在第一次点击 canvas 时调用 initAudio(),把音频上下文初始化好,后面再触发音效就顺畅了。
屏幕震动我用一个小技巧:碰到黑暗料理时,把整个 canvas 的 transform 短暂平移几个像素,然后立即恢复。这个效果其实是通过修改 ctx.setTransform 实现的,但更简单的做法是给画布一个短暂的 CSS transform 动画:
function shakeScreen() { canvas.style.transition = 'transform 0.05s'; canvas.style.transform = 'translateX(6px)'; setTimeout(() => { canvas.style.transform = 'translateX(-6px)'; setTimeout(() => { canvas.style.transform = 'translateX(0)'; canvas.style.transition = ''; }, 50); }, 50); }这个反馈虽然简单,但对"击中负面物品"的感知强化非常有效。玩家看到画面一晃,再配上低沉的音效,就知道自己踩雷了,完全不需要额外的文字提示。
4. 踩坑记录与性能优化
开发过程中我踩了几个不算深但很典型的坑,写下来给后来人避雷。这些问题单看都不起眼,但组合起来能让人排查大半天。
4.1 页面空白排查顺序
如果你打开页面直接白屏,先按这个顺序排查:
第一,控制台有没有报错。最常见的错误是某个变量名拼写不一致,或者在引用 canvas 元素时用了错误的 id。按 F12 打开开发者工具,看 Console 面板,红色报错基本能定位到第一行出错代码。
第二,canvas 的尺寸是否为0。如果 resize 没有执行成功,或者 CSS 把 canvas 高度压成0,画面自然什么都看不到。检查一下 canvas.clientWidth 和 canvas.clientHeight 是否大于0。
第三,是否是 requestAnimationFrame 没有启动。如果主循环一开始就报错,整个循环停住,画面只会显示 CSS 背景但你画的月亮星星都不会出现。把 requestAnimationFrame(loop) 放在所有函数定义之后调用,确保声明完再执行。
4.2 月饼越跑越多导致卡顿
这是我刚开始犯过的毛病:月饼掉出屏幕后只在视觉上消失了,但数组里依然保存着它的对象。如果玩家挂机不操作,月饼数量会一路累积,每一帧都要遍历这个越来越长的数组,还执行碰撞检测,最终整个页面卡成幻灯片。
解决办法是每一帧及时清理跑出屏幕的月饼:
mooncakes = mooncakes.filter(mc => mc.y < H + 80);有人在生成处做限制,比如"最多同时存在30个月饼",也能缓解,但治标不治本。真正的问题还是出在清理不及时。我建议在 updateMooncakes 的循环里统一处理移除逻辑,无论是掉出屏幕还是被吃掉,都在一个地方 splice 掉,避免到处散落清理代码。
4.3 高DPI屏幕模糊问题
这是个非常典型的屏幕适配问题:明明在普通屏上很清晰的画面,到了 Retina 屏上就发虚。原因前面提到过,canvas 的像素尺寸和 CSS 尺寸不一致,被浏览器拉伸了。
我的处理是 resize 时统一用 devicePixelRatio 缩放,并且用 ctx.setTransform 保持坐标系一致:
canvas.width = W * DPR; canvas.height = H * DPR; ctx.setTransform(DPR, 0, 0, DPR, 0, 0);这样后续所有逻辑代码都按照 CSS 像素(W 和 H)来思考,不用在每个绘制函数里再乘 DPR。注意 resize 会重置 canvas 的所有状态,包括 transform,所以在 resize 里要重新 setTransform 一次,否则会叠加变换导致绘制位置跑偏。
4.4 移动端适配与触摸控制
手机浏览器上 mousemove 事件不会正常触发,必须监听 touchmove 事件。我在 canvas 上补了一段:
canvas.addEventListener('touchmove', (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const touch = e.touches[0]; rabbit.x = touch.clientX - rect.left; }, { passive: false });touchmove 的事件对象里没有 offsetX,所以要手动用 clientX 减去 canvas 左上角坐标。加 preventDefault 是阻止页面跟着滑动,否则玩着玩着页面就滚动了。这里给 passive: false 是因为被动监听器里不能调用 preventDefault。
另外手机上点击 canvas 可能会触发双击缩放,我在 CSS 里加了:
canvas { touch-action: none; -webkit-user-select: none; user-select: none; }touch-action: none 能让浏览器不处理所有触摸手势,包括缩放和滚动,游戏控制会稳定很多。
4.5 后台切标签后游戏加速跳变
这个问题很多新手不会遇到,但一旦遇到就会很困惑。你正在玩游戏,朋友发来消息,你切去看一眼,再回到游戏,发现月饼已经扎堆掉到屏幕下面了,甚至有时候瞬间扣了几条命。
根本原因是 requestAnimationFrame 在后台标签页会被浏览器暂停,切回来后会用一个很大的 dt 作为第一帧时间差。如果不加限制,月饼会在这一帧内"瞬移"几百像素,直接穿过玉兔,碰撞检测还没来得及判定,月饼已经掉出屏幕被当作漏掉了。
解决方法是给 dt 设上限:
const dt = Math.min((t - lastTime) / 1000, 0.05);0.05 秒虽然理论上会导致游戏稍微变慢,但比"瞬移+误判"的体验好太多了。你还可以在页面 visibilitychange 事件里重置 lastTime,回到页面时直接从0开始累计,更彻底:
document.addEventListener('visibilitychange', () => { if (!document.hidden) { lastTime = performance.now(); } });两个方案选一个就行,我两个都加了,实测下来非常稳。
4.6 其他小问题速查
我把剩下的零碎问题整理成一张速查表,方便你遇到时直接查:
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| 玉兔只能在鼠标移动时动,键盘没反应 | 键盘事件绑定在 document 上但可能未触发 | 确认页面获得焦点,不要在 iframe 中预览 |
| 月饼不旋转或不显示 | ctx.save / ctx.restore 配对错误 | 检查绘制函数是否有未闭合的 ctx.save |
| 分数一直为0 | 碰撞检测的半径值设置过大或过小 | 输出 dx、dy、dist 临时调试 |
| 重新开始后游戏没有复位 | setState 没有清理全局变量 | 在 setState 里统一重置分数、生命、月饼数组 |
| 音频在首次点击前不播放 | 浏览器自动播放策略 | 在 click 事件里调用 initAudio() 初始化 |
调试技巧方面,我有一个建议:不要一上来就调视觉,先在 update 里把关键变量的值 console.log 出来,比如月饼数量、得分、玉兔位置。确认逻辑正确后,再关掉日志去做视觉细化。很多"看起来不对劲"的问题,其实都是数据层面已经错了,只是视觉上表现成了卡顿、闪现或者碰撞不敏感。
写在最后的一点体会
做完这个项目我最深的感受是:一个体量很小的游戏,想要做到"玩起来舒服",比想象中更花心思。难度曲线、碰撞容差、音效反馈、双端控制,单拎出来每一个都是几行代码的量,但把它们组合起来、反复调参,才是真正花时间的地方。我建议你完成基础版本后,至少找3个朋友试玩一下,重点观察他们在前30秒里会不会困惑、会不会觉得玉兔反应慢、月饼掉得太快还是太慢。用户反馈永远是最准的调试工具。
如果你想继续扩展,这几个方向都很有意思:给月饼增加"双击得分"的连击判定,加入节日祝福弹窗,或者把背景从静态夜空改成动态的飘落桂花花瓣。核心代码我已经全部贴在前面了,你完全可以在它基础上自由发挥。中秋嘛,代码写不写得完不重要,氛围先到为止。