news 2026/11/3 18:01:06

从C语言到H5 Canvas:投篮小游戏物理模型与碰撞判定拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从C语言到H5 Canvas:投篮小游戏物理模型与碰撞判定拆解

投篮小游戏这个题材我前前后后写过三版:第一版是 C 语言在控制台里用字符画出来的,第二版换成了常见的 C 图形库,第三版就是现在手机上能直接打开玩的 H5 Canvas 版本。同样是“把球扔进筐”,C 语言版本逼着我把物理模型和状态机拆得干干净净,因为控制台没有半透明、没有缓动、没有滤镜,任何手感都只能靠数值算出来;H5 Canvas 版本则胜在交付速度快,一个 HTML 文件发出去,手机、平板、电脑都能点开就玩。这两个版本的技术栈差异极大,但核心可玩性其实是一套东西:抛物线怎么调、筐口怎么判、失败反馈怎么做。这篇就把两套实现从头拆一遍,包括参数怎么算、代码怎么组织、以及那些教程里不会写、只有真正跑起来才会暴露的坑。适合刚学完语法想找个完整项目练手的读者,也适合做过小游戏但总觉得“手感差一口气”的人。

1. 先想清楚:为什么投篮题材适合拿来练两种技术栈

1.1 投篮玩法的可玩性到底建立在什么上面

投篮游戏看上去是个极简玩法——一个球、一个筐、一次出手。但它能撑起完整游戏循环,靠的是三层反馈的叠加:即时反馈(球飞出去的轨迹是否和手指/按键的预期一致)、结果反馈(进没进、进了几分)、进度反馈(连续进球、关卡变难)。这三层里,第一层完全由物理参数决定,第二层由碰撞判定决定,第三层由难度曲线决定。

我把这个拆解叫作“三层漏斗”。绝大多数投篮小游戏不好玩,问题都出在第一层:球飞出去的弧线跟玩家的直觉对不上。你以为拉到这个角度就该进,结果球砸在筐前沿弹回来;你以为再加点力就行,结果球直接飞过篮板。这种挫败不是难度带来的,是模型和直觉错位带来的,玩家会觉得“这游戏不听话”而不是“我技术不够”。

所以这个项目的核心不是画得多好看,而是把抛物线模型和玩家的肌肉直觉对齐。C 语言版本因为缺少视觉辅助,反而倒逼你把参数写死在代码里、反复推算,理解最深;Canvas 版本有瞄准线、有轨迹预览,可以边调边看,但一旦你把预览做得太准,游戏又会变成“照着虚线拉就必进”的无脑操作,可玩性瞬间归零。

这个平衡点我摸索出来的做法是:给预览线,但只给前 30% 的轨迹,后面的部分靠玩家自己脑补。既降低了入门门槛,又保留了判断空间。这个细节后面在 Canvas 章节会具体写怎么实现。

1.2 C 语言版本和 H5 Canvas 版本各自该练什么

两套实现的目标完全不同,别指望用一套思路套过去。C 语言版本的目标是"把模型讲清楚",H5 Canvas 版本的目标是"把体验做出来"。

C 语言版本真正的价值在于强制你处理三件在脚本语言里容易糊弄过去的事:内存布局(实体用结构体数组还是链表)、状态机(待发射 / 飞行中 / 结算 / 重开)、以及坐标变换(世界坐标和屏幕坐标的分离)。这三件事在 Canvas 版本里你也可以糊弄,比如全用像素坐标、全用全局变量、状态靠一堆布尔量硬凑,短期能跑,一加功能就崩。

H5 Canvas 版本真正该练的是另一组能力:设备像素比处理(不处理就是一片模糊)、帧率无关的物理积分(不处理就是高刷屏上球飞得飞快)、输入事件的统一抽象(鼠标、触摸、笔要一套逻辑)、以及资源加载与音效的时机控制。这些是 Web 小游戏上线前必踩的坑,跟物理模型没关系,但缺一个就出不了成品。

我建议的练手顺序是:先用 C 语言在控制台把物理和判定跑通,一行字符一行字符地看到球飞起来、落下去、砸到筐沿,这个过程对理解抛物线极有帮助;然后迁移到 Canvas,你会发现物理部分几乎是照抄,剩下的精力全部花在渲染和输入上。

2. 物理模型和判定逻辑:写代码之前先把数算明白

2.1 抛物线运动的数学拆解与参数标定

投篮的球在空中只受重力(忽略空气阻力的量级),所以运动可以拆成两个完全独立的轴:水平方向匀速,垂直方向匀加速。这就是最经典的抛体运动,公式简单到很多人反而不信它能做出好手感:

物理量公式说明
水平位置x = x0 + vx · t匀速,无加速度
垂直位置y = y0 + vy · t − ½ · g · t²世界坐标下向上为正
最高点时间t_peak = vy / g垂直速度归零的时刻
最高点高度h_max = vy² / (2g)只和初速度有关

关键在于参数标定。假设球在 (0, 2.0) 出手(单位是米,成年人出手高度 2 米左右),篮筐在水平 4.6 米、垂直 3.05 米的位置——这两个数字不是随便编的,是标准球场的罚球线距离和篮筐高度,用真实数据能让你对"这个球该用多大力"有直觉。

现在反推需要的初速度。设飞行时间 t = 1.0 秒,那么:

  • 水平速度 vx = 4.6 / 1.0 = 4.6 m/s
  • 垂直位移需要 3.05 − 2.0 = 1.05 米,即 y 方向净升高 1.05 米
  • 由 1.05 = vy · 1.0 − ½ · 9.8 · 1.0²,解得 vy = 1.05 + 4.9 = 5.95 m/s

验算一下最高点:h_max = 5.95² / (2 × 9.8) ≈ 1.81 米,也就是球最高会升到出手点上方 1.81 米,即离地 3.81 米,比篮筐高出 76 厘米。这个"高出筐口再下落"的弧线正是投篮该有的形状——如果算出来最高点刚好在筐口高度,那说明球是平着撞进去的,视觉上会很别扭。

这套反推方法可以直接写进代码,做成"给定目标和时间求初速度"的辅助函数。开发期用它来生成推荐参数,正式玩的时候把时间 t 藏起来,让玩家自己找。我把这个函数叫solveVelocity(x0, y0, x1, y1, t),在 C 和 JS 版本里都保留着,调参时省了大量试错。

2.2 篮筐判定:矩形碰撞是最容易写错的地方

新手最容易犯的错是把篮筐当成一个矩形,球心进矩形就算进球。这样做的后果是:球从下方穿上来会被判进球,球从侧面撞进去也会被判进球,甚至球贴着筐沿滑过去也算进。实际篮球规则是球必须从上往下穿过筐口平面。

正确做法是分两步。第一步判"是否穿越筐口平面":记录上一帧球心的 y 和这一帧的 y,如果上一帧 y 在筐口平面之上(世界坐标里 y 更大),这一帧 y 在筐口平面之下,说明发生了自上而下的穿越。第二步判"穿越点是否在筐口水平范围内":用线性插值算出精确的穿越横坐标,判断它是否落在筐口左右端点之间。

// 伪代码,两步判定缺一不可 if (prevY > rimY && currY <= rimY) { // 自上而下穿越 const t = (prevY - rimY) / (prevY - currY); const crossX = prevX + (currX - prevX) * t; // 精确穿越点 if (crossX > rimLeft && crossX < rimRight) return SCORE; }

用插值而不是直接用当前帧的 x,是因为高帧率下球一帧能移动好几个像素,直接取当前帧位置会有"穿模"误判。我实测过:60 帧下球速快时,一帧位移能到 0.15 米,差不多是筐口半径的一倍,不插值的话进球判定会飘忽不定,玩家会觉得"明明进了却没算"。

筐沿本身要当成实体处理。我的做法是把筐口左右两端各视为一个半径 0.03 米的小圆(相当于筐沿的截面),球撞上去就反弹。反弹用最朴素的方法:沿碰撞法线把速度反射,再乘一个 0.6 的衰减系数。

注意:筐沿反弹不要做成完全弹性,否则球会在筐口来回弹五六次,判定逻辑会陷入"到底算不算进"的混乱。衰减到 0.5 以下,球碰一次就基本掉出去了。

2.3 决定手感的隐藏参数:重力、最大速度与阻尼

真正决定手感的不是公式,是那几个你写在常量区的数字。我把它们列出来,附上我反复试出来的区间:

参数建议区间调大的效果调小的效果
重力 g9.8 ~ 20 m/s²弧线更陡、飞行更快、更难瞄准球飘、滞空久、像月球
初速度上限12 ~ 18 m/s操作空间大但容易乱飞力度不够,够不到筐
筐沿反弹衰减0.4 ~ 0.6球黏在筐口反复弹碰一下就掉,判定干净
拖拽灵敏度0.005 ~ 0.012稍微一拉就满力,难精细控制要拉很长距离,手机上够不着

重力这个参数特别值得说。用真实 9.8 会得到一个"很真实但很慢"的游戏——球在空中要飞一秒多,玩家等得不耐烦。绝大多数投篮小游戏实际用的是 15 到 20 之间的重力值,弧线更陡、节奏更快,看起来反而更"爽"。这不是物理错误,是游戏设计取舍。我在 Canvas 版本里用的是 g = 18,配上 1.0 到 1.4 秒的飞行时间,节奏刚好。

另一个隐藏参数是出手点高度随拖拽的变化。如果你做成"拉得越长,出手角度越平",玩家会很难受;更符合直觉的是"出手角度由拖拽方向决定,力度由拖拽长度决定",两个维度解耦。这一点在输入处理章节会展开。

3. C 语言版本:从控制台字符画开始把模型跑通

3.1 世界坐标与屏幕坐标必须分离

控制台版本最值钱的一课,是逼你建立"世界坐标"和"屏幕坐标"两层结构。世界坐标用米做单位,向上为正,描述物理真实;屏幕坐标用字符行列,向右向下为正,描述显示。中间靠一个映射函数转换。

#define MAP_W 60 // 字符画宽度 #define MAP_H 24 // 字符画高度 #define VIEW_W 8.0 // 视野宽度(米) #define VIEW_H 6.0 // 视野高度(米) int worldToScreenX(double x) { return (int)(x / VIEW_W * MAP_W); } int worldToScreenY(double y) { return MAP_H - 1 - (int)(y / VIEW_H * MAP_H); }

这两行看着简单,但如果你一开始就用像素/字符坐标直接做物理运算,后面想把视野拉宽、想在 Canvas 上复用,全部要重写。我在第一次写的时候就吃过这个亏:整个物理部分用的是字符行列,重力设成"每帧下落 2 行",结果换个分辨率就完全不对了。

字段设计也建议一次到位。球需要位置、速度、是否在飞、是否已经结算;篮筐需要位置、筐口半宽、是否被击中。用结构体把它们分开,别用一堆并列的全局变量。

typedef struct { double x, y; // 世界坐标(米) double vx, vy; // 速度(米/秒) int state; // 0=待发射 1=飞行中 2=已结算 } Ball; typedef struct { double x; // 筐口中心横坐标 double y; // 筐口高度(固定 3.05) double halfW; // 筐口半宽,约 0.225 米 int hits; // 本局进球数 int shots; // 本局出手数 } Hoop;

半宽用 0.225 米是标准筐口内径 45 厘米的一半。这个数字在 C 版本里建议老老实实用真实值,因为字符画分辨率低,筐口画出来大概就是 2 到 3 个字符宽,判定太宽松会显得"随便扔就进"。

3.2 主循环与输入:控制台版的固定帧率

控制台没有 requestAnimationFrame,帧率靠延时控制。最朴素的做法是循环里做一次物理更新、一次渲染、然后 Sleep(16) 凑 60 帧。但 Sleep 的精度在常见系统上只有 15 毫秒左右,实际帧率会掉到五十几,物理就会跑慢。想稳一点就记录时间戳,算出真实的 dt 再喂给物理更新。

#include <stdio.h> #include <math.h> #include <conio.h> #include <windows.h> int main(void) { Ball ball = { 1.0, 2.0, 0, 0, 0 }; Hoop hoop = { 5.6, 3.05, 0.225, 0, 0 }; const double G = 9.8; while (1) { if (_kbhit()) { int key = _getch(); if (key == ' ' && ball.state == 0) { // 固定角度 55 度,力度由随机或固定值决定 double speed = 8.0; double rad = 55.0 * 3.14159265 / 180.0; ball.vx = speed * cos(rad); ball.vy = speed * sin(rad); ball.state = 1; hoop.shots++; } if (key == 'q') break; } if (ball.state == 1) { const double dt = 1.0 / 60.0; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.vy -= G * dt; if (ball.y <= 0) { // 落地 ball.y = 0; ball.state = 2; } } system("cls"); // 清屏后重绘 // ... 渲染逻辑,见下一节 Sleep(16); } return 0; }

这段代码依赖了 Windows 下的 conio.h 和 windows.h,换到类 Unix 环境需要把按键读取换成 termios 的原始模式,把清屏换成 ANSI 转义序列\033[2J\033[H。这也是我建议新手先在熟悉的平台上跑通、再考虑移植的原因——被环境问题卡住会消磨掉全部热情。

3.3 渲染:字符画的排布技巧与坑

控制台渲染的核心难点是"怎么把球和筐画在同一张字符网格上"。我的做法是先构造一个二维字符数组当画布,全部填成空格,然后把每个实体往对应位置写字符,最后一次性输出。

char screen[MAP_H][MAP_W + 1]; void render(const Ball *b, const Hoop *h) { for (int r = 0; r < MAP_H; r++) { memset(screen[r], ' ', MAP_W); screen[r][MAP_W] = '\0'; } // 画地面 for (int c = 0; c < MAP_W; c++) screen[MAP_H - 1][c] = '_'; // 画篮筐(两个竖线 + 一条横线表示筐口) int hx = worldToScreenX(h->x); int hy = worldToScreenY(h->y); if (hy >= 0 && hy < MAP_H) { screen[hy][hx - 1] = '['; screen[hy][hx + 1] = ']'; screen[hy][hx] = '-'; } // 画球 int bx = worldToScreenX(b->x); int by = worldToScreenY(b->y); if (by >= 0 && by < MAP_H && bx >= 0 && bx < MAP_W) screen[by][bx] = 'O'; for (int r = 0; r < MAP_H; r++) printf("%s\n", screen[r]); printf("Shots: %d Hits: %d Score: %d\n", hp.shots, hp.hits, hp.hits * 2); }

这里有个必须提醒的坑:字符的宽高比不是 1:1。终端里一个字符大约是宽 8 像素、高 16 像素,也就是高是宽的两倍。如果你老老实实按 60×24 映射 8×6 米的视野,画出来会是一个被横向压扁的世界,球的弧线看起来特别平。解决办法要么是把视野高度设成宽度的一半(比如 8×4),要么在映射时人为补偿两倍。

提示:第一次画出来发现"抛物线不弯",九成是字符宽高比没补偿,不是物理算错了。这个坑我见过太多人踩。

3.4 状态机与回合流程

控制台版本没有按钮,回合流程全压在状态机里。我用的四个状态是:待发射、飞行中、结算中、本局结束。每次落地或进球后进入结算,短暂停顿再复位到待发射。这个"短暂停顿"很关键——立刻复位会让玩家来不及看清球是砸在筐沿还是从下面穿过去,反馈就断了。

#define STATE_IDLE 0 #define STATE_FLYING 1 #define STATE_RESULT 2 #define STATE_OVER 3 // 落地后 if (ball.state == STATE_FLYING && ball.y <= 0) { ball.y = 0; ball.state = STATE_RESULT; resultTimer = 60; // 停留 60 帧,约 1 秒 } // 结算倒计时 if (ball.state == STATE_RESULT) { if (--resultTimer <= 0) { if (hoop.shots >= 10) ball.state = STATE_OVER; else ball.state = STATE_IDLE, resetBall(&ball); } }

十次出手一局、进一球两分,这个配置在控制台版本里已经足够了。别小看这种极简玩法,字符画反而会因为"信息少"而让人专注在弹道判断上,我第一次把判定写对之后,连续在脑子里推算了十几个弧线才找到稳定进球的角度,那种成就感比后来做好看画面还强。

4. H5 Canvas 版本:从零到可玩的完整流程

4.1 画布缩放与设备像素比,第一行代码就要处理

Canvas 版本最大的坑不在游戏逻辑,在显示清晰度。如果你写死<canvas width="800" height="600">,在手机上打开就是糊的,在高分屏笔记本上也是糊的。原因是 CSS 像素和设备物理像素不是一个东西,一个 CSS 像素可能对应两个甚至三个物理像素。

正确的初始化流程是:CSS 用百分比或 flex 让画布铺满容器,JS 里读getBoundingClientRect()拿到实际 CSS 尺寸,再乘以devicePixelRatio设置 canvas 的 width/height 属性,最后用setTransform把绘制坐标系缩回去。

const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); let W = 0, H = 0, scale = 0, groundY = 0; function resize() { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 后续全部按 CSS 像素绘制 layout(); } function layout() { // 视野高度固定 5 米,宽度按比例自适应 scale = (H * 0.72) / 5.0; // 像素 / 米 groundY = H * 0.88; // 地面在屏幕 88% 高度处 }

setTransform这行千万别漏,漏了之后你的所有绘制坐标都得手动乘 dpr,代码会变得极难看。另外 resize 之后必须重新调用 layout,否则旋转屏幕、改变窗口大小后,篮筐位置会错位,出现"看得见筐但球怎么也进不去"的诡异现象。

scale的算法值得说一句:我用的是"固定视野高度"策略,即无论窗口多高,屏幕上看到的世界始终是 5 米高。这样在横屏手机上(矮)球会显得小,在竖屏上(高)球会显得大,但玩家的操作幅度感是一致的。反过来用"固定视野宽度"会让不同设备上的难度差异明显,因为投篮的关键是垂直距离。

4.2 帧率无关的物理积分

浏览器里requestAnimationFrame的回调间隔不是固定的,可能是 16.7ms,可能因为后台标签页掉到 100ms,也可能在 144Hz 屏幕上变成 6.9ms。如果你直接每帧执行x += vx,那么高刷屏上球会飞得比 60Hz 屏快一倍多,物理完全失控。

标准解法是固定步长 + 累加器:物理永远按 1/120 秒一步推进,渲染按实际帧率走,两者解耦。

const STEP = 1 / 120; let acc = 0, lastTime = 0; function loop(now) { if (!lastTime) lastTime = now; let dt = (now - lastTime) / 1000; lastTime = now; if (dt > 0.1) dt = 0.1; // 切标签页回来时防止一次性补帧 acc += dt; while (acc >= STEP) { update(STEP); acc -= STEP; } render(); requestAnimationFrame(loop); }

dt > 0.1这个钳位是保命的。用户切到别的标签页再切回来,浏览器会把积累的时间一次性补上,你的循环会执行几百次 update,球瞬间飞到宇宙尽头。截断到 0.1 秒,最多补 12 步,视觉上就是球停顿了一下继续飞,可接受。

物理部分和 C 版本几乎一样,只是坐标换成世界单位(米),绘制时再乘 scale:

function update(dt) { if (state !== 'flying') return; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.vy -= G * dt; // G = 18,游戏化重力 checkRimCollision(); checkScore(); if (ball.y < 0.12) { ball.y = 0.12; ball.vy *= -0.45; ball.vx *= 0.7; } }

地面反弹用 0.45 的恢复系数,横向速度额外乘 0.7 模拟摩擦,球弹两下就停,不会在地上来回滚太久。

4.3 拖拽蓄力:一个输入逻辑要吃掉鼠标、触摸和笔

Web 端做拖拽,最麻烦的是三套事件(mouse、touch、pointer)的坐标提取方式不一样。现在浏览器普遍支持 Pointer Events,一套pointerdown / pointermove / pointerup就能覆盖全部,前提是在 canvas 上加touch-action: none的样式,否则手机上拖拽会触发页面滚动。

#game { touch-action: none; display: block; width: 100%; height: 100%; }

蓄力的方向映射我试过两种。第一种是"从球往拖动方向拉",像弹弓,松开后球反方向飞出。第二种是"从按下点往上拖,拖得越远越用力"。第一种在手机上别扭,因为手指会挡住球;第二种更符合直觉,也更容易做轨迹预览。

具体实现里,力度归一化用一个最大拖拽距离MAX_DRAG = 220(CSS 像素),超出就按满力算:

let dragging = false, startPt = null, curPt = null; canvas.addEventListener('pointerdown', e => { if (state !== 'ready') return; dragging = true; startPt = { x: e.clientX, y: e.clientY }; curPt = { ...startPt }; canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener('pointermove', e => { if (!dragging) return; curPt = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener('pointerup', () => { if (!dragging) return; dragging = false; launch(); });

setPointerCapture这行很重要,手指滑出 canvas 边界后事件仍然能收到,否则玩家拖到屏幕外松手,球就永远卡在待发射状态。

launch()里的方向计算是手感的核心。我让方向由"起点到终点的向量"决定,但把向上的分量做强约束:

function launch() { let dx = curPt.x - startPt.x; let dy = startPt.y - curPt.y; // 向上为正 if (dy < 10) dy = 10; // 禁止向下发射 const len = Math.hypot(dx, dy); const power = Math.min(len / MAX_DRAG, 1); const speed = 8 + power * 10; // 8 ~ 18 m/s const ang = Math.atan2(dy, dx); ball.vx = Math.cos(ang) * speed; ball.vy = Math.sin(ang) * speed; state = 'flying'; trail.length = 0; }

速度区间 8 到 18 这个范围是用前面算的参数估出来的:罚球线距离 4.6 米,需要的最小初速度大概在 7.5 左右,留一点余量到 8;最大 18 是为了让"满力平射"能够打到三分线外的距离。这两个数字在不同屏幕尺寸下都要验证,因为你的视野高度固定但水平视野会变,三分线的位置可能跑到屏幕外。

4.4 轨迹预览:给多少才既有帮助又不破坏可玩性

前面提过,预览线我只画前 30%。实现方式是拿当前蓄力参数跑一次"影子积分",不做碰撞,只记录位置点,取前三分之一画成渐隐的虚线或者小圆点。

function previewTrajectory() { let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy; const pts = []; const dt = 1 / 60; for (let i = 0; i < 90; i++) { // 最多模拟 1.5 秒 x += vx * dt; y += vy * dt; vy -= G * dt; if (y < 0) break; if (i % 3 === 0) pts.push({ x, y }); } return pts.slice(0, Math.floor(pts.length * 0.3)); }

有两点要注意。一是这段影子积分必须用和真实物理完全相同的参数(同样的 G、同样的 dt),否则预览线会和实际轨迹对不上,玩家的信任感一次就崩。二是预览只在拖拽过程中显示,松手后立刻清掉,避免玩家"照着线调"变成机械操作。

提示:如果测试时发现"预览线明明穿过筐,球却 outside",先检查影子积分里有没有漏掉阻力项或者筐沿反弹。物理参数不一致是这个 bug 的唯一原因。

4.5 碰撞检测与得分反馈的实现细节

篮筐用两个"筐沿端点"表示,每个端点是一个半径 0.035 米的圆。球的半径 0.12 米。圆与圆碰撞判定用距离平方比较,避免开方:

function checkRimCollision() { for (const rim of [rimLeft, rimRight]) { const dx = ball.x - rim.x; const dy = ball.y - rim.y; const rr = ball.r + rim.r; if (dx * dx + dy * dy < rr * rr) { const dist = Math.hypot(dx, dy) || 0.0001; const nx = dx / dist, ny = dy / dist; // 沿法线反射 + 衰减 const vn = ball.vx * nx + ball.vy * ny; ball.vx -= (1.5) * vn * nx; ball.vy -= (1.5) * vn * ny; ball.vx *= 0.7; ball.vy *= 0.7; // 推出重叠 ball.x = rim.x + nx * rr; ball.y = rim.y + ny * rr; } } }

反射系数用 1.5 而不是 2,相当于恢复系数 0.5,球碰筐沿后会明显减速。这个数值我调了很多次,2 太弹,1.2 太黏,1.5 手感最像真的。

进球判定用上一节的穿越法,但要注意记录的是"上一物理步"的位置,不是"上一帧"。因为一帧里可能跑了 2 步物理,如果用帧位置做判定,就会漏掉中间那次穿越。

let prevX = 0, prevY = 0; function checkScore() { if (state !== 'flying') return; if (prevY > rimY && ball.y <= rimY) { const t = (prevY - rimY) / (prevY - ball.y); const cx = prevX + (ball.x - prevX) * t; if (cx > rimLeft.x && cx < rimRight.x && ball.vy < 0) { onScore(); } } prevX = ball.x; prevY = ball.y; }

ball.vy < 0这个额外条件是为了彻底堵死"从下往上穿"的情况。世界坐标向上为正,向下穿越时 vy 一定是负的。

onScore()里我做了三件事:加分、生成粒子、播放音效。粒子用简单的数组管理,每个粒子有位置、速度、生命值,生命值递减到 0 就移除。

function spawnParticles(x, y, n = 16) { for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const s = 1 + Math.random() * 3; particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 2, life: 1, max: 1, color: Math.random() > 0.5 ? '#ffb703' : '#fb8500' }); } }

音效这块有个必须注意的地方:移动浏览器不允许在没有用户交互的情况下播放音频。所以音频对象要在第一次 pointerdown 时初始化并解锁(播放一次静音或音量为 0 的片段),否则进球音效在手机上永远是哑的。

4.6 移动端适配与性能取舍

手机上跑 Canvas 游戏,最容易出问题的是"绘制太频繁"和"渐变用太多"。我在第一版里给每个进球粒子都画了径向渐变,结果在中端安卓机上直接掉到 20 帧。后来全改成纯色圆,帧率立刻回来了。

另一个常被忽略的点是shadowBlur。它看起来很高级,但它会触发离屏合成,在移动端是性能黑洞。篮筐的高光我用两层纯色圆叠加模拟,视觉差距不大,帧率差好几倍。

还有背景。如果你画了复杂的渐变背景,可以把它预渲染到一个离屏 canvas 上,之后每帧只 drawImage 一次,比每帧重新构建 Gradient 快得多。

5. 常见问题排查实录

下面这张表是我两套实现加起来的踩坑合集,按"现象—原因—解法"整理,遇到问题可以对号入座。

现象高频原因解决方向
球飞得比预期快/慢每帧更新未乘 dt,或 dt 单位用了毫秒统一秒为单位,固定步长积分
高刷屏上球速明显变快物理绑在 rAF 回调上引入累加器与固定 STEP
进球了但没加分穿越判定用了帧位置,丢中间步记录物理步的 prevX/prevY
从下方穿过筐也算进缺少 vy < 0 条件加垂直速度方向约束
球卡在筐沿抖动推出重叠逻辑缺失碰撞后把球推到表面外侧
球在筐口来回弹五次恢复系数过高衰减系数降到 0.5 左右
手机上画面模糊未处理 devicePixelRatioresize 时按 dpr 设置尺寸并 setTransform
旋转屏幕后筐位置错位resize 后未重算布局把 layout 抽成独立函数并调用
手机上拖拽触发页面滚动未设置 touch-actioncanvas 样式加 touch-action:none
松手后球卡住不动手指移出画布丢失事件使用 setPointerCapture
进球音效在手机上没声音频未在用户手势中解锁首次 pointerdown 时初始化音频
控制台字符画弧线很平字符宽高比未补偿视野高度设为宽度一半或映射补偿
切标签页回来后球瞬移累加器一次补了巨量帧dt 上限钳位 0.1 秒

除了表里的这些,还有一类问题不在代码里,在参数里。比如"怎么都投不进",很可能是你的筐口半宽设得太小(小于球半径的两倍),物理上球就塞不进去;"太容易进了",则是筐口半宽大于球半径四倍,几乎必进。这两个值一定要对着自己的球半径和画面比例手算一遍,别拍脑袋。

另外分享一个排查方法:加一个调试开关,按住某个键时把世界坐标系、球速、速度方向、筐口平面用辅助线画出来。很多看似"物理错了"的问题,一画辅助线就发现是渲染坐标算错了,物理根本没问题。这个开关我到现在写任何带物理的小游戏都会留,删代码的时候一起删,成本接近于零。

6. 难度曲线:让同一套物理参数撑住十关

6.1 用距离和高度差两个维度造难度

物理参数锁死之后,制造难度就只能靠改变篮筐位置。我用的两个维度是物理距离和出手高度差。距离越远需要的初速度越大,容错窗越窄;高度差越大(筐越高)需要的垂直速度越大,弧线必须更陡。

具体做法是把篮筐位置参数化:

const LEVELS = [ { x: 3.8, y: 3.05, wind: 0 }, { x: 4.6, y: 3.05, wind: 0 }, { x: 5.8, y: 3.05, wind: 0 }, { x: 5.2, y: 3.60, wind: 0 }, { x: 6.4, y: 3.05, wind: -1.2 }, { x: 6.8, y: 3.60, wind: 1.5 }, ];

第六关开始引入横向风力,就是每步物理给 vx 加一个常量加速度。这是最省的难度递增手段——不需要新素材、不需要新玩法,只是让玩家重新校准手感。但要注意风力必须有可视提示(背景飘动的线条、风向箭头),否则玩家只会觉得"这游戏抽风"。

6.2 连续进球奖励与挫败感控制

单纯加距离,玩家连输三把就退了。我加了一个连击机制:连续进球时分数倍率从 1 升到 1.5、2、3,但一旦失败就清零回到 1。这个机制的价值在于制造"要不要保守一点"的决策,而不是单纯加数值。

配套的是容错设计。第一关允许不进也过关(三次机会进一球即可),后面逐关收紧到三次必须进两次。这种梯度让新手有个缓冲期,不会在第一关就被劝退。

最后分享一个小技巧:把每一关的参数做成可热更新的配置对象,开发时用 URL 参数或者键盘快捷键切关,不要每次都从头点过去。我调试第七关的风力手感时,这个开关省了至少两个小时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 18:00:35

Playwright + AI 回归测试提效实战:三天变三小时

1. 三天变三小时&#xff0c;这事到底靠不靠谱先交代背景。我手头维护着一个中型 Web 项目&#xff0c;前端是 React 技术栈&#xff0c;后端 Java 接口&#xff0c;测试环境三套&#xff0c;回归用例手工加半自动加起来大概四百多条。每次发版前的回归测试&#xff0c;三个人轮…

作者头像 李华
网站建设 2026/11/3 18:00:33

跨源数据清洗实战:MCP架构与金融风控应用

1. 项目背景与核心痛点 数据清洗这个活&#xff0c;干过的都知道有多酸爽。去年我们团队接手一个跨银行的风控项目&#xff0c;光是处理不同来源的客户数据就耗了三个月——某银行的日期格式是"DD/MM/YYYY"&#xff0c;另一家偏偏用"YYYY年M月D日"&#xf…

作者头像 李华
网站建设 2026/11/3 18:00:18

并查集核心原理:集合运算、路径压缩与按大小合并

集合这个概念&#xff0c;几乎每个学编程的人都觉得自己懂——数学课上就学过&#xff0c;一堆元素放一起嘛。可真到了要写代码去维护一堆"谁和谁是一伙的"关系时&#xff0c;很多人第一反应还是开个二维数组或者给每个元素贴个集合编号&#xff0c;然后每次合并就全…

作者头像 李华
网站建设 2026/11/3 17:59:29

SSM+Vue流浪动物救助平台:真实业务驱动的技术选型实践

简介&#xff1a;本资源是一套完整的毕业设计级流浪动物救助平台管理系统&#xff0c;面向计算机专业本科生及Java全栈初学者&#xff0c;聚焦公益场景下的Web系统开发实践。系统采用前后端分离架构&#xff1a;前端基于Vue 2.x与Element UI实现响应式交互&#xff0c;后端依托…

作者头像 李华
网站建设 2026/11/3 17:59:09

Jev 与 TypeSafe AI:把大模型调用变成类型安全的函数调用

1. 从"函数调用"这个比喻说起&#xff1a;Jev 到底想解决什么问题第一次看到"把前沿智能变成一次函数调用"这个说法&#xff0c;我的反应是&#xff1a;又是一个包装概念的项目。但仔细拆开 Jev 和它背后的 TypeSafe AI、System One Model 这套组合之后&am…

作者头像 李华
网站建设 2026/11/3 17:58:01

GPL协议解析:开源许可证对比与合规实践

1. GPL协议的历史渊源与技术哲学自由软件运动始于1983年&#xff0c;当时理查德斯托曼&#xff08;Richard Stallman&#xff09;在MIT人工智能实验室工作时&#xff0c;亲历了软件行业从开放共享转向商业闭源的转变过程。这种转变直接催生了GNU项目和GPL协议的诞生。斯托曼的核…

作者头像 李华