简介:面向Processing初学者与视觉编程爱好者的类FlappyBird小游戏源码包,适合用来练习绘图、动画循环、碰撞检测、键盘输入与面向对象组织。项目将小鸟、管道和通用逻辑拆分为独立模块,配合图片与音效素材,较完整地呈现角色运动、随机障碍生成、得分判定与游戏状态切换的实现思路;读者可在其基础上调整参数、替换素材或扩展玩法,借此理解小型交互游戏从绘制到逻辑闭环的基本结构,也可作为课堂示例拆解。压缩包共12个文件,以4个pde源码为主,另有3张png图像、3个mp3与1个wav音频及1个properties配置文件,整体约2.48MB,源码、素材与项目设置相对齐全。已有1041人学习,适合Processing入门者自学练手,也便于教师布置图形交互作业时参考。
1. 用 Processing 复刻 FlappyBird,难的不是画鸟
用 Processing 写一个类 FlappyBird 的横版躲避小游戏,网上能搜到的版本大多两百行上下,图能跑、鸟能飞,可真拿去给人玩,十分钟内必然被问三句话:我明明没碰到管子怎么就死了?重开一局分数怎么还挂着上一局的?为什么在我机器上特别飘、换台电脑又变得特别难?这份源码包正面处理的就是这三件事——重力、跳跃、管道、碰撞、状态机各自拆成互不越界的几块,参数集中在文件头部,改一个数字就能改手感。适合两类人:刚学完 setup/draw 想找个完整小项目练手的新手,以及课程设计要做横版躲避 Demo、需要一份能讲清每行代码为什么这么写的骨架的从业者。下面按“先把物理立住、再让管道可复用、最后把状态和参数收干净”的顺序拆。
2. 主循环与重力:把“手感”拆成四个可调参数
手感这东西听起来玄学,落到代码里其实就是四个数字:重力多大、点击给多大冲量、最大下落速度多少、点击有没有冷却。四个数字定死之前,先得把数据结构和主循环摆对,否则后面每加一个功能都在还债。
2.1 为什么用 PVector 存速度,而不是一个 float vy
Processing 的坐标系原点在左上角,y 轴向下为正,所以“向上跳”必须给负值——这个符号是最容易写反的地方。我一般把方向直接编进常量名里,JUMP_IMPULSE写成 -7.6 并在旁边注释“负值=向上”,比事后查 bug 便宜得多。
用PVector而不是三个 float,理由有三条:一是位置和速度天然成对,重置时pos.set(...)、vel.set(0,0)一行解决;二是以后想加横向风、管道推速、震屏偏移,直接pos.add(wind)就行,不用再补一堆变量;三是PVector是可变对象,pos.add(vel)原地修改、不产生新对象,在每帧都跑的 draw 里不会给 GC 添麻烦。唯一要小心的是别写成pos = pos.add(vel)这种看着像“产生新向量”的写法,容易让后面接手的人误判语义。
// 坐标原点在左上角,y 轴向下为正:向上跳必须给负值 final float GRAVITY = 0.42; // 每帧(60fps 基准)叠加到速度上的加速度 final float JUMP_IMPULSE = -7.6; // 点击瞬间的垂直速度,负值 = 向上 final float MAX_FALL_SPEED = 11.0; // 终端速度,防止掉帧时一帧穿过管道 final float BIRD_X = 120; // 鸟的横坐标固定,世界向左滚动 PVector pos; PVector vel; float gravity; // 用变量而不是直接用常量,方便第 6 章在线调参 void setup() { size(480, 720); frameRate(60); gravity = GRAVITY; resetBird(); } void resetBird() { pos = new PVector(BIRD_X, height * 0.42); vel = new PVector(0, 0); }逻辑说明:pos只存位置,vel只存速度,重力永远不直接改pos,而是先进入vel、再由vel进入pos。这个“加速度→速度→位置”的两级积分是所有运动的基础骨架,顺序写反(先加位置再加加速度)会让鸟在低帧率下出现明显的台阶感。resetBird()单独抽出来,是为第 4 章的重开逻辑留接口,避免重开时到处复制两行初始化。
参数说明:GRAVITY决定松手后的“沉”度,0.35 以下像月球漫步,0.6 以上基本救不回来;JUMP_IMPULSE决定一次点击抬多高,绝对值越大越“弹”;MAX_FALL_SPEED是安全阀,后面碰撞那章会用到它;BIRD_X固定不动,因为滚动的是世界不是鸟,这一点跟很多横版卷轴的做法一致,能让碰撞判定只在一个方向上变化。
提示:
frameRate(60)只是“请求”,不是承诺。窗口被遮挡、图片过大、后台开了别的程序,实际帧率都会掉,这就是下一节要处理的问题。
2.2 归一化系数与固定步长:两种让手感不随帧率漂移的写法
如果直接写vel.y += GRAVITY; pos.y += vel.y;,在 60fps 下一切正常,可一旦实际帧率掉到 30,同样的重力每秒只累加 30 次,鸟的下落明显变慢,玩家会觉得“这游戏今天特别简单”。这种随设备漂移的手感是最难复现的 bug,因为它在你机器上永远不出现。
写法 A 是系数归一化,一行搞定:
void draw() { float k = 60.0 / max(1.0, frameRate); // 以 60fps 为基准的缩放系数 k = constrain(k, 0.5, 3.0); // 卡顿太久时不至于一帧跳出去 vel.y += gravity * k; vel.y = min(vel.y, MAX_FALL_SPEED); // 终端速度 pos.y += vel.y * k; }逻辑说明:k的物理含义是“这一帧相当于几个标准帧”,帧率越低 k 越大,重力累加得越多。constrain那行是后悔药——窗口被最小化再还原时,frameRate可能瞬间变成一个很小的值,k 会变成几十,鸟一帧就飞出屏幕,夹到 3 倍以内基本能兜住。
参数说明:这种写法有个理论缺陷,重力和位移同时乘 k,速度项实际被放大了 k² 量级的误差,帧率波动大时轨迹会有细微差别。对 FlappyBird 这种只有竖直一维运动的场景完全够用,但如果后面要加抛物线弹道、要算落点,就得换写法 B。
写法 B 是固定步长累加器,把物理推进和渲染彻底解耦:
final float FIXED_DT = 1.0 / 60.0; // 物理固定按 1/60 秒推进 float accumulator = 0; int lastMillis = 0; void draw() { int now = millis(); float frameTime = (now - lastMillis) / 1000.0; lastMillis = now; frameTime = min(frameTime, 0.25); // 防止窗口挂起后一次性补几百帧 accumulator += frameTime; while (accumulator >= FIXED_DT) { stepPhysics(FIXED_DT); accumulator -= FIXED_DT; } render(); // 渲染每帧只做一次,直接用 pos 画 } void stepPhysics(float dt) { vel.y += GRAVITY * 60 * dt; // GRAVITY 按“每帧”定义,这里换算成每秒 vel.y = min(vel.y, MAX_FALL_SPEED); pos.y += vel.y * 60 * dt; }逻辑说明:不管渲染帧率是多少,物理永远以 1/60 秒为单位推进,一帧内可能要跑 0 次(高刷屏)也可能跑 3 次(卡顿),但每一步的数值完全一致,所以同一串点击时间戳在任何机器上都能复现出同一条轨迹。min(frameTime, 0.25)是必须的,否则断点调试回来那一帧会一次性补上几十步物理,鸟直接穿模。
参数说明:FIXED_DT不建议小于 1/120,否则一步的位移只有零点几像素,属于浪费;也不建议大于 1/45,否则快速下落时单步位移过大,配合第 3 章的碰撞会漏判。
| 方案 | 代码量 | 轨迹一致性 | 适用场景 |
|---|---|---|---|
| 系数归一化 | 3 行 | 一般,低帧率下有偏差 | 单机小 Demo、只想快速跑通 |
| 固定步长 | 20 行 | 强,同输入必同轨迹 | 要做回放、要做自动测试、要交付 |
2.3 跳跃冲量、终端速度与连点冷却
跳跃有两种写法,差别比想象中大。vel.y = JUMP_IMPULSE是硬置,每次点击后鸟的上升轨迹完全一致,玩家能形成肌肉记忆;vel.y += JUMP_IMPULSE是叠加,连点会累加速度,鸟能一路窜到屏幕顶端。早期我图省事用了叠加,结果玩家全程狂点就能过,管道形同虚设,这是最典型的一次翻车。
硬置还要配一个冷却,否则鼠标事件和键盘事件在同一帧里都触发时,等于白按一次:
int lastJumpMillis = -1000; final int JUMP_COOLDOWN = 90; // 毫秒,防止同帧多输入事件叠加 void doJump() { if (millis() - lastJumpMillis < JUMP_COOLDOWN) return; lastJumpMillis = millis(); vel.y = JUMP_IMPULSE; // 硬置,不是 += } void keyPressed() { if (key == ' ') doJump(); } void mousePressed() { doJump(); }逻辑说明:冷却窗口取 90ms 左右,比人类最快连点间隔(约 100ms)稍短,正常操作不会觉得卡,但能把同一帧的重复事件吃掉。lastJumpMillis初值给 -1000 而不是 0,是为了让游戏开始时的第一次点击不被冷却挡住。
再补两个视觉细节。第一是终端速度,它除了防止穿模,还让鸟在长时间不下落时有个稳定姿态,不会越掉越快变成自由落体。第二是倾斜角,用速度映射旋转,玩家的操作反馈立刻直观:
// 把速度映射成机身角度:上升时抬头,下落时低头 float angle = map(vel.y, JUMP_IMPULSE, MAX_FALL_SPEED, -0.42, 1.45); angle = constrain(angle, -0.42, 1.45); // -0.42rad≈-24°,1.45rad≈83°这两个数字(-24° 和 83°)是试出来的:抬得太高像在倒着飞,低得不够又看不出俯冲。它们不影响任何逻辑,纯粹是让判定和视觉对得上的心理暗示——玩家觉得“鸟头朝下冲进管子”比“鸟平平地贴上去”更容易接受死亡。
3. 管道生成与碰撞:对象池、开口公式和一个收缩系数
管道是这类游戏里唯一会持续增长的东西,写法不对就会出现“越玩越卡”和“明明没碰到却死了”两大顽疾。前者是内存问题,后者是数据不同源问题,两个都得从数据结构层面解决。
3.1 用对象池代替 remove/add
最直觉的写法是每帧pipes.add(new Pipe())、出屏后pipes.remove(0)。ArrayList的remove(0)会把后面所有元素整体搬移一位,add在容量不足时还要扩容复制,管道保持在屏幕上 3~4 根、每 2 秒轮换一次,看着不频繁,但配合每帧的遍历和第 4 章的碰撞,实测每过七八根管子就有一次可感知的顿挫。
改成固定长度数组加环形复用就干净了:
class Pipe { float x; // 管道左边缘的世界坐标 float gapY; // 开口中心的纵坐标 float gapH; // 开口总高度 boolean scored; // 是否已计分,见 4.2 } final int PIPE_COUNT = 4; Pipe[] pipes = new Pipe[PIPE_COUNT]; float pipeW = 78; // 管道宽度 float gapH = 180; // 开口高度 float spacing = 240; // 相邻两根管道的水平间距 void initPipes() { for (int i = 0; i < PIPE_COUNT; i++) { pipes[i] = new Pipe(); pipes[i].x = width + 120 + i * spacing; pipes[i].gapY = random(gapH * 0.6, height - gapH * 0.6); pipes[i].gapH = gapH; pipes[i].scored = false; } } void updatePipes(float dx) { for (Pipe p : pipes) { p.x -= dx; // 世界向左滚动 if (p.x + pipeW < 0) { // 完全出屏才回收 p.x += PIPE_COUNT * spacing; // 接到队尾,而不是 new 一个 p.gapY = random(p.gapH * 0.6, height - p.gapH * 0.6); p.scored = false; } } }逻辑说明:回收时用p.x += PIPE_COUNT * spacing而不是p.x = width,这是整个对象池能稳住的关键。如果重置到width,一旦某帧因为卡顿导致dx变大,队尾的管道会和刚回收的管道挤在一起甚至重叠;加上“一整轮的间距”之后,队列的相对位置永远均匀,间距不会因为帧率波动而漂移。
参数说明:PIPE_COUNT取 4 是算出来的,屏幕宽度 480 除以间距 240 约等于 2,再多留两根在屏幕右侧待命,保证任何时刻都有管子接上。pipeW取 78 是视觉宽度,碰撞时用的是另一套收缩后的判定框,两者刻意不同源但共用x和gapY——只要绘制和判定读的是同一份gapY/gapH,就不会出现画一套算一套的穿模。
3.2 开口高度与水平间距:难度曲线的三个旋钮
真正决定难度的是三个量:gapH(开口高度)、spacing(水平间距)、dx(每帧滚动像素)。前两个是空间,后一个是时间,三者可以换算成一个玩家能感知的指标——反应时间:
从管道进入屏幕右缘到抵达鸟的位置,可用的秒数 ≈ (width - BIRD_X) / (dx * 60)
按 480 宽、鸟在 120、dx 取 2.2 计算,大约是 2.7 秒;当 dx 涨到 3.4,只剩 1.76 秒。低于 0.8 秒基本就是背板而不是反应,不适合作为起始难度。这个换算看着简单,但它把“我调了速度好像变难了”变成“反应窗口从 2.7 秒压到 1.8 秒”,调参时心里有数。
难度递增用一个 0~1 的进度量做线性插值,30 分封顶:
void updateDifficulty() { float d = constrain(score / 30.0, 0, 1); // 0~1,30 分后不再变难 gapH = lerp(190, 140, d); // 开口 190 -> 140 scrollSpeed = lerp(2.2, 3.4, d); // 每帧像素 spacing = lerp(250, 210, d); }逻辑说明:lerp的三个参数是起点、终点、进度,写起来比手算start + (end - start) * d清楚。注意这里只改“下一根被回收的管道”的参数,已经在屏幕上的管道保持原样——如果每帧都去改所有管道的gapH,玩家会看到管子开口像呼吸一样收放,那才是真正的玄学。
参数说明:gapH的下限 140 是硬边界,再低就会出现“鸟的判定框加上管道宽度已经不允许任何操作余量”的情况,理论上还能过,实际全靠运气。spacing的下限 210 是为了保证两根管子不会同时在鸟的位置附近形成夹击。
3.3 碰撞:AABB 加一个 0.75 收缩系数
碰撞用轴对齐包围盒(AABB)就够了,不要去做像素级检测。像素级不但慢,而且会把贴图上的描边、阴影算进去,玩家看到的是“我贴着边过去了”,判定却认为撞上,这种不公平感比直接撞死更劝退。
// 鸟的判定框比贴图小 25%,这是让玩家觉得“公平”的关键 final float HIT_SCALE = 0.75; boolean hitPipe(Pipe p) { float halfW = 24 * 0.5 * HIT_SCALE; float halfH = 24 * 0.5 * HIT_SCALE; // 横向不相交,直接返回,省掉后面的纵向判断 if (pos.x + halfW < p.x || pos.x - halfW > p.x + pipeW) return false; float topBottom = p.gapY - p.gapH * 0.5; // 上管的底边 float botTop = p.gapY + p.gapH * 0.5; // 下管的顶边 if (pos.y - halfH < topBottom) return true; // 撞上管 if (pos.y + halfH > botTop) return true; // 撞下管 return false; }逻辑说明:先做横向粗筛再做纵向判断,是因为横向不相交的概率远高于纵向,提前返回能省掉大部分计算。上管的绘制高度就是topBottom,下管从botTop画到height,判定和绘制共用这两个变量,所以只要gapY和gapH不被别的逻辑偷偷改掉,就永远不会出现“看着没碰到却死了”。
参数说明:HIT_SCALE是本项目里最值得单独拿出来讲的一个数。取 1.0 时判定框和贴图完全重合,玩家会觉得“擦边就死”;取 0.75 相当于允许鸟的像素边缘压进管道 3 像素左右,视觉上有惊无险,操作反馈立刻变好。低于 0.6 就反过来了,看着撞进去却没死,信任感崩得比撞死还快。
注意:判定框收缩只缩鸟,不要缩管道。缩管道会让开口看起来比实际大,玩家对距离的判断会持续失准。
4. 状态机与计分:READY / PLAYING / GAME_OVER 三态怎么切
把状态和计分单拎一章,是因为这类小游戏的 bug 有七成出在这里:重开不清零、一次过管加两分、死亡后还在跑物理。用布尔量拼状态是万恶之源。
4.1 用 enum 管状态,别用三个 boolean 拼
boolean playing、boolean gameOver、boolean started三个变量一起用,必然出现playing && gameOver同时为真的中间态。我见过最典型的一次是重开时忘了清gameOver,鸟在 READY 界面就开始自由落体,玩家还没反应过来已经死了。enum加switch强制每次只走一个分支,这类 bug 从结构上就不存在。
enum GameState { READY, PLAYING, GAME_OVER } GameState state = GameState.READY; void update() { switch (state) { case READY: // 待机时鸟做上下浮动,不施加重力 pos.y = height * 0.42 + sin(millis() / 300.0) * 8; break; case PLAYING: stepPhysics(FIXED_DT); updatePipes(scrollSpeed); updateDifficulty(); if (hitAnyPipe() || pos.y > height - 12) { state = GameState.GAME_OVER; } checkScore(); break; case GAME_OVER: // 只落不飞:掉到接近底部就停住 if (pos.y < height - 20) { stepPhysics(FIXED_DT); } break; } }逻辑说明:READY 状态下完全不碰vel,鸟的位置每帧由正弦函数直接给,这样玩家看到的是一只悬停的鸟,而不是一只正在下坠的鸟。PLAYING 分支里先算物理再算管道再判碰撞,顺序不能反——先判碰撞再移动位置,会出现判定用的是上一帧坐标的情况,表现为“穿过去半根才死”。GAME_OVER 分支保留物理但不接受输入,鸟会继续下坠一段再停住,这个细节让死亡有重量感。
参数说明:height - 12是落地判定的阈值,取 12 是鸟贴图半高,配合height - 20的停止线,让鸟落在地上而不是陷进去。millis() / 300.0里的 300 是浮动周期,改小变快、改大变得更慢。
4.2 计分:用 scored 标志位,不要用距离阈值
见过不少实现写成if (abs(pos.x - p.x) < 5) score++;,这行代码在管道每帧移动 2~3 像素的情况下会漏判(一帧跨过去了)或重复判(阈值太大,连续两帧都满足)。正确做法是给每根管道一个“是否已计分”的布尔位,条件写成“鸟已经越过管道的右边缘”,天然幂等。
void checkScore() { for (Pipe p : pipes) { if (!p.scored && pos.x > p.x + pipeW) { p.scored = true; // 置位,保证一根管道只计一次 score++; } } }逻辑说明:pos.x > p.x + pipeW是“整只鸟(含判定框)都过了管道右边缘”的意思,比用中心点判断更符合直觉。p.scored在 3.1 的回收逻辑里被重置为false,所以同一根管道被复用后还能再计一次分,整套流程闭环。
参数说明:score建议单独维护,不要复用frameCount或任何全局计数。分数一旦和帧数绑在一起,重开就会出问题——frameCount是自 sketch 启动以来的累计值,永远不会归零。
4.3 重开一局要重置的清单
重开是这类游戏最容易留残影的地方。我一般写一个resetAll()作为唯一入口,把所有会变的东西列成清单,一条一条对着写,而不是散落在keyPressed里。
void resetAll() { score = 0; scrollSpeed = 2.2; gapH = 190; spacing = 250; resetBird(); // pos / vel initPipes(); // 管道的 x / gapY / gapH / scored lastJumpMillis = -1000; stateStartFrame = frameCount; state = GameState.READY; }逻辑说明:清单式重置的好处是新增字段时一眼能看出“这里要补一行”。管道必须走initPipes()而不是只清scored,因为上一局的管道位置会留在屏幕中间,重开后鸟直接撞上去。
参数说明:stateStartFrame是自己记的帧号,用途见下。
注意:
frameCount是自 sketch 启动以来的全局帧数,重开不会清零。任何“每 30 帧闪一次”的动画都要写成frameCount - stateStartFrame,直接用frameCount % 30会在重开后表现得不规律。
5. 五条血泪踩坑:从“能跑”到“能玩”之间隔着什么
下面这五条是照着别人代码改或者自己从头写都会撞上的,前三条偏画面与性能,后两条偏逻辑判定。每条按“现象→原因→解决”说,能对号入座就不用重走一遍。
5.1 画面与性能类
第一条:越玩越卡,七八根管子之后明显顿一下。现象是前面几根很顺,过了十来根开始每隔一两秒卡一帧,帧率显示从 60 掉到 45 又弹回来。原因是每帧在draw()里new Pipe()或者new PVector(),管道轮换时对象不断创建又丢弃,垃圾回收周期被打断,表现为规律性的顿挫。解决是把管道改成 3.1 的固定数组复用,PVector只在resetBird()里创建一次,后续全部用set()和add()原地修改。改完再测,帧率曲线会变成一条直线。
第二条:第一帧图片黑块,或者鸟身后拖着一条残影。现象是启动瞬间鸟的位置是一块黑色矩形,或者飞行时背后留下一串半透明残像。原因有两个:一是loadImage()写在draw()里,第一帧图片还没解码完就画了;二是draw()开头漏了background(),上一帧没被覆盖。解决是把所有loadImage()挪进setup()并检查返回值是否为 null,同时在draw()第一行无条件调用background()。如果确实需要拖影效果,用半透明矩形叠一层,别指望不清屏。
第三条:判定框和看到的鸟对不上,明明擦过去了却说撞上。现象是视觉上鸟和管道还有一两个像素的间隙,游戏却判定死亡,或者反过来。原因通常是渲染用了P2D或做了scale()缩放,而碰撞判定还在原始坐标系里算。解决是把碰撞计算统一放在世界坐标里做,需要缩放或旋转时用pushMatrix()/popMatrix()把变换包起来,画完就弹栈,绝不让变换泄漏到判定逻辑里。
5.2 逻辑与输入类
第四条:鸟从管道中间穿过去,没触发死亡。现象是偶尔一次下落特别快,鸟直接出现在管道另一侧,判定完全没反应。原因是掉帧导致单帧位移超过了判定框高度——vel.y达到 20 以上时,一帧就能移动 20 像素,而鸟的判定框高度只有 18 像素,中间那一小段被整帧跳过了。解决分两层:先把MAX_FALL_SPEED压到 11 以内,保证单帧位移小于判定框高度;再在stepPhysics里做子步进,位移大于半高就拆成多次小步,每步都判一次碰撞。
第五条:一次过管加了两分,或者重开一局分数接着上一局算。现象有两种,但根子是同一个。前者是计分条件写成了“鸟在管道范围内”,这个条件连续多帧为真,每帧都加一次分;后者是score没进resetAll()。解决是把计分改成 4.2 的标志位写法,条件收紧到“越过右边缘”,并把score = 0明确写进重置清单。顺手把scored也一起重置,否则复用的管道会带着上一局的计分状态。
还有一个容易被忽略的输入问题:想实现“按住鼠标连跳”的人会发现按住不动鸟不飞。mousePressed()函数是按下瞬间触发一次,按住不会重复调用,这是符合预期的;要连跳得自己判断mousePressed这个布尔变量并在draw()里按冷却节奏触发doJump()。这个坑跟判定无关,但很多人第一次做都会卡在这儿。
6. 把参数拧成一张表:调手感、验证与一份可复现的自检流程
前面所有常量都是“我觉得合适”的值,真正交付前必须按目标设备调一轮。我的习惯是把可调参数从final常量挪到普通成员变量,用一个调试面板实时改,边玩边调,比改一次重跑一次快十倍。
| 参数 | 起始值 | 调整步长 | 观察什么 |
|---|---|---|---|
gravity | 0.42 | ±0.03 | 松手后下坠的“沉”度,太快会来不及点 |
JUMP_IMPULSE | -7.6 | ±0.3 | 一次点击抬升的高度,太小会被管道憋死 |
MAX_FALL_SPEED | 11 | ±1 | 掉帧时会不会穿管,超过判定框半高就危险 |
gapH | 190→140 | ±10 | 新手第一次能不能过,140 是硬下限 |
spacing | 250→210 | ±10 | 两根管之间的喘息时间 |
scrollSpeed | 2.2→3.4 | ±0.1 | 整体节奏,3.4 时反应窗口约 1.8 秒 |
// 调试期用方向键实时改参数,改完按 r 重开验证 void keyPressed() { if (key == CODED && keyCode == UP) gravity += 0.03; if (key == CODED && keyCode == DOWN) gravity -= 0.03; if (key == CODED && keyCode == LEFT) JUMP_IMPULSE += 0.3; // 注意负值,加是变弱 if (key == CODED && keyCode == RIGHT) JUMP_IMPULSE -= 0.3; if (key == 'r') resetAll(); if (key == 'p') saveFrame("frames/frame-####.png"); // 逐帧存图,做轨迹比对 doJump(); // 空格也走跳跃 }这段代码里JUMP_IMPULSE的方向最容易搞混:它是负值,+0.3实际是让跳跃变弱。调试面板一定要在屏幕上把当前数值打印出来(text画在左上角),否则调着调着就不知道自己在哪个值上了。
调完之后要验证,不能靠“我感觉变难了”。我用的办法是固定随机种子加脚本化点击:randomSeed(42)保证每局管道布局一致,然后用一个计数器每隔固定帧数调一次doJump(),看能不能稳定通过前 10 根管道。调参前后各跑一遍,通过根数的变化就是可比的基线。这套流程不玄学,比反复手动试玩靠谱得多,也方便把“这一版手感更好”这种主观判断变成“同样的自动操作多过了三根管”的客观记录。
从那以后我每次交这类小游戏之前,都会先固定随机种子跑一遍 30 根管道的自动点击回放,再手动玩三局;两边的结论要是对不上,八成就是判定框和绘制不同源,去查gapY有没有被别的地方改过,基本一查一个准。希望帮到你。
本文还有配套的精品资源,点击获取