ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C# Winform小鸟过管道游戏源码解析:GDI+游戏循环与碰撞检测

C# Winform小鸟过管道游戏源码解析:GDI+游戏循环与碰撞检测 简介C# Winform 小鸟过管道小游戏源码面向C#初学者、相关课程学生及游戏开发爱好者是一款开箱即用、可直接运行的窗体小游戏。在游戏中玩家通过键盘空格键或鼠标左键控制小鸟上下飞行灵巧躲避不断出现的上、下管道每成功通过一根管道积1分一旦触碰管道立即失败并伴有清脆音效交互反馈及时。整个项目基于VS2022开发压缩包共114个文件、5.45MB除20个cs源码文件外还有21个png图片资源、18个wav音效素材、6个exe可执行文件、配置文件、gif演示动画等源码注释完整、命名规范游戏主循环、碰撞检测、计分机制一目了然。资源包已吸引557人学习下载。既适合学习者分析Winform绘图、键盘/鼠标事件与碰撞判定也可作为课程设计或毕业设计的基础框架开发者在此基础上扩展关卡、调整难度或更换美术资源快速搭建属于自己的小游戏。1. 把标题拆成能跑的方案C# Winform 小鸟过管道小游戏源码是什么你可能觉得 Winform 做游戏性能撑不住其实这是个被低估的方向。C# Winform 小鸟过管道小游戏源码本质上是用 Winform 自带的 GDI 绘图能力在窗体上实现一个类似 Flappy Bird 的横版过关小游戏小鸟受重力影响持续下落按一次键就获得一个向上的冲量玩家要控制它穿过间距不等的绿色管道。这套源码的价值不在游戏本身而在它把游戏循环、碰撞检测、键盘事件、自定义绘制这几块 C# 桌面开发最实用的能力压缩在了一个能直接跑起来的项目里。不需要 Unity不需要第三方引擎一个 .NET 环境加一套 Visual Studio 就能跑。适合刚学完 C# 基础想做点“能给别人玩的东西”的新手也适合做 Winform 上位机、想补一补 GDI 绘图和帧循环经验的在职开发。下面从技术选型开始把整个实现路径拆开讲。2. 技术选型先立住为什么用 Winform 而不是 Unity 或 WPF很多人在动手前会纠结做游戏不是应该用 Unity 吗WPF 的动画能力不是更强吗这里先把选型理由说清楚否则后边所有代码都会带着自我怀疑去写。2.1 GDI 绘制 vs 控件拖动两条路线怎么选Winform 做 2D 小游戏有两条路线一是直接用 PictureBox 或 Label 当“小鸟”每帧改它的 Left 和 Top二是建一个自定义控件或直接在窗体上画用 GDI 的 Graphics 对象把小鸟画出来。两条路都能跑但维护成本差很多。用控件拖动的方式好处是几乎不用学绘图 API把控件当棋子挪就行。坏处是两个第一控件本身是窗口句柄数量一多资源开销立刻上来管道如果一根一个控件五根管道叠加就明显卡顿第二碰撞检测你得拿到每个控件的 Bounds 再手动算代码会越写越脏。常见做法是直接绘制的路线窗体上放一个 Timer 作为游戏循环的“心跳”每次 Tick 触发一次重绘所有游戏对象小鸟、管道、背景、分数都在自定义的 Paint 事件里用 Graphics 画出来。这套思路和做 C# 上位机时自定义仪表盘、趋势曲线的套路完全一致——你先画背景再画动态对象最后画覆盖层坐标都由代码算出来。做过 Winform 上位机的人上手会非常快本质就是一次定时刷新里的绘图逻辑重组。2.2 游戏循环的正确姿势别把逻辑堆在窗体事件里第一次写这类游戏的人最容易犯的错是把所有状态都塞进窗体的字段里然后 Timer 的 Tick 事件里写五十行逻辑更新位置、判断碰撞、生成管道、更新分数、重绘。一个 Tick 里塞越多职责后面每加一个功能就越痛苦。更整洁的做法是拆分游戏逻辑全部放在独立的 Game 类里这个类只管状态更新位置、速度、管道列表、分数、存活状态不碰任何 UI 控件。窗体只负责三件事接收键盘或鼠标输入并转发给 Game、用 Timer 驱动 Game.Update()、在 Paint 里调用 Game 的数据进行绘制。这样拆完你会发现调试逻辑时根本不需要打开窗体设计器直接给 Game 类写一个简单的控制台测试或在更新方法里打日志就可以了。游戏暂停、重置、难度调整也都只需要操作 Game 的公开字段不用去动 UI 线程里那些控件属性。后面的编码部分我会按这个结构给代码。2.3 主循环骨架一个 60fps 级别的定时驱动框架先看最核心的循环骨架这是整个游戏的发动机。以下代码在游戏窗体类里public partial class GameForm : Form { private GameWorld _world; // 游戏逻辑对象 private System.Windows.Forms.Timer _timer; private Stopwatch _stopwatch; // 用于计算真实时间差 private double _lastFrameTime; public GameForm() { InitializeComponent(); DoubleBuffered true; // 关键开启双缓冲避免闪烁 _world new GameWorld(ClientSize.Width, ClientSize.Height); _timer new System.Windows.Forms.Timer(); _timer.Interval 15; // 约 66fps注意 Timer 实际精度有限 _timer.Tick OnTick; _timer.Start(); _stopwatch Stopwatch.StartNew(); _lastFrameTime _stopwatch.Elapsed.TotalSeconds; } private void OnTick(object sender, EventArgs e) { double now _stopwatch.Elapsed.TotalSeconds; double deltaTime now - _lastFrameTime; _lastFrameTime now; // 用真实时间差驱动逻辑而不是假设每帧固定 15ms _world.Update(deltaTime); Invalidate(); // 请求重绘触发 Paint 事件 } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); _world.Draw(e.Graphics, ClientSize.Width, ClientSize.Height); } }这段代码里最关键的是DoubleBuffered true和 deltaTime。DoubleBuffered让窗体重绘先在内存里完成再一次性输出到屏幕否则你每帧会看到绘制过程中一闪一闪的残影。deltaTime 用Stopwatch拿到真实的帧间隔而不是依赖 Timer 的 Interval 设置——因为 Winform 的 Timer 底层依赖系统消息循环实际触发间隔经常比设定值慢几毫秒甚至十几毫秒。Invalidate()不会立刻执行重绘而是给消息队列投递一个待绘制标记。这个设计让多次逻辑更新可以合并成一次绘制减少无效的 Graph 绘制开销。整个循环思路是Tick 驱动逻辑更新绘制由系统统一调度逻辑帧率和渲染帧率解耦。3. 核心机制拆开写重力、跳跃、管道与碰撞的落地实现游戏循环搭好之后剩下的就是三个问题小鸟怎么飞、管道怎么来、怎么算死亡。这三个模块我会给出完整的可运行代码并解释每个参数为什么是这个值。3.1 小鸟的物理模型一个加速度和初速度就够小鸟的运动不需要真物理引擎它只有两个状态垂直位置 Y 和垂直速度 VelocityY。每帧速度因重力而增加位置因速度而改变。按空格键时直接把速度设为一个负值向上。public class Bird { public float X; // 小鸟固定横坐标管道移动 public float Y; // 小鸟顶部 Y 坐标 public float VelocityY; // 当前垂直速度像素/秒 public float Gravity; // 重力加速度 public float JumpSpeed; // 跳跃时的初始速度 public float Size 36; // 小鸟的绘制尺寸正方形 public Bird(float x, float y) { X x; Y y; VelocityY 0; Gravity 1500f; // 每秒速度增加 1500 像素 JumpSpeed -420f; // 负值代表向上 } public void Update(float deltaTime) { VelocityY Gravity * deltaTime; Y VelocityY * deltaTime; // 防止穿出屏幕顶部 if (Y 0) { Y 0; VelocityY 0; } } public void Jump() { VelocityY JumpSpeed; } public RectangleF GetBounds() { return new RectangleF(X, Y, Size, Size); } }重力给到 1500 像素每秒平方是一个在手感上“下落明显但不至于失控”的数值。如果你觉得下落太飘可以逐步加到 1800 到 2000如果觉得太重、按键响应不过来就降到 1200。注意我用的单位是“每秒”这样帧率变化不会影响物理表现——如果你在 Update 里写Y Y - 5这种常量位移在高帧率机器上游戏会明显变快。这是初学阶段最容易忽略的坑。跳跃初速度-420f的含义是按一下键瞬间获得每秒 420 像素的向上速度。配合 1500 的重力小鸟大约能上升约 0.28 秒、净上升约 59 个像素。你可以根据想要的手感缩放这两个值比例关系决定跳跃的最高点。3.2 管道生成与移动用 List 维护动态对象集合管道是一组不断向左移动的矩形。它们数量不定、不断需要移除出界的对象因此用ListPipe而不是固定数组。C# 中数组长度固定、扩容需要手动拷贝List 封装了扩容、插入、移除在这个场景里是正确选择。用数组也不是不能写但你会发现每次移除一根管道都得重新分配数组绕过这个问题的代码会有大量复制粘贴。public class Pipe { public float X; public float GapY; // 管道间隙的中心 Y 坐标 public float GapHeight 150f; // 间隙高度 public float Width 62f; public bool Counted false; // 防止同一根管道重复计分 public Pipe(float x, float gapY) { X x; GapY gapY; } public RectangleF GetUpperRect(float groundHeight) { return new RectangleF(X, 0, Width, GapY - GapHeight / 2f); } public RectangleF GetLowerRect(float groundHeight) { float top GapY GapHeight / 2f; return new RectangleF(X, top, Width, groundHeight - top); } }管道对象本身只记录 X 坐标和间隙中心点上下两根矩形由方法现场计算。这样设计的好处是你调整GapHeight或GapY时不需要重建对象碰撞检测和绘制拿到的永远是同一份数据。管道生成的调度放在游戏主逻辑里public class GameWorld { public ListPipe Pipes new ListPipe(); public Bird Player; public float GroundHeight 100f; // 地面高度 public float ScrollSpeed 120f; // 管道左移速度像素/秒 private float _pipeSpawnTimer 0f; private float _pipeSpawnInterval 1.6f; // 每 1.6 秒生成一根 private Random _random new Random(); private float _playableHeight; public GameWorld(int width, int height) { _playableHeight height; Player new Bird(width * 0.3f, height * 0.4f); } public void Update(float deltaTime) { Player.Update(deltaTime); // 管道向左移动 for (int i Pipes.Count - 1; i 0; i--) { Pipes[i].X - ScrollSpeed * deltaTime; if (Pipes[i].X Pipes[i].Width 0) Pipes.RemoveAt(i); // 移出屏幕的管道移除 } // 生成新管道 _pipeSpawnTimer - deltaTime; if (_pipeSpawnTimer 0) { float minGapY 80f GapHeight / 2f; float maxGapY _playableHeight - GroundHeight - GapHeight / 2f - 80f; float gapCenter _random.NextSingle() * (maxGapY - minGapY) minGapY; Pipes.Add(new Pipe(ClientWidth 50, gapCenter)); _pipeSpawnTimer _pipeSpawnInterval; } } }倒序遍历列表来移除元素是因为正序移除会让索引错位Pipes[i]会跳过原本相邻的元素。每次生成管道时间隙中心点在可用高度范围内随机取保证每一轮的通过位置都不同又不至于把间隙生成到屏幕外。_pipeSpawnInterval设为 1.6 秒配合 120 像素每秒的滚动速度两根管道之间的水平距离大约 192 像素刚好够玩家反应并通过。3.3 碰撞检测矩形相交与缩小 hitbox 的必要性Winform 里判断矩形相交有现成的 API不需要手写坐标比较。但直接使用小鸟和管道的原始矩形游戏难度会非常高——玩家视觉上觉得“没碰到”实际已经判定死亡。原因很简单人的视觉感知比精确的矩形边界宽容得多而且管道的上下边缘有一圈视觉边框玩家会把这部分视为“装饰”不觉得自己撞到了。所以常见做法是把小鸟的碰撞矩形缩小一圈private bool CheckCollision() { // 缩小 hitbox四周各缩进 6 像素 RectangleF birdBox Player.GetBounds(); RectangleF hitBox new RectangleF( birdBox.X 6, birdBox.Y 6, birdBox.Width - 12, birdBox.Height - 12); foreach (var pipe in Pipes) { RectangleF upper pipe.GetUpperRect(GroundHeight); RectangleF lower pipe.GetLowerRect(GroundHeight); if (hitBox.IntersectsWith(upper) || hitBox.IntersectsWith(lower)) return true; } // 撞地面也判定死亡 if (hitBox.Bottom ClientHeight - GroundHeight) return true; return false; }IntersectsWith是 .NET 自带的矩形碰撞判断底层实现是边界比较性能足够支撑每帧几十个矩形的检测。这里最值得关注的是 6 像素的缩进值——它在“宽容度”和“公平感”之间取平衡。缩得太多玩家会觉得明明撞上了却没死游戏失去挑战性缩太少玩家会频繁喊“假死”。6 到 10 像素是 Flappy Bird 类游戏里比较通行的手感区间取决于小鸟的尺寸和管道宽度。我一般先给一个不缩小的版本找两三个人试玩把反馈集中在“哪些死法让我觉得不公平”再决定缩进量。值得注意碰撞检测每帧都要遍历所有管道管道数量通常不超过 5 根所以不用做任何空间分割优化。真正会拖慢性能的是每帧绘制几十个带渐变效果的图形那是另一个层面的问题。4. 从空白窗体到可玩状态编码路径与资源处理框架和核心逻辑都有之后剩下的工作是把它“打扮”成一个能玩的游戏绘制样式、计分反馈、起手状态和游戏结束状态。这一章讲编码路径上最容易卡住的几个环节。4.1 绘制游戏对象GDI 的基础 API 组合绘制阶段是 Winform 游戏比控制台程序直观的地方但你得熟悉 Graphics 对象的基本操作。整个场景从上到下依次是天空背景、管道、小鸟、地面、UI 文字。public void Draw(Graphics g, int clientWidth, int clientHeight) { // 清空背景 g.Clear(Color.SkyBlue); // 绘制地面 using (var groundBrush new SolidBrush(Color.FromArgb(222, 184, 135))) { g.FillRectangle(groundBrush, 0, clientHeight - GroundHeight, clientWidth, GroundHeight); } // 绘制管道 foreach (var pipe in Pipes) { var upper pipe.GetUpperRect(GroundHeight); var lower pipe.GetLowerRect(GroundHeight); using (var pipeBrush new SolidBrush(Color.Green)) { g.FillRectangle(pipeBrush, upper); g.FillRectangle(pipeBrush, lower); } // 管道描边让边缘更清晰 using (var pen new Pen(Color.DarkGreen, 2f)) { g.DrawRectangle(pen, Rectangle.Round(upper)); g.DrawRectangle(pen, Rectangle.Round(lower)); } } // 绘制小鸟 float size Player.Size; using (var birdBrush new SolidBrush(Color.Yellow)) using (var eyeBrush new SolidBrush(Color.Black)) { // 身体 g.FillEllipse(birdBrush, Player.X, Player.Y, size, size); // 眼睛 g.FillEllipse(eyeBrush, Player.X size * 0.55f, Player.Y size * 0.2f, 5, 5); } // 绘制分数 using (var font new Font(Arial, 24f, FontStyle.Bold)) using (var textBrush new SolidBrush(Color.White)) { g.DrawString($分数: {Score}, font, textBrush, 20, 20); } }using包裹Pen和Brush是本段代码最重要的习惯。GDI 的笔刷和画笔是非托管资源不释放会导致内存和 GDI 句柄持续上涨。在游戏每帧绘制的情况下这个泄漏会被放大得非常明显——跑半小时后程序变卡任务管理器一看 GDI 对象数上千。每帧 new 出对象再释放的开销可以接受对这个规模的项目没有性能压力安全比微优化重要。4.2 计分逻辑与委托回调避免跨线程操作 UI计分逻辑按管道根数计数而不是按帧数或时间。实现方式是当一根管道的右边缘越过小鸟的 X 坐标且未被记过分就加一分。这样玩家成功穿过缝隙才得分符合直觉。此处嵌一个 C# 委托的经典使用场景分数变更后要刷新窗体标题栏上的文本但 GameWorld 不应该直接引用 Winform 控件。正确的做法是定义一个事件由窗体订阅并更新 UI。// GameWorld 内部 public event Actionint? ScoreChanged; public int Score { get; private set; } private void UpdateScore() { foreach (var pipe in Pipes) { if (!pipe.Counted pipe.X pipe.Width Player.X) { pipe.Counted true; Score; ScoreChanged?.Invoke(Score); } } }窗体的构造函数里这样订阅_world.ScoreChanged score Text $小鸟过管道 - 分数: {score};这里没有任何跨线程问题因为所有逻辑都在 UI 线程的 Timer Tick 里执行。Actionint就是 C# 内置的委托类型它让逻辑层和表现层通过事件解耦GameWorld 不需要知道分数显示在哪里。最容易踩的坑是在这个阶段贸然引入多线程。有人为了让游戏更新更流畅把 GameWorld.Update 放到后台线程跑结果在更新分数时直接修改 UI 控件文本抛 InvalidOperationException。Winform 控件只能在创建它的线程上操作跨线程必须用 Invoke 封送。这个规模的小游戏 UI 线程的 15 毫秒定时负担完全可以承受不值得为了那一点点性能引入跨线程复杂度。4.3 处理图片资源缺失用绘制代替外部素材从网上下载这类源码时最常见的问题是跑起来后小鸟是黑方块——因为代码里加载了bird.png资源但源码包没带图或者图片路径写的是绝对路径。与其依赖外部图片不如全部用 GDI 绘制好处是源码拿到任何机器上都能跑不需要额外素材。前面示例代码里的 FillEllipse 画小鸟已经足够。如果你想让小鸟更像真实 Flappy Bird可以用两条弧线加一个椭圆做出翅膀或者用 GraphicsPath 画一个类似身体轮廓的多边形。核心原则只有一个不要用 PictureBox 加载图片当碰撞参考物绘制样式和碰撞矩形永远是两套数据。这种“无素材依赖”的源码发布出来其他人直接 git clone 就能跑。省掉了满世界找素材的功夫反而更容易让别人从你代码里学到东西因为你连画小鸟都画了说明整个绘制链路是走通的。Winform 界面美化在这个阶段不需要额外花力气——把背景颜色、管道配色、字体大小调整成一致风格观感已经强过大部分教学项目。5. 五个必踩的坑从黑屏到手感玄学这一章写的是我实际跑这类游戏和给同事 review 代码时遇到最多的问题。每一条都是真实翻车现场按“现象 → 原因 → 解决”的结构记录。5.1 画面跳动和闪烁重影严重如果你在新窗体里手动写了一个 Paint 事件去画小球然后跑起来发现小球拖着长长的尾巴那是因为你只画了小球没有先清空背景。每次重绘时旧画面残留和新画面叠加看起来就是拖影。更常见的闪烁问题原因是窗体重绘时先擦除背景再调用 Paint擦除和重绘之间的间隙让屏幕短暂露出默认底色。解决方法是双管齐下在 Paint 里第一行调用g.Clear(Color.SkyBlue)清空整个画布同时把窗体的DoubleBuffered属性设为 true。前者解决拖影后者解决闪白。如果你从网上复制的代码没有这两行直接跑一定是花的。双缓冲的原理是先在内存位图里完成所有绘制再一次 BitBlt 到屏幕用户看到的永远是一帧完整画面不会有中间状态暴露。注意DoubleBuffered在窗体构造函数里设置才生效写在 Load 事件里也有效但有些派生自 Panel 的自定义控件需要额外调用SetStyleSetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);如果你以后做 Winform 上位机自定义控件这一行是标配。所有带波动动画的曲线图、仪表盘闪烁问题最后都是这么解的。5.2 帧率并不稳定游戏“一会儿快一会儿慢”Timer 的 Interval 设为 15但实际你会发现在高速移动的管道上每一帧移动的距离不一致。原因是 Winform Timer 是消息泵驱动的它的触发时间受系统负载影响最小精度大约在 10 到 15 毫秒且偶尔会吞掉一次 Tick。解决办法是前面已经用过的 deltaTime 方案。不要假设每帧固定间隔而是用Stopwatch实测两次 Tick 之间过去了多久然后所有位移都用“速度 × 真实时间”计算。这样即便某帧间隔突然变成 30 毫秒管道也只会移动 120 × 0.03 3.6 像素和两帧 1.8 像素累加等效。物理表现和帧率解耦是游戏手感稳定的基础。如果你发现游戏在发布版比调试版明显快基本就是这个原因——Release 模式下消息泵更高效Tick 触发更密集固定步长代码就会跑得更快。5.3 “明明离管道还有两像素为什么死了”碰撞判定用的矩形是管道图片的完整边界但玩家感知边界里有“空气”。尤其管道有描边和渐变时玩家会觉得描边部分是装饰撞到圆角过渡处也认为“擦过去”了。解决方式是缩小 hitbox前面代码里用的是四周各缩 6 像素。但要注意缩进量不是越大越好得配合实际尺寸来。小鸟 36 像素缩 6 像素意味着碰撞面积缩小到原来的约 44%这个宽容度对新手很友好但对老手可能觉得太宽松。更好的做法是把缩进量做成 GameWorld 的公开字段默认 6测试时按上下方向键现场调整。等手感确定了写死。另外补充一个容易忽略的细节矩形缩进后RectangleF.IntersectsWith依然有效但你要保证负的缩进不会让宽度变成负数。比如小鸟本身如果缩 20 像素宽度就剩 -4 了IntersectsWith 会返回 false 导致穿模。缩进量上限是边长的一半减 1。5.4 管道生成位置上下跳动玩家没有反应时间如果GapY的随机范围是整个可用高度会出现连续两根管道间隙一高一低、且低的那根紧贴地面导致玩家根本来不及上拉的情况。原因是随机分布没有考虑最小间隙高度间隔和上下边界缓冲。解决方法是设置硬性的生成边界。上面代码里我写了minGapY 80f GapHeight / 2f和maxGapY playableHeight - GroundHeight - GapHeight/2f - 80f。80 像素是留给玩家视觉感知的“安全线”确保间隙不会贴到屏幕顶或地面。如果你想增加低级难度的可玩性可以在生成时让GapY与上一根管道的GapY做一个最大差值限制比如 ±80 像素。这样就不会出现两根管道从最顶跳到最底的情况玩家有渐变式的挑战曲线。5.5 游戏跑久了越来越卡过几分钟后再操作反应变慢这种问题和游戏逻辑本身无关它指向的是内存持续增长。仔细检查代码里到处new Font、new Pen、new SolidBrush却没有 Dispose。GLib 和 GDI 的句柄不会立刻跟随垃圾回收释放长时间运行会让进程的 GDI 对象数逼近系统上限默认每个进程 10000 个新的创建请求因此变慢甚至失败。解决手段就是我在 4.1 里写的所有 Pen、Brush、Font 一律放进 using 块。这条规则甚至不用区分是不是每帧创建——凡是实现了 IDisposable 的 GDI 对象要么用 using、要么在 finally 里 Dispose。排查时打开任务管理器加 GDI 对象列如果稳定增长按这个思路一定能找到泄漏点。另外注意Font不是一个廉价对象如果你每帧都new Font(Arial, 24f)它会在内部加载字库开销比 Brush 大得多。可以把用到的 Font 缓存在字段里窗体关闭时统一释放。对 60fps 的游戏来说每帧省掉一次 Font 创建能让 GC 压力明显下降。6. 进阶一步把手感参数做成调试面板再用 hitbox 可视化验证到了这一步游戏已经能玩但手感大概率还不是你要的样子。接下来最值钱的投入不是继续加功能而是做两个调试工具参数热调面板和 hitbox 可视化。它们能把你从“调一下跑一下”的玄学循环里解放出来变成随身带一台测量仪的状态。参数热调面板的做法在窗体的 KeyDown 事件里用上下方向键调整当前选中的参数用左右键切换参数项用 F1 开启或关闭 hitbox 显示。给 GameWorld 加一个 DebugMode 布尔值在 Draw 方法里把碰撞矩形画成半透明红色if (DebugMode) { var birdBox Player.GetBounds(); RectangleF hitBox new RectangleF( birdBox.X 6, birdBox.Y 6, birdBox.Width - 12, birdBox.Height - 12); using (var pen new Pen(Color.Red, 2f)) { g.DrawRectangle(pen, Rectangle.Round(hitBox)); foreach (var pipe in Pipes) { g.DrawRectangle(pen, Rectangle.Round(pipe.GetUpperRect(GroundHeight))); g.DrawRectangle(pen, Rectangle.Round(pipe.GetLowerRect(GroundHeight))); } } }打开这个模式之后你会发现之前觉得“碰都没碰就死了”的瞬间里红色的矩形其实已经重叠了 20% 以上。这时候再调缩进量每次改 1 像素跑几轮记下感受。这里有个关键经验hitbox 要画在普通玩家能看到的叠层上不要只在调试窗口显示。因为你最终要判断的是“视觉边界”和“判定边界”之间的差值合不合理这只能在真实游戏画面上判断。下表是我在不同尺寸下试过的一组相对稳定的起始手感参数以窗体高度 600 为基准参数基准值调整方向重力1500 px/s²值越大下落越快操作反应时间越短跳跃初速度-420 px/s绝对值越大跳跃越高配合重力一起调滚动速度120 px/s值越大整体节奏越快管道间距感变窄管道间隙150 px小于 120 时通过难度急剧上升碰撞缩进6 px新手向可到 10竞速向可控制在 4调参数时记住一个节奏一次只改一个变量改完至少玩 10 局再判断。同时改重力和跳跃初速度如果手感变差你根本不知道是哪一项导致。数值的变化也不是线性的重力 1500 跳到 1600 感觉变化不大但 1500 跳到 2200 会直接把可玩性抹杀掉。先用大跨度找到一个手感区间再在其中用小步长微调。最后一条习惯把最终参数以常量的形式写在 GameWorld 类顶部标注“调好后请勿改动”。做小游戏最大的敌人不是代码复杂而是每次重开项目都要重新调手感的重复劳动。我吃过这个亏项目搁了两周再打开重力值忘了当时为什么改成 1700结果花了半小时重调。从那以后每定稿一组参数我都会在注释里写下当时的体感记录比如“1500/420下落略飘但跳跃弧线好看适合新手”。希望这个习惯能帮你在调参时少走点弯路也希望这份源码能成为你练手 GDI 和游戏循环的一块好跳板。本文还有配套的精品资源点击获取
返回列表