
简介C# Winform小鸟过管道小游戏源码是面向C#入门学习者与游戏开发初学者的完整示例项目。通过键盘空格键或鼠标左键控制小鸟躲避上下管道触碰即失败每过一根管道积一分界面干净整洁并伴有酷炫音效即时反馈可帮助理解Windows窗体应用中的控件绘图、事件处理、碰撞检测与简单游戏循环。资源共114个文件包含20个cs源码、21个png图片素材、18个wav音效资源、exe可执行程序以及项目配置、资源文件等压缩包大小5.45MB整体代码独立注释完整规范可直接用VS2022打开项目运行也适合二次修改扩展。已有557人学习下载源码上手简单能快速搭建自己的应用程序如果准备做C#课程设计或Windows小游戏练手这份压缩包从图片素材、音效资源到核心逻辑均有覆盖拿来即用。1. 一个 C# Winform 标题凭什么被当成游戏入门必修课很多刚接触 C# 的人会觉得做游戏当然要用 UnityWinform 这种老牌桌面 UI 框架拿来写业务系统还行做游戏是不是有点“用牛刀杀鸡还使错了刀”但如果你真的打开过 C# Winform 小鸟过管道小游戏源码你会发现它压根没有用任何游戏引擎全部逻辑都在一个 Form 上完成Paint 事件画画、Timer 推帧、矩形结构体做碰撞。这套组合看起来朴素恰恰是理解游戏最小闭环的最佳路径——游戏循环、对象状态、碰撞判定、界面渲染四件事一样不少却没有引擎帮你把细节藏起来。我见过不少想入行游戏开发的初学者一上来就拖一个 Unity 场景把刚体、碰撞体、Animator 拖上去跑通了却说不清重力是谁加的、碰撞是谁算的。换成 Winform 写小鸟过管道你自己写重力公式、自己算矩形相交、自己管理管道队列所有“黑匣子”都被打开翻车了也能一眼看到底。这篇文章会从整体设计开始把每一段核心代码连同参数选择、踩坑记录一起讲清楚。适合两类人一是正在学 C# 基础、想找一个能跑通且能扩展的 winform 项目案例的初学者二是带学生做课程设计、需要快速交付一个可演示项目的开发者。2. 从需求到结构先把游戏拆成一张能动手的图纸动手写代码之前先回答一个问题一个最小可玩的“小鸟过管道”游戏到底需要哪几样东西答案其实就四个——会下落、能上升的小鸟不断从右侧移入、带缺口的两根管道一条显示分数的逻辑一个“撞到管道或地面就算输”的判定。就这么点事复杂的是每个部分之间的状态怎么协调。2.1 游戏循环怎么选Timer 推帧 vs 独立线程Winform 里驱动游戏循环最常见的选择有两个System.Windows.Forms.Timer和后台线程加while循环。新手我建议直接用Timer原因很实际它的事件回调跑在 UI 线程上你可以在里面直接更新对象坐标、调用Invalidate()触发重绘完全不用操心跨线程访问控件的问题。// 游戏主循环——Timer 驱动 gameTimer new Timer(); gameTimer.Interval 16; // 约 60 FPS1000 / 16 ≈ 62.5 gameTimer.Tick GameLoop; // 每帧执行的逻辑 gameTimer.Start();这段代码里Interval 16是精确到毫秒的帧间隔设定。Windows 的 Timer 底层依赖消息泵实际触发精度达不到 1 毫秒所以别指望它是严格的 60 FPS——它只是给你一个“大约每秒 60 次”的节奏。只要游戏逻辑里所有速度都用“每帧移动多少像素”来描述最终手感就不会差太多。真正要避免的是把Interval设成 1那会让 CPU 空转到发烫而且消息堆积时照样掉帧。为什么要用 Timer 而不是线程因为 Winform 的控件只能由 UI 线程更新后台线程改坐标就必须Invoke代码复杂度上升一个档次。独立线程的优势是精准控制循环节奏、不被 UI 消息阻塞但对于这个体量的项目收益远小于成本。等游戏做到需要复杂物理运算时再考虑把计算丢到后台线程也不迟。2.2 对象模型Bird、Pipe、GameState 三类就够了既然是面向对象的语言就别把所有变量都堆在 Form 里。我一般会把游戏拆成三个类Bird管小鸟位置与速度Pipe管一根管道的上下矩形GameState管分数和运行状态。这样做的直接好处是碰撞检测、管道移出屏幕后的回收都变成了“对对象属性的判断”而不是在主逻辑里数着一堆零散变量。// 小鸟对象只管自身的状态与更新 public class Bird { public float X { get; set; } public float Y { get; set; } // Y 向下为正Winform 坐标系如此 public float Vy { get; set; } // 垂直速度像素/帧 public float Radius { get; set; } // 碰撞半径近似圆形 public void ApplyPhysics(float gravity, float maxSpeed) { Vy Math.Min(Vy gravity, maxSpeed); // 限制最大下落速度 Y Vy; } public void Flap(float flapSpeed) { Vy flapSpeed; // 直接把速度重置为向上的初速度 } }注意Y的方向——Winform 的坐标系原点在左上角Y 轴向下增长所以“向上飞”意味着让Y变小。这个方向搞反的话你会发现点一下鼠标小鸟往屏幕外掉这是绝大多数新手翻车的第一个地方。ApplyPhysics里我用的是“速度累加再移动”的顺序先改速度再改位置保证位置更新依赖的是本帧的新速度而不是上一帧的旧值。Pipe类的设计要考虑到“一对上下管道要同时判定、同时移动”所以我会让一个Pipe对象同时持有上下两个矩形的边界而不是拆成两个独立的矩形数组。这个设计决策在后面的碰撞检测和管道回收中会省很多事。2.3 管道队列与对象复用谁负责生成谁负责回收游戏里管道不是一次性生成全部的而是按固定间隔不断从右侧出现。这里有两个常见做法用ListPipe动态增删或者维护一个固定长度的队列。项目规模小List完全够用但要在每帧里反向遍历把移出屏幕左侧的管道移除掉避免List无限膨胀。// 反向遍历删除越界管道避免索引错乱 for (int i pipes.Count - 1; i 0; i--) { pipes[i].X - scrollSpeed; // 所有管道统一左移 pipes[i].UpdateRectangles(); // 更新上下矩形位置 if (pipes[i].X pipeWidth 0) // 完全离开左边界 { pipes.RemoveAt(i); // 从集合中移除 continue; } if (!pipes[i].Scored pipes[i].X pipeWidth bird.X) { pipes[i].Scored true; // 小鸟越过管道中心 score; } }RemoveAt在循环里必须反向遍历这是 C# 集合操作的经典坑——正向遍历时删除元素会让后面的索引前移轻则跳过一个元素重则越界崩溃。管道生成则放在另一个计数器里每 90 帧生成一根新管道相当于每 1.5 秒出现一对。这个间隔不是拍脑袋定的它决定了游戏的节奏密度调小到 60 帧就太挤调到 120 帧又会觉得无事可做。评分逻辑藏在“小鸟越过管道右边缘”这个条件里——而不是“碰到管道”的时刻。这样每根管道只会触发一次加分不用额外标记管道是否已经计过分。我给Pipe加了一个Scored标志位目的就是防止同一条管道被反复计数。这算是一个很细的设计点但往往决定分数逻辑是不是干净。3. 把核心逻辑写成能跑的代码从画图到碰撞的一气呵成结构拆完了进入最实际的环节代码怎么写才能跑得起来、看起来像样。这一章我会按真正的工作顺序来——先处理绘图和双缓冲再写按键输入然后做碰撞检测最后把这三块通过一个完整的窗体类串起来。每一步都给出可直接抄走的代码并说清楚为什么这么写。3.1 双缓冲绘图为什么画面一直闪以及怎么根治直接在一个Form的Paint事件里画矩形、画圆跑起来后画面会闪到怀疑人生。原因是每帧重绘时Windows 先擦掉旧画面再画新画面这中间露出的背景色会造成视觉上的闪烁。解决手段叫“双缓冲”市面上所有游戏引擎都在用同一个原理先在内存里画好一整帧再一次性地推送到屏幕上这样用户永远看不到“画了一半”的画面。// 开启 Winform 双缓冲这是最省事的方案 protected override void OnPaint(PaintEventArgs e) { // 1. 创建内存画布尺寸与窗体一致 using (var buffer new Bitmap(ClientSize.Width, ClientSize.Height)) { using (var g Graphics.FromImage(buffer)) { DrawGame(g); // 把所有绘制逻辑交给 DrawGame } e.Graphics.DrawImage(buffer, 0, 0); // 2. 一次性拷贝到屏幕 } } private void DrawGame(Graphics g) { g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 绘制背景、管道、小鸟、分数…… }其实 Winform 有一个内置的DoubleBuffered属性设成true也能达到类似效果但那是 Winform 控件内部的机制需要你保证所有绘制都发生在OnPaint里。上面这段写法是自己手动管理缓冲区好处是对“每一帧到底画了什么”有完全的控制权后续如果要加粒子效果、拖影特效这个模式能直接扩展。using语句不能省——Bitmap和Graphics都是 GDI 的托管包装底层持有非托管资源不释放的话内存占用会涨到让 IDE 都发烫。SmoothingMode.AntiAlias是给圆和斜线做抗锯齿的。小鸟如果用圆来表示不开这个选项边缘会有明显的锯齿感整个游戏看起来像上世纪的作品。代价是绘制开销略有上升但对这个小项目来说完全可以忽略。3.2 输入响应用键盘还是鼠标以及怎么处理“长按”Flappy Bird 的操作极其简单——按一下鸟就往上窜一下。Winform 里响应按键的方式有两种KeyDown事件和PreviewKeyDown。如果直接用KeyDown你会发现快速连按时偶尔会漏掉按键——因为 Winform 的键盘消息处理有重复按键延迟按住不放时会先触发一次隔几百毫秒才开始连续触发。游戏要的是“按一下就响应一下”而不是“按住后连发”所以这两种行为都不能直接用。// 用 KeyUp 触发跳跃天然屏蔽按键重复 protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (e.KeyCode Keys.Space isRunning) { bird.Flap(-8.0f); // 竖直向上的初速度负值因为是屏幕坐标 } }这里用KeyDown已经够用但如果你觉得偶尔按键响应有点“粘”就换成KeyUp——它只在按键抬起时触发一次彻底规避长按连发。两种方案都行关键是理解背后的行为差异OnKeyDown响应快但可能连发OnKeyUp手感干净但有一点点延迟。游戏手感调到这里其实已经没有标准答案全看你自己偏好。flapSpeed -8.0f这个负值又一次体现了屏幕坐标系的特性。8 这个数值需要配合重力参数一起试重力加速 0.4/帧跳跃初速 -8小鸟大约会在 20 帧后达到最高点下落时最多每秒 12 像素。这组参数是我试过很多轮之后觉得比较“跟手”的你完全可以按自己的手感去调后续我会专门讲怎么调。3.3 碰撞检测矩形相交、圆与矩形相交边界条件怎么设碰撞检测是本项目的“核心中的核心”。小鸟在代码里是一个带半径的圆管道是上下两个矩形所以碰撞判定本质上是“圆与矩形相交”。但为了计算简单我实际做的是“圆形的外接矩形”与管道矩形的相交判断——这种近似在视觉上差别不大计算却便宜很多每个矩形只要比大小就能出结果。// 圆与矩形碰撞的精确判定找矩形上离圆心最近的点 private bool CircleRectCollision(Bird bird, Rectangle rect) { // 1. 找最近点坐标 float nearestX Math.Max(rect.Left, Math.Min(bird.X, rect.Right)); float nearestY Math.Max(rect.Top, Math.Min(bird.Y, rect.Bottom)); // 2. 计算最近点与圆心的距离平方直接比较平方值避免开根号 float dx bird.X - nearestX; float dy bird.Y - nearestY; return (dx * dx dy * dy) (bird.Radius * bird.Radius); }注意这段代码用的是“精确的圆与矩形相交”而不是外接矩形近似。原因是管道边缘比较薄外接矩形在贴近管道边角的时候会提前判定碰撞——让玩家觉得“明明没碰到却死了”这是游戏手感上最致命的挫败感来源。用最近点法计算复杂度只多了两次Math.Max和Math.Min精度却完全不同。比较距离时用平方而不是开根号纯粹是性能习惯——一帧里要做两根管道的碰撞检测能省一次开根号就省。真正的判定在 Update 里其实是“对每一根管道的两个矩形分别检测再额外加一个地面碰撞”bool hit false; foreach (var pipe in pipes) { if (CircleRectCollision(bird, pipe.TopRect) || CircleRectCollision(bird, pipe.BottomRect)) { hit true; break; } } if (bird.Y bird.Radius groundY) hit true; // 落地也算输 if (hit) GameOver();一个细节是地面碰撞不应该等小鸟的圆心碰到地面才算而是让“脚底”也就是Y Radius碰到地面即刻判定。否则小鸟会看起来陷进地面里才死视觉上非常别扭。3.4 串起所有逻辑一个能运行的 GameForm 骨架前几节是零件这一节把零件装成整机。一个完整的GameForm要负责初始化对象、启动 Timer、处理输入、在每一帧里更新世界、重绘画面、切到 GameOver 状态。下面这个骨架省略了资源加载等次要内容但流程是完整的。public class GameForm : Form { private Bird bird; private ListPipe pipes; private Timer gameTimer; private int frameCount; private bool isRunning true; private int score; private readonly float gravity 0.4f; // 重力加速度像素/帧² private const int pipeInterval 90; // 每隔多少帧生成一根管道 private const int pipeWidth 70; // 管道宽度 public GameForm() { DoubleBuffered true; // 启用控件级双缓冲 Width 480; Height 700; bird new Bird { X 100, Y 300, Radius 18, Vy 0 }; pipes new ListPipe(); gameTimer new Timer { Interval 16 }; gameTimer.Tick GameLoop; gameTimer.Start(); } private void GameLoop(object sender, EventArgs e) { frameCount; if (frameCount % pipeInterval 0) pipes.Add(Pipe.CreateRandom(Width, Height, pipeWidth)); bird.ApplyPhysics(gravity, maxSpeed: 12f); // 更新管道、检测碰撞、更新分数代码同前两节 // ... Invalidate(); // 通知窗体重绘触发 OnPaint } }DoubleBuffered true这行写在构造函数里配合前面手动写的OnPaint双缓冲等于上了双保险。CreateRandom是Pipe的静态工厂方法负责生成一对上下管道缺口位置是随机的——但会限制在画面高度的 25% 到 75% 之间避免缺口出现在太靠边、玩家几乎无法通过的位置。GameLoop里先更新逻辑再重绘顺序不能反。如果先Invalidate再更新画面上出现的会是上一帧的世界状态视觉上会感觉操作“慢半拍”。这种“逻辑先行、渲染在后”的顺序是所有游戏循环的铁律。4. 五个高频踩坑现场从双缓冲到计时器精度问题与解法对照代码能跑只是第一步。这个项目看着简单真正让人磨掉耐心的是下面这些“看起来死活没道理”的坑。我按现象、原因、解决三步逐个讲都是我实际调试中遇到过、且身边同事同学反复踩的。4.1 画面撕裂与残影双缓冲开了也不干净现象已经开了DoubleBuffered但快速移动时小鸟周围有一圈淡淡的拖影管道边缘偶尔有明显的“割裂感”。原因DoubleBuffered只解决了“控件自身重绘时的闪烁”但如果你在OnPaint里用的是this.BackColor做背景又没先把背景整个覆盖上一帧的残留就会从边缘漏出来。更隐蔽的可能是Invalidate()默认不会立即触发重绘它只是把重绘请求挂到消息队列如果 UI 线程同时在忙别的比如 GDI 资源没释放、别的事件耗时太久重绘就攒到下一批渲染间隔不均匀撕裂感就出来了。解决DrawGame的第一步永远是用一个不透明的背景色或背景图整幅填充。检查每帧的绘制是否覆盖了全部客户区只要有一小块没盖住那块的“上一帧内容”就会被看到。另外确认OnPaint里除了绘图之外不干任何琐事尤其是不要在那里做碰撞检测、不要加载图片这些统统放到GameLoop里。4.2 空指针崩溃管道移出屏幕时集合索引错乱现象游戏运行几十秒后偶尔抛ArgumentOutOfRangeException崩溃点在管道遍历那段。原因循环里正向遍历ListPipe又执行RemoveAt(i)删掉一个元素后后面所有元素的索引都减一但循环变量 i 照常往后走于是越过了一个未处理的元素如果删的是最后一个元素下一次访问就直接越界。解决统一用反向 for 循环遍历并删除代码见 2.3 节或者用pipes.RemoveAll(p p.X pipeWidth 0)一步到位后者内部已经处理好索引问题写起来更简洁但没法在移除的同时更新分数——所以这个场景还是反向 for 循环更合适。4.3 定时器不准为什么 60 FPS 的画面像 30 FPS现象把Timer.Interval设成 16跑起来却明显感觉卡顿或者画面一顿一顿的一点也不顺滑。原因System.Windows.Forms.Timer的精度大约只有 15.6 毫秒取决于 Windows 计时器分辨率设 16 和设 15 实际触发间隔可能都是 15 或 16 毫米波动。它会受到 UI 线程繁忙程度影响其他事件处理时间长一点这一帧的触发就会往后拖。解决接受这个现实先看是不是每帧绘制太耗时——高性能模式下用e.Graphics.DrawImage(buffer, 0, 0)把整帧贴上去这步不可避免但DrawString画分数如果每帧都重新创建字体也会拖慢。真正的根治手段是改用Stopwatch驱动、后台线程跑循环用Interlocked或volatile同步状态。但不建议新手跳到那个复杂度先用 Timer等确实卡到不能忍再优化。4.4 按键“失灵”快速连按时漏掉跳跃现象连续快速按空格偶尔一次没反应小鸟没跳起来直接撞管道。原因Windows 键盘消息天然有合并和重复机制。KeyDown在按下瞬间会触发一次但如果你在系统配置的“重复延迟”窗口内再次按同一个键系统会当作“重复按键”过滤掉一部分事件不会每条都送给你。解决用KeyUp替代KeyDown来做跳跃触发KeyUp每条都是独立的不存在重复过滤问题。代价是按键反应慢一丁点用户必须松开才能下一次跳但在这个游戏里玩家本来就是“点一下松一下”几乎无感。如果你实在想保留KeyDown的即时性可以检测e.KeyCode Keys.Space !e.IsRepeat来过滤系统合成的重复事件。4.5 窗口缩放后布局全乱固定窗体的“后悔药”现象玩家拖拽窗体边缘放大窗口后游戏区域右侧露出大片空白管道不在画面内分数位置也歪了。原因所有坐标和尺寸都是按初始窗体大小写的常量。你和窗体尺寸耦合了窗体一变所有布局全部失效。解决最稳妥的办法是直接禁用缩放——把FormBorderStyle设为FixedSingle或FixedDialog让用户无法拖拽改大小。如果想支持缩放就得在OnResize里重新计算groundY、管道缺口位置参数而且所有绘制要按比例缩放来画。我的建议是这个项目别做自适应固定窗口大小是省心多的选择把精力留给游戏性而不是布局适配。5. 难度曲线、界面美化与验证技巧把“能跑的项目”变成“能拿出手的作品”代码逻辑跑通、坑也踩过一轮之后大多数人的作品停在了“能玩、但不好看、也不耐玩”的状态。最后这一步是把颜值和手感拉满的关键环节。5.1 难度曲线怎么调用参数驱动而不是拍脑门很多人的第一个版本是难度恒定从第一根管道到游戏结束管道速度、缺口大小完全一样。玩家要么十秒就死要么熟悉之后闭着眼睛都能飞五分钟两种体验都很糟糕。难度曲线应该随分数线性变化// 动态难度分数越高管道移速越快缺口越小 float speed 4.0f score * 0.03f; // 每分加速 0.03 像素/帧 float gap 170f - score * 0.6f; // 每分缩小 0.6 像素 gap Math.Max(gap, 110f); // 缺口最小 110 像素留活路这段逻辑我放在每一帧管道更新的最前面根据当前分数算出实时参数而不是一开始就定死。两个公式里的系数需要反复试加速太快会让人措手不及太慢又让玩家觉得无聊。经验值是让玩家在第 10 分左右明显感到“开始需要认真了”而不是从第 1 分就开始绝望。调试的时候可以把分数临时改成可调节的用一个TrackBar拖动预览手感确定后再写死。5.2 界面美化从几何图形到贴图与字体进阶的视觉方案代码全用几何图形画的游戏天然有一种“编程练习”的气质。要把成品感提上来最直接的办法是换贴图圆形小鸟换成一张 36x36 的 PNG 透明背景图管道用一张矩形贴图拉伸。绘制时用Graphics.DrawImage替代FillRectangle和FillEllipse其余逻辑完全不用动。背景与 UI 的调整往往对观感提升仅次于贴图。分数可以用Font指定一个微软雅黑大字号配合TextRenderer.DrawText绘制比默认的宋体字号有质感得多。开始界面上加一句“按空格开始”“Game Over”时在画面中央半透明遮罩上显示分数这些用FillRectangle加Color.FromArgb(100, 0, 0, 0)做半透明底就能实现不需要任何额外控件。5.3 验证方法判断游戏手感达标的三个硬指标代码写完怎么知道“行了”我每次做完一组调参会用三个硬指标来验证第一从启动到第一次触发跳跃中间有没有超过 100 毫秒的按键响应延迟第二连续玩 10 分钟有没有出现过一次“视线里没看清就死了”的不公平死亡——这种死法才算这个方向的优化空间。第三游戏分辨率不变的前提下有没有一个分数段玩家能明显感受到难度在抬升。这三个指标里第二个最难量化建议录屏回放看死亡瞬间确认碰撞框和贴图边缘是否贴合。如果小鸟看起来离管道还有两个像素就死了那说明碰撞框比视觉大一圈要么把碰撞半径缩小要么把贴图画大一圈反正视觉中心和碰撞中心必须对上。这个“对不上”的问题是几乎所有模仿作品的通病能自己发现并修正就已经比大多数半成品强一个台阶了。我自己做这类小项目有个习惯每次调完参数都要逼自己实际去玩十分钟而不是只看代码。手感这东西是最难代码评审的唯一的检验手段就是玩起来舒服。记住这感觉然后把这套从“画圆”到“跳”的完整逻辑移植到别的平台——Unity、Godot、甚至网页版你会发现核心思路从来只有一套。希望这个项目能帮你找到属于自己的那条路。本文还有配套的精品资源点击获取