
简介飞机大战小游戏源码C# Winform版专为Winform初学者与游戏开发爱好者设计完整演示了窗体程序中游戏循环、键盘控制、碰撞检测、音效播放等核心实现也适合作为课程设计或毕业设计的参考项目。资源包共101个文件大小约13.35MB内容涵盖图片素材、C#核心源码、音效文件、依赖库以及可直接运行的可执行程序图片用于飞机与敌机显示源码定义了玩家控制、子弹与碰撞逻辑音效增强游戏体验整体结构清晰便于边看边改。游戏规则简单明确使用WASD控制飞机K键发射子弹击中敌机增加1分并触发爆炸音效被敌机子弹击中则扣分扣血生命值或得分归零即游戏结束界面通过矩形条实时展示生命与得分状态。源码注释完整、代码规范、模块划分清楚已有650人学习下载适合在此基础上扩展关卡、道具或调整难度快速搭建属于自己的射击小游戏。1. C# Winform飞机大战小游戏源码把游戏循环、绘制与碰撞一次串起来第一次打开这份源码时我下意识翻了下 Form1.cs文件不短但整体结构很清晰基本能直接编译跑起来。C# Winform飞机大战小游戏源码并不是那种只演示一个控件用的教学 Demo它把游戏循环、GDI 绘图、碰撞检测、键盘控制和计分存档串成了一条完整的逻辑链。对正在学 C#、刚从控制台往界面编程跨步的人来说它是一个能拆开看的完整项目对要做计算机课设、或者想在公司内部快速做个交互小工具练手的人来说它的代码量也刚好不臃肿。它到底解决什么问题就是解决“Winform 能不能做游戏”“小游戏项目从哪下手”这两个反复被问的问题。读完你会知道一个能玩的飞机大战至少需要哪些零件以及每个零件坏了怎么查。2. 游戏循环与GDI双缓冲闪屏、卡顿和残影的根源都在这里2.1 循环载体为什么是 Winform Timer 而不是 while(true)飞机大战这类动作游戏核心就是一个永不停止的循环每过几十毫秒把所有对象的位置往前推一步检查有没有命中再重画一帧。Winform 里承载这个循环最合适的对象是System.Windows.Forms.Timer不是System.Threading.Timer也不是System.Timers.Timer。后两个 Timer 的回调在线程池线程里触发你如果直接在回调里改 Label 的 Text十有八九会撞上跨线程访问异常。而 Winform Timer 的 Tick 事件是在 UI 线程的消息循环里触发的我在这个资源里看到的做法也是这样一个 Timer一个 Tick 事件Tick 里先更新逻辑再让窗体重绘。// 游戏循环Timer 驱动所有逻辑都在 UI 线程里跑 private System.Windows.Forms.Timer gameTimer; private void InitGameLoop() { gameTimer new System.Windows.Forms.Timer(); gameTimer.Interval 16; // 单位毫秒约 60fps gameTimer.Tick GameTick; // 每帧回调 gameTimer.Start(); } private void GameTick(object sender, EventArgs e) { UpdateGameState(); // 1. 推进所有对象的位置 CheckCollisions(); // 2. 判断碰撞 Invalidate(); // 3. 请求窗体重绘 }这段代码里最关键的是Interval 16。Windows 消息循环的 Timer 实际精度在 15ms 左右你写 16 和写 17 差别不大但别写 1那会让 CPU 空转。Invalidate()不是立刻重画它只是给窗体发一个待重绘标记等消息循环空闲了再触发OnPaint这比手动调用Refresh()高效很多。为什么不推荐while(true) Thread.Sleep我见过不少人把控制台的思路搬过来在循环里调Application.DoEvents()结果窗体拖动时画面撕裂、按钮点不动、Debug 下直接卡死。Winform 的消息机制扛不住这种粗暴用法。这算是这个资源里第一个值得记住的选择循环载体用 Timer别自己造轮子。2.2 双缓冲让每帧绘制从“看得见的涂改”变成“看不见的内存合成”飞机大战的绘制量在 Winform 应用里不算小背景图、玩家机、十几颗子弹、五六架敌机再加上分数文本。如果你直接在OnPaint里往屏幕上画画面会闪成灾难片。原因很简单GDI 的绘制是逐像素写屏的画完背景画飞机时屏幕上已经出现了半个中间状态下一次刷新又从头画闪烁就产生了。解决方法有两个层次。第一层是在窗体构造函数里加一行this.DoubleBuffered true;这是 Winform 控件属性大全里最常被翻牌子的一个属性。它的原理是让系统先把所有绘制画到一个内存位图里画完一次性拷贝到屏幕。小项目、对象数量不多时这一行就够了。第二层是用BufferedGraphics手动控制双缓冲适合对象数量多、局部刷新要求高的场景。这份源码里的路径更接近这一层因为每帧要画的东西超过二十个对象系统自带的 DoubleBuffered 会频繁分配大块内存帧率反而不稳。protected override void OnPaint(PaintEventArgs e) { using (BufferedGraphicsContext context new BufferedGraphicsContext()) { using (BufferedGraphics buffer context.Allocate(e.Graphics, DisplayRectangle)) { Graphics g buffer.Graphics; DrawBackground(g); // 画背景一般是整张图片 DrawBullets(g); // 画子弹 DrawEnemies(g); // 画敌机 DrawPlayer(g); // 画玩家机放在敌机之上 DrawScore(g); // 画分数文本 buffer.Render(e.Graphics); // 内存整帧一次性上屏 } } }Allocate的第二个参数我习惯传DisplayRectangle而不是ClientRectangle。原因是高分屏缩放时两者有差异用 ClientRectangle 有时会出现贴边对象被裁剪的毛病。Graphics 对象用完必须释放这里靠using包裹避免 GDI 句柄泄漏。还有个细节分数文本的 Font 和 Brush 不要每帧创建在类里定义成static readonly字段否则玩十分钟后任务管理器里能看到句柄数一路飙升。2.3 帧步进与时间基准Timer 不准时游戏速度靠什么稳住Timer 的精度有限而且系统忙时 Tick 会被跳过。如果你用“帧数累加”的方式计算全屏滚动速度性能差的机器上游戏会越跑越慢。我一般会用一个独立的时间基准也就是Environment.TickCount它在程序启动后按毫秒递增读取开销也很小。private int startTime Environment.TickCount; private float GetTimeFactor() { // 返回 1.0 表示当前速度与基准速度一致 return (Environment.TickCount - startTime) / 1000f; }把GetTimeFactor()乘到每个对象的位移量上比如子弹速度是 12 像素/帧乘以 1.2 就是 14.4 像素。这样就算某一帧卡了 30ms下一帧的位移也会自动补回来视觉上速度始终保持一致。别用DateTime.Now做这个计算它的精度够但转换开销大而且新手容易写错时间差格式。3. 对象模型与碰撞检测把飞机大战写成不翻车的类结构3.1 GameObject 基类与三种派生对象很多刚开始做 Winform 小游戏的人会把所有对象都变成 Form 里的字段玩家机一个变量、子弹一个 List、敌机一个 List绘制代码全堆在 OnPaint 里。这种写法在对象只有两三种时没问题一旦加了 Boss、道具、爆炸特效OnPaint 会变成一坨没人敢动的代码。这份源码的对象划分是标准的先定义一个GameObject基类把共同属性收进去再让玩家机、子弹、敌机各自继承。基类里的Update和Draw定义成虚方法派生类各自实现行为差异。public class GameObject { public float X { get; set; } public float Y { get; set; } public int Width { get; set; } public int Height { get; set; } public bool IsAlive { get; set; } public RectangleF Bounds { get { return new RectangleF(X, Y, Width, Height); } } public virtual void Update(float speedFactor) { } public virtual void Draw(Graphics g) { } }坐标用float而不是int这是很多项目的隐藏加分项。DrawImage 的坐标参数本身就支持浮点用 int 做位移时速度为 1 的敌机动起来一格一格地跳用 float 才能做出平滑的慢速移动。IsAlive的作用贯穿整个框架对象被打爆了就把这个标记改成 falseUpdate、Draw、碰撞检测三重循环都先判断它避免对死对象做无效计算。子弹类的实现最能看出这个框架的边界处理思路。它只关心自己向上飞和出界面Y 小于 -20 或大于窗体高度加 20 就自杀这一步省掉了大量碰撞分支判断。public class Bullet : GameObject { public float SpeedY { get; set; } -12f; // 向上飞负值 public override void Update(float speedFactor) { Y SpeedY * speedFactor; // 超出窗体上下边缘就回收避免堆积死对象 IsAlive (Y -20 Y 400 20); } public override void Draw(Graphics g) { using (Brush b new SolidBrush(Color.Yellow)) { g.FillRectangle(b, X, Y, 4, 12); // 用小矩形模拟子弹省去图片依赖 } } }敌机类和子弹类相反它要处理随机出生位置、向下移动和与玩家机的碰撞反馈。把这三个类分开后后期加新机型只需要再派生一个类改出生参数Form 里的代码几乎不动。3.2 碰撞检测矩形缩边、圆形判定与粗过滤碰撞检测是飞机大战最影响手感的部分也是新手最容易做出“明明打中了却没事”的玄学问题的地方。最直白的做法是矩形相交判断直接调用RectangleF.IntersectsWith就行。但矩形有一个固有问题飞机图片通常是透明的 PNG实际可见区域只有机身那一条矩形却包含四个角结果就是子弹明明打在机翼旁边的透明区域系统也判定命中玩家会觉得这游戏判定太“松”。常见做法是两个偏移参数把碰撞框向内缩几个像素再做检测。缩边距离跟对象尺寸有关一般缩 4 到 6 像素比较适中。// 矩形碰撞把碰撞框内缩避免透明边角误判 public static bool RectHit(GameObject a, GameObject b, float inset) { RectangleF ra new RectangleF(a.X inset, a.Y inset, a.Width - inset * 2f, a.Height - inset * 2f); RectangleF rb new RectangleF(b.X inset, b.Y inset, b.Width - inset * 2f, b.Height - inset * 2f); return ra.IntersectsWith(rb); }另一种手感更好的做法是圆形判定用两个对象的中心点距离和它们的半径之和做比较。圆形更接近玩家对“飞机中间那点”的直觉而且不需要开平方改成比较距离的平方即可性能也好一点。// 圆形碰撞两点距平方与半径和平方比较省掉开方开销 public static bool CircleHit(GameObject a, GameObject b) { float ax a.X a.Width / 2f, ay a.Y a.Height / 2f; float bx b.X b.Width / 2f, by b.Y b.Height / 2f; float dx ax - bx, dy ay - by; float radiusSum (a.Width b.Width) / 2f; return dx * dx dy * dy radiusSum * radiusSum; }我一般混用两种方式子弹对敌机用缩边矩形玩家机对敌机用圆形。一帧内要检测的对象数量不超过一百对性能压力不大但碰撞循环里还是建议先做一个粗过滤两对象 X 距离大于固定值就直接跳过精细计算。3.3 更新顺序为什么先 Update、再碰撞、最后 Draw这三个操作的执行顺序看起来是小事踩过坑才知道它决定了一堆边界 Bug。如果先绘制再更新屏幕上会显示上一帧的位置如果把碰撞放在 Update 之前对象还没移动就判断子弹会穿透到敌机下半截才开始爆炸。标准顺序是固定的先UpdateGameState()把所有人的位置推进到位再CheckCollisions()判断当前帧这一瞬间的接触最后Invalidate()触发的 OnPaint 画出结果。一旦把顺序写反调试碰撞逻辑时会看到各种“迟半拍”的反馈。这个顺序在这份源码里是严格保持的我在自己的项目里也一直沿用它。4. 键盘响应、计分与难度曲线手感不是玄学是参数4.1 方向键与组合键KeyDown 陷阱和 ProcessCmdKey 兜底Winform 处理键盘输入有几个隐蔽的坑第一个就是方向键。默认情况下方向键的作用是切换按钮焦点如果你在窗体的 KeyDown 里监听左箭头会发现飞机根本不动焦点反而跳到某个控件上。这个资源里能看到一个聪明的处理重写ProcessCmdKey拦截方向键而不是在 KeyDown 里处理。protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { // 方向键先拦截到游戏逻辑避免窗体拿去移动焦点 if (keyData Keys.Left || keyData Keys.Right) { HandleInput(keyData, true); return true; // 表示事件已处理 } return base.ProcessCmdKey(ref msg, keyData); }还有个必须设置的地方窗体的KeyPreview属性要设为true。否则焦点如果落在某个 Button 上按键事件根本到不了窗体。第二个坑是“多键同时按”。在 KeyDown 事件里直接移动飞机按住右键再按空格时空格事件可能被丢。正确的状态管理是维护一个按下的按键集合KeyDown 时加入KeyUp 时移除然后在每帧 Update 里统一读取这个集合。private HashSetKeys pressedKeys new HashSetKeys(); protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); if (!pressedKeys.Contains(e.KeyCode)) pressedKeys.Add(e.KeyCode); } protected override void OnKeyUp(KeyEventArgs e) { base.OnKeyUp(e); pressedKeys.Remove(e.KeyCode); if (e.KeyCode Keys.Space) // 空格点射每次松开打一发 FireOnce(); }连射不用按住空格反复触发 KeyDown而是在 Update 里用帧计数做冷却。冷却值用帧数而不是毫秒这是老游戏移植过来的习惯帧数冷却与游戏循环同步调试时改 Interval 也不影响手感逻辑。4.2 状态栏计分、进度条与最高分存档计分显示这块Winform 的经典做法是用ToolStripStatusLabel。网上搜“winform 如何更新状态栏与进度条”讨论的大多是跨线程问题但在这个项目里游戏循环在 UI 线程直接赋值不会触发异常反而简单。private void UpdateScoreLabel() { toolStripStatusLabel1.Text $得分:{score} 生命:{lives}; toolStripProgressBar1.Maximum 100; // 假设生命上限 100 toolStripProgressBar1.Value playerHealth; }进度条拿来做玩家血条特别合适唯一要注意的是别每帧都刷新。文本变化会触发状态栏重排密集刷新时肉眼可见地闪。我一般只在分数或血量真正变化的那一帧刷新也就是在命中判定成功时调用这个方法而不是每帧无脑赋值。最高分存档我推荐用最朴素的方式一行文本写进应用目录下的文件不引第三方库。资源里如果已经引用了 Newtonsoft.Json 那是另一回事没有引用的情况下自己拼一行格式就够了。// 最高分存档格式 score|yyyy-MM-dd HH:mm private void SaveBestScore(int score) { string line score | DateTime.Now.ToString(yyyy-MM-dd HH:mm); string path Path.Combine(Application.StartupPath, bestscore.dat); File.WriteAllText(path, line); }放Application.StartupPath而不是当前工作目录这个区别在 IDE 里按 F5 和发布成 exe 后双击运行时很明显。路径不对会找不到存档文件或者存档写到奇怪的位置。4.3 难度曲线间隔、速度、分值三个参数怎么配飞机大战的“手感”其实是一组看得见的参数组合不是什么玄学。难度曲线通常由三个量控制敌机生成间隔、敌机移动速度、击毁得分。间隔随时间减小速度随时间增加阈值用 Math.Max 和 Math.Min 锁死防止后期失控。private void UpdateDifficulty() { int elapsedSeconds (Environment.TickCount - startTime) / 1000; // 生成间隔从 1200ms 降到不低于 300ms enemySpawnInterval Math.Max(300, 1200 - elapsedSeconds * 20); // 敌机速度从 3 开始涨最快封顶 9 enemySpeed Math.Min(9f, 3f elapsedSeconds * 0.08f); // 击毁得分越后期越值钱鼓励玩家撑住 scorePerEnemy 10 elapsedSeconds / 10 * 5; }三个参数分开看各有门道。生成间隔控制节奏前期 1200ms 给玩家适应时间60 秒后降到 600ms 左右形成压力速度控制压迫感涨太快玩家会觉得自己在玩弹幕游戏击毁得分随时间增长是为了制造正反馈让后期紧张的同时也有收益。这三个常量的初始值我会放在类顶部的const区而不是散落在 UpdateDifficulty 里。调整时只动常量游戏平衡性试几轮就能定下来。5. 源码导入与常见问题排查从打开项目到跑起来的四个坑5.1 导入步骤验证环境跑通第一个编译下载解压后的项目目录结构很标准先确认有这几样东西再动手打开.sln解决方案文件、Form1.cs和Form1.Designer.cs主窗体、Program.cs入口、以及 Resources 目录下的图片资源。打开方式很简单但有几个步骤别跳过。用 VS2015 或更高版本打开.sln文件弹安全警告就点“仍要打开”。项目是别人的VS 可能提示目标框架不一致这时先别强行编译右键项目图标打开属性检查“目标框架”一栏。常见的编译报错和解决方法可以照表排查报错信息处理方式找不到目标框架版本项目属性里降低/升高目标框架或手动修改 .csproj类型或命名空间不存在右键引用添加 System.Drawing / System.Core 等资源名称不存在检查 Properties/Resources.resx 里的资源名大小写项目无法加载用记事本看 .sln 里的 VS 版本号版本过旧则新建项目导入代码编译按 F6如果输出窗口只有一两个错误双击错误信息就能定位到行这类通常是小括号不匹配。如果错误滚了满屏别急着一个一个改先看是不是 Framework 版本不对八成是它引起的连锁反应。我习惯在跑起来之前先确认一件事图片资源是否在项目里正确引用。资源文件如果放在Resources/目录外换到别的机器就可能找不到图。最好的验收方式是按 F5 后看窗体上有没有粉色块或空白区域有的话就是图片路径问题继续往下排查。5.2 避坑实录四类高频问题坑一Framework 版本不匹配项目直接打不开。现象双击.sln报“此项目需要 v4.7.2但当前安装了 v4.6.1”点确定后项目被卸载。原因源码作者用的 VS 版本和本机版本不同目标框架是写死在.csproj文件里的。解决右键报错的项目选择“编辑项目文件”或直接用记事本打开.csproj找到TargetFrameworkVersion节点手动改成你机器上有的版本改完重新加载项目。TargetFrameworkVersionv4.6.1/TargetFrameworkVersion要注意的是改低版本可能让源码里一些新语法报错改高版本则要确认本机装了对应 SDK。改完必须“清理解决方案”再生成一次否则 VS 缓存旧配置错误不会消失。坑二图片不显示飞机和敌机全是透明或黑色块。现象运行正常但背景是灰的玩家机看不到或者直接抛 OutOfMemoryException。原因代码里用绝对路径加载图片比如Bitmap.FromFile(E:\飞机大战\player.png)换台机器路径就失效了。解决把图片复制进项目的 Resources 目录在解决方案资源管理器里选中图片属性面板里把“复制到输出目录”设为“如果较新则复制”然后在代码里用相对路径或直接引 Properties.Resources 里的资源。// 从项目资源里取图不依赖外部路径 private Bitmap playerImg Properties.Resources.player;从那以后我写 Winform 游戏都要求资源必须进.resx不碰绝对路径。这类问题偶尔出现的时候最难查报错信息又可能是模糊的内存不足。坑三玩了半分钟开始卡顿CPU 占用飙升到 50% 以上。现象游戏运行流畅十秒后明显掉帧打开任务管理器看到进程 CPU 居高不下。原因每帧都在new Font、new SolidBrush、new BitmapGDI 对象没有释放系统句柄被耗尽。解决对象的创建放到初始化阶段绘制中只使用静态字段。必须临时创建的画刷用using包裹画完立刻释放。// 错误每帧都创建新字体句柄只增不减 Font f new Font(宋体, 12f); // 正确定义成静态字段程序生命周期内只建一次 private static readonly Font ScoreFont new Font(宋体, 12f);排查这个坑的时候打开任务管理器切换“详细信息”标签勾选“句柄数”列如果数值每帧都在涨基本就是 GDI 泄漏。坑四按空格子弹发不出去反而弹出输入法候选框。现象游戏中按空格触发中文输入法的候选词浮窗得分也没反应。原因窗体默认的ImeMode跟随系统空格键被输入法拦截游戏逻辑收不到。解决在窗体构造函数里设置this.ImeMode ImeMode.Disable;。如果窗体上有文本框之类的控件还可能需要单独对每个控件设置同样的属性否则焦点在控件上时输入法仍然生效。public Form1() { InitializeComponent(); this.ImeMode ImeMode.Disable; // 游戏窗体不需要中文输入 }这个坑很隐蔽因为它在中文系统上必现换了英文系统玩一整天都不会触发。可视作 Winform 小游戏特有的“本土坑”第一次遇到的人经常往键盘事件上瞎排查半小时。6. 打包成exe 与再扩展把单机小游戏做成能发出去的产物6.1 ClickOnce 与绿色版的选择游戏写完后要发给别人玩常见两条路工程里点发布用 ClickOnce 生成 setup.exe或者直接把 Release 目录打成 zip 当绿色版。ClickOnce 的好处是自动创建开始菜单快捷方式、更新时只覆盖差异文件适合内部工具这种隔段时间发一版的场景。绿色版的好处是免安装拷贝即用但杀毒软件误报概率高一点因为无签名 exe 容易触发启发式扫描。从这台机器的视角讲轻度分发选 ClickOnce 更省事鱼和熊掌不可兼得想要免安装又想要口碑就只能补一张代码签名证书成本不低。6.2 值得做的三个扩展对象池、音效、Boss 关如果要拿这份源码继续深化优先做三件事。第一是对象池子弹和敌机频繁创建会导致 GC 压力用 Stack 缓存死亡对象需要时弹出来复用。第二是音效System.Media.SoundPlayer播放 wav 足够射击和爆炸各放一个音效文件要控制音量做成设置项。第三是 Boss 关给 Enemy 加一个 Phase 属性Boss 到半血切换弹幕行为。// 子弹对象池避免高频 new 对象造成的 GC 抖动 private StackBullet bulletPool new StackBullet(); private Bullet GetBullet() { return bulletPool.Count 0 ? bulletPool.Pop() : new Bullet(); }从那以后我每次写完一个交互型 Winform 程序都会强制走一遍三件事换一台机器重编译、关窗后查一下任务管理器确认进程退干净、连续跑二十分钟观察句柄数。这三件事加起来十分钟能挡住绝大多数发布现场的大型翻车事故。希望帮到你。本文还有配套的精品资源点击获取