简介:飞机大战小游戏源码(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的作用贯穿整个框架:对象被打爆了就把这个标记改成 false,Update、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 HashSet<Keys> pressedKeys = new HashSet<Keys>(); 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>节点手动改成你机器上有的版本,改完重新加载项目。
<TargetFrameworkVersion>v4.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 Bitmap,GDI+ 对象没有释放,系统句柄被耗尽。
解决:对象的创建放到初始化阶段,绘制中只使用静态字段。必须临时创建的画刷用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 Stack<Bullet> bulletPool = new Stack<Bullet>(); private Bullet GetBullet() { return bulletPool.Count > 0 ? bulletPool.Pop() : new Bullet(); }从那以后我每次写完一个交互型 Winform 程序,都会强制走一遍三件事:换一台机器重编译、关窗后查一下任务管理器确认进程退干净、连续跑二十分钟观察句柄数。这三件事加起来十分钟,能挡住绝大多数发布现场的大型翻车事故。希望帮到你。
本文还有配套的精品资源,点击获取