资讯动态

C# Winform飞机大战源码解析:GDI+绘图、Timer循环与碰撞检测实战

发布时间:2026/10/9 15:28:44 来源:尧图企业网站定制
简介这是一份面向C#初学者与Winform练手者的飞机大战小游戏完整源码采用窗体应用程序实现界面干净整洁、结构条理清晰适合用来熟悉GDI绘图、键盘事件与碰撞检测等桌面开发基础。游戏通过W-A-S-D控制飞机移动、K键发射子弹被敌机子弹击中会扣1分并减少1点生命值我方子弹命中敌机则加1分并触发爆炸与音效界面用两个矩形条分别显示生命值和得分任一归零即结束。压缩包共101个文件约13.35MB包含14个cs源码、4个resx与1个csproj工程文件、44个png及4个jpg图片、2个wav与1个mp3音效另有dll、exe、sln等运行与解决方案文件注释完整、代码规范可直接用程序打开或点击EXE运行。目前已有650人学习适合快速搭建自己的窗体小游戏应用。1. 从一份 C# Winform 飞机大战源码说起它到底能跑出什么很多人第一次接触 Winform 游戏开发都是从飞机大战这类小游戏开始的。这份 C# Winform 飞机大战小游戏源码核心就是用 GDI 在窗体上做实时绘制配合 Timer 驱动游戏主循环实现玩家飞机移动、子弹发射、敌机生成、碰撞检测和分数统计这一整套闭环。它不依赖 Unity 或任何游戏引擎纯靠 .NET Framework 自带的 System.Drawing 和 Winform 控件体系就能跑起来对想入门 C# 桌面开发、又不想一上来就啃大型框架的人来说是个很合适的练手项目。这份源码适合三类人一是刚学完 C# 基础语法、想找个能看见效果的项目练手的新手二是需要给课程设计或毕业设计找参考的在校生三是想快速理解 Winform 绘图机制、Timer 调度和简单碰撞算法的开发者。它解决的问题很具体——把「窗体上怎么画东西、怎么让东西动起来、怎么判断两个矩形撞上了」这三件事用可运行的代码讲清楚。下面我从源码结构、运行方式、核心机制到避坑点一层层拆开说。2. 源码结构与运行环境先搞清楚你拿到的是什么2.1 项目文件构成与依赖拿到这份源码解压后通常是一个标准的 Visual Studio 解决方案目录。核心文件包括.sln解决方案文件、.csproj项目文件、Form1.cs主窗体逻辑、Form1.Designer.cs设计器生成代码、Program.cs入口点以及Properties文件夹下的资源文件。游戏用到的飞机图片、子弹贴图、背景图一般放在Resources目录或直接嵌入.resx资源文件里。依赖方面这份源码只用到 .NET Framework 自带的程序集主要是System.Windows.Forms、System.Drawing、System.Timers或System.Windows.Forms.Timer。不需要 NuGet 装任何第三方包这也是它适合新手的原因——打开就能编译不会卡在环境配置上。常见做法是直接用 Visual Studio 2015 及以上版本打开.sln文件。如果你用的是 VS2015注意项目目标框架可能是 .NET Framework 4.5 或 4.5.1这个版本 VS2015 原生支持。如果用 VS2019 或 VS2022 打开可能会提示「目标框架未安装」这时候去项目属性里把目标框架改成你机器上已有的版本即可比如 4.7.2 或 4.8。2.2 编译运行的具体步骤第一步确认你的机器装了 Visual Studio并且勾选了「.NET 桌面开发」工作负载。打开 VS Installer点「修改」在「工作负载」标签页里找到「.NET 桌面开发」确保右侧的「.NET Framework 4.x 目标包」和「.NET Framework 4.x SDK」都勾上。第二步用 VS 打开解决方案文件。在解决方案资源管理器里右键项目选「属性」检查「目标框架」这一项。如果显示的是你机器上没有的版本下拉换成已有的版本保存。第三步直接按 F5 或点「启动」按钮编译运行。如果一切正常会弹出一个窗体背景是滚动星空或纯色你的飞机在底部敌机从顶部往下掉按方向键或鼠标移动飞机按空格或自动发射子弹。// Program.cs 入口点标准 Winform 启动代码 static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); // 启动主窗体 } }这段代码是 Winform 程序的标准入口。STAThread特性表示主线程使用单线程单元模型Winform 的 UI 操作必须在 STA 线程上执行去掉这个特性在某些系统上会直接抛异常。Application.Run(new Form1())启动消息循环窗体关闭时循环结束进程退出。第四步如果你想脱离 VS 直接双击 exe 运行去bin\Debug或bin\Release目录下找生成的 exe 文件。注意 Debug 版和 Release 版的区别Debug 版带调试符号体积大、运行慢Release 版做了优化适合分发。如果要发给别人玩记得把 exe 同目录下的所有依赖文件一起打包或者用 ILMerge 合并成单文件。2.3 游戏主循环与 Timer 调度Winform 游戏的核心是「主循环」。和 Unity 的Update()不同Winform 没有内置的游戏循环得自己用 Timer 模拟。这份源码里通常用System.Windows.Forms.TimerInterval 设成 16 到 33 毫秒对应大约 30 到 60 帧每秒。// 在 Form1 构造函数或 Load 事件里初始化 Timer private Timer gameTimer; private void InitGameTimer() { gameTimer new Timer(); gameTimer.Interval 20; // 单位毫秒20ms 约等于 50 帧每秒 gameTimer.Tick GameLoop; // 每帧调用一次 GameLoop gameTimer.Start(); } private void GameLoop(object sender, EventArgs e) { UpdateGame(); // 更新所有游戏对象的位置和状态 this.Invalidate(); // 触发重绘会调用 OnPaint }Interval 20意味着每 20 毫秒触发一次 Tick 事件。这个值不能设太小比如 1 毫秒因为 Winform Timer 的精度受系统消息队列限制实际间隔往往大于设定值设太小反而导致 CPU 占用飙升但帧率上不去。我一般会从 20 开始调如果画面卡顿就加到 33如果觉得不够流畅就减到 16但不要低于 10。this.Invalidate()是关键。它标记整个窗体区域为「需要重绘」系统会在消息队列空闲时调用OnPaint方法。所有绘制逻辑都写在OnPaint里用Graphics对象画图。这里有个血泪经验不要在GameLoop里直接拿CreateGraphics()画图那样画出来的东西在窗体被遮挡再恢复后会消失因为系统重绘时你没法控制。正确做法是重写OnPaint用e.Graphics画。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 抗锯齿 // 双缓冲减少闪烁 this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); // 绘制背景、玩家飞机、敌机、子弹 g.DrawImage(backgroundImage, 0, 0, this.Width, this.Height); g.DrawImage(playerPlane, playerX, playerY, playerWidth, playerHeight); foreach (var bullet in bullets) g.DrawImage(bulletImage, bullet.X, bullet.Y, bullet.W, bullet.H); foreach (var enemy in enemies) g.DrawImage(enemyImage, enemy.X, enemy.Y, enemy.W, enemy.H); }双缓冲那三行SetStyle建议放在窗体构造函数里不要放在OnPaint里反复设置。OptimizedDoubleBuffer开启双缓冲AllPaintingInWmPaint禁止擦除背景消息UserPaint表示自己处理绘制。这三者配合能把闪烁降到几乎看不见。如果不加飞机移动时画面会明显抖动这是 Winform 游戏最常见的翻车点。3. 核心机制拆解碰撞检测、对象管理与输入响应3.1 矩形碰撞检测的实现与边界飞机大战里最核心的算法就是碰撞检测。这份源码用的是 AABB轴对齐包围盒碰撞说白了就是判断两个矩形有没有重叠。原理很简单如果矩形 A 的右边界大于矩形 B 的左边界且 A 的左边界小于 B 的右边界同时 A 的下边界大于 B 的上边界且 A 的上边界小于 B 的下边界那它们就撞上了。// 矩形碰撞检测方法 private bool IsCollide(Rectangle a, Rectangle b) { return a.Right b.Left // A 的右边超过 B 的左边 a.Left b.Right // A 的左边在 B 的右边之内 a.Bottom b.Top // A 的下边超过 B 的上边 a.Top b.Bottom; // A 的上边在 B 的下边之内 } // 在 GameLoop 里检测子弹和敌机的碰撞 private void CheckBulletEnemyCollision() { for (int i bullets.Count - 1; i 0; i--) { Rectangle bulletRect new Rectangle( bullets[i].X, bullets[i].Y, bullets[i].Width, bullets[i].Height); for (int j enemies.Count - 1; j 0; j--) { Rectangle enemyRect new Rectangle( enemies[j].X, enemies[j].Y, enemies[j].Width, enemies[j].Height); if (IsCollide(bulletRect, enemyRect)) { // 碰撞后移除子弹和敌机加分 bullets.RemoveAt(i); enemies.RemoveAt(j); score 10; break; // 一颗子弹只打掉一架敌机 } } } }这段代码有两个细节值得说。第一倒序遍历for (int i bullets.Count - 1; i 0; i--)。因为碰撞后要RemoveAt如果正序遍历移除元素后索引会错位导致漏检或越界。倒序删除是处理集合元素移除的标准做法新手经常在这里踩坑。第二break的位置。一颗子弹撞上一架敌机后子弹就没了不需要再和其他敌机比较所以内层循环直接break。如果不加这个break一颗子弹可能同时打掉多架重叠的敌机游戏难度会莫名其妙降低。参数方面碰撞检测用的矩形通常比图片实际显示区域小一圈。比如飞机图片是 60x60但实际碰撞矩形可能设成 40x40居中放置。这叫「碰撞盒收缩」目的是让玩家感觉「擦边没撞上」是合理的而不是稍微碰一下图片边缘就爆炸。具体收缩多少一般取图片宽高的 60% 到 80%看游戏手感调。3.2 游戏对象管理与集合操作游戏里的子弹、敌机、爆炸特效都是动态生成和销毁的这份源码通常用ListT来管理。每个对象是一个简单的类包含 X、Y 坐标、宽高、速度、是否存活等字段。// 子弹类定义 public class Bullet { public int X { get; set; } public int Y { get; set; } public int Width { get; set; } 8; public int Height { get; set; } 16; public int Speed { get; set; } 10; // 每帧向上移动 10 像素 } // 在 GameLoop 里更新子弹位置 private void UpdateBullets() { for (int i bullets.Count - 1; i 0; i--) { bullets[i].Y - bullets[i].Speed; // 向上移动 if (bullets[i].Y bullets[i].Height 0) // 飞出屏幕顶部 { bullets.RemoveAt(i); // 移除避免内存泄漏 } } }这里的关键是「飞出屏幕就移除」。如果不移除子弹对象会一直留在 List 里每帧都要遍历和绘制时间一长内存和 CPU 都扛不住。敌机同理飞出屏幕底部就移除。我见过有人写的版本跑十分钟后卡成幻灯片就是因为忘了清理越界对象。敌机生成一般用另一个 Timer 或者在主循环里用计数器控制。比如每 30 帧生成一架敌机生成位置在屏幕顶部随机 X 坐标。随着分数增加生成间隔缩短敌机速度加快这就是最简单的难度曲线。private int frameCounter 0; private int spawnInterval 30; // 每 30 帧生成一架敌机 private void UpdateEnemies() { frameCounter; if (frameCounter spawnInterval) { frameCounter 0; Random rand new Random(); int x rand.Next(0, this.ClientSize.Width - 50); enemies.Add(new Enemy { X x, Y -50, Speed 3 }); } for (int i enemies.Count - 1; i 0; i--) { enemies[i].Y enemies[i].Speed; if (enemies[i].Y this.ClientSize.Height) { enemies.RemoveAt(i); } } }Random对象建议在类级别声明一次不要在循环里反复new Random()。因为Random用系统时间做种子如果在一个 Tick 里连续 new 多个种子可能相同生成的随机数会一模一样。这是 C# 里经典的玄学问题很多人调半天才发现敌机全从同一个位置出来。3.3 键盘与鼠标输入响应Winform 的键盘事件默认由获得焦点的控件处理。如果窗体上有按钮或其他控件方向键可能被它们截获导致飞机不动。解决办法是把窗体的KeyPreview属性设为true这样窗体先于子控件收到键盘事件。// 在窗体构造函数或 Load 事件里设置 this.KeyPreview true; this.KeyDown Form1_KeyDown; this.KeyUp Form1_KeyUp; private bool leftPressed, rightPressed, upPressed, downPressed; private void Form1_KeyDown(object sender, KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: leftPressed true; break; case Keys.Right: rightPressed true; break; case Keys.Up: upPressed true; break; case Keys.Down: downPressed true; break; case Keys.Space: FireBullet(); break; // 空格发射子弹 } } private void Form1_KeyUp(object sender, KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: leftPressed false; break; case Keys.Right: rightPressed false; break; case Keys.Up: upPressed false; break; case Keys.Down: downPressed false; break; } } // 在 GameLoop 里根据按键状态移动飞机 private void UpdatePlayer() { int speed 5; if (leftPressed) playerX - speed; if (rightPressed) playerX speed; if (upPressed) playerY - speed; if (downPressed) playerY speed; // 边界限制防止飞机飞出窗体 playerX Math.Max(0, Math.Min(playerX, this.ClientSize.Width - playerWidth)); playerY Math.Max(0, Math.Min(playerY, this.ClientSize.Height - playerHeight)); }用KeyDown和KeyUp配合布尔变量而不是直接在KeyDown里移动飞机原因是KeyDown事件有系统重复延迟。按住方向键不放前几次触发很快之后会停顿一下再连续触发飞机移动会一顿一顿的。用布尔变量记录按键状态在每帧的GameLoop里统一移动就能做到平滑移动。边界限制那两行Math.Max和Math.Min是必须的。不加的话飞机能移出窗体然后你就再也找不回来了只能重启游戏。这是新手最常见的翻车点之一。鼠标控制的话处理MouseMove事件把e.X和e.Y赋给飞机坐标同样要做边界限制。鼠标控制比键盘更跟手但精度差一些适合休闲玩法。4. 避坑与常见问题排查那些让你调一晚上的坑4.1 画面闪烁严重飞机移动有拖影现象飞机移动时画面明显抖动背景和飞机有残影看起来像幻灯片。原因没有开启双缓冲每次Invalidate后系统先用背景色擦除整个窗体再画新内容擦除和绘制之间有时间差人眼就能看到闪烁。解决在窗体构造函数里加this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true);同时把this.DoubleBuffered true;也设上。如果还闪检查OnPaint里有没有调用base.OnPaint(e)之后又自己擦背景重复擦除也会闪。4.2 子弹或敌机越打越多游戏越来越卡现象玩了几分钟后帧率明显下降CPU 占用越来越高。原因飞出屏幕的子弹和敌机没有从 List 里移除集合越来越大每帧遍历和绘制的对象越来越多。解决在更新位置的循环里加边界判断Y 0或Y ClientSize.Height的对象直接RemoveAt。注意用倒序遍历正序删除会索引错位。另外爆炸特效播放完后也要及时移除不能一直留在集合里。4.3 按方向键飞机不动或者一动就飞出窗体现象键盘按了没反应或者飞机移动一下就不见了。原因一是窗体KeyPreview没设成true键盘事件被其他控件截获二是移动后没有做边界限制飞机坐标超出窗体范围。解决设this.KeyPreview true;确保窗体优先接收键盘事件。移动逻辑里加playerX Math.Max(0, Math.Min(playerX, ClientSize.Width - playerWidth));Y 轴同理。如果用了 Panel 或 PictureBox 承载游戏画面记得把焦点设到那个控件上或者直接在主窗体上画。4.4 编译报错「目标框架未安装」或「找不到类型或命名空间」现象用高版本 VS 打开低版本项目提示框架不匹配或者System.Drawing引用报错。原因项目目标框架是旧版本如 .NET Framework 4.5你机器上没装对应的目标包或者项目引用路径是绝对路径换机器后找不到 DLL。解决右键项目 → 属性 → 目标框架改成你机器上已有的版本如 4.7.2 或 4.8。如果引用报错在解决方案资源管理器里展开「引用」删掉带黄色感叹号的项重新添加System.Drawing和System.Windows.Forms。这些是框架自带程序集在「程序集 → 框架」里能找到。4.5 发布给别人的 exe 打不开或报错现象自己机器上跑得好好的发给别人双击没反应或者提示缺少 DLL。原因Debug 版 exe 依赖调试运行库或者项目引用了非框架自带的 DLL 但没有一起拷贝。解决切换到 Release 模式重新编译把bin\Release目录下所有文件一起打包发出去。如果只想发一个 exe可以用 ILMerge 把依赖合并或者用 Costura.Fody 这类工具自动嵌入依赖。另外确认对方机器装了对应版本的 .NET Framework 运行时Win10 自带 4.6 以上Win7 可能需要手动装。5. 进阶改造与验证让这份源码真正变成你的东西把基础版本跑通之后我建议做三件事来验证你真的理解了这套机制同时让项目更有含金量。第一件事把 Timer 换成更精确的调度方式。Winform Timer 精度有限高帧率下会丢帧。可以改用System.Diagnostics.Stopwatch配合Application.Idle事件或者用多线程加Invoke回主线程绘制。但注意Winform 的 UI 操作必须在主线程执行子线程里直接调Invalidate会抛跨线程异常。常见做法是用this.BeginInvoke(new Action(() this.Invalidate()))把重绘请求丢回主线程。第二件事加一个简单的状态机。现在的代码是「运行中」一个状态可以扩展成「开始菜单 → 游戏中 → 暂停 → 游戏结束」四个状态。用一个枚举变量控制在GameLoop里根据当前状态决定更新哪些逻辑、绘制哪些界面。这样代码结构会清晰很多也方便加暂停功能。public enum GameState { Menu, Playing, Paused, GameOver } private GameState currentState GameState.Menu; private void GameLoop(object sender, EventArgs e) { switch (currentState) { case GameState.Menu: // 只绘制菜单不更新游戏对象 break; case GameState.Playing: UpdatePlayer(); UpdateBullets(); UpdateEnemies(); CheckCollision(); break; case GameState.Paused: // 不更新只绘制暂停界面 break; case GameState.GameOver: // 绘制结束界面和最终分数 break; } this.Invalidate(); }第三件事用性能计数器验证优化效果。在OnPaint开头记一个时间戳结尾再记一个算出每帧绘制耗时显示在窗体标题栏上。如果绘制耗时超过 Timer 间隔的一半说明绘制逻辑太重需要优化。常见优化手段包括把背景图缓存成Bitmap只画一次、减少DrawImage的缩放操作提前把图片缩放到目标尺寸、用Region只重绘脏区域而不是整个窗体。优化项优化前优化后说明背景绘制每帧缩放绘制预缩放缓存避免每帧做插值计算碰撞检测全量两两比较空间分区对象多时效果明显重绘区域整个窗体脏矩形减少绘制像素量对象集合List 遍历删除倒序删除避免索引错位最后说个我自己的习惯。每次改完碰撞逻辑或者对象管理代码我都会故意把敌机速度调到很慢、生成间隔调到很短让屏幕上同时存在几十个对象跑五分钟看内存和帧率曲线。如果内存持续上涨说明有对象没被回收如果帧率稳步下降说明集合在膨胀。这个「压力测试」花不了几分钟但能提前暴露大部分资源管理问题。从那以后我每次写完游戏循环都强制走一遍这个流程比事后 debug 省事得多。希望这份拆解能帮你把这份 C# Winform 飞机大战源码真正跑起来、改起来而不是停留在「下载了但没打开」的状态。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑