简介:这款C#飞机小游戏源码是一套完整的飞行射击游戏项目,面向初步接触C#或希望系统了解游戏开发流程的学习者,以短小精悍的代码呈现了游戏从启动到运行的完整闭环,适合课程设计、自学实践或作为二次开发的基础模板。资源打包为zip压缩包,共61个文件,主要包含.cs源码文件、Windows窗体界面设计文件、.resx资源文件、png/jpg图片素材以及wav/mp3音频资源,并附有可直接运行的exe和程序集dll,整包仅1.25MB,轻量便携;目前已有259人学习下载。源码中不仅展示了变量、循环、函数等C#基础语法,还通过飞机、子弹、敌人等对象体现了面向对象编程思想,并深入涉及游戏循环、定时器控制、碰撞检测、事件监听、多线程渲染和资源加载等关键技术点。分析和调试这套代码,能帮助开发者理解游戏状态更新、用户输入响应和性能优化的常见手段,是一份实操性很强的入门练手资源。
1. 为什么 C# 飞机小游戏源码值得一拆:一个循环里的完整游戏世界观
刚学完 C# 基础,最让人心里没底的从来不是委托怎么用、LINQ 怎么写,而是“我能不能独立写出一整个能跑的项目”。飞机大战刚好是这个阶段的试金石:没有数据库也没有网络通信,但游戏循环、对象管理、GDI+ 渲染、碰撞检测这些游戏开发的骨架它全占了。这套 C# 飞机小游戏源码用 WinForms 搭界面、GDI+ 画图、定时器驱动循环,没有任何引擎依赖,打开解决方案就能直接跑。它适合三类人:刚完成 C# 入门、想验证自己动手能力的新手;需要结课作业或面试作品的学生;以及想拿项目带学员的讲师。它的核心价值一句话说清——让你亲眼看到 60FPS 循环在 C# 里怎么从 Timer 一路走到碰撞检测。
2. 游戏主循环:从 WinForms 定时器到帧率控制
2.1 三种驱动方式,选型先看场景
在 C# 桌面环境里跑游戏主循环,绝大多数项目绕不开三条路:WinForms 自带的 System.Windows.Forms.Timer、Thread + while 死循环、以及基于 Stopwatch 的手动插帧循环。很多人一上来就搜“游戏循环怎么写”,照着一篇 Thread + while 的文章改,结果卡在跨线程访问控件上,心态直接崩掉。
先给结论:教学场景、源码演示场景下,Timer 是最稳的入门方案。它的 Tick 事件跑在 UI 线程上,可以放心操作界面对象,不触发跨线程异常,代码也短。代价是精度依赖 Windows 消息泵,窗口拖动、弹窗出现时帧率会有波动,但飞机小游戏对这种波动并不敏感。
Thread + while 更适合进阶改造,把游戏循环独立到后台线程,用 Invoke 或 BeginInvoke 把绘制结果推回 UI 线程。这套方案才能自由控制帧率上限和逻辑更新频率,也是很多开源 C# 小游戏的最终形态。Stopwatch 手动插帧通常作为辅助工具出现——它擅长精确计算 DeltaTime,而不是直接驱动循环。
我一般建议按“Timer 跑通全局 → 看懂循环结构 → 再改造成线程版”的路径走。一上来就把循环结构写成线程版,新手大概率在异常处理和双缓冲上两头踩坑,不容易收口。选型这件事没有绝对的对错,关键是你当前阶段能不能闭环把项目跑到能玩的状态。
2.2 核心循环代码与 Interval 参数的换算
主循环写在 Timer 的 Tick 事件里,思路很直接:每帧先更新逻辑,再触发重绘。逻辑更新包括玩家移动、子弹发射、敌机生成、碰撞检测,这些都在内存里操作数据;重绘交给 OnPaint,保证画面和逻辑不同步造成撕裂。
public partial class GameForm : Form { private Timer gameLoopTimer; private int frameCount = 0; private Stopwatch fpsStopwatch = Stopwatch.StartNew(); public GameForm() { InitializeComponent(); // Interval=16 是目标帧周期的近似值,60FPS 对应的理想值是 16.67ms gameLoopTimer = new Timer(); gameLoopTimer.Interval = 16; gameLoopTimer.Tick += GameLoop_Tick; gameLoopTimer.Start(); } private void GameLoop_Tick(object sender, EventArgs e) { // 先更新所有游戏对象,再请求重绘;顺序颠倒会引发绘制错位 UpdateLogic(); Invalidate(); // 帧率统计:每秒刷新一次标题栏,验证实际帧率与 Interval 是否一致 frameCount++; if (fpsStopwatch.ElapsedMilliseconds >= 1000) { this.Text = $"C# 飞机大战 - FPS: {frameCount}"; frameCount = 0; fpsStopwatch.Restart(); } } }几个参数要单独说清楚。Interval 单位是毫秒,16ms 是理想值,因为 Timer 的实际触发间隔还取决于消息泵调度,真实帧率大概在 55 到 60 之间跳动,标题栏的 FPS 就是干这个用的。UpdateLogic() 内部如果做了较多集合遍历和碰撞检测,单帧耗时增加,FPS 会掉到 45 左右,这时优先优化的不是 Interval,而是 UpdateLogic 的复杂度——把 Interval 调小并不会让变慢的逻辑变快。
Tick 事件里不应该直接写绘制代码,更不该调用 CreateGraphics 画图。主循环里只调用 Invalidate() 告诉系统“该重绘了”,真正的绘制写在 OnPaint 里。这样系统在处理窗口遮挡、尺寸变化时会自动重绘,不会出现“画面被挡住再露出来就花了”的问题。这个习惯也直接影响后续的双缓冲实现是否能生效。
2.3 用 FPS 计数器验证循环是否达标
帧率统计那段代码不只是显示一个数字,它是检验循环行为的第一道工具。判断一套循环逻辑有没有问题,最直观的做法是把 FPS 显示在窗体标题栏,然后拖动窗体、弹出提示框,观察数字变化幅度。如果从 60 掉到 30 以下,说明 UpdateLogic 里有阻塞点,最常见的两种:一是在逻辑更新里做了磁盘或资源加载;二是某个对象集合在遍历过程中被修改,导致异常反复创建和捕获。
网上的 C# 教程很多,但能让循环跑出真实 FPS 数字的很少。这里顺带说一个新手容易混淆的点:Timer 每帧之间的时间不是严格等距的。Windows 消息泵的优先级低于硬件中断,系统忙时,两次 Tick 的间隔可能从 16ms 波动到 40ms。所以严谨的做法不是依赖 Timer 的周期,而是在 UpdateLogic 里传入本次与上次的时间差,用 DeltaTime 计算移动距离。这套源码作为教学版没有做这一步,但它保留了 fpsStopwatch——后续你改造成 DeltaTime 驱动时,基准就是它。
注意:Interval 的最小有效值与系统定时器分辨率有关,Windows 默认定时器分辨率约 15.6ms,所以 Interval 设 10 和设 16 实际触发间隔可能相差不大。要严格锁帧 60,需要调用 timeBeginPeriod(1) 提高系统分辨率,或者直接上 Stopwatch 手写循环。
3. 对象管理:用 List 和对象池撑起整个战场
3.1 GameObject 基类,把共性收敛到一处
飞机、子弹、敌机、爆炸特效,看起来是四种完全不同的东西,但在游戏逻辑眼里都是“每一帧需要更新、需要绘制、有位置和范围”的对象。把这部分共性抽成基类,是这套源码里最值得抄的设计。抽完之后,主循环的 UpdateLogic() 只需要遍历基类集合,不需要对每种对象各写一套循环。
public abstract class GameObject { public Rectangle Bounds; // 位置和尺寸,碰撞检测直接读它 public bool IsActive = true; // 生命周期标记,主动失效比 remove 更可控 public int MoveSpeed; // 移动速度,单位是像素/帧 public abstract void Update(); public abstract void Draw(Graphics g); }先解释这三个成员的决定。Bounds 用一个 Rectangle 同时表达坐标和碰撞体积,比单独维护 X、Y、Width、Height 四个字段方便得多,碰撞检测时直接把 Bounds 丢给 IntersectsWith 就行。IsActive 是懒删除的关键——对象逻辑上死亡时,先置为 false,等遍历结束统一清理,避免在循环中间修改集合。MoveSpeed 用“像素/帧”而不是“像素/秒”,原因前面提过,Timer 教学版不计算 DeltaTime,直接用每帧固定步长最直观。
玩家、敌机、子弹继承之后各自实现 Update 和 Draw。Update 里做位置移动和状态判断,Draw 里按当前状态绘制贴图或图形。这里有一个隐藏约束:Update 和 Draw 之间不能有其他逻辑介入,否则两个方法拿到的对象状态可能不一致,表现出来就是“飞机被击中后还往前飞了半帧”。
3.2 对象池与复用:把 GC 压力从战场上移走
游戏循环每帧都在产生对象,特别是子弹,发射间隔一短,一秒可能新增几十个实例。如果每发子弹都 new,GC 会在某个瞬间一次性清理几百个废弃对象,表现就是游戏突然卡顿半秒。对象池的做法是预先分配一批对象,使用时不 new,而是从池里取一个“已失效且可复用”的对象。
public class BulletPool { private List<Bullet> pool = new List<Bullet>(100); public Bullet Acquire() { foreach (Bullet b in pool) { if (!b.IsActive) { b.Reset(); // 重置位置、速度、伤害后复用 return b; } } // 池不够时扩容,一次补 20 个,避免频繁扩容 for (int i = 0; i < 20; i++) { pool.Add(new Bullet()); } return pool[pool.Count - 1]; } }这段代码的关键是“失效对象优先复用”和“固定步长扩容”。Acquire 先从现有池里找 IsActive 为 false 的对象,找到就 Reset 复用;找不到再扩容 20 个,然后返回最后一个。这样池容量会逐渐逼近峰值需求,又不会一次性分配过大。Reset 方法必须把位置、速度、方向、伤害全部重置,漏掉任何一项,复用的子弹就会带着旧状态出现,这类 bug 非常隐蔽。
对象池不是所有对象都值得用。飞机小游戏里敌机数量少、生成频率低,直接 new 影响不大;真正值得池化的是子弹这个高频对象。这套源码整体流畅,很大程度归功于子弹做了池,而不是在敌机上做复杂的池管理。四十行的对象池代码,省掉的是肉眼可见的卡顿。
3.3 反向遍历删除:循环里改集合的后悔药
管理动态对象绕不开删除,而“在遍历集合过程中删除元素”是所有新手都会踩的坑。正序遍历调用 RemoveAt(index),被删元素后面的所有元素会前移一位,循环索引继续递增就会跳过下一个本应检查的对象。表现是敌机明明中弹了,却经常出现“隔了一帧才消失”或者“消失的是后面一架飞机”的诡异现象。
// 反向遍历:从尾部往头部删,元素前移不影响已经检查过的部分 for (int i = enemies.Count - 1; i >= 0; i--) { if (!enemies[i].IsActive) { enemies.RemoveAt(i); } }这个写法不需要额外标记和临时列表,逻辑也容易读。另一种工程化的方案是先用 List 收集待删除对象,循环结束后统一 RemoveAll。两种都行,我更推荐反向遍历:直观,而且不依赖 RemoveAll 的 Lambda 判断是否触发额外副作用。注意,对象池复用时,对象只是从活动列表里移除,并没有从池里移除——RemoveAt 抹掉的是“活动列表”上的引用,池里的实例还在等下一次 Reset。
4. GDI+ 双缓冲与碰撞检测:画面不闪、子弹不穿
4.1 双缓冲:三面旗子一次开齐
飞机小游戏里所有元素都在高频移动,如果不做缓冲处理,每帧重绘时都会被看到“先擦后画”的过程,画面闪烁到没法玩。GDI+ 闪烁的根源是背景擦除:系统默认先发送 WM_ERASEBKGND 擦掉旧背景,再触发绘制事件,这个时间差里窗口暴露的是空背景。
双缓冲的原理是在内存里画好整帧,再一次 BitBlt 到屏幕,用户看到的始终是完整画面。WinForms 里省事的做法是设置窗体的 DoubleBuffered 属性,但这套源码用的是另一种更彻底的方式——在构造函数里用 SetStyle 手动打开三个标志。
public GameForm() { InitializeComponent(); // 三个标志缺一个都可能出现不同程度的闪烁 this.SetStyle( ControlStyles.AllPaintingInWmPaint | // 阻止系统单独清空背景 ControlStyles.OptimizedDoubleBuffer | // 启用二级缓冲,真正的双缓冲 ControlStyles.UserPaint, // 所有绘制交由 OnPaint 处理 true); this.UpdateStyles(); }三个标志各管一块。AllPaintingInWmPaint 告诉系统:擦背景的动作并入绘制流程,不要单独发 WM_ERASEBKGND 消息;OptimizedDoubleBuffer 启用 WinForms 内置的第二缓冲区,所有绘制先在缓冲上完成;UserPaint 声明窗体完全自己绘制,允许前两个标志生效。设置完记得调用 UpdateStyles(),否则标志不会立刻应用。
这里有个容易被忽略的点:双缓冲生效的前提是“所有绘制都在 OnPaint 里完成”。如果在 Tick 事件里直接 CreateGraphics 画了某个元素,那个元素绕过了缓冲直接画在窗口表面,下一帧缓冲整体刷新时它就会“闪没”再“闪回”。检查方法是:在窗体 OnPaint 里画完所有对象,构造函数里一行绘制代码都不要写。
4.2 碰撞检测:矩形相交优先,别碰 GetPixel
碰撞检测有两种极端做法。像素级检测是拿透明位图逐像素判断两图像 Alpha 通道是否有重叠区域,精确但代价太高——每帧对整帧画面做像素遍历,在 C# 里用 GetPixel 读位图颜色,几十毫秒就没了,游戏直接掉到十几帧。矩形碰撞检测用对象的 Bounds 做相交判断,几行代码搞定,肉眼效果几乎一样,因为大多数飞机素材都是方正的。
private void CheckCollisions() { foreach (Bullet bullet in bullets) { if (!bullet.IsActive) continue; foreach (Enemy enemy in enemies) { if (!enemy.IsActive) continue; // 矩形相交即视为命中 if (bullet.Bounds.IntersectsWith(enemy.Bounds)) { bullet.IsActive = false; enemy.HP -= bullet.Damage; if (enemy.HP <= 0) { enemy.IsActive = false; score += enemy.ScoreValue; } break; // 一颗子弹只判定一次命中,命中后跳出内层循环 } } } }这个双层循环看起来是 O(n*m),但飞机小游戏的对象数量级很小:子弹池里活跃子弹通常几十个,敌机最多十几个,每帧几十次矩形比较,对 CPU 来说可以忽略。如果以后把对象数量放大到几百,再考虑空间划分,比如屏幕按网格分区,只检测同区域对象。现在的规模,这个简单版本反而最稳。
GetPixel 不是完全没用,它的合理位置是“碰撞发生后做二次精细判定”——先用矩形相交粗筛,再对相交区域里两图像的重叠像素做精确判定。飞机小游戏用不上这层复杂度,矩形判定足够,肉眼根本分不清差的那几个像素。
4.3 高速子弹为什么穿透:步长与连续检测
子弹速度调到 20px/帧以上,会出现一个典型问题:子弹从一个位置飞到下一个位置,前后两个瞬间的矩形都没碰上敌机矩形,但实际它已经“穿过去”了。这是因为矩形检测只检查离散时刻的状态,不检查这段时间内路径上发生了什么。敌机宽度只有 40px,子弹一帧飞 30px 时,很可能直接跳到敌机身后,前后两帧的相交检测全部落空。
// 连续碰撞检测:把一步拆成多个小步,逐段做矩形相交 private bool IsHitOnPath(Rectangle bulletBounds, int stepX, int stepY, Rectangle target) { int steps = Math.Max(Math.Abs(stepX), Math.Abs(stepY)); steps = Math.Max(steps, 1); for (int i = 0; i < steps; i++) { bulletBounds.X += Math.Sign(stepX); bulletBounds.Y += Math.Sign(stepY); if (bulletBounds.IntersectsWith(target)) { return true; } } return false; }思路是把一步移动拆成若干小步,每小步最多 1 像素,逐段检查矩形是否相交。steps 取水平、垂直两个方向位移的较大值,保证两方向步数一致,路径不失真。Math.Sign 返回 -1、0、1,保证每次只移动一格。代价是碰撞检测次数变多,但对象少,完全扛得住。
现实游戏里,高速子弹通常会配合“降低伤害、增加射速”来平衡体验。作为源码实现,把子弹速度控制在 12px/帧以内,普通矩形检测就不会出问题;要做超高速弹幕,才需要启用这段连续检测逻辑。注释里写清楚这个阈值,后面接手的人不会踩同一个坑。
5. 避坑指南:这套源码最容易翻车的五个现场
5.1 输入响应:按住方向键飞机不走直线
现象:第一次按键飞机立刻移动,持续按住后移动变得一顿一顿,像按键失效了,松开再按又恢复。
原因是 Windows 系统对键盘事件有“按下-延迟-重复”机制。WinForms 的 KeyDown 事件不会持续触发,而是受系统键盘重复延迟配置影响,默认首次按下后要等约 500ms 才开始重复触发。游戏循环里的移动逻辑如果依赖 KeyDown 累积方向,就必然卡在这个系统延迟里。
解决方式是改用一个 HashSet 记录所有当前按下的键,KeyDown 和 KeyUp 只负责添加、移除按键,实际移动在 UpdateLogic 里根据集合内容计算方向。这样移动完全由游戏循环驱动,不再依赖系统按键重复节奏。
private HashSet<Keys> pressedKeys = new HashSet<Keys>(); protected override void OnKeyDown(KeyEventArgs e) { pressedKeys.Add(e.KeyCode); base.OnKeyDown(e); } protected override void OnKeyUp(KeyEventArgs e) { pressedKeys.Remove(e.KeyCode); base.OnKeyUp(e); } // UpdateLogic 中调用: // if (pressedKeys.Contains(Keys.Left)) dx -= player.MoveSpeed; // if (pressedKeys.Contains(Keys.Right)) dx += player.MoveSpeed;5.2 边界生成:敌机从屏幕边缘“挤”进来
现象:敌机从左侧生成时像窗帘一样慢慢拉出来,经过很长一段距离才露出完整机身,看起来是从空气里长出来的。
原因是生成代码把敌机的 X 坐标限制在 0 到 ClientSize.Width 之间,但敌机对象的位置通常指矩形左上角。X 为 0 时,敌机机身可能有几十像素被挤在屏幕外,于是视觉上出现“挤进来”的过程。
解决方式是给生成逻辑做边界偏移:X 范围应该是 -enemy.Width 到 ClientSize.Width - enemy.Width。敌机从负坐标开始进入屏幕,才能做到机身完整滑入的效果。
// 生成坐标的偏移量:把机身宽度和高度纳入边界计算 int spawnX = random.Next(-enemy.Width, this.ClientSize.Width - enemy.Width); int spawnY = -enemy.Height; // 从屏幕上方进入5.3 窗体缩放后的黑边与错位
现象:拖动窗口大小后,游戏区域边缘出现黑色或白色条带,部分绘制内容停留在旧区域。
原因是窗体尺寸变化后,双缓冲缓冲区仍沿用旧尺寸,绘制区域与窗口新客户区不匹配。更隐蔽的是,如果背景是星空图案,背景图尺寸没有跟着更新,就会出现一条粗细不均匀的色带。
解决方式是在 OnResize 里更新缓冲区,并在 OnPaint 里根据 ClientSize 重新绘制背景。如果使用 ControlStyles.OptimizedDoubleBuffer,WinForms 会自动重新分配缓冲;但如果是自定义 BufferedGraphics,就必须手动重新 Allocate。标准做法是让 Resize 事件强制下一帧完整重绘,不要依赖旧的背景位图。
protected override void OnResize(EventArgs e) { base.OnResize(e); // 强制下一帧完整重绘,避免残留旧区域 Invalidate(); }5.4 暂停恢复后,飞机和子弹“跳”
现象:暂停 5 秒后点继续,游戏恢复的同一帧,所有对象像瞬移一样跳到另一个位置,之后又恢复正常。
原因是如果主循环改造成了 Thread + Stopwatch 手写循环,暂停期间 Stopwatch 仍在累计时间,恢复时的 DeltaTime 可能是几百毫秒。移动代码用的是“位移 = 速度 × DeltaTime”,巨大 DeltaTime 把速度放大了几十倍,于是瞬移。
解决方式是在暂停入口记录时间戳,恢复时把 DeltaTime 基准重置到当前时刻。Timer 版本没有 DeltaTime,所以不触发这个坑;但一旦按第二章的思路改造成手写循环,就一定会遇到。这个坑提前写清楚,改造时就能少翻一次车。
// 暂停时记下基准时刻 private void PauseGame() { isPaused = true; lastTick = Environment.TickCount; } // 恢复时重置基准,避免 DeltaTime 突变 private void ResumeGame() { isPaused = false; lastTick = Environment.TickCount; } // 帧循环内计算 DeltaTime int currentTick = Environment.TickCount; float deltaTime = (currentTick - lastTick) / 1000f; lastTick = currentTick;5.5 随机数种子:每次开始的弹幕像录像回放
现象:每次点击重新开始,前几波敌机和子弹出现的位置规律几乎一模一样,像在重复播放同一段录像。
原因是 Random 默认以时间作为种子,但如果代码里在游戏循环中快速连续创建了多个 Random 实例,这些实例的种子在极短时间内相同,生成的随机序列完全一致。常见写法是在敌机生成方法里直接new Random(),一帧内多次调用,拿到的是同一个“随机流”。
解决方式是整个项目只保留一个静态 Random 实例,所有随机需求都从它取数。这种看似玄学的问题,根源其实是构造函数调用时机,而不是随机算法本身。
// 全局只保留一个 Random 实例,避免连续 new 产生相同种子 private static readonly Random rng = new Random(); // 用全局实例替代局部 new Random() int spawnX = rng.Next(-enemy.Width, this.ClientSize.Width - enemy.Width);6. 把参数抽成配置类:给源码留一条换皮捷径
这套源码真正值得你带进下一个项目的设计,是把散落在事件里的魔数集中到配置类。射击间隔、玩家速度、敌机生成频率、子弹速度、敌机血量,全部用常量收编到一个类里。以后想从“普通模式”改成“困难模式”,只改配置,不动逻辑。
public static class GameConfig { // 玩家相关 public const int PlayerMoveSpeed = 8; // 像素/帧,太快会穿透碰撞 public const int PlayerFireIntervalMs = 180; // 毫秒,射速与弹幕密度的平衡点 public const int PlayerMaxHp = 3; // 子弹相关 public const int BulletSpeed = 12; // 超过 20 建议启用连续碰撞检测 public const int BulletPoolSize = 100; // 池的初始容量 // 敌机相关 public const int EnemySpawnIntervalMs = 1200; public const int EnemyMinSpeed = 2; public const int EnemyMaxSpeed = 6; public const int EnemyMaxCount = 12; // 同屏上限,防止生成过多拖慢帧率 // 难度系数:每击杀 10 个敌人提升一次生成速率 public const int DifficultyStep = 10; public const float SpawnIntervalDecreaseRate = 0.85f; }参数的含义直接写在注释里,后面的人改起来心里有数。比如 EnemyMinSpeed 和 EnemyMaxSpeed 会决定敌人的压迫感,建议差值保持 4 到 6,太小整体节奏呆板,太大新手反应不过来。PlayerFireIntervalMs 是手感的关键,180ms 是折中值,调到 120 会变成全屏弹幕,调到 300 会觉得武器乏力。改之前先看注释里的平衡点说明,比直接试值高效得多。
验证配置改动是否合理,我习惯把难度参数做成三档预设:简单、普通、困难,各自对应一组配置常量。玩法逻辑不变,只切换配置类,就能给一套源码扩出三种体验。这比在代码里堆 if else 判断难度干净得多。
从那以后,我拿到任何一套游戏或图形界面的源码,第一件事都是把魔数列出来,问自己这个数能不能在十秒内找到、改了会不会破坏别处逻辑。这个习惯救过我很多次,后来做偏界面的项目时,轮询间隔、刷新频率同样靠集中配置兜底,希望帮到你。
本文还有配套的精品资源,点击获取